citrascope 0.7.0__py3-none-any.whl → 0.8.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. citrascope/api/abstract_api_client.py +14 -0
  2. citrascope/api/citra_api_client.py +41 -0
  3. citrascope/citra_scope_daemon.py +75 -0
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +80 -2
  5. citrascope/hardware/adapter_registry.py +10 -3
  6. citrascope/hardware/devices/__init__.py +17 -0
  7. citrascope/hardware/devices/abstract_hardware_device.py +79 -0
  8. citrascope/hardware/devices/camera/__init__.py +13 -0
  9. citrascope/hardware/devices/camera/abstract_camera.py +102 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +402 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +744 -0
  13. citrascope/hardware/devices/device_registry.py +273 -0
  14. citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
  15. citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
  16. citrascope/hardware/devices/focuser/__init__.py +7 -0
  17. citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
  18. citrascope/hardware/devices/mount/__init__.py +7 -0
  19. citrascope/hardware/devices/mount/abstract_mount.py +115 -0
  20. citrascope/hardware/direct_hardware_adapter.py +787 -0
  21. citrascope/hardware/filter_sync.py +94 -0
  22. citrascope/hardware/indi_adapter.py +6 -2
  23. citrascope/hardware/kstars_dbus_adapter.py +46 -37
  24. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  25. citrascope/settings/citrascope_settings.py +6 -0
  26. citrascope/tasks/runner.py +2 -0
  27. citrascope/tasks/scope/static_telescope_task.py +17 -12
  28. citrascope/tasks/task.py +3 -0
  29. citrascope/time/__init__.py +13 -0
  30. citrascope/time/time_health.py +96 -0
  31. citrascope/time/time_monitor.py +164 -0
  32. citrascope/time/time_sources.py +62 -0
  33. citrascope/web/app.py +229 -51
  34. citrascope/web/static/app.js +296 -36
  35. citrascope/web/static/config.js +216 -81
  36. citrascope/web/static/filters.js +55 -0
  37. citrascope/web/static/style.css +39 -0
  38. citrascope/web/templates/dashboard.html +114 -9
  39. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  40. citrascope-0.8.0.dist-info/RECORD +62 -0
  41. citrascope-0.7.0.dist-info/RECORD +0 -41
  42. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  43. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  44. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,7 @@
1
1
  // Configuration management for CitraScope
2
2
 
3
3
  import { getConfig, saveConfig, getConfigStatus, getHardwareAdapters, getAdapterSchema } from './api.js';
4
+ import { getFilterColor } from './filters.js';
4
5
 
5
6
  // API Host constants - must match backend constants in app.py
6
7
  const PROD_API_HOST = 'api.citra.space';
