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.
- citrascope/hardware/abstract_astro_hardware_adapter.py +17 -0
- citrascope/hardware/adapter_registry.py +5 -0
- citrascope/hardware/devices/camera/abstract_camera.py +12 -0
- citrascope/hardware/devices/camera/usb_camera.py +20 -15
- citrascope/hardware/devices/camera/ximea_camera.py +22 -10
- citrascope/hardware/direct_hardware_adapter.py +21 -3
- citrascope/hardware/dummy_adapter.py +202 -0
- citrascope/time/__init__.py +2 -1
- citrascope/time/time_health.py +8 -1
- citrascope/time/time_monitor.py +27 -5
- citrascope/time/time_sources.py +199 -0
- citrascope/web/app.py +31 -9
- citrascope/web/static/app.js +118 -988
- citrascope/web/static/components.js +136 -0
- citrascope/web/static/config.js +212 -508
- citrascope/web/static/formatters.js +129 -0
- citrascope/web/static/store-init.js +216 -0
- citrascope/web/static/style.css +5 -0
- citrascope/web/templates/_config.html +175 -0
- citrascope/web/templates/_config_hardware.html +208 -0
- citrascope/web/templates/_monitoring.html +242 -0
- citrascope/web/templates/dashboard.html +72 -444
- {citrascope-0.8.0.dist-info → citrascope-0.9.1.dist-info}/METADATA +3 -2
- {citrascope-0.8.0.dist-info → citrascope-0.9.1.dist-info}/RECORD +27 -20
- {citrascope-0.8.0.dist-info → citrascope-0.9.1.dist-info}/WHEEL +0 -0
- {citrascope-0.8.0.dist-info → citrascope-0.9.1.dist-info}/entry_points.txt +0 -0
- {citrascope-0.8.0.dist-info → citrascope-0.9.1.dist-info}/licenses/LICENSE +0 -0
citrascope/web/static/app.js
CHANGED
|
@@ -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,
|
|
3
|
+
import { initConfig, initFilterConfig, setupAutofocusButton, createToast } from './config.js';
|
|
4
4
|
import { getTasks, getLogs } from './api.js';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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 &&
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
696
|
-
|
|
697
|
-
|
|
158
|
+
const hash = window.location.hash.substring(1);
|
|
159
|
+
if (hash && (hash === 'monitoring' || hash === 'config')) {
|
|
160
|
+
navigateToSection(hash);
|
|
698
161
|
} else {
|
|
699
|
-
|
|
700
|
-
switchEl.checked = false;
|
|
162
|
+
navigateToSection('monitoring');
|
|
701
163
|
}
|
|
702
164
|
}
|
|
703
165
|
|
|
704
|
-
|
|
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
|
-
|
|
168
|
+
// Camera control and version modal moved to Alpine store methods
|
|
709
169
|
|
|
710
|
-
|
|
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
|
-
|
|
768
|
-
|
|
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);
|
|
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:
|
|
1001
|
-
onLog:
|
|
1002
|
-
onTasks:
|
|
1003
|
-
onConnectionChange:
|
|
183
|
+
onStatus: updateStoreFromStatus,
|
|
184
|
+
onLog: appendLogToStore,
|
|
185
|
+
onTasks: updateStoreFromTasks,
|
|
186
|
+
onConnectionChange: updateStoreFromConnection
|
|
1004
187
|
});
|
|
1005
188
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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
|
});
|