18 Commits

Author SHA1 Message Date
bd3d937a82 feat: enhance project data handling with new Jinja filters and update UI labels for clarity 2026-02-25 21:41:51 +00:00
291fa8e862 feat: Manual sound data uploads, standalone SLM type added.(no modem mode), Smart uploading with fuzzy name matching enabled. 2026-02-25 00:43:47 +00:00
8e292b1aca add: Vibration location detail template 2026-02-24 20:06:55 +00:00
7516bbea70 feat: add manual SD card data upload for offline NRLs; rename RecordingSession to MonitoringSession
- Add POST /api/projects/{project_id}/nrl/{location_id}/upload-data endpoint
  accepting a ZIP or multi-file select of .rnd/.rnh files from an SD card.
  Parses .rnh metadata for session start/stop times, serial number, and store
  name. Creates a MonitoringSession (no unit assignment required) and DataFile
  records for each measurement file.

- Add Upload Data button and collapsible upload panel to the NRL detail Data
  Files tab, with inline success/error feedback and automatic file list refresh
  via HTMX after import.

- Rename RecordingSession -> MonitoringSession throughout the codebase
  (models.py, projects.py, project_locations.py, scheduler.py, roster_rename.py,
  main.py, init_projects_db.py, scripts/rename_unit.py). DB table renamed from
  recording_sessions to monitoring_sessions; old indexes dropped and recreated.

- Update all template UI copy from Recording Sessions to Monitoring Sessions
  (nrl_detail, projects/detail, session_list, schedule_oneoff, roster).

- Add backend/migrate_rename_recording_to_monitoring_sessions.py for applying
  the table rename on production databases before deploying this build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 19:54:40 +00:00
da4e5f66c5 chore: add dev env specifics to .gitignore 2026-02-23 17:37:50 +00:00
dae2595303 Chore: still cleaning up this crap with gitignore 2026-02-23 17:37:12 +00:00
0c4e7aa5e6 chore: remove old backup metadata files 2026-02-23 17:34:44 +00:00
229499ccf6 chore: ignored files removed from git tracking. 2026-02-23 08:42:16 +00:00
fdc4adeaee chore: ignored files removed from git. 2026-02-23 08:42:11 +00:00
b3bf91880a chore: dev-data vol added to gitignore 2026-02-23 08:39:22 +00:00
17b3f91dfc fix: dev server separated from prod deployment. now uses override via docker. 2026-02-23 08:15:04 +00:00
6c1d0bc467 add: vibration projects now no longer show SLM project tabs. 2026-02-23 08:13:51 +00:00
serversdwn
abd059983f fix: slm modal now shows bench/deploy 2026-02-20 02:20:51 +00:00
serversdwn
0f17841218 feat: enhance project management by canceling pending actions for archived and on_hold projects 2026-02-19 18:57:59 +00:00
serversdwn
65362bab21 feat: implement project status management with 'on_hold' state and associated UI updates
-feat: ability to hard delete projects, plus a soft delete with auto pruning.
2026-02-19 15:23:02 +00:00
serversdwn
dc77a362ce fix: add TCP_IDLE_TTL and TCP_MAX_AGE environment variables for SLMM service 2026-02-19 01:25:07 +00:00
serversdwn
28942600ab fix: Auto-downloaded files now show up in project_files data. 2026-02-18 19:51:44 +00:00
serversdwn
80861997af Fix: removed duplicate download following scheduled stop. 2026-02-18 06:44:04 +00:00
37 changed files with 2248 additions and 239 deletions

View File

@@ -1,3 +1,5 @@
docker-compose.override.yml
# Python cache / compiled
__pycache__
*.pyc
@@ -28,6 +30,7 @@ ENV/
# Runtime data (mounted volumes)
data/
data-dev/
# Editors / OS junk
.vscode/

17
.gitignore vendored
View File

@@ -1,3 +1,13 @@
# Terra-View Specifics
# SQLite database files
*.db
*.db-journal
data/
data-dev/
.aider*
.aider*
docker-compose.override.yml
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
@@ -206,10 +216,3 @@ marimo/_static/
marimo/_lsp/
__marimo__/
# Seismo Fleet Manager
# SQLite database files
*.db
*.db-journal
data/
.aider*
.aider*

View File

@@ -18,7 +18,7 @@ from backend.models import (
MonitoringLocation,
UnitAssignment,
ScheduledAction,
RecordingSession,
MonitoringSession,
DataFile,
)
from datetime import datetime

View File

@@ -312,7 +312,7 @@ async def nrl_detail_page(
db: Session = Depends(get_db)
):
"""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_
# Get project
@@ -348,23 +348,33 @@ async def nrl_detail_page(
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
# 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)
file_count = db.query(DataFile).join(
RecordingSession,
DataFile.session_id == RecordingSession.id
).filter(RecordingSession.location_id == location_id).count()
MonitoringSession,
DataFile.session_id == MonitoringSession.id
).filter(MonitoringSession.location_id == location_id).count()
# Check for active session
active_session = db.query(RecordingSession).filter(
active_session = db.query(MonitoringSession).filter(
and_(
RecordingSession.location_id == location_id,
RecordingSession.status == "recording"
MonitoringSession.location_id == location_id,
MonitoringSession.status == "recording"
)
).first()
return templates.TemplateResponse("nrl_detail.html", {
# Parse connection_mode from location_metadata JSON
import json as _json
connection_mode = "connected"
try:
meta = _json.loads(location.location_metadata or "{}")
connection_mode = meta.get("connection_mode", "connected")
except Exception:
pass
template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html"
return templates.TemplateResponse(template, {
"request": request,
"project_id": project_id,
"location_id": location_id,
@@ -375,6 +385,7 @@ async def nrl_detail_page(
"session_count": session_count,
"file_count": file_count,
"active_session": active_session,
"connection_mode": connection_mode,
})

View File

@@ -0,0 +1,56 @@
"""
Migration: Add deleted_at column to projects table
Adds columns:
- projects.deleted_at: Timestamp set when status='deleted'; data hard-deleted after 60 days
"""
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='projects'")
if not cursor.fetchone():
print("projects table does not exist. Skipping migration.")
return
cursor.execute("PRAGMA table_info(projects)")
existing_cols = {row[1] for row in cursor.fetchall()}
if 'deleted_at' not in existing_cols:
print("Adding deleted_at column to projects...")
cursor.execute("ALTER TABLE projects ADD COLUMN deleted_at DATETIME")
else:
print("deleted_at column already exists. Skipping.")
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)

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

View File

@@ -155,7 +155,7 @@ class Project(Base):
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
description = Column(Text, nullable=True)
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
status = Column(String, default="active") # active, completed, archived
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
# Project metadata
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
@@ -166,6 +166,7 @@ class Project(Base):
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
deleted_at = Column(DateTime, nullable=True) # Set when status='deleted'; hard delete scheduled after 60 days
class MonitoringLocation(Base):
@@ -244,17 +245,17 @@ class ScheduledAction(Base):
created_at = Column(DateTime, default=datetime.utcnow)
class RecordingSession(Base):
class MonitoringSession(Base):
"""
Recording sessions: tracks actual monitoring sessions.
Created when recording starts, updated when it stops.
Monitoring sessions: tracks actual monitoring sessions.
Created when monitoring starts, updated when it stops.
"""
__tablename__ = "recording_sessions"
__tablename__ = "monitoring_sessions"
id = Column(String, primary_key=True, index=True) # UUID
project_id = Column(String, nullable=False, index=True) # FK to Project.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
started_at = Column(DateTime, nullable=False)
@@ -277,7 +278,7 @@ class DataFile(Base):
__tablename__ = "data_files"
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_type = Column(String, nullable=False) # wav, csv, mseed, json

View File

@@ -1,5 +1,6 @@
from fastapi import APIRouter, Request, Depends
from sqlalchemy.orm import Session
from sqlalchemy import and_
from datetime import datetime, timedelta
from backend.database import get_db
@@ -48,10 +49,18 @@ def dashboard_todays_actions(request: Request, db: Session = Depends(get_db)):
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
# Exclude actions from paused/removed projects
paused_project_ids = [
p.id for p in db.query(Project.id).filter(
Project.status.in_(["on_hold", "archived", "deleted"])
).all()
]
# Query today's actions
actions = db.query(ScheduledAction).filter(
ScheduledAction.scheduled_time >= today_start_utc,
ScheduledAction.scheduled_time < today_end_utc,
ScheduledAction.project_id.notin_(paused_project_ids),
).order_by(ScheduledAction.scheduled_time.asc()).all()
# Enrich with location/project info and parse results

View File

@@ -14,6 +14,12 @@ from typing import Optional
import uuid
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.models import (
Project,
@@ -21,7 +27,8 @@ from backend.models import (
MonitoringLocation,
UnitAssignment,
RosterUnit,
RecordingSession,
MonitoringSession,
DataFile,
)
from backend.templates_config import templates
@@ -70,8 +77,8 @@ async def get_project_locations(
if assignment:
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
# Count recording sessions
session_count = db.query(RecordingSession).filter_by(
# Count monitoring sessions
session_count = db.query(MonitoringSession).filter_by(
location_id=location.id
).count()
@@ -370,19 +377,19 @@ async def unassign_unit(
if not assignment:
raise HTTPException(status_code=404, detail="Assignment not found")
# Check if there are active recording sessions
active_sessions = db.query(RecordingSession).filter(
# Check if there are active monitoring sessions
active_sessions = db.query(MonitoringSession).filter(
and_(
RecordingSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id,
RecordingSession.status == "recording",
MonitoringSession.location_id == assignment.location_id,
MonitoringSession.unit_id == assignment.unit_id,
MonitoringSession.status == "recording",
)
).count()
if active_sessions > 0:
raise HTTPException(
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"
@@ -451,14 +458,12 @@ async def get_nrl_sessions(
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.
"""
from backend.models import RecordingSession, RosterUnit
sessions = db.query(RecordingSession).filter_by(
sessions = db.query(MonitoringSession).filter_by(
location_id=location_id
).order_by(RecordingSession.started_at.desc()).all()
).order_by(MonitoringSession.started_at.desc()).all()
# Enrich with unit details
sessions_data = []
@@ -491,14 +496,12 @@ async def get_nrl_files(
Get data files for a specific NRL.
Returns HTML partial with file list.
"""
from backend.models import DataFile, RecordingSession
# Join DataFile with RecordingSession to filter by location_id
# Join DataFile with MonitoringSession to filter by location_id
files = db.query(DataFile).join(
RecordingSession,
DataFile.session_id == RecordingSession.id
MonitoringSession,
DataFile.session_id == MonitoringSession.id
).filter(
RecordingSession.location_id == location_id
MonitoringSession.location_id == location_id
).order_by(DataFile.created_at.desc()).all()
# Enrich with session details
@@ -506,7 +509,7 @@ async def get_nrl_files(
for file in files:
session = None
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({
"file": file,
@@ -519,3 +522,281 @@ async def get_nrl_files(
"location_id": location_id,
"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,
}
# ============================================================================
# NRL Live Status (connected NRLs only)
# ============================================================================
@router.get("/nrl/{location_id}/live-status", response_class=HTMLResponse)
async def get_nrl_live_status(
project_id: str,
location_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Fetch cached status from SLMM for the unit assigned to this NRL and
return a compact HTML status card. Used in the NRL overview tab for
connected NRLs. Gracefully shows an offline message if SLMM is unreachable.
"""
import os
import httpx
# Find the assigned unit
assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.status == "active",
)
).first()
if not assignment:
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
"request": request,
"status": None,
"error": "No unit assigned",
})
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if not unit:
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
"request": request,
"status": None,
"error": "Assigned unit not found",
})
slmm_base = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
status_data = None
error_msg = None
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{slmm_base}/api/nl43/{unit.id}/status")
if resp.status_code == 200:
status_data = resp.json()
else:
error_msg = f"SLMM returned {resp.status_code}"
except Exception as e:
error_msg = "SLMM unreachable"
return templates.TemplateResponse("partials/projects/nrl_live_status.html", {
"request": request,
"unit": unit,
"status": status_data,
"error": error_msg,
})