@@ -18,10 +19,14 @@ export async function initConfig() {
18
19
  // Hardware adapter selection change
19
20
  const adapterSelect = document.getElementById('hardwareAdapterSelect');
20
21
  if (adapterSelect) {
21
- adapterSelect.addEventListener('change', async function(e) {
22
+ adapterSelect.addEventListener('change', async (e) => {
22
23
  const adapter = e.target.value;
23
24
  if (adapter) {
24
- await loadAdapterSchema(adapter);
25
+ // Extract the NEW adapter's saved settings from the nested structure
26
+ const allAdapterSettings = currentConfig.adapter_settings || {};
27
+ const newAdapterSettings = allAdapterSettings[adapter] || {};
28
+ await loadAdapterSchema(adapter, newAdapterSettings);
29
+ populateAdapterSettings(newAdapterSettings);
25
30
  await loadFilterConfig();
26
31
  } else {
27
32
  document.getElementById('adapter-settings-container').innerHTML = '';
@@ -34,7 +39,7 @@ export async function initConfig() {
34
39
  // API endpoint selection change
35
40
  const apiEndpointSelect = document.getElementById('apiEndpoint');
36
41
  if (apiEndpointSelect) {
37
- apiEndpointSelect.addEventListener('change', function(e) {
42
+ apiEndpointSelect.addEventListener('change', (e) => {
38
43
  const customContainer = document.getElementById('customHostContainer');
39
44
  if (e.target.value === 'custom') {
40
45
  customContainer.style.display = 'block';
@@ -173,10 +178,21 @@ async function loadConfiguration() {
173
178
  autofocusInterval.value = config.autofocus_interval_minutes || 60;
174
179
  }
175
180
 
181
+ // Load time sync settings (monitoring always enabled)
182
+ const timeOffsetPause = document.getElementById('time_offset_pause_ms');
183
+
184
+ if (timeOffsetPause) {
185
+ timeOffsetPause.value = config.time_offset_pause_ms || 500;
186
+ }
187
+
176
188
  // Load adapter-specific settings if adapter is selected
177
189
  if (config.hardware_adapter) {
178
- await loadAdapterSchema(config.hardware_adapter);
179
- populateAdapterSettings(config.adapter_settings || {});
190
+ // adapter_settings is nested: {"nina": {...}, "kstars": {...}, "direct": {...}}
191
+ // Extract the current adapter's settings
192
+ const allAdapterSettings = config.adapter_settings || {};
193
+ const currentAdapterSettings = allAdapterSettings[config.hardware_adapter] || {};
194
+ await loadAdapterSchema(config.hardware_adapter, currentAdapterSettings);
195
+ populateAdapterSettings(currentAdapterSettings);
180
196
  }
181
197
  } catch (error) {
182
198
  console.error('Failed to load config:', error);
@@ -186,9 +202,16 @@ async function loadConfiguration() {
186
202
  /**
187
203
  * Load adapter schema and render settings form
188
204
  */
189
- async function loadAdapterSchema(adapterName) {
205
+ async function loadAdapterSchema(adapterName, currentSettings = {}) {
190
206
  try {
191
- const data = await getAdapterSchema(adapterName);
207
+ // Pass current adapter settings for dynamic schema generation
208
+ const settingsParam = Object.keys(currentSettings).length > 0
209
+ ? `?current_settings=${encodeURIComponent(JSON.stringify(currentSettings))}`
210
+ : '';
211
+
212
+ const response = await fetch(`/api/hardware-adapters/${adapterName}/schema${settingsParam}`);
213
+ const data = await response.json();
214
+
192
215
  currentAdapterSchema = data.schema || [];
193
216
  renderAdapterSettings(currentAdapterSchema);
194
217
  } catch (error) {
@@ -208,57 +231,115 @@ function renderAdapterSettings(schema) {
208
231
  return;
209
232
  }
210
233
 
211
- let html = '<h5 class="mb-3">Adapter Settings</h5><div class="row g-3 mb-4">';
234
+ // Group fields by their 'group' property
235
+ const grouped = schema.reduce((acc, field) => {
236
+ if (field.readonly) return acc; // Skip readonly fields
237
+ const group = field.group || 'General';
238
+ if (!acc[group]) acc[group] = [];
239
+ acc[group].push(field);
240
+ return acc;
241
+ }, {});
242
+
243
+ let html = '<h5 class="mb-3">Adapter Settings</h5>';
244
+
245
+ // Render each group as a card
246
+ Object.entries(grouped).forEach(([groupName, fields]) => {
247
+ html += `<div class="card bg-dark border-secondary mb-3">`;
248
+ html += `<div class="card-header">`;
249
+ html += `<h6 class="mb-0"><i class="bi bi-${getGroupIcon(groupName)} me-2"></i>${groupName}</h6>`;
250
+ html += `</div>`;
251
+ html += `<div class="card-body">`;
252
+ html += `<div class="row g-3">`;
253
+
254
+ fields.forEach(field => {
255
+ const isRequired = field.required ? '<span class="text-danger">*</span>' : '';
256
+ const placeholder = field.placeholder || '';
257
+ const description = field.description || '';
258
+ const displayName = field.friendly_name || field.name;
212
259
 
213
- schema.forEach(field => {
214
- // Skip readonly fields (handled elsewhere in UI)
215
- if (field.readonly) {
216
- return;
217
- }
260
+ html += '<div class="col-12 col-md-4">';
261
+ html += `<label for="adapter_${field.name}" class="form-label">${displayName} ${isRequired}</label>`;
262
+
263
+ if (field.type === 'bool') {
264
+ html += `<div class="form-check mt-2">`;
265
+ html += `<input class="form-check-input adapter-setting" type="checkbox" id="adapter_${field.name}" data-field="${field.name}" data-type="${field.type}">`;
266
+ html += `<label class="form-check-label" for="adapter_${field.name}">${description}</label>`;
267
+ html += `</div>`;
268
+ } else if (field.options && field.options.length > 0) {
269
+ html += `<select id="adapter_${field.name}" class="form-select adapter-setting" data-field="${field.name}" data-type="${field.type}" ${field.required ? 'required' : ''}>`;
270
+ html += `<option value="">-- Select ${displayName} --</option>`;
271
+ field.options.forEach(opt => {
272
+ // Handle both object format {value, label} and plain string options
273
+ const optValue = typeof opt === 'object' ? opt.value : opt;
274
+ const optLabel = typeof opt === 'object' ? opt.label : opt;
275
+ html += `<option value="${optValue}">${optLabel}</option>`;
276
+ });
277
+ html += `</select>`;
278
+ } else if (field.type === 'int' || field.type === 'float') {
279
+ const min = field.min !== undefined ? `min="${field.min}"` : '';
280
+ const max = field.max !== undefined ? `max="${field.max}"` : '';
281
+ const step = field.type === 'float' ? 'step="any"' : '';
282
+ html += `<input type="number" id="adapter_${field.name}" class="form-control adapter-setting" `;
283
+ html += `data-field="${field.name}" data-type="${field.type}" `;
284
+ html += `placeholder="${placeholder}" ${min} ${max} ${step} ${field.required ? 'required' : ''}>`;
285
+ } else {
286
+ // Default to text input
287
+ const pattern = field.pattern ? `pattern="${field.pattern}"` : '';
288
+ html += `<input type="text" id="adapter_${field.name}" class="form-control adapter-setting" `;
289
+ html += `data-field="${field.name}" data-type="${field.type}" `;
290
+ html += `placeholder="${placeholder}" ${pattern} ${field.required ? 'required' : ''}>`;
291
+ }
218
292
 
219
- const isRequired = field.required ? '<span class="text-danger">*</span>' : '';
220
- const placeholder = field.placeholder || '';
221
- const description = field.description || '';
222
- const displayName = field.friendly_name || field.name;
293
+ if (description && field.type !== 'bool') {
294
+ html += `<small class="text-muted">${description}</small>`;
295
+ }
296
+ html += '</div>';
297
+ });
223
298
 
224
- html += '<div class="col-12 col-md-6">';
225
- html += `<label for="adapter_${field.name}" class="form-label">${displayName} ${isRequired}</label>`;
299
+ html += `</div></div></div>`; // Close row, card-body, card
300
+ });
226
301
 
227
- if (field.type === 'bool') {
228
- html += `<div class="form-check mt-2">`;
229
- html += `<input class="form-check-input adapter-setting" type="checkbox" id="adapter_${field.name}" data-field="${field.name}" data-type="${field.type}">`;
230
- html += `<label class="form-check-label" for="adapter_${field.name}">${description}</label>`;
231
- html += `</div>`;
232
- } else if (field.options && field.options.length > 0) {
233
- const displayName = field.friendly_name || field.name;
234
- html += `<select id="adapter_${field.name}" class="form-select adapter-setting" data-field="${field.name}" data-type="${field.type}" ${field.required ? 'required' : ''}>`;
235
- html += `<option value="">-- Select ${displayName} --</option>`;
236
- field.options.forEach(opt => {
237
- html += `<option value="${opt}">${opt}</option>`;
238
- });
239
- html += `</select>`;
240
- } else if (field.type === 'int' || field.type === 'float') {
241
- const min = field.min !== undefined ? `min="${field.min}"` : '';
242
- const max = field.max !== undefined ? `max="${field.max}"` : '';
243
- html += `<input type="number" id="adapter_${field.name}" class="form-control adapter-setting" `;
244
- html += `data-field="${field.name}" data-type="${field.type}" `;
245
- html += `placeholder="${placeholder}" ${min} ${max} ${field.required ? 'required' : ''}>`;
246
- } else {
247
- // Default to text input
248
- const pattern = field.pattern ? `pattern="${field.pattern}"` : '';
249
- html += `<input type="text" id="adapter_${field.name}" class="form-control adapter-setting" `;
250
- html += `data-field="${field.name}" data-type="${field.type}" `;
251
- html += `placeholder="${placeholder}" ${pattern} ${field.required ? 'required' : ''}>`;
252
- }
302
+ container.innerHTML = html;
253
303
 
254
- if (description && field.type !== 'bool') {
255
- html += `<small class="text-muted">${description}</small>`;
304
+ // Add change listeners to device type fields to reload schema dynamically
305
+ const deviceTypeFields = ['camera_type', 'mount_type', 'filter_wheel_type', 'focuser_type'];
306
+ deviceTypeFields.forEach(fieldName => {
307
+ const select = document.getElementById(`adapter_${fieldName}`);
308
+ if (select) {
309
+ select.addEventListener('change', async () => {
310
+ // Get current adapter name
311
+ const adapterSelect = document.getElementById('hardwareAdapterSelect');
312
+ if (!adapterSelect || !adapterSelect.value) return;
313
+
314
+ // Collect current settings
315
+ const currentSettings = collectAdapterSettings();
316
+
317
+ // Reload schema with new device type selection
318
+ await loadAdapterSchema(adapterSelect.value, currentSettings);
319
+
320
+ // Repopulate settings (preserves user's selections)
321
+ populateAdapterSettings(currentSettings);
322
+ });
256
323
  }
257
- html += '</div>';
258
324
  });
325
+ }
259
326
 
260
- html += '</div>';
261
- container.innerHTML = html;
327
+ /**
328
+ * Get Bootstrap icon name for a group
329
+ */
330
+ function getGroupIcon(groupName) {
331
+ const icons = {
332
+ 'Camera': 'camera',
333
+ 'Mount': 'compass',
334
+ 'Filter Wheel': 'circle',
335
+ 'Focuser': 'eyeglasses',
336
+ 'Connection': 'ethernet',
337
+ 'Devices': 'hdd-network',
338
+ 'Imaging': 'image',
339
+ 'General': 'gear',
340
+ 'Advanced': 'sliders'
341
+ };
342
+ return icons[groupName] || 'gear';
262
343
  }
263
344
 
264
345
  /**
@@ -361,6 +442,8 @@ async function saveConfiguration(event) {
361
442
  // Autofocus settings (top-level)
362
443
  scheduled_autofocus_enabled: document.getElementById('scheduled_autofocus_enabled')?.checked || false,
363
444
  autofocus_interval_minutes: parseInt(document.getElementById('autofocus_interval_minutes')?.value || 60, 10),
445
+ // Time sync settings (monitoring always enabled)
446
+ time_offset_pause_ms: parseFloat(document.getElementById('time_offset_pause_ms')?.value || 500),
364
447
  // API settings from endpoint selector
365
448
  host: host,
366
449
  port: port,
@@ -428,7 +511,7 @@ async function saveConfiguration(event) {
428
511
  * @param {string} type - 'danger' for errors, 'success' for success messages
429
512
  * @param {boolean} autohide - Whether to auto-hide the toast
430
513
  */
431
- function createToast(message, type = 'danger', autohide = false) {
514
+ export function createToast(message, type = 'danger', autohide = false) {
432
515
  const toastContainer = document.getElementById('toastContainer');
433
516
  if (!toastContainer) {
434
517
  console.error('Toast container not found');
@@ -556,6 +639,9 @@ async function loadFilterConfig() {
556
639
  // Show the filter section
557
640
  if (filterSection) filterSection.style.display = 'block';
558
641
 
642
+ // Update enabled filters display on dashboard
643
+ updateEnabledFiltersDisplay(data.filters);
644
+
559
645
  // Populate filter table
560
646
  const tbody = document.getElementById('filterTableBody');
561
647
  const noFiltersMsg = document.getElementById('noFiltersMessage');
@@ -573,6 +659,7 @@ async function loadFilterConfig() {
573
659
  filterIds.forEach(filterId => {
574
660
  const filter = filters[filterId];
575
661
  const isEnabled = filter.enabled !== undefined ? filter.enabled : true;
662
+ const filterColor = getFilterColor(filter.name);
576
663
  const row = document.createElement('tr');
577
664
  row.innerHTML = `
578
665
  <td>
@@ -581,7 +668,9 @@ async function loadFilterConfig() {
581
668
  data-filter-id="${filterId}"
582
669
  ${isEnabled ? 'checked' : ''}>
583
670
  </td>
584
- <td>${filter.name}</td>
671
+ <td>
672
+ <span class="badge" style="background-color: ${filterColor}; color: white;">${filter.name}</span>
673
+ </td>
585
674
  <td>
586
675
  <input type="number"
587
676
  class="form-control form-control-sm filter-focus-input"
@@ -620,50 +709,74 @@ async function saveModifiedFilters() {
620
709
  return { success: 0, failed: inputs.length };
621
710
  }
622
711
 
623
- let successCount = 0;
624
- let failedCount = 0;
625
-
626
- // Save all filter values
712
+ // Collect all filter updates into array
713
+ const filterUpdates = [];
627
714
  for (const input of inputs) {
628
715
  const filterId = input.dataset.filterId;
629
716
  const focusPosition = parseInt(input.value);
630
717
 
631
- if (isNaN(focusPosition) || focusPosition < 0) {
632
- failedCount++;
633
- continue;
718
+ if (Number.isNaN(focusPosition) || focusPosition < 0) {
719
+ continue; // Skip invalid entries
634
720
  }
635
721
 
636
722
  // Get enabled state from corresponding checkbox
637
723
  const checkbox = document.querySelector(`.filter-enabled-checkbox[data-filter-id="${filterId}"]`);
638
724
  const enabled = checkbox ? checkbox.checked : true;
639
725
 
640
- try {
641
- const response = await fetch(`/api/adapter/filters/${filterId}`, {
642
- method: 'PATCH',
643
- headers: {
644
- 'Content-Type': 'application/json'
645
- },
646
- body: JSON.stringify({ focus_position: focusPosition, enabled: enabled })
647
- });
726
+ filterUpdates.push({
727
+ filter_id: filterId,
728
+ focus_position: focusPosition,
729
+ enabled: enabled
730
+ });
731
+ }
648
732
 
649
- if (response.ok) {
650
- successCount++;
651
- } else {
652
- failedCount++;
653
- const data = await response.json();
654
- console.error(`Failed to save filter ${filterId}: ${data.error || 'Unknown error'}`);
655
- // If it's the "last enabled filter" error, show it to the user
656
- if (response.status === 400 && data.error && data.error.includes('last enabled filter')) {
657
- showConfigMessage(data.error, 'danger');
733
+ if (filterUpdates.length === 0) {
734
+ return { success: 0, failed: 0 };
735
+ }
736
+
737
+ // Send single batch update
738
+ try {
739
+ const response = await fetch('/api/adapter/filters/batch', {
740
+ method: 'POST',
741
+ headers: {
742
+ 'Content-Type': 'application/json'
743
+ },
744
+ body: JSON.stringify(filterUpdates)
745
+ });
746
+
747
+ if (response.ok) {
748
+ const data = await response.json();
749
+ const successCount = data.updated_count || 0;
750
+
751
+ // After batch update, sync to backend
752
+ try {
753
+ const syncResponse = await fetch('/api/adapter/filters/sync', {
754
+ method: 'POST'
755
+ });
756
+ if (!syncResponse.ok) {
757
+ console.error('Failed to sync filters to backend');
658
758
  }
759
+ } catch (error) {
760
+ console.error('Error syncing filters to backend:', error);
659
761
  }
660
- } catch (error) {
661
- failedCount++;
662
- console.error(`Error saving filter ${filterId}:`, error);
762
+
763
+ return { success: successCount, failed: 0 };
764
+ } else {
765
+ const data = await response.json();
766
+ const errorMsg = data.error || 'Unknown error';
767
+ console.error(`Failed to save filters: ${errorMsg}`);
768
+
769
+ // Show error to user
770
+ if (response.status === 400 && errorMsg.includes('last enabled filter')) {
771
+ showConfigMessage(errorMsg, 'danger');
772
+ }
773
+
774
+ return { success: 0, failed: filterUpdates.length };
663
775
  }
776
+ } catch (error) {
777
+ console.error('Error saving filters:', error);
778
+ return { success: 0, failed: filterUpdates.length };
664
779
  }
665
-
666
- return { success: successCount, failed: failedCount };
667
780
  }
668
781
 
669
782
  /**
@@ -792,6 +905,28 @@ export async function initFilterConfig() {
792
905
  await loadFilterConfig();
793
906
  }
794
907
 
908
+ /**
909
+ * Update the enabled filters display on the dashboard.
910
+ * @param {Object} filters - Filter configuration object
911
+ */
912
+ function updateEnabledFiltersDisplay(filters) {
913
+ const filtersEl = document.getElementById('enabledFilters');
914
+ if (!filtersEl) return;
915
+
916
+ const enabledFilters = Object.values(filters)
917
+ .filter(filter => filter.enabled !== false)
918
+ .map(filter => filter.name);
919
+
920
+ if (enabledFilters.length > 0) {
921
+ filtersEl.innerHTML = enabledFilters.map(filterName => {
922
+ const color = getFilterColor(filterName);
923
+ return `<span class="badge me-1" style="background-color: ${color}; color: white;">${filterName}</span>`;
924
+ }).join('');
925
+ } else {
926
+ filtersEl.textContent = '-';
927
+ }
928
+ }
929
+
795
930
  /**
796
931
  * Setup autofocus button event listener (call once during init)
797
932
  */
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Filter color utilities for visual wavelength representation.
3
+ *
4
+ * Maps standard astronomical filters to their representative colors
5
+ * based on spectral wavelength. Colors are darker variants suitable
6
+ * for badge backgrounds with white text.
7
+ */
8
+
9
+ /**
10
+ * Standard filter name to color mappings.
11
+ * Colors chosen to be dark enough for white text readability.
12
+ */
13
+ export const FILTER_COLORS = {
14
+ // Johnson-Cousins UBVRI
15
+ 'U': '#33005D', // Ultraviolet (365nm)
16
+ 'B': '#002A6E', // Blue (445nm)
17
+ 'V': '#005500', // Visual/Green (551nm)
18
+ 'R': '#6E0000', // Red (658nm)
19
+ 'I': '#550000', // Near-infrared (806nm)
20
+
21
+ // Sloan ugriz
22
+ 'u': '#3B0066', // Ultraviolet (354nm)
23
+ 'g': '#00442A', // Green (477nm)
24
+ 'r': '#6E2200', // Orange-Red (623nm)
25
+ 'i': '#5D0000', // Near-infrared (763nm)
26
+ 'z': '#440000', // Infrared (913nm)
27
+
28
+ // RGB filters
29
+ 'Red': '#770000',
30
+ 'Green': '#006600',
31
+ 'Blue': '#003380',
32
+ 'Clear': '#4C4C4C', // Gray for broadband clear
33
+ 'Luminance': '#4C4C4C', // Gray for luminance
34
+
35
+ // Narrowband emission line filters
36
+ 'Ha': '#6E002A', // H-alpha (656.3nm) - characteristic deep red
37
+ 'Hb': '#004C66', // H-beta (486.1nm) - cyan
38
+ 'OIII': '#005D44', // OIII (500.7nm) - teal
39
+ 'SII': '#6E112A', // SII (672.4nm) - pink-red
40
+ };
41
+
42
+ /**
43
+ * Get color for a filter based on name.
44
+ *
45
+ * @param {string} name - Filter name (e.g., "Ha", "Red", "V")
46
+ * @returns {string} Hex color string suitable for dark badge backgrounds
47
+ */
48
+ export function getFilterColor(name) {
49
+ // Check known filters first
50
+ if (FILTER_COLORS[name]) {
51
+ return FILTER_COLORS[name];
52
+ }
53
+ // Default to gray if no match
54
+ return '#777777';
55
+ }
@@ -49,6 +49,45 @@ body {
49
49
  gap: 0.5em;
50
50
  }
51
51
 
52
+ #automatedSchedulingStatus {
53
+ display: inline-block;
54
+ min-width: 70px;
55
+ }
56
+
57
+ #toggleAutomatedSchedulingSwitch {
58
+ margin-top: 6px;
59
+ cursor: pointer;
60
+ }
61
+
62
+ #toggleAutomatedSchedulingSwitch:checked {
63
+ background-color: #198754;
64
+ border-color: #198754;
65
+ }
66
+
67
+ #toggleAutomatedSchedulingSwitch:focus {
68
+ box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25);
69
+ }
70
+
71
+ #processingStatus {
72
+ display: inline-block;
73
+ min-width: 70px;
74
+ }
75
+
76
+ /* Task processing switch alignment */
77
+ #toggleProcessingSwitch {
78
+ margin-top: 6px;
79
+ cursor: pointer;
80
+ }
81
+
82
+ #toggleProcessingSwitch:checked {
83
+ background-color: #198754;
84
+ border-color: #198754;
85
+ }
86
+
87
+ #toggleProcessingSwitch:focus {
88
+ box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.25);
89
+ }
90
+
52
91
  /* Clickable elements */
53
92
  #headerVersion, #updateIndicator {
54
93
  cursor: pointer;