citrascope 0.3.0__py3-none-any.whl → 0.5.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.
@@ -1,6 +1,6 @@
1
1
  // CitraScope Dashboard - Main Application
2
2
  import { connectWebSocket } from './websocket.js';
3
- import { initConfig, currentConfig } from './config.js';
3
+ import { initConfig, currentConfig, initFilterConfig, setupAutofocusButton } from './config.js';
4
4
  import { getTasks, getLogs } from './api.js';
5
5
 
6
6
  function updateAppUrlLinks() {
@@ -13,7 +13,154 @@ function updateAppUrlLinks() {
13
13
  });
14
14
  }
15
15
 
16
- // Global state for countdown
16
+ // --- Version Checking ---
17
+
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/, '');
26
+
27
+ const parts1 = v1.split('.').map(n => parseInt(n) || 0);
28
+ const parts2 = v2.split('.').map(n => parseInt(n) || 0);
29
+
30
+ const maxLen = Math.max(parts1.length, parts2.length);
31
+
32
+ for (let i = 0; i < maxLen; i++) {
33
+ const num1 = parts1[i] || 0;
34
+ const num2 = parts2[i] || 0;
35
+
36
+ if (num1 > num2) return 1;
37
+ if (num1 < num2) return -1;
38
+ }
39
+
40
+ return 0;
41
+ }
42
+
43
+ /**
44
+ * Fetch and display current version
45
+ */
46
+ async function fetchVersion() {
47
+ try {
48
+ const response = await fetch('/api/version');
49
+ 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
+ }
61
+ }
62
+ } catch (error) {
63
+ console.error('Error fetching version:', error);
64
+ const headerVersionEl = document.getElementById('headerVersion');
65
+
66
+ if (headerVersionEl) {
67
+ headerVersionEl.textContent = 'v?';
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Check for available updates from GitHub
74
+ * Returns the check result for modal display
75
+ */
76
+ async function checkForUpdates() {
77
+ try {
78
+ // Get current version
79
+ const versionResponse = await fetch('/api/version');
80
+ const versionData = await versionResponse.json();
81
+ const currentVersion = versionData.version;
82
+
83
+ // Check GitHub for latest release
84
+ const githubResponse = await fetch('https://api.github.com/repos/citra-space/citrascope/releases/latest');
85
+ if (!githubResponse.ok) {
86
+ return { status: 'error', currentVersion };
87
+ }
88
+
89
+ const releaseData = await githubResponse.json();
90
+ const latestVersion = releaseData.tag_name.replace(/^v/, '');
91
+ const releaseUrl = releaseData.html_url;
92
+
93
+ // Skip comparison for development versions
94
+ if (currentVersion === 'development' || currentVersion === 'unknown') {
95
+ return { status: 'up-to-date', currentVersion };
96
+ }
97
+
98
+ // Compare versions
99
+ 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
+ };
113
+ } else {
114
+ // Up to date - hide indicator badge
115
+ const indicator = document.getElementById('updateIndicator');
116
+ if (indicator) {
117
+ indicator.style.display = 'none';
118
+ }
119
+
120
+ return { status: 'up-to-date', currentVersion };
121
+ }
122
+ } catch (error) {
123
+ // Network error
124
+ console.debug('Update check failed:', error);
125
+ return { status: 'error', currentVersion: 'unknown' };
126
+ }
127
+ }
128
+
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 ---
17
164
  let nextTaskStartTime = null;
18
165
  let countdownInterval = null;
19
166
  let isTaskActive = false;
@@ -139,6 +286,11 @@ function initNavigation() {
139
286
  const section = link.getAttribute('data-section');
140
287
  activateNav(link);
141
288
  showSection(section);
289
+
290
+ // Reload filter config when config section is shown
291
+ if (section === 'config') {
292
+ initFilterConfig();
293
+ }
142
294
  }
143
295
  });
144
296
 
@@ -207,7 +359,9 @@ function updateStatus(status) {
207
359
  }
208
360
  // If isTaskActive is already false, don't touch the display (countdown is updating it)
