Files
terra-view/templates/nrl_detail.html
serversdwn 8431784708 feat: Refactor template handling, improve scheduler functions, and add timezone utilities
- Moved Jinja2 template setup to a shared configuration file (templates_config.py) for consistent usage across routers.
- Introduced timezone utilities in a new module (timezone.py) to handle UTC to local time conversions and formatting.
- Updated all relevant routers to use the new shared template configuration and timezone filters.
- Enhanced templates to utilize local time formatting for various datetime fields, improving user experience with timezone awareness.
2026-01-23 06:05:39 +00:00

564 lines
27 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ location.name }} - NRL Detail{% endblock %}
{% block content %}
<!-- Breadcrumb Navigation -->
<div class="mb-6">
<nav class="flex items-center space-x-2 text-sm">
<a href="/projects" 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>
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/{{ project_id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ 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">{{ location.name }}</span>
</nav>
</div>
<!-- Header -->
<div class="mb-8">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
{{ location.name }}
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Noise Recording Location • {{ project.name }}
</p>
</div>
<div class="flex gap-2">
{% if assigned_unit %}
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Unit Assigned
</span>
{% else %}
<span class="px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
No Unit Assigned
</span>
{% endif %}
</div>
</div>
</div>
<!-- Tab Navigation -->
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav class="flex space-x-6">
<button onclick="switchTab('overview')"
data-tab="overview"
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
Overview
</button>
<button onclick="switchTab('settings')"
data-tab="settings"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Settings
</button>
{% if assigned_unit %}
<button onclick="switchTab('command')"
data-tab="command"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Command Center
</button>
{% endif %}
<button onclick="switchTab('sessions')"
data-tab="sessions"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Recording Sessions
</button>
<button onclick="switchTab('data')"
data-tab="data"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Data Files
</button>
</nav>
</div>
<!-- Tab Content -->
<div id="tab-content">
<!-- Overview Tab -->
<div id="overview-tab" class="tab-panel">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Location Details Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location Details</h2>
<div class="space-y-4">
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Name</div>
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ location.name }}</div>
</div>
{% if location.description %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Description</div>
<div class="text-gray-900 dark:text-white">{{ location.description }}</div>
</div>
{% endif %}
{% if location.address %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Address</div>
<div class="text-gray-900 dark:text-white">{{ location.address }}</div>
</div>
{% endif %}
{% if location.coordinates %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Coordinates</div>
<div class="text-gray-900 dark:text-white font-mono text-sm">{{ location.coordinates }}</div>
</div>
{% endif %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Created</div>
<div class="text-gray-900 dark:text-white">{{ location.created_at|local_datetime if location.created_at else 'N/A' }}</div>
</div>
</div>
</div>
<!-- Assignment Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
{% if assigned_unit %}
<div class="space-y-4">
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
<div class="text-lg font-medium text-gray-900 dark:text-white">
<a href="/slm/{{ assigned_unit.id }}?from_project={{ project_id }}&from_nrl={{ location_id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ assigned_unit.id }}
</a>
</div>
</div>
{% if assigned_unit.slm_model %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
<div class="text-gray-900 dark:text-white">{{ assigned_unit.slm_model }}</div>
</div>
{% endif %}
{% if assignment %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at|local_datetime if assignment.assigned_at else 'N/A' }}</div>
</div>
{% if assignment.notes %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
<div class="text-gray-900 dark:text-white text-sm">{{ assignment.notes }}</div>
</div>
{% endif %}
{% endif %}
<div class="pt-2">
<button onclick="unassignUnit('{{ assignment.id }}')"
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
Unassign Unit
</button>
</div>
</div>
{% else %}
<div class="text-center py-8">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
<button onclick="openAssignModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
Assign a Unit
</button>
</div>
{% endif %}
</div>
</div>
<!-- Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Sessions</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ session_count }}</p>
</div>
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Data Files</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ file_count }}</p>
</div>
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Active Session</p>
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
{% if active_session %}
<span class="text-green-600 dark:text-green-400">Recording</span>
{% else %}
<span class="text-gray-500">Idle</span>
{% endif %}
</p>
</div>
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" 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>
</div>
</div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div id="settings-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Location Settings</h2>
<form id="location-settings-form" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
<input type="text" id="settings-name" value="{{ location.name }}"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
<textarea id="settings-description" rows="3"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">{{ location.description or '' }}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" id="settings-address" value="{{ location.address or '' }}"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
<input type="text" id="settings-coordinates" value="{{ location.coordinates or '' }}"
placeholder="40.7128,-74.0060"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
</div>
<div id="settings-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="window.location.href='/projects/{{ project_id }}'"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Save Changes
</button>
</div>
</form>
</div>
</div>
<!-- Command Center Tab -->
{% if assigned_unit %}
<div id="command-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
SLM Command Center - {{ assigned_unit.id }}
</h2>
<div id="slm-command-center"
hx-get="/api/slm-dashboard/live-view/{{ assigned_unit.id if assigned_unit else '' }}"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-seismo-orange mx-auto mb-4"></div>
<p>Loading command center...</p>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Recording Sessions Tab -->
<div id="sessions-tab" class="tab-panel hidden">
<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">Recording Sessions</h2>
{% if assigned_unit %}
<button onclick="openScheduleModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
Schedule Session
</button>
{% endif %}
</div>
<div id="sessions-list"
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/sessions"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
</div>
</div>
</div>
<!-- Data Files Tab -->
<div id="data-tab" class="tab-panel hidden">
<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>
<div class="text-sm text-gray-500">
<span class="font-medium">{{ file_count }}</span> files
</div>
</div>
<div id="data-files-list"
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/files"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading data files...</div>
</div>
</div>
</div>
</div>
<!-- Assign Unit Modal -->
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a sound level meter to this location</p>
</div>
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="assign-form" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
<select id="assign-unit-id" name="unit_id"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
<option value="">Loading units...</option>
</select>
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available units for this location type.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="assign-notes" name="notes" rows="2"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
</div>
<div id="assign-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeAssignModal()"
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Assign Unit
</button>
</div>
</form>
</div>
</div>
<script>
const projectId = "{{ project_id }}";
const locationId = "{{ location_id }}";
// Tab switching
function switchTab(tabName) {
// Hide all tab panels
document.querySelectorAll('.tab-panel').forEach(panel => {
panel.classList.add('hidden');
});
// Reset all tab buttons
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
});
// Show selected tab panel
const panel = document.getElementById(`${tabName}-tab`);
if (panel) {
panel.classList.remove('hidden');
}
// Highlight selected tab button
const button = document.querySelector(`[data-tab="${tabName}"]`);
if (button) {
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
button.classList.add('border-seismo-orange', 'text-seismo-orange');
}
}
// Location settings form submission
document.getElementById('location-settings-form').addEventListener('submit', async function(e) {
e.preventDefault();
const payload = {
name: document.getElementById('settings-name').value.trim(),
description: document.getElementById('settings-description').value.trim() || null,
address: document.getElementById('settings-address').value.trim() || null,
coordinates: document.getElementById('settings-coordinates').value.trim() || null,
};
try {
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to update location');
}
window.location.reload();
} catch (err) {
const errorEl = document.getElementById('settings-error');
errorEl.textContent = err.message || 'Failed to update location.';
errorEl.classList.remove('hidden');
}
});
// Assign modal functions
function openAssignModal() {
const modal = document.getElementById('assign-modal');
modal.classList.remove('hidden');
loadAvailableUnits();
}
function closeAssignModal() {
document.getElementById('assign-modal').classList.add('hidden');
}
async function loadAvailableUnits() {
try {
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=sound`);
if (!response.ok) {
throw new Error('Failed to load available units');
}
const data = await response.json();
const select = document.getElementById('assign-unit-id');
select.innerHTML = '<option value="">Select a unit</option>';
if (!data.length) {
document.getElementById('assign-empty').classList.remove('hidden');
return;
}
data.forEach(unit => {
const option = document.createElement('option');
option.value = unit.id;
option.textContent = `${unit.id}${unit.model || unit.device_type}`;
select.appendChild(option);
});
} catch (err) {
const errorEl = document.getElementById('assign-error');
errorEl.textContent = err.message || 'Failed to load units.';
errorEl.classList.remove('hidden');
}
}
document.getElementById('assign-form').addEventListener('submit', async function(e) {
e.preventDefault();
const unitId = document.getElementById('assign-unit-id').value;
const notes = document.getElementById('assign-notes').value.trim();
if (!unitId) {
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
document.getElementById('assign-error').classList.remove('hidden');
return;
}
try {
const formData = new FormData();
formData.append('unit_id', unitId);
formData.append('notes', notes);
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to assign unit');
}
window.location.reload();
} catch (err) {
const errorEl = document.getElementById('assign-error');
errorEl.textContent = err.message || 'Failed to assign unit.';
errorEl.classList.remove('hidden');
}
});
async function unassignUnit(assignmentId) {
if (!confirm('Unassign this unit from the location?')) return;
try {
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
method: 'POST'
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to unassign unit');
}
window.location.reload();
} catch (err) {
alert(err.message || 'Failed to unassign unit.');
}
}
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeAssignModal();
}
});
// Click outside to close modal
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeAssignModal();
}
});
</script>
{% endblock %}