2 Commits

Author SHA1 Message Date
serversdwn
7971092509 update ftp browser, enable folder downloads (local), reimplemented timer. Enhanced project view 2026-01-14 21:59:22 +00:00
serversdwn
d349af9444 Add Sound Level Meter support to roster management
- Updated roster.html to include a new option for Sound Level Meter in the device type selection.
- Added specific fields for Sound Level Meter information, including model, host/IP address, TCP and FTP ports, serial number, frequency weighting, and time weighting.
- Enhanced JavaScript to handle the visibility and state of Sound Level Meter fields based on the selected device type.
- Modified the unit editing functionality to populate Sound Level Meter fields with existing data when editing a unit.
- Updated settings.html to change the deployment status display from badges to radio buttons for better user interaction.
- Adjusted the toggleDeployed function to accept the new state directly instead of the current state.
- Changed the edit button in unit_detail.html to redirect to the roster edit page with the appropriate unit ID.
2026-01-14 21:59:13 +00:00
7 changed files with 1250 additions and 139 deletions

View File

@@ -744,6 +744,132 @@ async def ftp_download_to_server(
)
@router.post("/{project_id}/ftp-download-folder-to-server")
async def ftp_download_folder_to_server(
project_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Download an entire folder from an SLM to the server via FTP as a ZIP file.
Creates a DataFile record and stores the ZIP in data/Projects/{project_id}/
"""
import httpx
import os
import hashlib
from pathlib import Path
from backend.models import DataFile
data = await request.json()
unit_id = data.get("unit_id")
remote_path = data.get("remote_path")
location_id = data.get("location_id")
if not unit_id or not remote_path:
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
# Get or create active session for this location/unit
session = db.query(RecordingSession).filter(
and_(
RecordingSession.project_id == project_id,
RecordingSession.location_id == location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status.in_(["recording", "paused"])
)
).first()
# If no active session, create one
if not session:
session = RecordingSession(
id=str(uuid.uuid4()),
project_id=project_id,
location_id=location_id,
unit_id=unit_id,
status="completed",
started_at=datetime.utcnow(),
stopped_at=datetime.utcnow(),
notes="Auto-created for FTP folder download"
)
db.add(session)
db.commit()
db.refresh(session)
# Download folder from SLMM
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
try:
async with httpx.AsyncClient(timeout=600.0) as client: # Longer timeout for folders
response = await client.post(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download-folder",
json={"remote_path": remote_path}
)
if not response.is_success:
raise HTTPException(
status_code=response.status_code,
detail=f"Failed to download folder from SLMM: {response.text}"
)
# Extract folder name from remote_path
folder_name = os.path.basename(remote_path.rstrip('/'))
filename = f"{folder_name}.zip"
# Create directory structure: data/Projects/{project_id}/{session_id}/
project_dir = Path(f"data/Projects/{project_id}/{session.id}")
project_dir.mkdir(parents=True, exist_ok=True)
# Save ZIP file to disk
file_path = project_dir / filename
file_content = response.content
with open(file_path, 'wb') as f:
f.write(file_content)
# Calculate checksum
checksum = hashlib.sha256(file_content).hexdigest()
# Create DataFile record
data_file = DataFile(
id=str(uuid.uuid4()),
session_id=session.id,
file_path=str(file_path.relative_to("data")), # Store relative to data/
file_type='archive', # ZIP archives
file_size_bytes=len(file_content),
downloaded_at=datetime.utcnow(),
checksum=checksum,
file_metadata=json.dumps({
"source": "ftp_folder",
"remote_path": remote_path,
"unit_id": unit_id,
"location_id": location_id,
"folder_name": folder_name,
})
)
db.add(data_file)
db.commit()
return {
"success": True,
"message": f"Downloaded folder {folder_name} to server as ZIP",
"file_id": data_file.id,
"file_path": str(file_path),
"file_size": len(file_content),
}
except httpx.TimeoutException:
raise HTTPException(
status_code=504,
detail="Timeout downloading folder from SLM (large folders may take a while)"
)
except Exception as e:
logger.error(f"Error downloading folder to server: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to download folder to server: {str(e)}"
)
# ============================================================================
# Project Types
# ============================================================================

View File

@@ -158,7 +158,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
is_measuring = False
try:
async with httpx.AsyncClient(timeout=5.0) as client:
async with httpx.AsyncClient(timeout=10.0) as client:
# Get measurement state
state_response = await client.get(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
@@ -168,7 +168,23 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
measurement_state = state_data.get("measurement_state", "Unknown")
is_measuring = state_data.get("is_measuring", False)
# Get live status
# If measuring, sync start time from FTP to database (fixes wrong timestamps)
if is_measuring:
try:
sync_response = await client.post(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time",
timeout=10.0
)
if sync_response.status_code == 200:
sync_data = sync_response.json()
logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}")
else:
logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}")
except Exception as e:
# Don't fail the whole request if sync fails
logger.warning(f"Could not sync start time for {unit_id}: {e}")
# Get live status (now with corrected start time)
status_response = await client.get(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
)

View File

@@ -1,4 +1,4 @@
<!-- FTP File Browser for SLMs -->
<!-- FTP File Browser for SLMs v2.0 - Folder Download Support -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Download Files from SLMs</h2>
@@ -34,7 +34,7 @@
disabled>
Disable FTP
</button>
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL43_DATA')"
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL-43')"
id="browse-ftp-{{ unit_item.unit.id }}"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"
disabled>
@@ -50,8 +50,8 @@
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
</svg>
<span id="current-path-{{ unit_item.unit.id }}" class="text-sm font-mono text-gray-600 dark:text-gray-400">/NL43_DATA</span>
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL43_DATA')"
<span id="current-path-{{ unit_item.unit.id }}" class="text-sm font-mono text-gray-600 dark:text-gray-400">/NL-43</span>
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL-43')"
class="ml-auto text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
<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 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"></path>
@@ -127,7 +127,7 @@ async function enableFTP(unitId) {
if (response.ok) {
await checkFTPStatus(unitId);
// Auto-load files after enabling
setTimeout(() => loadFTPFiles(unitId, '/NL43_DATA'), 1000);
setTimeout(() => loadFTPFiles(unitId, '/NL-43'), 1000);
} else {
alert('Failed to enable FTP');
}
@@ -200,18 +200,34 @@ async function loadFTPFiles(unitId, path) {
: '<svg class="w-5 h-5 text-gray-400" 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"></path></svg>';
const sizeStr = file.is_dir ? '' : formatFileSize(file.size);
const clickAction = file.is_dir
? `onclick="loadFTPFiles('${unitId}', '${file.path}')"`
: '';
html += `
<div class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 rounded ${file.is_dir ? 'cursor-pointer' : ''}" ${clickAction}>
<div class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 rounded">
${icon}
<div class="flex-1 min-w-0">
<div class="flex-1 min-w-0 ${file.is_dir ? 'cursor-pointer' : ''}" ${file.is_dir ? `onclick="loadFTPFiles('${unitId}', '${file.path}')"` : ''}>
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">${file.name}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">${file.modified}${sizeStr ? ' • ' + sizeStr : ''}</div>
</div>
${!file.is_dir ? `
${file.is_dir ? `
<div class="flex items-center gap-2">
<button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${file.path}', '${file.name}')"
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
title="Download entire folder to server and add to database">
<svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
</svg>
To Server (ZIP)
</button>
<button onclick="event.stopPropagation(); downloadFTPFolder('${unitId}', '${file.path}', '${file.name}')"
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
title="Download entire folder as ZIP to your computer">
<svg class="w-3 h-3 inline mr-1" 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"></path>
</svg>
To Browser (ZIP)
</button>
</div>
` : `
<div class="flex items-center gap-2">
<button onclick="downloadToServer('${unitId}', '${file.path}', '${file.name}')"
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
@@ -230,7 +246,7 @@ async function loadFTPFiles(unitId, path) {
To Browser
</button>
</div>
` : ''}
`}
</div>
`;
}
@@ -280,6 +296,90 @@ async function downloadFTPFile(unitId, remotePath, fileName) {
}
}
async function downloadFTPFolder(unitId, remotePath, folderName) {
const btn = event.target;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<svg class="w-3 h-3 inline mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Downloading...';
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/download-folder`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
remote_path: remotePath
})
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${folderName}.zip`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
// Show success message
alert(`✓ Folder "${folderName}" downloaded successfully as ZIP file!`);
} else {
const errorData = await response.json();
alert('Folder download failed: ' + (errorData.detail || 'Unknown error'));
}
} catch (error) {
alert('Error downloading folder: ' + error);
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
async function downloadFolderToServer(unitId, remotePath, folderName) {
const btn = event.target;
const originalHTML = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<svg class="w-3 h-3 inline mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Downloading...';
// Get location_id from the unit's data attribute
const unitContainer = btn.closest('[id^="ftp-files-"]');
const locationId = unitContainer.dataset.locationId;
try {
const response = await fetch(`/api/projects/{{ project_id }}/ftp-download-folder-to-server`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
unit_id: unitId,
remote_path: remotePath,
location_id: locationId
})
});
const data = await response.json();
if (response.ok) {
// Show success message
alert(`✓ Folder "${folderName}" downloaded to server successfully as ZIP!\n\nFile ID: ${data.file_id}\nSize: ${formatFileSize(data.file_size)}`);
// Refresh the downloaded files list
htmx.trigger('#project-files', 'refresh');
} else {
alert('Folder download to server failed: ' + (data.detail || 'Unknown error'));
}
} catch (error) {
alert('Error downloading folder to server: ' + error);
} finally {
btn.disabled = false;
btn.innerHTML = originalHTML;
}
}
async function downloadToServer(unitId, remotePath, fileName) {
const btn = event.target;
const originalText = btn.innerHTML;

View File

@@ -1,5 +1,15 @@
<!-- Live View Panel for {{ unit.id }} -->
<div class="h-full flex flex-col">
<!-- Bootstrap data from server -->
<script id="slm-bootstrap-data" type="application/json">
{
"unit_id": "{{ unit.id }}",
"is_measuring": {{ 'true' if is_measuring else 'false' }},
"measurement_state": "{{ measurement_state }}",
"measurement_start_time": {% if current_status and current_status.measurement_start_time %}"{{ current_status.measurement_start_time }}"{% else %}null{% endif %}
}
</script>
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
@@ -45,6 +55,7 @@
</button>
<!-- Measurement Status Badge -->
<div class="flex flex-col items-end gap-2">
<div>
{% 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">
@@ -57,12 +68,24 @@
</span>
{% endif %}
</div>
<!-- Elapsed Time Display -->
<div id="elapsed-time-container" class="{% if not is_measuring %}hidden{% endif %}">
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span id="elapsed-time" class="font-mono font-medium text-gray-900 dark:text-white">00:00:00</span>
</div>
</div>
</div>
</div>
</div>
<!-- Control Buttons -->
<div class="flex gap-2 mb-6">
<button onclick="controlUnit('{{ unit.id }}', 'start')"
<!-- Measurement Controls -->
<div class="mb-6 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Measurement Control</h3>
<div class="flex gap-2 flex-wrap">
<button onclick="startMeasurementWithCheck('{{ unit.id }}')"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
@@ -96,6 +119,14 @@
Reset
</button>
<button onclick="controlUnit('{{ unit.id }}', 'store')"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
</svg>
Store
</button>
<button id="start-stream-btn" onclick="initLiveDataStream('{{ unit.id }}')"
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center ml-auto">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -113,6 +144,7 @@
Stop Live Stream
</button>
</div>
</div>
<!-- Current Metrics -->
<div class="grid grid-cols-5 gap-4 mb-6">
@@ -179,7 +211,7 @@
</div>
<div class="mt-2 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="battery-bar" class="bg-green-500 h-2 rounded-full transition-all"
style="width: {% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}0%{% endif %}">
style="width: {% if current_status and current_status.battery_level %}{{ current_status.battery_level }}{% else %}0{% endif %}%">
</div>
</div>
</div>
@@ -231,6 +263,116 @@
</div>
</div>
</div>
<!-- Device Settings & Commands -->
<div class="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Store Name & Clock -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">Store Name & Time</h3>
<!-- Store Name (Index Number) -->
<div class="mb-4">
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-2">Store Name (Index Number)</label>
<div class="flex items-center gap-2">
<input type="number" id="index-number-input"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
min="0" max="9999" placeholder="0000">
<button onclick="getIndexNumber('{{ unit.id }}')"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
Get
</button>
<button onclick="setIndexNumber('{{ unit.id }}')"
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm">
Set
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Range: 0000-9999. Used for file numbering.</p>
<div id="index-overwrite-warning" class="hidden mt-2 p-2 bg-red-100 dark:bg-red-900/30 border border-red-300 dark:border-red-700 rounded text-xs text-red-800 dark:text-red-400">
⚠️ <strong>Warning:</strong> Data exists at this index. Starting measurement will overwrite previous data!
</div>
</div>
<!-- Device Clock -->
<div>
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-2">Device Clock</label>
<div class="flex items-center gap-2">
<div id="device-clock" class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-700/50 text-gray-900 dark:text-white font-mono text-sm">
--
</div>
<button onclick="getDeviceClock('{{ unit.id }}')"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
Refresh
</button>
<button onclick="syncDeviceClock('{{ unit.id }}')"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm">
Sync
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Sync sets device clock to match server time.</p>
</div>
</div>
<!-- Measurement Settings -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">Measurement Settings</h3>
<!-- Frequency Weighting -->
<div class="mb-4">
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-2">Frequency Weighting</label>
<div class="flex items-center gap-2">
<select id="frequency-weighting-select"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">--</option>
<option value="A">A</option>
<option value="C">C</option>
<option value="Z">Z</option>
</select>
<button onclick="getFrequencyWeighting('{{ unit.id }}')"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
Get
</button>
<button onclick="setFrequencyWeighting('{{ unit.id }}')"
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm">
Set
</button>
</div>
</div>
<!-- Time Weighting -->
<div class="mb-4">
<label class="block text-xs text-gray-600 dark:text-gray-400 mb-2">Time Weighting</label>
<div class="flex items-center gap-2">
<select id="time-weighting-select"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="">--</option>
<option value="F">F (Fast)</option>
<option value="S">S (Slow)</option>
<option value="I">I (Impulse)</option>
</select>
<button onclick="getTimeWeighting('{{ unit.id }}')"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm">
Get
</button>
<button onclick="setTimeWeighting('{{ unit.id }}')"
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg text-sm">
Set
</button>
</div>
</div>
<!-- All Settings Query -->
<div>
<button onclick="getAllSettings('{{ unit.id }}')"
class="w-full px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-sm flex items-center justify-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"></path>
</svg>
Query All Settings
</button>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 text-center">View all device settings for diagnostics</p>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@@ -477,10 +619,19 @@ async function controlUnit(unitId, action) {
const result = await response.json();
if (result.status === 'ok') {
// Handle timer based on action
if (action === 'start') {
startMeasurementTimer(unitId);
} else if (action === 'stop' || action === 'reset') {
stopMeasurementTimer();
clearMeasurementStartTime(unitId);
}
// Note: pause does not stop timer - it keeps running
// Reload the live view to update status
setTimeout(() => {
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
target: '#live-view-panel',
target: '#slm-command-center',
swap: 'innerHTML'
});
}, 500);
@@ -492,6 +643,569 @@ async function controlUnit(unitId, action) {
}
}
// Start measurement with overwrite check
async function startMeasurementWithCheck(unitId) {
try {
// Check for overwrite risk
const checkResponse = await fetch(`/api/slmm/${unitId}/overwrite-check`);
const checkResult = await checkResponse.json();
console.log('Overwrite check result:', checkResult);
if (checkResult.status === 'ok') {
// API returns data directly, not nested under .data
const overwriteStatus = checkResult.overwrite_status;
const willOverwrite = checkResult.will_overwrite;
if (willOverwrite === true || overwriteStatus === 'Exist') {
// Data exists - warn user
const confirmed = confirm(
`⚠️ WARNING: Data exists at the current store index!\n\n` +
`Overwrite Status: ${overwriteStatus}\n\n` +
`Starting measurement will OVERWRITE previous data.\n\n` +
`Are you sure you want to continue?`
);
if (!confirmed) {
return; // User cancelled
}
}
}
// Proceed with start
await controlUnit(unitId, 'start');
} catch (error) {
console.error('Overwrite check failed:', error);
// Still allow start, but warn user
const proceed = confirm(
'Could not verify overwrite status.\n\n' +
'Do you want to start measurement anyway?'
);
if (proceed) {
await controlUnit(unitId, 'start');
}
}
}
// Index Number (Store Name) functions
async function getIndexNumber(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/index-number`);
const result = await response.json();
if (result.status === 'ok') {
const indexNumber = result.data?.index_number || result.index_number;
document.getElementById('index-number-input').value = parseInt(indexNumber);
// Check for overwrite risk at this index
await checkOverwriteStatus(unitId);
} else {
alert(`Failed to get index number: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to get index number: ${error.message}`);
}
}
async function setIndexNumber(unitId) {
const input = document.getElementById('index-number-input');
const indexValue = parseInt(input.value);
if (isNaN(indexValue) || indexValue < 0 || indexValue > 9999) {
alert('Please enter a valid index number (0-9999)');
return;
}
try {
const response = await fetch(`/api/slmm/${unitId}/index-number`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ index: indexValue })
});
const result = await response.json();
if (result.status === 'ok') {
alert(`Index number set to ${String(indexValue).padStart(4, '0')}`);
// Check for overwrite risk at new index
await checkOverwriteStatus(unitId);
} else {
alert(`Failed to set index number: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to set index number: ${error.message}`);
}
}
async function checkOverwriteStatus(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/overwrite-check`);
const result = await response.json();
console.log('Overwrite status check:', result);
const warningDiv = document.getElementById('index-overwrite-warning');
if (result.status === 'ok') {
// API returns data directly, not nested under .data
const overwriteStatus = result.overwrite_status;
const willOverwrite = result.will_overwrite;
if (willOverwrite === true || overwriteStatus === 'Exist') {
warningDiv.classList.remove('hidden');
} else {
warningDiv.classList.add('hidden');
}
} else {
warningDiv.classList.add('hidden');
}
} catch (error) {
console.error('Failed to check overwrite status:', error);
// Hide warning on error
const warningDiv = document.getElementById('index-overwrite-warning');
if (warningDiv) {
warningDiv.classList.add('hidden');
}
}
}
// Device Clock functions
async function getDeviceClock(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/clock`);
const result = await response.json();
if (result.status === 'ok') {
const clockValue = result.data?.clock || result.clock;
document.getElementById('device-clock').textContent = clockValue;
} else {
alert(`Failed to get device clock: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to get device clock: ${error.message}`);
}
}
async function syncDeviceClock(unitId) {
try {
// Format current time for NL43: YYYY/MM/DD,HH:MM:SS
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const datetime = `${year}/${month}/${day},${hours}:${minutes}:${seconds}`;
const response = await fetch(`/api/slmm/${unitId}/clock`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ datetime: datetime })
});
const result = await response.json();
if (result.status === 'ok') {
alert('Device clock synchronized successfully!');
await getDeviceClock(unitId);
} else {
alert(`Failed to sync clock: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to sync clock: ${error.message}`);
}
}
// Frequency Weighting functions
async function getFrequencyWeighting(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/frequency-weighting?channel=Main`);
const result = await response.json();
if (result.status === 'ok') {
const weighting = result.data?.frequency_weighting || result.frequency_weighting;
document.getElementById('frequency-weighting-select').value = weighting;
} else {
alert(`Failed to get frequency weighting: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to get frequency weighting: ${error.message}`);
}
}
async function setFrequencyWeighting(unitId) {
const select = document.getElementById('frequency-weighting-select');
const weighting = select.value;
if (!weighting) {
alert('Please select a frequency weighting');
return;
}
try {
const response = await fetch(`/api/slmm/${unitId}/frequency-weighting`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ weighting: weighting, channel: 'Main' })
});
const result = await response.json();
if (result.status === 'ok') {
alert(`Frequency weighting set to ${weighting}`);
} else {
alert(`Failed to set frequency weighting: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to set frequency weighting: ${error.message}`);
}
}
// Time Weighting functions
async function getTimeWeighting(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/time-weighting?channel=Main`);
const result = await response.json();
if (result.status === 'ok') {
const weighting = result.data?.time_weighting || result.time_weighting;
document.getElementById('time-weighting-select').value = weighting;
} else {
alert(`Failed to get time weighting: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to get time weighting: ${error.message}`);
}
}
async function setTimeWeighting(unitId) {
const select = document.getElementById('time-weighting-select');
const weighting = select.value;
if (!weighting) {
alert('Please select a time weighting');
return;
}
try {
const response = await fetch(`/api/slmm/${unitId}/time-weighting`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ weighting: weighting, channel: 'Main' })
});
const result = await response.json();
if (result.status === 'ok') {
alert(`Time weighting set to ${weighting}`);
} else {
alert(`Failed to set time weighting: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to set time weighting: ${error.message}`);
}
}
// Get All Settings
async function getAllSettings(unitId) {
try {
const response = await fetch(`/api/slmm/${unitId}/settings/all`);
const result = await response.json();
if (result.status === 'ok') {
const settings = result.data?.settings || result.settings;
// Format settings for display
let message = 'Current Device Settings:\n\n';
for (const [key, value] of Object.entries(settings)) {
const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
message += `${label}: ${value}\n`;
}
alert(message);
} else {
alert(`Failed to get settings: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Failed to get settings: ${error.message}`);
}
}
// ========================================
// Measurement Timer
// ========================================
let measurementTimerInterval = null;
const TIMER_STORAGE_KEY = 'slm_measurement_start_';
function startMeasurementTimer(unitId) {
// Stop any existing timer
stopMeasurementTimer();
// Only set start time if not already set (preserve existing start time)
const existingStartTime = localStorage.getItem(TIMER_STORAGE_KEY + unitId);
if (!existingStartTime) {
const startTime = Date.now();
localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTime.toString());
console.log('No existing start time, setting to now:', new Date(startTime));
} else {
console.log('Using existing start time from localStorage:', new Date(parseInt(existingStartTime)));
}
// Show timer container
const container = document.getElementById('elapsed-time-container');
if (container) {
container.classList.remove('hidden');
}
// Update timer immediately
updateElapsedTime(unitId);
// Update every second
measurementTimerInterval = setInterval(() => {
updateElapsedTime(unitId);
}, 1000);
console.log('Measurement timer started for', unitId);
}
function stopMeasurementTimer() {
if (measurementTimerInterval) {
clearInterval(measurementTimerInterval);
measurementTimerInterval = null;
}
// Hide timer container
const container = document.getElementById('elapsed-time-container');
if (container) {
container.classList.add('hidden');
}
console.log('Measurement timer stopped');
}
function updateElapsedTime(unitId) {
const startTimeStr = localStorage.getItem(TIMER_STORAGE_KEY + unitId);
if (!startTimeStr) {
return;
}
const startTime = parseInt(startTimeStr);
const now = Date.now();
const elapsedMs = now - startTime;
// Convert to HH:MM:SS
const hours = Math.floor(elapsedMs / (1000 * 60 * 60));
const minutes = Math.floor((elapsedMs % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((elapsedMs % (1000 * 60)) / 1000);
const timeString =
String(hours).padStart(2, '0') + ':' +
String(minutes).padStart(2, '0') + ':' +
String(seconds).padStart(2, '0');
const display = document.getElementById('elapsed-time');
if (display) {
display.textContent = timeString;
}
}
function clearMeasurementStartTime(unitId) {
localStorage.removeItem(TIMER_STORAGE_KEY + unitId);
console.log('Cleared measurement start time for', unitId);
}
// Resume timer if measurement is in progress
async function resumeMeasurementTimerIfNeeded(unitId, isMeasuring) {
const startTimeStr = localStorage.getItem(TIMER_STORAGE_KEY + unitId);
if (isMeasuring && startTimeStr) {
// Measurement is active and we have a start time - resume timer
startMeasurementTimer(unitId);
} else if (!isMeasuring && startTimeStr) {
// Measurement stopped but we have a start time - clear it
clearMeasurementStartTime(unitId);
stopMeasurementTimer();
} else if (isMeasuring && !startTimeStr) {
// Measurement is active but no start time recorded
// Try to get start time from last folder on FTP
console.log('Measurement active but no start time - fetching from FTP...');
await fetchStartTimeFromFTP(unitId);
}
}
// Fetch measurement start time from last folder on FTP
async function fetchStartTimeFromFTP(unitId) {
try {
console.log('Fetching FTP files from /NL-43...');
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=/NL-43`);
const result = await response.json();
console.log('FTP response status:', response.status);
console.log('FTP response data:', result);
if (result.status === 'ok' && result.files && result.files.length > 0) {
console.log(`Found ${result.files.length} files/folders`);
// Filter for directories only
const folders = result.files.filter(f => f.is_dir || f.type === 'directory');
console.log(`Found ${folders.length} folders:`, folders.map(f => f.name));
if (folders.length > 0) {
// Sort by modified timestamp (newest first) or by name
folders.sort((a, b) => {
// Try sorting by modified_timestamp first (ISO format)
if (a.modified_timestamp && b.modified_timestamp) {
return new Date(b.modified_timestamp) - new Date(a.modified_timestamp);
}
// Fall back to sorting by name (descending, assuming YYYYMMDD_HHMMSS format)
return b.name.localeCompare(a.name);
});
const lastFolder = folders[0];
console.log('Last measurement folder:', lastFolder.name);
console.log('Folder details:', lastFolder);
// Try to parse timestamp from folder name
// Common formats: YYYYMMDD_HHMMSS, YYYY-MM-DD_HH-MM-SS, or use modified time
const startTime = parseFolderTimestamp(lastFolder);
if (startTime) {
console.log('✓ Parsed start time from folder:', new Date(startTime));
console.log('✓ Storing start time in localStorage:', startTime);
localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTime.toString());
startMeasurementTimer(unitId);
} else {
// Can't parse folder time - start from now
console.warn('✗ Could not parse folder timestamp, starting timer from now');
startMeasurementTimer(unitId);
}
} else {
// No folders found - start from now
console.warn('✗ No measurement folders found in FTP response, starting timer from now');
startMeasurementTimer(unitId);
}
} else {
// FTP failed or no files - start from now
console.warn('✗ FTP request failed or returned no files:', result);
startMeasurementTimer(unitId);
}
} catch (error) {
console.error('✗ Error fetching start time from FTP:', error);
// Fallback - start from now
startMeasurementTimer(unitId);
}
}
// Parse timestamp from folder name or modified time
function parseFolderTimestamp(folder) {
console.log('Parsing timestamp from folder:', folder.name);
// Try parsing from folder name first
// Expected formats: YYYYMMDD_HHMMSS or YYYY-MM-DD_HH-MM-SS
const name = folder.name;
// Pattern: YYYYMMDD_HHMMSS (e.g., 20250114_143052)
const pattern1 = /(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})/;
const match1 = name.match(pattern1);
if (match1) {
const [_, year, month, day, hour, min, sec] = match1;
const timestamp = new Date(year, month - 1, day, hour, min, sec).getTime();
console.log(` ✓ Matched pattern YYYYMMDD_HHMMSS: ${new Date(timestamp)}`);
return timestamp;
}
// Pattern: YYYY-MM-DD_HH-MM-SS (e.g., 2025-01-14_14-30-52)
const pattern2 = /(\d{4})-(\d{2})-(\d{2})[_T](\d{2})-(\d{2})-(\d{2})/;
const match2 = name.match(pattern2);
if (match2) {
const [_, year, month, day, hour, min, sec] = match2;
const timestamp = new Date(year, month - 1, day, hour, min, sec).getTime();
console.log(` ✓ Matched pattern YYYY-MM-DD_HH-MM-SS: ${new Date(timestamp)}`);
return timestamp;
}
console.log(' No pattern matched in folder name, trying modified_timestamp field');
// Try using modified_timestamp (ISO format from SLMM - already in UTC)
if (folder.modified_timestamp) {
// SLMM returns timestamps in UTC format without 'Z' suffix
// Append 'Z' to ensure browser parses as UTC
let utcTimestamp = folder.modified_timestamp;
if (!utcTimestamp.endsWith('Z')) {
utcTimestamp = utcTimestamp + 'Z';
}
const timestamp = new Date(utcTimestamp).getTime();
console.log(` ✓ Using modified_timestamp (UTC): ${folder.modified_timestamp}${new Date(timestamp).toISOString()}${new Date(timestamp).toString()}`);
return timestamp;
}
// Fallback to modified (string format)
if (folder.modified) {
const parsedTime = new Date(folder.modified).getTime();
if (!isNaN(parsedTime)) {
console.log(` ✓ Using modified field: ${new Date(parsedTime)}`);
return parsedTime;
}
console.log(` ✗ Could not parse modified field: ${folder.modified}`);
}
// Could not parse
console.log(' ✗ Could not parse any timestamp from folder');
return null;
}
// Apply status data to the DOM (used by bootstrap data and live refresh)
function applyDeviceStatusData(data) {
if (!data) {
return;
}
const batteryLevelRaw = data.battery_level ?? '--';
const batteryLevelElement = document.getElementById('battery-level');
if (batteryLevelElement) {
const displayBattery = batteryLevelRaw === '' || batteryLevelRaw === '--'
? '--'
: `${batteryLevelRaw}%`;
batteryLevelElement.textContent = displayBattery;
}
const batteryBar = document.getElementById('battery-bar');
if (batteryBar && batteryLevelRaw !== '' && batteryLevelRaw !== '--') {
const level = Number(batteryLevelRaw);
if (!Number.isNaN(level)) {
batteryBar.style.width = `${level}%`;
if (level > 50) {
batteryBar.className = 'bg-green-500 h-2 rounded-full transition-all';
} else if (level > 20) {
batteryBar.className = 'bg-yellow-500 h-2 rounded-full transition-all';
} else {
batteryBar.className = 'bg-red-500 h-2 rounded-full transition-all';
}
}
}
if (document.getElementById('power-source')) {
document.getElementById('power-source').textContent = data.power_source || '--';
}
if (document.getElementById('sd-remaining')) {
const sdRemainingRaw = data.sd_remaining_mb ?? '--';
document.getElementById('sd-remaining').textContent = sdRemainingRaw === '--'
? '--'
: `${sdRemainingRaw} MB`;
}
if (document.getElementById('sd-ratio')) {
const sdRatioRaw = data.sd_free_ratio ?? '--';
document.getElementById('sd-ratio').textContent = sdRatioRaw === '--'
? '--'
: `${sdRatioRaw}% free`;
}
}
// Auto-refresh status every 30 seconds
let refreshInterval;
@@ -503,46 +1217,8 @@ function updateDeviceStatus() {
.then(response => response.json())
.then(result => {
if (result.status === 'ok' && result.data) {
const data = result.data;
applyDeviceStatusData(result.data);
// Update battery
if (document.getElementById('battery-level')) {
const batteryLevel = data.battery_level || '--';
document.getElementById('battery-level').textContent = batteryLevel === '--' ? '--' : `${batteryLevel}%`;
// Update battery bar
const batteryBar = document.getElementById('battery-bar');
if (batteryBar && batteryLevel !== '--') {
const level = parseInt(batteryLevel);
batteryBar.style.width = `${level}%`;
// Color based on level
if (level > 50) {
batteryBar.className = 'bg-green-500 h-2 rounded-full transition-all';
} else if (level > 20) {
batteryBar.className = 'bg-yellow-500 h-2 rounded-full transition-all';
} else {
batteryBar.className = 'bg-red-500 h-2 rounded-full transition-all';
}
}
}
// Update power source
if (document.getElementById('power-source')) {
document.getElementById('power-source').textContent = data.power_source || '--';
}
// Update SD card info
if (document.getElementById('sd-remaining')) {
const sdRemaining = data.sd_remaining_mb || '--';
document.getElementById('sd-remaining').textContent = sdRemaining === '--' ? '--' : `${sdRemaining} MB`;
}
if (document.getElementById('sd-ratio')) {
const sdRatio = data.sd_free_ratio || '--';
document.getElementById('sd-ratio').textContent = sdRatio === '--' ? '--' : `${sdRatio}% free`;
}
// Update last update timestamp
if (document.getElementById('last-update')) {
const now = new Date();
document.getElementById('last-update').textContent = now.toLocaleTimeString();
@@ -577,14 +1253,59 @@ function stopAutoRefresh() {
}
}
// Start auto-refresh when page loads
document.addEventListener('DOMContentLoaded', startAutoRefresh);
// Bootstrap data from server
const SLM_BOOTSTRAP_DATA = JSON.parse(document.getElementById('slm-bootstrap-data').textContent);
// Initialize immediately when script loads (HTMX loads this partial after DOMContentLoaded)
(async function() {
console.log('Initializing SLM live view...');
const unitId = SLM_BOOTSTRAP_DATA.unit_id;
const isMeasuring = SLM_BOOTSTRAP_DATA.is_measuring;
const measurementStartTime = SLM_BOOTSTRAP_DATA.measurement_start_time;
console.log('Is measuring:', isMeasuring);
console.log('Measurement start time from backend:', measurementStartTime);
// Start auto-refresh
startAutoRefresh();
// Load initial device settings with delays (NL-43 requires 1 second between commands)
// These are background updates, so we can space them out
setTimeout(() => getIndexNumber(unitId), 1500);
setTimeout(() => getDeviceClock(unitId), 3000);
setTimeout(() => getFrequencyWeighting(unitId), 4500);
setTimeout(() => getTimeWeighting(unitId), 6000);
// Initialize measurement timer if device is currently measuring
if (isMeasuring && measurementStartTime) {
// Backend has synced the start time from FTP, so database timestamp is now accurate
let utcTimestamp = measurementStartTime;
if (!utcTimestamp.endsWith('Z') && !utcTimestamp.includes('+') && !utcTimestamp.includes('-', 10)) {
utcTimestamp = utcTimestamp + 'Z';
}
const startTimeMs = new Date(utcTimestamp).getTime();
localStorage.setItem(TIMER_STORAGE_KEY + unitId, startTimeMs.toString());
console.log('✓ Timer initialized from synced database timestamp');
console.log(' Start time (UTC):', new Date(startTimeMs).toISOString());
console.log(' Start time (Local):', new Date(startTimeMs).toString());
startMeasurementTimer(unitId);
} else if (isMeasuring && !measurementStartTime) {
// Fallback: Measurement active but no start time - try FTP
console.warn('⚠ Measurement active but no start time, fetching from FTP...');
setTimeout(() => resumeMeasurementTimerIfNeeded(unitId, isMeasuring), 500);
}
})();
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
if (window.currentWebSocket) {
window.currentWebSocket.close();
}
// Timer will resume on next page load if measurement is still active
stopMeasurementTimer();
});
// ========================================
@@ -848,12 +1569,22 @@ async function loadFTPFiles(unitId, path) {
if (isDir) {
html += `
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer transition-colors" onclick="loadFTPFiles('${unitId}', '${escapeForAttribute(fullPath)}')">
<div class="flex items-center flex-1">
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors group">
<div class="flex items-center flex-1 cursor-pointer" onclick="loadFTPFiles('${unitId}', '${escapeForAttribute(fullPath)}')">
${icon}
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
</div>
<span class="text-xs text-gray-500">${dateText}</span>
<div class="flex items-center gap-3 flex-shrink-0 ml-4">
<span class="text-xs text-gray-500 hidden sm:inline">${dateText}</span>
<button onclick="event.stopPropagation(); downloadFTPFolderModal('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}', this)"
class="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded transition-colors flex items-center"
title="Download entire folder as ZIP">
<svg class="w-4 h-4 mr-1" 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"></path>
</svg>
<span class="hidden lg:inline">Download ZIP</span>
</button>
</div>
</div>
`;
} else {
@@ -956,6 +1687,58 @@ async function downloadFTPFile(unitId, filePath, fileName) {
}
}
async function downloadFTPFolderModal(unitId, folderPath, folderName, btnElement) {
// Get the button element - either passed as argument or from event
const downloadBtn = btnElement || event.target;
const originalText = downloadBtn.innerHTML;
try {
// Show download indicator
downloadBtn.innerHTML = '<svg class="w-4 h-4 animate-spin" 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"></path></svg>';
downloadBtn.disabled = true;
const response = await fetch(`/api/slmm/${unitId}/ftp/download-folder`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ remote_path: folderPath })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || 'Folder download failed');
}
// The response is a ZIP file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${folderName}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
// Reset button
downloadBtn.innerHTML = originalText;
downloadBtn.disabled = false;
// Show success message briefly
const originalBtnClass = downloadBtn.className;
downloadBtn.className = downloadBtn.className.replace('bg-blue-600', 'bg-green-600');
setTimeout(() => {
downloadBtn.className = originalBtnClass;
}, 2000);
} catch (error) {
console.error('Failed to download folder:', error);
alert('Failed to download folder: ' + error.message);
// Reset button on error
downloadBtn.innerHTML = originalText;
downloadBtn.disabled = false;
}
}
async function downloadToServer(unitId, filePath, fileName) {
try {
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {

View File

@@ -288,6 +288,7 @@
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<option value="seismograph">Seismograph</option>
<option value="modem">Modem</option>
<option value="sound_level_meter">Sound Level Meter</option>
</select>
</div>
<div>
@@ -351,6 +352,56 @@
</div>
</div>
<!-- Sound Level Meter-specific fields -->
<div id="editSlmFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Sound Level Meter Information</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">SLM Model</label>
<input type="text" name="slm_model" id="editSlmModel" placeholder="NL-43"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host/IP Address</label>
<input type="text" name="slm_host" id="editSlmHost" placeholder="192.168.1.100"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
<input type="number" name="slm_tcp_port" id="editSlmTcpPort" placeholder="2255"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
<input type="number" name="slm_ftp_port" id="editSlmFtpPort" placeholder="21"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
<input type="text" name="slm_serial_number" id="editSlmSerialNumber" placeholder="SN123456"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
<select name="slm_frequency_weighting" id="editSlmFrequencyWeighting"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<option value="">Not set</option>
<option value="A">A-weighting</option>
<option value="C">C-weighting</option>
<option value="Z">Z-weighting (Flat)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
<select name="slm_time_weighting" id="editSlmTimeWeighting"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<option value="">Not set</option>
<option value="F">F (Fast)</option>
<option value="S">S (Slow)</option>
<option value="I">I (Impulse)</option>
</select>
</div>
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="deployed" id="editDeployedCheckbox" value="true" onchange="toggleEditModemPairing()"
@@ -648,20 +699,30 @@
const deviceType = document.getElementById('editDeviceTypeSelect').value;
const seismoFields = document.getElementById('editSeismographFields');
const modemFields = document.getElementById('editModemFields');
const slmFields = document.getElementById('editSlmFields');
if (deviceType === 'seismograph') {
seismoFields.classList.remove('hidden');
modemFields.classList.add('hidden');
// Enable seismograph fields, disable modem fields
slmFields.classList.add('hidden');
setFieldsDisabled(seismoFields, false);
setFieldsDisabled(modemFields, true);
setFieldsDisabled(slmFields, true);
toggleEditModemPairing();
} else {
} else if (deviceType === 'modem') {
seismoFields.classList.add('hidden');
modemFields.classList.remove('hidden');
// Enable modem fields, disable seismograph fields
slmFields.classList.add('hidden');
setFieldsDisabled(seismoFields, true);
setFieldsDisabled(modemFields, false);
setFieldsDisabled(slmFields, true);
} else if (deviceType === 'sound_level_meter') {
seismoFields.classList.add('hidden');
modemFields.classList.add('hidden');
slmFields.classList.remove('hidden');
setFieldsDisabled(seismoFields, true);
setFieldsDisabled(modemFields, true);
setFieldsDisabled(slmFields, false);
}
}
@@ -711,6 +772,15 @@
document.getElementById('editPhoneNumber').value = unit.phone_number;
document.getElementById('editHardwareModel').value = unit.hardware_model;
// SLM fields
document.getElementById('editSlmModel').value = unit.slm_model || '';
document.getElementById('editSlmHost').value = unit.slm_host || '';
document.getElementById('editSlmTcpPort').value = unit.slm_tcp_port || '';
document.getElementById('editSlmFtpPort').value = unit.slm_ftp_port || '';
document.getElementById('editSlmSerialNumber').value = unit.slm_serial_number || '';
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
// Store unit ID for form submission
document.getElementById('editUnitForm').dataset.unitId = unitId;
@@ -1206,6 +1276,18 @@
alert(`Error renaming unit: ${error.message}`);
}
});
// Auto-open edit modal if ?edit=UNIT_ID query parameter is present
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const editUnitId = urlParams.get('edit');
if (editUnitId) {
// Wait a bit for the page to fully load, then open the edit modal
setTimeout(() => {
editUnit(editUnitId);
}, 500);
}
});
</script>
<style>

View File

@@ -859,11 +859,6 @@ async function loadRosterTable() {
function createRosterRow(unit) {
const statusBadges = [];
if (unit.deployed) {
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">Deployed</span>');
} else {
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">Benched</span>');
}
if (unit.retired) {
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300">Retired</span>');
}
@@ -880,8 +875,24 @@ function createRosterRow(unit) {
<div class="text-xs text-gray-500 dark:text-gray-400">${unit.unit_type}</div>
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap gap-1">
${statusBadges.join('')}
<div class="flex flex-col gap-2">
<div class="flex gap-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="deployed-${unit.id}" value="true"
${unit.deployed ? 'checked' : ''}
onchange="toggleDeployed('${unit.id}', true)"
class="w-4 h-4 text-green-600 focus:ring-green-500">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="deployed-${unit.id}" value="false"
${!unit.deployed ? 'checked' : ''}
onchange="toggleDeployed('${unit.id}', false)"
class="w-4 h-4 text-gray-600 focus:ring-gray-500">
<span class="text-sm text-gray-700 dark:text-gray-300">Benched</span>
</label>
</div>
${statusBadges.length > 0 ? '<div class="flex flex-wrap gap-1">' + statusBadges.join('') + '</div>' : ''}
</div>
</td>
<td class="px-4 py-3">
@@ -896,13 +907,6 @@ function createRosterRow(unit) {
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
<div class="flex justify-end gap-1">
<button onclick="toggleDeployed('${unit.id}', ${unit.deployed})"
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
title="${unit.deployed ? 'Bench Unit' : 'Deploy Unit'}">
<svg class="w-4 h-4 ${unit.deployed ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</button>
<button onclick="toggleRetired('${unit.id}', ${unit.retired})"
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
title="${unit.retired ? 'Unretire Unit' : 'Retire Unit'}">
@@ -930,12 +934,12 @@ function createRosterRow(unit) {
`;
}
async function toggleDeployed(unitId, currentState) {
async function toggleDeployed(unitId, newState) {
try {
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `deployed=${!currentState}`
body: `deployed=${newState}`
});
if (response.ok) {
@@ -969,7 +973,7 @@ async function toggleRetired(unitId, currentState) {
}
function editUnit(unitId) {
window.location.href = `/unit/${unitId}`;
window.location.href = `/roster?edit=${unitId}`;
}
async function confirmDeleteUnit(unitId) {

View File

@@ -43,7 +43,7 @@
</button>
</div>
<div class="flex gap-3">
<button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
<button id="editButton" onclick="window.location.href='/roster?edit=' + unitId" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>