citrascope 0.7.0__py3-none-any.whl → 0.9.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 (51) 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 +97 -2
  5. citrascope/hardware/adapter_registry.py +15 -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 +114 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +407 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +756 -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 +805 -0
  21. citrascope/hardware/dummy_adapter.py +202 -0
  22. citrascope/hardware/filter_sync.py +94 -0
  23. citrascope/hardware/indi_adapter.py +6 -2
  24. citrascope/hardware/kstars_dbus_adapter.py +46 -37
  25. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  26. citrascope/settings/citrascope_settings.py +6 -0
  27. citrascope/tasks/runner.py +2 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +14 -0
  31. citrascope/time/time_health.py +103 -0
  32. citrascope/time/time_monitor.py +186 -0
  33. citrascope/time/time_sources.py +261 -0
  34. citrascope/web/app.py +260 -60
  35. citrascope/web/static/app.js +121 -731
  36. citrascope/web/static/components.js +136 -0
  37. citrascope/web/static/config.js +259 -420
  38. citrascope/web/static/filters.js +55 -0
  39. citrascope/web/static/formatters.js +129 -0
  40. citrascope/web/static/store-init.js +204 -0
  41. citrascope/web/static/style.css +44 -0
  42. citrascope/web/templates/_config.html +175 -0
  43. citrascope/web/templates/_config_hardware.html +208 -0
  44. citrascope/web/templates/_monitoring.html +242 -0
  45. citrascope/web/templates/dashboard.html +109 -377
  46. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/METADATA +18 -1
  47. citrascope-0.9.0.dist-info/RECORD +69 -0
  48. citrascope-0.7.0.dist-info/RECORD +0 -41
  49. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/WHEEL +0 -0
  50. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/entry_points.txt +0 -0
  51. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,86 +1,111 @@
1
- // CitraScope Dashboard - Main Application
1
+ // CitraScope Dashboard - Main Application (Alpine.js)
2
2
  import { connectWebSocket } from './websocket.js';
3
- import { initConfig, currentConfig, initFilterConfig, setupAutofocusButton } from './config.js';
3
+ import { initConfig, initFilterConfig, setupAutofocusButton, createToast } from './config.js';
4
4
  import { getTasks, getLogs } from './api.js';
5
5
 
