citrascope 0.1.0__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. citrascope/__main__.py +13 -13
  2. citrascope/api/abstract_api_client.py +7 -0
  3. citrascope/api/citra_api_client.py +43 -2
  4. citrascope/citra_scope_daemon.py +205 -61
  5. citrascope/constants.py +23 -0
  6. citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
  7. citrascope/hardware/adapter_registry.py +94 -0
  8. citrascope/hardware/indi_adapter.py +456 -16
  9. citrascope/hardware/kstars_dbus_adapter.py +179 -0
  10. citrascope/hardware/nina_adv_http_adapter.py +593 -0
  11. citrascope/hardware/nina_adv_http_survey_template.json +328 -0
  12. citrascope/logging/__init__.py +2 -1
  13. citrascope/logging/_citrascope_logger.py +80 -1
  14. citrascope/logging/web_log_handler.py +75 -0
  15. citrascope/settings/citrascope_settings.py +140 -0
  16. citrascope/settings/settings_file_manager.py +126 -0
  17. citrascope/tasks/runner.py +129 -29
  18. citrascope/tasks/scope/base_telescope_task.py +25 -10
  19. citrascope/tasks/scope/static_telescope_task.py +11 -3
  20. citrascope/web/__init__.py +1 -0
  21. citrascope/web/app.py +479 -0
  22. citrascope/web/server.py +132 -0
  23. citrascope/web/static/api.js +82 -0
  24. citrascope/web/static/app.js +502 -0
  25. citrascope/web/static/config.js +438 -0
  26. citrascope/web/static/img/citra.png +0 -0
  27. citrascope/web/static/img/favicon.png +0 -0
  28. citrascope/web/static/style.css +152 -0
  29. citrascope/web/static/websocket.js +127 -0
  30. citrascope/web/templates/dashboard.html +407 -0
  31. {citrascope-0.1.0.dist-info → citrascope-0.4.0.dist-info}/METADATA +87 -47
  32. citrascope-0.4.0.dist-info/RECORD +38 -0
  33. {citrascope-0.1.0.dist-info → citrascope-0.4.0.dist-info}/WHEEL +1 -1
  34. citrascope/settings/_citrascope_settings.py +0 -42
  35. citrascope-0.1.0.dist-info/RECORD +0 -21
  36. docs/index.md +0 -47
  37. {citrascope-0.1.0.dist-info → citrascope-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,438 @@
1
+ // Configuration management for CitraScope
2
+
3
+ import { getConfig, saveConfig, getConfigStatus, getHardwareAdapters, getAdapterSchema } from './api.js';
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
+
10
+ let currentAdapterSchema = [];
11
+ export let currentConfig = {};
12
+
13
+ /**
14
+ * Initialize configuration management
15
+ */
16
+ async function fetchVersion() {
17
+ try {
18
+ const response = await fetch('/api/version');
19
+ const data = await response.json();
20
+ const versionEl = document.getElementById('citraScopeVersion');
21
+ if (versionEl && data.version) {
22
+ versionEl.textContent = data.version;
23
+ }
24
+ } catch (error) {
25
+ console.error('Error fetching version:', error);
26
+ const versionEl = document.getElementById('citraScopeVersion');
27
+ if (versionEl) {
28
+ versionEl.textContent = 'unknown';
29
+ }
30
+ }
31
+ }
32
+
33
+ export async function initConfig() {
34
+ // Populate hardware adapter dropdown
35
+ await loadAdapterOptions();
36
+
37
+ // Hardware adapter selection change
38
+ const adapterSelect = document.getElementById('hardwareAdapterSelect');
39
+ if (adapterSelect) {
40
+ adapterSelect.addEventListener('change', async function(e) {
41
+ const adapter = e.target.value;
42
+ if (adapter) {
43
+ await loadAdapterSchema(adapter);
44
+ } else {
45
+ document.getElementById('adapter-settings-container').innerHTML = '';
46
+ }
47
+ });
48
+ }
49
+
50
+ // API endpoint selection change
51
+ const apiEndpointSelect = document.getElementById('apiEndpoint');
52
+ if (apiEndpointSelect) {
53
+ apiEndpointSelect.addEventListener('change', function(e) {
54
+ const customContainer = document.getElementById('customHostContainer');
55
+ if (e.target.value === 'custom') {
56
+ customContainer.style.display = 'block';
57
+ } else {
58
+ customContainer.style.display = 'none';
59
+ }
60
+ });
61
+ }
62
+
63
+ // Config form submission
64
+ const configForm = document.getElementById('configForm');
65
+ if (configForm) {
66
+ configForm.addEventListener('submit', saveConfiguration);
67
+ }
68
+
69
+ // Load initial config
70
+ await loadConfiguration();
71
+ checkConfigStatus();
72
+ fetchVersion();
73
+ }
74
+
75
+ /**
76
+ * Check if configuration is needed and show setup wizard if not configured
77
+ */
78
+ async function checkConfigStatus() {
79
+ try {
80
+ const status = await getConfigStatus();
81
+
82
+ if (!status.configured) {
83
+ // Show setup wizard if not configured
84
+ const wizardModal = new bootstrap.Modal(document.getElementById('setupWizard'));
85
+ wizardModal.show();
86
+ }
87
+
88
+ if (status.error) {
89
+ showConfigError(status.error);
90
+ }
91
+ } catch (error) {
92
+ console.error('Failed to check config status:', error);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Load available hardware adapters and populate dropdown
98
+ */
99
+ async function loadAdapterOptions() {
100
+ try {
101
+ const data = await getHardwareAdapters();
102
+ const adapterSelect = document.getElementById('hardwareAdapterSelect');
103
+
104
+ if (adapterSelect && data.adapters) {
105
+ // Clear existing options except the first placeholder
106
+ while (adapterSelect.options.length > 1) {
107
+ adapterSelect.remove(1);
108
+ }
109
+
110
+ // Add options from API
111
+ data.adapters.forEach(adapterName => {
112
+ const option = document.createElement('option');
113
+ option.value = adapterName;
114
+ option.textContent = data.descriptions[adapterName] || adapterName;
115
+ adapterSelect.appendChild(option);
116
+ });
117
+ }
118
+ } catch (error) {
119
+ console.error('Failed to load hardware adapters:', error);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Load configuration from API and populate form
125
+ */
126
+ async function loadConfiguration() {
127
+ try {
128
+ const config = await getConfig();
129
+ currentConfig = config; // Save for reuse when saving
130
+
131
+ // Display config file path
132
+ const configPathElement = document.getElementById('configFilePath');
133
+ if (configPathElement && config.config_file_path) {
134
+ configPathElement.textContent = config.config_file_path;
135
+ }
136
+
137
+ // Display log file path
138
+ const logPathElement = document.getElementById('logFilePath');
139
+ if (logPathElement) {
140
+ if (config.log_file_path) {
141
+ logPathElement.textContent = config.log_file_path;
142
+ } else {
143
+ logPathElement.textContent = 'Disabled';
144
+ }
145
+ }
146
+
147
+ // Display images directory path
148
+ const imagesDirElement = document.getElementById('imagesDirPath');
149
+ if (imagesDirElement && config.images_dir_path) {
150
+ imagesDirElement.textContent = config.images_dir_path;
151
+ }
152
+
153
+ // API endpoint selector
154
+ const apiEndpointSelect = document.getElementById('apiEndpoint');
155
+ const customHostContainer = document.getElementById('customHostContainer');
156
+ const customHost = document.getElementById('customHost');
157
+ const customPort = document.getElementById('customPort');
158
+ const customUseSsl = document.getElementById('customUseSsl');
159
+
160
+ if (config.host === PROD_API_HOST) {
161
+ apiEndpointSelect.value = 'production';
162
+ customHostContainer.style.display = 'none';
163
+ } else if (config.host === DEV_API_HOST) {
164
+ apiEndpointSelect.value = 'development';
165
+ customHostContainer.style.display = 'none';
166
+ } else {
167
+ apiEndpointSelect.value = 'custom';
168
+ customHostContainer.style.display = 'block';
169
+ customHost.value = config.host || '';
170
+ customPort.value = config.port || DEFAULT_API_PORT;
171
+ customUseSsl.checked = config.use_ssl !== undefined ? config.use_ssl : true;
172
+ }
173
+
174
+ // Core fields
175
+ document.getElementById('personal_access_token').value = config.personal_access_token || '';
176
+ document.getElementById('telescopeId').value = config.telescope_id || '';
177
+ document.getElementById('hardwareAdapterSelect').value = config.hardware_adapter || '';
178
+ document.getElementById('logLevel').value = config.log_level || 'INFO';
179
+ document.getElementById('keep_images').checked = config.keep_images || false;
180
+ document.getElementById('file_logging_enabled').checked = config.file_logging_enabled !== undefined ? config.file_logging_enabled : true;
181
+
182
+ // Load adapter-specific settings if adapter is selected
183
+ if (config.hardware_adapter) {
184
+ await loadAdapterSchema(config.hardware_adapter);
185
+ populateAdapterSettings(config.adapter_settings || {});
186
+ }
187
+ } catch (error) {
188
+ console.error('Failed to load config:', error);
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Load adapter schema and render settings form
194
+ */
195
+ async function loadAdapterSchema(adapterName) {
196
+ try {
197
+ const data = await getAdapterSchema(adapterName);
198
+ currentAdapterSchema = data.schema || [];
199
+ renderAdapterSettings(currentAdapterSchema);
200
+ } catch (error) {
201
+ console.error('Failed to load adapter schema:', error);
202
+ showConfigError(`Failed to load settings for ${adapterName}`);
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Render adapter-specific settings form
208
+ */
209
+ function renderAdapterSettings(schema) {
210
+ const container = document.getElementById('adapter-settings-container');
211
+
212
+ if (!schema || schema.length === 0) {
213
+ container.innerHTML = '';
214
+ return;
215
+ }
216
+
217
+ let html = '<h5 class="mb-3">Adapter Settings</h5><div class="row g-3 mb-4">';
218
+
219
+ schema.forEach(field => {
220
+ const isRequired = field.required ? '<span class="text-danger">*</span>' : '';
221
+ const placeholder = field.placeholder || '';
222
+ const description = field.description || '';
223
+ const displayName = field.friendly_name || field.name;
224
+
225
+ html += '<div class="col-12 col-md-6">';
226
+ html += `<label for="adapter_${field.name}" class="form-label">${displayName} ${isRequired}</label>`;
227
+
228
+ if (field.type === 'bool') {
229
+ html += `<div class="form-check mt-2">`;
230
+ html += `<input class="form-check-input adapter-setting" type="checkbox" id="adapter_${field.name}" data-field="${field.name}" data-type="${field.type}">`;
231
+ html += `<label class="form-check-label" for="adapter_${field.name}">${description}</label>`;
232
+ html += `</div>`;
233
+ } else if (field.options && field.options.length > 0) {
234
+ const displayName = field.friendly_name || field.name;
235
+ html += `<select id="adapter_${field.name}" class="form-select adapter-setting" data-field="${field.name}" data-type="${field.type}" ${field.required ? 'required' : ''}>`;
236
+ html += `<option value="">-- Select ${displayName} --</option>`;
237
+ field.options.forEach(opt => {
238
+ html += `<option value="${opt}">${opt}</option>`;
239
+ });
240
+ html += `</select>`;
241
+ } else if (field.type === 'int' || field.type === 'float') {
242
+ const min = field.min !== undefined ? `min="${field.min}"` : '';
243
+ const max = field.max !== undefined ? `max="${field.max}"` : '';
244
+ html += `<input type="number" id="adapter_${field.name}" class="form-control adapter-setting" `;
245
+ html += `data-field="${field.name}" data-type="${field.type}" `;
246
+ html += `placeholder="${placeholder}" ${min} ${max} ${field.required ? 'required' : ''}>`;
247
+ } else {
248
+ // Default to text input
249
+ const pattern = field.pattern ? `pattern="${field.pattern}"` : '';
250
+ html += `<input type="text" id="adapter_${field.name}" class="form-control adapter-setting" `;
251
+ html += `data-field="${field.name}" data-type="${field.type}" `;
252
+ html += `placeholder="${placeholder}" ${pattern} ${field.required ? 'required' : ''}>`;
253
+ }
254
+
255
+ if (description && field.type !== 'bool') {
256
+ html += `<small class="text-muted">${description}</small>`;
257
+ }
258
+ html += '</div>';
259
+ });
260
+
261
+ html += '</div>';
262
+ container.innerHTML = html;
263
+ }
264
+
265
+ /**
266
+ * Populate adapter settings with values
267
+ */
268
+ function populateAdapterSettings(adapterSettings) {
269
+ Object.entries(adapterSettings).forEach(([key, value]) => {
270
+ const input = document.getElementById(`adapter_${key}`);
271
+ if (input) {
272
+ if (input.type === 'checkbox') {
273
+ input.checked = value;
274
+ } else {
275
+ input.value = value;
276
+ }
277
+ }
278
+ });
279
+ }
280
+
281
+ /**
282
+ * Collect adapter settings from form
283
+ */
284
+ function collectAdapterSettings() {
285
+ const settings = {};
286
+ const inputs = document.querySelectorAll('.adapter-setting');
287
+
288
+ inputs.forEach(input => {
289
+ const fieldName = input.dataset.field;
290
+ const fieldType = input.dataset.type;
291
+ let value;
292
+
293
+ if (input.type === 'checkbox') {
294
+ value = input.checked;
295
+ } else {
296
+ value = input.value;
297
+ }
298
+
299
+ // Type conversion
300
+ if (value !== '' && value !== null) {
301
+ if (fieldType === 'int') {
302
+ value = parseInt(value, 10);
303
+ } else if (fieldType === 'float') {
304
+ value = parseFloat(value);
305
+ } else if (fieldType === 'bool') {
306
+ // Already handled above
307
+ }
308
+ }
309
+
310
+ settings[fieldName] = value;
311
+ });
312
+
313
+ return settings;
314
+ }
315
+
316
+ /**
317
+ * Save configuration form handler
318
+ */
319
+ async function saveConfiguration(event) {
320
+ event.preventDefault();
321
+
322
+ const saveButton = document.getElementById('saveConfigButton');
323
+ const buttonText = document.getElementById('saveButtonText');
324
+ const spinner = document.getElementById('saveButtonSpinner');
325
+
326
+ // Show loading state
327
+ saveButton.disabled = true;
328
+ spinner.style.display = 'inline-block';
329
+ buttonText.textContent = 'Saving...';
330
+
331
+ // Hide previous messages
332
+ hideConfigMessages();
333
+
334
+ // Determine API host settings based on endpoint selection
335
+ const apiEndpoint = document.getElementById('apiEndpoint').value;
336
+ let host, port, use_ssl;
337
+
338
+ if (apiEndpoint === 'production') {
339
+ host = PROD_API_HOST;
340
+ port = DEFAULT_API_PORT;
341
+ use_ssl = true;
342
+ } else if (apiEndpoint === 'development') {
343
+ host = DEV_API_HOST;
344
+ port = DEFAULT_API_PORT;
345
+ use_ssl = true;
346
+ } else { // custom
347
+ host = document.getElementById('customHost').value;
348
+ port = parseInt(document.getElementById('customPort').value, 10);
349
+ use_ssl = document.getElementById('customUseSsl').checked;
350
+ }
351
+
352
+ const config = {
353
+ personal_access_token: document.getElementById('personal_access_token').value,
354
+ telescope_id: document.getElementById('telescopeId').value,
355
+ hardware_adapter: document.getElementById('hardwareAdapterSelect').value,
356
+ adapter_settings: collectAdapterSettings(),
357
+ log_level: document.getElementById('logLevel').value,
358
+ keep_images: document.getElementById('keep_images').checked,
359
+ file_logging_enabled: document.getElementById('file_logging_enabled').checked,
360
+ // API settings from endpoint selector
361
+ host: host,
362
+ port: port,
363
+ use_ssl: use_ssl,
364
+ // Preserve other settings from loaded config
365
+ max_task_retries: currentConfig.max_task_retries || 3,
366
+ initial_retry_delay_seconds: currentConfig.initial_retry_delay_seconds || 30,
367
+ max_retry_delay_seconds: currentConfig.max_retry_delay_seconds || 300,
368
+ log_retention_days: currentConfig.log_retention_days || 30,
369
+ };
370
+
371
+ try {
372
+ const result = await saveConfig(config);
373
+
374
+ if (result.ok) {
375
+ showConfigSuccess(result.data.message || 'Configuration saved and applied successfully!');
376
+ } else {
377
+ showConfigError(result.data.error || result.data.message || 'Failed to save configuration');
378
+ }
379
+ } catch (error) {
380
+ showConfigError('Failed to save configuration: ' + error.message);
381
+ } finally {
382
+ // Reset button state
383
+ saveButton.disabled = false;
384
+ spinner.style.display = 'none';
385
+ buttonText.textContent = 'Save Configuration';
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Show configuration error message
391
+ */
392
+ function showConfigError(message) {
393
+ const errorDiv = document.getElementById('configError');
394
+ errorDiv.textContent = message;
395
+ errorDiv.style.display = 'block';
396
+ }
397
+
398
+ /**
399
+ * Show configuration success message
400
+ */
401
+ function showConfigSuccess(message) {
402
+ const successDiv = document.getElementById('configSuccess');
403
+ successDiv.textContent = message;
404
+ successDiv.style.display = 'block';
405
+
406
+ // Auto-hide after 5 seconds
407
+ setTimeout(() => {
408
+ successDiv.style.display = 'none';
409
+ }, 5000);
410
+ }
411
+
412
+ /**
413
+ * Hide all configuration messages
414
+ */
415
+ function hideConfigMessages() {
416
+ document.getElementById('configError').style.display = 'none';
417
+ document.getElementById('configSuccess').style.display = 'none';
418
+ }
419
+
420
+ /**
421
+ * Show configuration section (called from setup wizard)
422
+ */
423
+ export function showConfigSection() {
424
+ // Close setup wizard modal
425
+ const wizardModal = bootstrap.Modal.getInstance(document.getElementById('setupWizard'));
426
+ if (wizardModal) {
427
+ wizardModal.hide();
428
+ }
429
+
430
+ // Show config section
431
+ const configLink = document.querySelector('a[data-section="config"]');
432
+ if (configLink) {
433
+ configLink.click();
434
+ }
435
+ }
436
+
437
+ // Make showConfigSection available globally for onclick handlers in HTML
438
+ window.showConfigSection = showConfigSection;
Binary file
Binary file
@@ -0,0 +1,152 @@
1
+ /* CitraScope Web Interface - Custom Styles
2
+ * Bootstrap 5 handles most styling; these are overrides and additions only
3
+ */
4
+
5
+ /* Log terminal container - custom scrollbar and background watermark */
6
+ .log-container {
7
+ background: #0d1117 url('/static/img/citra.png') no-repeat 85% center;
8
+ background-size: auto 70%;
9
+ background-blend-mode: soft-light;
10
+ opacity: 1;
11
+ font-family: monospace;
12
+ line-height: 1.5;
13
+ }
14
+
15
+ /* Dark scrollbar for log container */
16
+ .log-container::-webkit-scrollbar {
17
+ width: 12px;
18
+ }
19
+
20
+ .log-container::-webkit-scrollbar-track {
21
+ background: #0d1117;
22
+ border-radius: 5px;
23
+ }
24
+
25
+ .log-container::-webkit-scrollbar-thumb {
26
+ background: #30363d;
27
+ border-radius: 5px;
28
+ }
29
+
30
+ .log-container::-webkit-scrollbar-thumb:hover {
31
+ background: #484f58;
32
+ }
33
+
34
+ /* Body padding for fixed bottom log terminal */
35
+ body {
36
+ padding-bottom: 100px;
37
+ }
38
+
39
+ /* Logo styling */
40
+ .logo-img {
41
+ height: 1.5em;
42
+ width: auto;
43
+ vertical-align: middle;
44
+ display: inline-block;
45
+ }
46
+
47
+ /* Status badge container */
48
+ .status-badge-container {
49
+ gap: 0.5em;
50
+ }
51
+
52
+ /* Muted text color for placeholders */
53
+ .text-muted-dark {
54
+ color: #a0aec0;
55
+ }
56
+
57
+ /* Log accordion customization */
58
+ .log-accordion-button {
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;
83
+ }
84
+
85
+ .log-accordion-body {
86
+ max-height: 40vh;
87
+ overflow: hidden;
88
+ }
89
+
90
+ .log-container {
91
+ max-height: 40vh;
92
+ overflow-y: auto;
93
+ }
94
+
95
+ .log-latest-line {
96
+ font-family: monospace;
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 */
107
+ }
108
+
109
+ /* Task display */
110
+ .task-title {
111
+ font-size: 1.1em;
112
+ }
113
+
114
+ .no-task-message {
115
+ color: #a0aec0;
116
+ margin: 0;
117
+ }
118
+
119
+ /* Ground station link */
120
+ .ground-station-link {
121
+ color: #4299e1;
122
+ text-decoration: none;
123
+ }
124
+
125
+ .ground-station-link:hover {
126
+ color: #63b3ed;
127
+ text-decoration: underline;
128
+ }
129
+
130
+ /* Log entry components */
131
+ .log-entry {
132
+ margin-bottom: 4px;
133
+ }
134
+
135
+ .log-timestamp {
136
+ color: #a0aec0;
137
+ }
138
+
139
+ .log-level {
140
+ font-weight: bold;
141
+ margin: 0 8px;
142
+ }
143
+
144
+ .log-level-DEBUG { color: #a0aec0; }
145
+ .log-level-INFO { color: #48bb78; }
146
+ .log-level-WARNING { color: #f6ad55; }
147
+ .log-level-ERROR { color: #f56565; }
148
+ .log-level-CRITICAL { color: #c53030; }
149
+
150
+ .log-message {
151
+ color: #e2e8f0;
152
+ }