Compare commits
2 Commits
da4e5f66c5
...
8e292b1aca
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e292b1aca | |||
| 7516bbea70 |
@@ -18,7 +18,7 @@ from backend.models import (
|
|||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
ScheduledAction,
|
ScheduledAction,
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
DataFile,
|
DataFile,
|
||||||
)
|
)
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ async def nrl_detail_page(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""NRL (Noise Recording Location) detail page with tabs"""
|
"""NRL (Noise Recording Location) detail page with tabs"""
|
||||||
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile
|
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, MonitoringSession, DataFile
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
# Get project
|
# Get project
|
||||||
@@ -348,23 +348,24 @@ async def nrl_detail_page(
|
|||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
|
||||||
# Get session count
|
# Get session count
|
||||||
session_count = db.query(RecordingSession).filter_by(location_id=location_id).count()
|
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
||||||
|
|
||||||
# Get file count (DataFile links to session, not directly to location)
|
# Get file count (DataFile links to session, not directly to location)
|
||||||
file_count = db.query(DataFile).join(
|
file_count = db.query(DataFile).join(
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
DataFile.session_id == RecordingSession.id
|
DataFile.session_id == MonitoringSession.id
|
||||||
).filter(RecordingSession.location_id == location_id).count()
|
).filter(MonitoringSession.location_id == location_id).count()
|
||||||
|
|
||||||
# Check for active session
|
# Check for active session
|
||||||
active_session = db.query(RecordingSession).filter(
|
active_session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == location_id,
|
MonitoringSession.location_id == location_id,
|
||||||
RecordingSession.status == "recording"
|
MonitoringSession.status == "recording"
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
return templates.TemplateResponse("nrl_detail.html", {
|
template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html"
|
||||||
|
return templates.TemplateResponse(template, {
|
||||||
"request": request,
|
"request": request,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"location_id": location_id,
|
"location_id": location_id,
|
||||||
|
|||||||
54
backend/migrate_rename_recording_to_monitoring_sessions.py
Normal file
54
backend/migrate_rename_recording_to_monitoring_sessions.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Migration: Rename recording_sessions table to monitoring_sessions
|
||||||
|
|
||||||
|
Renames the table and updates the model name from RecordingSession to MonitoringSession.
|
||||||
|
Run once per database: python backend/migrate_rename_recording_to_monitoring_sessions.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(db_path: str):
|
||||||
|
"""Run the migration."""
|
||||||
|
print(f"Migrating database: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='recording_sessions'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='monitoring_sessions'")
|
||||||
|
if cursor.fetchone():
|
||||||
|
print("monitoring_sessions table already exists. Skipping migration.")
|
||||||
|
else:
|
||||||
|
print("recording_sessions table does not exist. Skipping migration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Renaming recording_sessions -> monitoring_sessions...")
|
||||||
|
cursor.execute("ALTER TABLE recording_sessions RENAME TO monitoring_sessions")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Migration completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration failed: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
db_path = "./data/terra-view.db"
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
db_path = sys.argv[1]
|
||||||
|
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
print(f"Database not found: {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
migrate(db_path)
|
||||||
@@ -245,17 +245,17 @@ class ScheduledAction(Base):
|
|||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class RecordingSession(Base):
|
class MonitoringSession(Base):
|
||||||
"""
|
"""
|
||||||
Recording sessions: tracks actual monitoring sessions.
|
Monitoring sessions: tracks actual monitoring sessions.
|
||||||
Created when recording starts, updated when it stops.
|
Created when monitoring starts, updated when it stops.
|
||||||
"""
|
"""
|
||||||
__tablename__ = "recording_sessions"
|
__tablename__ = "monitoring_sessions"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
||||||
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
||||||
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
|
||||||
|
|
||||||
session_type = Column(String, nullable=False) # sound | vibration
|
session_type = Column(String, nullable=False) # sound | vibration
|
||||||
started_at = Column(DateTime, nullable=False)
|
started_at = Column(DateTime, nullable=False)
|
||||||
@@ -278,7 +278,7 @@ class DataFile(Base):
|
|||||||
__tablename__ = "data_files"
|
__tablename__ = "data_files"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
session_id = Column(String, nullable=False, index=True) # FK to RecordingSession.id
|
session_id = Column(String, nullable=False, index=True) # FK to MonitoringSession.id
|
||||||
|
|
||||||
file_path = Column(String, nullable=False) # Relative to data/Projects/
|
file_path = Column(String, nullable=False) # Relative to data/Projects/
|
||||||
file_type = Column(String, nullable=False) # wav, csv, mseed, json
|
file_type = Column(String, nullable=False) # wav, csv, mseed, json
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ from typing import Optional
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from fastapi import UploadFile, File
|
||||||
|
import zipfile
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
Project,
|
Project,
|
||||||
@@ -21,7 +27,8 @@ from backend.models import (
|
|||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
RosterUnit,
|
RosterUnit,
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
|
DataFile,
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
@@ -70,8 +77,8 @@ async def get_project_locations(
|
|||||||
if assignment:
|
if assignment:
|
||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
|
||||||
# Count recording sessions
|
# Count monitoring sessions
|
||||||
session_count = db.query(RecordingSession).filter_by(
|
session_count = db.query(MonitoringSession).filter_by(
|
||||||
location_id=location.id
|
location_id=location.id
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
@@ -370,19 +377,19 @@ async def unassign_unit(
|
|||||||
if not assignment:
|
if not assignment:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
# Check if there are active recording sessions
|
# Check if there are active monitoring sessions
|
||||||
active_sessions = db.query(RecordingSession).filter(
|
active_sessions = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == assignment.location_id,
|
MonitoringSession.location_id == assignment.location_id,
|
||||||
RecordingSession.unit_id == assignment.unit_id,
|
MonitoringSession.unit_id == assignment.unit_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
if active_sessions > 0:
|
if active_sessions > 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Cannot unassign unit with active recording sessions. Stop recording first.",
|
detail="Cannot unassign unit with active monitoring sessions. Stop monitoring first.",
|
||||||
)
|
)
|
||||||
|
|
||||||
assignment.status = "completed"
|
assignment.status = "completed"
|
||||||
@@ -451,14 +458,12 @@ async def get_nrl_sessions(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get recording sessions for a specific NRL.
|
Get monitoring sessions for a specific NRL.
|
||||||
Returns HTML partial with session list.
|
Returns HTML partial with session list.
|
||||||
"""
|
"""
|
||||||
from backend.models import RecordingSession, RosterUnit
|
sessions = db.query(MonitoringSession).filter_by(
|
||||||
|
|
||||||
sessions = db.query(RecordingSession).filter_by(
|
|
||||||
location_id=location_id
|
location_id=location_id
|
||||||
).order_by(RecordingSession.started_at.desc()).all()
|
).order_by(MonitoringSession.started_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with unit details
|
# Enrich with unit details
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
@@ -491,14 +496,12 @@ async def get_nrl_files(
|
|||||||
Get data files for a specific NRL.
|
Get data files for a specific NRL.
|
||||||
Returns HTML partial with file list.
|
Returns HTML partial with file list.
|
||||||
"""
|
"""
|
||||||
from backend.models import DataFile, RecordingSession
|
# Join DataFile with MonitoringSession to filter by location_id
|
||||||
|
|
||||||
# Join DataFile with RecordingSession to filter by location_id
|
|
||||||
files = db.query(DataFile).join(
|
files = db.query(DataFile).join(
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
DataFile.session_id == RecordingSession.id
|
DataFile.session_id == MonitoringSession.id
|
||||||
).filter(
|
).filter(
|
||||||
RecordingSession.location_id == location_id
|
MonitoringSession.location_id == location_id
|
||||||
).order_by(DataFile.created_at.desc()).all()
|
).order_by(DataFile.created_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with session details
|
# Enrich with session details
|
||||||
@@ -506,7 +509,7 @@ async def get_nrl_files(
|
|||||||
for file in files:
|
for file in files:
|
||||||
session = None
|
session = None
|
||||||
if file.session_id:
|
if file.session_id:
|
||||||
session = db.query(RecordingSession).filter_by(id=file.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file.session_id).first()
|
||||||
|
|
||||||
files_data.append({
|
files_data.append({
|
||||||
"file": file,
|
"file": file,
|
||||||
@@ -519,3 +522,217 @@ async def get_nrl_files(
|
|||||||
"location_id": location_id,
|
"location_id": location_id,
|
||||||
"files": files_data,
|
"files": files_data,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Manual SD Card Data Upload
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _parse_rnh(content: bytes) -> dict:
|
||||||
|
"""
|
||||||
|
Parse a Rion .rnh metadata file (INI-style with [Section] headers).
|
||||||
|
Returns a dict of key metadata fields.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8", errors="replace")
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("["):
|
||||||
|
continue
|
||||||
|
if "," in line:
|
||||||
|
key, _, value = line.partition(",")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
if key == "Serial Number":
|
||||||
|
result["serial_number"] = value
|
||||||
|
elif key == "Store Name":
|
||||||
|
result["store_name"] = value
|
||||||
|
elif key == "Index Number":
|
||||||
|
result["index_number"] = value
|
||||||
|
elif key == "Measurement Start Time":
|
||||||
|
result["start_time_str"] = value
|
||||||
|
elif key == "Measurement Stop Time":
|
||||||
|
result["stop_time_str"] = value
|
||||||
|
elif key == "Total Measurement Time":
|
||||||
|
result["total_time_str"] = value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rnh_datetime(s: str):
|
||||||
|
"""Parse RNH datetime string: '2026/02/17 19:00:19' -> datetime"""
|
||||||
|
from datetime import datetime
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_file(filename: str) -> str:
|
||||||
|
"""Classify a file by name into a DataFile file_type."""
|
||||||
|
name = filename.lower()
|
||||||
|
if name.endswith(".rnh"):
|
||||||
|
return "log"
|
||||||
|
if name.endswith(".rnd"):
|
||||||
|
return "measurement"
|
||||||
|
if name.endswith(".zip"):
|
||||||
|
return "archive"
|
||||||
|
return "data"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/nrl/{location_id}/upload-data")
|
||||||
|
async def upload_nrl_data(
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
files: list[UploadFile] = File(...),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Manually upload SD card data for an offline NRL.
|
||||||
|
|
||||||
|
Accepts either:
|
||||||
|
- A single .zip file (the Auto_#### folder zipped) — auto-extracted
|
||||||
|
- Multiple .rnd / .rnh files selected directly from the SD card folder
|
||||||
|
|
||||||
|
Creates a MonitoringSession from .rnh metadata and DataFile records
|
||||||
|
for each measurement file. No unit assignment required.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Verify project and location exist
|
||||||
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
|
id=location_id, project_id=project_id
|
||||||
|
).first()
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
|
||||||
|
# --- Step 1: Normalize to (filename, bytes) list ---
|
||||||
|
file_entries: list[tuple[str, bytes]] = []
|
||||||
|
|
||||||
|
if len(files) == 1 and files[0].filename.lower().endswith(".zip"):
|
||||||
|
raw = await files[0].read()
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
name = Path(info.filename).name # strip folder path
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
file_entries.append((name, zf.read(info)))
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive.")
|
||||||
|
else:
|
||||||
|
for uf in files:
|
||||||
|
data = await uf.read()
|
||||||
|
file_entries.append((uf.filename, data))
|
||||||
|
|
||||||
|
if not file_entries:
|
||||||
|
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
||||||
|
|
||||||
|
# --- Step 2: Find and parse .rnh metadata ---
|
||||||
|
rnh_meta = {}
|
||||||
|
for fname, fbytes in file_entries:
|
||||||
|
if fname.lower().endswith(".rnh"):
|
||||||
|
rnh_meta = _parse_rnh(fbytes)
|
||||||
|
break
|
||||||
|
|
||||||
|
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||||
|
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||||
|
duration_seconds = None
|
||||||
|
if started_at and stopped_at:
|
||||||
|
duration_seconds = int((stopped_at - started_at).total_seconds())
|
||||||
|
|
||||||
|
store_name = rnh_meta.get("store_name", "")
|
||||||
|
serial_number = rnh_meta.get("serial_number", "")
|
||||||
|
index_number = rnh_meta.get("index_number", "")
|
||||||
|
|
||||||
|
# --- Step 3: Create MonitoringSession ---
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
monitoring_session = MonitoringSession(
|
||||||
|
id=session_id,
|
||||||
|
project_id=project_id,
|
||||||
|
location_id=location_id,
|
||||||
|
unit_id=None,
|
||||||
|
session_type="sound",
|
||||||
|
started_at=started_at,
|
||||||
|
stopped_at=stopped_at,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
status="completed",
|
||||||
|
session_metadata=json.dumps({
|
||||||
|
"source": "manual_upload",
|
||||||
|
"store_name": store_name,
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"index_number": index_number,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
db.add(monitoring_session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(monitoring_session)
|
||||||
|
|
||||||
|
# --- Step 4: Write files to disk and create DataFile records ---
|
||||||
|
output_dir = Path("data/Projects") / project_id / session_id
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
leq_count = 0
|
||||||
|
lp_count = 0
|
||||||
|
metadata_count = 0
|
||||||
|
files_imported = 0
|
||||||
|
|
||||||
|
for fname, fbytes in file_entries:
|
||||||
|
file_type = _classify_file(fname)
|
||||||
|
fname_lower = fname.lower()
|
||||||
|
|
||||||
|
# Track counts for summary
|
||||||
|
if fname_lower.endswith(".rnd"):
|
||||||
|
if "_leq_" in fname_lower:
|
||||||
|
leq_count += 1
|
||||||
|
elif "_lp" in fname_lower:
|
||||||
|
lp_count += 1
|
||||||
|
elif fname_lower.endswith(".rnh"):
|
||||||
|
metadata_count += 1
|
||||||
|
|
||||||
|
# Write to disk
|
||||||
|
dest = output_dir / fname
|
||||||
|
dest.write_bytes(fbytes)
|
||||||
|
|
||||||
|
# Compute checksum
|
||||||
|
checksum = hashlib.sha256(fbytes).hexdigest()
|
||||||
|
|
||||||
|
# Store relative path from data/ dir
|
||||||
|
rel_path = str(dest.relative_to("data"))
|
||||||
|
|
||||||
|
data_file = DataFile(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
session_id=session_id,
|
||||||
|
file_path=rel_path,
|
||||||
|
file_type=file_type,
|
||||||
|
file_size_bytes=len(fbytes),
|
||||||
|
downloaded_at=datetime.utcnow(),
|
||||||
|
checksum=checksum,
|
||||||
|
file_metadata=json.dumps({
|
||||||
|
"source": "manual_upload",
|
||||||
|
"original_filename": fname,
|
||||||
|
"store_name": store_name,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
db.add(data_file)
|
||||||
|
files_imported += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"session_id": session_id,
|
||||||
|
"files_imported": files_imported,
|
||||||
|
"leq_files": leq_count,
|
||||||
|
"lp_files": lp_count,
|
||||||
|
"metadata_files": metadata_count,
|
||||||
|
"store_name": store_name,
|
||||||
|
"started_at": started_at.isoformat() if started_at else None,
|
||||||
|
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from backend.models import (
|
|||||||
ProjectType,
|
ProjectType,
|
||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
ScheduledAction,
|
ScheduledAction,
|
||||||
RecurringSchedule,
|
RecurringSchedule,
|
||||||
RosterUnit,
|
RosterUnit,
|
||||||
@@ -89,10 +89,10 @@ async def get_projects_list(
|
|||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Count active sessions
|
# Count active sessions
|
||||||
active_session_count = db.query(func.count(RecordingSession.id)).filter(
|
active_session_count = db.query(func.count(MonitoringSession.id)).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.project_id == project.id,
|
MonitoringSession.project_id == project.id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Count active recording sessions
|
# Count active recording sessions
|
||||||
active_sessions = db.query(func.count(RecordingSession.id)).filter_by(
|
active_sessions = db.query(func.count(MonitoringSession.id)).filter_by(
|
||||||
status="recording"
|
status="recording"
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
@@ -410,7 +410,7 @@ async def permanently_delete_project(project_id: str, db: Session = Depends(get_
|
|||||||
# Delete related data
|
# Delete related data
|
||||||
db.query(RecurringSchedule).filter_by(project_id=project_id).delete()
|
db.query(RecurringSchedule).filter_by(project_id=project_id).delete()
|
||||||
db.query(ScheduledAction).filter_by(project_id=project_id).delete()
|
db.query(ScheduledAction).filter_by(project_id=project_id).delete()
|
||||||
db.query(RecordingSession).filter_by(project_id=project_id).delete()
|
db.query(MonitoringSession).filter_by(project_id=project_id).delete()
|
||||||
db.query(UnitAssignment).filter_by(project_id=project_id).delete()
|
db.query(UnitAssignment).filter_by(project_id=project_id).delete()
|
||||||
db.query(MonitoringLocation).filter_by(project_id=project_id).delete()
|
db.query(MonitoringLocation).filter_by(project_id=project_id).delete()
|
||||||
db.delete(project)
|
db.delete(project)
|
||||||
@@ -501,18 +501,18 @@ async def get_project_dashboard(
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Get active recording sessions
|
# Get active recording sessions
|
||||||
active_sessions = db.query(RecordingSession).filter(
|
active_sessions = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.project_id == project_id,
|
MonitoringSession.project_id == project_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# Get completed sessions count
|
# Get completed sessions count
|
||||||
completed_sessions_count = db.query(func.count(RecordingSession.id)).filter(
|
completed_sessions_count = db.query(func.count(MonitoringSession.id)).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.project_id == project_id,
|
MonitoringSession.project_id == project_id,
|
||||||
RecordingSession.status == "completed",
|
MonitoringSession.status == "completed",
|
||||||
)
|
)
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
@@ -591,26 +591,26 @@ async def get_project_units(
|
|||||||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||||
|
|
||||||
# Count sessions for this assignment
|
# Count sessions for this assignment
|
||||||
session_count = db.query(func.count(RecordingSession.id)).filter_by(
|
session_count = db.query(func.count(MonitoringSession.id)).filter_by(
|
||||||
location_id=assignment.location_id,
|
location_id=assignment.location_id,
|
||||||
unit_id=assignment.unit_id,
|
unit_id=assignment.unit_id,
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Count files from sessions
|
# Count files from sessions
|
||||||
file_count = db.query(func.count(DataFile.id)).join(
|
file_count = db.query(func.count(DataFile.id)).join(
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
DataFile.session_id == RecordingSession.id
|
DataFile.session_id == MonitoringSession.id
|
||||||
).filter(
|
).filter(
|
||||||
RecordingSession.location_id == assignment.location_id,
|
MonitoringSession.location_id == assignment.location_id,
|
||||||
RecordingSession.unit_id == assignment.unit_id,
|
MonitoringSession.unit_id == assignment.unit_id,
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Check if currently recording
|
# Check if currently recording
|
||||||
active_session = db.query(RecordingSession).filter(
|
active_session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == assignment.location_id,
|
MonitoringSession.location_id == assignment.location_id,
|
||||||
RecordingSession.unit_id == assignment.unit_id,
|
MonitoringSession.unit_id == assignment.unit_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -797,13 +797,13 @@ async def get_project_sessions(
|
|||||||
Returns HTML partial with session list.
|
Returns HTML partial with session list.
|
||||||
Optional status filter: recording, completed, paused, failed
|
Optional status filter: recording, completed, paused, failed
|
||||||
"""
|
"""
|
||||||
query = db.query(RecordingSession).filter_by(project_id=project_id)
|
query = db.query(MonitoringSession).filter_by(project_id=project_id)
|
||||||
|
|
||||||
# Filter by status if provided
|
# Filter by status if provided
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(RecordingSession.status == status)
|
query = query.filter(MonitoringSession.status == status)
|
||||||
|
|
||||||
sessions = query.order_by(RecordingSession.started_at.desc()).all()
|
sessions = query.order_by(MonitoringSession.started_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with unit and location details
|
# Enrich with unit and location details
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
@@ -895,18 +895,18 @@ async def ftp_download_to_server(
|
|||||||
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
||||||
|
|
||||||
# Get or create active session for this location/unit
|
# Get or create active session for this location/unit
|
||||||
session = db.query(RecordingSession).filter(
|
session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.project_id == project_id,
|
MonitoringSession.project_id == project_id,
|
||||||
RecordingSession.location_id == location_id,
|
MonitoringSession.location_id == location_id,
|
||||||
RecordingSession.unit_id == unit_id,
|
MonitoringSession.unit_id == unit_id,
|
||||||
RecordingSession.status.in_(["recording", "paused"])
|
MonitoringSession.status.in_(["recording", "paused"])
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# If no active session, create one
|
# If no active session, create one
|
||||||
if not session:
|
if not session:
|
||||||
session = RecordingSession(
|
session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
@@ -1060,18 +1060,18 @@ async def ftp_download_folder_to_server(
|
|||||||
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
||||||
|
|
||||||
# Get or create active session for this location/unit
|
# Get or create active session for this location/unit
|
||||||
session = db.query(RecordingSession).filter(
|
session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.project_id == project_id,
|
MonitoringSession.project_id == project_id,
|
||||||
RecordingSession.location_id == location_id,
|
MonitoringSession.location_id == location_id,
|
||||||
RecordingSession.unit_id == unit_id,
|
MonitoringSession.unit_id == unit_id,
|
||||||
RecordingSession.status.in_(["recording", "paused"])
|
MonitoringSession.status.in_(["recording", "paused"])
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# If no active session, create one
|
# If no active session, create one
|
||||||
if not session:
|
if not session:
|
||||||
session = RecordingSession(
|
session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
@@ -1231,9 +1231,9 @@ async def get_unified_files(
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
# Get all sessions for this project
|
# Get all sessions for this project
|
||||||
sessions = db.query(RecordingSession).filter_by(
|
sessions = db.query(MonitoringSession).filter_by(
|
||||||
project_id=project_id
|
project_id=project_id
|
||||||
).order_by(RecordingSession.started_at.desc()).all()
|
).order_by(MonitoringSession.started_at.desc()).all()
|
||||||
|
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
@@ -1310,7 +1310,7 @@ async def download_project_file(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1344,7 +1344,7 @@ async def download_session_files(
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
# Verify session belongs to this project
|
# Verify session belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=session_id).first()
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
if session.project_id != project_id:
|
if session.project_id != project_id:
|
||||||
@@ -1412,7 +1412,7 @@ async def delete_project_file(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1442,7 +1442,7 @@ async def delete_session(
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Verify session belongs to this project
|
# Verify session belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=session_id).first()
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
if session.project_id != project_id:
|
if session.project_id != project_id:
|
||||||
@@ -1491,7 +1491,7 @@ async def view_rnd_file(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1557,7 +1557,7 @@ async def get_rnd_data(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1695,7 +1695,7 @@ async def generate_excel_report(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -2101,7 +2101,7 @@ async def preview_report_data(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -2309,7 +2309,7 @@ async def generate_report_from_preview(
|
|||||||
if not file_record:
|
if not file_record:
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -2471,7 +2471,7 @@ async def generate_combined_excel_report(
|
|||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
# Get all sessions with measurement files
|
# Get all sessions with measurement files
|
||||||
sessions = db.query(RecordingSession).filter_by(project_id=project_id).all()
|
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
# Collect all Leq RND files grouped by location
|
# Collect all Leq RND files grouped by location
|
||||||
# Only include files with '_Leq_' in the path (15-minute averaged data)
|
# Only include files with '_Leq_' in the path (15-minute averaged data)
|
||||||
|
|||||||
@@ -92,15 +92,15 @@ async def rename_unit(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update unit_assignments: {e}")
|
logger.warning(f"Could not update unit_assignments: {e}")
|
||||||
|
|
||||||
# Update recording_sessions table (if exists)
|
# Update monitoring_sessions table (if exists)
|
||||||
try:
|
try:
|
||||||
from backend.models import RecordingSession
|
from backend.models import MonitoringSession
|
||||||
db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update(
|
db.query(MonitoringSession).filter(MonitoringSession.unit_id == old_id).update(
|
||||||
{"unit_id": new_id},
|
{"unit_id": new_id},
|
||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update recording_sessions: {e}")
|
logger.warning(f"Could not update monitoring_sessions: {e}")
|
||||||
|
|
||||||
# Commit all changes
|
# Commit all changes
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
from backend.database import SessionLocal
|
from backend.database import SessionLocal
|
||||||
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule
|
from backend.models import ScheduledAction, MonitoringSession, MonitoringLocation, Project, RecurringSchedule
|
||||||
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
||||||
from backend.services.alert_service import get_alert_service
|
from backend.services.alert_service import get_alert_service
|
||||||
import uuid
|
import uuid
|
||||||
@@ -272,7 +272,7 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create recording session
|
# Create recording session
|
||||||
session = RecordingSession(
|
session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=action.project_id,
|
project_id=action.project_id,
|
||||||
location_id=action.location_id,
|
location_id=action.location_id,
|
||||||
@@ -336,11 +336,11 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Find and update the active recording session
|
# Find and update the active recording session
|
||||||
active_session = db.query(RecordingSession).filter(
|
active_session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == action.location_id,
|
MonitoringSession.location_id == action.location_id,
|
||||||
RecordingSession.unit_id == unit_id,
|
MonitoringSession.unit_id == unit_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -617,11 +617,11 @@ class SchedulerService:
|
|||||||
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
|
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
|
||||||
|
|
||||||
# Close out the old recording session
|
# Close out the old recording session
|
||||||
active_session = db.query(RecordingSession).filter(
|
active_session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == action.location_id,
|
MonitoringSession.location_id == action.location_id,
|
||||||
RecordingSession.unit_id == unit_id,
|
MonitoringSession.unit_id == unit_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -648,7 +648,7 @@ class SchedulerService:
|
|||||||
result["steps"]["start"] = {"success": True, "response": cycle_response}
|
result["steps"]["start"] = {"success": True, "response": cycle_response}
|
||||||
|
|
||||||
# Create new recording session
|
# Create new recording session
|
||||||
new_session = RecordingSession(
|
new_session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=action.project_id,
|
project_id=action.project_id,
|
||||||
location_id=action.location_id,
|
location_id=action.location_id,
|
||||||
|
|||||||
@@ -90,14 +90,14 @@ def rename_unit(old_id: str, new_id: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Table may not exist
|
pass # Table may not exist
|
||||||
|
|
||||||
# Update recording_sessions table (if exists)
|
# Update monitoring_sessions table (if exists)
|
||||||
try:
|
try:
|
||||||
result = session.execute(
|
result = session.execute(
|
||||||
text("UPDATE recording_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
text("UPDATE monitoring_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||||
{"new_id": new_id, "old_id": old_id}
|
{"new_id": new_id, "old_id": old_id}
|
||||||
)
|
)
|
||||||
if result.rowcount > 0:
|
if result.rowcount > 0:
|
||||||
print(f" ✓ Updated recording_sessions ({result.rowcount} rows)")
|
print(f" ✓ Updated monitoring_sessions ({result.rowcount} rows)")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Table may not exist
|
pass # Table may not exist
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
<button onclick="switchTab('sessions')"
|
<button onclick="switchTab('sessions')"
|
||||||
data-tab="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">
|
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
|
Monitoring Sessions
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('data')"
|
<button onclick="switchTab('data')"
|
||||||
data-tab="data"
|
data-tab="data"
|
||||||
@@ -302,11 +302,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recording Sessions Tab -->
|
<!-- Monitoring Sessions Tab -->
|
||||||
<div id="sessions-tab" class="tab-panel hidden">
|
<div id="sessions-tab" class="tab-panel hidden">
|
||||||
<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">Recording Sessions</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||||
{% if assigned_unit %}
|
{% if assigned_unit %}
|
||||||
<button onclick="openScheduleModal()"
|
<button onclick="openScheduleModal()"
|
||||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
@@ -329,8 +329,40 @@
|
|||||||
<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">Data Files</h2>
|
||||||
<div class="text-sm text-gray-500">
|
<div class="flex items-center gap-3">
|
||||||
<span class="font-medium">{{ file_count }}</span> files
|
<span class="text-sm text-gray-500"><span class="font-medium">{{ file_count }}</span> files</span>
|
||||||
|
<button onclick="toggleUploadPanel()"
|
||||||
|
class="px-3 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
|
||||||
|
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||||
|
</svg>
|
||||||
|
Upload Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Panel -->
|
||||||
|
<div id="upload-panel" class="hidden mb-6 p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Upload SD Card Data</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Select a ZIP file, or select all files from inside an <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">Auto_####</code> folder. File types (.rnd, .rnh) are auto-detected.
|
||||||
|
</p>
|
||||||
|
<input type="file" id="upload-input" multiple
|
||||||
|
accept=".zip,.rnd,.rnh,.RND,.RNH"
|
||||||
|
class="block w-full text-sm text-gray-500 dark:text-gray-400
|
||||||
|
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
||||||
|
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||||
|
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||||
|
<div class="flex items-center gap-3 mt-3">
|
||||||
|
<button onclick="submitUpload()"
|
||||||
|
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||||
|
Import Files
|
||||||
|
</button>
|
||||||
|
<button onclick="toggleUploadPanel()"
|
||||||
|
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<span id="upload-status" class="text-sm hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -559,5 +591,64 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
|||||||
closeAssignModal();
|
closeAssignModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Upload Data ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleUploadPanel() {
|
||||||
|
const panel = document.getElementById('upload-panel');
|
||||||
|
const status = document.getElementById('upload-status');
|
||||||
|
panel.classList.toggle('hidden');
|
||||||
|
// Reset status when reopening
|
||||||
|
if (!panel.classList.contains('hidden')) {
|
||||||
|
status.textContent = '';
|
||||||
|
status.className = 'text-sm hidden';
|
||||||
|
document.getElementById('upload-input').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUpload() {
|
||||||
|
const input = document.getElementById('upload-input');
|
||||||
|
const status = document.getElementById('upload-status');
|
||||||
|
|
||||||
|
if (!input.files.length) {
|
||||||
|
alert('Please select files to upload.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of input.files) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = 'Uploading\u2026';
|
||||||
|
status.className = 'text-sm text-gray-500';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
|
||||||
|
{ method: 'POST', body: formData }
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
||||||
|
if (data.leq_files || data.lp_files) {
|
||||||
|
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
||||||
|
}
|
||||||
|
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
|
||||||
|
status.textContent = parts.join(' ');
|
||||||
|
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
input.value = '';
|
||||||
|
// Refresh the file list
|
||||||
|
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
||||||
|
} else {
|
||||||
|
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = `Error: ${err.message}`;
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -151,9 +151,9 @@
|
|||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="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>
|
<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>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-2">No data files yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">
|
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||||
Files will appear here once they are downloaded from the sound level meter
|
Files appear here after an FTP download from a connected meter, or after uploading SD card data manually.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
|
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Schedule a single recording session with a specific start and end time.
|
Schedule a single monitoring session with a specific start and end time.
|
||||||
Duration can be between 15 minutes and 24 hours.
|
Duration can be between 15 minutes and 24 hours.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- Recording Sessions List -->
|
<!-- Monitoring Sessions List -->
|
||||||
{% if sessions %}
|
{% if sessions %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{% for item in sessions %}
|
{% for item in sessions %}
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="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>
|
<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>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No recording sessions yet</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -99,7 +99,7 @@ function viewSession(sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording(sessionId) {
|
function stopRecording(sessionId) {
|
||||||
if (!confirm('Stop this recording session?')) return;
|
if (!confirm('Stop this monitoring session?')) return;
|
||||||
|
|
||||||
// TODO: Implement stop recording API call
|
// TODO: Implement stop recording API call
|
||||||
alert('Stop recording API coming soon for session: ' + sessionId);
|
alert('Stop recording API coming soon for session: ' + sessionId);
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
<button id="sessions-tab-btn" onclick="switchTab('sessions')"
|
<button id="sessions-tab-btn" onclick="switchTab('sessions')"
|
||||||
data-tab="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 whitespace-nowrap">
|
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 whitespace-nowrap">
|
||||||
Recording Sessions
|
Monitoring Sessions
|
||||||
</button>
|
</button>
|
||||||
<button id="data-tab-btn" onclick="switchTab('data')"
|
<button id="data-tab-btn" onclick="switchTab('data')"
|
||||||
data-tab="data"
|
data-tab="data"
|
||||||
@@ -185,11 +185,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recording Sessions Tab -->
|
<!-- Monitoring Sessions Tab -->
|
||||||
<div id="sessions-tab" class="tab-panel hidden">
|
<div id="sessions-tab" class="tab-panel hidden">
|
||||||
<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">Recording Sessions</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<select id="sessions-filter" onchange="filterSessions()"
|
<select id="sessions-filter" onchange="filterSessions()"
|
||||||
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">
|
||||||
@@ -521,7 +521,7 @@
|
|||||||
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
|
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Single recording session with a specific start and end date/time (15 min - 24 hrs).
|
Single monitoring session with a specific start and end date/time (15 min - 24 hrs).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -721,7 +721,7 @@ async function loadProjectDetails() {
|
|||||||
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||||
document.getElementById('add-location-label').textContent = 'Add NRL';
|
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||||
}
|
}
|
||||||
// Recording Sessions and Data Files tabs are SLM-only
|
// Monitoring Sessions and Data Files tabs are SLM-only
|
||||||
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||||
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||||
|
|
||||||
@@ -808,6 +808,10 @@ function openLocationModal(defaultType) {
|
|||||||
locationTypeSelect.value = 'sound';
|
locationTypeSelect.value = 'sound';
|
||||||
locationTypeSelect.disabled = true;
|
locationTypeSelect.disabled = true;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
|
} else if (projectTypeId === 'vibration_monitoring') {
|
||||||
|
locationTypeSelect.value = 'vibration';
|
||||||
|
locationTypeSelect.disabled = true;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
locationTypeSelect.disabled = false;
|
locationTypeSelect.disabled = false;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
@@ -832,6 +836,10 @@ function openEditLocationModal(button) {
|
|||||||
locationTypeSelect.value = 'sound';
|
locationTypeSelect.value = 'sound';
|
||||||
locationTypeSelect.disabled = true;
|
locationTypeSelect.disabled = true;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
|
} else if (projectTypeId === 'vibration_monitoring') {
|
||||||
|
locationTypeSelect.value = 'vibration';
|
||||||
|
locationTypeSelect.disabled = true;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
locationTypeSelect.disabled = false;
|
locationTypeSelect.disabled = false;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
@@ -855,6 +863,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
let locationType = document.getElementById('location-type').value;
|
let locationType = document.getElementById('location-type').value;
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
locationType = 'sound';
|
locationType = 'sound';
|
||||||
|
} else if (projectTypeId === 'vibration_monitoring') {
|
||||||
|
locationType = 'vibration';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -78,9 +78,16 @@
|
|||||||
<!-- Create Project Modal -->
|
<!-- Create Project Modal -->
|
||||||
<div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<div id="createProjectModal" 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-4xl max-h-[90vh] overflow-y-auto">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
<div>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="hideCreateProjectModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 ml-4">
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="p-6" id="createProjectContent">
|
<div class="p-6" id="createProjectContent">
|
||||||
@@ -97,6 +104,12 @@
|
|||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button type="button" onclick="hideCreateProjectModal()"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Project Details Form (hidden initially) -->
|
<!-- Step 2: Project Details Form (hidden initially) -->
|
||||||
|
|||||||
@@ -1504,7 +1504,7 @@
|
|||||||
`• Unit roster entry\n` +
|
`• Unit roster entry\n` +
|
||||||
`• All history records\n` +
|
`• All history records\n` +
|
||||||
`• Project assignments\n` +
|
`• Project assignments\n` +
|
||||||
`• Recording sessions\n` +
|
`• Monitoring sessions\n` +
|
||||||
`• Modem references\n\n` +
|
`• Modem references\n\n` +
|
||||||
`This action cannot be undone.`
|
`This action cannot be undone.`
|
||||||
);
|
);
|
||||||
|
|||||||
415
templates/vibration_location_detail.html
Normal file
415
templates/vibration_location_detail.html
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ location.name }} - Monitoring Location{% 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">
|
||||||
|
Monitoring 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>
|
||||||
|
</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="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ assigned_unit.id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if assigned_unit.device_type %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</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>
|
||||||
|
</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>
|
||||||
|
</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 seismograph 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 seismographs for this project.</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) {
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||||
|
panel.classList.add('hidden');
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
const panel = document.getElementById(`${tabName}-tab`);
|
||||||
|
if (panel) panel.classList.remove('hidden');
|
||||||
|
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
|
||||||
|
function openAssignModal() {
|
||||||
|
document.getElementById('assign-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=vibration`);
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') closeAssignModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeAssignModal();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user