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.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * CitraScope Formatter Utilities
3
+ *
4
+ * Shared formatting functions for the dashboard UI.
5
+ * These functions are exposed in the Alpine.js store for use in templates.
6
+ */
7
+
8
+ /**
9
+ * Strip ANSI color codes from text
10
+ * @param {string} text - Text containing ANSI codes
11
+ * @returns {string} Text with ANSI codes removed
12
+ */
13
+ export function stripAnsiCodes(text) {
14
+ const esc = String.fromCharCode(27);
15
+ return text.replace(new RegExp(esc + '\\[\\d+m', 'g'), '').replace(/\[\d+m/g, '');
16
+ }
17
+
18
+ /**
19
+ * Format ISO date string to local time
20
+ * @param {string} isoString - ISO 8601 date string
21
+ * @returns {string} Formatted local time string
22
+ */
23
+ export function formatLocalTime(isoString) {
24
+ const date = new Date(isoString);
25
+ return date.toLocaleString(undefined, {
26
+ month: 'short',
27
+ day: 'numeric',
28
+ hour: '2-digit',
29
+ minute: '2-digit',
30
+ second: '2-digit',
31
+ hour12: true
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Format milliseconds as countdown string
37
+ * @param {number} milliseconds - Time in milliseconds
38
+ * @returns {string} Formatted countdown string (e.g., "2h 30m 15s")
39
+ */
40
+ export function formatCountdown(milliseconds) {
41
+ const totalSeconds = Math.floor(milliseconds / 1000);
42
+ if (totalSeconds < 0) return 'Starting soon...';
43
+ const hours = Math.floor(totalSeconds / 3600);
44
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
45
+ const seconds = totalSeconds % 60;
46
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
47
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
48
+ return `${seconds}s`;
49
+ }
50
+
51
+ /**
52
+ * Format elapsed time for "X ago" display
53
+ * @param {number} milliseconds - Elapsed time in milliseconds
54
+ * @returns {string} Human-readable elapsed time (e.g., "2 hours ago")
55
+ */
56
+ export function formatElapsedTime(milliseconds) {
57
+ const seconds = Math.floor(milliseconds / 1000);
58
+ const minutes = Math.floor(seconds / 60);
59
+ const hours = Math.floor(minutes / 60);
60
+ const days = Math.floor(hours / 24);
61
+ if (days > 0) return `${days} day${days !== 1 ? 's' : ''} ago`;
62
+ if (hours > 0) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
63
+ if (minutes > 0) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
64
+ return 'just now';
65
+ }
66
+
67
+ /**
68
+ * Format minutes as "Xh Ym" display
69
+ * @param {number} minutes - Time in minutes
70
+ * @returns {string} Formatted time string (e.g., "2h 30m")
71
+ */
72
+ export function formatMinutes(minutes) {
73
+ const hours = Math.floor(minutes / 60);
74
+ const mins = Math.floor(minutes % 60);
75
+ if (hours > 0) return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
76
+ return `${mins}m`;
77
+ }
78
+
79
+ /**
80
+ * Format last autofocus timestamp
81
+ * @param {Object} status - Status object containing last_autofocus_timestamp
82
+ * @returns {string} Formatted autofocus time or "Never"
83
+ */
84
+ export function formatLastAutofocus(status) {
85
+ if (!status || !status.last_autofocus_timestamp) return 'Never';
86
+ const elapsed = Date.now() - status.last_autofocus_timestamp * 1000;
87
+ return formatElapsedTime(elapsed);
88
+ }
89
+
90
+ /**
91
+ * Format time offset with source information - compact format for status pill
92
+ * @param {Object} timeHealth - Time health object with offset_ms, source, and optional metadata
93
+ * @returns {string} Formatted time offset (e.g., "17ns, 10 sats" or "+2ms, ntp")
94
+ */
95
+ export function formatTimeOffset(timeHealth) {
96
+ if (!timeHealth || timeHealth.offset_ms == null) return 'Unknown';
97
+
98
+ const o = timeHealth.offset_ms;
99
+ const abs = Math.abs(o);
100
+ const s = o >= 0 ? '+' : '';
101
+
102
+ // Format offset with appropriate units
103
+ let offsetStr;
104
+ if (abs < 0.001) {
105
+ // Sub-microsecond: show as nanoseconds
106
+ offsetStr = `${s}${Math.round(abs * 1000000)}ns`;
107
+ } else if (abs < 1) {
108
+ // Sub-millisecond: show as microseconds
109
+ offsetStr = `${s}${Math.round(abs * 1000)}µs`;
110
+ } else if (abs < 1000) {
111
+ // Milliseconds
112
+ offsetStr = `${s}${abs.toFixed(0)}ms`;
113
+ } else {
114
+ // Seconds
115
+ offsetStr = `${s}${(abs / 1000).toFixed(1)}s`;
116
+ }
117
+
118
+ // Add source/satellite info
119
+ if (timeHealth.source === 'gps' && timeHealth.metadata?.satellites != null) {
120
+ // GPS with satellite count
121
+ return `${offsetStr}, ${timeHealth.metadata.satellites} sats`;
122
+ } else if (timeHealth.source && timeHealth.source !== 'unknown') {
123
+ // Other sources (ntp, chrony)
124
+ return `${offsetStr}, ${timeHealth.source}`;
125
+ } else {
126
+ // No source info
127
+ return offsetStr;
128
+ }
129
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * CitraScope Alpine store - must register BEFORE Alpine starts.
3
+ * Load this script before Alpine.js so the alpine:init listener is attached in time.
4
+ */
5
+ import * as formatters from './formatters.js';
6
+ import * as components from './components.js';
7
+
8
+ (() => {
9
+ document.addEventListener('alpine:init', () => {
10
+ // Register Alpine components FIRST (before Alpine starts processing the DOM)
11
+ window.Alpine.data('adapterField', components.adapterField);
12
+ window.Alpine.data('taskRow', components.taskRow);
13
+ window.Alpine.data('filterRow', components.filterRow);
14
+ window.Alpine.data('logEntry', components.logEntry);
15
+
16
+ // Register store
17
+ window.Alpine.store('citrascope', {
18
+ status: {},
19
+ tasks: [],
20
+ logs: [],
21
+ latestLog: null,
22
+ wsConnected: false,
23
+ wsReconnecting: false,
24
+ currentTaskId: null,
25
+ isTaskActive: false,
26
+ nextTaskStartTime: null,
27
+ countdown: '',
28
+ config: {},
29
+ apiEndpoint: 'production',
30
+ hardwareAdapters: [], // [{value, label}]
31
+ filters: {},
32
+ savedAdapter: null,
33
+ enabledFilters: [],
34
+ filterConfigVisible: false,
35
+ filterAdapterChangeMessageVisible: false,
36
+ currentSection: 'monitoring',
37
+ version: '',
38
+ updateIndicator: '',
39
+ versionCheckState: 'idle',
40
+ versionCheckResult: null,
41
+
42
+ // Loading states for async operations
43
+ isSavingConfig: false,
44
+ isCapturing: false,
45
+ isAutofocusing: false,
46
+ captureResult: null,
47
+ exposureDuration: 0.1,
48
+
49
+ // Spread all formatter functions from shared module
50
+ ...formatters,
51
+
52
+ // Unified adapter fields (schema + values merged)
53
+ adapterFields: [],
54
+
55
+ // Computed property: Group adapter fields by their group property
56
+ get groupedAdapterFields() {
57
+ const grouped = {};
58
+ this.adapterFields.forEach(f => {
59
+ const g = f.group || 'General';
60
+ if (!grouped[g]) grouped[g] = [];
61
+ grouped[g].push(f);
62
+ });
63
+ return Object.entries(grouped);
64
+ },
65
+
66
+ // Store methods
67
+ async captureImage() {
68
+ if (Number.isNaN(this.exposureDuration) || this.exposureDuration <= 0) {
69
+ // Import createToast from config.js
70
+ const { createToast } = await import('./config.js');
71
+ createToast('Invalid exposure duration', 'danger', false);
72
+ return;
73
+ }
74
+
75
+ this.isCapturing = true;
76
+ try {
77
+ const response = await fetch('/api/camera/capture', {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ duration: this.exposureDuration })
81
+ });
82
+ const data = await response.json();
83
+
84
+ if (response.ok && data.success) {
85
+ this.captureResult = data;
86
+ const { createToast } = await import('./config.js');
87
+ createToast('Image captured successfully', 'success', true);
88
+ } else {
89
+ const { createToast } = await import('./config.js');
90
+ createToast(data.error || 'Failed to capture image', 'danger', false);
91
+ }
92
+ } catch (error) {
93
+ console.error('Capture error:', error);
94
+ const { createToast } = await import('./config.js');
95
+ createToast('Failed to capture image: ' + error.message, 'danger', false);
96
+ } finally {
97
+ this.isCapturing = false;
98
+ }
99
+ },
100
+
101
+ async toggleProcessing(enabled) {
102
+ const endpoint = enabled ? '/api/tasks/resume' : '/api/tasks/pause';
103
+ try {
104
+ const response = await fetch(endpoint, { method: 'POST' });
105
+ const result = await response.json();
106
+ if (!response.ok) {
107
+ alert(result.error || 'Failed to toggle task processing');
108
+ // Revert on error
109
+ this.status.processing_active = !enabled;
110
+ }
111
+ } catch (error) {
112
+ console.error('Error toggling processing:', error);
113
+ alert('Error toggling task processing');
114
+ this.status.processing_active = !enabled;
115
+ }
116
+ },
117
+
118
+ async toggleAutomatedScheduling(enabled) {
119
+ try {
120
+ const response = await fetch('/api/telescope/automated-scheduling', {
121
+ method: 'PATCH',
122
+ headers: { 'Content-Type': 'application/json' },
123
+ body: JSON.stringify({ enabled: enabled })
124
+ });
125
+ const result = await response.json();
126
+ if (!response.ok) {
127
+ alert(result.error || 'Failed to toggle automated scheduling');
128
+ // Revert on error
129
+ this.status.automated_scheduling = !enabled;
130
+ }
131
+ } catch (error) {
132
+ console.error('Error toggling automated scheduling:', error);
133
+ alert('Error toggling automated scheduling');
134
+ this.status.automated_scheduling = !enabled;
135
+ }
136
+ },
137
+
138
+ showCameraControl() {
139
+ this.captureResult = null; // Reset capture result when opening modal
140
+ const modal = new bootstrap.Modal(document.getElementById('cameraControlModal'));
141
+ modal.show();
142
+ },
143
+
144
+ async showVersionModal() {
145
+ this.versionCheckState = 'loading';
146
+ this.versionCheckResult = null;
147
+
148
+ const modal = new bootstrap.Modal(document.getElementById('versionModal'));
149
+ modal.show();
150
+
151
+ // Check for updates (inline implementation)
152
+ try {
153
+ const versionResponse = await fetch('/api/version');
154
+ const versionData = await versionResponse.json();
155
+ const currentVersion = versionData.version;
156
+
157
+ const githubResponse = await fetch('https://api.github.com/repos/citra-space/citrascope/releases/latest');
158
+ if (!githubResponse.ok) {
159
+ this.versionCheckState = 'error';
160
+ this.versionCheckResult = { status: 'error', currentVersion };
161
+ return;
162
+ }
163
+
164
+ const releaseData = await githubResponse.json();
165
+ const latestVersion = releaseData.tag_name.replace(/^v/, '');
166
+ const releaseUrl = releaseData.html_url;
167
+
168
+ if (currentVersion === 'development' || currentVersion === 'unknown') {
169
+ this.updateIndicator = '';
170
+ this.versionCheckState = 'up-to-date';
171
+ this.versionCheckResult = { status: 'up-to-date', currentVersion };
172
+ return;
173
+ }
174
+
175
+ // Compare versions
176
+ const v1 = latestVersion.split('.').map(n => parseInt(n) || 0);
177
+ const v2 = currentVersion.split('.').map(n => parseInt(n) || 0);
178
+ const maxLen = Math.max(v1.length, v2.length);
179
+ let comparison = 0;
180
+ for (let i = 0; i < maxLen; i++) {
181
+ const num1 = v1[i] || 0;
182
+ const num2 = v2[i] || 0;
183
+ if (num1 > num2) { comparison = 1; break; }
184
+ if (num1 < num2) { comparison = -1; break; }
185
+ }
186
+
187
+ if (comparison > 0) {
188
+ this.updateIndicator = `${latestVersion} Available!`;
189
+ this.versionCheckState = 'update-available';
190
+ this.versionCheckResult = { status: 'update-available', currentVersion, latestVersion, releaseUrl };
191
+ } else {
192
+ this.updateIndicator = '';
193
+ this.versionCheckState = 'up-to-date';
194
+ this.versionCheckResult = { status: 'up-to-date', currentVersion };
195
+ }
196
+ } catch (error) {
197
+ console.debug('Update check failed:', error);
198
+ this.versionCheckState = 'error';
199
+ this.versionCheckResult = { status: 'error', currentVersion: 'unknown' };
200
+ }
201
+ },
202
+
203
+ showConfigSection() {
204
+ // Close setup wizard modal
205
+ const wizardModal = bootstrap.Modal.getInstance(document.getElementById('setupWizard'));
206
+ if (wizardModal) {
207
+ wizardModal.hide();
208
+ }
209
+
210
+ // Navigate to config section
211
+ this.currentSection = 'config';
212
+ window.location.hash = 'config';
213
+ }
214
+ });
215
+ });
216
+ })();
@@ -2,6 +2,11 @@
2
2
  * Bootstrap 5 handles most styling; these are overrides and additions only
3
3
  */
4
4
 
5
+ /* Alpine.js: hide elements until Alpine has processed them */
6
+ [x-cloak] {
7
+ display: none !important;
8
+ }
9
+
5
10
  /* Log terminal container - custom scrollbar and background watermark */
6
11
  .log-container {
7
12
  background: #0d1117 url('/static/img/citra.png') no-repeat 85% center;
@@ -0,0 +1,175 @@
1
+ <div class="container my-4" id="configSection" x-show="$store.citrascope.currentSection === 'config'" x-cloak>
2
+ <form @submit.prevent="window.saveConfiguration($event)">
3
+ <div class="row g-3 mb-3">
4
+ <!-- API Configuration Card -->
5
+ <div class="col-12" x-data="{ apiCardExpanded: $persist(true).as('apiCard') }">
6
+ <div class="card bg-dark text-light border-secondary">
7
+ <div class="card-header d-flex justify-content-between align-items-center" style="cursor: pointer;" @click="apiCardExpanded = !apiCardExpanded">
8
+ <div class="d-flex align-items-center gap-2">
9
+ <i class="bi bi-cloud-arrow-up"></i>
10
+ <h5 class="mb-0">API Configuration</h5>
11
+ <small class="text-muted" x-show="!apiCardExpanded" x-text="$store.citrascope.apiEndpoint === 'production' ? 'Production' : ($store.citrascope.apiEndpoint === 'development' ? 'Development' : 'Custom')"></small>
12
+ </div>
13
+ <i class="bi" :class="apiCardExpanded ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
14
+ </div>
15
+ <div class="card-body" x-show="apiCardExpanded">
16
+ <div class="row g-3">
17
+ <div class="col-12">
18
+ <label for="apiEndpoint" class="form-label">API Endpoint</label>
19
+ <select id="apiEndpoint" class="form-select" x-model="$store.citrascope.apiEndpoint">
20
+ <option value="production">Production (api.citra.space)</option>
21
+ <option value="development">Development (dev.api.citra.space)</option>
22
+ <option value="custom">Custom</option>
23
+ </select>
24
+ </div>
25
+ <div class="col-12" id="customHostContainer" x-show="$store.citrascope.apiEndpoint === 'custom'">
26
+ <label for="customHost" class="form-label">Custom API Host</label>
27
+ <input type="text" id="customHost" class="form-control" placeholder="api.example.com" x-model="$store.citrascope.config.host">
28
+ <div class="row g-2 mt-2">
29
+ <div class="col-6">
30
+ <label for="customPort" class="form-label small">Port</label>
31
+ <input type="number" id="customPort" class="form-control" placeholder="443" x-model.number="$store.citrascope.config.port">
32
+ </div>
33
+ <div class="col-6 d-flex align-items-end">
34
+ <div class="form-check">
35
+ <input class="form-check-input" type="checkbox" id="customUseSsl" x-model="$store.citrascope.config.use_ssl">
36
+ <label class="form-check-label" for="customUseSsl">
37
+ Use SSL
38
+ </label>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ <div class="col-12 col-md-6">
44
+ <label for="personal_access_token" class="form-label">Personal Access Token <span class="text-danger">*</span></label>
45
+ <input type="password" id="personal_access_token" class="form-control" placeholder="Enter your Citra API token" required x-model="$store.citrascope.config.personal_access_token">
46
+ <small class="text-muted">Get your token and telescope ID from <a :href="$store.citrascope.config.app_url || ''" target="_blank" x-text="($store.citrascope.config.app_url || '').replace('https://', '')"></a></small>
47
+ </div>
48
+ <div class="col-12 col-md-6">
49
+ <label for="telescopeId" class="form-label">Telescope ID <span class="text-danger">*</span></label>
50
+ <input type="text" id="telescopeId" class="form-control" placeholder="Enter telescope ID" required x-model="$store.citrascope.config.telescope_id">
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+
58
+ {% include '_config_hardware.html' %}
59
+
60
+
61
+ <!-- Time Synchronization Settings Card -->
62
+ <div class="col-12" x-data="{ timeCardExpanded: $persist(true).as('timeCard') }">
63
+ <div class="card bg-dark text-light border-secondary">
64
+ <div class="card-header d-flex justify-content-between align-items-center" style="cursor: pointer;" @click="timeCardExpanded = !timeCardExpanded">
65
+ <div class="d-flex align-items-center gap-2">
66
+ <i class="bi bi-clock-history"></i>
67
+ <h5 class="mb-0">Time Synchronization</h5>
68
+ <small class="text-muted" x-show="!timeCardExpanded" x-text="'Check every ' + ($store.citrascope.config.time_check_interval_minutes || 5) + 'min • ' + ($store.citrascope.config.time_offset_pause_ms || 500) + 'ms threshold'"></small>
69
+ </div>
70
+ <i class="bi" :class="timeCardExpanded ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
71
+ </div>
72
+ <div class="card-body" x-show="timeCardExpanded">
73
+ <div class="row g-3">
74
+ <div class="col-12 col-md-6">
75
+ <label for="time_offset_pause_ms" class="form-label">Pause Threshold (ms)</label>
76
+ <input type="number" class="form-control form-control-sm" id="time_offset_pause_ms" min="1" max="10000" step="1" x-model.number="$store.citrascope.config.time_offset_pause_ms">
77
+ <small class="text-muted">Clock drift that triggers task pause</small>
78
+ </div>
79
+ <div class="col-12 col-md-6">
80
+ <label for="time_check_interval_minutes" class="form-label">Check Interval (minutes)</label>
81
+ <input type="number" class="form-control form-control-sm" id="time_check_interval_minutes" min="1" max="60" step="1" x-model.number="$store.citrascope.config.time_check_interval_minutes">
82
+ <small class="text-muted">How often to check time synchronization</small>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <!-- Logging Settings Card -->
90
+ <div class="col-12" x-data="{ loggingCardExpanded: $persist(true).as('loggingCard') }">
91
+ <div class="card bg-dark text-light border-secondary">
92
+ <div class="card-header d-flex justify-content-between align-items-center" style="cursor: pointer;" @click="loggingCardExpanded = !loggingCardExpanded">
93
+ <div class="d-flex align-items-center gap-2">
94
+ <i class="bi bi-file-earmark-text"></i>
95
+ <h5 class="mb-0">Logging Settings</h5>
96
+ <small class="text-muted" x-show="!loggingCardExpanded" x-text="($store.citrascope.config.log_level || 'INFO') + ($store.citrascope.config.file_logging_enabled !== false ? ' • File logging' : ' • Console only')"></small>
97
+ </div>
98
+ <i class="bi" :class="loggingCardExpanded ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
99
+ </div>
100
+ <div class="card-body" x-show="loggingCardExpanded">
101
+ <div class="row g-3">
102
+ <div class="col-12 col-md-6">
103
+ <label for="logLevel" class="form-label">Log Level</label>
104
+ <select id="logLevel" class="form-select" x-model="$store.citrascope.config.log_level">
105
+ <option value="DEBUG">DEBUG</option>
106
+ <option value="INFO">INFO</option>
107
+ <option value="WARNING">WARNING</option>
108
+ <option value="ERROR">ERROR</option>
109
+ </select>
110
+ </div>
111
+ <div class="col-12 col-md-6">
112
+ <div class="form-check mt-4">
113
+ <input class="form-check-input" type="checkbox" id="file_logging_enabled" x-model="$store.citrascope.config.file_logging_enabled">
114
+ <label class="form-check-label" for="file_logging_enabled">
115
+ Enable file logging
116
+ </label>
117
+ </div>
118
+ </div>
119
+ <div class="col-12">
120
+ <small class="text-muted">
121
+ Log file: <span class="text-secondary" x-text="$store.citrascope.config.log_file_path || 'Disabled'">Loading...</span>
122
+ </small>
123
+ </div>
124
+ </div>
125
+ </div>
126
+ </div>
127
+ </div>
128
+
129
+ <!-- Task Settings Card -->
130
+ <div class="col-12" x-data="{ taskCardExpanded: $persist(true).as('taskCard') }">
131
+ <div class="card bg-dark text-light border-secondary">
132
+ <div class="card-header d-flex justify-content-between align-items-center" style="cursor: pointer;" @click="taskCardExpanded = !taskCardExpanded">
133
+ <div class="d-flex align-items-center gap-2">
134
+ <i class="bi bi-gear"></i>
135
+ <h5 class="mb-0">Task Settings</h5>
136
+ <small class="text-muted" x-show="!taskCardExpanded" x-text="$store.citrascope.config.keep_images ? 'Keep images' : 'Delete images after upload'"></small>
137
+ </div>
138
+ <i class="bi" :class="taskCardExpanded ? 'bi-chevron-up' : 'bi-chevron-down'"></i>
139
+ </div>
140
+ <div class="card-body" x-show="taskCardExpanded">
141
+ <div class="row g-3">
142
+ <div class="col-12 col-md-6">
143
+ <div class="form-check mt-2">
144
+ <input class="form-check-input" type="checkbox" id="keep_images" x-model="$store.citrascope.config.keep_images">
145
+ <label class="form-check-label" for="keep_images">
146
+ Keep captured images
147
+ </label>
148
+ </div>
149
+ <small class="text-muted ms-4">By default, images are deleted after upload unless this is enabled</small>
150
+ </div>
151
+ <div class="col-12">
152
+ <small class="text-muted">
153
+ Images directory: <span class="text-secondary" x-text="$store.citrascope.config.images_dir_path || 'Loading...'">Loading...</span>
154
+ </small>
155
+ </div>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>
161
+
162
+ <!-- Save Button -->
163
+ <div class="row">
164
+ <div class="col">
165
+ <button type="submit" class="btn btn-primary" :disabled="$store.citrascope.isSavingConfig">
166
+ <span x-text="$store.citrascope.isSavingConfig ? 'Saving...' : 'Save Configuration'"></span>
167
+ <span x-show="$store.citrascope.isSavingConfig" class="spinner-border spinner-border-sm ms-2" role="status"></span>
168
+ </button>
169
+ <small class="text-muted ms-3">
170
+ Config file: <span class="text-secondary" x-text="$store.citrascope.config.config_file_path || 'Loading...'">Loading...</span>
171
+ </small>
172
+ </div>
173
+ </div>
174
+ </form>
175
+ </div>