sfm-old-042 #6

Merged
serversdown merged 3 commits from sfm-old-042 into main 2026-01-12 11:22:08 -05:00
3 changed files with 926 additions and 28 deletions
Showing only changes of commit ee025f1f34 - Show all commits

View File

@@ -524,4 +524,6 @@ def health_check():
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001) import os
port = int(os.getenv("PORT", 8001))
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -4,14 +4,14 @@ services:
terra-view-prod: terra-view-prod:
build: . build: .
container_name: terra-view container_name: terra-view
ports: network_mode: host
- "8001:8001"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- ENVIRONMENT=production - ENVIRONMENT=production
- SLMM_BASE_URL=http://slmm:8100 - PORT=8001
- SLMM_BASE_URL=http://localhost:8100
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- slmm - slmm
@@ -26,19 +26,21 @@ services:
terra-view-dev: terra-view-dev:
build: . build: .
container_name: terra-view-dev container_name: terra-view-dev
ports: network_mode: host
- "1001:8001"
volumes: volumes:
- ./data-dev:/app/data - ./data-dev:/app/data
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- ENVIRONMENT=development - ENVIRONMENT=development
- SLMM_BASE_URL=http://slmm:8100 - PORT=1001
- SLMM_BASE_URL=http://localhost:8100
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- slmm - slmm
profiles:
- dev
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"] test: ["CMD", "curl", "-f", "http://localhost:1001/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -50,8 +52,7 @@ services:
context: ../../slmm context: ../../slmm
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: slmm container_name: slmm
ports: network_mode: host
- "8100:8100"
volumes: volumes:
- ../../slmm/data:/app/data - ../../slmm/data:/app/data
environment: environment:

View File

