Compare commits
2 Commits
be83cb3fe7
...
7971092509
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7971092509 | ||
|
|
d349af9444 |
@@ -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
|
# Project Types
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
|||||||
is_measuring = False
|
is_measuring = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
# Get measurement state
|
# Get measurement state
|
||||||
state_response = await client.get(
|
state_response = await client.get(
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
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")
|
measurement_state = state_data.get("measurement_state", "Unknown")
|
||||||
is_measuring = state_data.get("is_measuring", False)
|
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(
|
status_response = await client.get(
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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">
|
<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>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Download Files from SLMs</h2>
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
disabled>
|
disabled>
|
||||||
Disable FTP
|
Disable FTP
|
||||||
</button>
|
</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 }}"
|
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"
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"
|
||||||
disabled>
|
disabled>
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<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>
|
</svg>
|
||||||
<span id="current-path-{{ unit_item.unit.id }}" class="text-sm font-mono text-gray-600 dark:text-gray-400">/NL43_DATA</span>
|
<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 }}', '/NL43_DATA')"
|
<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">
|
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">
|
<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>
|
<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) {
|
if (response.ok) {
|
||||||
await checkFTPStatus(unitId);
|
await checkFTPStatus(unitId);
|
||||||
// Auto-load files after enabling
|
// Auto-load files after enabling
|
||||||
setTimeout(() => loadFTPFiles(unitId, '/NL43_DATA'), 1000);
|
setTimeout(() => loadFTPFiles(unitId, '/NL-43'), 1000);
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to enable FTP');
|
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>';
|
: '<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 sizeStr = file.is_dir ? '' : formatFileSize(file.size);
|
||||||
const clickAction = file.is_dir
|
|
||||||
? `onclick="loadFTPFiles('${unitId}', '${file.path}')"`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
html += `
|
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}
|
${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-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 class="text-xs text-gray-500 dark:text-gray-400">${file.modified}${sizeStr ? ' • ' + sizeStr : ''}</div>
|
||||||
</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">
|
<div class="flex items-center gap-2">
|
||||||
<button onclick="downloadToServer('${unitId}', '${file.path}', '${file.name}')"
|
<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"
|
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
|
To Browser
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
`}
|
||||||
</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) {
|
async function downloadToServer(unitId, remotePath, fileName) {
|
||||||
const btn = event.target;
|
const btn = event.target;
|
||||||
const originalText = btn.innerHTML;
|
const originalText = btn.innerHTML;
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
<!-- Live View Panel for {{ unit.id }} -->
|
<!-- Live View Panel for {{ unit.id }} -->
|
||||||
<div class="h-full flex flex-col">
|
<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 -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -45,6 +55,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Measurement Status Badge -->
|
<!-- Measurement Status Badge -->
|
||||||
|
<div class="flex flex-col items-end gap-2">
|
||||||
<div>
|
<div>
|
||||||
{% 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">
|
||||||
@@ -57,12 +68,24 @@
|
|||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Control Buttons -->
|
<!-- Measurement Controls -->
|
||||||
<div class="flex gap-2 mb-6">
|
<div class="mb-6 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
<button onclick="controlUnit('{{ unit.id }}', 'start')"
|
<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">
|
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">
|
<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>
|
<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
|
Reset
|
||||||
</button>
|
</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 }}')"
|
<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">
|
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">
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -113,6 +144,7 @@
|
|||||||
Stop Live Stream
|
Stop Live Stream
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Current Metrics -->
|
<!-- Current Metrics -->
|
||||||
<div class="grid grid-cols-5 gap-4 mb-6">
|
<div class="grid grid-cols-5 gap-4 mb-6">
|
||||||
@@ -179,7 +211,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-2 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<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"
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,6 +263,116 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
@@ -477,10 +619,19 @@ async function controlUnit(unitId, action) {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.status === 'ok') {
|
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
|
// Reload the live view to update status
|
||||||
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: '#slm-command-center',
|
||||||
swap: 'innerHTML'
|
swap: 'innerHTML'
|
||||||
});
|
});
|
||||||
}, 500);
|
}, 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
|
// Auto-refresh status every 30 seconds
|
||||||
let refreshInterval;
|
let refreshInterval;
|
||||||
@@ -503,46 +1217,8 @@ function updateDeviceStatus() {
|
|||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (result.status === 'ok' && result.data) {
|
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')) {
|
if (document.getElementById('last-update')) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
document.getElementById('last-update').textContent = now.toLocaleTimeString();
|
document.getElementById('last-update').textContent = now.toLocaleTimeString();
|
||||||
@@ -577,14 +1253,59 @@ function stopAutoRefresh() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start auto-refresh when page loads
|
// Bootstrap data from server
|
||||||
document.addEventListener('DOMContentLoaded', startAutoRefresh);
|
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
|
// Cleanup on page unload
|
||||||
window.addEventListener('beforeunload', function() {
|
window.addEventListener('beforeunload', function() {
|
||||||
if (window.currentWebSocket) {
|
if (window.currentWebSocket) {
|
||||||
window.currentWebSocket.close();
|
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) {
|
if (isDir) {
|
||||||
html += `
|
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 justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors group">
|
||||||
<div class="flex items-center flex-1">
|
<div class="flex items-center flex-1 cursor-pointer" onclick="loadFTPFiles('${unitId}', '${escapeForAttribute(fullPath)}')">
|
||||||
${icon}
|
${icon}
|
||||||
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
} 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) {
|
async function downloadToServer(unitId, filePath, fileName) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
|
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
|
||||||
|
|||||||
@@ -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">
|
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="seismograph">Seismograph</option>
|
||||||
<option value="modem">Modem</option>
|
<option value="modem">Modem</option>
|
||||||
|
<option value="sound_level_meter">Sound Level Meter</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -351,6 +352,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex items-center gap-4">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" name="deployed" id="editDeployedCheckbox" value="true" onchange="toggleEditModemPairing()"
|
<input type="checkbox" name="deployed" id="editDeployedCheckbox" value="true" onchange="toggleEditModemPairing()"
|
||||||
@@ -648,20 +699,30 @@
|
|||||||
const deviceType = document.getElementById('editDeviceTypeSelect').value;
|
const deviceType = document.getElementById('editDeviceTypeSelect').value;
|
||||||
const seismoFields = document.getElementById('editSeismographFields');
|
const seismoFields = document.getElementById('editSeismographFields');
|
||||||
const modemFields = document.getElementById('editModemFields');
|
const modemFields = document.getElementById('editModemFields');
|
||||||
|
const slmFields = document.getElementById('editSlmFields');
|
||||||
|
|
||||||
if (deviceType === 'seismograph') {
|
if (deviceType === 'seismograph') {
|
||||||
seismoFields.classList.remove('hidden');
|
seismoFields.classList.remove('hidden');
|
||||||
modemFields.classList.add('hidden');
|
modemFields.classList.add('hidden');
|
||||||
// Enable seismograph fields, disable modem fields
|
slmFields.classList.add('hidden');
|
||||||
setFieldsDisabled(seismoFields, false);
|
setFieldsDisabled(seismoFields, false);
|
||||||
setFieldsDisabled(modemFields, true);
|
setFieldsDisabled(modemFields, true);
|
||||||
|
setFieldsDisabled(slmFields, true);
|
||||||
toggleEditModemPairing();
|
toggleEditModemPairing();
|
||||||
} else {
|
} else if (deviceType === 'modem') {
|
||||||
seismoFields.classList.add('hidden');
|
seismoFields.classList.add('hidden');
|
||||||
modemFields.classList.remove('hidden');
|
modemFields.classList.remove('hidden');
|
||||||
// Enable modem fields, disable seismograph fields
|
slmFields.classList.add('hidden');
|
||||||
setFieldsDisabled(seismoFields, true);
|
setFieldsDisabled(seismoFields, true);
|
||||||
setFieldsDisabled(modemFields, false);
|
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('editPhoneNumber').value = unit.phone_number;
|
||||||
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
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
|
// Store unit ID for form submission
|
||||||
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
||||||
|
|
||||||
@@ -1206,6 +1276,18 @@
|
|||||||
alert(`Error renaming unit: ${error.message}`);
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -859,11 +859,6 @@ async function loadRosterTable() {
|
|||||||
|
|
||||||
function createRosterRow(unit) {
|
function createRosterRow(unit) {
|
||||||
const statusBadges = [];
|
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) {
|
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>');
|
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>
|
<div class="text-xs text-gray-500 dark:text-gray-400">${unit.unit_type}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-col gap-2">
|
||||||
${statusBadges.join('')}
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
@@ -896,13 +907,6 @@ function createRosterRow(unit) {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
<div class="flex justify-end gap-1">
|
<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})"
|
<button onclick="toggleRetired('${unit.id}', ${unit.retired})"
|
||||||
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
||||||
title="${unit.retired ? 'Unretire Unit' : 'Retire Unit'}">
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: `deployed=${!currentState}`
|
body: `deployed=${newState}`
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -969,7 +973,7 @@ async function toggleRetired(unitId, currentState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editUnit(unitId) {
|
function editUnit(unitId) {
|
||||||
window.location.href = `/unit/${unitId}`;
|
window.location.href = `/roster?edit=${unitId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDeleteUnit(unitId) {
|
async function confirmDeleteUnit(unitId) {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<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">
|
<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>
|
<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>
|
</svg>
|
||||||
|
|||||||
Reference in New Issue
Block a user