209
361
 
210
- document.getElementById('tasksPending').textContent = status.tasks_pending || '0';
362
+ if (status.tasks_pending !== undefined) {
363
+ document.getElementById('tasksPending').textContent = status.tasks_pending || '0';
364
+ }
211
365
 
212
366
  if (status.telescope_ra !== null) {
213
367
  document.getElementById('telescopeRA').textContent = status.telescope_ra.toFixed(4) + '°';
@@ -217,20 +371,45 @@ function updateStatus(status) {
217
371
  }
218
372
 
219
373
  // 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';
374
+ if (status.ground_station_name !== undefined || status.ground_station_url !== undefined) {
375
+ const gsNameEl = document.getElementById('groundStationName');
376
+ const taskScopeButton = document.getElementById('taskScopeButton');
377
+
378
+ if (status.ground_station_name && status.ground_station_url) {
379
+ gsNameEl.innerHTML = `<a href="${status.ground_station_url}" target="_blank" class="ground-station-link">${status.ground_station_name} ↗</a>`;
380
+ // Update the Task My Scope button
381
+ taskScopeButton.href = status.ground_station_url;
382
+ taskScopeButton.style.display = 'inline-block';
383
+ } else if (status.ground_station_name) {
384
+ gsNameEl.textContent = status.ground_station_name;
385
+ taskScopeButton.style.display = 'none';
386
+ } else {
387
+ gsNameEl.textContent = '-';
388
+ taskScopeButton.style.display = 'none';
389
+ }
390
+ }
391
+
392
+ // Update task processing state
393
+ if (status.processing_active !== undefined) {
394
+ updateProcessingState(status.processing_active);
395
+ }
396
+ }
397
+
398
+ function updateProcessingState(isActive) {
399
+ const statusEl = document.getElementById('processingStatus');
400
+ const button = document.getElementById('toggleProcessingButton');
401
+ const icon = document.getElementById('processingButtonIcon');
402
+
403
+ if (!statusEl || !button || !icon) return;
404
+
405
+ if (isActive) {
406
+ statusEl.innerHTML = '<span class="badge rounded-pill bg-success">Active</span>';
407
+ icon.textContent = 'Pause';
408
+ button.title = 'Pause task processing';
231
409
  } else {
232
- gsNameEl.textContent = '-';
233
- taskScopeButton.style.display = 'none';
410
+ statusEl.innerHTML = '<span class="badge rounded-pill bg-warning">Paused</span>';
411
+ icon.textContent = 'Resume';
412
+ button.title = 'Resume task processing';
234
413
  }
235
414
  }
236
415
 