6
- function updateAppUrlLinks() {
7
- const appUrl = currentConfig.app_url;
8
- [document.getElementById('appUrlLink'), document.getElementById('setupAppUrlLink')].forEach(link => {
9
- if (link && appUrl) {
10
- link.href = appUrl;
11
- link.textContent = appUrl.replace('https://', '');
12
- }
13
- });
6
+ // Store and components are registered in store-init.js (loaded before Alpine)
7
+
8
+ // --- Store update handlers (replace DOM manipulation) ---
9
+ function updateStoreFromStatus(status) {
10
+ const store = Alpine.store('citrascope');
11
+ store.status = status;
12
+
13
+ if (status.current_task && status.current_task !== 'None') {
14
+ store.isTaskActive = true;
15
+ store.currentTaskId = status.current_task;
16
+ store.nextTaskStartTime = null;
17
+ } else {
18
+ store.isTaskActive = false;
19
+ store.currentTaskId = null;
20
+ }
21
+
22
+ // Set nextTaskStartTime from tasks if we have them and no active task
23
+ if (!store.isTaskActive && store.tasks.length > 0) {
24
+ const sorted = [...store.tasks].sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
25
+ store.nextTaskStartTime = sorted[0].start_time;
26
+ }
14
27
  }
15
28
 
16
- // --- Version Checking ---
29
+ function updateStoreFromTasks(tasks) {
30
+ const store = Alpine.store('citrascope');
31
+ const sorted = [...(tasks || [])].sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
32
+ store.tasks = sorted;
17
33
 
18
- /**
19
- * Compare two semantic version strings
20
- * Returns: 1 if v1 > v2, -1 if v1 < v2, 0 if equal
21
- */
22
- function compareVersions(v1, v2) {
23
- // Strip 'v' prefix if present
24
- v1 = v1.replace(/^v/, '');
25
- v2 = v2.replace(/^v/, '');
34
+ if (!store.isTaskActive && sorted.length > 0) {
35
+ store.nextTaskStartTime = sorted[0].start_time;
36
+ } else if (store.isTaskActive) {
37
+ store.nextTaskStartTime = null;
38
+ }
39
+ }
40
+
41
+ function appendLogToStore(log) {
42
+ const store = Alpine.store('citrascope');
43
+ store.logs = [...store.logs, log];
44
+ store.latestLog = log;
45
+ }
46
+
47
+ function updateStoreFromConnection(connected, reconnectInfo = '') {
48
+ const store = Alpine.store('citrascope');
49
+ store.wsConnected = connected;
50
+ store.wsReconnecting = !!reconnectInfo;
51
+ }
26
52
 
53
+ // --- Countdown tick (updates store.countdown) ---
54
+ let countdownInterval = null;
55
+
56
+ function startCountdownUpdater() {
57
+ if (countdownInterval) return;
58
+ countdownInterval = setInterval(() => {
59
+ const store = Alpine.store('citrascope');
60
+ if (!store.nextTaskStartTime || store.isTaskActive) {
61
+ store.countdown = '';
62
+ return;
63
+ }
64
+ const now = new Date();
65
+ const timeUntil = new Date(store.nextTaskStartTime) - now;
66
+ store.countdown = timeUntil > 0 ? store.formatCountdown(timeUntil) : 'Starting soon...';
67
+ }, 1000);
68
+ }
69
+
70
+ // --- Version checking ---
71
+ function compareVersions(v1, v2) {
72
+ v1 = (v1 || '').replace(/^v/, '');
73
+ v2 = (v2 || '').replace(/^v/, '');
27
74
  const parts1 = v1.split('.').map(n => parseInt(n) || 0);
28
75
  const parts2 = v2.split('.').map(n => parseInt(n) || 0);
29
-
30
76
  const maxLen = Math.max(parts1.length, parts2.length);
31
-
32
77
  for (let i = 0; i < maxLen; i++) {
33
78
  const num1 = parts1[i] || 0;
34
79
  const num2 = parts2[i] || 0;
35
-
36
80
  if (num1 > num2) return 1;
37
81
  if (num1 < num2) return -1;
38
82
  }
39
-
40
83
  return 0;
41
84
  }
42
85
 
43
- /**
44
- * Fetch and display current version
45
- */
46
86
  async function fetchVersion() {
47
87
  try {
48
88
  const response = await fetch('/api/version');
49
89
  const data = await response.json();
50
-
51
- // Update header version
52
- const headerVersionEl = document.getElementById('headerVersion');
53
-
54
- if (headerVersionEl && data.version) {
55
- // Show "dev" for development, "v" prefix for releases
56
- if (data.version === 'development') {
57
- headerVersionEl.textContent = 'dev';
58
- } else {
59
- headerVersionEl.textContent = 'v' + data.version;
60
- }
90
+ const store = Alpine.store('citrascope');
91
+ if (data.version) {
92
+ store.version = data.version === 'development' ? 'dev' : 'v' + data.version;
93
+ } else {
94
+ store.version = 'v?';
61
95
  }
62
96
  } catch (error) {
63
97
  console.error('Error fetching version:', error);
64
- const headerVersionEl = document.getElementById('headerVersion');
65
-
66
- if (headerVersionEl) {
67
- headerVersionEl.textContent = 'v?';
68
- }
98
+ Alpine.store('citrascope').version = 'v?';
69
99
  }
70
100
  }
71
101
 
72
- /**
73
- * Check for available updates from GitHub
74
- * Returns the check result for modal display
75
- */
76
102
  async function checkForUpdates() {
103
+ const store = Alpine.store('citrascope');
77
104
  try {
78
- // Get current version
79
105
  const versionResponse = await fetch('/api/version');
80
106
  const versionData = await versionResponse.json();
81
107
  const currentVersion = versionData.version;
82
108
 
83
- // Check GitHub for latest release
84
109
  const githubResponse = await fetch('https://api.github.com/repos/citra-space/citrascope/releases/latest');
85
110
  if (!githubResponse.ok) {
86
111
  return { status: 'error', currentVersion };
@@ -90,729 +115,94 @@ async function checkForUpdates() {
90
115
  const latestVersion = releaseData.tag_name.replace(/^v/, '');
91
116
  const releaseUrl = releaseData.html_url;
92
117
 
93
- // Skip comparison for development versions
94
118
  if (currentVersion === 'development' || currentVersion === 'unknown') {
119
+ store.updateIndicator = '';
95
120
  return { status: 'up-to-date', currentVersion };
96
121
  }
97
122
 
98
- // Compare versions
99
123
  if (compareVersions(latestVersion, currentVersion) > 0) {
100
- // Update available - show indicator badge with version
101
- const indicator = document.getElementById('updateIndicator');
102
- if (indicator) {
103
- indicator.textContent = `${latestVersion} Available!`;
104
- indicator.style.display = 'inline-block';
105
- }
106
-
107
- return {
108
- status: 'update-available',
109
- currentVersion,
110
- latestVersion,
111
- releaseUrl
112
- };
124
+ store.updateIndicator = `${latestVersion} Available!`;
125
+ return { status: 'update-available', currentVersion, latestVersion, releaseUrl };
113
126
  } else {
114
- // Up to date - hide indicator badge
115
- const indicator = document.getElementById('updateIndicator');
116
- if (indicator) {
117
- indicator.style.display = 'none';
118
- }
119
-
127
+ store.updateIndicator = '';
120
128
  return { status: 'up-to-date', currentVersion };
121
129
  }
122
130
  } catch (error) {
123
- // Network error
124
131
  console.debug('Update check failed:', error);
125
132
  return { status: 'error', currentVersion: 'unknown' };
126
133
  }
127
134
  }
128
135
 
129
- /**
130
- * Show version check modal with results
131
- */
132
- async function showVersionModal() {
133
- const modal = new bootstrap.Modal(document.getElementById('versionModal'));
134
- modal.show();
135
-
136
- // Show loading state
137
- document.getElementById('versionCheckLoading').style.display = 'block';
138
- document.getElementById('versionCheckUpToDate').style.display = 'none';
139
- document.getElementById('versionCheckUpdateAvailable').style.display = 'none';
140
- document.getElementById('versionCheckError').style.display = 'none';
141
-
142
- // Perform check
143
- const result = await checkForUpdates();
144
-
145
- // Hide loading
146
- document.getElementById('versionCheckLoading').style.display = 'none';
147
-
148
- // Show appropriate result
149
- if (result.status === 'update-available') {
150
- document.getElementById('modalCurrentVersion').textContent = 'v' + result.currentVersion;
151
- document.getElementById('modalLatestVersion').textContent = 'v' + result.latestVersion;
152
- document.getElementById('releaseNotesLink').href = result.releaseUrl;
153
- document.getElementById('versionCheckUpdateAvailable').style.display = 'block';
154
- } else if (result.status === 'up-to-date') {
155
- document.getElementById('modalCurrentVersionUpToDate').textContent = result.currentVersion === 'development' ? 'development' : 'v' + result.currentVersion;
156
- document.getElementById('versionCheckUpToDate').style.display = 'block';
157
- } else {
158
- document.getElementById('modalCurrentVersionError').textContent = result.currentVersion === 'development' ? 'development' : result.currentVersion;
159
- document.getElementById('versionCheckError').style.display = 'block';
160
- }
161
- }
162
-
163
- // --- Task Management ---
164
- let nextTaskStartTime = null;
165
- let countdownInterval = null;
166
- let isTaskActive = false;
167
- let currentTaskId = null;
168
- let currentTasks = []; // Store tasks for lookup
169
-
170
- // --- Utility Functions ---
171
- function stripAnsiCodes(text) {
172
- // Remove ANSI color codes (e.g., [92m, [0m, etc.)
173
- return text.replace(/\x1B\[\d+m/g, '').replace(/\[\d+m/g, '');
174
- }
175
-
176
- function formatLocalTime(isoString) {
177
- const date = new Date(isoString);
178
- return date.toLocaleString(undefined, {
179
- month: 'short',
180
- day: 'numeric',
181
- hour: '2-digit',
182
- minute: '2-digit',
183
- second: '2-digit',
184
- hour12: true
185
- });
186
- }
187
-
188
- function formatCountdown(milliseconds) {
189
- const totalSeconds = Math.floor(milliseconds / 1000);
190
-
191
- if (totalSeconds < 0) return 'Starting soon...';
192
-
193
- const hours = Math.floor(totalSeconds / 3600);
194
- const minutes = Math.floor((totalSeconds % 3600) / 60);
195
- const seconds = totalSeconds % 60;
196
-
197
- if (hours > 0) {
198
- return `${hours}h ${minutes}m ${seconds}s`;
199
- } else if (minutes > 0) {
200
- return `${minutes}m ${seconds}s`;
201
- } else {
202
- return `${seconds}s`;
203
- }
204
- }
205
-
206
- function updateCountdown() {
207
- if (!nextTaskStartTime || isTaskActive) return;
208
-
209
- const now = new Date();
210
- const timeUntil = nextTaskStartTime - now;
211
-
212
- const currentTaskDisplay = document.getElementById('currentTaskDisplay');
213
- if (currentTaskDisplay && timeUntil > 0) {
214
- const countdown = formatCountdown(timeUntil);
215
- currentTaskDisplay.innerHTML = `<p class="no-task-message">No active task - next task in ${countdown}</p>`;
216
- }
217
- }
218
-
219
- function startCountdown(startTime) {
220
- nextTaskStartTime = new Date(startTime);
221
-
222
- // Clear any existing interval
223
- if (countdownInterval) {
224
- clearInterval(countdownInterval);
225
- }
226
-
227
- // Update immediately
228
- updateCountdown();
229
-
230
- // Update every second
231
- countdownInterval = setInterval(updateCountdown, 1000);
232
- }
136
+ // Version modal moved to Alpine store method
233
137
 
234
- function stopCountdown() {
235
- nextTaskStartTime = null;
236
- if (countdownInterval) {
237
- clearInterval(countdownInterval);
238
- countdownInterval = null;
138
+ // --- Navigation (Alpine-driven in Phase 3, keep hash sync for now) ---
139
+ function navigateToSection(section) {
140
+ const store = Alpine.store('citrascope');
141
+ store.currentSection = section;
142
+ window.location.hash = section;
143
+ if (section === 'config') {
144
+ initFilterConfig();
239
145
  }
240
146
  }
241
147
 
242
- // --- Navigation Logic ---
243
148
  function initNavigation() {
244
- // Initialize Bootstrap tooltips
245
- const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
246
- tooltipTriggerList.forEach(function (tooltipTriggerEl) {
247
- new bootstrap.Tooltip(tooltipTriggerEl);
248
- });
249
-
250
- const nav = document.getElementById('mainNav');
251
- if (nav) {
252
- // Find all nav links and all dashboard sections with id ending in 'Section'
253
- const navLinks = nav.querySelectorAll('a[data-section]');
254
- const sections = {};
255
- navLinks.forEach(link => {
256
- const section = link.getAttribute('data-section');
257
- const sectionEl = document.getElementById(section + 'Section');
258
- if (sectionEl) {
259
- sections[section] = sectionEl
260
- }
261
- else {
262
- console.log(`No section element found for section: ${section}`);
263
- }
264
- });
265
-
266
- function activateNav(link) {
267
- navLinks.forEach(a => {
268
- a.classList.remove('text-white');
269
- a.removeAttribute('aria-current');
270
- });
271
- link.classList.add('text-white');
272
- link.setAttribute('aria-current', 'page');
149
+ window.addEventListener('hashchange', () => {
150
+ const hash = window.location.hash.substring(1);
151
+ if (hash && (hash === 'monitoring' || hash === 'config')) {
152
+ const store = Alpine.store('citrascope');
153
+ store.currentSection = hash;
154
+ if (hash === 'config') initFilterConfig();
273
155
  }
156
+ });
274
157
 
275
- function showSection(section) {
276
- Object.values(sections).forEach(sec => sec.style.display = 'none');
277
- if (sections[section]) {sections[section].style.display = '';} else {
278
- console.log(`No section found to show for section: ${section}`);
279
- }
280
- }
281
-
282
- nav.addEventListener('click', function(e) {
283
- const link = e.target.closest('a[data-section]');
284
- if (link) {
285
- e.preventDefault();
286
- const section = link.getAttribute('data-section');
287
- activateNav(link);
288
- showSection(section);
289
-
290
- // Reload filter config when config section is shown
291
- if (section === 'config') {
292
- initFilterConfig();
293
- }
294
- }
295
- });
296
-
297
- // Default to first nav item
298
- const first = nav.querySelector('a[data-section]');
299
- if (first) {
300
- activateNav(first);
301
- showSection(first.getAttribute('data-section'));
302
- }
303
- }
304
- }
305
-
306
- // --- WebSocket Status Display ---
307
- function updateWSStatus(connected, reconnectInfo = '') {
308
- const statusEl = document.getElementById('wsStatus');
309
- const template = document.getElementById('connectionStatusTemplate');
310
- const content = template.content.cloneNode(true);
311
- const badge = content.querySelector('.connection-status-badge');
312
- const statusText = content.querySelector('.status-text');
313
-
314
- if (connected) {
315
- badge.classList.add('bg-success');
316
- badge.setAttribute('title', 'Dashboard connected - receiving live updates');
317
- statusText.textContent = 'Connected';
318
- } else if (reconnectInfo) {
319
- badge.classList.add('bg-warning', 'text-dark');
320
- badge.setAttribute('title', 'Dashboard reconnecting - attempting to restore connection');
321
- statusText.textContent = 'Reconnecting';
322
- } else {
323
- badge.classList.add('bg-danger');
324
- badge.setAttribute('title', 'Dashboard disconnected - no live updates');
325
- statusText.textContent = 'Disconnected';
326
- }
327
-
328
- statusEl.innerHTML = '';
329
- statusEl.appendChild(content);
330
-
331
- // Reinitialize tooltips after updating the DOM
332
- const tooltipTrigger = statusEl.querySelector('[data-bs-toggle="tooltip"]');
333
- if (tooltipTrigger) {
334
- new bootstrap.Tooltip(tooltipTrigger);
335
- }
336
- }
337
-
338
- // --- Status Updates ---
339
- function updateStatus(status) {
340
- document.getElementById('hardwareAdapter').textContent = status.hardware_adapter || '-';
341
- document.getElementById('telescopeConnected').innerHTML = status.telescope_connected
342
- ? '<span class="badge rounded-pill bg-success">Connected</span>'
343
- : '<span class="badge rounded-pill bg-danger">Disconnected</span>';
344
- document.getElementById('cameraConnected').innerHTML = status.camera_connected
345
- ? '<span class="badge rounded-pill bg-success">Connected</span>'
346
- : '<span class="badge rounded-pill bg-danger">Disconnected</span>';
347
-
348
- // Update current task display
349
- if (status.current_task && status.current_task !== 'None') {
350
- isTaskActive = true;
351
- currentTaskId = status.current_task;
352
- stopCountdown();
353
- updateCurrentTaskDisplay();
354
- } else if (isTaskActive) {
355
- // Task just finished, set to idle state
356
- isTaskActive = false;
357
- currentTaskId = null;
358
- updateCurrentTaskDisplay();
359
- }
360
- // If isTaskActive is already false, don't touch the display (countdown is updating it)
361
-
362
- if (status.tasks_pending !== undefined) {
363
- document.getElementById('tasksPending').textContent = status.tasks_pending || '0';
364
- }
365
-
366
- if (status.telescope_ra !== null) {
367
- document.getElementById('telescopeRA').textContent = status.telescope_ra.toFixed(4) + '°';
368
- }
369
- if (status.telescope_dec !== null) {
370
- document.getElementById('telescopeDEC').textContent = status.telescope_dec.toFixed(4) + '°';
371
- }
372
-
373
- // Update ground station information
374
- if (status.ground_station_name !== undefined || status.ground_station_url !== undefined) {
375
- const gsNameEl = document.getElementById('groundStationName');
376
- const taskScopeButton = document.getElementById('taskScopeButton');
377
-
378
- if (status.ground_station_name && status.ground_station_url) {
379
- gsNameEl.innerHTML = `<a href="${status.ground_station_url}" target="_blank" class="ground-station-link">${status.ground_station_name} ↗</a>`;
380
- // Update the Task My Scope button
381
- taskScopeButton.href = status.ground_station_url;
382
- taskScopeButton.style.display = 'inline-block';
383
- } else if (status.ground_station_name) {
384
- gsNameEl.textContent = status.ground_station_name;
385
- taskScopeButton.style.display = 'none';
386
- } else {
387
- gsNameEl.textContent = '-';
388
- taskScopeButton.style.display = 'none';
389
- }
390
- }
391
-
392
- // Update task processing state
393
- if (status.processing_active !== undefined) {
394
- updateProcessingState(status.processing_active);
395
- }
396
-
397
- // Update autofocus status
398
- updateAutofocusStatus(status);
399
- }
400
-
401
- function updateAutofocusStatus(status) {
402
- // Update autofocus button state
403
- const button = document.getElementById('runAutofocusButton');
404
- const buttonText = document.getElementById('autofocusButtonText');
405
-
406
- if (button && buttonText && status.autofocus_requested !== undefined) {
407
- if (status.autofocus_requested) {
408
- buttonText.textContent = 'Cancel Autofocus';
409
- button.dataset.action = 'cancel';
410
- button.classList.remove('btn-outline-primary');
411
- button.classList.add('btn-outline-warning');
412
- } else {
413
- buttonText.textContent = 'Run Autofocus';
414
- button.dataset.action = 'request';
415
- button.classList.remove('btn-outline-warning');
416
- button.classList.add('btn-outline-primary');
417
- }
418
- }
419
-
420
- // Update last autofocus display
421
- const lastAutofocusDisplay = document.getElementById('lastAutofocusDisplay');
422
- if (lastAutofocusDisplay) {
423
- if (status.last_autofocus_timestamp) {
424
- const timestamp = status.last_autofocus_timestamp * 1000; // Convert to milliseconds
425
- const now = Date.now();
426
- const elapsed = now - timestamp;
427
- lastAutofocusDisplay.textContent = formatElapsedTime(elapsed);
428
- } else {
429
- lastAutofocusDisplay.textContent = 'Never';
430
- }
431
- }
432
-
433
- // Update next autofocus display
434
- const nextAutofocusDisplay = document.getElementById('nextAutofocusDisplay');
435
- const nextAutofocusTime = document.getElementById('nextAutofocusTime');
436
-
437
- if (nextAutofocusDisplay && nextAutofocusTime) {
438
- if (status.next_autofocus_minutes !== null && status.next_autofocus_minutes !== undefined) {
439
- nextAutofocusDisplay.style.display = 'block';
440
- if (status.next_autofocus_minutes === 0) {
441
- nextAutofocusTime.textContent = 'now (overdue)';
442
- } else {
443
- nextAutofocusTime.textContent = formatMinutes(status.next_autofocus_minutes);
444
- }
445
- } else {
446
- nextAutofocusDisplay.style.display = 'none';
447
- }
448
- }
449
- }
450
-
451
- function formatElapsedTime(milliseconds) {
452
- const seconds = Math.floor(milliseconds / 1000);
453
- const minutes = Math.floor(seconds / 60);
454
- const hours = Math.floor(minutes / 60);
455
- const days = Math.floor(hours / 24);
456
-
457
- if (days > 0) {
458
- return `${days} day${days !== 1 ? 's' : ''} ago`;
459
- } else if (hours > 0) {
460
- return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
461
- } else if (minutes > 0) {
462
- return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
463
- } else {
464
- return 'just now';
465
- }
466
- }
467
-
468
- function formatMinutes(minutes) {
469
- const hours = Math.floor(minutes / 60);
470
- const mins = Math.floor(minutes % 60);
471
-
472
- if (hours > 0) {
473
- if (mins > 0) {
474
- return `${hours}h ${mins}m`;
475
- }
476
- return `${hours}h`;
477
- }
478
- return `${mins}m`;
479
- }
480
-
481
- function updateProcessingState(isActive) {
482
- const statusEl = document.getElementById('processingStatus');
483
- const button = document.getElementById('toggleProcessingButton');
484
- const icon = document.getElementById('processingButtonIcon');
485
-
486
- if (!statusEl || !button || !icon) return;
487
-
488
- if (isActive) {
489
- statusEl.innerHTML = '<span class="badge rounded-pill bg-success">Active</span>';
490
- icon.textContent = 'Pause';
491
- button.title = 'Pause task processing';
492
- } else {
493
- statusEl.innerHTML = '<span class="badge rounded-pill bg-warning text-dark">Paused</span>';
494
- icon.textContent = 'Resume';
495
- button.title = 'Resume task processing';
496
- }
497
- }
498
-
499
- // --- Task Management ---
500
- function getCurrentTaskDetails() {
501
- if (!currentTaskId) return null;
502
- return currentTasks.find(task => task.id === currentTaskId);
503
- }
504
-
505
- function updateCurrentTaskDisplay() {
506
- const currentTaskDisplay = document.getElementById('currentTaskDisplay');
507
- if (!currentTaskDisplay) return;
508
-
509
- if (currentTaskId) {
510
- const taskDetails = getCurrentTaskDetails();
511
- if (taskDetails) {
512
- currentTaskDisplay.innerHTML = `
513
- <div class="d-flex align-items-center gap-2 mb-2">
514
- <div class="spinner-border spinner-border-sm text-success" role="status">
515
- <span class="visually-hidden">Loading...</span>
516
- </div>
517
- <div class="fw-bold" style="font-size: 1.3em;">${taskDetails.target}</div>
518
- </div>
519
- <div class="text-secondary small">
520
- <span>Task ID: ${currentTaskId}</span>
521
- </div>
522
- `;
523
- }
524
- // Don't show fallback - just wait for task details to arrive
525
- } else if (!isTaskActive && !nextTaskStartTime) {
526
- // Only show "No active task" if we're not in countdown mode
527
- currentTaskDisplay.innerHTML = '<p class="no-task-message">No active task</p>';
528
- }
529
- }
530
-
531
- function updateTasks(tasks) {
532
- currentTasks = tasks;
533
- renderTasks(tasks);
534
- // Re-render current task display with updated task info
535
- updateCurrentTaskDisplay();
536
- }
537
-
538
- async function loadTasks() {
539
- try {
540
- const tasks = await getTasks();
541
- renderTasks(tasks);
542
- } catch (error) {
543
- console.error('Failed to load tasks:', error);
544
- }
545
- }
546
-
547
- function renderTasks(tasks) {
548
- try {
549
- const taskList = document.getElementById('taskList');
550
-
551
- if (tasks.length === 0) {
552
- taskList.innerHTML = '<p class="p-3 text-muted-dark">No pending tasks</p>';
553
- stopCountdown();
554
- } else {
555
- // Sort tasks by start time (earliest first)
556
- const sortedTasks = tasks.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
557
-
558
- // Start countdown for next task if no current task is active
559
- if (!isTaskActive && sortedTasks.length > 0) {
560
- startCountdown(sortedTasks[0].start_time);
561
- }
562
-
563
- // Create table structure
564
- const table = document.createElement('table');
565
- table.className = 'table table-dark table-hover mb-0';
566
-
567
- const thead = document.createElement('thead');
568
- thead.innerHTML = `
569
- <tr>
570
- <th>Target</th>
571
- <th>Start Time</th>
572
- <th>End Time</th>
573
- <th>Status</th>
574
- </tr>
575
- `;
576
- table.appendChild(thead);
577
-
578
- const tbody = document.createElement('tbody');
579
- const template = document.getElementById('taskRowTemplate');
580
-
581
- sortedTasks.forEach(task => {
582
- const isActive = task.id === currentTaskId;
583
- const row = template.content.cloneNode(true);
584
- const tr = row.querySelector('.task-row');
585
-
586
- if (isActive) {
587
- tr.classList.add('table-active');
588
- }
589
-
590
- row.querySelector('.task-target').textContent = task.target;
591
- row.querySelector('.task-start').textContent = formatLocalTime(task.start_time);
592
- row.querySelector('.task-end').textContent = task.stop_time ? formatLocalTime(task.stop_time) : '-';
593
-
594
- const badge = row.querySelector('.task-status');
595
- badge.classList.add(isActive ? 'bg-success' : 'bg-info');
596
- badge.textContent = isActive ? 'Active' : task.status;
597
-
598
- tbody.appendChild(row);
599
- });
600
-
601
- table.appendChild(tbody);
602
- taskList.innerHTML = '';
603
- taskList.appendChild(table);
604
- }
605
- } catch (error) {
606
- console.error('Failed to render tasks:', error);
607
- }
608
- }
609
-
610
- // --- Log Display ---
611
- async function loadLogs() {
612
- try {
613
- const data = await getLogs(100);
614
- const logContainer = document.getElementById('logContainer');
615
-
616
- if (data.logs.length === 0) {
617
- logContainer.innerHTML = '<p class="text-muted-dark">No logs available</p>';
618
- } else {
619
- logContainer.innerHTML = '';
620
- data.logs.forEach(log => {
621
- appendLog(log);
622
- });
623
- // Scroll to bottom
624
- logContainer.scrollTop = logContainer.scrollHeight;
625
- }
626
- } catch (error) {
627
- console.error('Failed to load logs:', error);
628
- }
629
- }
630
-
631
- function appendLog(log) {
632
- const logContainer = document.getElementById('logContainer');
633
- const template = document.getElementById('logEntryTemplate');
634
- const entry = template.content.cloneNode(true);
635
-
636
- const timestamp = new Date(log.timestamp).toLocaleTimeString();
637
- const cleanMessage = stripAnsiCodes(log.message);
638
-
639
- entry.querySelector('.log-timestamp').textContent = timestamp;
640
- const levelSpan = entry.querySelector('.log-level');
641
- levelSpan.classList.add(`log-level-${log.level}`);
642
- levelSpan.textContent = log.level;
643
- entry.querySelector('.log-message').textContent = cleanMessage;
644
-
645
- const logEntryElement = logContainer.appendChild(entry);
646
-
647
- const scrollParent = logContainer.closest('.accordion-body');
648
- if (scrollParent) {
649
- const isNearBottom = (scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight) < 100;
650
- if (isNearBottom) {
651
- // Get the actual appended element (first child of the DocumentFragment)
652
- const lastEntry = logContainer.lastElementChild;
653
- if (lastEntry) {
654
- lastEntry.scrollIntoView({ behavior: 'smooth', block: 'end' });
655
- }
656
- }
657
- }
658
- }
659
-
660
- // --- Roll-up Terminal Overlay Logic (Bootstrap Accordion) ---
661
- let isLogExpanded = false;
662
- let latestLog = null;
663
-
664
- function updateLatestLogLine() {
665
- const latestLogLine = document.getElementById('latestLogLine');
666
- if (!latestLogLine) return;
667
- if (isLogExpanded) {
668
- latestLogLine.textContent = 'Activity';
669
- return;
670
- }
671
- if (latestLog) {
672
- const template = document.getElementById('latestLogLineTemplate');
673
- const content = template.content.cloneNode(true);
674
-
675
- const timestamp = new Date(latestLog.timestamp).toLocaleTimeString();
676
- const cleanMessage = stripAnsiCodes(latestLog.message);
677
- // Truncate message to ~150 chars for collapsed header (approx 2 lines)
678
- const truncatedMessage = cleanMessage.length > 150 ? cleanMessage.substring(0, 150) + '...' : cleanMessage;
679
-
680
- content.querySelector('.log-timestamp').textContent = timestamp;
681
- const levelSpan = content.querySelector('.log-level');
682
- levelSpan.classList.add(`log-level-${latestLog.level}`);
683
- levelSpan.textContent = latestLog.level;
684
- content.querySelector('.log-message').textContent = truncatedMessage;
685
-
686
- latestLogLine.innerHTML = '';
687
- latestLogLine.appendChild(content);
158
+ const hash = window.location.hash.substring(1);
159
+ if (hash && (hash === 'monitoring' || hash === 'config')) {
160
+ navigateToSection(hash);
688
161
  } else {
689
- latestLogLine.textContent = '';
162
+ navigateToSection('monitoring');
690
163
  }
691
164
  }
692
165
 
693
- window.addEventListener('DOMContentLoaded', () => {
694
- // Bootstrap accordion events for log terminal
695
- const logAccordionCollapse = document.getElementById('logAccordionCollapse');
696
- if (logAccordionCollapse) {
697
- logAccordionCollapse.addEventListener('shown.bs.collapse', () => {
698
- isLogExpanded = true;
699
- updateLatestLogLine();
700
- const logContainer = document.getElementById('logContainer');
701
- if (logContainer) {
702
- setTimeout(() => {
703
- const lastLog = logContainer.lastElementChild;
704
- if (lastLog) {
705
- lastLog.scrollIntoView({ behavior: 'smooth', block: 'end' });
706
- } else {
707
- logContainer.scrollTop = logContainer.scrollHeight;
708
- }
709
- }, 100);
710
- }
711
- });
712
- logAccordionCollapse.addEventListener('hide.bs.collapse', () => {
713
- isLogExpanded = false;
714
- updateLatestLogLine();
715
- });
716
- }
717
- // Start collapsed by default
718
- isLogExpanded = false;
719
- updateLatestLogLine();
720
- });
721
- // --- End Roll-up Terminal Overlay Logic ---
166
+ // Config module will need to update store.config when loaded - we'll handle in config.js
722
167
 
723
- // Patch appendLog to update latestLog and handle collapsed state
724
- const origAppendLog = appendLog;
725
- appendLog = function(log) {
726
- latestLog = log;
727
- if (!isLogExpanded) {
728
- updateLatestLogLine();
729
- }
730
- origAppendLog(log);
731
- };
168
+ // Camera control and version modal moved to Alpine store methods
732
169
 
733
- // Patch loadLogs to only show latest log in collapsed mode
734
- const origLoadLogs = loadLogs;
735
- loadLogs = async function() {
736
- await origLoadLogs();
737
- if (!isLogExpanded) {
738
- updateLatestLogLine();
739
- }
740
- };
170
+ // Camera capture moved to Alpine store method
741
171
 
742
- // --- Initialize Application ---
743
- document.addEventListener('DOMContentLoaded', async function() {
744
- // Initialize UI navigation
172
+ // --- Initialize ---
173
+ document.addEventListener('DOMContentLoaded', async () => {
745
174
  initNavigation();
746
-
747
- // Initialize configuration management (loads config)
748
175
  await initConfig();
749
-
750
- // Initialize filter configuration
751
176
  await initFilterConfig();
752
-
753
- // Setup autofocus button (only once)
754
177
  setupAutofocusButton();
755
-
756
- // Update app URL links from loaded config
757
- updateAppUrlLinks();
758
-
759
- // Fetch and display version
760
178
  fetchVersion();
761
-
762
- // Check for updates on load and every hour
763
179
  checkForUpdates();
764
- setInterval(checkForUpdates, 3600000); // Check every hour
180
+ setInterval(checkForUpdates, 3600000);
765
181
 
766
- // Wire up version click to open modal
767
- const headerVersion = document.getElementById('headerVersion');
768
- if (headerVersion) {
769
- headerVersion.addEventListener('click', showVersionModal);
770
- }
771
-
772
- // Wire up update indicator badge click to open modal
773
- const updateIndicator = document.getElementById('updateIndicator');
774
- if (updateIndicator) {
775
- updateIndicator.addEventListener('click', showVersionModal);
776
- }
777
-
778
- // Connect WebSocket with handlers
779
182
  connectWebSocket({
780
- onStatus: updateStatus,
781
- onLog: appendLog,
782
- onTasks: updateTasks,
783
- onConnectionChange: updateWSStatus
183
+ onStatus: updateStoreFromStatus,
184
+ onLog: appendLogToStore,
185
+ onTasks: updateStoreFromTasks,
186
+ onConnectionChange: updateStoreFromConnection
784
187
  });
785
188
 
786
- // Load initial data
787
- loadTasks();
788
- loadLogs();
189
+ const tasksData = await getTasks();
190
+ const tasks = Array.isArray(tasksData) ? tasksData : (tasksData?.tasks || []);
191
+ updateStoreFromTasks(tasks);
789
192
 
790
- // Add pause/resume button handler
791
- const toggleButton = document.getElementById('toggleProcessingButton');
792
- if (toggleButton) {
793
- toggleButton.addEventListener('click', async () => {
794
- const icon = document.getElementById('processingButtonIcon');
795
- const currentlyPaused = icon && icon.textContent === 'Resume';
796
- const endpoint = currentlyPaused ? '/api/tasks/resume' : '/api/tasks/pause';
193
+ const logsData = await getLogs(100);
194
+ const store = Alpine.store('citrascope');
195
+ store.logs = (logsData.logs || []).map(log => ({ ...log }));
196
+ if (store.logs.length > 0) {
197
+ store.latestLog = store.logs[store.logs.length - 1];
198
+ }
797
199
 
798
- try {
799
- toggleButton.disabled = true;
800
- const response = await fetch(endpoint, { method: 'POST' });
801
- const result = await response.json();
200
+ startCountdownUpdater();
802
201
 
803
- if (!response.ok) {
804
- console.error('Failed to toggle processing:', result);
805
- // Show specific error message (e.g., "Cannot resume during autofocus")
806
- alert((result.error || 'Failed to toggle task processing') +
807
- (response.status === 409 ? '' : ' - Unknown error'));
808
- }
809
- // State will be updated via WebSocket broadcast within 2 seconds
810
- } catch (error) {
811
- console.error('Error toggling processing:', error);
812
- alert('Error toggling task processing');
813
- } finally {
814
- toggleButton.disabled = false;
815
- }
816
- });
202
+ const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
203
+ for (const el of tooltipTriggerList) {
204
+ new bootstrap.Tooltip(el);
817
205
  }
206
+
207
+ // Toggle switches now use Alpine @change directives in templates
818
208
  });