citrascope 0.1.0__py3-none-any.whl → 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- citrascope/__main__.py +8 -5
- citrascope/api/abstract_api_client.py +7 -0
- citrascope/api/citra_api_client.py +30 -1
- citrascope/citra_scope_daemon.py +214 -61
- citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
- citrascope/hardware/adapter_registry.py +94 -0
- citrascope/hardware/indi_adapter.py +456 -16
- citrascope/hardware/kstars_dbus_adapter.py +179 -0
- citrascope/hardware/nina_adv_http_adapter.py +593 -0
- citrascope/hardware/nina_adv_http_survey_template.json +328 -0
- citrascope/logging/__init__.py +2 -1
- citrascope/logging/_citrascope_logger.py +80 -1
- citrascope/logging/web_log_handler.py +74 -0
- citrascope/settings/citrascope_settings.py +145 -0
- citrascope/settings/settings_file_manager.py +126 -0
- citrascope/tasks/runner.py +124 -28
- citrascope/tasks/scope/base_telescope_task.py +25 -10
- citrascope/tasks/scope/static_telescope_task.py +11 -3
- citrascope/web/__init__.py +1 -0
- citrascope/web/app.py +470 -0
- citrascope/web/server.py +123 -0
- citrascope/web/static/api.js +82 -0
- citrascope/web/static/app.js +500 -0
- citrascope/web/static/config.js +362 -0
- citrascope/web/static/img/citra.png +0 -0
- citrascope/web/static/img/favicon.png +0 -0
- citrascope/web/static/style.css +120 -0
- citrascope/web/static/websocket.js +127 -0
- citrascope/web/templates/dashboard.html +354 -0
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/METADATA +68 -36
- citrascope-0.3.0.dist-info/RECORD +38 -0
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/WHEEL +1 -1
- citrascope/settings/_citrascope_settings.py +0 -42
- citrascope-0.1.0.dist-info/RECORD +0 -21
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// CitraScope Dashboard - Main Application
|
|
2
|
+
import { connectWebSocket } from './websocket.js';
|
|
3
|
+
import { initConfig, currentConfig } from './config.js';
|
|
4
|
+
import { getTasks, getLogs } from './api.js';
|
|
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
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Global state for countdown
|
|
17
|
+
let nextTaskStartTime = null;
|
|
18
|
+
let countdownInterval = null;
|
|
19
|
+
let isTaskActive = false;
|
|
20
|
+
let currentTaskId = null;
|
|
21
|
+
let currentTasks = []; // Store tasks for lookup
|
|
22
|
+
|
|
23
|
+
// --- Utility Functions ---
|
|
24
|
+
function stripAnsiCodes(text) {
|
|
25
|
+
// Remove ANSI color codes (e.g., [92m, [0m, etc.)
|
|
26
|
+
return text.replace(/\x1B\[\d+m/g, '').replace(/\[\d+m/g, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatLocalTime(isoString) {
|
|
30
|
+
const date = new Date(isoString);
|
|
31
|
+
return date.toLocaleString(undefined, {
|
|
32
|
+
month: 'short',
|
|
33
|
+
day: 'numeric',
|
|
34
|
+
hour: '2-digit',
|
|
35
|
+
minute: '2-digit',
|
|
36
|
+
second: '2-digit',
|
|
37
|
+
hour12: true
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatCountdown(milliseconds) {
|
|
42
|
+
const totalSeconds = Math.floor(milliseconds / 1000);
|
|
43
|
+
|
|
44
|
+
if (totalSeconds < 0) return 'Starting soon...';
|
|
45
|
+
|
|
46
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
47
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
48
|
+
const seconds = totalSeconds % 60;
|
|
49
|
+
|
|
50
|
+
if (hours > 0) {
|
|
51
|
+
return `${hours}h ${minutes}m ${seconds}s`;
|
|
52
|
+
} else if (minutes > 0) {
|
|
53
|
+
return `${minutes}m ${seconds}s`;
|
|
54
|
+
} else {
|
|
55
|
+
return `${seconds}s`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function updateCountdown() {
|
|
60
|
+
if (!nextTaskStartTime || isTaskActive) return;
|
|
61
|
+
|
|
62
|
+
const now = new Date();
|
|
63
|
+
const timeUntil = nextTaskStartTime - now;
|
|
64
|
+
|
|
65
|
+
const currentTaskDisplay = document.getElementById('currentTaskDisplay');
|
|
66
|
+
if (currentTaskDisplay && timeUntil > 0) {
|
|
67
|
+
const countdown = formatCountdown(timeUntil);
|
|
68
|
+
currentTaskDisplay.innerHTML = `<p class="no-task-message">No active task - next task in ${countdown}</p>`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function startCountdown(startTime) {
|
|
73
|
+
nextTaskStartTime = new Date(startTime);
|
|
74
|
+
|
|
75
|
+
// Clear any existing interval
|
|
76
|
+
if (countdownInterval) {
|
|
77
|
+
clearInterval(countdownInterval);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Update immediately
|
|
81
|
+
updateCountdown();
|
|
82
|
+
|
|
83
|
+
// Update every second
|
|
84
|
+
countdownInterval = setInterval(updateCountdown, 1000);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function stopCountdown() {
|
|
88
|
+
nextTaskStartTime = null;
|
|
89
|
+
if (countdownInterval) {
|
|
90
|
+
clearInterval(countdownInterval);
|
|
91
|
+
countdownInterval = null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- Navigation Logic ---
|
|
96
|
+
function initNavigation() {
|
|
97
|
+
// Initialize Bootstrap tooltips
|
|
98
|
+
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
|
99
|
+
tooltipTriggerList.forEach(function (tooltipTriggerEl) {
|
|
100
|
+
new bootstrap.Tooltip(tooltipTriggerEl);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const nav = document.getElementById('mainNav');
|
|
104
|
+
if (nav) {
|
|
105
|
+
// Find all nav links and all dashboard sections with id ending in 'Section'
|
|
106
|
+
const navLinks = nav.querySelectorAll('a[data-section]');
|
|
107
|
+
const sections = {};
|
|
108
|
+
navLinks.forEach(link => {
|
|
109
|
+
const section = link.getAttribute('data-section');
|
|
110
|
+
const sectionEl = document.getElementById(section + 'Section');
|
|
111
|
+
if (sectionEl) {
|
|
112
|
+
sections[section] = sectionEl
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
console.log(`No section element found for section: ${section}`);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
function activateNav(link) {
|
|
120
|
+
navLinks.forEach(a => {
|
|
121
|
+
a.classList.remove('text-white');
|
|
122
|
+
a.removeAttribute('aria-current');
|
|
123
|
+
});
|
|
124
|
+
link.classList.add('text-white');
|
|
125
|
+
link.setAttribute('aria-current', 'page');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function showSection(section) {
|
|
129
|
+
Object.values(sections).forEach(sec => sec.style.display = 'none');
|
|
130
|
+
if (sections[section]) {sections[section].style.display = '';} else {
|
|
131
|
+
console.log(`No section found to show for section: ${section}`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
nav.addEventListener('click', function(e) {
|
|
136
|
+
const link = e.target.closest('a[data-section]');
|
|
137
|
+
if (link) {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
const section = link.getAttribute('data-section');
|
|
140
|
+
activateNav(link);
|
|
141
|
+
showSection(section);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Default to first nav item
|
|
146
|
+
const first = nav.querySelector('a[data-section]');
|
|
147
|
+
if (first) {
|
|
148
|
+
activateNav(first);
|
|
149
|
+
showSection(first.getAttribute('data-section'));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- WebSocket Status Display ---
|
|
155
|
+
function updateWSStatus(connected, reconnectInfo = '') {
|
|
156
|
+
const statusEl = document.getElementById('wsStatus');
|
|
157
|
+
const template = document.getElementById('connectionStatusTemplate');
|
|
158
|
+
const content = template.content.cloneNode(true);
|
|
159
|
+
const badge = content.querySelector('.connection-status-badge');
|
|
160
|
+
const statusText = content.querySelector('.status-text');
|
|
161
|
+
|
|
162
|
+
if (connected) {
|
|
163
|
+
badge.classList.add('bg-success');
|
|
164
|
+
badge.setAttribute('title', 'Dashboard connected - receiving live updates');
|
|
165
|
+
statusText.textContent = 'Connected';
|
|
166
|
+
} else if (reconnectInfo) {
|
|
167
|
+
badge.classList.add('bg-warning', 'text-dark');
|
|
168
|
+
badge.setAttribute('title', 'Dashboard reconnecting - attempting to restore connection');
|
|
169
|
+
statusText.textContent = 'Reconnecting';
|
|
170
|
+
} else {
|
|
171
|
+
badge.classList.add('bg-danger');
|
|
172
|
+
badge.setAttribute('title', 'Dashboard disconnected - no live updates');
|
|
173
|
+
statusText.textContent = 'Disconnected';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
statusEl.innerHTML = '';
|
|
177
|
+
statusEl.appendChild(content);
|
|
178
|
+
|
|
179
|
+
// Reinitialize tooltips after updating the DOM
|
|
180
|
+
const tooltipTrigger = statusEl.querySelector('[data-bs-toggle="tooltip"]');
|
|
181
|
+
if (tooltipTrigger) {
|
|
182
|
+
new bootstrap.Tooltip(tooltipTrigger);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- Status Updates ---
|
|
187
|
+
function updateStatus(status) {
|
|
188
|
+
document.getElementById('hardwareAdapter').textContent = status.hardware_adapter || '-';
|
|
189
|
+
document.getElementById('telescopeConnected').innerHTML = status.telescope_connected
|
|
190
|
+
? '<span class="badge rounded-pill bg-success">Connected</span>'
|
|
191
|
+
: '<span class="badge rounded-pill bg-danger">Disconnected</span>';
|
|
192
|
+
document.getElementById('cameraConnected').innerHTML = status.camera_connected
|
|
193
|
+
? '<span class="badge rounded-pill bg-success">Connected</span>'
|
|
194
|
+
: '<span class="badge rounded-pill bg-danger">Disconnected</span>';
|
|
195
|
+
|
|
196
|
+
// Update current task display
|
|
197
|
+
if (status.current_task && status.current_task !== 'None') {
|
|
198
|
+
isTaskActive = true;
|
|
199
|
+
currentTaskId = status.current_task;
|
|
200
|
+
stopCountdown();
|
|
201
|
+
updateCurrentTaskDisplay();
|
|
202
|
+
} else if (isTaskActive) {
|
|
203
|
+
// Task just finished, set to idle state
|
|
204
|
+
isTaskActive = false;
|
|
205
|
+
currentTaskId = null;
|
|
206
|
+
updateCurrentTaskDisplay();
|
|
207
|
+
}
|
|
208
|
+
// If isTaskActive is already false, don't touch the display (countdown is updating it)
|
|
209
|
+
|
|
210
|
+
document.getElementById('tasksPending').textContent = status.tasks_pending || '0';
|
|
211
|
+
|
|
212
|
+
if (status.telescope_ra !== null) {
|
|
213
|
+
document.getElementById('telescopeRA').textContent = status.telescope_ra.toFixed(4) + '°';
|
|
214
|
+
}
|
|
215
|
+
if (status.telescope_dec !== null) {
|
|
216
|
+
document.getElementById('telescopeDEC').textContent = status.telescope_dec.toFixed(4) + '°';
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Update ground station information
|
|
220
|
+
const gsNameEl = document.getElementById('groundStationName');
|
|
221
|
+
const taskScopeButton = document.getElementById('taskScopeButton');
|
|
222
|
+
|
|
223
|
+
if (status.ground_station_name && status.ground_station_url) {
|
|
224
|
+
gsNameEl.innerHTML = `<a href="${status.ground_station_url}" target="_blank" class="ground-station-link">${status.ground_station_name} ↗</a>`;
|
|
225
|
+
// Update the Task My Scope button
|
|
226
|
+
taskScopeButton.href = status.ground_station_url;
|
|
227
|
+
taskScopeButton.style.display = 'inline-block';
|
|
228
|
+
} else if (status.ground_station_name) {
|
|
229
|
+
gsNameEl.textContent = status.ground_station_name;
|
|
230
|
+
taskScopeButton.style.display = 'none';
|
|
231
|
+
} else {
|
|
232
|
+
gsNameEl.textContent = '-';
|
|
233
|
+
taskScopeButton.style.display = 'none';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// --- Task Management ---
|
|
238
|
+
function getCurrentTaskDetails() {
|
|
239
|
+
if (!currentTaskId) return null;
|
|
240
|
+
return currentTasks.find(task => task.id === currentTaskId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function updateCurrentTaskDisplay() {
|
|
244
|
+
const currentTaskDisplay = document.getElementById('currentTaskDisplay');
|
|
245
|
+
if (!currentTaskDisplay) return;
|
|
246
|
+
|
|
247
|
+
if (currentTaskId) {
|
|
248
|
+
const taskDetails = getCurrentTaskDetails();
|
|
249
|
+
if (taskDetails) {
|
|
250
|
+
currentTaskDisplay.innerHTML = `
|
|
251
|
+
<div class="d-flex align-items-center gap-2 mb-2">
|
|
252
|
+
<div class="spinner-border spinner-border-sm text-success" role="status">
|
|
253
|
+
<span class="visually-hidden">Loading...</span>
|
|
254
|
+
</div>
|
|
255
|
+
<div class="fw-bold" style="font-size: 1.3em;">${taskDetails.target}</div>
|
|
256
|
+
</div>
|
|
257
|
+
<div class="text-secondary small">
|
|
258
|
+
<span>Task ID: ${currentTaskId}</span>
|
|
259
|
+
</div>
|
|
260
|
+
`;
|
|
261
|
+
}
|
|
262
|
+
// Don't show fallback - just wait for task details to arrive
|
|
263
|
+
} else if (!isTaskActive && !nextTaskStartTime) {
|
|
264
|
+
// Only show "No active task" if we're not in countdown mode
|
|
265
|
+
currentTaskDisplay.innerHTML = '<p class="no-task-message">No active task</p>';
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function updateTasks(tasks) {
|
|
270
|
+
currentTasks = tasks;
|
|
271
|
+
renderTasks(tasks);
|
|
272
|
+
// Re-render current task display with updated task info
|
|
273
|
+
updateCurrentTaskDisplay();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function loadTasks() {
|
|
277
|
+
try {
|
|
278
|
+
const tasks = await getTasks();
|
|
279
|
+
renderTasks(tasks);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error('Failed to load tasks:', error);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function renderTasks(tasks) {
|
|
286
|
+
try {
|
|
287
|
+
const taskList = document.getElementById('taskList');
|
|
288
|
+
|
|
289
|
+
if (tasks.length === 0) {
|
|
290
|
+
taskList.innerHTML = '<p class="p-3 text-muted-dark">No pending tasks</p>';
|
|
291
|
+
stopCountdown();
|
|
292
|
+
} else {
|
|
293
|
+
// Sort tasks by start time (earliest first)
|
|
294
|
+
const sortedTasks = tasks.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
|
|
295
|
+
|
|
296
|
+
// Start countdown for next task if no current task is active
|
|
297
|
+
if (!isTaskActive && sortedTasks.length > 0) {
|
|
298
|
+
startCountdown(sortedTasks[0].start_time);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Create table structure
|
|
302
|
+
const table = document.createElement('table');
|
|
303
|
+
table.className = 'table table-dark table-hover mb-0';
|
|
304
|
+
|
|
305
|
+
const thead = document.createElement('thead');
|
|
306
|
+
thead.innerHTML = `
|
|
307
|
+
<tr>
|
|
308
|
+
<th>Target</th>
|
|
309
|
+
<th>Start Time</th>
|
|
310
|
+
<th>End Time</th>
|
|
311
|
+
<th>Status</th>
|
|
312
|
+
</tr>
|
|
313
|
+
`;
|
|
314
|
+
table.appendChild(thead);
|
|
315
|
+
|
|
316
|
+
const tbody = document.createElement('tbody');
|
|
317
|
+
const template = document.getElementById('taskRowTemplate');
|
|
318
|
+
|
|
319
|
+
sortedTasks.forEach(task => {
|
|
320
|
+
const isActive = task.id === currentTaskId;
|
|
321
|
+
const row = template.content.cloneNode(true);
|
|
322
|
+
const tr = row.querySelector('.task-row');
|
|
323
|
+
|
|
324
|
+
if (isActive) {
|
|
325
|
+
tr.classList.add('table-active');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
row.querySelector('.task-target').textContent = task.target;
|
|
329
|
+
row.querySelector('.task-start').textContent = formatLocalTime(task.start_time);
|
|
330
|
+
row.querySelector('.task-end').textContent = task.stop_time ? formatLocalTime(task.stop_time) : '-';
|
|
331
|
+
|
|
332
|
+
const badge = row.querySelector('.task-status');
|
|
333
|
+
badge.classList.add(isActive ? 'bg-success' : 'bg-info');
|
|
334
|
+
badge.textContent = isActive ? 'Active' : task.status;
|
|
335
|
+
|
|
336
|
+
tbody.appendChild(row);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
table.appendChild(tbody);
|
|
340
|
+
taskList.innerHTML = '';
|
|
341
|
+
taskList.appendChild(table);
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
console.error('Failed to render tasks:', error);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- Log Display ---
|
|
349
|
+
async function loadLogs() {
|
|
350
|
+
try {
|
|
351
|
+
const data = await getLogs(100);
|
|
352
|
+
const logContainer = document.getElementById('logContainer');
|
|
353
|
+
|
|
354
|
+
if (data.logs.length === 0) {
|
|
355
|
+
logContainer.innerHTML = '<p class="text-muted-dark">No logs available</p>';
|
|
356
|
+
} else {
|
|
357
|
+
logContainer.innerHTML = '';
|
|
358
|
+
data.logs.forEach(log => {
|
|
359
|
+
appendLog(log);
|
|
360
|
+
});
|
|
361
|
+
// Scroll to bottom
|
|
362
|
+
logContainer.scrollTop = logContainer.scrollHeight;
|
|
363
|
+
}
|
|
364
|
+
} catch (error) {
|
|
365
|
+
console.error('Failed to load logs:', error);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function appendLog(log) {
|
|
370
|
+
const logContainer = document.getElementById('logContainer');
|
|
371
|
+
const template = document.getElementById('logEntryTemplate');
|
|
372
|
+
const entry = template.content.cloneNode(true);
|
|
373
|
+
|
|
374
|
+
const timestamp = new Date(log.timestamp).toLocaleTimeString();
|
|
375
|
+
const cleanMessage = stripAnsiCodes(log.message);
|
|
376
|
+
|
|
377
|
+
entry.querySelector('.log-timestamp').textContent = timestamp;
|
|
378
|
+
const levelSpan = entry.querySelector('.log-level');
|
|
379
|
+
levelSpan.classList.add(`log-level-${log.level}`);
|
|
380
|
+
levelSpan.textContent = log.level;
|
|
381
|
+
entry.querySelector('.log-message').textContent = cleanMessage;
|
|
382
|
+
|
|
383
|
+
const logEntryElement = logContainer.appendChild(entry);
|
|
384
|
+
|
|
385
|
+
const scrollParent = logContainer.closest('.accordion-body');
|
|
386
|
+
if (scrollParent) {
|
|
387
|
+
const isNearBottom = (scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight) < 100;
|
|
388
|
+
if (isNearBottom) {
|
|
389
|
+
// Get the actual appended element (first child of the DocumentFragment)
|
|
390
|
+
const lastEntry = logContainer.lastElementChild;
|
|
391
|
+
if (lastEntry) {
|
|
392
|
+
lastEntry.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// --- Roll-up Terminal Overlay Logic (Bootstrap Accordion) ---
|
|
399
|
+
let isLogExpanded = false;
|
|
400
|
+
let latestLog = null;
|
|
401
|
+
|
|
402
|
+
function updateLatestLogLine() {
|
|
403
|
+
const latestLogLine = document.getElementById('latestLogLine');
|
|
404
|
+
if (!latestLogLine) return;
|
|
405
|
+
if (isLogExpanded) {
|
|
406
|
+
latestLogLine.textContent = 'Activity';
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (latestLog) {
|
|
410
|
+
const template = document.getElementById('latestLogLineTemplate');
|
|
411
|
+
const content = template.content.cloneNode(true);
|
|
412
|
+
|
|
413
|
+
const timestamp = new Date(latestLog.timestamp).toLocaleTimeString();
|
|
414
|
+
const cleanMessage = stripAnsiCodes(latestLog.message);
|
|
415
|
+
|
|
416
|
+
content.querySelector('.log-timestamp').textContent = timestamp;
|
|
417
|
+
const levelSpan = content.querySelector('.log-level');
|
|
418
|
+
levelSpan.classList.add(`log-level-${latestLog.level}`);
|
|
419
|
+
levelSpan.textContent = latestLog.level;
|
|
420
|
+
content.querySelector('.log-message').textContent = cleanMessage;
|
|
421
|
+
|
|
422
|
+
latestLogLine.innerHTML = '';
|
|
423
|
+
latestLogLine.appendChild(content);
|
|
424
|
+
} else {
|
|
425
|
+
latestLogLine.textContent = '';
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
window.addEventListener('DOMContentLoaded', () => {
|
|
430
|
+
// Bootstrap accordion events for log terminal
|
|
431
|
+
const logAccordionCollapse = document.getElementById('logAccordionCollapse');
|
|
432
|
+
if (logAccordionCollapse) {
|
|
433
|
+
logAccordionCollapse.addEventListener('shown.bs.collapse', () => {
|
|
434
|
+
isLogExpanded = true;
|
|
435
|
+
updateLatestLogLine();
|
|
436
|
+
const logContainer = document.getElementById('logContainer');
|
|
437
|
+
if (logContainer) {
|
|
438
|
+
setTimeout(() => {
|
|
439
|
+
const lastLog = logContainer.lastElementChild;
|
|
440
|
+
if (lastLog) {
|
|
441
|
+
lastLog.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
442
|
+
} else {
|
|
443
|
+
logContainer.scrollTop = logContainer.scrollHeight;
|
|
444
|
+
}
|
|
445
|
+
}, 100);
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
logAccordionCollapse.addEventListener('hide.bs.collapse', () => {
|
|
449
|
+
isLogExpanded = false;
|
|
450
|
+
updateLatestLogLine();
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
// Start collapsed by default
|
|
454
|
+
isLogExpanded = false;
|
|
455
|
+
updateLatestLogLine();
|
|
456
|
+
});
|
|
457
|
+
// --- End Roll-up Terminal Overlay Logic ---
|
|
458
|
+
|
|
459
|
+
// Patch appendLog to update latestLog and handle collapsed state
|
|
460
|
+
const origAppendLog = appendLog;
|
|
461
|
+
appendLog = function(log) {
|
|
462
|
+
latestLog = log;
|
|
463
|
+
if (!isLogExpanded) {
|
|
464
|
+
updateLatestLogLine();
|
|
465
|
+
}
|
|
466
|
+
origAppendLog(log);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
// Patch loadLogs to only show latest log in collapsed mode
|
|
470
|
+
const origLoadLogs = loadLogs;
|
|
471
|
+
loadLogs = async function() {
|
|
472
|
+
await origLoadLogs();
|
|
473
|
+
if (!isLogExpanded) {
|
|
474
|
+
updateLatestLogLine();
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
// --- Initialize Application ---
|
|
479
|
+
document.addEventListener('DOMContentLoaded', async function() {
|
|
480
|
+
// Initialize UI navigation
|
|
481
|
+
initNavigation();
|
|
482
|
+
|
|
483
|
+
// Initialize configuration management (loads config)
|
|
484
|
+
await initConfig();
|
|
485
|
+
|
|
486
|
+
// Update app URL links from loaded config
|
|
487
|
+
updateAppUrlLinks();
|
|
488
|
+
|
|
489
|
+
// Connect WebSocket with handlers
|
|
490
|
+
connectWebSocket({
|
|
491
|
+
onStatus: updateStatus,
|
|
492
|
+
onLog: appendLog,
|
|
493
|
+
onTasks: updateTasks,
|
|
494
|
+
onConnectionChange: updateWSStatus
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Load initial data
|
|
498
|
+
loadTasks();
|
|
499
|
+
loadLogs();
|
|
500
|
+
});
|