@@ -412,12 +591,14 @@ function updateLatestLogLine() {
412
591
 
413
592
  const timestamp = new Date(latestLog.timestamp).toLocaleTimeString();
414
593
  const cleanMessage = stripAnsiCodes(latestLog.message);
594
+ // Truncate message to ~150 chars for collapsed header (approx 2 lines)
595
+ const truncatedMessage = cleanMessage.length > 150 ? cleanMessage.substring(0, 150) + '...' : cleanMessage;
415
596
 
416
597
  content.querySelector('.log-timestamp').textContent = timestamp;
417
598
  const levelSpan = content.querySelector('.log-level');
418
599
  levelSpan.classList.add(`log-level-${latestLog.level}`);
419
600
  levelSpan.textContent = latestLog.level;
420
- content.querySelector('.log-message').textContent = cleanMessage;
601
+ content.querySelector('.log-message').textContent = truncatedMessage;
421
602
 
422
603
  latestLogLine.innerHTML = '';
423
604
  latestLogLine.appendChild(content);
@@ -483,9 +664,28 @@ document.addEventListener('DOMContentLoaded', async function() {
483
664
  // Initialize configuration management (loads config)
484
665
  await initConfig();
485
666
 
667
+ // Initialize filter configuration
668
+ await initFilterConfig();
669
+
670
+ // Setup autofocus button (only once)
671
+ setupAutofocusButton();
672
+
486
673
  // Update app URL links from loaded config
487
674
  updateAppUrlLinks();
488
675
 
676
+ // Fetch and display version
677
+ fetchVersion();
678
+
679
+ // Check for updates on load and every hour
680
+ checkForUpdates();
681
+ setInterval(checkForUpdates, 3600000); // Check every hour
682
+
683
+ // Wire up version click to open modal
684
+ const headerVersion = document.getElementById('headerVersion');
685
+ if (headerVersion) {
686
+ headerVersion.addEventListener('click', showVersionModal);
687
+ }
688
+
489
689
  // Connect WebSocket with handlers
490
690
  connectWebSocket({
491
691
  onStatus: updateStatus,
@@ -497,4 +697,33 @@ document.addEventListener('DOMContentLoaded', async function() {
497
697
  // Load initial data
498
698
  loadTasks();
499
699
  loadLogs();
700
+
701
+ // Add pause/resume button handler
702
+ const toggleButton = document.getElementById('toggleProcessingButton');
703
+ if (toggleButton) {
704
+ toggleButton.addEventListener('click', async () => {
705
+ const icon = document.getElementById('processingButtonIcon');
706
+ const currentlyPaused = icon && icon.textContent === 'Resume';
707
+ const endpoint = currentlyPaused ? '/api/tasks/resume' : '/api/tasks/pause';
708
+
709
+ try {
710
+ toggleButton.disabled = true;
711
+ const response = await fetch(endpoint, { method: 'POST' });
712
+ const result = await response.json();
713
+
714
+ if (!response.ok) {
715
+ console.error('Failed to toggle processing:', result);
716
+ // Show specific error message (e.g., "Cannot resume during autofocus")
717
+ alert((result.error || 'Failed to toggle task processing') +
718
+ (response.status === 409 ? '' : ' - Unknown error'));
719
+ }
720
+ // State will be updated via WebSocket broadcast within 2 seconds
721
+ } catch (error) {
722
+ console.error('Error toggling processing:', error);
723
+ alert('Error toggling task processing');
724
+ } finally {
725
+ toggleButton.disabled = false;
726
+ }
727
+ });
728
+ }
500
729
  });
@@ -2,12 +2,14 @@
2
2
 
3
3
  import { getConfig, saveConfig, getConfigStatus, getHardwareAdapters, getAdapterSchema } from './api.js';
4
4
 
5
+ // API Host constants - must match backend constants in app.py
6
+ const PROD_API_HOST = 'api.citra.space';
7
+ const DEV_API_HOST = 'dev.api.citra.space';
8
+ const DEFAULT_API_PORT = 443;
9
+
5
10
  let currentAdapterSchema = [];
6
11
  export let currentConfig = {};
7
12
 
8
- /**
9
- * Initialize configuration management
10
- */
11
13
  export async function initConfig() {
12
14
  // Populate hardware adapter dropdown
13
15
  await loadAdapterOptions();
@@ -25,6 +27,19 @@ export async function initConfig() {
25
27
  });
26
28
  }
27
29
 
30
+ // API endpoint selection change
31
+ const apiEndpointSelect = document.getElementById('apiEndpoint');
32
+ if (apiEndpointSelect) {
33
+ apiEndpointSelect.addEventListener('change', function(e) {
34
+ const customContainer = document.getElementById('customHostContainer');
35
+ if (e.target.value === 'custom') {
36
+ customContainer.style.display = 'block';
37
+ } else {
38
+ customContainer.style.display = 'none';
39
+ }
40
+ });
41
+ }
42
+
28
43
  // Config form submission
29
44
  const configForm = document.getElementById('configForm');
30
45
  if (configForm) {
@@ -114,6 +129,27 @@ async function loadConfiguration() {
114
129
  imagesDirElement.textContent = config.images_dir_path;
115
130
  }
116
131
 
132
+ // API endpoint selector
133
+ const apiEndpointSelect = document.getElementById('apiEndpoint');
134
+ const customHostContainer = document.getElementById('customHostContainer');
135
+ const customHost = document.getElementById('customHost');
136
+ const customPort = document.getElementById('customPort');
137
+ const customUseSsl = document.getElementById('customUseSsl');
138
+
139
+ if (config.host === PROD_API_HOST) {
140
+ apiEndpointSelect.value = 'production';
141
+ customHostContainer.style.display = 'none';
142
+ } else if (config.host === DEV_API_HOST) {
143
+ apiEndpointSelect.value = 'development';
144
+ customHostContainer.style.display = 'none';
145
+ } else {
146
+ apiEndpointSelect.value = 'custom';
147
+ customHostContainer.style.display = 'block';
148
+ customHost.value = config.host || '';
149
+ customPort.value = config.port || DEFAULT_API_PORT;
150
+ customUseSsl.checked = config.use_ssl !== undefined ? config.use_ssl : true;
151
+ }
152
+
117
153
  // Core fields
118
154
  document.getElementById('personal_access_token').value = config.personal_access_token || '';
119
155
  document.getElementById('telescopeId').value = config.telescope_id || '';
@@ -274,6 +310,24 @@ async function saveConfiguration(event) {
274
310
  // Hide previous messages
275
311
  hideConfigMessages();
276
312
 
313
+ // Determine API host settings based on endpoint selection
314
+ const apiEndpoint = document.getElementById('apiEndpoint').value;
315
+ let host, port, use_ssl;
316
+
317
+ if (apiEndpoint === 'production') {
318
+ host = PROD_API_HOST;
319
+ port = DEFAULT_API_PORT;
320
+ use_ssl = true;
321
+ } else if (apiEndpoint === 'development') {
322
+ host = DEV_API_HOST;
323
+ port = DEFAULT_API_PORT;
324
+ use_ssl = true;
325
+ } else { // custom
326
+ host = document.getElementById('customHost').value;
327
+ port = parseInt(document.getElementById('customPort').value, 10);
328
+ use_ssl = document.getElementById('customUseSsl').checked;
329
+ }
330
+
277
331
  const config = {
278
332
  personal_access_token: document.getElementById('personal_access_token').value,
279
333
  telescope_id: document.getElementById('telescopeId').value,
@@ -282,10 +336,11 @@ async function saveConfiguration(event) {
282
336
  log_level: document.getElementById('logLevel').value,
283
337
  keep_images: document.getElementById('keep_images').checked,
284
338
  file_logging_enabled: document.getElementById('file_logging_enabled').checked,
285
- // Preserve API settings from loaded config
286
- host: currentConfig.host || 'api.citra.space',
287
- port: currentConfig.port || 443,
288
- use_ssl: currentConfig.use_ssl !== undefined ? currentConfig.use_ssl : true,
339
+ // API settings from endpoint selector
340
+ host: host,
341
+ port: port,
342
+ use_ssl: use_ssl,
343
+ // Preserve other settings from loaded config
289
344
  max_task_retries: currentConfig.max_task_retries || 3,
290
345
  initial_retry_delay_seconds: currentConfig.initial_retry_delay_seconds || 30,
291
346
  max_retry_delay_seconds: currentConfig.max_retry_delay_seconds || 300,
@@ -296,9 +351,23 @@ async function saveConfiguration(event) {
296
351
  const result = await saveConfig(config);
297
352
 
298
353
  if (result.ok) {
299
- showConfigSuccess(result.data.message || 'Configuration saved and applied successfully!');
354
+ // After config saved successfully, save any modified filter focus positions
355
+ const filterResults = await saveModifiedFilters();
356
+
357
+ // Build success message based on results
358
+ let message = result.data.message || 'Configuration saved and applied successfully!';
359
+ if (filterResults.success > 0) {
360
+ message += ` Updated ${filterResults.success} filter focus position${filterResults.success > 1 ? 's' : ''}.`;
361
+ }
362
+ if (filterResults.failed > 0) {
363
+ message += ` Warning: ${filterResults.failed} filter update${filterResults.failed > 1 ? 's' : ''} failed.`;
364
+ }
365
+
366
+ showConfigSuccess(message);
300
367
  } else {
301
- showConfigError(result.data.error || result.data.message || 'Failed to save configuration');
368
+ // Check for specific error codes
369
+ const errorMsg = result.data.error || result.data.message || 'Failed to save configuration';
370
+ showConfigError(errorMsg);
302
371
  }
303
372
  } catch (error) {
304
373
  showConfigError('Failed to save configuration: ' + error.message);
@@ -358,5 +427,175 @@ export function showConfigSection() {
358
427
  }
359
428
  }
360
429
 
430
+ /**
431
+ * Load and display filter configuration
432
+ */
433
+ async function loadFilterConfig() {
434
+ const filterSection = document.getElementById('filterConfigSection');
435
+
436
+ try {
437
+ const response = await fetch('/api/adapter/filters');
438
+
439
+ if (response.status === 404 || response.status === 503) {
440
+ // Adapter doesn't support filters or isn't available
441
+ if (filterSection) filterSection.style.display = 'none';
442
+ return;
443
+ }
444
+
445
+ const data = await response.json();
446
+
447
+ if (response.ok && data.filters) {
448
+ // Show the filter section
449
+ if (filterSection) filterSection.style.display = 'block';
450
+
451
+ // Populate filter table
452
+ const tbody = document.getElementById('filterTableBody');
453
+ const noFiltersMsg = document.getElementById('noFiltersMessage');
454
+
455
+ if (tbody) {
456
+ tbody.innerHTML = '';
457
+ const filters = data.filters;
458
+ const filterIds = Object.keys(filters).sort();
459
+
460
+ if (filterIds.length === 0) {
461
+ if (noFiltersMsg) noFiltersMsg.style.display = 'block';
462
+ } else {
463
+ if (noFiltersMsg) noFiltersMsg.style.display = 'none';
464
+
465
+ filterIds.forEach(filterId => {
466
+ const filter = filters[filterId];
467
+ const row = document.createElement('tr');
468
+ row.innerHTML = `
469
+ <td>${filterId}</td>
470
+ <td>${filter.name}</td>
471
+ <td>
472
+ <input type="number"
473
+ class="form-control form-control-sm filter-focus-input"
474
+ data-filter-id="${filterId}"
475
+ value="${filter.focus_position}"
476
+ min="0"
477
+ step="1">
478
+ </td>
479
+ `;
480
+ tbody.appendChild(row);
481
+ });
482
+ }
483
+ }
484
+ } else {
485
+ if (filterSection) filterSection.style.display = 'none';
486
+ }
487
+ } catch (error) {
488
+ console.error('Error loading filter config:', error);
489
+ if (filterSection) filterSection.style.display = 'none';
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Save all filter focus positions (called during main config save)
495
+ * Returns: Object with { success: number, failed: number }
496
+ */
497
+ async function saveModifiedFilters() {
498
+ const inputs = document.querySelectorAll('.filter-focus-input');
499
+ if (inputs.length === 0) return { success: 0, failed: 0 }; // No filters to save
500
+
501
+ let successCount = 0;
502
+ let failedCount = 0;
503
+
504
+ // Save all filter values
505
+ for (const input of inputs) {
506
+ const filterId = input.dataset.filterId;
507
+ const focusPosition = parseInt(input.value);
508
+
509
+ if (isNaN(focusPosition) || focusPosition < 0) {
510
+ failedCount++;
511
+ continue;
512
+ }
513
+
514
+ try {
515
+ const response = await fetch(`/api/adapter/filters/${filterId}`, {
516
+ method: 'PATCH',
517
+ headers: {
518
+ 'Content-Type': 'application/json'
519
+ },
520
+ body: JSON.stringify({ focus_position: focusPosition })
521
+ });
522
+
523
+ if (response.ok) {
524
+ successCount++;
525
+ } else {
526
+ failedCount++;
527
+ console.error(`Failed to save filter ${filterId}: HTTP ${response.status}`);
528
+ }
529
+ } catch (error) {
530
+ failedCount++;
531
+ console.error(`Error saving filter ${filterId}:`, error);
532
+ }
533
+ }
534
+
535
+ return { success: successCount, failed: failedCount };
536
+ }
537
+
538
+ /**
539
+ * Trigger autofocus routine
540
+ */
541
+ async function triggerAutofocus() {
542
+ const button = document.getElementById('runAutofocusButton');
543
+ const buttonText = document.getElementById('autofocusButtonText');
544
+ const buttonSpinner = document.getElementById('autofocusButtonSpinner');
545
+
546
+ if (!button || !buttonText || !buttonSpinner) return;
547
+
548
+ // Clear any previous messages
549
+ hideConfigMessages();
550
+
551
+ // Disable button and show spinner
552
+ button.disabled = true;
553
+ buttonText.textContent = 'Running Autofocus...';
554
+ buttonSpinner.style.display = 'inline-block';
555
+
556
+ try {
557
+ const response = await fetch('/api/adapter/autofocus', {
558
+ method: 'POST'
559
+ });
560
+
561
+ const data = await response.json();
562
+
563
+ if (response.ok) {
564
+ showConfigSuccess('Autofocus completed successfully');
565
+ // Reload filter config to show updated focus positions
566
+ await loadFilterConfig();
567
+ } else {
568
+ // Show clear error message (e.g., if autofocus already running or other conflict)
569
+ showConfigError(data.error || 'Autofocus failed');
570
+ }
571
+ } catch (error) {
572
+ console.error('Error triggering autofocus:', error);
573
+ showConfigError('Failed to trigger autofocus');
574
+ } finally {
575
+ // Re-enable button
576
+ button.disabled = false;
577
+ buttonText.textContent = 'Run Autofocus';
578
+ buttonSpinner.style.display = 'none';
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Initialize filter configuration on page load
584
+ */
585
+ export async function initFilterConfig() {
586
+ // Load filter config when config section is visible
587
+ await loadFilterConfig();
588
+ }
589
+
590
+ /**
591
+ * Setup autofocus button event listener (call once during init)
592
+ */
593
+ export function setupAutofocusButton() {
594
+ const autofocusBtn = document.getElementById('runAutofocusButton');
595
+ if (autofocusBtn) {
596
+ autofocusBtn.addEventListener('click', triggerAutofocus);
597
+ }
598
+ }
599
+
361
600
  // Make showConfigSection available globally for onclick handlers in HTML
362
601
  window.showConfigSection = showConfigSection;
@@ -57,6 +57,29 @@ body {
57
57
  /* Log accordion customization */
58
58
  .log-accordion-button {
59
59
  border-bottom: 1px solid #444 !important;
60
+ position: relative;
61
+ }
62
+
63
+ .accordion-social-links {
64
+ position: absolute;
65
+ right: 50px; /* Position to the left of the accordion arrow */
66
+ top: 50%;
67
+ transform: translateY(-50%);
68
+ display: flex;
69
+ gap: 12px;
70
+ align-items: center;
71
+ }
72
+
73
+ .social-link {
74
+ color: #a0aec0;
75
+ transition: color 0.2s ease;
76
+ display: flex;
77
+ align-items: center;
78
+ text-decoration: none;
79
+ }
80
+
81
+ .social-link:hover {
82
+ color: #e2e8f0;
60
83
  }
61
84
 
62
85
  .log-accordion-body {
@@ -72,6 +95,15 @@ body {
72
95
  .log-latest-line {
73
96
  font-family: monospace;
74
97
  color: #e2e8f0;
98
+ display: block;
99
+ overflow: hidden;
100
+ text-overflow: ellipsis;
101
+ display: -webkit-box;
102
+ -webkit-line-clamp: 2;
103
+ -webkit-box-orient: vertical;
104
+ line-height: 1.4em;
105
+ max-height: 2.8em;
106
+ padding-right: 130px; /* Make room for 3 social icons and accordion arrow */
75
107
  }
76
108
 
77
109
  /* Task display */