SLM return to project button added.

This commit is contained in:
serversdwn
2026-01-13 18:57:31 +00:00
parent d93785c230
commit e9216b9abc
8 changed files with 588 additions and 22 deletions

View File

@@ -7,7 +7,7 @@ from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
from fastapi.exceptions import RequestValidationError from fastapi.exceptions import RequestValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Dict from typing import List, Dict, Optional
from pydantic import BaseModel from pydantic import BaseModel
# Configure logging # Configure logging
@@ -158,11 +158,24 @@ async def sound_level_meters_page(request: Request):
@app.get("/slm/{unit_id}", response_class=HTMLResponse) @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""" """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", { return templates.TemplateResponse("slm_legacy_dashboard.html", {
"request": request, "request": request,
"unit_id": unit_id "unit_id": unit_id,
"from_project": from_project,
"project": project
}) })

View File

@@ -17,6 +17,7 @@ from datetime import datetime, timedelta
from typing import Optional from typing import Optional
import uuid import uuid
import json import json
import logging
from backend.database import get_db from backend.database import get_db
from backend.models import ( from backend.models import (
@@ -31,6 +32,7 @@ from backend.models import (
router = APIRouter(prefix="/api/projects", tags=["projects"]) router = APIRouter(prefix="/api/projects", tags=["projects"])
templates = Jinja2Templates(directory="templates") 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 # Project Types
# ============================================================================ # ============================================================================

View 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>

View File

@@ -33,7 +33,7 @@
{% if item.unit %} {% if item.unit %}
<div> <div>
<span class="text-xs text-gray-500 dark:text-gray-500">Unit:</span> <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 }} {{ item.unit.id }}
</a> </a>
</div> </div>

View File

@@ -7,7 +7,7 @@
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<h4 class="font-semibold text-gray-900 dark:text-white"> <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 }} {{ item.unit.id }}
</a> </a>
</h4> </h4>
@@ -73,7 +73,7 @@
</div> </div>
<div class="flex items-center gap-2"> <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"> class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
View Unit View Unit
</a> </a>

View File

@@ -180,9 +180,20 @@
<!-- Data Files Tab --> <!-- Data Files Tab -->
<div id="data-tab" class="tab-panel hidden"> <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="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-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"> <div class="flex items-center gap-4">
<select id="files-filter" onchange="filterFiles()" <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"> 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">

View File

@@ -4,12 +4,27 @@
{% block content %} {% block content %}
<div class="mb-6"> <div class="mb-6">
{% 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"> <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"> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg> </svg>
Back to Roster Back to Roster
</a> </a>
{% endif %}
</div> </div>
<div class="mb-8"> <div class="mb-8">

View File

@@ -6,6 +6,22 @@
<!-- Breadcrumb Navigation --> <!-- Breadcrumb Navigation -->
<div class="mb-6"> <div class="mb-6">
<nav class="flex items-center space-x-2 text-sm"> <nav class="flex items-center space-x-2 text-sm">
{% 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>
<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"> <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"> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
@@ -16,6 +32,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg> </svg>
<span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span> <span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span>
{% endif %}
</nav> </nav>
</div> </div>