Compare commits
16 Commits
v0.6.1
...
8e292b1aca
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e292b1aca | |||
| 7516bbea70 | |||
| da4e5f66c5 | |||
| dae2595303 | |||
| 0c4e7aa5e6 | |||
| 229499ccf6 | |||
| fdc4adeaee | |||
| b3bf91880a | |||
| 17b3f91dfc | |||
| 6c1d0bc467 | |||
|
|
abd059983f | ||
|
|
0f17841218 | ||
|
|
65362bab21 | ||
|
|
dc77a362ce | ||
|
|
28942600ab | ||
|
|
80861997af |
@@ -1,3 +1,5 @@
|
|||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
# Python cache / compiled
|
# Python cache / compiled
|
||||||
__pycache__
|
__pycache__
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -28,6 +30,7 @@ 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,3 +1,13 @@
|
|||||||
|
# Terra-View Specifics
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
data/
|
||||||
|
data-dev/
|
||||||
|
.aider*
|
||||||
|
.aider*
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[codz]
|
*.py[codz]
|
||||||
@@ -206,10 +216,3 @@ 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,
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
DataFile,
|
DataFile,
|
||||||
)
|
)
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ async def nrl_detail_page(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""NRL (Noise Recording Location) detail page with tabs"""
|
"""NRL (Noise Recording Location) detail page with tabs"""
|
||||||
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile
|
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, MonitoringSession, DataFile
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
# Get project
|
# Get project
|
||||||
@@ -348,23 +348,24 @@ async def nrl_detail_page(
|
|||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
|
||||||
# Get session count
|
# Get session count
|
||||||
session_count = db.query(RecordingSession).filter_by(location_id=location_id).count()
|
session_count = db.query(MonitoringSession).filter_by(location_id=location_id).count()
|
||||||
|
|
||||||
# Get file count (DataFile links to session, not directly to location)
|
# Get file count (DataFile links to session, not directly to location)
|
||||||
file_count = db.query(DataFile).join(
|
file_count = db.query(DataFile).join(
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
DataFile.session_id == RecordingSession.id
|
DataFile.session_id == MonitoringSession.id
|
||||||
).filter(RecordingSession.location_id == location_id).count()
|
).filter(MonitoringSession.location_id == location_id).count()
|
||||||
|
|
||||||
# Check for active session
|
# Check for active session
|
||||||
active_session = db.query(RecordingSession).filter(
|
active_session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == location_id,
|
MonitoringSession.location_id == location_id,
|
||||||
RecordingSession.status == "recording"
|
MonitoringSession.status == "recording"
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
return templates.TemplateResponse("nrl_detail.html", {
|
template = "vibration_location_detail.html" if location.location_type == "vibration" else "nrl_detail.html"
|
||||||
|
return templates.TemplateResponse(template, {
|
||||||
"request": request,
|
"request": request,
|
||||||
"project_id": project_id,
|
"project_id": project_id,
|
||||||
"location_id": location_id,
|
"location_id": location_id,
|
||||||
|
|||||||
56
backend/migrate_add_project_deleted_at.py
Normal file
56
backend/migrate_add_project_deleted_at.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add deleted_at column to projects table
|
||||||
|
|
||||||
|
Adds columns:
|
||||||
|
- projects.deleted_at: Timestamp set when status='deleted'; data hard-deleted after 60 days
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(db_path: str):
|
||||||
|
"""Run the migration."""
|
||||||
|
print(f"Migrating database: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("projects table does not exist. Skipping migration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
cursor.execute("PRAGMA table_info(projects)")
|
||||||
|
existing_cols = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
|
if 'deleted_at' not in existing_cols:
|
||||||
|
print("Adding deleted_at column to projects...")
|
||||||
|
cursor.execute("ALTER TABLE projects ADD COLUMN deleted_at DATETIME")
|
||||||
|
else:
|
||||||
|
print("deleted_at column already exists. Skipping.")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Migration completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration failed: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
db_path = "./data/terra-view.db"
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
db_path = sys.argv[1]
|
||||||
|
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
print(f"Database not found: {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
migrate(db_path)
|
||||||
54
backend/migrate_rename_recording_to_monitoring_sessions.py
Normal file
54
backend/migrate_rename_recording_to_monitoring_sessions.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""
|
||||||
|
Migration: Rename recording_sessions table to monitoring_sessions
|
||||||
|
|
||||||
|
Renames the table and updates the model name from RecordingSession to MonitoringSession.
|
||||||
|
Run once per database: python backend/migrate_rename_recording_to_monitoring_sessions.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(db_path: str):
|
||||||
|
"""Run the migration."""
|
||||||
|
print(f"Migrating database: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='recording_sessions'")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='monitoring_sessions'")
|
||||||
|
if cursor.fetchone():
|
||||||
|
print("monitoring_sessions table already exists. Skipping migration.")
|
||||||
|
else:
|
||||||
|
print("recording_sessions table does not exist. Skipping migration.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Renaming recording_sessions -> monitoring_sessions...")
|
||||||
|
cursor.execute("ALTER TABLE recording_sessions RENAME TO monitoring_sessions")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("Migration completed successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration failed: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
db_path = "./data/terra-view.db"
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
db_path = sys.argv[1]
|
||||||
|
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
print(f"Database not found: {db_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
migrate(db_path)
|
||||||
@@ -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, completed, archived
|
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
||||||
|
|
||||||
# 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,6 +166,7 @@ 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):
|
||||||
@@ -244,17 +245,17 @@ class ScheduledAction(Base):
|
|||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
class RecordingSession(Base):
|
class MonitoringSession(Base):
|
||||||
"""
|
"""
|
||||||
Recording sessions: tracks actual monitoring sessions.
|
Monitoring sessions: tracks actual monitoring sessions.
|
||||||
Created when recording starts, updated when it stops.
|
Created when monitoring starts, updated when it stops.
|
||||||
"""
|
"""
|
||||||
__tablename__ = "recording_sessions"
|
__tablename__ = "monitoring_sessions"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
||||||
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
||||||
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable for offline uploads)
|
||||||
|
|
||||||
session_type = Column(String, nullable=False) # sound | vibration
|
session_type = Column(String, nullable=False) # sound | vibration
|
||||||
started_at = Column(DateTime, nullable=False)
|
started_at = Column(DateTime, nullable=False)
|
||||||
@@ -277,7 +278,7 @@ class DataFile(Base):
|
|||||||
__tablename__ = "data_files"
|
__tablename__ = "data_files"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
session_id = Column(String, nullable=False, index=True) # FK to RecordingSession.id
|
session_id = Column(String, nullable=False, index=True) # FK to MonitoringSession.id
|
||||||
|
|
||||||
file_path = Column(String, nullable=False) # Relative to data/Projects/
|
file_path = Column(String, nullable=False) # Relative to data/Projects/
|
||||||
file_type = Column(String, nullable=False) # wav, csv, mseed, json
|
file_type = Column(String, nullable=False) # wav, csv, mseed, json
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
@@ -48,10 +49,18 @@ def dashboard_todays_actions(request: Request, db: Session = Depends(get_db)):
|
|||||||
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
today_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,6 +14,12 @@ from typing import Optional
|
|||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from fastapi import UploadFile, File
|
||||||
|
import zipfile
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import (
|
from backend.models import (
|
||||||
Project,
|
Project,
|
||||||
@@ -21,7 +27,8 @@ from backend.models import (
|
|||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
RosterUnit,
|
RosterUnit,
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
|
DataFile,
|
||||||
)
|
)
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
|
||||||
@@ -70,8 +77,8 @@ async def get_project_locations(
|
|||||||
if assignment:
|
if assignment:
|
||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||||
|
|
||||||
# Count recording sessions
|
# Count monitoring sessions
|
||||||
session_count = db.query(RecordingSession).filter_by(
|
session_count = db.query(MonitoringSession).filter_by(
|
||||||
location_id=location.id
|
location_id=location.id
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
@@ -370,19 +377,19 @@ async def unassign_unit(
|
|||||||
if not assignment:
|
if not assignment:
|
||||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||||
|
|
||||||
# Check if there are active recording sessions
|
# Check if there are active monitoring sessions
|
||||||
active_sessions = db.query(RecordingSession).filter(
|
active_sessions = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == assignment.location_id,
|
MonitoringSession.location_id == assignment.location_id,
|
||||||
RecordingSession.unit_id == assignment.unit_id,
|
MonitoringSession.unit_id == assignment.unit_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
if active_sessions > 0:
|
if active_sessions > 0:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Cannot unassign unit with active recording sessions. Stop recording first.",
|
detail="Cannot unassign unit with active monitoring sessions. Stop monitoring first.",
|
||||||
)
|
)
|
||||||
|
|
||||||
assignment.status = "completed"
|
assignment.status = "completed"
|
||||||
@@ -451,14 +458,12 @@ async def get_nrl_sessions(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get recording sessions for a specific NRL.
|
Get monitoring sessions for a specific NRL.
|
||||||
Returns HTML partial with session list.
|
Returns HTML partial with session list.
|
||||||
"""
|
"""
|
||||||
from backend.models import RecordingSession, RosterUnit
|
sessions = db.query(MonitoringSession).filter_by(
|
||||||
|
|
||||||
sessions = db.query(RecordingSession).filter_by(
|
|
||||||
location_id=location_id
|
location_id=location_id
|
||||||
).order_by(RecordingSession.started_at.desc()).all()
|
).order_by(MonitoringSession.started_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with unit details
|
# Enrich with unit details
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
@@ -491,14 +496,12 @@ async def get_nrl_files(
|
|||||||
Get data files for a specific NRL.
|
Get data files for a specific NRL.
|
||||||
Returns HTML partial with file list.
|
Returns HTML partial with file list.
|
||||||
"""
|
"""
|
||||||
from backend.models import DataFile, RecordingSession
|
# Join DataFile with MonitoringSession to filter by location_id
|
||||||
|
|
||||||
# Join DataFile with RecordingSession to filter by location_id
|
|
||||||
files = db.query(DataFile).join(
|
files = db.query(DataFile).join(
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
DataFile.session_id == RecordingSession.id
|
DataFile.session_id == MonitoringSession.id
|
||||||
).filter(
|
).filter(
|
||||||
RecordingSession.location_id == location_id
|
MonitoringSession.location_id == location_id
|
||||||
).order_by(DataFile.created_at.desc()).all()
|
).order_by(DataFile.created_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with session details
|
# Enrich with session details
|
||||||
@@ -506,7 +509,7 @@ async def get_nrl_files(
|
|||||||
for file in files:
|
for file in files:
|
||||||
session = None
|
session = None
|
||||||
if file.session_id:
|
if file.session_id:
|
||||||
session = db.query(RecordingSession).filter_by(id=file.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file.session_id).first()
|
||||||
|
|
||||||
files_data.append({
|
files_data.append({
|
||||||
"file": file,
|
"file": file,
|
||||||
@@ -519,3 +522,217 @@ async def get_nrl_files(
|
|||||||
"location_id": location_id,
|
"location_id": location_id,
|
||||||
"files": files_data,
|
"files": files_data,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Manual SD Card Data Upload
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _parse_rnh(content: bytes) -> dict:
|
||||||
|
"""
|
||||||
|
Parse a Rion .rnh metadata file (INI-style with [Section] headers).
|
||||||
|
Returns a dict of key metadata fields.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8", errors="replace")
|
||||||
|
for line in text.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("["):
|
||||||
|
continue
|
||||||
|
if "," in line:
|
||||||
|
key, _, value = line.partition(",")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip()
|
||||||
|
if key == "Serial Number":
|
||||||
|
result["serial_number"] = value
|
||||||
|
elif key == "Store Name":
|
||||||
|
result["store_name"] = value
|
||||||
|
elif key == "Index Number":
|
||||||
|
result["index_number"] = value
|
||||||
|
elif key == "Measurement Start Time":
|
||||||
|
result["start_time_str"] = value
|
||||||
|
elif key == "Measurement Stop Time":
|
||||||
|
result["stop_time_str"] = value
|
||||||
|
elif key == "Total Measurement Time":
|
||||||
|
result["total_time_str"] = value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_rnh_datetime(s: str):
|
||||||
|
"""Parse RNH datetime string: '2026/02/17 19:00:19' -> datetime"""
|
||||||
|
from datetime import datetime
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(s.strip(), "%Y/%m/%d %H:%M:%S")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_file(filename: str) -> str:
|
||||||
|
"""Classify a file by name into a DataFile file_type."""
|
||||||
|
name = filename.lower()
|
||||||
|
if name.endswith(".rnh"):
|
||||||
|
return "log"
|
||||||
|
if name.endswith(".rnd"):
|
||||||
|
return "measurement"
|
||||||
|
if name.endswith(".zip"):
|
||||||
|
return "archive"
|
||||||
|
return "data"
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/nrl/{location_id}/upload-data")
|
||||||
|
async def upload_nrl_data(
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
files: list[UploadFile] = File(...),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Manually upload SD card data for an offline NRL.
|
||||||
|
|
||||||
|
Accepts either:
|
||||||
|
- A single .zip file (the Auto_#### folder zipped) — auto-extracted
|
||||||
|
- Multiple .rnd / .rnh files selected directly from the SD card folder
|
||||||
|
|
||||||
|
Creates a MonitoringSession from .rnh metadata and DataFile records
|
||||||
|
for each measurement file. No unit assignment required.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Verify project and location exist
|
||||||
|
location = db.query(MonitoringLocation).filter_by(
|
||||||
|
id=location_id, project_id=project_id
|
||||||
|
).first()
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
|
|
||||||
|
# --- Step 1: Normalize to (filename, bytes) list ---
|
||||||
|
file_entries: list[tuple[str, bytes]] = []
|
||||||
|
|
||||||
|
if len(files) == 1 and files[0].filename.lower().endswith(".zip"):
|
||||||
|
raw = await files[0].read()
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
||||||
|
for info in zf.infolist():
|
||||||
|
if info.is_dir():
|
||||||
|
continue
|
||||||
|
name = Path(info.filename).name # strip folder path
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
file_entries.append((name, zf.read(info)))
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
raise HTTPException(status_code=400, detail="Uploaded file is not a valid ZIP archive.")
|
||||||
|
else:
|
||||||
|
for uf in files:
|
||||||
|
data = await uf.read()
|
||||||
|
file_entries.append((uf.filename, data))
|
||||||
|
|
||||||
|
if not file_entries:
|
||||||
|
raise HTTPException(status_code=400, detail="No usable files found in upload.")
|
||||||
|
|
||||||
|
# --- Step 2: Find and parse .rnh metadata ---
|
||||||
|
rnh_meta = {}
|
||||||
|
for fname, fbytes in file_entries:
|
||||||
|
if fname.lower().endswith(".rnh"):
|
||||||
|
rnh_meta = _parse_rnh(fbytes)
|
||||||
|
break
|
||||||
|
|
||||||
|
started_at = _parse_rnh_datetime(rnh_meta.get("start_time_str")) or datetime.utcnow()
|
||||||
|
stopped_at = _parse_rnh_datetime(rnh_meta.get("stop_time_str"))
|
||||||
|
duration_seconds = None
|
||||||
|
if started_at and stopped_at:
|
||||||
|
duration_seconds = int((stopped_at - started_at).total_seconds())
|
||||||
|
|
||||||
|
store_name = rnh_meta.get("store_name", "")
|
||||||
|
serial_number = rnh_meta.get("serial_number", "")
|
||||||
|
index_number = rnh_meta.get("index_number", "")
|
||||||
|
|
||||||
|
# --- Step 3: Create MonitoringSession ---
|
||||||
|
session_id = str(uuid.uuid4())
|
||||||
|
monitoring_session = MonitoringSession(
|
||||||
|
id=session_id,
|
||||||
|
project_id=project_id,
|
||||||
|
location_id=location_id,
|
||||||
|
unit_id=None,
|
||||||
|
session_type="sound",
|
||||||
|
started_at=started_at,
|
||||||
|
stopped_at=stopped_at,
|
||||||
|
duration_seconds=duration_seconds,
|
||||||
|
status="completed",
|
||||||
|
session_metadata=json.dumps({
|
||||||
|
"source": "manual_upload",
|
||||||
|
"store_name": store_name,
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"index_number": index_number,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
db.add(monitoring_session)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(monitoring_session)
|
||||||
|
|
||||||
|
# --- Step 4: Write files to disk and create DataFile records ---
|
||||||
|
output_dir = Path("data/Projects") / project_id / session_id
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
leq_count = 0
|
||||||
|
lp_count = 0
|
||||||
|
metadata_count = 0
|
||||||
|
files_imported = 0
|
||||||
|
|
||||||
|
for fname, fbytes in file_entries:
|
||||||
|
file_type = _classify_file(fname)
|
||||||
|
fname_lower = fname.lower()
|
||||||
|
|
||||||
|
# Track counts for summary
|
||||||
|
if fname_lower.endswith(".rnd"):
|
||||||
|
if "_leq_" in fname_lower:
|
||||||
|
leq_count += 1
|
||||||
|
elif "_lp" in fname_lower:
|
||||||
|
lp_count += 1
|
||||||
|
elif fname_lower.endswith(".rnh"):
|
||||||
|
metadata_count += 1
|
||||||
|
|
||||||
|
# Write to disk
|
||||||
|
dest = output_dir / fname
|
||||||
|
dest.write_bytes(fbytes)
|
||||||
|
|
||||||
|
# Compute checksum
|
||||||
|
checksum = hashlib.sha256(fbytes).hexdigest()
|
||||||
|
|
||||||
|
# Store relative path from data/ dir
|
||||||
|
rel_path = str(dest.relative_to("data"))
|
||||||
|
|
||||||
|
data_file = DataFile(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
session_id=session_id,
|
||||||
|
file_path=rel_path,
|
||||||
|
file_type=file_type,
|
||||||
|
file_size_bytes=len(fbytes),
|
||||||
|
downloaded_at=datetime.utcnow(),
|
||||||
|
checksum=checksum,
|
||||||
|
file_metadata=json.dumps({
|
||||||
|
"source": "manual_upload",
|
||||||
|
"original_filename": fname,
|
||||||
|
"store_name": store_name,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
db.add(data_file)
|
||||||
|
files_imported += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"session_id": session_id,
|
||||||
|
"files_imported": files_imported,
|
||||||
|
"leq_files": leq_count,
|
||||||
|
"lp_files": lp_count,
|
||||||
|
"metadata_files": metadata_count,
|
||||||
|
"store_name": store_name,
|
||||||
|
"started_at": started_at.isoformat() if started_at else None,
|
||||||
|
"stopped_at": stopped_at.isoformat() if stopped_at else None,
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from backend.models import (
|
|||||||
ProjectType,
|
ProjectType,
|
||||||
MonitoringLocation,
|
MonitoringLocation,
|
||||||
UnitAssignment,
|
UnitAssignment,
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
ScheduledAction,
|
ScheduledAction,
|
||||||
RecurringSchedule,
|
RecurringSchedule,
|
||||||
RosterUnit,
|
RosterUnit,
|
||||||
@@ -57,9 +57,11 @@ async def get_projects_list(
|
|||||||
"""
|
"""
|
||||||
query = db.query(Project)
|
query = db.query(Project)
|
||||||
|
|
||||||
# Filter by status if provided
|
# Filter by status if provided; otherwise exclude soft-deleted projects
|
||||||
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:
|
||||||
@@ -87,10 +89,10 @@ async def get_projects_list(
|
|||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Count active sessions
|
# Count active sessions
|
||||||
active_session_count = db.query(func.count(RecordingSession.id)).filter(
|
active_session_count = db.query(func.count(MonitoringSession.id)).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.project_id == project.id,
|
MonitoringSession.project_id == project.id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
@@ -118,9 +120,10 @@ 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
|
# Count projects by status (exclude deleted)
|
||||||
total_projects = db.query(func.count(Project.id)).scalar()
|
total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar()
|
||||||
active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar()
|
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
|
||||||
@@ -132,7 +135,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Count active recording sessions
|
# Count active recording sessions
|
||||||
active_sessions = db.query(func.count(RecordingSession.id)).filter_by(
|
active_sessions = db.query(func.count(MonitoringSession.id)).filter_by(
|
||||||
status="recording"
|
status="recording"
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
@@ -140,6 +143,7 @@ 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,
|
||||||
@@ -178,13 +182,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 != "archived"
|
Project.status.notin_(["archived", "deleted"])
|
||||||
).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 != "archived",
|
Project.status.notin_(["archived", "deleted"]),
|
||||||
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),
|
||||||
@@ -223,13 +227,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 != "archived"
|
Project.status.notin_(["archived", "deleted"])
|
||||||
).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 != "archived",
|
Project.status.notin_(["archived", "deleted"]),
|
||||||
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),
|
||||||
@@ -338,6 +342,14 @@ 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:
|
||||||
@@ -359,18 +371,93 @@ 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)):
|
||||||
"""
|
"""
|
||||||
Delete a project (soft delete by archiving).
|
Soft-delete a project. Sets status='deleted' and records deleted_at timestamp.
|
||||||
|
Data will be permanently removed after 60 days (or via /permanent endpoint).
|
||||||
"""
|
"""
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
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 = "archived"
|
project.status = "deleted"
|
||||||
|
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 archived successfully"}
|
return {"success": True, "message": "Project deleted. Data will be permanently removed after 60 days."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}/permanent")
|
||||||
|
async def permanently_delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Hard-delete a project and all related data. Only allowed when status='deleted'.
|
||||||
|
Removes: locations, assignments, sessions, scheduled actions, recurring schedules.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
if project.status != "deleted":
|
||||||
|
raise HTTPException(status_code=400, detail="Project must be soft-deleted before permanent deletion.")
|
||||||
|
|
||||||
|
# Delete related data
|
||||||
|
db.query(RecurringSchedule).filter_by(project_id=project_id).delete()
|
||||||
|
db.query(ScheduledAction).filter_by(project_id=project_id).delete()
|
||||||
|
db.query(MonitoringSession).filter_by(project_id=project_id).delete()
|
||||||
|
db.query(UnitAssignment).filter_by(project_id=project_id).delete()
|
||||||
|
db.query(MonitoringLocation).filter_by(project_id=project_id).delete()
|
||||||
|
db.delete(project)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "Project permanently deleted."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/hold")
|
||||||
|
async def hold_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Put a project on hold. Pauses without archiving; assignments and schedules remain.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
project.status = "on_hold"
|
||||||
|
project.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
# Cancel pending scheduled actions so they don't appear in dashboards or fire
|
||||||
|
db.query(ScheduledAction).filter(
|
||||||
|
and_(
|
||||||
|
ScheduledAction.project_id == project_id,
|
||||||
|
ScheduledAction.execution_status == "pending",
|
||||||
|
)
|
||||||
|
).update({"execution_status": "cancelled"})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "Project put on hold."}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{project_id}/unhold")
|
||||||
|
async def unhold_project(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Resume a project that was on hold.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
project.status = "active"
|
||||||
|
project.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"success": True, "message": "Project resumed."}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -414,18 +501,18 @@ async def get_project_dashboard(
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Get active recording sessions
|
# Get active recording sessions
|
||||||
active_sessions = db.query(RecordingSession).filter(
|
active_sessions = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.project_id == project_id,
|
MonitoringSession.project_id == project_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# Get completed sessions count
|
# Get completed sessions count
|
||||||
completed_sessions_count = db.query(func.count(RecordingSession.id)).filter(
|
completed_sessions_count = db.query(func.count(MonitoringSession.id)).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.project_id == project_id,
|
MonitoringSession.project_id == project_id,
|
||||||
RecordingSession.status == "completed",
|
MonitoringSession.status == "completed",
|
||||||
)
|
)
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
@@ -504,26 +591,26 @@ async def get_project_units(
|
|||||||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||||
|
|
||||||
# Count sessions for this assignment
|
# Count sessions for this assignment
|
||||||
session_count = db.query(func.count(RecordingSession.id)).filter_by(
|
session_count = db.query(func.count(MonitoringSession.id)).filter_by(
|
||||||
location_id=assignment.location_id,
|
location_id=assignment.location_id,
|
||||||
unit_id=assignment.unit_id,
|
unit_id=assignment.unit_id,
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Count files from sessions
|
# Count files from sessions
|
||||||
file_count = db.query(func.count(DataFile.id)).join(
|
file_count = db.query(func.count(DataFile.id)).join(
|
||||||
RecordingSession,
|
MonitoringSession,
|
||||||
DataFile.session_id == RecordingSession.id
|
DataFile.session_id == MonitoringSession.id
|
||||||
).filter(
|
).filter(
|
||||||
RecordingSession.location_id == assignment.location_id,
|
MonitoringSession.location_id == assignment.location_id,
|
||||||
RecordingSession.unit_id == assignment.unit_id,
|
MonitoringSession.unit_id == assignment.unit_id,
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
||||||
# Check if currently recording
|
# Check if currently recording
|
||||||
active_session = db.query(RecordingSession).filter(
|
active_session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == assignment.location_id,
|
MonitoringSession.location_id == assignment.location_id,
|
||||||
RecordingSession.unit_id == assignment.unit_id,
|
MonitoringSession.unit_id == assignment.unit_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -610,10 +697,14 @@ 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -706,13 +797,13 @@ async def get_project_sessions(
|
|||||||
Returns HTML partial with session list.
|
Returns HTML partial with session list.
|
||||||
Optional status filter: recording, completed, paused, failed
|
Optional status filter: recording, completed, paused, failed
|
||||||
"""
|
"""
|
||||||
query = db.query(RecordingSession).filter_by(project_id=project_id)
|
query = db.query(MonitoringSession).filter_by(project_id=project_id)
|
||||||
|
|
||||||
# Filter by status if provided
|
# Filter by status if provided
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(RecordingSession.status == status)
|
query = query.filter(MonitoringSession.status == status)
|
||||||
|
|
||||||
sessions = query.order_by(RecordingSession.started_at.desc()).all()
|
sessions = query.order_by(MonitoringSession.started_at.desc()).all()
|
||||||
|
|
||||||
# Enrich with unit and location details
|
# Enrich with unit and location details
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
@@ -804,18 +895,18 @@ async def ftp_download_to_server(
|
|||||||
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
||||||
|
|
||||||
# Get or create active session for this location/unit
|
# Get or create active session for this location/unit
|
||||||
session = db.query(RecordingSession).filter(
|
session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.project_id == project_id,
|
MonitoringSession.project_id == project_id,
|
||||||
RecordingSession.location_id == location_id,
|
MonitoringSession.location_id == location_id,
|
||||||
RecordingSession.unit_id == unit_id,
|
MonitoringSession.unit_id == unit_id,
|
||||||
RecordingSession.status.in_(["recording", "paused"])
|
MonitoringSession.status.in_(["recording", "paused"])
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# If no active session, create one
|
# If no active session, create one
|
||||||
if not session:
|
if not session:
|
||||||
session = RecordingSession(
|
session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
@@ -969,18 +1060,18 @@ async def ftp_download_folder_to_server(
|
|||||||
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
||||||
|
|
||||||
# Get or create active session for this location/unit
|
# Get or create active session for this location/unit
|
||||||
session = db.query(RecordingSession).filter(
|
session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.project_id == project_id,
|
MonitoringSession.project_id == project_id,
|
||||||
RecordingSession.location_id == location_id,
|
MonitoringSession.location_id == location_id,
|
||||||
RecordingSession.unit_id == unit_id,
|
MonitoringSession.unit_id == unit_id,
|
||||||
RecordingSession.status.in_(["recording", "paused"])
|
MonitoringSession.status.in_(["recording", "paused"])
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
# If no active session, create one
|
# If no active session, create one
|
||||||
if not session:
|
if not session:
|
||||||
session = RecordingSession(
|
session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
location_id=location_id,
|
location_id=location_id,
|
||||||
@@ -1140,9 +1231,9 @@ async def get_unified_files(
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
# Get all sessions for this project
|
# Get all sessions for this project
|
||||||
sessions = db.query(RecordingSession).filter_by(
|
sessions = db.query(MonitoringSession).filter_by(
|
||||||
project_id=project_id
|
project_id=project_id
|
||||||
).order_by(RecordingSession.started_at.desc()).all()
|
).order_by(MonitoringSession.started_at.desc()).all()
|
||||||
|
|
||||||
sessions_data = []
|
sessions_data = []
|
||||||
for session in sessions:
|
for session in sessions:
|
||||||
@@ -1219,7 +1310,7 @@ async def download_project_file(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1253,7 +1344,7 @@ async def download_session_files(
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
# Verify session belongs to this project
|
# Verify session belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=session_id).first()
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
if session.project_id != project_id:
|
if session.project_id != project_id:
|
||||||
@@ -1321,7 +1412,7 @@ async def delete_project_file(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1351,7 +1442,7 @@ async def delete_session(
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Verify session belongs to this project
|
# Verify session belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=session_id).first()
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(status_code=404, detail="Session not found")
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
if session.project_id != project_id:
|
if session.project_id != project_id:
|
||||||
@@ -1400,7 +1491,7 @@ async def view_rnd_file(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1466,7 +1557,7 @@ async def get_rnd_data(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -1604,7 +1695,7 @@ async def generate_excel_report(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -2010,7 +2101,7 @@ async def preview_report_data(
|
|||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
# Verify file belongs to this project
|
# Verify file belongs to this project
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -2218,7 +2309,7 @@ async def generate_report_from_preview(
|
|||||||
if not file_record:
|
if not file_record:
|
||||||
raise HTTPException(status_code=404, detail="File not found")
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
session = db.query(RecordingSession).filter_by(id=file_record.session_id).first()
|
session = db.query(MonitoringSession).filter_by(id=file_record.session_id).first()
|
||||||
if not session or session.project_id != project_id:
|
if not session or session.project_id != project_id:
|
||||||
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
raise HTTPException(status_code=403, detail="File does not belong to this project")
|
||||||
|
|
||||||
@@ -2380,7 +2471,7 @@ async def generate_combined_excel_report(
|
|||||||
raise HTTPException(status_code=404, detail="Project not found")
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
# Get all sessions with measurement files
|
# Get all sessions with measurement files
|
||||||
sessions = db.query(RecordingSession).filter_by(project_id=project_id).all()
|
sessions = db.query(MonitoringSession).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
# Collect all Leq RND files grouped by location
|
# Collect all Leq RND files grouped by location
|
||||||
# Only include files with '_Leq_' in the path (15-minute averaged data)
|
# Only include files with '_Leq_' in the path (15-minute averaged data)
|
||||||
|
|||||||
@@ -497,6 +497,9 @@ 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()
|
||||||
@@ -515,4 +518,5 @@ 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 recording_sessions table (if exists)
|
# Update monitoring_sessions table (if exists)
|
||||||
try:
|
try:
|
||||||
from backend.models import RecordingSession
|
from backend.models import MonitoringSession
|
||||||
db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update(
|
db.query(MonitoringSession).filter(MonitoringSession.unit_id == old_id).update(
|
||||||
{"unit_id": new_id},
|
{"unit_id": new_id},
|
||||||
synchronize_session=False
|
synchronize_session=False
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not update recording_sessions: {e}")
|
logger.warning(f"Could not update monitoring_sessions: {e}")
|
||||||
|
|
||||||
# Commit all changes
|
# Commit all changes
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
@@ -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
|
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment, Project
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -332,10 +332,12 @@ class RecurringScheduleService:
|
|||||||
)
|
)
|
||||||
actions.append(start_action)
|
actions.append(start_action)
|
||||||
|
|
||||||
# Create STOP action
|
# Create STOP action (stop_cycle handles download when include_download is True)
|
||||||
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()),
|
||||||
@@ -350,27 +352,6 @@ 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(
|
||||||
@@ -613,8 +594,16 @@ 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."""
|
"""Get all enabled recurring schedules for projects that are not on hold or deleted."""
|
||||||
return self.db.query(RecurringSchedule).filter_by(enabled=True).all()
|
active_project_ids = [
|
||||||
|
p.id for p in self.db.query(Project.id).filter(
|
||||||
|
Project.status.notin_(["on_hold", "archived", "deleted"])
|
||||||
|
).all()
|
||||||
|
]
|
||||||
|
return self.db.query(RecurringSchedule).filter(
|
||||||
|
RecurringSchedule.enabled == True,
|
||||||
|
RecurringSchedule.project_id.in_(active_project_ids),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
|
||||||
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:
|
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, RecordingSession, MonitoringLocation, Project, RecurringSchedule
|
from backend.models import ScheduledAction, MonitoringSession, MonitoringLocation, Project, RecurringSchedule
|
||||||
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
||||||
from backend.services.alert_service import get_alert_service
|
from backend.services.alert_service import get_alert_service
|
||||||
import uuid
|
import uuid
|
||||||
@@ -107,10 +107,19 @@ 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()
|
||||||
|
|
||||||
@@ -263,7 +272,7 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create recording session
|
# Create recording session
|
||||||
session = RecordingSession(
|
session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=action.project_id,
|
project_id=action.project_id,
|
||||||
location_id=action.location_id,
|
location_id=action.location_id,
|
||||||
@@ -295,9 +304,20 @@ class SchedulerService:
|
|||||||
stop_cycle handles:
|
stop_cycle handles:
|
||||||
1. Stop measurement
|
1. Stop measurement
|
||||||
2. Enable FTP
|
2. Enable FTP
|
||||||
3. Download measurement folder
|
3. Download measurement folder to SLMM local storage
|
||||||
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:
|
||||||
@@ -308,7 +328,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
|
# SLMM handles stop, FTP enable, and download to SLMM-local storage
|
||||||
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,
|
||||||
@@ -316,11 +336,11 @@ class SchedulerService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Find and update the active recording session
|
# Find and update the active recording session
|
||||||
active_session = db.query(RecordingSession).filter(
|
active_session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == action.location_id,
|
MonitoringSession.location_id == action.location_id,
|
||||||
RecordingSession.unit_id == unit_id,
|
MonitoringSession.unit_id == unit_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -340,10 +360,81 @@ 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(
|
||||||
@@ -526,11 +617,11 @@ class SchedulerService:
|
|||||||
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
|
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
|
||||||
|
|
||||||
# Close out the old recording session
|
# Close out the old recording session
|
||||||
active_session = db.query(RecordingSession).filter(
|
active_session = db.query(MonitoringSession).filter(
|
||||||
and_(
|
and_(
|
||||||
RecordingSession.location_id == action.location_id,
|
MonitoringSession.location_id == action.location_id,
|
||||||
RecordingSession.unit_id == unit_id,
|
MonitoringSession.unit_id == unit_id,
|
||||||
RecordingSession.status == "recording",
|
MonitoringSession.status == "recording",
|
||||||
)
|
)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
@@ -557,7 +648,7 @@ class SchedulerService:
|
|||||||
result["steps"]["start"] = {"success": True, "response": cycle_response}
|
result["steps"]["start"] = {"success": True, "response": cycle_response}
|
||||||
|
|
||||||
# Create new recording session
|
# Create new recording session
|
||||||
new_session = RecordingSession(
|
new_session = MonitoringSession(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
project_id=action.project_id,
|
project_id=action.project_id,
|
||||||
location_id=action.location_id,
|
location_id=action.location_id,
|
||||||
|
|||||||
@@ -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"/NL43_DATA/{folder_name}"
|
remote_path = f"/NL-43/{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)
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"filename": "snapshot_20251216_201738.db",
|
|
||||||
"created_at": "20251216_201738",
|
|
||||||
"created_at_iso": "2025-12-16T20:17:38.638982",
|
|
||||||
"description": "Auto-backup before restore",
|
|
||||||
"size_bytes": 57344,
|
|
||||||
"size_mb": 0.05,
|
|
||||||
"original_db_size_bytes": 57344,
|
|
||||||
"type": "manual"
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"filename": "snapshot_uploaded_20251216_201732.db",
|
|
||||||
"created_at": "20251216_201732",
|
|
||||||
"created_at_iso": "2025-12-16T20:17:32.574205",
|
|
||||||
"description": "Uploaded: snapshot_20251216_200259.db",
|
|
||||||
"size_bytes": 77824,
|
|
||||||
"size_mb": 0.07,
|
|
||||||
"type": "uploaded"
|
|
||||||
}
|
|
||||||
@@ -24,30 +24,6 @@ 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:
|
||||||
@@ -61,6 +37,8 @@ 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"]
|
||||||
@@ -71,4 +49,3 @@ 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 recording_sessions table (if exists)
|
# Update monitoring_sessions table (if exists)
|
||||||
try:
|
try:
|
||||||
result = session.execute(
|
result = session.execute(
|
||||||
text("UPDATE recording_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
text("UPDATE monitoring_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||||
{"new_id": new_id, "old_id": old_id}
|
{"new_id": new_id, "old_id": old_id}
|
||||||
)
|
)
|
||||||
if result.rowcount > 0:
|
if result.rowcount > 0:
|
||||||
print(f" ✓ Updated recording_sessions ({result.rowcount} rows)")
|
print(f" ✓ Updated monitoring_sessions ({result.rowcount} rows)")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Table may not exist
|
pass # Table may not exist
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
<button onclick="switchTab('sessions')"
|
<button onclick="switchTab('sessions')"
|
||||||
data-tab="sessions"
|
data-tab="sessions"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
Recording Sessions
|
Monitoring Sessions
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('data')"
|
<button onclick="switchTab('data')"
|
||||||
data-tab="data"
|
data-tab="data"
|
||||||
@@ -302,11 +302,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Recording Sessions Tab -->
|
<!-- Monitoring Sessions Tab -->
|
||||||
<div id="sessions-tab" class="tab-panel hidden">
|
<div id="sessions-tab" class="tab-panel hidden">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||||
{% if assigned_unit %}
|
{% if assigned_unit %}
|
||||||
<button onclick="openScheduleModal()"
|
<button onclick="openScheduleModal()"
|
||||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
@@ -329,8 +329,40 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
||||||
<div class="text-sm text-gray-500">
|
<div class="flex items-center gap-3">
|
||||||
<span class="font-medium">{{ file_count }}</span> files
|
<span class="text-sm text-gray-500"><span class="font-medium">{{ file_count }}</span> files</span>
|
||||||
|
<button onclick="toggleUploadPanel()"
|
||||||
|
class="px-3 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||||
|
</svg>
|
||||||
|
Upload Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Panel -->
|
||||||
|
<div id="upload-panel" class="hidden mb-6 p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Upload SD Card Data</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Select a ZIP file, or select all files from inside an <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">Auto_####</code> folder. File types (.rnd, .rnh) are auto-detected.
|
||||||
|
</p>
|
||||||
|
<input type="file" id="upload-input" multiple
|
||||||
|
accept=".zip,.rnd,.rnh,.RND,.RNH"
|
||||||
|
class="block w-full text-sm text-gray-500 dark:text-gray-400
|
||||||
|
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
||||||
|
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||||
|
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||||
|
<div class="flex items-center gap-3 mt-3">
|
||||||
|
<button onclick="submitUpload()"
|
||||||
|
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||||
|
Import Files
|
||||||
|
</button>
|
||||||
|
<button onclick="toggleUploadPanel()"
|
||||||
|
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<span id="upload-status" class="text-sm hidden"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -559,5 +591,64 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
|||||||
closeAssignModal();
|
closeAssignModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Upload Data ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toggleUploadPanel() {
|
||||||
|
const panel = document.getElementById('upload-panel');
|
||||||
|
const status = document.getElementById('upload-status');
|
||||||
|
panel.classList.toggle('hidden');
|
||||||
|
// Reset status when reopening
|
||||||
|
if (!panel.classList.contains('hidden')) {
|
||||||
|
status.textContent = '';
|
||||||
|
status.className = 'text-sm hidden';
|
||||||
|
document.getElementById('upload-input').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUpload() {
|
||||||
|
const input = document.getElementById('upload-input');
|
||||||
|
const status = document.getElementById('upload-status');
|
||||||
|
|
||||||
|
if (!input.files.length) {
|
||||||
|
alert('Please select files to upload.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const file of input.files) {
|
||||||
|
formData.append('files', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
status.textContent = 'Uploading\u2026';
|
||||||
|
status.className = 'text-sm text-gray-500';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
|
||||||
|
{ method: 'POST', body: formData }
|
||||||
|
);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
||||||
|
if (data.leq_files || data.lp_files) {
|
||||||
|
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
||||||
|
}
|
||||||
|
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
|
||||||
|
status.textContent = parts.join(' ');
|
||||||
|
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
input.value = '';
|
||||||
|
// Refresh the file list
|
||||||
|
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
||||||
|
} else {
|
||||||
|
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
status.textContent = `Error: ${err.message}`;
|
||||||
|
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -151,9 +151,9 @@
|
|||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-2">No data files yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">
|
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||||
Files will appear here once they are downloaded from the sound level meter
|
Files appear here after an FTP download from a connected meter, or after uploading SD card data manually.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
</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,6 +34,10 @@
|
|||||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
|
<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,6 +16,8 @@
|
|||||||
|
|
||||||
{% 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,6 +27,20 @@
|
|||||||
</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 not item.schedule.enabled %}opacity-60{% endif %}">
|
{% if project_status == 'on_hold' or not item.schedule.enabled %}opacity-60{% endif %}">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex 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,7 +29,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Status badge -->
|
<!-- Status badge -->
|
||||||
{% if item.schedule.enabled %}
|
{% if project_status == 'on_hold' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
|
||||||
|
On Hold
|
||||||
|
</span>
|
||||||
|
{% elif project_status == 'archived' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
Archived
|
||||||
|
</span>
|
||||||
|
{% elif item.schedule.enabled %}
|
||||||
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
<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>
|
||||||
@@ -98,7 +106,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions (hidden when project is on hold or archived) -->
|
||||||
|
{% if project_status not in ('on_hold', 'archived') %}
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<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"
|
||||||
@@ -131,6 +140,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
<!-- 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">
|
||||||
@@ -54,6 +55,11 @@
|
|||||||
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
<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
|
||||||
@@ -157,7 +163,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions (hidden when project is on hold or archived) -->
|
||||||
|
{% if project_status not in ('on_hold', 'archived') %}
|
||||||
<div class="flex items-center gap-2 flex-shrink-0">
|
<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 }}')"
|
||||||
@@ -177,6 +184,7 @@
|
|||||||
</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 recording session with a specific start and end time.
|
Schedule a single monitoring session with a specific start and end time.
|
||||||
Duration can be between 15 minutes and 24 hours.
|
Duration can be between 15 minutes and 24 hours.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<!-- Recording Sessions List -->
|
<!-- Monitoring Sessions List -->
|
||||||
{% if sessions %}
|
{% if sessions %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{% for item in sessions %}
|
{% for item in sessions %}
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No recording sessions yet</p>
|
<p class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p>
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -99,7 +99,7 @@ function viewSession(sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopRecording(sessionId) {
|
function stopRecording(sessionId) {
|
||||||
if (!confirm('Stop this recording session?')) return;
|
if (!confirm('Stop this monitoring session?')) return;
|
||||||
|
|
||||||
// TODO: Implement stop recording API call
|
// TODO: Implement stop recording API call
|
||||||
alert('Stop recording API coming soon for session: ' + sessionId);
|
alert('Stop recording API coming soon for session: ' + sessionId);
|
||||||
|
|||||||
@@ -163,6 +163,25 @@
|
|||||||
</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">
|
||||||
@@ -264,6 +283,9 @@ 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;
|
||||||
@@ -525,6 +547,77 @@ 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 onclick="switchTab('sessions')"
|
<button id="sessions-tab-btn" onclick="switchTab('sessions')"
|
||||||
data-tab="sessions"
|
data-tab="sessions"
|
||||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
Recording Sessions
|
Monitoring Sessions
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('data')"
|
<button id="data-tab-btn" 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>
|
||||||
|
|
||||||
<!-- Recording Sessions Tab -->
|
<!-- Monitoring Sessions Tab -->
|
||||||
<div id="sessions-tab" class="tab-panel hidden">
|
<div id="sessions-tab" class="tab-panel hidden">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<select id="sessions-filter" onchange="filterSessions()"
|
<select id="sessions-filter" onchange="filterSessions()"
|
||||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
@@ -279,6 +279,7 @@
|
|||||||
<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>
|
||||||
@@ -329,14 +330,39 @@
|
|||||||
<!-- 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="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
<div class="space-y-3">
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
<!-- On Hold -->
|
||||||
Archive this project to remove it from active listings. All data will be preserved.
|
<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">
|
||||||
</p>
|
<div>
|
||||||
<button onclick="archiveProject()"
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Put Project On Hold</p>
|
||||||
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
<p class="text-sm text-gray-600 dark:text-gray-400">Pause this project without archiving. Assignments and schedules remain in place.</p>
|
||||||
Archive Project
|
</div>
|
||||||
</button>
|
<div id="hold-btn-container" class="shrink-0">
|
||||||
|
<!-- Rendered by updateDangerZone() based on current status -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Archive -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-600 rounded-lg p-4 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Archive Project</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Remove from active listings. All data is preserved and can be restored.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="archiveProject()"
|
||||||
|
class="shrink-0 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm">
|
||||||
|
Archive
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Delete -->
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Delete Project</p>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Permanently removes all project data after a 60-day grace period. This action is difficult to undo.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="openDeleteModal()"
|
||||||
|
class="shrink-0 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -495,7 +521,7 @@
|
|||||||
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
|
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Single recording session with a specific start and end date/time (15 min - 24 hrs).
|
Single monitoring session with a specific start and end date/time (15 min - 24 hrs).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@@ -596,6 +622,40 @@
|
|||||||
</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;
|
||||||
@@ -654,14 +714,19 @@ 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 based on project type
|
// Update tab labels and visibility based on project type
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
const isSoundProject = 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);
|
||||||
}
|
}
|
||||||
@@ -743,6 +808,10 @@ function openLocationModal(defaultType) {
|
|||||||
locationTypeSelect.value = 'sound';
|
locationTypeSelect.value = 'sound';
|
||||||
locationTypeSelect.disabled = true;
|
locationTypeSelect.disabled = true;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
|
} else if (projectTypeId === 'vibration_monitoring') {
|
||||||
|
locationTypeSelect.value = 'vibration';
|
||||||
|
locationTypeSelect.disabled = true;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
locationTypeSelect.disabled = false;
|
locationTypeSelect.disabled = false;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
@@ -767,6 +836,10 @@ function openEditLocationModal(button) {
|
|||||||
locationTypeSelect.value = 'sound';
|
locationTypeSelect.value = 'sound';
|
||||||
locationTypeSelect.disabled = true;
|
locationTypeSelect.disabled = true;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
|
} else if (projectTypeId === 'vibration_monitoring') {
|
||||||
|
locationTypeSelect.value = 'vibration';
|
||||||
|
locationTypeSelect.disabled = true;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
locationTypeSelect.disabled = false;
|
locationTypeSelect.disabled = false;
|
||||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
@@ -790,6 +863,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
|||||||
let locationType = document.getElementById('location-type').value;
|
let locationType = document.getElementById('location-type').value;
|
||||||
if (projectTypeId === 'sound_monitoring') {
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
locationType = 'sound';
|
locationType = 'sound';
|
||||||
|
} else if (projectTypeId === 'vibration_monitoring') {
|
||||||
|
locationType = 'vibration';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1027,6 +1102,78 @@ 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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-6 mb-8"
|
||||||
hx-get="/api/projects/stats"
|
hx-get="/api/projects/stats"
|
||||||
hx-trigger="load, every 30s"
|
hx-trigger="load, every 30s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
<div 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 -->
|
||||||
@@ -43,6 +44,11 @@
|
|||||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
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">
|
||||||
@@ -72,9 +78,16 @@
|
|||||||
<!-- Create Project Modal -->
|
<!-- Create Project Modal -->
|
||||||
<div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
<div>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="hideCreateProjectModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 ml-4">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6" id="createProjectContent">
|
<div class="p-6" id="createProjectContent">
|
||||||
@@ -91,6 +104,12 @@
|
|||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button type="button" onclick="hideCreateProjectModal()"
|
||||||
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2: Project Details Form (hidden initially) -->
|
<!-- Step 2: Project Details Form (hidden initially) -->
|
||||||
|
|||||||
@@ -1504,7 +1504,7 @@
|
|||||||
`• Unit roster entry\n` +
|
`• Unit roster entry\n` +
|
||||||
`• All history records\n` +
|
`• All history records\n` +
|
||||||
`• Project assignments\n` +
|
`• Project assignments\n` +
|
||||||
`• Recording sessions\n` +
|
`• Monitoring sessions\n` +
|
||||||
`• Modem references\n\n` +
|
`• Modem references\n\n` +
|
||||||
`This action cannot be undone.`
|
`This action cannot be undone.`
|
||||||
);
|
);
|
||||||
|
|||||||
415
templates/vibration_location_detail.html
Normal file
415
templates/vibration_location_detail.html
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ location.name }} - Monitoring Location{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Breadcrumb Navigation -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex items-center space-x-2 text-sm">
|
||||||
|
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
Projects
|
||||||
|
</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/projects/{{ project_id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ project.name }}
|
||||||
|
</a>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">{{ location.name }}</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ location.name }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Monitoring Location • {{ project.name }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{% if assigned_unit %}
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
Unit Assigned
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
No Unit Assigned
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="flex space-x-6">
|
||||||
|
<button onclick="switchTab('overview')"
|
||||||
|
data-tab="overview"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('settings')"
|
||||||
|
data-tab="settings"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div id="tab-content">
|
||||||
|
<!-- Overview Tab -->
|
||||||
|
<div id="overview-tab" class="tab-panel">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Location Details Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location Details</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Name</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ location.name }}</div>
|
||||||
|
</div>
|
||||||
|
{% if location.description %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Description</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ location.description }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if location.address %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Address</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ location.address }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if location.coordinates %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Coordinates</div>
|
||||||
|
<div class="text-gray-900 dark:text-white font-mono text-sm">{{ location.coordinates }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Created</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ location.created_at|local_datetime if location.created_at else 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assignment Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
||||||
|
{% if assigned_unit %}
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
|
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||||
|
{{ assigned_unit.id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if assigned_unit.device_type %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Device Type</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ assigned_unit.device_type|capitalize }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if assignment %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at|local_datetime if assignment.assigned_at else 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
{% if assignment.notes %}
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
||||||
|
<div class="text-gray-900 dark:text-white text-sm">{{ assignment.notes }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
<div class="pt-2">
|
||||||
|
<button onclick="unassignUnit('{{ assignment.id }}')"
|
||||||
|
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
|
||||||
|
Unassign Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
||||||
|
<button onclick="openAssignModal()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
Assign a Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<div id="settings-tab" class="tab-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Location Settings</h2>
|
||||||
|
|
||||||
|
<form id="location-settings-form" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
|
||||||
|
<input type="text" id="settings-name" value="{{ location.name }}"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||||
|
<textarea id="settings-description" rows="3"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">{{ location.description or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
|
<input type="text" id="settings-address" value="{{ location.address or '' }}"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
||||||
|
<input type="text" id="settings-coordinates" value="{{ location.coordinates or '' }}"
|
||||||
|
placeholder="40.7128,-74.0060"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="settings-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="window.location.href='/projects/{{ project_id }}'"
|
||||||
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assign Unit Modal -->
|
||||||
|
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a seismograph to this location</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="assign-form" class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
||||||
|
<select id="assign-unit-id" name="unit_id"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||||
|
<option value="">Loading units...</option>
|
||||||
|
</select>
|
||||||
|
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available seismographs for this project.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||||
|
<textarea id="assign-notes" name="notes" rows="2"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="assign-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="closeAssignModal()"
|
||||||
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
|
Assign Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const projectId = "{{ project_id }}";
|
||||||
|
const locationId = "{{ location_id }}";
|
||||||
|
|
||||||
|
// Tab switching
|
||||||
|
function switchTab(tabName) {
|
||||||
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||||
|
panel.classList.add('hidden');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.tab-button').forEach(button => {
|
||||||
|
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
||||||
|
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
});
|
||||||
|
const panel = document.getElementById(`${tabName}-tab`);
|
||||||
|
if (panel) panel.classList.remove('hidden');
|
||||||
|
const button = document.querySelector(`[data-tab="${tabName}"]`);
|
||||||
|
if (button) {
|
||||||
|
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||||
|
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location settings form submission
|
||||||
|
document.getElementById('location-settings-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById('settings-name').value.trim(),
|
||||||
|
description: document.getElementById('settings-description').value.trim() || null,
|
||||||
|
address: document.getElementById('settings-address').value.trim() || null,
|
||||||
|
coordinates: document.getElementById('settings-coordinates').value.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to update location');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('settings-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to update location.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign modal
|
||||||
|
function openAssignModal() {
|
||||||
|
document.getElementById('assign-modal').classList.remove('hidden');
|
||||||
|
loadAvailableUnits();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAssignModal() {
|
||||||
|
document.getElementById('assign-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailableUnits() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
|
||||||
|
if (!response.ok) throw new Error('Failed to load available units');
|
||||||
|
const data = await response.json();
|
||||||
|
const select = document.getElementById('assign-unit-id');
|
||||||
|
select.innerHTML = '<option value="">Select a unit</option>';
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
document.getElementById('assign-empty').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
data.forEach(unit => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = unit.id;
|
||||||
|
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('assign-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to load units.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const unitId = document.getElementById('assign-unit-id').value;
|
||||||
|
const notes = document.getElementById('assign-notes').value.trim();
|
||||||
|
|
||||||
|
if (!unitId) {
|
||||||
|
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
||||||
|
document.getElementById('assign-error').classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('unit_id', unitId);
|
||||||
|
formData.append('notes', notes);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to assign unit');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('assign-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to assign unit.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function unassignUnit(assignmentId) {
|
||||||
|
if (!confirm('Unassign this unit from the location?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to unassign unit');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to unassign unit.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') closeAssignModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeAssignModal();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user