Compare commits
3 Commits
bd3d937a82
...
v0.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
| b15d434fce | |||
| 7b4e12c127 | |||
| 742a98a8ed |
@@ -1,5 +1,3 @@
|
|||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# Python cache / compiled
|
# Python cache / compiled
|
||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -30,7 +28,6 @@ ENV/
|
|||||||
|
|
||||||
# Runtime data (mounted volumes)
|
# Runtime data (mounted volumes)
|
||||||
data/
|
data/
|
||||||
data-dev/
|
|
||||||
|
|
||||||
# Editors / OS junk
|
# Editors / OS junk
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|||||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -1,13 +1,3 @@
|
|||||||
# Terra-View Specifics
|
|
||||||
# SQLite database files
|
|
||||||
*.db
|
|
||||||
*.db-journal
|
|
||||||
data/
|
|
||||||
data-dev/
|
|
||||||
.aider*
|
|
||||||
.aider*
|
|
||||||
docker-compose.override.yml
|
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[codz]
|
*.py[codz]
|
||||||
@@ -216,3 +206,10 @@ marimo/_static/
|
|||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
|
# Seismo Fleet Manager
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
data/
|
||||||
|
.aider*
|
||||||
|
.aider*
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from backend.models import (
|
|||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
ScheduledAction,
|
ScheduledAction,
|
||||||
MonitoringSession,
|
RecordingSession,
|
||||||
DataFile,
|
DataFile,
|
||||||
)
|
)
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ async def nrl_detail_page(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""NRL (Noise Recording Location) detail page with tabs"""
|
"""NRL (Noise Recording Location) detail page with tabs"""
|
||||||
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, MonitoringSession, DataFile
|
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
# Get project
|
# Get project
|
||||||
@@ -348,33 +348,23 @@ async def nrl_detail_page(
|
|||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
|
||||||
# Get session count
|
# Get session count
|
||||||
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
session_count = db.query(RecordingSession).filter_by(location_id=location_id).count()
|
||||||
|
|
||||||
# Get file count (DataFile links to session, not directly to location)
|
# Get file count (DataFile links to session, not directly to location)
|
||||||
file_count = db.query(DataFile).join(
|
file_count = db.query(DataFile).join(
|
||||||
MonitoringSession,
|
RecordingSession,
|
||||||
DataFile.session_id == MonitoringSession.id
|
DataFile.session_id == RecordingSession.id
|
||||||
).filter(MonitoringSession.location_id == location_id).count()
|
).filter(RecordingSession.location_id == location_id).count()
|
||||||
|
|
||||||
# Check for active session
|
# Check for active session
|
||||||
active_session = db.query(MonitoringSession).filter(
|
active_session = db.query(RecordingSession).filter(
|
||||||
and_(
|
and_(
|
||||||
MonitoringSession.location_id == location_id,
|
RecordingSession.location_id == location_id,
|
||||||
MonitoringSession.status == "recording"
|
RecordingSession.status == "recording"
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# Parse connection_mode from location_metadata JSON
|
return templates.TemplateResponse("nrl_detail.html", {
|
||||||
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,
|
"request": request,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"location_id": location_id,
|
"location_id": location_id,
|
||||||
@@ -385,7 +375,6 @@ async def nrl_detail_page(
|
|||||||
"session_count": session_count,
|
"session_count": session_count,
|
||||||
"file_count": file_count,
|
"file_count": file_count,
|
||||||
"active_session": active_session,
|
"active_session": active_session,
|
||||||
"connection_mode": connection_mode,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
"""
|
|
||||||
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)
|
|
||||||
@@ -155,7 +155,7 @@ class Project(Base):
|
|||||||
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
||||||
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
status = Column(String, default="active") # active, completed, archived
|
||||||
|
|
||||||
# Project metadata
|
# Project metadata
|
||||||
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||||
@@ -166,7 +166,6 @@ class Project(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=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):
|
class MonitoringLocation(Base):
|
||||||
@@ -245,17 +244,17 @@ class ScheduledAction(Base):
|
|||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class MonitoringSession(Base):
|
class RecordingSession(Base):
|
||||||
"""
|
"""
|
||||||
Monitoring sessions: tracks actual monitoring sessions.
|
Recording sessions: tracks actual monitoring sessions.
|
||||||
Created when monitoring starts, updated when it stops.
|
Created when recording starts, updated when it stops.
|
||||||
"""
|
"""
|
||||||
__tablename__ = "monitoring_sessions"
|
__tablename__ = "recording_sessions"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
||||||
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
||||||
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
|
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||||
|
|
||||||
session_type = Column(String, nullable=False) # sound | vibration
|
session_type = Column(String, nullable=False) # sound | vibration
|
||||||
started_at = Column(DateTime, nullable=False)
|
started_at = Column(DateTime, nullable=False)
|
||||||
@@ -278,7 +277,7 @@ class DataFile(Base):
|
|||||||
__tablename__ = "data_files"
|
__tablename__ = "data_files"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
session_id = Column(String, nullable=False, index=True) # FK to MonitoringSession.id
|
session_id = Column(String, nullable=False, index=True) # FK to RecordingSession.id
|
||||||
|
|
||||||
file_path = Column(String, nullable=False) # Relative to data/Projects/
|
file_path = Column(String, nullable=False) # Relative to data/Projects/
|
||||||
file_type = Column(String, nullable=False) # wav, csv, mseed, json
|
file_type = Column(String, nullable=False) # wav, csv, mseed, json
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
@@ -49,18 +48,10 @@ def dashboard_todays_actions(request: Request, db: Session = Depends(get_db)):
|
|||||||
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
today_end_utc = today_end_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
|
# Query today's actions
|
||||||
actions = db.query(ScheduledAction).filter(
|
actions = db.query(ScheduledAction).filter(
|
||||||
ScheduledAction.scheduled_time >= today_start_utc,
|
ScheduledAction.scheduled_time >= today_start_utc,
|
||||||
ScheduledAction.scheduled_time < today_end_utc,
|
ScheduledAction.scheduled_time < today_end_utc,
|
||||||
ScheduledAction.project_id.notin_(paused_project_ids),
|
|
||||||
).order_by(ScheduledAction.scheduled_time.asc()).all()
|
).order_by(ScheduledAction.scheduled_time.asc()).all()
|
||||||
|
|
||||||
# Enrich with location/project info and parse results
|
# Enrich with location/project info and parse results
|
||||||
|
|||||||
@@ -14,12 +14,6 @@ from typing import Optional
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import UploadFile, File
|
|
||||||
import zipfile
|
|
||||||
import hashlib
|
|
||||||
import io
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
Project,
|
Project,
|
||||||
@@ -27,8 +21,7 @@ from backend.models import (
|
|||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
RosterUnit,
|
RosterUnit,
|
||||||
MonitoringSession,
|
RecordingSession,
|
||||||
DataFile,
|
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
@@ -77,8 +70,8 @@ async def get_project_locations(
|
|||||||
if assignment:
|
if assignment:
|
||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
|
||||||
# Count monitoring sessions
|
# Count recording sessions
|
||||||
session_count = db.query(MonitoringSession).filter_by(
|
session_count = db.query(RecordingSession).filter_by(
|
||||||
location_id=location.id
|
location_id=location.id
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
@@ -377,19 +370,19 @@ async def unassign_unit(
|
|||||||
if not assignment:
|
if not assignment:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
# Check if there are active monitoring sessions
|
# Check if there are active recording sessions
|
||||||
active_sessions = db.query(MonitoringSession).filter(
|
active_sessions = db.query(RecordingSession).filter(
|
||||||
and_(
|
and_(
|
||||||
MonitoringSession.location_id == assignment.location_id,
|
RecordingSession.location_id == assignment.location_id,
|
||||||
MonitoringSession.unit_id == assignment.unit_id,
|
RecordingSession.unit_id == assignment.unit_id,
|
||||||
MonitoringSession.status == "recording",
|
RecordingSession.status == "recording",
|
||||||
)
|
)
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
if active_sessions > 0:
|
if active_sessions > 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Cannot unassign unit with active monitoring sessions. Stop monitoring first.",
|
detail="Cannot unassign unit with active recording sessions. Stop recording first.",
|
||||||
)
|
)
|
||||||
|
|
||||||
assignment.status = "completed"
|
assignment.status = "completed"
|
||||||
@@ -458,12 +451,14 @@ async def get_nrl_sessions(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get monitoring sessions for a specific NRL.
|
Get recording sessions for a specific NRL.
|
||||||
Returns HTML partial with session list.
|
Returns HTML partial with session list.
|
||||||
"""
|
"""
|
||||||
sessions = db.query(MonitoringSession).filter_by(
|
from backend.models import RecordingSession, RosterUnit
|
||||||
|
|
||||||
|
sessions = db.query(RecordingSession).filter_by(
|
||||||
location_id=location_id
|
location_id=location_id
|
||||||
).order_by(MonitoringSession.started_at.desc()).all()
|
).order_by(RecordingSession.started_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with unit details
|
# Enrich with unit details
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
@@ -496,12 +491,14 @@ async def get_nrl_files(
|
|||||||
Get data files for a specific NRL.
|
Get data files for a specific NRL.
|
||||||
Returns HTML partial with file list.
|
Returns HTML partial with file list.
|
||||||
"""
|
"""
|
||||||
# Join DataFile with MonitoringSession to filter by location_id
|
from backend.models import DataFile, RecordingSession
|
||||||
|
|
||||||
|
# Join DataFile with RecordingSession to filter by location_id
|
||||||
files = db.query(DataFile).join(
|
files = db.query(DataFile).join(
|
||||||
MonitoringSession,
|
RecordingSession,
|
||||||
DataFile.session_id == MonitoringSession.id
|
DataFile.session_id == RecordingSession.id
|
||||||
).filter(
|
).filter(
|
||||||
MonitoringSession.location_id == location_id
|
RecordingSession.location_id == location_id
|
||||||
).order_by(DataFile.created_at.desc()).all()
|
).order_by(DataFile.created_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with session details
|
# Enrich with session details
|
||||||
@@ -509,7 +506,7 @@ async def get_nrl_files(
|
|||||||
for file in files:
|
for file in files:
|
||||||
session = None
|
session = None
|
||||||
if file.session_id:
|
if file.session_id:
|
||||||
session = db.query(MonitoringSession).filter_by(id=file.session_id).first()
|
session = db.query(RecordingSession).filter_by(id=file.session_id).first()
|
||||||
|
|
||||||
files_data.append({
|
files_data.append({
|
||||||
"file": file,
|
"file": file,
|
||||||
@@ -522,281 +519,3 @@ async def get_nrl_files(
|
|||||||
"location_id": location_id,
|
"location_id": location_id,
|
||||||
"files": files_data,
|
"files": files_data,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# Manual SD Card Data Upload
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
def _parse_rnh(content: bytes) -> dict:
|
|
||||||
"""
|
|
||||||
Parse a Rion .rnh metadata file (INI-style with [Section] headers).
|
|
||||||
Returns a dict of key metadata fields.
|
|
||||||
"""
|
|
||||||
result = {}
|
|
||||||
try:
|
|
||||||
text = content.decode("utf-8", errors="replace")
|
|
||||||
for line in text.splitlines():
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith("["):
|
|
||||||
continue
|
|
||||||
if "," in line:
|
|
||||||
key, _, value = line.partition(",")
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip()
|
|
||||||
if key == "Serial Number":
|
|
||||||
result["serial_number"] = value
|
|
||||||
elif key == "Store Name":
|
|
||||||
result["store_name"] = value
|
|
||||||
elif key == "Index Number":
|
|
||||||
result["index_number"] = value
|
|
||||||
elif key == "Measurement Start Time":
|
|
||||||
result["start_time_str"] = value
|
|
||||||
elif key == "Measurement Stop Time":
|
|
||||||
result["stop_time_str"] = value
|
|
||||||
elif key == "Total Measurement Time":
|
|
||||||
result["total_time_str"] = value
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_rnh_datetime(s: str):
|
|
||||||
"""Parse RNH datetime string: '2026/02/17 19:00:19' -> datetime"""
|
|
||||||
from datetime import datetime
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _classify_file(filename: str) -> str:
|
|
||||||
"""Classify a file by name into a DataFile file_type."""
|
|
||||||
name = filename.lower()
|
|
||||||
if name.endswith(".rnh"):
|
|
||||||
return "log"
|
|
||||||
if name.endswith(".rnd"):
|
|
||||||
return "measurement"
|
|
||||||
if name.endswith(".zip"):
|
|
||||||
return "archive"
|
|
||||||
return "data"
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/nrl/{location_id}/upload-data")
|
|
||||||
async def upload_nrl_data(
|
|
||||||
project_id: str,
|
|
||||||
location_id: str,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
files: list[UploadFile] = File(...),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Manually upload SD card data for an offline NRL.
|
|
||||||
|
|
||||||
Accepts either:
|
|
||||||
- A single .zip file (the Auto_#### folder zipped) — auto-extracted
|
|
||||||
- Multiple .rnd / .rnh files selected directly from the SD card folder
|
|
||||||
|
|
||||||
Creates a MonitoringSession from .rnh metadata and DataFile records
|
|
||||||
for each measurement file. No unit assignment required.
|
|
||||||
"""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# Verify project and location exist
|
|
||||||
location = db.query(MonitoringLocation).filter_by(
|
|
||||||
id=location_id, project_id=project_id
|
|
||||||
).first()
|
|
||||||
if not location:
|
|
||||||
raise HTTPException(status_code=404, detail="Location not found")
|
|
||||||
|
|
||||||
# --- Step 1: Normalize to (filename, bytes) list ---
|
|
||||||
file_entries: list[tuple[str, bytes]] = []
|
|
||||||
|
|
||||||
if len(files) == 1 and files[0].filename.lower().endswith(".zip"):
|
|
||||||
raw = await files[0].read()
|
|
||||||
try:
|
|
||||||
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
|
||||||
for info in zf.infolist():
|
|
||||||
if info.is_dir():
|
|
||||||
continue
|
|
||||||
name = Path(info.filename).name # strip folder path
|
|
||||||
if not name:
|
|
||||||
continue
|
|
||||||
file_entries.append((name, zf.read(info)))
|
|
||||||
except zipfile.BadZipFile:
|
|
||||||
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive.")
|
|
||||||
else:
|
|
||||||
for uf in files:
|
|
||||||
data = await uf.read()
|
|
||||||
file_entries.append((uf.filename, data))
|
|
||||||
|
|
||||||
if not file_entries:
|
|
||||||
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
|
||||||
|
|
||||||
# --- Step 2: Find and parse .rnh metadata ---
|
|
||||||
rnh_meta = {}
|
|
||||||
for fname, fbytes in file_entries:
|
|
||||||
if fname.lower().endswith(".rnh"):
|
|
||||||
rnh_meta = _parse_rnh(fbytes)
|
|
||||||
break
|
|
||||||
|
|
||||||
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
|
||||||
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
|
||||||
duration_seconds = None
|
|
||||||
if started_at and stopped_at:
|
|
||||||
duration_seconds = int((stopped_at - started_at).total_seconds())
|
|
||||||
|
|
||||||
store_name = rnh_meta.get("store_name", "")
|
|
||||||
serial_number = rnh_meta.get("serial_number", "")
|
|
||||||
index_number = rnh_meta.get("index_number", "")
|
|
||||||
|
|
||||||
# --- Step 3: Create MonitoringSession ---
|
|
||||||
session_id = str(uuid.uuid4())
|
|
||||||
monitoring_session = MonitoringSession(
|
|
||||||
id=session_id,
|
|
||||||
project_id=project_id,
|
|
||||||
location_id=location_id,
|
|
||||||
unit_id=None,
|
|
||||||
session_type="sound",
|
|
||||||
started_at=started_at,
|
|
||||||
stopped_at=stopped_at,
|
|
||||||
duration_seconds=duration_seconds,
|
|
||||||
status="completed",
|
|
||||||
session_metadata=json.dumps({
|
|
||||||
"source": "manual_upload",
|
|
||||||
"store_name": store_name,
|
|
||||||
"serial_number": serial_number,
|
|
||||||
"index_number": index_number,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
db.add(monitoring_session)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(monitoring_session)
|
|
||||||
|
|
||||||
# --- Step 4: Write files to disk and create DataFile records ---
|
|
||||||
output_dir = Path("data/Projects") / project_id / session_id
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
leq_count = 0
|
|
||||||
lp_count = 0
|
|
||||||
metadata_count = 0
|
|
||||||
files_imported = 0
|
|
||||||
|
|
||||||
for fname, fbytes in file_entries:
|
|
||||||
file_type = _classify_file(fname)
|
|
||||||
fname_lower = fname.lower()
|
|
||||||
|
|
||||||
# Track counts for summary
|
|
||||||
if fname_lower.endswith(".rnd"):
|
|
||||||
if "_leq_" in fname_lower:
|
|
||||||
leq_count += 1
|
|
||||||
elif "_lp" in fname_lower:
|
|
||||||
lp_count += 1
|
|
||||||
elif fname_lower.endswith(".rnh"):
|
|
||||||
metadata_count += 1
|
|
||||||
|
|
||||||
# Write to disk
|
|
||||||
dest = output_dir / fname
|
|
||||||
dest.write_bytes(fbytes)
|
|
||||||
|
|
||||||
# Compute checksum
|
|
||||||
checksum = hashlib.sha256(fbytes).hexdigest()
|
|
||||||
|
|
||||||
# Store relative path from data/ dir
|
|
||||||
rel_path = str(dest.relative_to("data"))
|
|
||||||
|
|
||||||
data_file = DataFile(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
session_id=session_id,
|
|
||||||
file_path=rel_path,
|
|
||||||
file_type=file_type,
|
|
||||||
file_size_bytes=len(fbytes),
|
|
||||||
downloaded_at=datetime.utcnow(),
|
|
||||||
checksum=checksum,
|
|
||||||
file_metadata=json.dumps({
|
|
||||||
"source": "manual_upload",
|
|
||||||
"original_filename": fname,
|
|
||||||
"store_name": store_name,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
db.add(data_file)
|
|
||||||
files_imported += 1
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"session_id": session_id,
|
|
||||||
"files_imported": files_imported,
|
|
||||||
"leq_files": leq_count,
|
|
||||||
"lp_files": lp_count,
|
|
||||||
"metadata_files": metadata_count,
|
|
||||||
"store_name": store_name,
|
|
||||||
"started_at": started_at.isoformat() if started_at else None,
|
|
||||||
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 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,
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -23,18 +23,12 @@ import io
|
|||||||
from backend.utils.timezone import utc_to_local, format_local_datetime
|
from backend.utils.timezone import utc_to_local, format_local_datetime
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from fastapi import UploadFile, File
|
|
||||||
import zipfile
|
|
||||||
import hashlib
|
|
||||||
import pathlib as _pathlib
|
|
||||||
|
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
Project,
|
Project,
|
||||||
ProjectType,
|
ProjectType,
|
||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
MonitoringSession,
|
RecordingSession,
|
||||||
DataFile,
|
|
||||||
ScheduledAction,
|
ScheduledAction,
|
||||||
RecurringSchedule,
|
RecurringSchedule,
|
||||||
RosterUnit,
|
RosterUnit,
|
||||||
@@ -63,11 +57,9 @@ async def get_projects_list(
|
|||||||
"""
|
"""
|
||||||
query = db.query(Project)
|
query = db.query(Project)
|
||||||
|
|
||||||
# Filter by status if provided; otherwise exclude soft-deleted projects
|
# Filter by status if provided
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(Project.status == status)
|
query = query.filter(Project.status == status)
|
||||||
else:
|
|
||||||
query = query.filter(Project.status != "deleted")
|
|
||||||
|
|
||||||
# Filter by project type if provided
|
# Filter by project type if provided
|
||||||
if project_type_id:
|
if project_type_id:
|
||||||
@@ -95,10 +87,10 @@ async def get_projects_list(
|
|||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Count active sessions
|
# Count active sessions
|
||||||
active_session_count = db.query(func.count(MonitoringSession.id)).filter(
|
active_session_count = db.query(func.count(RecordingSession.id)).filter(
|
||||||
and_(
|
and_(
|
||||||
MonitoringSession.project_id == project.id,
|
RecordingSession.project_id == project.id,
|
||||||
MonitoringSession.status == "recording",
|
RecordingSession.status == "recording",
|
||||||
)
|
)
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
@@ -126,10 +118,9 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
Get summary statistics for projects overview.
|
Get summary statistics for projects overview.
|
||||||
Returns HTML partial with stat cards.
|
Returns HTML partial with stat cards.
|
||||||
"""
|
"""
|
||||||
# Count projects by status (exclude deleted)
|
# Count projects by status
|
||||||
total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar()
|
total_projects = db.query(func.count(Project.id)).scalar()
|
||||||
active_projects = db.query(func.count(Project.id)).filter_by(status="active").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()
|
completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar()
|
||||||
|
|
||||||
# Count total locations across all projects
|
# Count total locations across all projects
|
||||||
@@ -141,7 +132,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Count active recording sessions
|
# Count active recording sessions
|
||||||
active_sessions = db.query(func.count(MonitoringSession.id)).filter_by(
|
active_sessions = db.query(func.count(RecordingSession.id)).filter_by(
|
||||||
status="recording"
|
status="recording"
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
@@ -149,7 +140,6 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
"request": request,
|
"request": request,
|
||||||
"total_projects": total_projects,
|
"total_projects": total_projects,
|
||||||
"active_projects": active_projects,
|
"active_projects": active_projects,
|
||||||
"on_hold_projects": on_hold_projects,
|
|
||||||
"completed_projects": completed_projects,
|
"completed_projects": completed_projects,
|
||||||
"total_locations": total_locations,
|
"total_locations": total_locations,
|
||||||
"assigned_units": assigned_units,
|
"assigned_units": assigned_units,
|
||||||
@@ -188,13 +178,13 @@ async def search_projects(
|
|||||||
if not q.strip():
|
if not q.strip():
|
||||||
# Return recent active projects when no search term
|
# Return recent active projects when no search term
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
Project.status.notin_(["archived", "deleted"])
|
Project.status != "archived"
|
||||||
).order_by(Project.updated_at.desc()).limit(limit).all()
|
).order_by(Project.updated_at.desc()).limit(limit).all()
|
||||||
else:
|
else:
|
||||||
search_term = f"%{q}%"
|
search_term = f"%{q}%"
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
and_(
|
and_(
|
||||||
Project.status.notin_(["archived", "deleted"]),
|
Project.status != "archived",
|
||||||
or_(
|
or_(
|
||||||
Project.project_number.ilike(search_term),
|
Project.project_number.ilike(search_term),
|
||||||
Project.client_name.ilike(search_term),
|
Project.client_name.ilike(search_term),
|
||||||
@@ -233,13 +223,13 @@ async def search_projects_json(
|
|||||||
"""
|
"""
|
||||||
if not q.strip():
|
if not q.strip():
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
Project.status.notin_(["archived", "deleted"])
|
Project.status != "archived"
|
||||||
).order_by(Project.updated_at.desc()).limit(limit).all()
|
).order_by(Project.updated_at.desc()).limit(limit).all()
|
||||||
else:
|
else:
|
||||||
search_term = f"%{q}%"
|
search_term = f"%{q}%"
|
||||||
projects = db.query(Project).filter(
|
projects = db.query(Project).filter(
|
||||||
and_(
|
and_(
|
||||||
Project.status.notin_(["archived", "deleted"]),
|
Project.status != "archived",
|
||||||
or_(
|
or_(
|
||||||
Project.project_number.ilike(search_term),
|
Project.project_number.ilike(search_term),
|
||||||
Project.client_name.ilike(search_term),
|
Project.client_name.ilike(search_term),
|
||||||
@@ -348,14 +338,6 @@ async def update_project(
|
|||||||
project.description = data["description"]
|
project.description = data["description"]
|
||||||
if "status" in data:
|
if "status" in data:
|
||||||
project.status = data["status"]
|
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:
|
if "client_name" in data:
|
||||||
project.client_name = data["client_name"]
|
project.client_name = data["client_name"]
|
||||||
if "site_address" in data:
|
if "site_address" in data:
|
||||||
@@ -377,93 +359,18 @@ async def update_project(
|
|||||||
@router.delete("/{project_id}")
|
@router.delete("/{project_id}")
|
||||||
async def delete_project(project_id: str, db: Session = Depends(get_db)):
|
async def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Soft-delete a project. Sets status='deleted' and records deleted_at timestamp.
|
Delete a project (soft delete by archiving).
|
||||||
Data will be permanently removed after 60 days (or via /permanent endpoint).
|
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
if not project:
|
if not project:
|
||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
project.status = "deleted"
|
project.status = "archived"
|
||||||
project.deleted_at = datetime.utcnow()
|
|
||||||
project.updated_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()
|
db.commit()
|
||||||
|
|
||||||
return {"success": True, "message": "Project deleted. Data will be permanently removed after 60 days."}
|
return {"success": True, "message": "Project archived successfully"}
|
||||||
|
|
||||||
|
|
||||||
@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."}
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -507,18 +414,18 @@ async def get_project_dashboard(
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Get active recording sessions
|
# Get active recording sessions
|
||||||
active_sessions = db.query(MonitoringSession).filter(
|
active_sessions = db.query(RecordingSession).filter(
|
||||||
and_(
|
and_(
|
||||||
MonitoringSession.project_id == project_id,
|
RecordingSession.project_id == project_id,
|
||||||
MonitoringSession.status == "recording",
|
RecordingSession.status == "recording",
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# Get completed sessions count
|
# Get completed sessions count
|
||||||
completed_sessions_count = db.query(func.count(MonitoringSession.id)).filter(
|
completed_sessions_count = db.query(func.count(RecordingSession.id)).filter(
|
||||||
and_(
|
and_(
|
||||||
MonitoringSession.project_id == project_id,
|
RecordingSession.project_id == project_id,
|
||||||
MonitoringSession.status == "completed",
|
RecordingSession.status == "completed",
|
||||||
)
|
)
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
@@ -597,26 +504,26 @@ async def get_project_units(
|
|||||||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||||
|
|
||||||
# Count sessions for this assignment
|
# Count sessions for this assignment
|
||||||
session_count = db.query(func.count(MonitoringSession.id)).filter_by(
|
session_count = db.query(func.count(RecordingSession.id)).filter_by(
|
||||||
location_id=assignment.location_id,
|
location_id=assignment.location_id,
|
||||||
unit_id=assignment.unit_id,
|
unit_id=assignment.unit_id,
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Count files from sessions
|
# Count files from sessions
|
||||||
file_count = db.query(func.count(DataFile.id)).join(
|
file_count = db.query(func.count(DataFile.id)).join(
|
||||||
MonitoringSession,
|
RecordingSession,
|
||||||
DataFile.session_id == MonitoringSession.id
|
DataFile.session_id == RecordingSession.id
|
||||||
).filter(
|
).filter(
|
||||||
MonitoringSession.location_id == assignment.location_id,
|
RecordingSession.location_id == assignment.location_id,
|
||||||
MonitoringSession.unit_id == assignment.unit_id,
|
RecordingSession.unit_id == assignment.unit_id,
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Check if currently recording
|
# Check if currently recording
|
||||||
active_session = db.query(MonitoringSession).filter(
|
active_session = db.query(RecordingSession).filter(
|
||||||
and_(
|
and_(
|
||||||
MonitoringSession.location_id == assignment.location_id,
|
RecordingSession.location_id == assignment.location_id,
|
||||||
MonitoringSession.unit_id == assignment.unit_id,
|
RecordingSession.unit_id == assignment.unit_id,
|
||||||
MonitoringSession.status == "recording",
|
RecordingSession.status == "recording",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -703,14 +610,10 @@ async def get_project_schedules(
|
|||||||
"result": result_data,
|
"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", {
|
return templates.TemplateResponse("partials/projects/schedule_list.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"schedules_by_date": schedules_by_date,
|
"schedules_by_date": schedules_by_date,
|
||||||
"project_status": project_status,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -803,13 +706,13 @@ async def get_project_sessions(
|
|||||||
Returns HTML partial with session list.
|
Returns HTML partial with session list.
|
||||||
Optional status filter: recording, completed, paused, failed
|
Optional status filter: recording, completed, paused, failed
|
||||||
"""
|
"""
|
||||||
query = db.query(MonitoringSession).filter_by(project_id=project_id)
|
query = db.query(RecordingSession).filter_by(project_id=project_id)
|
||||||
|
|
||||||
# Filter by status if provided
|
# Filter by status if provided
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(MonitoringSession.status == status)
|
query = query.filter(RecordingSession.status == status)
|
||||||
|
|
||||||
sessions = query.order_by(MonitoringSession.started_at.desc()).all()
|
sessions = query.order_by(RecordingSession.started_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with unit and location details
|
# Enrich with unit and location details
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
@@ -901,18 +804,18 @@ async def ftp_download_to_server(
|
|||||||
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
||||||
|
|
||||||
# Get or create active session for this location/unit
|
# Get or create active session for this location/unit
|
||||||
session = db.query(MonitoringSession).filter(
|
session = db.query(RecordingSession).filter(
|
||||||
and_(
|
and_(
|
||||||
MonitoringSession.project_id == project_id,
|
RecordingSession.project_id == project_id,
|
||||||
MonitoringSession.location_id == location_id,
|
RecordingSession.location_id == location_id,
|
||||||
MonitoringSession.unit_id == unit_id,
|
RecordingSession.unit_id == unit_id,
|
||||||
MonitoringSession.status.in_(["recording", "paused"])
|
RecordingSession.status.in_(["recording", "paused"])
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# If no active session, create one
|
# If no active session, create one
|
||||||
if not session:
|
if not session:
|
||||||
session = MonitoringSession(
|
session = RecordingSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
@@ -1066,18 +969,18 @@ async def ftp_download_folder_to_server(
|
|||||||
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
||||||
|
|
||||||
# Get or create active session for this location/unit
|
# Get or create active session for this location/unit
|
||||||
session = db.query(MonitoringSession).filter(
|
session = db.query(RecordingSession).filter(
|
||||||
and_(
|
and_(
|
||||||
MonitoringSession.project_id == project_id,
|
RecordingSession.project_id == project_id,
|
||||||
MonitoringSession.location_id == location_id,
|
RecordingSession.location_id == location_id,
|
||||||
MonitoringSession.unit_id == unit_id,
|
RecordingSession.unit_id == unit_id,
|
||||||
MonitoringSession.status.in_(["recording", "paused"])
|
RecordingSession.status.in_(["recording", "paused"])
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# If no active session, create one
|
# If no active session, create one
|
||||||
if not session:
|
if not session:
|
||||||
session = MonitoringSession(
|
session = RecordingSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
@@ -1237,9 +1140,9 @@ async def get_unified_files(
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
# Get all sessions for this project
|
# Get all sessions for this project
|
||||||
sessions = db.query(MonitoringSession).filter_by(
|
sessions = db.query(RecordingSession).filter_by(
|
||||||
project_id=project_id
|
project_id=project_id
|
||||||
).order_by(MonitoringSession.started_at.desc()).all()
|
).order_by(RecordingSession.started_at.desc()).all()
|
||||||
|
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
@@ -1316,7 +1219,7 @@ async def download_project_file(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1350,7 +1253,7 @@ async def download_session_files(
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
# Verify session belongs to this project
|
# Verify session belongs to this project
|
||||||
session = db.query(MonitoringSession).filter_by(id=session_id).first()
|
session = db.query(RecordingSession).filter_by(id=session_id).first()
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
if session.project_id != project_id:
|
if session.project_id != project_id:
|
||||||
@@ -1418,7 +1321,7 @@ async def delete_project_file(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1448,7 +1351,7 @@ async def delete_session(
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Verify session belongs to this project
|
# Verify session belongs to this project
|
||||||
session = db.query(MonitoringSession).filter_by(id=session_id).first()
|
session = db.query(RecordingSession).filter_by(id=session_id).first()
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
if session.project_id != project_id:
|
if session.project_id != project_id:
|
||||||
@@ -1497,7 +1400,7 @@ async def view_rnd_file(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1563,7 +1466,7 @@ async def get_rnd_data(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1701,7 +1604,7 @@ async def generate_excel_report(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -2107,7 +2010,7 @@ async def preview_report_data(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -2315,7 +2218,7 @@ async def generate_report_from_preview(
|
|||||||
if not file_record:
|
if not file_record:
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -2477,7 +2380,7 @@ async def generate_combined_excel_report(
|
|||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
# Get all sessions with measurement files
|
# Get all sessions with measurement files
|
||||||
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
sessions = db.query(RecordingSession).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
# Collect all Leq RND files grouped by location
|
# Collect all Leq RND files grouped by location
|
||||||
# Only include files with '_Leq_' in the path (15-minute averaged data)
|
# Only include files with '_Leq_' in the path (15-minute averaged data)
|
||||||
@@ -2703,304 +2606,6 @@ 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)
|
@router.get("/types/list", response_class=HTMLResponse)
|
||||||
async def get_project_types(request: Request, db: Session = Depends(get_db)):
|
async def get_project_types(request: Request, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -497,9 +497,6 @@ async def get_schedule_list_partial(
|
|||||||
"""
|
"""
|
||||||
Return HTML partial for schedule list.
|
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(
|
schedules = db.query(RecurringSchedule).filter_by(
|
||||||
project_id=project_id
|
project_id=project_id
|
||||||
).order_by(RecurringSchedule.created_at.desc()).all()
|
).order_by(RecurringSchedule.created_at.desc()).all()
|
||||||
@@ -518,5 +515,4 @@ async def get_schedule_list_partial(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"schedules": schedule_data,
|
"schedules": schedule_data,
|
||||||
"project_status": project_status,
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -92,15 +92,15 @@ async def rename_unit(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update unit_assignments: {e}")
|
logger.warning(f"Could not update unit_assignments: {e}")
|
||||||
|
|
||||||
# Update monitoring_sessions table (if exists)
|
# Update recording_sessions table (if exists)
|
||||||
try:
|
try:
|
||||||
from backend.models import MonitoringSession
|
from backend.models import RecordingSession
|
||||||
db.query(MonitoringSession).filter(MonitoringSession.unit_id == old_id).update(
|
db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update(
|
||||||
{"unit_id": new_id},
|
{"unit_id": new_id},
|
||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update monitoring_sessions: {e}")
|
logger.warning(f"Could not update recording_sessions: {e}")
|
||||||
|
|
||||||
# Commit all changes
|
# Commit all changes
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from zoneinfo import ZoneInfo
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment, Project
|
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -332,12 +332,10 @@ class RecurringScheduleService:
|
|||||||
)
|
)
|
||||||
actions.append(start_action)
|
actions.append(start_action)
|
||||||
|
|
||||||
# Create STOP action (stop_cycle handles download when include_download is True)
|
# Create STOP action
|
||||||
stop_notes = json.dumps({
|
stop_notes = json.dumps({
|
||||||
"schedule_name": schedule.name,
|
"schedule_name": schedule.name,
|
||||||
"schedule_id": schedule.id,
|
"schedule_id": schedule.id,
|
||||||
"schedule_type": "weekly_calendar",
|
|
||||||
"include_download": schedule.include_download,
|
|
||||||
})
|
})
|
||||||
stop_action = ScheduledAction(
|
stop_action = ScheduledAction(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
@@ -352,6 +350,27 @@ class RecurringScheduleService:
|
|||||||
)
|
)
|
||||||
actions.append(stop_action)
|
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
|
return actions
|
||||||
|
|
||||||
def _generate_interval_actions(
|
def _generate_interval_actions(
|
||||||
@@ -594,16 +613,8 @@ class RecurringScheduleService:
|
|||||||
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
|
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
def get_enabled_schedules(self) -> List[RecurringSchedule]:
|
def get_enabled_schedules(self) -> List[RecurringSchedule]:
|
||||||
"""Get all enabled recurring schedules for projects that are not on hold or deleted."""
|
"""Get all enabled recurring schedules."""
|
||||||
active_project_ids = [
|
return self.db.query(RecurringSchedule).filter_by(enabled=True).all()
|
||||||
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:
|
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from sqlalchemy.orm import Session
|
|||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
from backend.database import SessionLocal
|
from backend.database import SessionLocal
|
||||||
from backend.models import ScheduledAction, MonitoringSession, MonitoringLocation, Project, RecurringSchedule
|
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule
|
||||||
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
||||||
from backend.services.alert_service import get_alert_service
|
from backend.services.alert_service import get_alert_service
|
||||||
import uuid
|
import uuid
|
||||||
@@ -107,19 +107,10 @@ class SchedulerService:
|
|||||||
try:
|
try:
|
||||||
# Find pending actions that are due
|
# Find pending actions that are due
|
||||||
now = datetime.utcnow()
|
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(
|
pending_actions = db.query(ScheduledAction).filter(
|
||||||
and_(
|
and_(
|
||||||
ScheduledAction.execution_status == "pending",
|
ScheduledAction.execution_status == "pending",
|
||||||
ScheduledAction.scheduled_time <= now,
|
ScheduledAction.scheduled_time <= now,
|
||||||
ScheduledAction.project_id.in_(active_project_ids),
|
|
||||||
)
|
)
|
||||||
).order_by(ScheduledAction.scheduled_time).all()
|
).order_by(ScheduledAction.scheduled_time).all()
|
||||||
|
|
||||||
@@ -272,7 +263,7 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create recording session
|
# Create recording session
|
||||||
session = MonitoringSession(
|
session = RecordingSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=action.project_id,
|
project_id=action.project_id,
|
||||||
location_id=action.location_id,
|
location_id=action.location_id,
|
||||||
@@ -304,20 +295,9 @@ class SchedulerService:
|
|||||||
stop_cycle handles:
|
stop_cycle handles:
|
||||||
1. Stop measurement
|
1. Stop measurement
|
||||||
2. Enable FTP
|
2. Enable FTP
|
||||||
3. Download measurement folder to SLMM local storage
|
3. Download measurement folder
|
||||||
|
4. Verify download
|
||||||
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
|
# Parse notes for download preference
|
||||||
include_download = True
|
include_download = True
|
||||||
try:
|
try:
|
||||||
@@ -328,7 +308,7 @@ class SchedulerService:
|
|||||||
pass # Notes is plain text, not JSON
|
pass # Notes is plain text, not JSON
|
||||||
|
|
||||||
# Execute the full stop cycle via device controller
|
# Execute the full stop cycle via device controller
|
||||||
# SLMM handles stop, FTP enable, and download to SLMM-local storage
|
# SLMM handles stop, FTP enable, and download
|
||||||
cycle_response = await self.device_controller.stop_cycle(
|
cycle_response = await self.device_controller.stop_cycle(
|
||||||
unit_id,
|
unit_id,
|
||||||
action.device_type,
|
action.device_type,
|
||||||
@@ -336,11 +316,11 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Find and update the active recording session
|
# Find and update the active recording session
|
||||||
active_session = db.query(MonitoringSession).filter(
|
active_session = db.query(RecordingSession).filter(
|
||||||
and_(
|
and_(
|
||||||
MonitoringSession.location_id == action.location_id,
|
RecordingSession.location_id == action.location_id,
|
||||||
MonitoringSession.unit_id == unit_id,
|
RecordingSession.unit_id == unit_id,
|
||||||
MonitoringSession.status == "recording",
|
RecordingSession.status == "recording",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -360,81 +340,10 @@ class SchedulerService:
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
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 {
|
return {
|
||||||
"status": "stopped",
|
"status": "stopped",
|
||||||
"session_id": active_session.id if active_session else None,
|
"session_id": active_session.id if active_session else None,
|
||||||
"cycle_response": cycle_response,
|
"cycle_response": cycle_response,
|
||||||
"files_created": files_created,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _execute_download(
|
async def _execute_download(
|
||||||
@@ -617,11 +526,11 @@ class SchedulerService:
|
|||||||
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
|
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
|
||||||
|
|
||||||
# Close out the old recording session
|
# Close out the old recording session
|
||||||
active_session = db.query(MonitoringSession).filter(
|
active_session = db.query(RecordingSession).filter(
|
||||||
and_(
|
and_(
|
||||||
MonitoringSession.location_id == action.location_id,
|
RecordingSession.location_id == action.location_id,
|
||||||
MonitoringSession.unit_id == unit_id,
|
RecordingSession.unit_id == unit_id,
|
||||||
MonitoringSession.status == "recording",
|
RecordingSession.status == "recording",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -648,7 +557,7 @@ class SchedulerService:
|
|||||||
result["steps"]["start"] = {"success": True, "response": cycle_response}
|
result["steps"]["start"] = {"success": True, "response": cycle_response}
|
||||||
|
|
||||||
# Create new recording session
|
# Create new recording session
|
||||||
new_session = MonitoringSession(
|
new_session = RecordingSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=action.project_id,
|
project_id=action.project_id,
|
||||||
location_id=action.location_id,
|
location_id=action.location_id,
|
||||||
|
|||||||
@@ -659,7 +659,7 @@ class SLMMClient:
|
|||||||
|
|
||||||
# Format as Auto_XXXX folder name
|
# Format as Auto_XXXX folder name
|
||||||
folder_name = f"Auto_{index_number:04d}"
|
folder_name = f"Auto_{index_number:04d}"
|
||||||
remote_path = f"/NL-43/{folder_name}"
|
remote_path = f"/NL43_DATA/{folder_name}"
|
||||||
|
|
||||||
# Download the folder
|
# Download the folder
|
||||||
result = await self.download_folder(unit_id, remote_path)
|
result = await self.download_folder(unit_id, remote_path)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ All routers should import `templates` from this module to get consistent
|
|||||||
filter and global function registration.
|
filter and global function registration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json as _json
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
# Import timezone utilities
|
# Import timezone utilities
|
||||||
@@ -33,38 +32,8 @@ def jinja_timezone_abbr():
|
|||||||
# Create templates instance
|
# Create templates instance
|
||||||
templates = Jinja2Templates(directory="templates")
|
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
|
# Register Jinja filters and globals
|
||||||
templates.env.filters["local_datetime"] = jinja_local_datetime
|
templates.env.filters["local_datetime"] = jinja_local_datetime
|
||||||
templates.env.filters["local_time"] = jinja_local_time
|
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["timezone_abbr"] = jinja_timezone_abbr
|
||||||
templates.env.globals["get_user_timezone"] = get_user_timezone
|
templates.env.globals["get_user_timezone"] = get_user_timezone
|
||||||
templates.env.globals["same_date"] = jinja_same_date
|
|
||||||
|
|||||||
10
data-dev/backups/snapshot_20251216_201738.db.meta.json
Normal file
10
data-dev/backups/snapshot_20251216_201738.db.meta.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -24,6 +24,30 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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 (Sound Level Meter Manager) ---
|
||||||
slmm:
|
slmm:
|
||||||
build:
|
build:
|
||||||
@@ -37,8 +61,6 @@ services:
|
|||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PORT=8100
|
- PORT=8100
|
||||||
- CORS_ORIGINS=*
|
- CORS_ORIGINS=*
|
||||||
- TCP_IDLE_TTL=-1
|
|
||||||
- TCP_MAX_AGE=-1
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
|
||||||
@@ -49,3 +71,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
data-dev:
|
||||||
|
|||||||
@@ -90,14 +90,14 @@ def rename_unit(old_id: str, new_id: str):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Table may not exist
|
pass # Table may not exist
|
||||||
|
|
||||||
# Update monitoring_sessions table (if exists)
|
# Update recording_sessions table (if exists)
|
||||||
try:
|
try:
|
||||||
result = session.execute(
|
result = session.execute(
|
||||||
text("UPDATE monitoring_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
text("UPDATE recording_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||||
{"new_id": new_id, "old_id": old_id}
|
{"new_id": new_id, "old_id": old_id}
|
||||||
)
|
)
|
||||||
if result.rowcount > 0:
|
if result.rowcount > 0:
|
||||||
print(f" ✓ Updated monitoring_sessions ({result.rowcount} rows)")
|
print(f" ✓ Updated recording_sessions ({result.rowcount} rows)")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Table may not exist
|
pass # Table may not exist
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
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
|
Settings
|
||||||
</button>
|
</button>
|
||||||
{% if assigned_unit and connection_mode == 'connected' %}
|
{% if assigned_unit %}
|
||||||
<button onclick="switchTab('command')"
|
<button onclick="switchTab('command')"
|
||||||
data-tab="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">
|
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')"
|
<button onclick="switchTab('sessions')"
|
||||||
data-tab="sessions"
|
data-tab="sessions"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
Monitoring Sessions
|
Recording Sessions
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('data')"
|
<button onclick="switchTab('data')"
|
||||||
data-tab="data"
|
data-tab="data"
|
||||||
@@ -214,54 +214,23 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
{% if connection_mode == 'connected' %}
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Active Session</p>
|
<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">
|
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
|
||||||
{% if active_session %}
|
{% if active_session %}
|
||||||
<span class="text-green-600 dark:text-green-400">Monitoring</span>
|
<span class="text-green-600 dark:text-green-400">Recording</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-gray-500">Idle</span>
|
<span class="text-gray-500">Idle</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</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>
|
||||||
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
<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">
|
<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>
|
<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>
|
</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>
|
||||||
</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…</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
<!-- Settings Tab -->
|
||||||
@@ -312,8 +281,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Command Center Tab (connected NRLs only) -->
|
<!-- Command Center Tab -->
|
||||||
{% if assigned_unit and connection_mode == 'connected' %}
|
{% if assigned_unit %}
|
||||||
<div id="command-tab" class="tab-panel hidden">
|
<div id="command-tab" class="tab-panel hidden">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
@@ -333,11 +302,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Monitoring Sessions Tab -->
|
<!-- Recording Sessions Tab -->
|
||||||
<div id="sessions-tab" class="tab-panel hidden">
|
<div id="sessions-tab" class="tab-panel hidden">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
||||||
{% if assigned_unit %}
|
{% if assigned_unit %}
|
||||||
<button onclick="openScheduleModal()"
|
<button onclick="openScheduleModal()"
|
||||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
@@ -360,40 +329,8 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
||||||
<div class="flex items-center gap-3">
|
<div class="text-sm text-gray-500">
|
||||||
<span class="text-sm text-gray-500"><span class="font-medium">{{ file_count }}</span> files</span>
|
<span class="font-medium">{{ file_count }}</span> files
|
||||||
<button onclick="toggleUploadPanel()"
|
|
||||||
class="px-3 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
|
||||||
</svg>
|
|
||||||
Upload Data
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload Panel -->
|
|
||||||
<div id="upload-panel" class="hidden mb-6 p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
|
||||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Upload SD Card Data</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
||||||
Select a ZIP file, or select all files from inside an <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">Auto_####</code> folder. File types (.rnd, .rnh) are auto-detected.
|
|
||||||
</p>
|
|
||||||
<input type="file" id="upload-input" multiple
|
|
||||||
accept=".zip,.rnd,.rnh,.RND,.RNH"
|
|
||||||
class="block w-full text-sm text-gray-500 dark:text-gray-400
|
|
||||||
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
|
||||||
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
|
||||||
hover:file:bg-seismo-navy file:cursor-pointer" />
|
|
||||||
<div class="flex items-center gap-3 mt-3">
|
|
||||||
<button onclick="submitUpload()"
|
|
||||||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
|
||||||
Import Files
|
|
||||||
</button>
|
|
||||||
<button onclick="toggleUploadPanel()"
|
|
||||||
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<span id="upload-status" class="text-sm hidden"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -622,64 +559,5 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
|||||||
closeAssignModal();
|
closeAssignModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Upload Data ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function toggleUploadPanel() {
|
|
||||||
const panel = document.getElementById('upload-panel');
|
|
||||||
const status = document.getElementById('upload-status');
|
|
||||||
panel.classList.toggle('hidden');
|
|
||||||
// Reset status when reopening
|
|
||||||
if (!panel.classList.contains('hidden')) {
|
|
||||||
status.textContent = '';
|
|
||||||
status.className = 'text-sm hidden';
|
|
||||||
document.getElementById('upload-input').value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitUpload() {
|
|
||||||
const input = document.getElementById('upload-input');
|
|
||||||
const status = document.getElementById('upload-status');
|
|
||||||
|
|
||||||
if (!input.files.length) {
|
|
||||||
alert('Please select files to upload.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
for (const file of input.files) {
|
|
||||||
formData.append('files', file);
|
|
||||||
}
|
|
||||||
|
|
||||||
status.textContent = 'Uploading\u2026';
|
|
||||||
status.className = 'text-sm text-gray-500';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
|
|
||||||
{ method: 'POST', body: formData }
|
|
||||||
);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
|
||||||
if (data.leq_files || data.lp_files) {
|
|
||||||
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
|
||||||
}
|
|
||||||
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
|
|
||||||
status.textContent = parts.join(' ');
|
|
||||||
status.className = 'text-sm text-green-600 dark:text-green-400';
|
|
||||||
input.value = '';
|
|
||||||
// Refresh the file list
|
|
||||||
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
|
||||||
} else {
|
|
||||||
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
status.textContent = `Error: ${err.message}`;
|
|
||||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -151,9 +151,9 @@
|
|||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No data files yet</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">
|
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||||
Files appear here after an FTP download from a connected meter, or after uploading SD card data manually.
|
Files will appear here once they are downloaded from the sound level meter
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
<!-- 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 %}—{% 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">—</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">—</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 %} • {{ unit.slm_model }}{% endif %}
|
|
||||||
</span>
|
|
||||||
<a href="/slm/{{ unit.id }}"
|
|
||||||
class="text-xs text-seismo-orange hover:text-seismo-navy transition-colors">
|
|
||||||
Open Unit →
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% else %}
|
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400">No status data available.</div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -13,8 +13,6 @@
|
|||||||
</div>
|
</div>
|
||||||
{% if project.status == 'active' %}
|
{% 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>
|
<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' %}
|
{% 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>
|
<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' %}
|
{% elif project.status == 'archived' %}
|
||||||
|
|||||||
@@ -34,10 +34,6 @@
|
|||||||
<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">
|
<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
|
Active
|
||||||
</span>
|
</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' %}
|
{% 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">
|
<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
|
Completed
|
||||||
|
|||||||
@@ -16,8 +16,6 @@
|
|||||||
|
|
||||||
{% if item.project.status == 'active' %}
|
{% 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>
|
<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' %}
|
{% 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>
|
<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' %}
|
{% elif item.project.status == 'archived' %}
|
||||||
|
|||||||
@@ -27,20 +27,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-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="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% if schedules %}
|
{% if schedules %}
|
||||||
{% for item in 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
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4
|
||||||
{% if project_status == 'on_hold' or not item.schedule.enabled %}opacity-60{% endif %}">
|
{% if not item.schedule.enabled %}opacity-60{% endif %}">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
@@ -29,15 +29,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Status badge -->
|
<!-- Status badge -->
|
||||||
{% if project_status == 'on_hold' %}
|
{% if item.schedule.enabled %}
|
||||||
<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">
|
<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
|
Active
|
||||||
</span>
|
</span>
|
||||||
@@ -106,8 +98,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions (hidden when project is on hold or archived) -->
|
<!-- Actions -->
|
||||||
{% if project_status not in ('on_hold', 'archived') %}
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
{% if item.schedule.enabled %}
|
{% if item.schedule.enabled %}
|
||||||
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable"
|
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable"
|
||||||
@@ -140,7 +131,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -19,8 +19,7 @@
|
|||||||
<!-- Actions for this date -->
|
<!-- Actions for this date -->
|
||||||
<div class="space-y-3 ml-13 pl-3 border-l-2 border-gray-200 dark:border-gray-700">
|
<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 %}
|
{% 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="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
@@ -55,11 +54,6 @@
|
|||||||
<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">
|
<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
|
Pending
|
||||||
</span>
|
</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' %}
|
{% 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">
|
<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
|
Completed
|
||||||
@@ -163,8 +157,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions (hidden when project is on hold or archived) -->
|
<!-- Actions -->
|
||||||
{% if project_status not in ('on_hold', 'archived') %}
|
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
{% if item.schedule.execution_status == 'pending' %}
|
{% if item.schedule.execution_status == 'pending' %}
|
||||||
<button onclick="executeSchedule('{{ item.schedule.id }}')"
|
<button onclick="executeSchedule('{{ item.schedule.id }}')"
|
||||||
@@ -184,7 +177,6 @@
|
|||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
|
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Schedule a single monitoring session with a specific start and end time.
|
Schedule a single recording session with a specific start and end time.
|
||||||
Duration can be between 15 minutes and 24 hours.
|
Duration can be between 15 minutes and 24 hours.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- Monitoring Sessions List -->
|
<!-- Recording Sessions List -->
|
||||||
{% if sessions %}
|
{% if sessions %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{% for item in sessions %}
|
{% for item in sessions %}
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-2">No recording sessions yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -99,7 +99,7 @@ function viewSession(sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording(sessionId) {
|
function stopRecording(sessionId) {
|
||||||
if (!confirm('Stop this monitoring session?')) return;
|
if (!confirm('Stop this recording session?')) return;
|
||||||
|
|
||||||
// TODO: Implement stop recording API call
|
// TODO: Implement stop recording API call
|
||||||
alert('Stop recording API coming soon for session: ' + sessionId);
|
alert('Stop recording API coming soon for session: ' + sessionId);
|
||||||
|
|||||||
@@ -23,26 +23,12 @@
|
|||||||
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path>
|
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<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">
|
<div class="font-semibold text-gray-900 dark:text-white">
|
||||||
{% if location %}{{ location.name }}{% else %}Unknown Location{% endif %}
|
{{ session.started_at|local_datetime if session.started_at else 'Unknown Date' }}
|
||||||
{% if session.started_at %}
|
|
||||||
—
|
|
||||||
{% 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>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{% if is_manual %}
|
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
|
||||||
{% set store = meta.get('store_name') %}
|
{% if location %} @ {{ location.name }}{% endif %}
|
||||||
Manual upload{% if store %} — Store {{ store }}{% endif %}
|
|
||||||
{% elif unit %}
|
|
||||||
{{ unit.id }}
|
|
||||||
{% endif %}
|
|
||||||
<span class="mx-2">•</span>
|
<span class="mx-2">•</span>
|
||||||
{{ files|length }} file{{ 's' if files|length != 1 else '' }}
|
{{ files|length }} file{{ 's' if files|length != 1 else '' }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -163,25 +163,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- FTP Enable Toggle -->
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
<label class="flex items-center justify-between cursor-pointer">
|
<label class="flex items-center justify-between cursor-pointer">
|
||||||
@@ -283,9 +264,6 @@ async function openSLMSettingsModal(unitId) {
|
|||||||
// FTP enabled from SLMM
|
// FTP enabled from SLMM
|
||||||
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
|
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
|
||||||
|
|
||||||
// Deploy/bench status from Terra-View
|
|
||||||
updateDeployButton(unitData.deployed !== false);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load SLM settings:', error);
|
console.error('Failed to load SLM settings:', error);
|
||||||
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
|
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
|
||||||
@@ -547,77 +525,6 @@ async function saveFTPSettings(event) {
|
|||||||
return saveSLMSettings(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
|
// Close modal on background click
|
||||||
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
|
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
|
||||||
if (e.target === this) {
|
if (e.target === this) {
|
||||||
|
|||||||
@@ -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">
|
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
|
Schedules
|
||||||
</button>
|
</button>
|
||||||
<button id="sessions-tab-btn" onclick="switchTab('sessions')"
|
<button onclick="switchTab('sessions')"
|
||||||
data-tab="sessions"
|
data-tab="sessions"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
Monitoring Sessions
|
Recording Sessions
|
||||||
</button>
|
</button>
|
||||||
<button id="data-tab-btn" onclick="switchTab('data')"
|
<button onclick="switchTab('data')"
|
||||||
data-tab="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">
|
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
|
Data Files
|
||||||
@@ -185,11 +185,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Monitoring Sessions Tab -->
|
<!-- Recording Sessions Tab -->
|
||||||
<div id="sessions-tab" class="tab-panel hidden">
|
<div id="sessions-tab" class="tab-panel hidden">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<select id="sessions-filter" onchange="filterSessions()"
|
<select id="sessions-filter" onchange="filterSessions()"
|
||||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
@@ -230,13 +230,6 @@
|
|||||||
Project Files
|
Project Files
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex items-center gap-3">
|
<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')"
|
<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">
|
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">
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -248,37 +241,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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"
|
<div id="unified-files"
|
||||||
hx-get="/api/projects/{{ project_id }}/files-unified"
|
hx-get="/api/projects/{{ project_id }}/files-unified"
|
||||||
hx-trigger="load, refresh from:#unified-files"
|
hx-trigger="load, refresh from:#unified-files"
|
||||||
@@ -317,7 +279,6 @@
|
|||||||
<select name="status" id="settings-status"
|
<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">
|
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="active">Active</option>
|
||||||
<option value="on_hold">On Hold</option>
|
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
<option value="archived">Archived</option>
|
<option value="archived">Archived</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -368,40 +329,15 @@
|
|||||||
<!-- Danger Zone -->
|
<!-- Danger Zone -->
|
||||||
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
<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>
|
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
|
||||||
<div class="space-y-3">
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
<!-- On Hold -->
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||||
<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">
|
Archive this project to remove it from active listings. All data will be preserved.
|
||||||
<div>
|
</p>
|
||||||
<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()"
|
<button onclick="archiveProject()"
|
||||||
class="shrink-0 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm">
|
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||||
Archive
|
Archive Project
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,7 +376,7 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Type</label>
|
<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" onchange="updateConnectionModeVisibility()"
|
<select name="location_type" id="location-type"
|
||||||
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">
|
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="sound">Sound</option>
|
||||||
<option value="vibration">Vibration</option>
|
<option value="vibration">Vibration</option>
|
||||||
@@ -453,29 +389,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
<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"
|
<input type="text" name="address" id="location-address"
|
||||||
@@ -582,7 +495,7 @@
|
|||||||
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
|
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Single monitoring session with a specific start and end date/time (15 min - 24 hrs).
|
Single recording session with a specific start and end date/time (15 min - 24 hrs).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -683,40 +596,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
const projectId = "{{ project_id }}";
|
const projectId = "{{ project_id }}";
|
||||||
let editingLocationId = null;
|
let editingLocationId = null;
|
||||||
@@ -775,19 +654,14 @@ async function loadProjectDetails() {
|
|||||||
document.getElementById('settings-start-date').value = formatDate(data.start_date);
|
document.getElementById('settings-start-date').value = formatDate(data.start_date);
|
||||||
document.getElementById('settings-end-date').value = formatDate(data.end_date);
|
document.getElementById('settings-end-date').value = formatDate(data.end_date);
|
||||||
|
|
||||||
// Update tab labels and visibility based on project type
|
// Update tab labels based on project type
|
||||||
const isSoundProject = projectTypeId === 'sound_monitoring';
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
if (isSoundProject) {
|
|
||||||
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
||||||
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||||
document.getElementById('add-location-label').textContent = 'Add NRL';
|
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||||
}
|
}
|
||||||
// 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');
|
document.getElementById('settings-error').classList.add('hidden');
|
||||||
updateDangerZone();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load project details:', err);
|
console.error('Failed to load project details:', err);
|
||||||
}
|
}
|
||||||
@@ -855,33 +729,6 @@ function refreshProjectDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Location modal functions
|
// 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) {
|
function openLocationModal(defaultType) {
|
||||||
editingLocationId = null;
|
editingLocationId = null;
|
||||||
document.getElementById('location-modal-title').textContent = 'Add Location';
|
document.getElementById('location-modal-title').textContent = 'Add Location';
|
||||||
@@ -890,25 +737,17 @@ function openLocationModal(defaultType) {
|
|||||||
document.getElementById('location-description').value = '';
|
document.getElementById('location-description').value = '';
|
||||||
document.getElementById('location-address').value = '';
|
document.getElementById('location-address').value = '';
|
||||||
document.getElementById('location-coordinates').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 locationTypeSelect = document.getElementById('location-type');
|
||||||
const locationTypeWrapper = locationTypeSelect.closest('div');
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
locationTypeSelect.value = 'sound';
|
locationTypeSelect.value = 'sound';
|
||||||
locationTypeSelect.disabled = true;
|
locationTypeSelect.disabled = true;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
} else if (projectTypeId === 'vibration_monitoring') {
|
|
||||||
locationTypeSelect.value = 'vibration';
|
|
||||||
locationTypeSelect.disabled = true;
|
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
|
||||||
} else {
|
} else {
|
||||||
locationTypeSelect.disabled = false;
|
locationTypeSelect.disabled = false;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
locationTypeSelect.value = defaultType || 'sound';
|
locationTypeSelect.value = defaultType || 'sound';
|
||||||
}
|
}
|
||||||
updateConnectionModeVisibility();
|
|
||||||
document.getElementById('location-error').classList.add('hidden');
|
document.getElementById('location-error').classList.add('hidden');
|
||||||
document.getElementById('location-modal').classList.remove('hidden');
|
document.getElementById('location-modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
@@ -922,27 +761,17 @@ function openEditLocationModal(button) {
|
|||||||
document.getElementById('location-description').value = data.description || '';
|
document.getElementById('location-description').value = data.description || '';
|
||||||
document.getElementById('location-address').value = data.address || '';
|
document.getElementById('location-address').value = data.address || '';
|
||||||
document.getElementById('location-coordinates').value = data.coordinates || '';
|
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 locationTypeSelect = document.getElementById('location-type');
|
||||||
const locationTypeWrapper = locationTypeSelect.closest('div');
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
locationTypeSelect.value = 'sound';
|
locationTypeSelect.value = 'sound';
|
||||||
locationTypeSelect.disabled = true;
|
locationTypeSelect.disabled = true;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
} else if (projectTypeId === 'vibration_monitoring') {
|
|
||||||
locationTypeSelect.value = 'vibration';
|
|
||||||
locationTypeSelect.disabled = true;
|
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
|
||||||
} else {
|
} else {
|
||||||
locationTypeSelect.disabled = false;
|
locationTypeSelect.disabled = false;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
locationTypeSelect.value = data.location_type || 'sound';
|
locationTypeSelect.value = data.location_type || 'sound';
|
||||||
}
|
}
|
||||||
updateConnectionModeVisibility();
|
|
||||||
document.getElementById('location-error').classList.add('hidden');
|
document.getElementById('location-error').classList.add('hidden');
|
||||||
document.getElementById('location-modal').classList.remove('hidden');
|
document.getElementById('location-modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
@@ -961,12 +790,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
let locationType = document.getElementById('location-type').value;
|
let locationType = document.getElementById('location-type').value;
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
locationType = 'sound';
|
locationType = 'sound';
|
||||||
} else if (projectTypeId === 'vibration_monitoring') {
|
|
||||||
locationType = 'vibration';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const connectionMode = document.querySelector('input[name="connection_mode"]:checked')?.value || 'connected';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingLocationId) {
|
if (editingLocationId) {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -974,8 +799,7 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
description: description || null,
|
description: description || null,
|
||||||
address: address || null,
|
address: address || null,
|
||||||
coordinates: coordinates || 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}`, {
|
const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -993,7 +817,6 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
formData.append('address', address);
|
formData.append('address', address);
|
||||||
formData.append('coordinates', coordinates);
|
formData.append('coordinates', coordinates);
|
||||||
formData.append('location_type', locationType);
|
formData.append('location_type', locationType);
|
||||||
formData.append('location_metadata', JSON.stringify({ connection_mode: connectionMode }));
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/create`, {
|
const response = await fetch(`/api/projects/${projectId}/locations/create`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
@@ -1204,78 +1027,6 @@ function archiveProject() {
|
|||||||
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
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
|
// Schedule Modal Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1575,88 +1326,6 @@ 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> — ${sess.files} files`;
|
|
||||||
if (sess.leq_files || sess.lp_files) html += ` (${sess.leq_files} Leq, ${sess.lp_files} Lp)`;
|
|
||||||
if (sess.store_name) html += ` — ${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
|
// Load project details on page load and restore active tab from URL hash
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadProjectDetails();
|
loadProjectDetails();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 mb-8"
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
||||||
hx-get="/api/projects/stats"
|
hx-get="/api/projects/stats"
|
||||||
hx-trigger="load, every 30s"
|
hx-trigger="load, every 30s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
@@ -27,7 +27,6 @@
|
|||||||
<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 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>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
@@ -44,11 +43,6 @@
|
|||||||
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">
|
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
|
Active
|
||||||
</button>
|
</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')"
|
<button onclick="switchTab('completed')"
|
||||||
id="tab-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">
|
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">
|
||||||
@@ -78,17 +72,10 @@
|
|||||||
<!-- Create Project Modal -->
|
<!-- Create Project Modal -->
|
||||||
<div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
<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>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
||||||
</div>
|
</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">
|
<div class="p-6" id="createProjectContent">
|
||||||
<!-- Step 1: Project Type Selection (initially shown) -->
|
<!-- Step 1: Project Type Selection (initially shown) -->
|
||||||
@@ -104,12 +91,6 @@
|
|||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-6 flex justify-end">
|
|
||||||
<button type="button" onclick="hideCreateProjectModal()"
|
|
||||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Project Details Form (hidden initially) -->
|
<!-- Step 2: Project Details Form (hidden initially) -->
|
||||||
|
|||||||
@@ -1504,7 +1504,7 @@
|
|||||||
`• Unit roster entry\n` +
|
`• Unit roster entry\n` +
|
||||||
`• All history records\n` +
|
`• All history records\n` +
|
||||||
`• Project assignments\n` +
|
`• Project assignments\n` +
|
||||||
`• Monitoring sessions\n` +
|
`• Recording sessions\n` +
|
||||||
`• Modem references\n\n` +
|
`• Modem references\n\n` +
|
||||||
`This action cannot be undone.`
|
`This action cannot be undone.`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,415 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ location.name }} - Monitoring Location{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<!-- Breadcrumb Navigation -->
|
|
||||||
<div class="mb-6">
|
|
||||||
<nav class="flex items-center space-x-2 text-sm">
|
|
||||||
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
|
||||||
</svg>
|
|
||||||
Projects
|
|
||||||
</a>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
<a href="/projects/{{ project_id }}" class="text-seismo-orange hover:text-seismo-navy">
|
|
||||||
{{ project.name }}
|
|
||||||
</a>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-gray-900 dark:text-white font-medium">{{ location.name }}</span>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
||||||
</svg>
|
|
||||||
{{ location.name }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Monitoring Location • {{ project.name }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
{% if assigned_unit %}
|
|
||||||
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
|
||||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
|
||||||
</svg>
|
|
||||||
Unit Assigned
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
|
||||||
No Unit Assigned
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab Navigation -->
|
|
||||||
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<nav class="flex space-x-6">
|
|
||||||
<button onclick="switchTab('overview')"
|
|
||||||
data-tab="overview"
|
|
||||||
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
|
|
||||||
Overview
|
|
||||||
</button>
|
|
||||||
<button onclick="switchTab('settings')"
|
|
||||||
data-tab="settings"
|
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
|
||||||
Settings
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab Content -->
|
|
||||||
<div id="tab-content">
|
|
||||||
<!-- Overview Tab -->
|
|
||||||
<div id="overview-tab" class="tab-panel">
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<!-- Location Details Card -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location Details</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Name</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ location.name }}</div>
|
|
||||||
</div>
|
|
||||||
{% if location.description %}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Description</div>
|
|
||||||
<div class="text-gray-900 dark:text-white">{{ location.description }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if location.address %}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Address</div>
|
|
||||||
<div class="text-gray-900 dark:text-white">{{ location.address }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if location.coordinates %}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Coordinates</div>
|
|
||||||
<div class="text-gray-900 dark:text-white font-mono text-sm">{{ location.coordinates }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Created</div>
|
|
||||||
<div class="text-gray-900 dark:text-white">{{ location.created_at|local_datetime if location.created_at else 'N/A' }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assignment Card -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
|
||||||
{% if assigned_unit %}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
|
||||||
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
|
||||||
{{ assigned_unit.id }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if assigned_unit.device_type %}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div>
|
|
||||||
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if assignment %}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
|
||||||
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at|local_datetime if assignment.assigned_at else 'N/A' }}</div>
|
|
||||||
</div>
|
|
||||||
{% if assignment.notes %}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
|
||||||
<div class="text-gray-900 dark:text-white text-sm">{{ assignment.notes }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
<div class="pt-2">
|
|
||||||
<button onclick="unassignUnit('{{ assignment.id }}')"
|
|
||||||
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
|
|
||||||
Unassign Unit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
|
||||||
</svg>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
|
||||||
<button onclick="openAssignModal()"
|
|
||||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
|
||||||
Assign a Unit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
|
||||||
<div id="settings-tab" class="tab-panel hidden">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Location Settings</h2>
|
|
||||||
|
|
||||||
<form id="location-settings-form" class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
|
|
||||||
<input type="text" id="settings-name" value="{{ location.name }}"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
|
||||||
<textarea id="settings-description" rows="3"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">{{ location.description or '' }}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
|
||||||
<input type="text" id="settings-address" value="{{ location.address or '' }}"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
|
||||||
<input type="text" id="settings-coordinates" value="{{ location.coordinates or '' }}"
|
|
||||||
placeholder="40.7128,-74.0060"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="settings-error" class="hidden text-sm text-red-600"></div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
|
||||||
<button type="button" onclick="window.location.href='/projects/{{ project_id }}'"
|
|
||||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assign Unit Modal -->
|
|
||||||
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a seismograph to this location</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="assign-form" class="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
|
||||||
<select id="assign-unit-id" name="unit_id"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
|
||||||
<option value="">Loading units...</option>
|
|
||||||
</select>
|
|
||||||
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
|
||||||
<textarea id="assign-notes" name="notes" rows="2"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="assign-error" class="hidden text-sm text-red-600"></div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
|
||||||
<button type="button" onclick="closeAssignModal()"
|
|
||||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
|
||||||
Assign Unit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const projectId = "{{ project_id }}";
|
|
||||||
const locationId = "{{ location_id }}";
|
|
||||||
|
|
||||||
// Tab switching
|
|
||||||
function switchTab(tabName) {
|
|
||||||
document.querySelectorAll('.tab-panel').forEach(panel => {
|
|
||||||
panel.classList.add('hidden');
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.tab-button').forEach(button => {
|
|
||||||
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
|
||||||
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
|
||||||
});
|
|
||||||
const panel = document.getElementById(`${tabName}-tab`);
|
|
||||||
if (panel) panel.classList.remove('hidden');
|
|
||||||
const button = document.querySelector(`[data-tab="${tabName}"]`);
|
|
||||||
if (button) {
|
|
||||||
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
|
||||||
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Location settings form submission
|
|
||||||
document.getElementById('location-settings-form').addEventListener('submit', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
name: document.getElementById('settings-name').value.trim(),
|
|
||||||
description: document.getElementById('settings-description').value.trim() || null,
|
|
||||||
address: document.getElementById('settings-address').value.trim() || null,
|
|
||||||
coordinates: document.getElementById('settings-coordinates').value.trim() || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(data.detail || 'Failed to update location');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
} catch (err) {
|
|
||||||
const errorEl = document.getElementById('settings-error');
|
|
||||||
errorEl.textContent = err.message || 'Failed to update location.';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assign modal
|
|
||||||
function openAssignModal() {
|
|
||||||
document.getElementById('assign-modal').classList.remove('hidden');
|
|
||||||
loadAvailableUnits();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAssignModal() {
|
|
||||||
document.getElementById('assign-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAvailableUnits() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
|
||||||
if (!response.ok) throw new Error('Failed to load available units');
|
|
||||||
const data = await response.json();
|
|
||||||
const select = document.getElementById('assign-unit-id');
|
|
||||||
select.innerHTML = '<option value="">Select a unit</option>';
|
|
||||||
|
|
||||||
if (!data.length) {
|
|
||||||
document.getElementById('assign-empty').classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
data.forEach(unit => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = unit.id;
|
|
||||||
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const errorEl = document.getElementById('assign-error');
|
|
||||||
errorEl.textContent = err.message || 'Failed to load units.';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const unitId = document.getElementById('assign-unit-id').value;
|
|
||||||
const notes = document.getElementById('assign-notes').value.trim();
|
|
||||||
|
|
||||||
if (!unitId) {
|
|
||||||
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
|
||||||
document.getElementById('assign-error').classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('unit_id', unitId);
|
|
||||||
formData.append('notes', notes);
|
|
||||||
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(data.detail || 'Failed to assign unit');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
} catch (err) {
|
|
||||||
const errorEl = document.getElementById('assign-error');
|
|
||||||
errorEl.textContent = err.message || 'Failed to assign unit.';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function unassignUnit(assignmentId) {
|
|
||||||
if (!confirm('Unassign this unit from the location?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(data.detail || 'Failed to unassign unit');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || 'Failed to unassign unit.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') closeAssignModal();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) closeAssignModal();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
Reference in New Issue
Block a user