citrascope 0.8.0__py3-none-any.whl → 0.9.1__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.
@@ -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, createToast } 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
+ }
52
+
53
+ // --- Countdown tick (updates store.countdown) ---
54
+ let countdownInterval = null;
26
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,989 +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;
136
+ // Version modal moved to Alpine store method
211
137
 
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>`;
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();
216
145
  }
217
146
  }
218
147
 
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
- }
233
-
234
- function stopCountdown() {
235
- nextTaskStartTime = null;
236
- if (countdownInterval) {
237
- clearInterval(countdownInterval);
238
- countdownInterval = null;
239
- }
240
- }
241
-
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');
273
- }
274
-
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
- function navigateToSection(section) {
283
- const link = nav.querySelector(`a[data-section="${section}"]`);
284
- if (link) {
285
- activateNav(link);
286
- showSection(section);
287
- window.location.hash = section;
288
-
289
- // Reload filter config when config section is shown
290
- if (section === 'config') {
291
- initFilterConfig();
292
- }
293
- }
294
- }
295
-
296
- nav.addEventListener('click', function(e) {
297
- const link = e.target.closest('a[data-section]');
298
- if (link) {
299
- e.preventDefault();
300
- const section = link.getAttribute('data-section');
301
- navigateToSection(section);
302
- }
303
- });
304
-
305
- // Handle hash changes (back/forward navigation)
306
- window.addEventListener('hashchange', function() {
307
- const hash = window.location.hash.substring(1);
308
- if (hash && sections[hash]) {
309
- const link = nav.querySelector(`a[data-section="${hash}"]`);
310
- if (link) {
311
- activateNav(link);
312
- showSection(hash);
313
- if (hash === 'config') {
314
- initFilterConfig();
315
- }
316
- }
317
- }
318
- });
319
-
320
- // Initialize from hash or default to first nav item
149
+ window.addEventListener('hashchange', () => {
321
150
  const hash = window.location.hash.substring(1);
322
- if (hash && sections[hash]) {
323
- navigateToSection(hash);
324
- } else {
325
- const first = nav.querySelector('a[data-section]');
326
- if (first) {
327
- navigateToSection(first.getAttribute('data-section'));
328
- }
329
- }
330
- }
331
- }
332
-
333
- // --- WebSocket Status Display ---
334
- function updateWSStatus(connected, reconnectInfo = '') {
335
- const statusEl = document.getElementById('wsStatus');
336
- const template = document.getElementById('connectionStatusTemplate');
337
- const content = template.content.cloneNode(true);
338
- const badge = content.querySelector('.connection-status-badge');
339
- const statusText = content.querySelector('.status-text');
340
-
341
- if (connected) {
342
- badge.classList.add('bg-success');
343
- badge.setAttribute('title', 'Dashboard connected - receiving live updates');
344
- statusText.textContent = 'Connected';
345
- } else if (reconnectInfo) {
346
- badge.classList.add('bg-warning', 'text-dark');
347
- badge.setAttribute('title', 'Dashboard reconnecting - attempting to restore connection');
348
- statusText.textContent = 'Reconnecting';
349
- } else {
350
- badge.classList.add('bg-danger');
351
- badge.setAttribute('title', 'Dashboard disconnected - no live updates');
352
- statusText.textContent = 'Disconnected';
353
- }
354
-
355
- statusEl.innerHTML = '';
356
- statusEl.appendChild(content);
357
-
358
- // Reinitialize tooltips after updating the DOM
359
- const tooltipTrigger = statusEl.querySelector('[data-bs-toggle="tooltip"]');
360
- if (tooltipTrigger) {
361
- new bootstrap.Tooltip(tooltipTrigger);
362
- }
363
- }
364
-
365
- // --- Camera Control ---
366
- window.showCameraControl = function() {
367
- const modal = new bootstrap.Modal(document.getElementById('cameraControlModal'));
368
-
369
- // Populate images directory link from config
370
- const imagesDirLink = document.getElementById('imagesDirLink');
371
- if (imagesDirLink && currentConfig.images_dir_path) {
372
- imagesDirLink.textContent = currentConfig.images_dir_path;
373
- imagesDirLink.href = `file://${currentConfig.images_dir_path}`;
374
- }
375
-
376
- // Clear previous capture result
377
- document.getElementById('captureResult').style.display = 'none';
378
-
379
- modal.show();
380
- };
381
-
382
- window.captureImage = async function() {
383
- const captureButton = document.getElementById('captureButton');
384
- const buttonText = document.getElementById('captureButtonText');
385
- const spinner = document.getElementById('captureButtonSpinner');
386
- const exposureDuration = parseFloat(document.getElementById('exposureDuration').value);
387
-
388
- // Validate exposure duration
389
- if (isNaN(exposureDuration) || exposureDuration <= 0) {
390
- createToast('Invalid exposure duration', 'danger', false);
391
- return;
392
- }
393
-
394
- // Show loading state
395
- captureButton.disabled = true;
396
- spinner.style.display = 'inline-block';
397
- buttonText.textContent = 'Capturing...';
398
-
399
- try {
400
- const response = await fetch('/api/camera/capture', {
401
- method: 'POST',
402
- headers: {
403
- 'Content-Type': 'application/json'
404
- },
405
- body: JSON.stringify({ duration: exposureDuration })
406
- });
407
-
408
- const data = await response.json();
409
-
410
- if (response.ok && data.success) {
411
- // Show capture result
412
- document.getElementById('captureFilename').textContent = data.filename;
413
- document.getElementById('captureFormat').textContent = data.format || 'Unknown';
414
- document.getElementById('captureResult').style.display = 'block';
415
-
416
- createToast('Image captured successfully', 'success', true);
417
- } else {
418
- const errorMsg = data.error || 'Failed to capture image';
419
- createToast(errorMsg, 'danger', false);
420
- }
421
- } catch (error) {
422
- console.error('Capture error:', error);
423
- createToast('Failed to capture image: ' + error.message, 'danger', false);
424
- } finally {
425
- // Reset button state
426
- captureButton.disabled = false;
427
- spinner.style.display = 'none';
428
- buttonText.textContent = 'Capture';
429
- }
430
- };
431
-
432
- // --- Status Updates ---
433
- function updateStatus(status) {
434
- document.getElementById('hardwareAdapter').textContent = status.hardware_adapter || '-';
435
- document.getElementById('telescopeConnected').innerHTML = status.telescope_connected
436
- ? '<span class="badge rounded-pill bg-success">Connected</span>'
437
- : '<span class="badge rounded-pill bg-danger">Disconnected</span>';
438
- // Update camera status with control button when connected
439
- const cameraEl = document.getElementById('cameraConnected');
440
- if (status.camera_connected) {
441
- cameraEl.innerHTML = `
442
- <span class="badge rounded-pill bg-success">Connected</span>
443
- <button class="btn btn-sm btn-outline-light" onclick="showCameraControl()">
444
- <i class="bi bi-camera"></i> Control
445
- </button>
446
- `;
447
- } else {
448
- cameraEl.innerHTML = '<span class="badge rounded-pill bg-danger">Disconnected</span>';
449
- }
450
-
451
- // Update current task display
452
- if (status.current_task && status.current_task !== 'None') {
453
- isTaskActive = true;
454
- currentTaskId = status.current_task;
455
- stopCountdown();
456
- updateCurrentTaskDisplay();
457
- } else if (isTaskActive) {
458
- // Task just finished, set to idle state
459
- isTaskActive = false;
460
- currentTaskId = null;
461
- updateCurrentTaskDisplay();
462
- }
463
- // If isTaskActive is already false, don't touch the display (countdown is updating it)
464
-
465
- if (status.tasks_pending !== undefined) {
466
- document.getElementById('tasksPending').textContent = status.tasks_pending || '0';
467
- }
468
-
469
- if (status.telescope_ra !== null && status.telescope_dec !== null) {
470
- document.getElementById('telescopeCoords').textContent =
471
- status.telescope_ra.toFixed(3) + '° / ' + status.telescope_dec.toFixed(3) + '°';
472
- }
473
-
474
- // Update ground station information
475
- if (status.ground_station_name !== undefined || status.ground_station_url !== undefined) {
476
- const gsNameEl = document.getElementById('groundStationName');
477
- const taskScopeButton = document.getElementById('taskScopeButton');
478
-
479
- if (status.ground_station_name && status.ground_station_url) {
480
- gsNameEl.innerHTML = `<a href="${status.ground_station_url}" target="_blank" class="ground-station-link">${status.ground_station_name} ↗</a>`;
481
- // Update the Task My Scope button
482
- taskScopeButton.href = status.ground_station_url;
483
- taskScopeButton.style.display = 'inline-block';
484
- } else if (status.ground_station_name) {
485
- gsNameEl.textContent = status.ground_station_name;
486
- taskScopeButton.style.display = 'none';
487
- } else {
488
- gsNameEl.textContent = '-';
489
- taskScopeButton.style.display = 'none';
490
- }
491
- }
492
-
493
- // Update task processing state
494
- if (status.processing_active !== undefined) {
495
- updateProcessingState(status.processing_active);
496
- }
497
-
498
- // Update automated scheduling state
499
- if (status.automated_scheduling !== undefined) {
500
- updateAutomatedSchedulingState(status.automated_scheduling);
501
- }
502
-
503
- // Update autofocus status
504
- updateAutofocusStatus(status);
505
-
506
- // Update time sync status
507
- updateTimeSyncStatus(status);
508
-
509
- // Update missing dependencies display
510
- updateMissingDependencies(status.missing_dependencies || []);
511
- }
512
-
513
- function updateMissingDependencies(missingDeps) {
514
- const dashboardContainer = document.getElementById('missingDependenciesAlert');
515
- const configContainer = document.getElementById('configMissingDependenciesAlert');
516
-
517
- const containers = [dashboardContainer, configContainer].filter(c => c);
518
-
519
- if (missingDeps.length === 0) {
520
- containers.forEach(container => container.style.display = 'none');
521
- return;
522
- }
523
-
524
- let html = '<strong><i class="bi bi-exclamation-triangle-fill me-2"></i>Missing Dependencies:</strong><ul class="mb-0 mt-2">';
525
- missingDeps.forEach(dep => {
526
- html += `<li><strong>${dep.device_name}</strong>: Missing ${dep.missing_packages}<br>`;
527
- html += `<code class="small">${dep.install_cmd}</code></li>`;
528
- });
529
- html += '</ul>';
530
-
531
- containers.forEach(container => {
532
- container.innerHTML = html;
533
- container.style.display = 'block';
534
- });
535
- }
536
-
537
- function updateAutofocusStatus(status) {
538
- // Update autofocus button state
539
- const button = document.getElementById('runAutofocusButton');
540
- const buttonText = document.getElementById('autofocusButtonText');
541
-
542
- if (button && buttonText && status.autofocus_requested !== undefined) {
543
- if (status.autofocus_requested) {
544
- buttonText.textContent = 'Cancel Autofocus';
545
- button.dataset.action = 'cancel';
546
- button.classList.remove('btn-outline-primary');
547
- button.classList.add('btn-outline-warning');
548
- } else {
549
- buttonText.textContent = 'Run Autofocus';
550
- button.dataset.action = 'request';
551
- button.classList.remove('btn-outline-warning');
552
- button.classList.add('btn-outline-primary');
553
- }
554
- }
555
-
556
- // Update last autofocus display
557
- const lastAutofocusDisplay = document.getElementById('lastAutofocusDisplay');
558
- if (lastAutofocusDisplay) {
559
- if (status.last_autofocus_timestamp) {
560
- const timestamp = status.last_autofocus_timestamp * 1000; // Convert to milliseconds
561
- const now = Date.now();
562
- const elapsed = now - timestamp;
563
- lastAutofocusDisplay.textContent = formatElapsedTime(elapsed);
564
- } else {
565
- lastAutofocusDisplay.textContent = 'Never';
566
- }
567
- }
568
-
569
- // Update next autofocus display
570
- const nextAutofocusDisplay = document.getElementById('nextAutofocusDisplay');
571
- const nextAutofocusTime = document.getElementById('nextAutofocusTime');
572
-
573
- if (nextAutofocusDisplay && nextAutofocusTime) {
574
- if (status.next_autofocus_minutes !== null && status.next_autofocus_minutes !== undefined) {
575
- nextAutofocusDisplay.style.display = 'block';
576
- if (status.next_autofocus_minutes === 0) {
577
- nextAutofocusTime.textContent = 'now (overdue)';
578
- } else {
579
- nextAutofocusTime.textContent = formatMinutes(status.next_autofocus_minutes);
580
- }
581
- } else {
582
- nextAutofocusDisplay.style.display = 'none';
583
- }
584
- }
585
- }
586
-
587
- function updateTimeSyncStatus(status) {
588
- const statusEl = document.getElementById('timeSyncStatus');
589
- const offsetEl = document.getElementById('timeOffsetDisplay');
590
-
591
- if (!statusEl || !offsetEl) {
592
- return;
593
- }
594
-
595
- const timeHealth = status.time_health;
596
-
597
- // Handle missing health data
598
- if (!timeHealth) {
599
- statusEl.innerHTML = '<span class="badge rounded-pill bg-secondary" data-bs-toggle="tooltip" title="Time sync status unknown">Unknown</span>';
600
- offsetEl.textContent = '-';
601
- return;
602
- }
603
-
604
- // Determine badge color based on status
605
- let badgeClass = 'bg-secondary';
606
- let badgeText = 'Unknown';
607
- let tooltipText = 'Time sync status unknown';
608
-
609
- switch (timeHealth.status) {
610
- case 'ok':
611
- badgeClass = 'bg-success';
612
- badgeText = 'OK';
613
- tooltipText = 'Time sync within threshold';
614
- break;
615
- case 'critical':
616
- badgeClass = 'bg-danger';
617
- badgeText = 'Paused';
618
- tooltipText = 'Time drift exceeded threshold - tasks paused';
619
- break;
620
- case 'unknown':
621
- default:
622
- badgeClass = 'bg-secondary';
623
- badgeText = 'Unknown';
624
- tooltipText = 'Unable to check time sync';
625
- break;
626
- }
627
-
628
- statusEl.innerHTML = `<span class="badge rounded-pill ${badgeClass}" data-bs-toggle="tooltip" title="${tooltipText}">${badgeText}</span>`;
629
-
630
- // Format offset display
631
- if (timeHealth.offset_ms !== null && timeHealth.offset_ms !== undefined) {
632
- const offset = timeHealth.offset_ms;
633
- const absOffset = Math.abs(offset);
634
- const sign = offset >= 0 ? '+' : '-';
635
-
636
- if (absOffset < 1) {
637
- offsetEl.textContent = `${sign}${absOffset.toFixed(2)}ms`;
638
- } else if (absOffset < 1000) {
639
- offsetEl.textContent = `${sign}${absOffset.toFixed(0)}ms`;
640
- } else {
641
- offsetEl.textContent = `${sign}${(absOffset / 1000).toFixed(2)}s`;
642
- }
643
-
644
- // Add source indicator
645
- if (timeHealth.source && timeHealth.source !== 'unknown') {
646
- offsetEl.textContent += ` (${timeHealth.source})`;
151
+ if (hash && (hash === 'monitoring' || hash === 'config')) {
152
+ const store = Alpine.store('citrascope');
153
+ store.currentSection = hash;
154
+ if (hash === 'config') initFilterConfig();
647
155
  }
648
- } else {
649
- offsetEl.textContent = '-';
650
- }
651
-
652
- // Reinitialize tooltips
653
- const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
654
- tooltipTriggerList.map(function (tooltipTriggerEl) {
655
- return new bootstrap.Tooltip(tooltipTriggerEl);
656
156
  });
657
- }
658
-
659
- function formatElapsedTime(milliseconds) {
660
- const seconds = Math.floor(milliseconds / 1000);
661
- const minutes = Math.floor(seconds / 60);
662
- const hours = Math.floor(minutes / 60);
663
- const days = Math.floor(hours / 24);
664
-
665
- if (days > 0) {
666
- return `${days} day${days !== 1 ? 's' : ''} ago`;
667
- } else if (hours > 0) {
668
- return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
669
- } else if (minutes > 0) {
670
- return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
671
- } else {
672
- return 'just now';
673
- }
674
- }
675
-
676
- function formatMinutes(minutes) {
677
- const hours = Math.floor(minutes / 60);
678
- const mins = Math.floor(minutes % 60);
679
-
680
- if (hours > 0) {
681
- if (mins > 0) {
682
- return `${hours}h ${mins}m`;
683
- }
684
- return `${hours}h`;
685
- }
686
- return `${mins}m`;
687
- }
688
-
689
- function updateProcessingState(isActive) {
690
- const statusEl = document.getElementById('processingStatus');
691
- const switchEl = document.getElementById('toggleProcessingSwitch');
692
-
693
- if (!statusEl || !switchEl) return;
694
157
 
695
- if (isActive) {
696
- statusEl.innerHTML = '<span class="badge rounded-pill bg-success">Enabled</span>';
697
- switchEl.checked = true;
158
+ const hash = window.location.hash.substring(1);
159
+ if (hash && (hash === 'monitoring' || hash === 'config')) {
160
+ navigateToSection(hash);
698
161
  } else {
699
- statusEl.innerHTML = '<span class="badge rounded-pill bg-secondary">Disabled</span>';
700
- switchEl.checked = false;
162
+ navigateToSection('monitoring');
701
163
  }
702
164
  }
703
165
 
704
- function updateAutomatedSchedulingState(isEnabled) {
705
- const statusEl = document.getElementById('automatedSchedulingStatus');
706
- const switchEl = document.getElementById('toggleAutomatedSchedulingSwitch');
166
+ // Config module will need to update store.config when loaded - we'll handle in config.js
707
167
 
708
- if (!statusEl || !switchEl) return;
168
+ // Camera control and version modal moved to Alpine store methods
709
169
 
710
- if (isEnabled) {
711
- statusEl.innerHTML = '<span class="badge rounded-pill bg-success">Enabled</span>';
712
- switchEl.checked = true;
713
- } else {
714
- statusEl.innerHTML = '<span class="badge rounded-pill bg-secondary">Disabled</span>';
715
- switchEl.checked = false;
716
- }
717
- }
718
-
719
- // --- Task Management ---
720
- function getCurrentTaskDetails() {
721
- if (!currentTaskId) return null;
722
- return currentTasks.find(task => task.id === currentTaskId);
723
- }
724
-
725
- function updateCurrentTaskDisplay() {
726
- const currentTaskDisplay = document.getElementById('currentTaskDisplay');
727
- if (!currentTaskDisplay) return;
728
-
729
- if (currentTaskId) {
730
- const taskDetails = getCurrentTaskDetails();
731
- if (taskDetails) {
732
- currentTaskDisplay.innerHTML = `
733
- <div class="d-flex align-items-center gap-2 mb-2">
734
- <div class="spinner-border spinner-border-sm text-success" role="status">
735
- <span class="visually-hidden">Loading...</span>
736
- </div>
737
- <div class="fw-bold" style="font-size: 1.3em;">${taskDetails.target}</div>
738
- </div>
739
- <div class="text-secondary small">
740
- <span>Task ID: ${currentTaskId}</span>
741
- </div>
742
- `;
743
- }
744
- // Don't show fallback - just wait for task details to arrive
745
- } else if (!isTaskActive && !nextTaskStartTime) {
746
- // Only show "No active task" if we're not in countdown mode
747
- currentTaskDisplay.innerHTML = '<p class="no-task-message">No active task</p>';
748
- }
749
- }
750
-
751
- function updateTasks(tasks) {
752
- currentTasks = tasks;
753
- renderTasks(tasks);
754
- // Re-render current task display with updated task info
755
- updateCurrentTaskDisplay();
756
- }
757
-
758
- async function loadTasks() {
759
- try {
760
- const tasks = await getTasks();
761
- renderTasks(tasks);
762
- } catch (error) {
763
- console.error('Failed to load tasks:', error);
764
- }
765
- }
170
+ // Camera capture moved to Alpine store method
766
171
 
767
- function renderTasks(tasks) {
768
- try {
769
- const taskList = document.getElementById('taskList');
770
-
771
- if (tasks.length === 0) {
772
- taskList.innerHTML = '<p class="p-3 text-muted-dark">No pending tasks</p>';
773
- stopCountdown();
774
- } else {
775
- // Sort tasks by start time (earliest first)
776
- const sortedTasks = tasks.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
777
-
778
- // Start countdown for next task if no current task is active
779
- if (!isTaskActive && sortedTasks.length > 0) {
780
- startCountdown(sortedTasks[0].start_time);
781
- }
782
-
783
- // Create table structure
784
- const table = document.createElement('table');
785
- table.className = 'table table-dark table-hover mb-0';
786
-
787
- const thead = document.createElement('thead');
788
- thead.innerHTML = `
789
- <tr>
790
- <th>Target</th>
791
- <th>Start Time</th>
792
- <th>End Time</th>
793
- <th>Status</th>
794
- </tr>
795
- `;
796
- table.appendChild(thead);
797
-
798
- const tbody = document.createElement('tbody');
799
- const template = document.getElementById('taskRowTemplate');
800
-
801
- sortedTasks.forEach(task => {
802
- const isActive = task.id === currentTaskId;
803
- const row = template.content.cloneNode(true);
804
- const tr = row.querySelector('.task-row');
805
-
806
- if (isActive) {
807
- tr.classList.add('table-active');
808
- }
809
-
810
- row.querySelector('.task-target').textContent = task.target;
811
- row.querySelector('.task-start').textContent = formatLocalTime(task.start_time);
812
- row.querySelector('.task-end').textContent = task.stop_time ? formatLocalTime(task.stop_time) : '-';
813
-
814
- const badge = row.querySelector('.task-status');
815
- badge.classList.add(isActive ? 'bg-success' : 'bg-info');
816
- badge.textContent = isActive ? 'Active' : task.status;
817
-
818
- tbody.appendChild(row);
819
- });
820
-
821
- table.appendChild(tbody);
822
- taskList.innerHTML = '';
823
- taskList.appendChild(table);
824
- }
825
- } catch (error) {
826
- console.error('Failed to render tasks:', error);
827
- }
828
- }
829
-
830
- // --- Log Display ---
831
- async function loadLogs() {
832
- try {
833
- const data = await getLogs(100);
834
- const logContainer = document.getElementById('logContainer');
835
-
836
- if (data.logs.length === 0) {
837
- logContainer.innerHTML = '<p class="text-muted-dark">No logs available</p>';
838
- } else {
839
- logContainer.innerHTML = '';
840
- data.logs.forEach(log => {
841
- appendLog(log);
842
- });
843
- // Scroll to bottom
844
- logContainer.scrollTop = logContainer.scrollHeight;
845
- }
846
- } catch (error) {
847
- console.error('Failed to load logs:', error);
848
- }
849
- }
850
-
851
- function appendLog(log) {
852
- const logContainer = document.getElementById('logContainer');
853
- const template = document.getElementById('logEntryTemplate');
854
- const entry = template.content.cloneNode(true);
855
-
856
- const timestamp = new Date(log.timestamp).toLocaleTimeString();
857
- const cleanMessage = stripAnsiCodes(log.message);
858
-
859
- entry.querySelector('.log-timestamp').textContent = timestamp;
860
- const levelSpan = entry.querySelector('.log-level');
861
- levelSpan.classList.add(`log-level-${log.level}`);
862
- levelSpan.textContent = log.level;
863
- entry.querySelector('.log-message').textContent = cleanMessage;
864
-
865
- const logEntryElement = logContainer.appendChild(entry);
866
-
867
- const scrollParent = logContainer.closest('.accordion-body');
868
- if (scrollParent) {
869
- const isNearBottom = (scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight) < 100;
870
- if (isNearBottom) {
871
- // Get the actual appended element (first child of the DocumentFragment)
872
- const lastEntry = logContainer.lastElementChild;
873
- if (lastEntry) {
874
- lastEntry.scrollIntoView({ behavior: 'smooth', block: 'end' });
875
- }
876
- }
877
- }
878
- }
879
-
880
- // --- Roll-up Terminal Overlay Logic (Bootstrap Accordion) ---
881
- let isLogExpanded = false;
882
- let latestLog = null;
883
-
884
- function updateLatestLogLine() {
885
- const latestLogLine = document.getElementById('latestLogLine');
886
- if (!latestLogLine) return;
887
- if (isLogExpanded) {
888
- latestLogLine.textContent = 'Activity';
889
- return;
890
- }
891
- if (latestLog) {
892
- const template = document.getElementById('latestLogLineTemplate');
893
- const content = template.content.cloneNode(true);
894
-
895
- const timestamp = new Date(latestLog.timestamp).toLocaleTimeString();
896
- const cleanMessage = stripAnsiCodes(latestLog.message);
897
- // Truncate message to ~150 chars for collapsed header (approx 2 lines)
898
- const truncatedMessage = cleanMessage.length > 150 ? cleanMessage.substring(0, 150) + '...' : cleanMessage;
899
-
900
- content.querySelector('.log-timestamp').textContent = timestamp;
901
- const levelSpan = content.querySelector('.log-level');
902
- levelSpan.classList.add(`log-level-${latestLog.level}`);
903
- levelSpan.textContent = latestLog.level;
904
- content.querySelector('.log-message').textContent = truncatedMessage;
905
-
906
- latestLogLine.innerHTML = '';
907
- latestLogLine.appendChild(content);
908
- } else {
909
- latestLogLine.textContent = '';
910
- }
911
- }
912
-
913
- window.addEventListener('DOMContentLoaded', () => {
914
- // Bootstrap accordion events for log terminal
915
- const logAccordionCollapse = document.getElementById('logAccordionCollapse');
916
- if (logAccordionCollapse) {
917
- logAccordionCollapse.addEventListener('shown.bs.collapse', () => {
918
- isLogExpanded = true;
919
- updateLatestLogLine();
920
- const logContainer = document.getElementById('logContainer');
921
- if (logContainer) {
922
- setTimeout(() => {
923
- const lastLog = logContainer.lastElementChild;
924
- if (lastLog) {
925
- lastLog.scrollIntoView({ behavior: 'smooth', block: 'end' });
926
- } else {
927
- logContainer.scrollTop = logContainer.scrollHeight;
928
- }
929
- }, 100);
930
- }
931
- });
932
- logAccordionCollapse.addEventListener('hide.bs.collapse', () => {
933
- isLogExpanded = false;
934
- updateLatestLogLine();
935
- });
936
- }
937
- // Start collapsed by default
938
- isLogExpanded = false;
939
- updateLatestLogLine();
940
- });
941
- // --- End Roll-up Terminal Overlay Logic ---
942
-
943
- // Patch appendLog to update latestLog and handle collapsed state
944
- const origAppendLog = appendLog;
945
- appendLog = function(log) {
946
- latestLog = log;
947
- if (!isLogExpanded) {
948
- updateLatestLogLine();
949
- }
950
- origAppendLog(log);
951
- };
952
-
953
- // Patch loadLogs to only show latest log in collapsed mode
954
- const origLoadLogs = loadLogs;
955
- loadLogs = async function() {
956
- await origLoadLogs();
957
- if (!isLogExpanded) {
958
- updateLatestLogLine();
959
- }
960
- };
961
-
962
- // --- Initialize Application ---
963
- document.addEventListener('DOMContentLoaded', async function() {
964
- // Initialize UI navigation
172
+ // --- Initialize ---
173
+ document.addEventListener('DOMContentLoaded', async () => {
965
174
  initNavigation();
966
-
967
- // Initialize configuration management (loads config)
968
175
  await initConfig();
969
-
970
- // Initialize filter configuration
971
176
  await initFilterConfig();
972
-
973
- // Setup autofocus button (only once)
974
177
  setupAutofocusButton();
975
-
976
- // Update app URL links from loaded config
977
- updateAppUrlLinks();
978
-
979
- // Fetch and display version
980
178
  fetchVersion();
981
-
982
- // Check for updates on load and every hour
983
179
  checkForUpdates();
984
- setInterval(checkForUpdates, 3600000); // Check every hour
985
-
986
- // Wire up version click to open modal
987
- const headerVersion = document.getElementById('headerVersion');
988
- if (headerVersion) {
989
- headerVersion.addEventListener('click', showVersionModal);
990
- }
991
-
992
- // Wire up update indicator badge click to open modal
993
- const updateIndicator = document.getElementById('updateIndicator');
994
- if (updateIndicator) {
995
- updateIndicator.addEventListener('click', showVersionModal);
996
- }
180
+ setInterval(checkForUpdates, 3600000);
997
181
 
998
- // Connect WebSocket with handlers
999
182
  connectWebSocket({
1000
- onStatus: updateStatus,
1001
- onLog: appendLog,
1002
- onTasks: updateTasks,
1003
- onConnectionChange: updateWSStatus
183
+ onStatus: updateStoreFromStatus,
184
+ onLog: appendLogToStore,
185
+ onTasks: updateStoreFromTasks,
186
+ onConnectionChange: updateStoreFromConnection
1004
187
  });
1005
188
 
1006
- // Load initial data
1007
- loadTasks();
1008
- loadLogs();
1009
-
1010
- // Initialize Bootstrap tooltips
1011
- const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
1012
- const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
1013
-
1014
- // Add pause/resume switch handler
1015
- const toggleSwitch = document.getElementById('toggleProcessingSwitch');
1016
- if (toggleSwitch) {
1017
- toggleSwitch.addEventListener('change', async (e) => {
1018
- const isChecked = e.target.checked;
1019
- const endpoint = isChecked ? '/api/tasks/resume' : '/api/tasks/pause';
189
+ const tasksData = await getTasks();
190
+ const tasks = Array.isArray(tasksData) ? tasksData : (tasksData?.tasks || []);
191
+ updateStoreFromTasks(tasks);
1020
192
 
1021
- try {
1022
- toggleSwitch.disabled = true;
1023
- const response = await fetch(endpoint, { method: 'POST' });
1024
- const result = await response.json();
1025
-
1026
- if (!response.ok) {
1027
- console.error('Failed to toggle processing:', result);
1028
- // Show specific error message (e.g., "Cannot resume during autofocus")
1029
- alert((result.error || 'Failed to toggle task processing') +
1030
- (response.status === 409 ? '' : ' - Unknown error'));
1031
- // Revert switch state on error
1032
- toggleSwitch.checked = !isChecked;
1033
- }
1034
- // State will be updated via WebSocket broadcast within 2 seconds
1035
- } catch (error) {
1036
- console.error('Error toggling processing:', error);
1037
- alert('Error toggling task processing');
1038
- // Revert switch state on error
1039
- toggleSwitch.checked = !isChecked;
1040
- } finally {
1041
- toggleSwitch.disabled = false;
1042
- }
1043
- });
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];
1044
198
  }
1045
199
 
1046
- // Add automated scheduling switch handler
1047
- const automatedSchedulingSwitch = document.getElementById('toggleAutomatedSchedulingSwitch');
1048
- if (automatedSchedulingSwitch) {
1049
- automatedSchedulingSwitch.addEventListener('change', async (e) => {
1050
- const isChecked = e.target.checked;
1051
-
1052
- try {
1053
- automatedSchedulingSwitch.disabled = true;
1054
- const response = await fetch('/api/telescope/automated-scheduling', {
1055
- method: 'PATCH',
1056
- headers: { 'Content-Type': 'application/json' },
1057
- body: JSON.stringify({ enabled: isChecked })
1058
- });
1059
- const result = await response.json();
200
+ startCountdownUpdater();
1060
201
 
1061
- if (!response.ok) {
1062
- console.error('Failed to toggle automated scheduling:', result);
1063
- alert(result.error || 'Failed to toggle automated scheduling');
1064
- // Revert switch state on error
1065
- automatedSchedulingSwitch.checked = !isChecked;
1066
- }
1067
- // State will be updated via WebSocket broadcast
1068
- } catch (error) {
1069
- console.error('Error toggling automated scheduling:', error);
1070
- alert('Error toggling automated scheduling');
1071
- // Revert switch state on error
1072
- automatedSchedulingSwitch.checked = !isChecked;
1073
- } finally {
1074
- automatedSchedulingSwitch.disabled = false;
1075
- }
1076
- });
202
+ const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
203
+ for (const el of tooltipTriggerList) {
204
+ new bootstrap.Tooltip(el);
1077
205
  }
206
+
207
+ // Toggle switches now use Alpine @change directives in templates
1078
208
  });