View File

@@ -23,12 +23,18 @@ import io
from backend.utils.timezone import utc_to_local, format_local_datetime
from backend.database import get_db
from fastapi import UploadFile, File
import zipfile
import hashlib
import pathlib as _pathlib
from backend.models import (
Project,
ProjectType,
MonitoringLocation,
UnitAssignment,
RecordingSession,
MonitoringSession,
DataFile,
ScheduledAction,
RecurringSchedule,
RosterUnit,
@@ -57,9 +63,11 @@ async def get_projects_list(
"""
query = db.query(Project)
# Filter by status if provided
# Filter by status if provided; otherwise exclude soft-deleted projects
if status:
query = query.filter(Project.status == status)
else:
query = query.filter(Project.status != "deleted")
# Filter by project type if provided
if project_type_id:
@@ -87,10 +95,10 @@ async def get_projects_list(
).scalar()
# Count active sessions
active_session_count = db.query(func.count(RecordingSession.id)).filter(
active_session_count = db.query(func.count(MonitoringSession.id)).filter(
and_(
RecordingSession.project_id == project.id,
RecordingSession.status == "recording",
MonitoringSession.project_id == project.id,
MonitoringSession.status == "recording",
)
).scalar()
@@ -118,9 +126,10 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
Get summary statistics for projects overview.
Returns HTML partial with stat cards.
"""
# Count projects by status
total_projects = db.query(func.count(Project.id)).scalar()
# Count projects by status (exclude deleted)
total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar()
active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar()
on_hold_projects = db.query(func.count(Project.id)).filter_by(status="on_hold").scalar()
completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar()
# Count total locations across all projects
@@ -132,7 +141,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
).scalar()
# 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"
).scalar()
@@ -140,6 +149,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
"request": request,
"total_projects": total_projects,
"active_projects": active_projects,
"on_hold_projects": on_hold_projects,
"completed_projects": completed_projects,
"total_locations": total_locations,
"assigned_units": assigned_units,
@@ -178,13 +188,13 @@ async def search_projects(
if not q.strip():
# Return recent active projects when no search term
projects = db.query(Project).filter(
Project.status != "archived"
Project.status.notin_(["archived", "deleted"])
).order_by(Project.updated_at.desc()).limit(limit).all()
else:
search_term = f"%{q}%"
projects = db.query(Project).filter(
and_(
Project.status != "archived",
Project.status.notin_(["archived", "deleted"]),
or_(
Project.project_number.ilike(search_term),
Project.client_name.ilike(search_term),
@@ -223,13 +233,13 @@ async def search_projects_json(
"""
if not q.strip():
projects = db.query(Project).filter(
Project.status != "archived"
Project.status.notin_(["archived", "deleted"])
).order_by(Project.updated_at.desc()).limit(limit).all()
else:
search_term = f"%{q}%"
projects = db.query(Project).filter(
and_(
Project.status != "archived",
Project.status.notin_(["archived", "deleted"]),
or_(
Project.project_number.ilike(search_term),
Project.client_name.ilike(search_term),
@@ -338,6 +348,14 @@ async def update_project(
project.description = data["description"]
if "status" in data:
project.status = data["status"]
# Cancel pending scheduled actions when archiving
if data["status"] == "archived":
db.query(ScheduledAction).filter(
and_(
ScheduledAction.project_id == project_id,
ScheduledAction.execution_status == "pending",
)
).update({"execution_status": "cancelled"})
if "client_name" in data:
project.client_name = data["client_name"]
if "site_address" in data:
@@ -359,18 +377,93 @@ async def update_project(
@router.delete("/{project_id}")
async def delete_project(project_id: str, db: Session = Depends(get_db)):
"""
Delete a project (soft delete by archiving).
Soft-delete a project. Sets status='deleted' and records deleted_at timestamp.
Data will be permanently removed after 60 days (or via /permanent endpoint).
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.status = "archived"
project.status = "deleted"
project.deleted_at = datetime.utcnow()
project.updated_at = datetime.utcnow()
# Cancel all pending scheduled actions
db.query(ScheduledAction).filter(
and_(
ScheduledAction.project_id == project_id,
ScheduledAction.execution_status == "pending",
)
).update({"execution_status": "cancelled"})
db.commit()
return {"success": True, "message": "Project archived successfully"}
return {"success": True, "message": "Project deleted. Data will be permanently removed after 60 days."}
@router.delete("/{project_id}/permanent")
async def permanently_delete_project(project_id: str, db: Session = Depends(get_db)):
"""
Hard-delete a project and all related data. Only allowed when status='deleted'.
Removes: locations, assignments, sessions, scheduled actions, recurring schedules.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if project.status != "deleted":
raise HTTPException(status_code=400, detail="Project must be soft-deleted before permanent deletion.")
# Delete related data
db.query(RecurringSchedule).filter_by(project_id=project_id).delete()
db.query(ScheduledAction).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(MonitoringLocation).filter_by(project_id=project_id).delete()
db.delete(project)
db.commit()
return {"success": True, "message": "Project permanently deleted."}
@router.post("/{project_id}/hold")
async def hold_project(project_id: str, db: Session = Depends(get_db)):
"""
Put a project on hold. Pauses without archiving; assignments and schedules remain.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.status = "on_hold"
project.updated_at = datetime.utcnow()
# Cancel pending scheduled actions so they don't appear in dashboards or fire
db.query(ScheduledAction).filter(
and_(
ScheduledAction.project_id == project_id,
ScheduledAction.execution_status == "pending",
)
).update({"execution_status": "cancelled"})
db.commit()
return {"success": True, "message": "Project put on hold."}
@router.post("/{project_id}/unhold")
async def unhold_project(project_id: str, db: Session = Depends(get_db)):
"""
Resume a project that was on hold.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.status = "active"
project.updated_at = datetime.utcnow()
db.commit()
return {"success": True, "message": "Project resumed."}
# ============================================================================
@@ -414,18 +507,18 @@ async def get_project_dashboard(
})
# Get active recording sessions
active_sessions = db.query(RecordingSession).filter(
active_sessions = db.query(MonitoringSession).filter(
and_(
RecordingSession.project_id == project_id,
RecordingSession.status == "recording",
MonitoringSession.project_id == project_id,
MonitoringSession.status == "recording",
)
).all()
# 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_(
RecordingSession.project_id == project_id,
RecordingSession.status == "completed",
MonitoringSession.project_id == project_id,
MonitoringSession.status == "completed",
)
).scalar()
@@ -504,26 +597,26 @@ async def get_project_units(
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
# 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,
unit_id=assignment.unit_id,
).scalar()
# Count files from sessions
file_count = db.query(func.count(DataFile.id)).join(
RecordingSession,
DataFile.session_id == RecordingSession.id
MonitoringSession,
DataFile.session_id == MonitoringSession.id
).filter(
RecordingSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id,
MonitoringSession.location_id == assignment.location_id,
MonitoringSession.unit_id == assignment.unit_id,
).scalar()
# Check if currently recording
active_session = db.query(RecordingSession).filter(
active_session = db.query(MonitoringSession).filter(
and_(
RecordingSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id,
RecordingSession.status == "recording",
MonitoringSession.location_id == assignment.location_id,
MonitoringSession.unit_id == assignment.unit_id,
MonitoringSession.status == "recording",
)
).first()
@@ -610,10 +703,14 @@ async def get_project_schedules(
"result": result_data,
})
project = db.query(Project).filter_by(id=project_id).first()
project_status = project.status if project else "active"
return templates.TemplateResponse("partials/projects/schedule_list.html", {
"request": request,
"project_id": project_id,
"schedules_by_date": schedules_by_date,
"project_status": project_status,
})
@@ -706,13 +803,13 @@ async def get_project_sessions(
Returns HTML partial with session list.
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
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
sessions_data = []
@@ -804,18 +901,18 @@ async def ftp_download_to_server(
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
# Get or create active session for this location/unit
session = db.query(RecordingSession).filter(
session = db.query(MonitoringSession).filter(
and_(
RecordingSession.project_id == project_id,
RecordingSession.location_id == location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status.in_(["recording", "paused"])
MonitoringSession.project_id == project_id,
MonitoringSession.location_id == location_id,
MonitoringSession.unit_id == unit_id,
MonitoringSession.status.in_(["recording", "paused"])
)
).first()
# If no active session, create one
if not session:
session = RecordingSession(
session = MonitoringSession(
id=str(uuid.uuid4()),
project_id=project_id,
location_id=location_id,
@@ -969,18 +1066,18 @@ async def ftp_download_folder_to_server(
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
# Get or create active session for this location/unit
session = db.query(RecordingSession).filter(
session = db.query(MonitoringSession).filter(
and_(
RecordingSession.project_id == project_id,
RecordingSession.location_id == location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status.in_(["recording", "paused"])
MonitoringSession.project_id == project_id,
MonitoringSession.location_id == location_id,
MonitoringSession.unit_id == unit_id,
MonitoringSession.status.in_(["recording", "paused"])
)
).first()
# If no active session, create one
if not session:
session = RecordingSession(
session = MonitoringSession(
id=str(uuid.uuid4()),
project_id=project_id,
location_id=location_id,
@@ -1140,9 +1237,9 @@ async def get_unified_files(
import json
# Get all sessions for this project
sessions = db.query(RecordingSession).filter_by(
sessions = db.query(MonitoringSession).filter_by(
project_id=project_id
).order_by(RecordingSession.started_at.desc()).all()
).order_by(MonitoringSession.started_at.desc()).all()
sessions_data = []
for session in sessions:
@@ -1219,7 +1316,7 @@ async def download_project_file(
raise HTTPException(status_code=404, detail="File not found")
# 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:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1253,7 +1350,7 @@ async def download_session_files(
import zipfile
# 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:
raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id:
@@ -1321,7 +1418,7 @@ async def delete_project_file(
raise HTTPException(status_code=404, detail="File not found")
# 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:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1351,7 +1448,7 @@ async def delete_session(
from pathlib import Path
# 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:
raise HTTPException(status_code=404, detail="Session not found")
if session.project_id != project_id:
@@ -1400,7 +1497,7 @@ async def view_rnd_file(
raise HTTPException(status_code=404, detail="File not found")
# 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:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1466,7 +1563,7 @@ async def get_rnd_data(
raise HTTPException(status_code=404, detail="File not found")
# 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:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -1604,7 +1701,7 @@ async def generate_excel_report(
raise HTTPException(status_code=404, detail="File not found")
# 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:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -2010,7 +2107,7 @@ async def preview_report_data(
raise HTTPException(status_code=404, detail="File not found")
# 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:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -2218,7 +2315,7 @@ async def generate_report_from_preview(
if not file_record:
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:
raise HTTPException(status_code=403, detail="File does not belong to this project")
@@ -2380,7 +2477,7 @@ async def generate_combined_excel_report(
raise HTTPException(status_code=404, detail="Project not found")
# 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
# Only include files with '_Leq_' in the path (15-minute averaged data)
@@ -2606,6 +2703,304 @@ async def generate_combined_excel_report(
)
# ============================================================================
# Project-level bulk upload (entire date-folder structure)
# ============================================================================
def _bulk_parse_rnh(content: bytes) -> dict:
"""Parse a Rion .rnh metadata file for session start/stop times and device info."""
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()
mapping = {
"Serial Number": "serial_number",
"Store Name": "store_name",
"Index Number": "index_number",
"Measurement Start Time": "start_time_str",
"Measurement Stop Time": "stop_time_str",
"Total Measurement Time": "total_time_str",
}
if key in mapping:
result[mapping[key]] = value
except Exception:
pass
return result
def _bulk_parse_datetime(s: str):
if not s:
return None
try:
return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S")
except Exception:
return None
def _bulk_classify_file(filename: str) -> str:
name = filename.lower()
if name.endswith(".rnh"):
return "log"
if name.endswith(".rnd"):
return "measurement"
if name.endswith(".mp3") or name.endswith(".wav") or name.endswith(".m4a"):
return "audio"
if name.endswith(".xlsx") or name.endswith(".xls") or name.endswith(".csv"):
return "data"
return "data"
# Files we skip entirely — already-converted outputs that don't need re-importing
_BULK_SKIP_EXTENSIONS = {".xlsx", ".xls"}
@router.post("/{project_id}/upload-all")
async def upload_all_project_data(
project_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Bulk-import an entire structured data folder selected via webkitdirectory.
Expected folder structure (flexible depth):
[date_folder]/[NRL_name]/[Auto_####]/ ← files here
-- OR --
[NRL_name]/[Auto_####]/ ← files here (no date wrapper)
-- OR --
[date_folder]/[NRL_name]/ ← files directly in NRL folder
Each leaf folder group of .rnd/.rnh files becomes one MonitoringSession.
NRL folder names are matched case-insensitively to MonitoringLocation.name.
.mp3 files are stored as audio. .xlsx/.xls are skipped (already-converted).
Unmatched folders are reported but don't cause failure.
"""
form = await request.form()
# Collect (relative_path, filename, bytes) for every uploaded file.
# The JS sends each file as "files" and its webkitRelativePath as "paths".
from collections import defaultdict
uploaded_files = form.getlist("files")
uploaded_paths = form.getlist("paths")
if not uploaded_files:
raise HTTPException(status_code=400, detail="No files received.")
if len(uploaded_paths) != len(uploaded_files):
# Fallback: use bare filename if paths weren't sent
uploaded_paths = [f.filename for f in uploaded_files]
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# Load all sound monitoring locations for this project
locations = db.query(MonitoringLocation).filter_by(
project_id=project_id,
location_type="sound",
).all()
# Build a case-insensitive name → location map
loc_by_name: dict[str, MonitoringLocation] = {
loc.name.strip().lower(): loc for loc in locations
}
def _normalize(s: str) -> str:
"""Lowercase, strip spaces/hyphens/underscores for fuzzy comparison."""
return s.lower().replace(" ", "").replace("-", "").replace("_", "")
# Pre-build normalized keys for fuzzy matching
loc_by_normalized: dict[str, MonitoringLocation] = {
_normalize(loc.name): loc for loc in locations
}
def _find_location_for_path(path: str):
"""
Walk path components from right and return first matching location.
Tries exact match first, then normalized (strips spaces/hyphens/underscores),
then checks if the location name *starts with* the normalized folder name.
e.g. folder "NRL 1" matches location "NRL1 - Test Location"
"""
components = path.replace("\\", "/").split("/")
for comp in reversed(components):
# Exact match
key = comp.strip().lower()
if key in loc_by_name:
return loc_by_name[key]
# Normalized match ("NRL 1" == "NRL1")
norm = _normalize(comp)
if norm in loc_by_normalized:
return loc_by_normalized[norm]
# Prefix match: location name starts with the folder component
# e.g. "NRL1" matches "NRL1 - Test Location"
for loc_norm, loc in loc_by_normalized.items():
if loc_norm.startswith(norm) or norm.startswith(loc_norm):
return loc
return None
def _session_group_key(parts: tuple) -> str:
"""
Determine the grouping key for a file path.
Files inside Auto_####/Auto_Leq/ or Auto_####/Auto_Lp_01/ are collapsed
up to their Auto_#### parent so they all land in the same session.
Only folder components are examined (not the filename, which is parts[-1]).
"""
# Only look at folder components — exclude the filename (last part)
folder_parts = parts[:-1]
auto_idx = None
for i, p in enumerate(folder_parts):
p_lower = p.lower()
if p_lower.startswith("auto_") and not p_lower.startswith("auto_leq") and not p_lower.startswith("auto_lp"):
auto_idx = i
if auto_idx is not None:
# Group key = everything up to and including Auto_####
return "/".join(folder_parts[:auto_idx + 1])
# Fallback: use the immediate parent folder
return "/".join(folder_parts) if folder_parts else ""
# --- Group files by session key ---
groups: dict[str, list[tuple[str, bytes]]] = defaultdict(list)
for rel_path, uf in zip(uploaded_paths, uploaded_files):
rel_path = rel_path.replace("\\", "/").strip("/")
parts = _pathlib.PurePosixPath(rel_path).parts
if not parts:
continue
fname = parts[-1]
# Skip already-converted Excel exports
if _pathlib.PurePosixPath(fname).suffix.lower() in _BULK_SKIP_EXTENSIONS:
continue
group_key = _session_group_key(parts)
data = await uf.read()
groups[group_key].append((fname, data))
# Aggregate by (location_id, date_label) so each Auto_#### group is one session
# key: (location_id or None, group_path)
session_results = []
unmatched_paths = []
total_files = 0
total_sessions = 0
for group_path, file_list in sorted(groups.items()):
matched_loc = _find_location_for_path(group_path)
if matched_loc is None:
unmatched_paths.append(group_path)
continue
# Parse .rnh if present in this group
rnh_meta = {}
for fname, fbytes in file_list:
if fname.lower().endswith(".rnh"):
rnh_meta = _bulk_parse_rnh(fbytes)
break
started_at = _bulk_parse_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
stopped_at = _bulk_parse_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", "")
session_id = str(uuid.uuid4())
monitoring_session = MonitoringSession(
id=session_id,
project_id=project_id,
location_id=matched_loc.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": "bulk_upload",
"group_path": group_path,
"store_name": store_name,
"serial_number": serial_number,
"index_number": index_number,
}),
)
db.add(monitoring_session)
db.commit()
db.refresh(monitoring_session)
# Write files
output_dir = _pathlib.Path("data/Projects") / project_id / session_id
output_dir.mkdir(parents=True, exist_ok=True)
leq_count = 0
lp_count = 0
group_file_count = 0
for fname, fbytes in file_list:
file_type = _bulk_classify_file(fname)
fname_lower = fname.lower()
if fname_lower.endswith(".rnd"):
if "_leq_" in fname_lower:
leq_count += 1
elif "_lp" in fname_lower:
lp_count += 1
dest = output_dir / fname
dest.write_bytes(fbytes)
checksum = hashlib.sha256(fbytes).hexdigest()
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": "bulk_upload",
"original_filename": fname,
"group_path": group_path,
"store_name": store_name,
}),
)
db.add(data_file)
group_file_count += 1
db.commit()
total_files += group_file_count
total_sessions += 1
session_results.append({
"location_name": matched_loc.name,
"location_id": matched_loc.id,
"session_id": session_id,
"group_path": group_path,
"files": group_file_count,
"leq_files": leq_count,
"lp_files": lp_count,
"store_name": store_name,
"started_at": started_at.isoformat() if started_at else None,
})
return {
"success": True,
"sessions_created": total_sessions,
"files_imported": total_files,
"unmatched_folders": unmatched_paths,
"sessions": session_results,
}
@router.get("/types/list", response_class=HTMLResponse)
async def get_project_types(request: Request, db: Session = Depends(get_db)):
"""

View File

@@ -497,6 +497,9 @@ async def get_schedule_list_partial(
"""
Return HTML partial for schedule list.
"""
project = db.query(Project).filter_by(id=project_id).first()
project_status = project.status if project else "active"
schedules = db.query(RecurringSchedule).filter_by(
project_id=project_id
).order_by(RecurringSchedule.created_at.desc()).all()
@@ -515,4 +518,5 @@ async def get_schedule_list_partial(
"request": request,
"project_id": project_id,
"schedules": schedule_data,
"project_status": project_status,
})

View File

@@ -92,15 +92,15 @@ async def rename_unit(
except Exception as e:
logger.warning(f"Could not update unit_assignments: {e}")
# Update recording_sessions table (if exists)
# Update monitoring_sessions table (if exists)
try:
from backend.models import RecordingSession
db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update(
from backend.models import MonitoringSession
db.query(MonitoringSession).filter(MonitoringSession.unit_id == old_id).update(
{"unit_id": new_id},
synchronize_session=False
)
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
db.commit()

View File

@@ -15,7 +15,7 @@ from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from sqlalchemy import and_
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment, Project
logger = logging.getLogger(__name__)
@@ -332,10 +332,12 @@ class RecurringScheduleService:
)
actions.append(start_action)
# Create STOP action
# Create STOP action (stop_cycle handles download when include_download is True)
stop_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"schedule_type": "weekly_calendar",
"include_download": schedule.include_download,
})
stop_action = ScheduledAction(
id=str(uuid.uuid4()),
@@ -350,27 +352,6 @@ class RecurringScheduleService:
)
actions.append(stop_action)
# Create DOWNLOAD action if enabled (1 minute after stop)
if schedule.include_download:
download_time = end_utc + timedelta(minutes=1)
download_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"schedule_type": "weekly_calendar",
})
download_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="download",
device_type=schedule.device_type,
scheduled_time=download_time,
execution_status="pending",
notes=download_notes,
)
actions.append(download_action)
return actions
def _generate_interval_actions(
@@ -613,8 +594,16 @@ class RecurringScheduleService:
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
def get_enabled_schedules(self) -> List[RecurringSchedule]:
"""Get all enabled recurring schedules."""
return self.db.query(RecurringSchedule).filter_by(enabled=True).all()
"""Get all enabled recurring schedules for projects that are not on hold or deleted."""
active_project_ids = [
p.id for p in self.db.query(Project.id).filter(
Project.status.notin_(["on_hold", "archived", "deleted"])
).all()
]
return self.db.query(RecurringSchedule).filter(
RecurringSchedule.enabled == True,
RecurringSchedule.project_id.in_(active_project_ids),
).all()
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:

View File

@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import and_
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.alert_service import get_alert_service
import uuid
@@ -107,10 +107,19 @@ class SchedulerService:
try:
# Find pending actions that are due
now = datetime.utcnow()
# Only execute actions for active/completed projects (not on_hold, archived, or deleted)
active_project_ids = [
p.id for p in db.query(Project.id).filter(
Project.status.notin_(["on_hold", "archived", "deleted"])
).all()
]
pending_actions = db.query(ScheduledAction).filter(
and_(
ScheduledAction.execution_status == "pending",
ScheduledAction.scheduled_time <= now,
ScheduledAction.project_id.in_(active_project_ids),
)
).order_by(ScheduledAction.scheduled_time).all()
@@ -263,7 +272,7 @@ class SchedulerService:
)
# Create recording session
session = RecordingSession(
session = MonitoringSession(
id=str(uuid.uuid4()),
project_id=action.project_id,
location_id=action.location_id,
@@ -295,9 +304,20 @@ class SchedulerService:
stop_cycle handles:
1. Stop measurement
2. Enable FTP
3. Download measurement folder
4. Verify download
3. Download measurement folder to SLMM local storage
After stop_cycle, if download succeeded, this method fetches the ZIP
from SLMM and extracts it into Terra-View's project directory, creating
DataFile records for each file.
"""
import hashlib
import io
import os
import zipfile
import httpx
from pathlib import Path
from backend.models import DataFile
# Parse notes for download preference
include_download = True
try:
@@ -308,7 +328,7 @@ class SchedulerService:
pass # Notes is plain text, not JSON
# Execute the full stop cycle via device controller
# SLMM handles stop, FTP enable, and download
# SLMM handles stop, FTP enable, and download to SLMM-local storage
cycle_response = await self.device_controller.stop_cycle(
unit_id,
action.device_type,
@@ -316,11 +336,11 @@ class SchedulerService:
)
# Find and update the active recording session
active_session = db.query(RecordingSession).filter(
active_session = db.query(MonitoringSession).filter(
and_(
RecordingSession.location_id == action.location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status == "recording",
MonitoringSession.location_id == action.location_id,
MonitoringSession.unit_id == unit_id,
MonitoringSession.status == "recording",
)
).first()
@@ -340,10 +360,81 @@ class SchedulerService:
except json.JSONDecodeError:
pass
db.commit()
# If SLMM downloaded the folder successfully, fetch the ZIP from SLMM
# and extract it into Terra-View's project directory, creating DataFile records
files_created = 0
if include_download and cycle_response.get("download_success") and active_session:
folder_name = cycle_response.get("downloaded_folder") # e.g. "Auto_0058"
remote_path = f"/NL-43/{folder_name}"
try:
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
async with httpx.AsyncClient(timeout=600.0) as client:
zip_response = await client.post(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download-folder",
json={"remote_path": remote_path}
)
if zip_response.is_success and len(zip_response.content) > 22:
base_dir = Path(f"data/Projects/{action.project_id}/{active_session.id}/{folder_name}")
base_dir.mkdir(parents=True, exist_ok=True)
file_type_map = {
'.wav': 'audio', '.mp3': 'audio',
'.csv': 'data', '.txt': 'data', '.json': 'data', '.dat': 'data',
'.rnd': 'data', '.rnh': 'data',
'.log': 'log',
'.zip': 'archive',
'.jpg': 'image', '.jpeg': 'image', '.png': 'image',
'.pdf': 'document',
}
with zipfile.ZipFile(io.BytesIO(zip_response.content)) as zf:
for zip_info in zf.filelist:
if zip_info.is_dir():
continue
file_data = zf.read(zip_info.filename)
file_path = base_dir / zip_info.filename
file_path.parent.mkdir(parents=True, exist_ok=True)
with open(file_path, 'wb') as f:
f.write(file_data)
checksum = hashlib.sha256(file_data).hexdigest()
ext = os.path.splitext(zip_info.filename)[1].lower()
data_file = DataFile(
id=str(uuid.uuid4()),
session_id=active_session.id,
file_path=str(file_path.relative_to("data")),
file_type=file_type_map.get(ext, 'data'),
file_size_bytes=len(file_data),
downloaded_at=datetime.utcnow(),
checksum=checksum,
file_metadata=json.dumps({
"source": "stop_cycle",
"remote_path": remote_path,
"unit_id": unit_id,
"folder_name": folder_name,
"relative_path": zip_info.filename,
}),
)
db.add(data_file)
files_created += 1
db.commit()
logger.info(f"Created {files_created} DataFile records for session {active_session.id} from {folder_name}")
else:
logger.warning(f"ZIP from SLMM for {folder_name} was empty or failed, skipping DataFile creation")
except Exception as e:
logger.error(f"Failed to extract ZIP and create DataFile records for {folder_name}: {e}")
# Don't fail the stop action — the device was stopped successfully
return {
"status": "stopped",
"session_id": active_session.id if active_session else None,
"cycle_response": cycle_response,
"files_created": files_created,
}
async def _execute_download(
@@ -526,11 +617,11 @@ class SchedulerService:
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
# Close out the old recording session
active_session = db.query(RecordingSession).filter(
active_session = db.query(MonitoringSession).filter(
and_(
RecordingSession.location_id == action.location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status == "recording",
MonitoringSession.location_id == action.location_id,
MonitoringSession.unit_id == unit_id,
MonitoringSession.status == "recording",
)
).first()
@@ -557,7 +648,7 @@ class SchedulerService:
result["steps"]["start"] = {"success": True, "response": cycle_response}
# Create new recording session
new_session = RecordingSession(
new_session = MonitoringSession(
id=str(uuid.uuid4()),
project_id=action.project_id,
location_id=action.location_id,

View File

@@ -659,7 +659,7 @@ class SLMMClient:
# Format as Auto_XXXX folder name
folder_name = f"Auto_{index_number:04d}"
remote_path = f"/NL43_DATA/{folder_name}"
remote_path = f"/NL-43/{folder_name}"
# Download the folder
result = await self.download_folder(unit_id, remote_path)

View File

@@ -5,6 +5,7 @@ All routers should import `templates` from this module to get consistent
filter and global function registration.
"""
import json as _json
from fastapi.templating import Jinja2Templates
# Import timezone utilities
@@ -32,8 +33,38 @@ def jinja_timezone_abbr():
# Create templates instance
templates = Jinja2Templates(directory="templates")
def jinja_local_date(dt, fmt="%m-%d-%y"):
"""Jinja filter: format a UTC datetime as a local date string (e.g. 02-19-26)."""
return format_local_datetime(dt, fmt)
def jinja_fromjson(s):
"""Jinja filter: parse a JSON string into a dict (returns {} on failure)."""
if not s:
return {}
try:
return _json.loads(s)
except Exception:
return {}
def jinja_same_date(dt1, dt2) -> bool:
"""Jinja global: True if two datetimes fall on the same local date."""
if not dt1 or not dt2:
return False
try:
d1 = format_local_datetime(dt1, "%Y-%m-%d")
d2 = format_local_datetime(dt2, "%Y-%m-%d")
return d1 == d2
except Exception:
return False
# Register Jinja filters and globals
templates.env.filters["local_datetime"] = jinja_local_datetime
templates.env.filters["local_time"] = jinja_local_time
templates.env.filters["local_date"] = jinja_local_date
templates.env.filters["fromjson"] = jinja_fromjson
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
templates.env.globals["get_user_timezone"] = get_user_timezone
templates.env.globals["same_date"] = jinja_same_date

View File

@@ -1,10 +0,0 @@
{
"filename": "snapshot_20251216_201738.db",
"created_at": "20251216_201738",
"created_at_iso": "2025-12-16T20:17:38.638982",
"description": "Auto-backup before restore",
"size_bytes": 57344,
"size_mb": 0.05,
"original_db_size_bytes": 57344,
"type": "manual"
}

View File

@@ -1,9 +0,0 @@
{
"filename": "snapshot_uploaded_20251216_201732.db",
"created_at": "20251216_201732",
"created_at_iso": "2025-12-16T20:17:32.574205",
"description": "Uploaded: snapshot_20251216_200259.db",
"size_bytes": 77824,
"size_mb": 0.07,
"type": "uploaded"
}

View File

@@ -24,30 +24,6 @@ services:
retries: 3
start_period: 40s
# --- TERRA-VIEW DEVELOPMENT ---
terra-view-dev:
build: .
container_name: terra-view-dev
ports:
- "1001:8001"
volumes:
- ./data-dev:/app/data
environment:
- PYTHONUNBUFFERED=1
- ENVIRONMENT=development
- SLMM_BASE_URL=http://host.docker.internal:8100
restart: unless-stopped
depends_on:
- slmm
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# --- SLMM (Sound Level Meter Manager) ---
slmm:
build:
@@ -61,6 +37,8 @@ services:
- PYTHONUNBUFFERED=1
- PORT=8100
- CORS_ORIGINS=*
- TCP_IDLE_TTL=-1
- TCP_MAX_AGE=-1
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
@@ -71,4 +49,3 @@ services:
volumes:
data:
data-dev:

View File

@@ -90,14 +90,14 @@ def rename_unit(old_id: str, new_id: str):
except Exception:
pass # Table may not exist
# Update recording_sessions table (if exists)
# Update monitoring_sessions table (if exists)
try:
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}
)
if result.rowcount > 0:
print(f" ✓ Updated recording_sessions ({result.rowcount} rows)")
print(f" ✓ Updated monitoring_sessions ({result.rowcount} rows)")
except Exception:
pass # Table may not exist

View File

@@ -70,7 +70,7 @@
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 %}
{% if assigned_unit and connection_mode == 'connected' %}
<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">
@@ -80,7 +80,7 @@
<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
Monitoring Sessions
</button>
<button onclick="switchTab('data')"
data-tab="data"
@@ -214,23 +214,54 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
{% if connection_mode == 'connected' %}
<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>
<span class="text-green-600 dark:text-green-400">Monitoring</span>
{% else %}
<span class="text-gray-500">Idle</span>
{% endif %}
</p>
{% else %}
<p class="text-sm text-gray-600 dark:text-gray-400">Mode</p>
<p class="text-lg font-semibold mt-2">
<span class="text-amber-600 dark:text-amber-400">Offline / Manual</span>
</p>
{% endif %}
</div>
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
{% if connection_mode == 'connected' %}
<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>
{% else %}
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" 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>
{% endif %}
</div>
</div>
</div>
</div>
{% if connection_mode == 'connected' and assigned_unit %}
<!-- Live Status Row (connected NRLs only) -->
<div class="mt-6">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Live Status</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ assigned_unit.id }}</span>
</div>
<div id="nrl-live-status"
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/live-status"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<div class="text-center py-4 text-gray-500 text-sm">Loading status&hellip;</div>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Settings Tab -->
@@ -281,8 +312,8 @@
</div>
</div>
<!-- Command Center Tab -->
{% if assigned_unit %}
<!-- Command Center Tab (connected NRLs only) -->
{% if assigned_unit and connection_mode == 'connected' %}
<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">
@@ -302,11 +333,11 @@
</div>
{% endif %}
<!-- Recording Sessions Tab -->
<!-- Monitoring 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>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring 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">
@@ -329,8 +360,40 @@
<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 class="flex items-center gap-3">
<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>
@@ -559,5 +622,64 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
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>
{% endblock %}

View File

@@ -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">
<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>
<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">
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>
</div>
{% endif %}

View File

@@ -0,0 +1,89 @@
<!-- Live Status Card content for connected NRLs -->
{% if error and not status %}
<div class="flex items-center gap-3 text-gray-500 dark:text-gray-400">
<svg class="w-5 h-5 text-amber-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<span class="text-sm">{{ error }}</span>
</div>
{% elif status %}
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
<!-- Measurement State -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">State</span>
{% set state = status.get('measurement_state', 'unknown') if status is mapping else 'unknown' %}
{% if state in ('measuring', 'recording') %}
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-green-600 dark:text-green-400">
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
Measuring
</span>
{% elif state == 'paused' %}
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-yellow-600 dark:text-yellow-400">
<span class="w-2 h-2 bg-yellow-500 rounded-full"></span>
Paused
</span>
{% elif state == 'stopped' %}
<span class="inline-flex items-center gap-1.5 text-sm font-semibold text-gray-600 dark:text-gray-400">
<span class="w-2 h-2 bg-gray-400 rounded-full"></span>
Stopped
</span>
{% else %}
<span class="text-sm text-gray-500 dark:text-gray-400 capitalize">{{ state }}</span>
{% endif %}
</div>
<!-- Lp (instantaneous) -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Lp (dB)</span>
{% set lp = status.get('lp') if status is mapping else None %}
<span class="text-xl font-bold text-gray-900 dark:text-white">
{% if lp is not none %}{{ "%.1f"|format(lp) }}{% else %}&mdash;{% endif %}
</span>
</div>
<!-- Battery -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Battery</span>
{% set batt = status.get('battery_level') if status is mapping else None %}
{% if batt is not none %}
<span class="text-sm font-semibold
{% if batt >= 60 %}text-green-600 dark:text-green-400
{% elif batt >= 30 %}text-yellow-600 dark:text-yellow-400
{% else %}text-red-600 dark:text-red-400{% endif %}">
{{ batt }}%
</span>
{% else %}
<span class="text-sm text-gray-500">&mdash;</span>
{% endif %}
</div>
<!-- Last Seen -->
<div class="flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 mb-1">Last Seen</span>
{% set last_seen = status.get('last_seen') if status is mapping else None %}
{% if last_seen %}
<span class="text-sm text-gray-700 dark:text-gray-300">{{ last_seen|local_datetime }}</span>
{% else %}
<span class="text-sm text-gray-500">&mdash;</span>
{% endif %}
</div>
</div>
{% if unit %}
<div class="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700 flex items-center justify-between">
<span class="text-xs text-gray-400 dark:text-gray-500">
Unit: {{ unit.id }}
{% if unit.slm_model %} &bull; {{ unit.slm_model }}{% endif %}
</span>
<a href="/slm/{{ unit.id }}"
class="text-xs text-seismo-orange hover:text-seismo-navy transition-colors">
Open Unit &rarr;
</a>
</div>
{% endif %}
{% else %}
<div class="text-sm text-gray-500 dark:text-gray-400">No status data available.</div>
{% endif %}

View File

@@ -13,6 +13,8 @@
</div>
{% if project.status == 'active' %}
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
{% elif project.status == 'on_hold' %}
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
{% elif project.status == 'completed' %}
<span class="px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
{% elif project.status == 'archived' %}

View File

@@ -34,6 +34,10 @@
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
Active
</span>
{% elif item.project.status == 'on_hold' %}
<span class="px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
On Hold
</span>
{% elif item.project.status == 'completed' %}
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
Completed

View File

@@ -16,6 +16,8 @@
{% if item.project.status == 'active' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
{% elif item.project.status == 'on_hold' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>
{% elif item.project.status == 'completed' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
{% elif item.project.status == 'archived' %}

View File

@@ -27,6 +27,20 @@
</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-500 dark:text-gray-400">On Hold</p>
<p class="text-3xl font-bold text-amber-600 dark:text-amber-400">{{ on_hold_projects }}</p>
</div>
<div class="p-3 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<svg class="w-8 h-8 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></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>

View File

@@ -5,7 +5,7 @@
{% if schedules %}
{% for item in schedules %}
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4
{% if not item.schedule.enabled %}opacity-60{% endif %}">
{% if project_status == 'on_hold' or not item.schedule.enabled %}opacity-60{% endif %}">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2">
@@ -29,7 +29,15 @@
{% endif %}
<!-- Status badge -->
{% if item.schedule.enabled %}
{% if project_status == 'on_hold' %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
On Hold
</span>
{% elif project_status == 'archived' %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
Archived
</span>
{% elif item.schedule.enabled %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
Active
</span>
@@ -98,7 +106,8 @@
</div>
</div>
<!-- Actions -->
<!-- Actions (hidden when project is on hold or archived) -->
{% if project_status not in ('on_hold', 'archived') %}
<div class="flex items-center gap-2 flex-shrink-0">
{% if item.schedule.enabled %}
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable"
@@ -131,6 +140,7 @@
</svg>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}

View File

@@ -19,7 +19,8 @@
<!-- Actions for this date -->
<div class="space-y-3 ml-13 pl-3 border-l-2 border-gray-200 dark:border-gray-700">
{% for item in date_group.actions %}
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow">
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow
{% if project_status == 'on_hold' and item.schedule.execution_status == 'pending' %}opacity-60{% endif %}">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3 mb-2">
@@ -54,6 +55,11 @@
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
Pending
</span>
{% if project_status == 'on_hold' %}
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">
On Hold
</span>
{% endif %}
{% elif item.schedule.execution_status == 'completed' %}
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
Completed
@@ -157,7 +163,8 @@
{% endif %}
</div>
<!-- Actions -->
<!-- Actions (hidden when project is on hold or archived) -->
{% if project_status not in ('on_hold', 'archived') %}
<div class="flex items-center gap-2 flex-shrink-0">
{% if item.schedule.execution_status == 'pending' %}
<button onclick="executeSchedule('{{ item.schedule.id }}')"
@@ -177,6 +184,7 @@
</button>
{% endif %}
</div>
{% endif %}
</div>
</div>
{% endfor %}

View File

@@ -5,7 +5,7 @@
<div class="mb-4">
<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">
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.
</p>
</div>

View File

@@ -1,4 +1,4 @@
<!-- Recording Sessions List -->
<!-- Monitoring Sessions List -->
{% if sessions %}
<div class="space-y-4">
{% 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">
<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>
<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>
</div>
{% endif %}
@@ -99,7 +99,7 @@ function viewSession(sessionId) {
}
function stopRecording(sessionId) {
if (!confirm('Stop this recording session?')) return;
if (!confirm('Stop this monitoring session?')) return;
// TODO: Implement stop recording API call
alert('Stop recording API coming soon for session: ' + sessionId);

View File

@@ -23,12 +23,26 @@
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path>
</svg>
<div>
{% set meta = session.session_metadata|fromjson if session.session_metadata else {} %}
{% set is_manual = meta.get('source') in ('manual_upload', 'bulk_upload') %}
<div class="font-semibold text-gray-900 dark:text-white">
{{ session.started_at|local_datetime if session.started_at else 'Unknown Date' }}
{% if location %}{{ location.name }}{% else %}Unknown Location{% endif %}
{% if session.started_at %}
&mdash;
{% if session.stopped_at and not same_date(session.started_at, session.stopped_at) %}
{{ session.started_at|local_date }} to {{ session.stopped_at|local_date }}
{% else %}
{{ session.started_at|local_date }}
{% endif %}
{% endif %}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
{% if location %} @ {{ location.name }}{% endif %}
{% if is_manual %}
{% set store = meta.get('store_name') %}
Manual upload{% if store %} &mdash; Store {{ store }}{% endif %}
{% elif unit %}
{{ unit.id }}
{% endif %}
<span class="mx-2"></span>
{{ files|length }} file{{ 's' if files|length != 1 else '' }}
</div>

View File

@@ -163,6 +163,25 @@
</div>
</div>
<!-- Deploy / Bench Toggle -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>
</svg>
<div>
<span class="font-medium text-gray-900 dark:text-white">Deployment Status</span>
<p id="slm-settings-deploy-desc" class="text-xs text-gray-500 dark:text-gray-400">Unit is currently deployed in the field</p>
</div>
</div>
<button type="button" id="slm-settings-deploy-btn"
onclick="toggleSLMDeployed()"
class="px-3 py-1.5 text-sm rounded-lg font-medium transition-colors">
</button>
</div>
</div>
<!-- FTP Enable Toggle -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<label class="flex items-center justify-between cursor-pointer">
@@ -264,6 +283,9 @@ async function openSLMSettingsModal(unitId) {
// FTP enabled from SLMM
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
// Deploy/bench status from Terra-View
updateDeployButton(unitData.deployed !== false);
} catch (error) {
console.error('Failed to load SLM settings:', error);
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
@@ -525,6 +547,77 @@ async function saveFTPSettings(event) {
return saveSLMSettings(event);
}
// Update the deploy/bench button appearance based on current deployed state
function updateDeployButton(isDeployed) {
const btn = document.getElementById('slm-settings-deploy-btn');
const desc = document.getElementById('slm-settings-deploy-desc');
const icon = btn.closest('.border').querySelector('svg');
if (isDeployed) {
btn.textContent = 'Bench Unit';
btn.className = 'px-3 py-1.5 text-sm rounded-lg font-medium transition-colors bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600';
desc.textContent = 'Unit is currently deployed in the field';
icon.classList.remove('text-gray-400');
icon.classList.add('text-green-500');
} else {
btn.textContent = 'Deploy Unit';
btn.className = 'px-3 py-1.5 text-sm rounded-lg font-medium transition-colors bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 hover:bg-green-200 dark:hover:bg-green-900/50';
desc.textContent = 'Unit is currently benched (not in field)';
icon.classList.remove('text-green-500');
icon.classList.add('text-gray-400');
}
// Store current state on button for toggle reference
btn.dataset.deployed = isDeployed ? '1' : '0';
}
// Toggle deploy/bench status
async function toggleSLMDeployed() {
const unitId = document.getElementById('slm-settings-unit-id').value;
const btn = document.getElementById('slm-settings-deploy-btn');
const errorDiv = document.getElementById('slm-settings-error');
const successDiv = document.getElementById('slm-settings-success');
const currentlyDeployed = btn.dataset.deployed === '1';
const newDeployed = !currentlyDeployed;
btn.disabled = true;
btn.textContent = newDeployed ? 'Deploying...' : 'Benching...';
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
try {
const formData = new FormData();
formData.append('deployed', newDeployed ? 'true' : 'false');
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.detail || 'Failed to update deployment status');
}
updateDeployButton(newDeployed);
successDiv.textContent = newDeployed ? 'Unit marked as deployed.' : 'Unit marked as benched.';
successDiv.classList.remove('hidden');
setTimeout(() => successDiv.classList.add('hidden'), 3000);
// Refresh any SLM list on the page
if (typeof htmx !== 'undefined') {
htmx.trigger('#slm-list', 'load');
}
} catch (error) {
btn.disabled = false;
updateDeployButton(currentlyDeployed); // restore button state
errorDiv.textContent = 'Error: ' + error.message;
errorDiv.classList.remove('hidden');
} finally {
btn.disabled = false;
}
}
// Close modal on background click
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
if (e.target === this) {

View File

@@ -50,12 +50,12 @@
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">
Schedules
</button>
<button onclick="switchTab('sessions')"
<button id="sessions-tab-btn" 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 whitespace-nowrap">
Recording Sessions
Monitoring Sessions
</button>
<button onclick="switchTab('data')"
<button id="data-tab-btn" 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 whitespace-nowrap">
Data Files
@@ -185,11 +185,11 @@
</div>
</div>
<!-- Recording Sessions Tab -->
<!-- Monitoring 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>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
<div class="flex items-center gap-4">
<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">
@@ -230,6 +230,13 @@
Project Files
</h2>
<div class="flex items-center gap-3">
<button onclick="toggleUploadAll()"
class="px-3 py-2 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 Days
</button>
<button onclick="htmx.trigger('#unified-files', 'refresh')"
class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -241,6 +248,37 @@
</div>
</div>
<!-- Upload Days Panel -->
<div id="upload-all-panel" class="hidden border-b border-gray-200 dark:border-gray-700">
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-800/50">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bulk Import — Select Folder</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Select your data folder directly — no zipping needed. Expected structure:
<code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">[date]/[NRL name]/[Auto_####]/</code>.
NRL folders are matched to locations by name. MP3s are stored; Excel exports are skipped.
</p>
<div class="flex flex-wrap items-center gap-3">
<input type="file" id="upload-all-input"
webkitdirectory directory multiple
class="block 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" />
<button onclick="submitUploadAll()"
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
Import
</button>
<button onclick="toggleUploadAll()"
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-all-status" class="text-sm hidden"></span>
</div>
<!-- Result summary -->
<div id="upload-all-results" class="hidden mt-3 text-sm space-y-1"></div>
</div>
</div>
<div id="unified-files"
hx-get="/api/projects/{{ project_id }}/files-unified"
hx-trigger="load, refresh from:#unified-files"
@@ -279,6 +317,7 @@
<select name="status" id="settings-status"
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">
<option value="active">Active</option>
<option value="on_hold">On Hold</option>
<option value="completed">Completed</option>
<option value="archived">Archived</option>
</select>
@@ -329,15 +368,40 @@
<!-- Danger Zone -->
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
Archive this project to remove it from active listings. All data will be preserved.
</p>
<div class="space-y-3">
<!-- On Hold -->
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Put Project On Hold</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Pause this project without archiving. Assignments and schedules remain in place.</p>
</div>
<div id="hold-btn-container" class="shrink-0">
<!-- Rendered by updateDangerZone() based on current status -->
</div>
</div>
<!-- Archive -->
<div class="bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-600 rounded-lg p-4 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Archive Project</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Remove from active listings. All data is preserved and can be restored.</p>
</div>
<button onclick="archiveProject()"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
Archive Project
class="shrink-0 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm">
Archive
</button>
</div>
<!-- Delete -->
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-white">Delete Project</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Permanently removes all project data after a 60-day grace period. This action is difficult to undo.</p>
</div>
<button onclick="openDeleteModal()"
class="shrink-0 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm">
Delete
</button>
</div>
</div>
</div>
</div>
</div>
@@ -376,7 +440,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type</label>
<select name="location_type" id="location-type"
<select name="location_type" id="location-type" onchange="updateConnectionModeVisibility()"
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">
<option value="sound">Sound</option>
<option value="vibration">Vibration</option>
@@ -389,6 +453,29 @@
</div>
</div>
<!-- Connection Mode — sound locations only -->
<div id="connection-mode-field">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connection Mode</label>
<div class="grid grid-cols-2 gap-3">
<label class="flex items-start gap-3 p-3 border-2 border-seismo-orange rounded-lg cursor-pointer bg-orange-50 dark:bg-orange-900/10" id="mode-connected-label">
<input type="radio" name="connection_mode" value="connected" checked
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
<div>
<div class="font-medium text-gray-900 dark:text-white text-sm">Connected</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Remote unit accessible via modem. Supports live control and FTP download.</div>
</div>
</label>
<label class="flex items-start gap-3 p-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" id="mode-offline-label">
<input type="radio" name="connection_mode" value="offline"
class="mt-0.5 text-seismo-orange" onchange="updateModeLabels()">
<div>
<div class="font-medium text-gray-900 dark:text-white text-sm">Offline / Manual Upload</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">No network access. Data collected from SD card and uploaded manually.</div>
</div>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" name="address" id="location-address"
@@ -495,7 +582,7 @@
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
</div>
<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>
</div>
</label>
@@ -596,6 +683,40 @@
</div>
</div>
<!-- Delete Project Confirmation Modal -->
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-red-100 dark:bg-red-900/40 rounded-lg">
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path>
</svg>
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Delete Project</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
This project will be soft-deleted and <strong class="text-gray-900 dark:text-white">permanently removed after 60 days</strong>. All associated locations, assignments, and sessions will be lost.
</p>
<p class="text-sm text-gray-700 dark:text-gray-300 mb-4">
Type <span class="font-mono font-bold text-red-600 dark:text-red-400">delete</span> to confirm:
</p>
<input type="text" id="delete-confirm-input"
placeholder="type delete"
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 mb-4 focus:outline-none focus:ring-2 focus:ring-red-500"
autocomplete="off">
<div class="flex gap-3 justify-end">
<button onclick="closeDeleteModal()"
class="px-4 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 text-sm">
Cancel
</button>
<button id="confirm-delete-btn" disabled onclick="executeDeleteProject()"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm disabled:opacity-40 disabled:cursor-not-allowed">
Delete Project
</button>
</div>
</div>
</div>
<script>
const projectId = "{{ project_id }}";
let editingLocationId = null;
@@ -654,14 +775,19 @@ async function loadProjectDetails() {
document.getElementById('settings-start-date').value = formatDate(data.start_date);
document.getElementById('settings-end-date').value = formatDate(data.end_date);
// Update tab labels based on project type
if (projectTypeId === 'sound_monitoring') {
// Update tab labels and visibility based on project type
const isSoundProject = projectTypeId === 'sound_monitoring';
if (isSoundProject) {
document.getElementById('locations-tab-label').textContent = 'NRLs';
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
document.getElementById('add-location-label').textContent = 'Add NRL';
}
// Monitoring Sessions and Data Files tabs are SLM-only
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
document.getElementById('settings-error').classList.add('hidden');
updateDangerZone();
} catch (err) {
console.error('Failed to load project details:', err);
}
@@ -729,6 +855,33 @@ function refreshProjectDashboard() {
}
// Location modal functions
function updateConnectionModeVisibility() {
const locType = document.getElementById('location-type').value;
const field = document.getElementById('connection-mode-field');
if (field) field.classList.toggle('hidden', locType !== 'sound');
}
function updateModeLabels() {
const connected = document.querySelector('input[name="connection_mode"][value="connected"]');
const offline = document.querySelector('input[name="connection_mode"][value="offline"]');
const connLabel = document.getElementById('mode-connected-label');
const offLabel = document.getElementById('mode-offline-label');
if (!connected || !connLabel || !offLabel) return;
const activeClasses = ['border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/10'];
const inactiveClasses = ['border-gray-300', 'dark:border-gray-600'];
if (connected.checked) {
connLabel.classList.add(...activeClasses);
connLabel.classList.remove(...inactiveClasses);
offLabel.classList.remove(...activeClasses);
offLabel.classList.add(...inactiveClasses);
} else {
offLabel.classList.add(...activeClasses);
offLabel.classList.remove(...inactiveClasses);
connLabel.classList.remove(...activeClasses);
connLabel.classList.add(...inactiveClasses);
}
}
function openLocationModal(defaultType) {
editingLocationId = null;
document.getElementById('location-modal-title').textContent = 'Add Location';
@@ -737,17 +890,25 @@ function openLocationModal(defaultType) {
document.getElementById('location-description').value = '';
document.getElementById('location-address').value = '';
document.getElementById('location-coordinates').value = '';
// Reset connection mode to connected
const connectedRadio = document.querySelector('input[name="connection_mode"][value="connected"]');
if (connectedRadio) { connectedRadio.checked = true; updateModeLabels(); }
const locationTypeSelect = document.getElementById('location-type');
const locationTypeWrapper = locationTypeSelect.closest('div');
if (projectTypeId === 'sound_monitoring') {
locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (projectTypeId === 'vibration_monitoring') {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else {
locationTypeSelect.disabled = false;
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
locationTypeSelect.value = defaultType || 'sound';
}
updateConnectionModeVisibility();
document.getElementById('location-error').classList.add('hidden');
document.getElementById('location-modal').classList.remove('hidden');
}
@@ -761,17 +922,27 @@ function openEditLocationModal(button) {
document.getElementById('location-description').value = data.description || '';
document.getElementById('location-address').value = data.address || '';
document.getElementById('location-coordinates').value = data.coordinates || '';
// Restore connection mode from metadata
let savedMode = 'connected';
try { savedMode = JSON.parse(data.location_metadata || '{}').connection_mode || 'connected'; } catch(e) {}
const modeRadio = document.querySelector(`input[name="connection_mode"][value="${savedMode}"]`);
if (modeRadio) { modeRadio.checked = true; updateModeLabels(); }
const locationTypeSelect = document.getElementById('location-type');
const locationTypeWrapper = locationTypeSelect.closest('div');
if (projectTypeId === 'sound_monitoring') {
locationTypeSelect.value = 'sound';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else if (projectTypeId === 'vibration_monitoring') {
locationTypeSelect.value = 'vibration';
locationTypeSelect.disabled = true;
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
} else {
locationTypeSelect.disabled = false;
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
locationTypeSelect.value = data.location_type || 'sound';
}
updateConnectionModeVisibility();
document.getElementById('location-error').classList.add('hidden');
document.getElementById('location-modal').classList.remove('hidden');
}
@@ -790,8 +961,12 @@ document.getElementById('location-form').addEventListener('submit', async functi
let locationType = document.getElementById('location-type').value;
if (projectTypeId === 'sound_monitoring') {
locationType = 'sound';
} else if (projectTypeId === 'vibration_monitoring') {
locationType = 'vibration';
}
const connectionMode = document.querySelector('input[name="connection_mode"]:checked')?.value || 'connected';
try {
if (editingLocationId) {
const payload = {
@@ -799,7 +974,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
description: description || null,
address: address || null,
coordinates: coordinates || null,
location_type: locationType
location_type: locationType,
location_metadata: JSON.stringify({ connection_mode: connectionMode }),
};
const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, {
method: 'PUT',
@@ -817,6 +993,7 @@ document.getElementById('location-form').addEventListener('submit', async functi
formData.append('address', address);
formData.append('coordinates', coordinates);
formData.append('location_type', locationType);
formData.append('location_metadata', JSON.stringify({ connection_mode: connectionMode }));
const response = await fetch(`/api/projects/${projectId}/locations/create`, {
method: 'POST',
body: formData
@@ -1027,6 +1204,78 @@ function archiveProject() {
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
}
async function holdProject() {
try {
const response = await fetch(`/api/projects/${projectId}/hold`, { method: 'POST' });
if (!response.ok) throw new Error('Failed to put project on hold');
await loadProjectDetails();
updateDangerZone();
htmx.trigger('#project-header', 'load');
} catch (err) {
alert('Failed to put project on hold: ' + err.message);
}
}
async function unholdProject() {
try {
const response = await fetch(`/api/projects/${projectId}/unhold`, { method: 'POST' });
if (!response.ok) throw new Error('Failed to resume project');
await loadProjectDetails();
updateDangerZone();
htmx.trigger('#project-header', 'load');
} catch (err) {
alert('Failed to resume project: ' + err.message);
}
}
function updateDangerZone() {
const status = document.getElementById('settings-status').value;
const container = document.getElementById('hold-btn-container');
if (!container) return;
if (status === 'on_hold') {
container.innerHTML = `<button onclick="unholdProject()"
class="px-4 py-2 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors text-sm">
Resume Project
</button>`;
} else {
container.innerHTML = `<button onclick="holdProject()"
class="px-4 py-2 border border-amber-500 text-amber-600 dark:text-amber-400 rounded-lg hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors text-sm">
Put On Hold
</button>`;
}
}
function openDeleteModal() {
document.getElementById('delete-confirm-input').value = '';
document.getElementById('confirm-delete-btn').disabled = true;
document.getElementById('delete-project-modal').classList.remove('hidden');
document.getElementById('delete-confirm-input').focus();
}
function closeDeleteModal() {
document.getElementById('delete-project-modal').classList.add('hidden');
}
async function executeDeleteProject() {
try {
const response = await fetch(`/api/projects/${projectId}`, { method: 'DELETE' });
if (!response.ok) throw new Error('Failed to delete project');
closeDeleteModal();
window.location.href = '/projects';
} catch (err) {
alert('Failed to delete project: ' + err.message);
}
}
document.addEventListener('DOMContentLoaded', function() {
const input = document.getElementById('delete-confirm-input');
if (input) {
input.addEventListener('input', function() {
document.getElementById('confirm-delete-btn').disabled = this.value !== 'delete';
});
}
});
// ============================================================================
// Schedule Modal Functions
// ============================================================================
@@ -1326,6 +1575,88 @@ document.getElementById('schedule-modal')?.addEventListener('click', function(e)
}
});
// ── Upload Days ───────────────────────────────────────────────────────────────
function toggleUploadAll() {
const panel = document.getElementById('upload-all-panel');
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) {
document.getElementById('upload-all-status').textContent = '';
document.getElementById('upload-all-status').className = 'text-sm hidden';
document.getElementById('upload-all-results').classList.add('hidden');
document.getElementById('upload-all-results').innerHTML = '';
document.getElementById('upload-all-input').value = '';
}
}
async function submitUploadAll() {
const input = document.getElementById('upload-all-input');
const status = document.getElementById('upload-all-status');
const resultsEl = document.getElementById('upload-all-results');
if (!input.files.length) {
alert('Please select a folder to upload.');
return;
}
const formData = new FormData();
for (const f of input.files) {
// webkitRelativePath gives the path relative to the selected folder root
formData.append('files', f);
formData.append('paths', f.webkitRelativePath || f.name);
}
status.textContent = `Uploading ${input.files.length} files\u2026`;
status.className = 'text-sm text-gray-500';
resultsEl.classList.add('hidden');
try {
const response = await fetch(
`/api/projects/{{ project_id }}/upload-all`,
{ method: 'POST', body: formData }
);
const data = await response.json();
if (response.ok) {
const s = data.sessions_created;
const f = data.files_imported;
status.textContent = `\u2713 Imported ${f} file${f !== 1 ? 's' : ''} across ${s} session${s !== 1 ? 's' : ''}`;
status.className = 'text-sm text-green-600 dark:text-green-400';
input.value = '';
// Build results summary
let html = '';
if (data.sessions && data.sessions.length) {
html += '<div class="font-medium text-gray-700 dark:text-gray-300 mb-1">Sessions created:</div>';
html += '<ul class="space-y-0.5 ml-2">';
for (const sess of data.sessions) {
html += `<li class="text-xs text-gray-600 dark:text-gray-400">\u2022 <span class="font-medium">${sess.location_name}</span> &mdash; ${sess.files} files`;
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
if (sess.store_name) html += ` &mdash; ${sess.store_name}`;
html += '</li>';
}
html += '</ul>';
}
if (data.unmatched_folders && data.unmatched_folders.length) {
html += `<div class="mt-2 text-xs text-amber-600 dark:text-amber-400">\u26a0 Unmatched folders (no NRL location found): ${data.unmatched_folders.join(', ')}</div>`;
}
if (html) {
resultsEl.innerHTML = html;
resultsEl.classList.remove('hidden');
}
// Refresh the unified files view
htmx.trigger(document.getElementById('unified-files'), 'refresh');
} 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';
}
}
// Load project details on page load and restore active tab from URL hash
document.addEventListener('DOMContentLoaded', function() {
loadProjectDetails();

View File

@@ -18,7 +18,7 @@
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 mb-8"
hx-get="/api/projects/stats"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
@@ -27,6 +27,7 @@
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
</div>
<!-- Tabs -->
@@ -43,6 +44,11 @@
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
Active
</button>
<button onclick="switchTab('on_hold')"
id="tab-on_hold"
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
On Hold
</button>
<button onclick="switchTab('completed')"
id="tab-completed"
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
@@ -72,10 +78,17 @@
<!-- Create Project Modal -->
<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="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">
<div>
<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 class="p-6" id="createProjectContent">
<!-- Step 1: Project Type Selection (initially shown) -->
@@ -91,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>
<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>
<!-- Step 2: Project Details Form (hidden initially) -->

View File

@@ -1504,7 +1504,7 @@
`• Unit roster entry\n` +
`• All history records\n` +
`• Project assignments\n` +
`Recording sessions\n` +
`Monitoring sessions\n` +
`• Modem references\n\n` +
`This action cannot be undone.`
);

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