@@ -23,8 +23,20 @@
{% endif %} {% endif %}
</div> </div>
<!-- Measurement Status Badge --> <!-- Measurement Status Badge & Timer -->
<div> <div class="flex flex-col items-end gap-2">
<div class="flex items-center gap-3">
<!-- Timer Display -->
<div id="measurement-timer" class="px-4 py-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
<span id="timer-display" class="text-xl font-mono font-bold text-blue-600 dark:text-blue-400">00:00:00</span>
</div>
</div>
<!-- Status Badge -->
{% if is_measuring %} {% if is_measuring %}
<span class="px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded-lg font-medium flex items-center"> <span class="px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded-lg font-medium flex items-center">
<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span> <span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
@@ -37,6 +49,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
<!-- Control Buttons --> <!-- Control Buttons -->
<div class="flex gap-2 mb-6"> <div class="flex gap-2 mb-6">
@@ -209,6 +222,171 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Device Settings -->
<div class="mt-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Device Settings</h3>
<!-- Clock Sync -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Device Clock</span>
</div>
<button onclick="syncClockToNow()"
class="px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded flex items-center">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Sync to Current Time
</button>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Set the device clock manually or sync to your computer's current time. Device must be stopped to change clock.
</div>
<div class="flex gap-2">
<input type="datetime-local"
id="clock-input"
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<button onclick="setDeviceClock()"
class="px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded">
Set Clock
</button>
</div>
<div id="clock-status" class="mt-2 text-xs hidden"></div>
</div>
<!-- Store Name (Index Number) -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 mt-3">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M7 3a1 1 0 000 2h6a1 1 0 100-2H7zM4 7a1 1 0 011-1h10a1 1 0 110 2H5a1 1 0 01-1-1zM2 11a2 2 0 012-2h12a2 2 0 012 2v4a2 2 0 01-2 2H4a2 2 0 01-2-2v-4z"/>
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Store Name (Index)</span>
</div>
<div class="flex items-center gap-2">
<span id="store-name-display" class="text-sm font-mono text-gray-600 dark:text-gray-400">----</span>
<span id="overwrite-indicator" class="text-xs px-2 py-1 rounded hidden"></span>
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Set the file index number (0000-9999) for the next measurement storage. <strong>Requirements:</strong> Device must be <strong>stopped</strong> and <strong>FTP must be disabled</strong> to change this setting.
</div>
<div id="overwrite-warning" class="hidden mb-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded">
<div class="flex items-start">
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-500 mr-2 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
<div class="flex-1">
<p class="text-xs font-semibold text-yellow-800 dark:text-yellow-200">Data Already Exists</p>
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">Starting a measurement will overwrite existing data at this index. Consider using auto-increment to find an empty slot.</p>
</div>
</div>
</div>
<div class="flex gap-2">
<input type="number"
id="store-name-input"
min="0"
max="9999"
placeholder="0000"
class="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<button onclick="setStoreName()"
class="px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded">
Set Index
</button>
<button onclick="autoIncrementStoreName()"
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded"
title="Find next available index">
Auto +
</button>
</div>
<div id="store-name-status" class="mt-2 text-xs hidden"></div>
</div>
</div>
<!-- Measurement Data Files -->
<div class="mt-6">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Measurement Data Files</h3>
<div class="flex gap-2">
<button id="enable-ftp-btn" onclick="enableFTP()" class="px-3 py-1 text-xs bg-green-600 hover:bg-green-700 text-white rounded hidden">
Enable FTP
</button>
<button id="disable-ftp-btn" onclick="disableFTP()" class="px-3 py-1 text-xs bg-red-600 hover:bg-red-700 text-white rounded hidden">
Disable FTP
</button>
<button onclick="refreshFileList()"
class="px-3 py-1 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded flex items-center">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
Refresh
</button>
</div>
</div>
<!-- FTP Status Indicator -->
<div id="ftp-status" class="mb-3 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg hidden">
<div class="flex items-center text-sm text-yellow-800 dark:text-yellow-400">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<span id="ftp-status-text">FTP server is disabled. Click "Enable FTP" to browse files.</span>
</div>
<button onclick="enableFTP()"
class="mt-2 px-3 py-1 text-xs bg-yellow-600 hover:bg-yellow-700 text-white rounded">
Enable FTP
</button>
</div>
<!-- File Browser -->
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<!-- Current Path -->
<div class="px-4 py-2 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700 flex items-center">
<svg class="w-4 h-4 text-gray-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/>
</svg>
<span id="current-path" class="text-sm text-gray-700 dark:text-gray-300 font-mono">/</span>
</div>
<!-- File List -->
<div id="file-list" class="divide-y divide-gray-200 dark:divide-gray-700 max-h-96 overflow-y-auto">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p>Click "Refresh" to load files from the device</p>
</div>
</div>
<!-- Loading State -->
<div id="file-list-loading" class="p-8 text-center hidden">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<p class="text-sm text-gray-500 dark:text-gray-400">Loading files...</p>
</div>
<!-- Error State -->
<div id="file-list-error" class="p-4 bg-red-50 dark:bg-red-900/20 border-t border-red-200 dark:border-red-800 hidden">
<div class="flex items-center text-sm text-red-800 dark:text-red-400">
<svg class="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
<span id="file-list-error-text">Failed to load files</span>
</div>
</div>
</div>
</div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@@ -236,9 +414,14 @@ function initializeChart() {
if (window.liveChart && typeof window.liveChart.destroy === 'function') { if (window.liveChart && typeof window.liveChart.destroy === 'function') {
console.log('Destroying existing chart'); console.log('Destroying existing chart');
window.liveChart.destroy(); window.liveChart.destroy();
window.liveChart = null;
} }
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('Failed to get 2D context from canvas');
return;
}
console.log('Creating new chart...'); console.log('Creating new chart...');
// Dark mode detection // Dark mode detection
@@ -246,6 +429,7 @@ function initializeChart() {
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'; const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
try {
window.liveChart = new Chart(ctx, { window.liveChart = new Chart(ctx, {
type: 'line', type: 'line',
data: { data: {
@@ -318,11 +502,32 @@ function initializeChart() {
}); });
console.log('Chart created successfully:', window.liveChart); console.log('Chart created successfully:', window.liveChart);
} catch (error) {
console.error('Failed to create chart:', error);
// Display error message in the chart container
const container = canvas.parentElement;
if (container) {
container.innerHTML = `
<div class="flex items-center justify-center h-full text-sm text-red-600 dark:text-red-400">
<div class="text-center">
<svg class="w-8 h-8 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<p>Chart failed to load</p>
<p class="text-xs mt-1">Try refreshing the page</p>
</div>
</div>
`;
}
}
} }
// Initialize chart when DOM is ready // Initialize chart when DOM is ready
console.log('Executing initializeChart...'); console.log('Executing initializeChart...');
initializeChart(); // Use a small delay to ensure DOM is fully ready
setTimeout(() => {
initializeChart();
}, 50);
// WebSocket management (use global scope to avoid redeclaration) // WebSocket management (use global scope to avoid redeclaration)
if (typeof window.currentWebSocket === 'undefined') { if (typeof window.currentWebSocket === 'undefined') {
@@ -455,13 +660,19 @@ async function controlUnit(unitId, action) {
const result = await response.json(); const result = await response.json();
if (result.status === 'ok') { if (result.status === 'ok') {
// Reload the live view to update status // Give device more time to change state before checking
// Start/Stop commands need time to propagate
setTimeout(() => {
checkMeasurementState();
}, 1500);
// Reload the live view to update all status displays
setTimeout(() => { setTimeout(() => {
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, { htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
target: '#live-view-panel', target: '#live-view-panel',
swap: 'innerHTML' swap: 'innerHTML'
}); });
}, 500); }, 2000);
} else { } else {
alert(`Error: ${result.detail || 'Unknown error'}`); alert(`Error: ${result.detail || 'Unknown error'}`);
} }
@@ -472,7 +683,9 @@ async function controlUnit(unitId, action) {
// Auto-refresh status every 30 seconds // Auto-refresh status every 30 seconds
let refreshInterval; if (typeof window.refreshInterval === 'undefined') {
window.refreshInterval = null;
}
const REFRESH_INTERVAL_MS = 30000; // 30 seconds const REFRESH_INTERVAL_MS = 30000; // 30 seconds
const unit_id = '{{ unit.id }}'; const unit_id = '{{ unit.id }}';
@@ -556,6 +769,182 @@ function stopAutoRefresh() {
} }
// Start auto-refresh when page loads // Start auto-refresh when page loads
// Backend-Synced Measurement Timer
// Uses measurement_start_time from backend (tracked via state transitions)
// This works even for manually-started measurements!
if (typeof window.timerInterval === 'undefined') {
window.timerInterval = null;
window.measurementStartTime = null; // ISO string from backend
}
// Format elapsed time as HH:MM:SS
function formatElapsedTime(milliseconds) {
const totalSeconds = Math.floor(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return hours.toString().padStart(2, '0') + ':' + minutes.toString().padStart(2, '0') + ':' + seconds.toString().padStart(2, '0');
}
// Update timer display based on backend measurement_start_time
function updateTimerDisplay() {
const timerElement = document.getElementById('timer-display');
if (!timerElement) {
console.error('Timer display element not found!');
return;
}
if (!window.measurementStartTime) {
timerElement.textContent = '00:00:00';
return;
}
// Calculate elapsed time from backend-provided start time (UTC)
// Ensure the timestamp is treated as UTC by adding 'Z' if not present
let timestamp = window.measurementStartTime;
if (!timestamp.endsWith('Z') && !timestamp.includes('+')) {
timestamp = timestamp + 'Z';
}
const startTime = new Date(timestamp);
const elapsed = Date.now() - startTime.getTime();
const formattedTime = formatElapsedTime(elapsed);
timerElement.textContent = formattedTime;
}
// Sync timer with backend data (with FTP fallback)
async function syncTimerWithBackend(measurementState, measurementStartTime) {
// Device returns "Start" when measuring, "Stop" when stopped
const isMeasuring = measurementState === 'Start';
if (isMeasuring && measurementStartTime) {
// Measurement is running - check both backend and FTP timestamps
// Use whichever is earlier (older = actual measurement start)
// First check FTP for potentially older timestamp
try {
const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`);
const result = await response.json();
if (result.status === 'ok' && result.latest_timestamp) {
const backendTime = new Date(measurementStartTime + 'Z');
const ftpTime = new Date(result.latest_timestamp + 'Z');
// Use the earlier timestamp (represents actual measurement start)
if (ftpTime < backendTime) {
window.measurementStartTime = result.latest_timestamp;
window.timerSource = 'ftp';
console.log('Timer synced with FTP folder (earlier):', result.latest_folder, '@', result.latest_timestamp);
} else {
window.measurementStartTime = measurementStartTime;
window.timerSource = 'backend';
console.log('Timer synced with backend state (earlier):', measurementStartTime);
}
} else {
// No FTP timestamp, use backend
window.measurementStartTime = measurementStartTime;
window.timerSource = 'backend';
console.log('Timer synced with backend state:', measurementStartTime);
}
} catch (error) {
console.error('Failed to check FTP timestamp:', error);
// Fallback to backend on error
window.measurementStartTime = measurementStartTime;
window.timerSource = 'backend';
console.log('Timer synced with backend state (FTP check failed):', measurementStartTime);
}
// Start interval if not already running
if (!window.timerInterval) {
window.timerInterval = setInterval(updateTimerDisplay, 1000);
console.log('Timer display started');
}
// Update display immediately
updateTimerDisplay();
} else if (isMeasuring && !measurementStartTime) {
// Device is measuring but backend doesn't have start time yet
// Try FTP fallback to get measurement start from latest folder timestamp
if (!window.measurementStartTime || window.timerSource !== 'ftp') {
console.log('Device measuring but no backend start time - checking FTP fallback...');
try {
const response = await fetch(`/api/slmm/${unit_id}/ftp/latest-measurement-time`);
const result = await response.json();
if (result.status === 'ok' && result.latest_timestamp) {
window.measurementStartTime = result.latest_timestamp;
window.timerSource = 'ftp';
console.log('Timer synced with FTP folder:', result.latest_folder, '@', result.latest_timestamp);
// Start timer interval if not already running
if (!window.timerInterval) {
window.timerInterval = setInterval(updateTimerDisplay, 1000);
console.log('Timer display started (FTP source)');
}
updateTimerDisplay();
} else {
console.log('No FTP timestamp available');
}
} catch (error) {
console.error('Failed to get FTP timestamp:', error);
}
}
} else {
// Not measuring - stop timer
if (window.timerInterval) {
clearInterval(window.timerInterval);
window.timerInterval = null;
console.log('Timer display stopped');
}
window.measurementStartTime = null;
window.timerSource = null;
document.getElementById('timer-display').textContent = '00:00:00';
}
}
// Check measurement state and sync timer
async function checkMeasurementState() {
try {
const response = await fetch(`/api/slmm/${unit_id}/live`);
const result = await response.json();
if (result.status === 'ok' && result.data) {
await syncTimerWithBackend(
result.data.measurement_state,
result.data.measurement_start_time
);
}
} catch (error) {
console.error('Failed to check measurement state:', error);
}
}
// Initialize timer immediately (this partial is loaded via HTMX, so DOMContentLoaded may have already fired)
if (typeof window.timerInitialized === 'undefined') {
window.timerInitialized = true;
// Execute initialization immediately
(async function() {
console.log('Timer initialization: Starting immediately');
// Check measurement state immediately
await checkMeasurementState();
// Load store name (index number)
await loadStoreName();
// Check FTP status and update buttons
await checkFTPStatus();
// Check measurement state periodically (every 5 seconds)
setInterval(checkMeasurementState, 5000);
console.log('Timer initialization: Complete, polling every 5 seconds');
})();
}
document.addEventListener('DOMContentLoaded', startAutoRefresh); document.addEventListener('DOMContentLoaded', startAutoRefresh);
// Cleanup on page unload // Cleanup on page unload
@@ -564,4 +953,510 @@ window.addEventListener('beforeunload', function() {
window.currentWebSocket.close(); window.currentWebSocket.close();
} }
}); });
// File Browser Functions
if (typeof window.currentPath === 'undefined') {
window.currentPath = '/';
}
// Format file size
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Format date
function formatDate(dateStr) {
try {
const date = new Date(dateStr);
return date.toLocaleString();
} catch (e) {
return dateStr;
}
}
// Enable FTP on device
async function enableFTP() {
try {
const response = await fetch(`/api/slmm/${unit_id}/ftp/enable`, {
method: 'POST'
});
const result = await response.json();
if (result.status === 'ok') {
document.getElementById('ftp-status').classList.add('hidden');
updateFTPButtons(true);
refreshFileList();
} else {
alert('Failed to enable FTP: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('Failed to enable FTP: ' + error.message);
}
}
async function disableFTP() {
try {
const response = await fetch(`/api/slmm/${unit_id}/ftp/disable`, {
method: 'POST'
});
const result = await response.json();
if (result.status === 'ok') {
updateFTPButtons(false);
alert('FTP disabled. TCP commands are now available.');
} else {
alert('Failed to disable FTP: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('Failed to disable FTP: ' + error.message);
}
}
function updateFTPButtons(ftpEnabled) {
const enableBtn = document.getElementById('enable-ftp-btn');
const disableBtn = document.getElementById('disable-ftp-btn');
console.log('updateFTPButtons called with ftpEnabled:', ftpEnabled);
console.log('Enable button element:', enableBtn);
console.log('Disable button element:', disableBtn);
if (enableBtn && disableBtn) {
if (ftpEnabled) {
enableBtn.classList.add('hidden');
disableBtn.classList.remove('hidden');
console.log('FTP is enabled - showing Disable FTP button');
} else {
enableBtn.classList.remove('hidden');
disableBtn.classList.add('hidden');
console.log('FTP is disabled - showing Enable FTP button');
}
} else {
console.error('FTP button elements not found in DOM');
}
}
async function checkFTPStatus() {
console.log('checkFTPStatus: Checking FTP status for unit_id:', unit_id);
try {
const response = await fetch(`/api/slmm/${unit_id}/ftp/status`);
const result = await response.json();
console.log('checkFTPStatus: Response:', result);
if (result.status === 'ok') {
console.log('checkFTPStatus: FTP enabled status:', result.ftp_enabled);
updateFTPButtons(result.ftp_enabled);
} else {
console.error('checkFTPStatus: Unexpected response status:', result.status);
}
} catch (error) {
console.error('checkFTPStatus: Failed to check FTP status:', error);
}
}
// Clock Management Functions
async function syncClockToNow() {
const now = new Date();
// Format as YYYY-MM-DDTHH:mm for datetime-local input
const localDateTime = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)).toISOString().slice(0, 16);
// Update the input field
document.getElementById('clock-input').value = localDateTime;
// Auto-submit
await setDeviceClock();
}
async function setDeviceClock() {
const clockInput = document.getElementById('clock-input');
const statusDiv = document.getElementById('clock-status');
if (!clockInput.value) {
showClockStatus('Please select a date and time', 'error');
return;
}
// Convert datetime-local to NL43 format: YYYY/MM/DD,HH:MM:SS
const dateTime = new Date(clockInput.value);
const nl43Format = dateTime.getFullYear() + '/' +
String(dateTime.getMonth() + 1).padStart(2, '0') + '/' +
String(dateTime.getDate()).padStart(2, '0') + ',' +
String(dateTime.getHours()).padStart(2, '0') + ':' +
String(dateTime.getMinutes()).padStart(2, '0') + ':' +
String(dateTime.getSeconds()).padStart(2, '0');
showClockStatus('Setting device clock...', 'info');
try {
const response = await fetch(`/api/slmm/${unit_id}/clock`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ datetime: nl43Format })
});
const result = await response.json();
if (response.ok && result.status === 'ok') {
showClockStatus('✓ Clock set successfully to ' + dateTime.toLocaleString(), 'success');
} else {
showClockStatus('✗ Failed: ' + (result.detail || 'Unknown error'), 'error');
}
} catch (error) {
showClockStatus('✗ Failed: ' + error.message, 'error');
}
}
function showClockStatus(message, type) {
const statusDiv = document.getElementById('clock-status');
statusDiv.textContent = message;
statusDiv.classList.remove('hidden', 'text-green-600', 'text-red-600', 'text-blue-600');
if (type === 'success') {
statusDiv.classList.add('text-green-600', 'dark:text-green-400');
} else if (type === 'error') {
statusDiv.classList.add('text-red-600', 'dark:text-red-400');
} else {
statusDiv.classList.add('text-blue-600', 'dark:text-blue-400');
}
}
// Store Name (Index) Management Functions
async function loadStoreName() {
try {
const response = await fetch(`/api/slmm/${unit_id}/index-number`);
const result = await response.json();
if (response.ok && result.status === 'ok') {
const indexNum = result.index_number;
document.getElementById('store-name-display').textContent = indexNum;
document.getElementById('store-name-input').value = parseInt(indexNum);
// Check overwrite status after loading
await checkOverwriteStatus();
} else {
console.error('Failed to load store name:', result);
}
} catch (error) {
console.error('Error loading store name:', error);
}
}
async function setStoreName() {
const input = document.getElementById('store-name-input');
const statusDiv = document.getElementById('store-name-status');
const index = parseInt(input.value);
if (isNaN(index) || index < 0 || index > 9999) {
showStoreNameStatus('Please enter a number between 0 and 9999', 'error');
return;
}
showStoreNameStatus('Setting store name...', 'info');
try {
const response = await fetch(`/api/slmm/${unit_id}/index-number`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ index: index })
});
const result = await response.json();
if (response.ok && result.status === 'ok') {
showStoreNameStatus('✓ Store name set successfully to ' + index.toString().padStart(4, '0'), 'success');
// Update display
document.getElementById('store-name-display').textContent = index.toString().padStart(4, '0');
// Check overwrite status for new index
await checkOverwriteStatus();
} else {
showStoreNameStatus('✗ Failed: ' + (result.detail || 'Unknown error'), 'error');
}
} catch (error) {
showStoreNameStatus('✗ Failed: ' + error.message, 'error');
}
}
function showStoreNameStatus(message, type) {
const statusDiv = document.getElementById('store-name-status');
statusDiv.textContent = message;
statusDiv.classList.remove('hidden', 'text-green-600', 'text-red-600', 'text-blue-600');
if (type === 'success') {
statusDiv.classList.add('text-green-600', 'dark:text-green-400');
} else if (type === 'error') {
statusDiv.classList.add('text-red-600', 'dark:text-red-400');
} else {
statusDiv.classList.add('text-blue-600', 'dark:text-blue-400');
}
}
// Check Overwrite Status
async function checkOverwriteStatus() {
try {
const response = await fetch(`/api/slmm/${unit_id}/overwrite-check`);
const result = await response.json();
const warningDiv = document.getElementById('overwrite-warning');
const indicator = document.getElementById('overwrite-indicator');
if (response.ok && result.status === 'ok') {
if (result.will_overwrite) {
// Show warning
warningDiv.classList.remove('hidden');
indicator.classList.remove('hidden', 'bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200');
indicator.classList.add('bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200');
indicator.textContent = 'Data Exists';
} else {
// Hide warning
warningDiv.classList.add('hidden');
indicator.classList.remove('hidden', 'bg-yellow-100', 'text-yellow-800', 'dark:bg-yellow-900', 'dark:text-yellow-200');
indicator.classList.add('bg-green-100', 'text-green-800', 'dark:bg-green-900', 'dark:text-green-200');
indicator.textContent = 'Safe';
}
} else {
// Hide on error
warningDiv.classList.add('hidden');
indicator.classList.add('hidden');
}
} catch (error) {
console.error('Error checking overwrite status:', error);
}
}
// Auto-increment to find next available index
async function autoIncrementStoreName() {
showStoreNameStatus('Finding next available index...', 'info');
try {
const currentIndexResponse = await fetch(`/api/slmm/${unit_id}/index-number`);
const currentIndexResult = await currentIndexResponse.json();
if (!currentIndexResponse.ok || currentIndexResult.status !== 'ok') {
showStoreNameStatus('✗ Failed to get current index', 'error');
return;
}
let currentIndex = parseInt(currentIndexResult.index_number);
let foundEmpty = false;
let checkedCount = 0;
const maxChecks = 100; // Safety limit
// Start from next index
currentIndex++;
while (!foundEmpty && checkedCount < maxChecks && currentIndex <= 9999) {
// Set the index
const setResponse = await fetch(`/api/slmm/${unit_id}/index-number`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index: currentIndex })
});
if (!setResponse.ok) {
currentIndex++;
checkedCount++;
continue;
}
// Check if data exists at this index
const checkResponse = await fetch(`/api/slmm/${unit_id}/overwrite-check`);
const checkResult = await checkResponse.json();
if (checkResponse.ok && checkResult.status === 'ok') {
if (!checkResult.will_overwrite) {
// Found empty slot!
foundEmpty = true;
document.getElementById('store-name-display').textContent = currentIndex.toString().padStart(4, '0');
document.getElementById('store-name-input').value = currentIndex;
showStoreNameStatus(`✓ Found empty slot at index ${currentIndex.toString().padStart(4, '0')}`, 'success');
await checkOverwriteStatus();
break;
}
}
currentIndex++;
checkedCount++;
}
if (!foundEmpty) {
if (currentIndex > 9999) {
showStoreNameStatus('✗ No empty slots found (reached max 9999)', 'error');
} else {
showStoreNameStatus(`✗ No empty slot found in next ${maxChecks} indices`, 'error');
}
}
} catch (error) {
showStoreNameStatus('✗ Error: ' + error.message, 'error');
}
}
// Navigate to directory
function navigateToDir(path) {
window.currentPath = path;
document.getElementById('current-path').textContent = path;
loadFiles(path);
}
// Go up one directory
function navigateUp() {
const parts = window.currentPath.split('/').filter(p => p);
parts.pop();
const newPath = '/' + parts.join('/');
navigateToDir(newPath || '/');
}
// Download file
async function downloadFile(path, filename) {
try {
const response = await fetch(`/api/slmm/${unit_id}/ftp/download`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ remote_path: path })
});
if (!response.ok) {
throw new Error('Download failed');
}
// Create blob and download
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
alert('Failed to download file: ' + error.message);
}
}
// Load files from path
async function loadFiles(path) {
const fileList = document.getElementById('file-list');
const loading = document.getElementById('file-list-loading');
const error = document.getElementById('file-list-error');
const ftpStatus = document.getElementById('ftp-status');
// Show loading state
fileList.classList.add('hidden');
loading.classList.remove('hidden');
error.classList.add('hidden');
try {
const response = await fetch(`/api/slmm/${unit_id}/ftp/files?path=${encodeURIComponent(path)}`);
const result = await response.json();
if (result.status === 'ok') {
ftpStatus.classList.add('hidden');
displayFiles(result.files || []);
} else {
throw new Error(result.detail || 'Failed to load files');
}
} catch (err) {
loading.classList.add('hidden');
// Check if it's an FTP disabled error
if (err.message.includes('FTP') || err.message.includes('502')) {
ftpStatus.classList.remove('hidden');
fileList.classList.remove('hidden');
fileList.innerHTML = '<div class="p-8 text-center text-gray-500 dark:text-gray-400">FTP is disabled on the device</div>';
} else {
error.classList.remove('hidden');
document.getElementById('file-list-error-text').textContent = err.message;
fileList.classList.remove('hidden');
}
}
}
// Display files in the list
function displayFiles(files) {
const fileList = document.getElementById('file-list');
const loading = document.getElementById('file-list-loading');
loading.classList.add('hidden');
fileList.classList.remove('hidden');
if (files.length === 0) {
fileList.innerHTML = '<div class="p-8 text-center text-gray-500 dark:text-gray-400">No files found</div>';
return;
}
// Sort: directories first, then files
files.sort((a, b) => {
if (a.is_dir && !b.is_dir) return -1;
if (!a.is_dir && b.is_dir) return 1;
return a.name.localeCompare(b.name);
});
let html = '';
// Add "up" button if not at root
if (window.currentPath !== '/') {
html += `
<div class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer flex items-center"
onclick="navigateUp()">
<svg class="w-5 h-5 text-gray-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
<span class="text-sm text-gray-700 dark:text-gray-300 font-medium">..</span>
</div>
`;
}
files.forEach(file => {
const icon = file.is_dir ?
'<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"/></svg>' :
'<svg class="w-5 h-5 text-gray-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"/></svg>';
const clickHandler = file.is_dir ?
`navigateToDir('${file.path}')` :
`downloadFile('${file.path}', '${file.name}')`;
html += `
<div class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer flex items-center justify-between"
onclick="${clickHandler}">
<div class="flex items-center flex-1 min-w-0">
${icon}
<div class="ml-3 flex-1 min-w-0">
<p class="text-sm text-gray-900 dark:text-white truncate">${file.name}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
${file.is_dir ? 'Folder' : formatFileSize(file.size)}
${file.modified_timestamp ? ' • ' + formatDate(file.modified_timestamp) : ''}
</p>
</div>
</div>
${!file.is_dir ? `
<button onclick="event.stopPropagation(); downloadFile('${file.path}', '${file.name}')"
class="ml-2 p-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
</button>
` : ''}
</div>
`;
});
fileList.innerHTML = html;
}
// Refresh file list
function refreshFileList() {
loadFiles(window.currentPath);
}
</script> </script>