SLM return to project button added.
This commit is contained in:
@@ -7,7 +7,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict
|
||||
from typing import List, Dict, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Configure logging
|
||||
@@ -158,11 +158,24 @@ async def sound_level_meters_page(request: Request):
|
||||
|
||||
|
||||
@app.get("/slm/{unit_id}", response_class=HTMLResponse)
|
||||
async def slm_legacy_dashboard(request: Request, unit_id: str):
|
||||
async def slm_legacy_dashboard(
|
||||
request: Request,
|
||||
unit_id: str,
|
||||
from_project: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Legacy SLM control center dashboard for a specific unit"""
|
||||
# Get project details if from_project is provided
|
||||
project = None
|
||||
if from_project:
|
||||
from backend.models import Project
|
||||
project = db.query(Project).filter_by(id=from_project).first()
|
||||
|
||||
return templates.TemplateResponse("slm_legacy_dashboard.html", {
|
||||
"request": request,
|
||||
"unit_id": unit_id
|
||||
"unit_id": unit_id,
|
||||
"from_project": from_project,
|
||||
"project": project
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import (
|
||||
@@ -31,6 +32,7 @@ from backend.models import (
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -565,6 +567,183 @@ async def get_project_files(
|
||||
})
|
||||
|
||||
|
||||
@router.get("/{project_id}/ftp-browser", response_class=HTMLResponse)
|
||||
async def get_ftp_browser(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get FTP browser interface for downloading files from assigned SLMs.
|
||||
Returns HTML partial with FTP browser.
|
||||
"""
|
||||
from backend.models import DataFile
|
||||
|
||||
# Get all assignments for this project
|
||||
assignments = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.project_id == project_id,
|
||||
UnitAssignment.status == "active",
|
||||
)
|
||||
).all()
|
||||
|
||||
# Enrich with unit and location details
|
||||
units_data = []
|
||||
for assignment in assignments:
|
||||
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||
|
||||
# Only include SLM units
|
||||
if unit and unit.device_type == "sound_level_meter":
|
||||
units_data.append({
|
||||
"assignment": assignment,
|
||||
"unit": unit,
|
||||
"location": location,
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("partials/projects/ftp_browser.html", {
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"units": units_data,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/{project_id}/ftp-download-to-server")
|
||||
async def ftp_download_to_server(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Download a file from an SLM to the server via FTP.
|
||||
Creates a DataFile record and stores the file 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 download"
|
||||
)
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
|
||||
# Download file from SLMM
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
response = await client.post(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download",
|
||||
json={"remote_path": remote_path}
|
||||
)
|
||||
|
||||
if not response.is_success:
|
||||
raise HTTPException(
|
||||
status_code=response.status_code,
|
||||
detail=f"Failed to download from SLMM: {response.text}"
|
||||
)
|
||||
|
||||
# Extract filename from remote_path
|
||||
filename = os.path.basename(remote_path)
|
||||
|
||||
# Determine file type from extension
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
file_type_map = {
|
||||
'.wav': 'audio',
|
||||
'.mp3': 'audio',
|
||||
'.csv': 'data',
|
||||
'.txt': 'data',
|
||||
'.log': 'log',
|
||||
'.json': 'data',
|
||||
}
|
||||
file_type = file_type_map.get(ext, 'data')
|
||||
|
||||
# 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 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=file_type,
|
||||
file_size_bytes=len(file_content),
|
||||
downloaded_at=datetime.utcnow(),
|
||||
checksum=checksum,
|
||||
file_metadata=json.dumps({
|
||||
"source": "ftp",
|
||||
"remote_path": remote_path,
|
||||
"unit_id": unit_id,
|
||||
"location_id": location_id,
|
||||
})
|
||||
)
|
||||
|
||||
db.add(data_file)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Downloaded {filename} to server",
|
||||
"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 file from SLM"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading file to server: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to download file to server: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Project Types
|
||||
# ============================================================================
|
||||
|
||||
331
templates/partials/projects/ftp_browser.html
Normal file
331
templates/partials/projects/ftp_browser.html
Normal file
@@ -0,0 +1,331 @@
|
||||
<!-- FTP File Browser for SLMs -->
|
||||
<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>
|
||||
|
||||
{% if units %}
|
||||
<div class="space-y-6">
|
||||
{% for unit_item in units %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<!-- Unit Header -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ unit_item.unit.id }}
|
||||
</h3>
|
||||
{% if unit_item.location %}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
@ {{ unit_item.location.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span id="ftp-status-{{ unit_item.unit.id }}" class="px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
Checking...
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick="enableFTP('{{ unit_item.unit.id }}')"
|
||||
id="enable-ftp-{{ unit_item.unit.id }}"
|
||||
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
disabled>
|
||||
Enable FTP
|
||||
</button>
|
||||
<button onclick="disableFTP('{{ unit_item.unit.id }}')"
|
||||
id="disable-ftp-{{ unit_item.unit.id }}"
|
||||
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
disabled>
|
||||
Disable FTP
|
||||
</button>
|
||||
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL43_DATA')"
|
||||
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>
|
||||
Browse Files
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FTP File List -->
|
||||
<div id="ftp-files-{{ unit_item.unit.id }}" class="hidden" data-location-id="{{ unit_item.location.id if unit_item.location else '' }}">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<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')"
|
||||
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>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ftp-file-list-{{ unit_item.unit.id }}" class="space-y-1">
|
||||
<!-- Files will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
||||
</svg>
|
||||
<p>No units assigned to this project</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Check FTP status for all units on load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% for unit_item in units %}
|
||||
checkFTPStatus('{{ unit_item.unit.id }}');
|
||||
{% endfor %}
|
||||
});
|
||||
|
||||
async function checkFTPStatus(unitId) {
|
||||
const statusSpan = document.getElementById(`ftp-status-${unitId}`);
|
||||
const enableBtn = document.getElementById(`enable-ftp-${unitId}`);
|
||||
const disableBtn = document.getElementById(`disable-ftp-${unitId}`);
|
||||
const browseBtn = document.getElementById(`browse-ftp-${unitId}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/status`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ftp_enabled) {
|
||||
statusSpan.textContent = 'FTP Enabled';
|
||||
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
enableBtn.disabled = true;
|
||||
disableBtn.disabled = false;
|
||||
browseBtn.disabled = false;
|
||||
} else {
|
||||
statusSpan.textContent = 'FTP Disabled';
|
||||
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||
enableBtn.disabled = false;
|
||||
disableBtn.disabled = true;
|
||||
browseBtn.disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
statusSpan.textContent = 'Error';
|
||||
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||
console.error('Error checking FTP status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableFTP(unitId) {
|
||||
const enableBtn = document.getElementById(`enable-ftp-${unitId}`);
|
||||
enableBtn.disabled = true;
|
||||
enableBtn.textContent = 'Enabling...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/enable`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await checkFTPStatus(unitId);
|
||||
// Auto-load files after enabling
|
||||
setTimeout(() => loadFTPFiles(unitId, '/NL43_DATA'), 1000);
|
||||
} else {
|
||||
alert('Failed to enable FTP');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error enabling FTP: ' + error);
|
||||
} finally {
|
||||
enableBtn.textContent = 'Enable FTP';
|
||||
enableBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disableFTP(unitId) {
|
||||
if (!confirm('Disable FTP on this unit? This will close the FTP connection.')) return;
|
||||
|
||||
const disableBtn = document.getElementById(`disable-ftp-${unitId}`);
|
||||
disableBtn.disabled = true;
|
||||
disableBtn.textContent = 'Disabling...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/disable`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await checkFTPStatus(unitId);
|
||||
// Hide file list
|
||||
document.getElementById(`ftp-files-${unitId}`).classList.add('hidden');
|
||||
} else {
|
||||
alert('Failed to disable FTP');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error disabling FTP: ' + error);
|
||||
} finally {
|
||||
disableBtn.textContent = 'Disable FTP';
|
||||
disableBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFTPFiles(unitId, path) {
|
||||
const fileListDiv = document.getElementById(`ftp-file-list-${unitId}`);
|
||||
const filesContainer = document.getElementById(`ftp-files-${unitId}`);
|
||||
const currentPathSpan = document.getElementById(`current-path-${unitId}`);
|
||||
|
||||
// Show loading
|
||||
fileListDiv.innerHTML = '<div class="text-center py-4 text-gray-500">Loading files...</div>';
|
||||
filesContainer.classList.remove('hidden');
|
||||
currentPathSpan.textContent = path;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=${encodeURIComponent(path)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.files || data.files.length === 0) {
|
||||
fileListDiv.innerHTML = '<div class="text-center py-4 text-gray-500">No files found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort: directories first, then files
|
||||
const sorted = data.files.sort((a, b) => {
|
||||
if (a.is_dir && !b.is_dir) return -1;
|
||||
if (!a.is_dir && b.is_dir) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Render file list
|
||||
let html = '';
|
||||
for (const file of sorted) {
|
||||
const icon = file.is_dir
|
||||
? '<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></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 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}>
|
||||
${icon}
|
||||
<div class="flex-1 min-w-0">
|
||||
<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 ? `
|
||||
<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"
|
||||
title="Download 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
|
||||
</button>
|
||||
<button onclick="downloadFTPFile('${unitId}', '${file.path}', '${file.name}')"
|
||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors"
|
||||
title="Download directly 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
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
fileListDiv.innerHTML = html;
|
||||
} catch (error) {
|
||||
fileListDiv.innerHTML = '<div class="text-center py-4 text-red-500">Error loading files: ' + error + '</div>';
|
||||
console.error('Error loading FTP files:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFTPFile(unitId, remotePath, fileName) {
|
||||
const btn = event.target;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Downloading...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
|
||||
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 = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
alert('Download failed: ' + (errorData.detail || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error downloading file: ' + error);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Download';
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadToServer(unitId, remotePath, fileName) {
|
||||
const btn = event.target;
|
||||
const originalText = 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"><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>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-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(`✓ ${fileName} downloaded to server successfully!\n\nFile ID: ${data.file_id}\nSize: ${formatFileSize(data.file_size)}`);
|
||||
|
||||
// Refresh the downloaded files list
|
||||
htmx.trigger('#project-files', 'refresh');
|
||||
} else {
|
||||
alert('Download to server failed: ' + (data.detail || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error downloading to server: ' + error);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(2) + ' GB';
|
||||
}
|
||||
</script>
|
||||
@@ -33,7 +33,7 @@
|
||||
{% if item.unit %}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-500">Unit:</span>
|
||||
<a href="/slm/{{ item.unit.id }}" class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
||||
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}" class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
||||
{{ item.unit.id }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white">
|
||||
<a href="/slm/{{ item.unit.id }}" class="hover:text-seismo-orange">
|
||||
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}" class="hover:text-seismo-orange">
|
||||
{{ item.unit.id }}
|
||||
</a>
|
||||
</h4>
|
||||
@@ -73,7 +73,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/slm/{{ item.unit.id }}"
|
||||
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}"
|
||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
View Unit
|
||||
</a>
|
||||
|
||||
@@ -180,9 +180,20 @@
|
||||
|
||||
<!-- Data Files Tab -->
|
||||
<div id="data-tab" class="tab-panel hidden">
|
||||
<!-- FTP File Browser -->
|
||||
<div id="ftp-browser"
|
||||
hx-get="/api/projects/{{ project_id }}/ftp-browser"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
||||
<div class="text-center py-8 text-gray-500">Loading FTP browser...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Downloaded Files List -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Downloaded Files</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<select id="files-filter" onchange="filterFiles()"
|
||||
class="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 text-sm">
|
||||
|
||||
@@ -4,12 +4,27 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
||||
<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="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Back to Roster
|
||||
</a>
|
||||
{% if from_project and project %}
|
||||
<nav class="flex items-center space-x-2 text-sm">
|
||||
<a href="/projects" class="text-gray-500 hover:text-seismo-orange">Projects</a>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<a href="/projects/{{ from_project }}" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
||||
<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="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
{{ project.name }}
|
||||
</a>
|
||||
</nav>
|
||||
{% else %}
|
||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
||||
<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="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Back to Roster
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
|
||||
@@ -6,16 +6,33 @@
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<div class="mb-6">
|
||||
<nav class="flex items-center space-x-2 text-sm">
|
||||
<a href="/sound-level-meters" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||
<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="M15 19l-7-7 7-7"></path>
|
||||
{% if from_project and project %}
|
||||
<a href="/projects" class="text-gray-500 hover:text-seismo-orange">Projects</a>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
Sound Level Meters
|
||||
</a>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span>
|
||||
<a href="/projects/{{ from_project }}" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||
<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="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
{{ project.name }}
|
||||
</a>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span>
|
||||
{% else %}
|
||||
<a href="/sound-level-meters" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||
<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="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Sound Level Meters
|
||||
</a>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user