14 Commits

Author SHA1 Message Date
serversdwn
4957a08198 fix: improvedr pair status sharing. 2026-01-29 16:37:59 +00:00
serversdwn
05482bd903 Add:
- pair_devices.html template for device pairing interface
- SLMM device control lock prevents flooding nl43.
Fix:
- Polling intervals for SLMM.
- modem view now list
- device pairing much improved.
- various other tweaks through out UI.
- SLMM Scheduled downloads fixed.
2026-01-29 07:50:13 +00:00
serversdwn
5ee6f5eb28 feat: Enhance dashboard with filtering options and sync SLM status
- Added a new filtering system to the dashboard for device types and statuses.
- Implemented asynchronous SLM status synchronization to update the Emitter table.
- Updated the status snapshot endpoint to sync SLM status before generating the snapshot.
- Refactored the dashboard HTML to include filter controls and JavaScript for managing filter state.
- Improved the unit detail page to handle modem associations and cascade updates to paired devices.
- Removed redundant code related to syncing start time for measuring devices.
2026-01-28 20:02:10 +00:00
serversdwn
6492fdff82 BIG update: Update to 0.5.1. Added:
-Project management
-Modem Managerment
-Modem/unit pairing

and more
2026-01-28 03:27:50 +00:00
serversdwn
44d7841852 BIG update: Update to 0.5.1. Added:
-Project management
-Modem Managerment
-Modem/unit pairing

and more
2026-01-28 03:26:52 +00:00
serversdwn
38c600aca3 Feat: schedule added to dashboard view. logo rework 2026-01-23 19:07:42 +00:00
serversdwn
eeda94926f doc: update to 0.4.4 (again) 2026-01-23 08:46:46 +00:00
serversdwn
57be9bf1f1 Docs: update to 0.4.4 2026-01-23 08:26:02 +00:00
serversdwn
8431784708 feat: Refactor template handling, improve scheduler functions, and add timezone utilities
- Moved Jinja2 template setup to a shared configuration file (templates_config.py) for consistent usage across routers.
- Introduced timezone utilities in a new module (timezone.py) to handle UTC to local time conversions and formatting.
- Updated all relevant routers to use the new shared template configuration and timezone filters.
- Enhanced templates to utilize local time formatting for various datetime fields, improving user experience with timezone awareness.
2026-01-23 06:05:39 +00:00
serversdwn
c771a86675 Feat/Fix: Scheduler actions more strictly defined. Commands now working. 2026-01-22 20:25:19 +00:00
serversdwn
65ea0920db Feat: Scheduler implemented, WIP 2026-01-21 23:11:58 +00:00
serversdwn
1f3fa7a718 feat: Add report templates API for CRUD operations and implement SLM settings modal
- Implemented a new API router for managing report templates, including endpoints for listing, creating, retrieving, updating, and deleting templates.
- Added a new HTML partial for a unified SLM settings modal, allowing users to configure SLM settings with dynamic modem selection and FTP credentials.
- Created a report preview page with an editable data table using jspreadsheet, enabling users to modify report details and download the report as an Excel file.
2026-01-20 21:43:50 +00:00
serversdwn
a9c9b1fd48 feat: SLM project report generator added. WIP 2026-01-20 08:46:06 +00:00
serversdwn
4c213c96ee Feat: rnd file viewer built 2026-01-19 21:49:10 +00:00
92 changed files with 13040 additions and 809 deletions

View File

@@ -1,10 +1,48 @@
# Changelog
All notable changes to Seismo Fleet Manager will be documented in this file.
All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.5.1] - 2026-01-27
### Added
- **Dashboard Schedule View**: Today's scheduled actions now display directly on the main dashboard
- New "Today's Actions" panel showing upcoming and past scheduled events
- Schedule list partial for project-specific schedule views
- API endpoint for fetching today's schedule data
- **New Branding Assets**: Complete logo rework for Terra-View
- New Terra-View logos for light and dark themes
- Retina-ready (@2x) logo variants
- Updated favicons (16px and 32px)
- Refreshed PWA icons (72px through 512px)
### Changed
- **Dashboard Layout**: Reorganized to include schedule information panel
- **Base Template**: Updated to use new Terra-View logos with theme-aware switching
## [0.5.0] - 2026-01-23
_Note: This version was not formally released; changes were included in v0.5.1._
## [0.4.4] - 2026-01-23
### Added
- **Recurring schedules**: New scheduler service, recurring schedule APIs, and schedule templates (calendar/interval/list).
- **Alerts UI + backend**: Alerting service plus dropdown/list templates for surfacing notifications.
- **Report templates + viewers**: CRUD API for report templates, report preview screen, and RND file viewer.
- **SLM tooling**: SLM settings modal and SLM project report generator workflow.
### Changed
- **Project data management**: Unified files view, refreshed FTP browser, and new project header/templates for file/session/unit/assignment lists.
- **Device/SLM sync**: Standardized SLM device types and tightened SLMM sync paths.
- **Docs/scripts**: Cleanup pass and expanded device-type documentation.
### Fixed
- **Scheduler actions**: Strict command definitions so actions run reliably.
- **Project view title**: Resolved JSON string rendering in project headers.
## [0.4.3] - 2026-01-14
### Added
@@ -361,6 +399,9 @@ No database migration required for v0.4.0. All new features use existing databas
- Photo management per unit
- Automated status categorization (OK/Pending/Missing)
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
[0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4
[0.4.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1

View File

@@ -1,4 +1,4 @@
# Seismo Fleet Manager v0.4.3
# Terra-View v0.5.1
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features
@@ -571,9 +571,13 @@ MIT
## Version
**Current: 0.4.3**SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
**Current: 0.5.1**Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
Previous: 0.4.2SLM configuration interface with TCP/FTP controls, modem diagnostics, and dashboard endpoints for Sound Level Meters (2026-01-05)
Previous: 0.4.4Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
0.4.3 — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
0.4.2 — SLM configuration interface with TCP/FTP controls, modem diagnostics, and dashboard endpoints for Sound Level Meters (2026-01-05)
0.4.1 — Sound Level Meter integration with full management UI for SLM units (2026-01-05)

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -18,9 +18,10 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
from backend.database import engine, Base, get_db
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
from backend.services.snapshot import emit_status_snapshot
from backend.models import IgnoredUnit
from backend.utils.timezone import get_user_timezone
# Create database tables
Base.metadata.create_all(bind=engine)
@@ -29,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app
VERSION = "0.4.3"
VERSION = "0.5.1"
app = FastAPI(
title="Seismo Fleet Manager",
description="Backend API for managing seismograph fleet status",
@@ -58,8 +59,8 @@ app.add_middleware(
# Mount static files
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
# Setup Jinja2 templates
templates = Jinja2Templates(directory="templates")
# Use shared templates configuration with timezone filters
from backend.templates_config import templates
# Add custom context processor to inject environment variable into all templates
@app.middleware("http")
@@ -92,6 +93,7 @@ app.include_router(slmm.router)
app.include_router(slm_ui.router)
app.include_router(slm_dashboard.router)
app.include_router(seismo_dashboard.router)
app.include_router(modem_dashboard.router)
from backend.routers import settings
app.include_router(settings.router)
@@ -101,8 +103,21 @@ app.include_router(projects.router)
app.include_router(project_locations.router)
app.include_router(scheduler.router)
# Start scheduler service on application startup
# Report templates router
from backend.routers import report_templates
app.include_router(report_templates.router)
# Alerts router
from backend.routers import alerts
app.include_router(alerts.router)
# Recurring schedules router
from backend.routers import recurring_schedules
app.include_router(recurring_schedules.router)
# Start scheduler service and device status monitor on application startup
from backend.services.scheduler import start_scheduler, stop_scheduler
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
@app.on_event("startup")
async def startup_event():
@@ -111,9 +126,17 @@ async def startup_event():
await start_scheduler()
logger.info("Scheduler service started")
logger.info("Starting device status monitor...")
await start_device_status_monitor()
logger.info("Device status monitor started")
@app.on_event("shutdown")
def shutdown_event():
"""Clean up services on app shutdown"""
logger.info("Stopping device status monitor...")
stop_device_status_monitor()
logger.info("Device status monitor stopped")
logger.info("Stopping scheduler service...")
stop_scheduler()
logger.info("Scheduler service stopped")
@@ -195,6 +218,73 @@ async def seismographs_page(request: Request):
return templates.TemplateResponse("seismographs.html", {"request": request})
@app.get("/modems", response_class=HTMLResponse)
async def modems_page(request: Request):
"""Field modems management dashboard"""
return templates.TemplateResponse("modems.html", {"request": request})
@app.get("/pair-devices", response_class=HTMLResponse)
async def pair_devices_page(request: Request, db: Session = Depends(get_db)):
"""
Device pairing page - two-column layout for pairing recorders with modems.
"""
from backend.models import RosterUnit
# Get all non-retired recorders (seismographs and SLMs)
recorders = db.query(RosterUnit).filter(
RosterUnit.retired == False,
RosterUnit.device_type.in_(["seismograph", "slm", None]) # None defaults to seismograph
).order_by(RosterUnit.id).all()
# Get all non-retired modems
modems = db.query(RosterUnit).filter(
RosterUnit.retired == False,
RosterUnit.device_type == "modem"
).order_by(RosterUnit.id).all()
# Build existing pairings list
pairings = []
for recorder in recorders:
if recorder.deployed_with_modem_id:
modem = next((m for m in modems if m.id == recorder.deployed_with_modem_id), None)
pairings.append({
"recorder_id": recorder.id,
"recorder_type": (recorder.device_type or "seismograph").upper(),
"modem_id": recorder.deployed_with_modem_id,
"modem_ip": modem.ip_address if modem else None
})
# Convert to dicts for template
recorders_data = [
{
"id": r.id,
"device_type": r.device_type or "seismograph",
"deployed": r.deployed,
"deployed_with_modem_id": r.deployed_with_modem_id
}
for r in recorders
]
modems_data = [
{
"id": m.id,
"deployed": m.deployed,
"deployed_with_unit_id": m.deployed_with_unit_id,
"ip_address": m.ip_address,
"phone_number": m.phone_number
}
for m in modems
]
return templates.TemplateResponse("pair_devices.html", {
"request": request,
"recorders": recorders_data,
"modems": modems_data,
"pairings": pairings
})
@app.get("/projects", response_class=HTMLResponse)
async def projects_page(request: Request):
"""Projects management and overview"""
@@ -559,6 +649,7 @@ async def devices_all_partial(request: Request):
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
@@ -582,6 +673,7 @@ async def devices_all_partial(request: Request):
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
@@ -605,6 +697,7 @@ async def devices_all_partial(request: Request):
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
@@ -628,6 +721,7 @@ async def devices_all_partial(request: Request):
"last_calibrated": None,
"next_calibration_due": None,
"deployed_with_modem_id": None,
"deployed_with_unit_id": None,
"ip_address": None,
"phone_number": None,
"hardware_model": None,
@@ -650,7 +744,8 @@ async def devices_all_partial(request: Request):
return templates.TemplateResponse("partials/devices_table.html", {
"request": request,
"units": units_list,
"timestamp": datetime.now().strftime("%H:%M:%S")
"timestamp": datetime.now().strftime("%H:%M:%S"),
"user_timezone": get_user_timezone()
})

View File

@@ -0,0 +1,67 @@
"""
Migration: Add auto_increment_index column to recurring_schedules table
This migration adds the auto_increment_index column that controls whether
the scheduler should automatically find an unused store index before starting
a new measurement.
Run this script once to update existing databases:
python -m backend.migrate_add_auto_increment_index
"""
import sqlite3
import os
DB_PATH = "data/seismo_fleet.db"
def migrate():
"""Add auto_increment_index column to recurring_schedules table."""
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return False
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
# Check if recurring_schedules table exists
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='recurring_schedules'
""")
if not cursor.fetchone():
print("recurring_schedules table does not exist yet. Will be created on app startup.")
conn.close()
return True
# Check if auto_increment_index column already exists
cursor.execute("PRAGMA table_info(recurring_schedules)")
columns = [row[1] for row in cursor.fetchall()]
if "auto_increment_index" in columns:
print("auto_increment_index column already exists in recurring_schedules table.")
conn.close()
return True
# Add the column
print("Adding auto_increment_index column to recurring_schedules table...")
cursor.execute("""
ALTER TABLE recurring_schedules
ADD COLUMN auto_increment_index BOOLEAN DEFAULT 1
""")
conn.commit()
print("Successfully added auto_increment_index column.")
conn.close()
return True
except Exception as e:
print(f"Migration failed: {e}")
conn.close()
return False
if __name__ == "__main__":
success = migrate()
exit(0 if success else 1)

View File

@@ -0,0 +1,84 @@
"""
Migration script to add deployment_type and deployed_with_unit_id fields to roster table.
deployment_type: tracks what type of device a modem is deployed with:
- "seismograph" - Modem is connected to a seismograph
- "slm" - Modem is connected to a sound level meter
- NULL/empty - Not assigned or unknown
deployed_with_unit_id: stores the ID of the seismograph/SLM this modem is deployed with
(reverse relationship of deployed_with_modem_id)
Run this script once to migrate an existing database.
"""
import sqlite3
import os
# Database path
DB_PATH = "./data/seismo_fleet.db"
def migrate_database():
"""Add deployment_type and deployed_with_unit_id columns to roster table"""
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
print("The database will be created automatically when you run the application.")
return
print(f"Migrating database: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Check if roster table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='roster'")
table_exists = cursor.fetchone()
if not table_exists:
print("Roster table does not exist yet - will be created when app runs")
conn.close()
return
# Check existing columns
cursor.execute("PRAGMA table_info(roster)")
columns = [col[1] for col in cursor.fetchall()]
try:
# Add deployment_type if not exists
if 'deployment_type' not in columns:
print("Adding deployment_type column to roster table...")
cursor.execute("ALTER TABLE roster ADD COLUMN deployment_type TEXT")
print(" Added deployment_type column")
cursor.execute("CREATE INDEX IF NOT EXISTS ix_roster_deployment_type ON roster(deployment_type)")
print(" Created index on deployment_type")
else:
print("deployment_type column already exists")
# Add deployed_with_unit_id if not exists
if 'deployed_with_unit_id' not in columns:
print("Adding deployed_with_unit_id column to roster table...")
cursor.execute("ALTER TABLE roster ADD COLUMN deployed_with_unit_id TEXT")
print(" Added deployed_with_unit_id column")
cursor.execute("CREATE INDEX IF NOT EXISTS ix_roster_deployed_with_unit_id ON roster(deployed_with_unit_id)")
print(" Created index on deployed_with_unit_id")
else:
print("deployed_with_unit_id column already exists")
conn.commit()
print("\nMigration completed successfully!")
except sqlite3.Error as e:
print(f"\nError during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
migrate_database()

View File

@@ -0,0 +1,80 @@
"""
Migration script to add project_number field to projects table.
This adds a new column for TMI internal project numbering:
- Format: xxxx-YY (e.g., "2567-23")
- xxxx = incremental project number
- YY = year project was started
Combined with client_name and name (project/site name), this enables
smart searching across all project identifiers.
Run this script once to migrate an existing database.
"""
import sqlite3
import os
# Database path
DB_PATH = "./data/seismo_fleet.db"
def migrate_database():
"""Add project_number column to projects table"""
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
print("The database will be created automatically when you run the application.")
return
print(f"Migrating database: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Check if projects table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
table_exists = cursor.fetchone()
if not table_exists:
print("Projects table does not exist yet - will be created when app runs")
conn.close()
return
# Check if project_number column already exists
cursor.execute("PRAGMA table_info(projects)")
columns = [col[1] for col in cursor.fetchall()]
if 'project_number' in columns:
print("Migration already applied - project_number column exists")
conn.close()
return
print("Adding project_number column to projects table...")
try:
cursor.execute("ALTER TABLE projects ADD COLUMN project_number TEXT")
print(" Added project_number column")
# Create index for faster searching
cursor.execute("CREATE INDEX IF NOT EXISTS ix_projects_project_number ON projects(project_number)")
print(" Created index on project_number")
# Also add index on client_name if it doesn't exist
cursor.execute("CREATE INDEX IF NOT EXISTS ix_projects_client_name ON projects(client_name)")
print(" Created index on client_name")
conn.commit()
print("\nMigration completed successfully!")
except sqlite3.Error as e:
print(f"\nError during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
migrate_database()

View File

@@ -0,0 +1,88 @@
"""
Migration script to add report_templates table.
This creates a new table for storing report generation configurations:
- Template name and project association
- Time filtering settings (start/end time)
- Date range filtering (optional)
- Report title defaults
Run this script once to migrate an existing database.
"""
import sqlite3
import os
# Database path
DB_PATH = "./data/seismo_fleet.db"
def migrate_database():
"""Create report_templates table"""
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
print("The database will be created automatically when you run the application.")
return
print(f"Migrating database: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Check if report_templates table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='report_templates'")
table_exists = cursor.fetchone()
if table_exists:
print("Migration already applied - report_templates table exists")
conn.close()
return
print("Creating report_templates table...")
try:
cursor.execute("""
CREATE TABLE report_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_id TEXT,
report_title TEXT DEFAULT 'Background Noise Study',
start_time TEXT,
end_time TEXT,
start_date TEXT,
end_date TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
print(" ✓ Created report_templates table")
# Insert default templates
import uuid
default_templates = [
(str(uuid.uuid4()), "Nighttime (7PM-7AM)", None, "Background Noise Study", "19:00", "07:00", None, None),
(str(uuid.uuid4()), "Daytime (7AM-7PM)", None, "Background Noise Study", "07:00", "19:00", None, None),
(str(uuid.uuid4()), "Full Day (All Data)", None, "Background Noise Study", None, None, None, None),
]
cursor.executemany("""
INSERT INTO report_templates (id, name, project_id, report_title, start_time, end_time, start_date, end_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", default_templates)
print(" ✓ Inserted default templates (Nighttime, Daytime, Full Day)")
conn.commit()
print("\nMigration completed successfully!")
except sqlite3.Error as e:
print(f"\nError during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
migrate_database()

View File

@@ -50,6 +50,8 @@ class RosterUnit(Base):
ip_address = Column(String, nullable=True)
phone_number = Column(String, nullable=True)
hardware_model = Column(String, nullable=True)
deployment_type = Column(String, nullable=True) # "seismograph" | "slm" - what type of device this modem is deployed with
deployed_with_unit_id = Column(String, nullable=True) # ID of seismograph/SLM this modem is deployed with
# Sound Level Meter-specific fields (nullable for seismographs and modems)
slm_host = Column(String, nullable=True) # Device IP or hostname
@@ -137,17 +139,26 @@ class Project(Base):
"""
Projects: top-level organization for monitoring work.
Type-aware to enable/disable features based on project_type_id.
Project naming convention:
- project_number: TMI internal ID format xxxx-YY (e.g., "2567-23")
- client_name: Client/contractor name (e.g., "PJ Dick")
- name: Project/site name (e.g., "RKM Hall", "CMU Campus")
Display format: "2567-23 - PJ Dick - RKM Hall"
Users can search by any of these fields.
"""
__tablename__ = "projects"
id = Column(String, primary_key=True, index=True) # UUID
name = Column(String, nullable=False, unique=True)
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
description = Column(Text, nullable=True)
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
status = Column(String, default="active") # active, completed, archived
# Project metadata
client_name = Column(String, nullable=True)
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
site_address = Column(String, nullable=True)
site_coordinates = Column(String, nullable=True) # "lat,lon"
start_date = Column(Date, nullable=True)
@@ -278,3 +289,116 @@ class DataFile(Base):
file_metadata = Column(Text, nullable=True) # JSON
created_at = Column(DateTime, default=datetime.utcnow)
class ReportTemplate(Base):
"""
Report templates: saved configurations for generating Excel reports.
Allows users to save time filter presets, titles, etc. for reuse.
"""
__tablename__ = "report_templates"
id = Column(String, primary_key=True, index=True) # UUID
name = Column(String, nullable=False) # "Nighttime Report", "Full Day Report"
project_id = Column(String, nullable=True) # Optional: project-specific template
# Template settings
report_title = Column(String, default="Background Noise Study")
start_time = Column(String, nullable=True) # "19:00" format
end_time = Column(String, nullable=True) # "07:00" format
start_date = Column(String, nullable=True) # "2025-01-15" format (optional)
end_date = Column(String, nullable=True) # "2025-01-20" format (optional)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# ============================================================================
# Sound Monitoring Scheduler
# ============================================================================
class RecurringSchedule(Base):
"""
Recurring schedule definitions for automated sound monitoring.
Supports two schedule types:
- "weekly_calendar": Select specific days with start/end times (e.g., Mon/Wed/Fri 7pm-7am)
- "simple_interval": For 24/7 monitoring with daily stop/download/restart cycles
"""
__tablename__ = "recurring_schedules"
id = Column(String, primary_key=True, index=True) # UUID
project_id = Column(String, nullable=False, index=True) # FK to Project.id
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (optional, can use assignment)
name = Column(String, nullable=False) # "Weeknight Monitoring", "24/7 Continuous"
schedule_type = Column(String, nullable=False) # "weekly_calendar" | "simple_interval"
device_type = Column(String, nullable=False) # "slm" | "seismograph"
# Weekly Calendar fields (schedule_type = "weekly_calendar")
# JSON format: {
# "monday": {"enabled": true, "start": "19:00", "end": "07:00"},
# "tuesday": {"enabled": false},
# ...
# }
weekly_pattern = Column(Text, nullable=True)
# Simple Interval fields (schedule_type = "simple_interval")
interval_type = Column(String, nullable=True) # "daily" | "hourly"
cycle_time = Column(String, nullable=True) # "00:00" - time to run stop/download/restart
include_download = Column(Boolean, default=True) # Download data before restart
# Automation options (applies to both schedule types)
auto_increment_index = Column(Boolean, default=True) # Auto-increment store/index number before start
# When True: prevents "overwrite data?" prompts by using a new index each time
# Shared configuration
enabled = Column(Boolean, default=True)
timezone = Column(String, default="America/New_York")
# Tracking
last_generated_at = Column(DateTime, nullable=True) # When actions were last generated
next_occurrence = Column(DateTime, nullable=True) # Computed next action time
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class Alert(Base):
"""
In-app alerts for device status changes and system events.
Designed for future expansion to email/webhook notifications.
Currently supports:
- device_offline: Device became unreachable
- device_online: Device came back online
- schedule_failed: Scheduled action failed to execute
- schedule_completed: Scheduled action completed successfully
"""
__tablename__ = "alerts"
id = Column(String, primary_key=True, index=True) # UUID
# Alert classification
alert_type = Column(String, nullable=False) # "device_offline" | "device_online" | "schedule_failed" | "schedule_completed"
severity = Column(String, default="warning") # "info" | "warning" | "critical"
# Related entities (nullable - may not all apply)
project_id = Column(String, nullable=True, index=True)
location_id = Column(String, nullable=True, index=True)
unit_id = Column(String, nullable=True, index=True)
schedule_id = Column(String, nullable=True) # RecurringSchedule or ScheduledAction id
# Alert content
title = Column(String, nullable=False) # "NRL-001 Device Offline"
message = Column(Text, nullable=True) # Detailed description
alert_metadata = Column(Text, nullable=True) # JSON: additional context data
# Status tracking
status = Column(String, default="active") # "active" | "acknowledged" | "resolved" | "dismissed"
acknowledged_at = Column(DateTime, nullable=True)
resolved_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time

326
backend/routers/alerts.py Normal file
View File

@@ -0,0 +1,326 @@
"""
Alerts Router
API endpoints for managing in-app alerts.
"""
from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime, timedelta
from backend.database import get_db
from backend.models import Alert, RosterUnit
from backend.services.alert_service import get_alert_service
from backend.templates_config import templates
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
# ============================================================================
# Alert List and Count
# ============================================================================
@router.get("/")
async def list_alerts(
db: Session = Depends(get_db),
status: Optional[str] = Query(None, description="Filter by status: active, acknowledged, resolved, dismissed"),
project_id: Optional[str] = Query(None),
unit_id: Optional[str] = Query(None),
alert_type: Optional[str] = Query(None, description="Filter by type: device_offline, device_online, schedule_failed"),
limit: int = Query(50, le=100),
offset: int = Query(0, ge=0),
):
"""
List alerts with optional filters.
"""
alert_service = get_alert_service(db)
alerts = alert_service.get_all_alerts(
status=status,
project_id=project_id,
unit_id=unit_id,
alert_type=alert_type,
limit=limit,
offset=offset,
)
return {
"alerts": [
{
"id": a.id,
"alert_type": a.alert_type,
"severity": a.severity,
"title": a.title,
"message": a.message,
"status": a.status,
"unit_id": a.unit_id,
"project_id": a.project_id,
"location_id": a.location_id,
"created_at": a.created_at.isoformat() if a.created_at else None,
"acknowledged_at": a.acknowledged_at.isoformat() if a.acknowledged_at else None,
"resolved_at": a.resolved_at.isoformat() if a.resolved_at else None,
}
for a in alerts
],
"count": len(alerts),
"limit": limit,
"offset": offset,
}
@router.get("/active")
async def list_active_alerts(
db: Session = Depends(get_db),
project_id: Optional[str] = Query(None),
unit_id: Optional[str] = Query(None),
alert_type: Optional[str] = Query(None),
min_severity: Optional[str] = Query(None, description="Minimum severity: info, warning, critical"),
limit: int = Query(50, le=100),
):
"""
List only active alerts.
"""
alert_service = get_alert_service(db)
alerts = alert_service.get_active_alerts(
project_id=project_id,
unit_id=unit_id,
alert_type=alert_type,
min_severity=min_severity,
limit=limit,
)
return {
"alerts": [
{
"id": a.id,
"alert_type": a.alert_type,
"severity": a.severity,
"title": a.title,
"message": a.message,
"unit_id": a.unit_id,
"project_id": a.project_id,
"created_at": a.created_at.isoformat() if a.created_at else None,
}
for a in alerts
],
"count": len(alerts),
}
@router.get("/active/count")
async def get_active_alert_count(db: Session = Depends(get_db)):
"""
Get count of active alerts (for navbar badge).
"""
alert_service = get_alert_service(db)
count = alert_service.get_active_alert_count()
return {"count": count}
# ============================================================================
# Single Alert Operations
# ============================================================================
@router.get("/{alert_id}")
async def get_alert(
alert_id: str,
db: Session = Depends(get_db),
):
"""
Get a specific alert.
"""
alert = db.query(Alert).filter_by(id=alert_id).first()
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
# Get related unit info
unit = None
if alert.unit_id:
unit = db.query(RosterUnit).filter_by(id=alert.unit_id).first()
return {
"id": alert.id,
"alert_type": alert.alert_type,
"severity": alert.severity,
"title": alert.title,
"message": alert.message,
"metadata": alert.alert_metadata,
"status": alert.status,
"unit_id": alert.unit_id,
"unit_name": unit.id if unit else None,
"project_id": alert.project_id,
"location_id": alert.location_id,
"schedule_id": alert.schedule_id,
"created_at": alert.created_at.isoformat() if alert.created_at else None,
"acknowledged_at": alert.acknowledged_at.isoformat() if alert.acknowledged_at else None,
"resolved_at": alert.resolved_at.isoformat() if alert.resolved_at else None,
"expires_at": alert.expires_at.isoformat() if alert.expires_at else None,
}
@router.post("/{alert_id}/acknowledge")
async def acknowledge_alert(
alert_id: str,
db: Session = Depends(get_db),
):
"""
Mark alert as acknowledged.
"""
alert_service = get_alert_service(db)
alert = alert_service.acknowledge_alert(alert_id)
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
return {
"success": True,
"alert_id": alert.id,
"status": alert.status,
}
@router.post("/{alert_id}/dismiss")
async def dismiss_alert(
alert_id: str,
db: Session = Depends(get_db),
):
"""
Dismiss alert.
"""
alert_service = get_alert_service(db)
alert = alert_service.dismiss_alert(alert_id)
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
return {
"success": True,
"alert_id": alert.id,
"status": alert.status,
}
@router.post("/{alert_id}/resolve")
async def resolve_alert(
alert_id: str,
db: Session = Depends(get_db),
):
"""
Manually resolve an alert.
"""
alert_service = get_alert_service(db)
alert = alert_service.resolve_alert(alert_id)
if not alert:
raise HTTPException(status_code=404, detail="Alert not found")
return {
"success": True,
"alert_id": alert.id,
"status": alert.status,
}
# ============================================================================
# HTML Partials for HTMX
# ============================================================================
@router.get("/partials/dropdown", response_class=HTMLResponse)
async def get_alert_dropdown(
request: Request,
db: Session = Depends(get_db),
):
"""
Return HTML partial for alert dropdown in navbar.
"""
alert_service = get_alert_service(db)
alerts = alert_service.get_active_alerts(limit=10)
# Calculate relative time for each alert
now = datetime.utcnow()
alerts_data = []
for alert in alerts:
delta = now - alert.created_at
if delta.days > 0:
time_ago = f"{delta.days}d ago"
elif delta.seconds >= 3600:
time_ago = f"{delta.seconds // 3600}h ago"
elif delta.seconds >= 60:
time_ago = f"{delta.seconds // 60}m ago"
else:
time_ago = "just now"
alerts_data.append({
"alert": alert,
"time_ago": time_ago,
})
return templates.TemplateResponse("partials/alerts/alert_dropdown.html", {
"request": request,
"alerts": alerts_data,
"total_count": alert_service.get_active_alert_count(),
})
@router.get("/partials/list", response_class=HTMLResponse)
async def get_alert_list(
request: Request,
db: Session = Depends(get_db),
status: Optional[str] = Query(None),
limit: int = Query(20),
):
"""
Return HTML partial for alert list page.
"""
alert_service = get_alert_service(db)
if status:
alerts = alert_service.get_all_alerts(status=status, limit=limit)
else:
alerts = alert_service.get_all_alerts(limit=limit)
# Calculate relative time for each alert
now = datetime.utcnow()
alerts_data = []
for alert in alerts:
delta = now - alert.created_at
if delta.days > 0:
time_ago = f"{delta.days}d ago"
elif delta.seconds >= 3600:
time_ago = f"{delta.seconds // 3600}h ago"
elif delta.seconds >= 60:
time_ago = f"{delta.seconds // 60}m ago"
else:
time_ago = "just now"
alerts_data.append({
"alert": alert,
"time_ago": time_ago,
})
return templates.TemplateResponse("partials/alerts/alert_list.html", {
"request": request,
"alerts": alerts_data,
"status_filter": status,
})
# ============================================================================
# Cleanup
# ============================================================================
@router.post("/cleanup-expired")
async def cleanup_expired_alerts(db: Session = Depends(get_db)):
"""
Cleanup expired alerts (admin/maintenance endpoint).
"""
alert_service = get_alert_service(db)
count = alert_service.cleanup_expired_alerts()
return {
"success": True,
"cleaned_up": count,
}

View File

@@ -1,10 +1,14 @@
from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from backend.database import get_db
from backend.models import ScheduledAction, MonitoringLocation, Project
from backend.services.snapshot import emit_status_snapshot
from backend.templates_config import templates
from backend.utils.timezone import utc_to_local, local_to_utc, get_user_timezone
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/dashboard/active")
@@ -23,3 +27,71 @@ def dashboard_benched(request: Request):
"partials/benched_table.html",
{"request": request, "units": snapshot["benched"]}
)
@router.get("/dashboard/todays-actions")
def dashboard_todays_actions(request: Request, db: Session = Depends(get_db)):
"""
Get today's scheduled actions for the dashboard card.
Shows upcoming, completed, and failed actions for today.
"""
import json
from zoneinfo import ZoneInfo
# Get today's date range in local timezone
tz = ZoneInfo(get_user_timezone())
now_local = datetime.now(tz)
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
today_end_local = today_start_local + timedelta(days=1)
# Convert to UTC for database query
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
# Query today's actions
actions = db.query(ScheduledAction).filter(
ScheduledAction.scheduled_time >= today_start_utc,
ScheduledAction.scheduled_time < today_end_utc,
).order_by(ScheduledAction.scheduled_time.asc()).all()
# Enrich with location/project info and parse results
enriched_actions = []
for action in actions:
location = None
project = None
if action.location_id:
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
if action.project_id:
project = db.query(Project).filter_by(id=action.project_id).first()
# Parse module_response for result details
result_data = None
if action.module_response:
try:
result_data = json.loads(action.module_response)
except json.JSONDecodeError:
pass
enriched_actions.append({
"action": action,
"location": location,
"project": project,
"result": result_data,
})
# Count by status
pending_count = sum(1 for a in actions if a.execution_status == "pending")
completed_count = sum(1 for a in actions if a.execution_status == "completed")
failed_count = sum(1 for a in actions if a.execution_status == "failed")
return templates.TemplateResponse(
"partials/dashboard/todays_actions.html",
{
"request": request,
"actions": enriched_actions,
"pending_count": pending_count,
"completed_count": completed_count,
"failed_count": failed_count,
"total_count": len(actions),
}
)

View File

@@ -0,0 +1,286 @@
"""
Modem Dashboard Router
Provides API endpoints for the Field Modems management page.
"""
from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from datetime import datetime
import subprocess
import time
import logging
from backend.database import get_db
from backend.models import RosterUnit
from backend.templates_config import templates
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/modem-dashboard", tags=["modem-dashboard"])
@router.get("/stats", response_class=HTMLResponse)
async def get_modem_stats(request: Request, db: Session = Depends(get_db)):
"""
Get summary statistics for modem dashboard.
Returns HTML partial with stat cards.
"""
# Query all modems
all_modems = db.query(RosterUnit).filter_by(device_type="modem").all()
# Get IDs of modems that have devices paired to them
paired_modem_ids = set()
devices_with_modems = db.query(RosterUnit).filter(
RosterUnit.deployed_with_modem_id.isnot(None),
RosterUnit.retired == False
).all()
for device in devices_with_modems:
if device.deployed_with_modem_id:
paired_modem_ids.add(device.deployed_with_modem_id)
# Count categories
total_count = len(all_modems)
retired_count = sum(1 for m in all_modems if m.retired)
# In use = deployed AND paired with a device
in_use_count = sum(1 for m in all_modems
if m.deployed and not m.retired and m.id in paired_modem_ids)
# Spare = deployed but NOT paired (available for assignment)
spare_count = sum(1 for m in all_modems
if m.deployed and not m.retired and m.id not in paired_modem_ids)
# Benched = not deployed and not retired
benched_count = sum(1 for m in all_modems if not m.deployed and not m.retired)
return templates.TemplateResponse("partials/modem_stats.html", {
"request": request,
"total_count": total_count,
"in_use_count": in_use_count,
"spare_count": spare_count,
"benched_count": benched_count,
"retired_count": retired_count
})
@router.get("/units", response_class=HTMLResponse)
async def get_modem_units(
request: Request,
db: Session = Depends(get_db),
search: str = Query(None),
filter_status: str = Query(None), # "in_use", "spare", "benched", "retired"
):
"""
Get list of modem units for the dashboard.
Returns HTML partial with modem cards.
"""
query = db.query(RosterUnit).filter_by(device_type="modem")
# Filter by search term if provided
if search:
search_term = f"%{search}%"
query = query.filter(
(RosterUnit.id.ilike(search_term)) |
(RosterUnit.ip_address.ilike(search_term)) |
(RosterUnit.hardware_model.ilike(search_term)) |
(RosterUnit.phone_number.ilike(search_term)) |
(RosterUnit.location.ilike(search_term))
)
modems = query.order_by(
RosterUnit.retired.asc(),
RosterUnit.deployed.desc(),
RosterUnit.id.asc()
).all()
# Get paired device info for each modem
paired_devices = {}
devices_with_modems = db.query(RosterUnit).filter(
RosterUnit.deployed_with_modem_id.isnot(None),
RosterUnit.retired == False
).all()
for device in devices_with_modems:
if device.deployed_with_modem_id:
paired_devices[device.deployed_with_modem_id] = {
"id": device.id,
"device_type": device.device_type,
"deployed": device.deployed
}
# Annotate modems with paired device info
modem_list = []
for modem in modems:
paired = paired_devices.get(modem.id)
# Determine status category
if modem.retired:
status = "retired"
elif not modem.deployed:
status = "benched"
elif paired:
status = "in_use"
else:
status = "spare"
# Apply filter if specified
if filter_status and status != filter_status:
continue
modem_list.append({
"id": modem.id,
"ip_address": modem.ip_address,
"phone_number": modem.phone_number,
"hardware_model": modem.hardware_model,
"deployed": modem.deployed,
"retired": modem.retired,
"location": modem.location,
"project_id": modem.project_id,
"paired_device": paired,
"status": status
})
return templates.TemplateResponse("partials/modem_list.html", {
"request": request,
"modems": modem_list
})
@router.get("/{modem_id}/paired-device")
async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
"""
Get the device (SLM/seismograph) that is paired with this modem.
Returns JSON with device info or null if not paired.
"""
# Check modem exists
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
return {"status": "error", "detail": f"Modem {modem_id} not found"}
# Find device paired with this modem
device = db.query(RosterUnit).filter(
RosterUnit.deployed_with_modem_id == modem_id,
RosterUnit.retired == False
).first()
if device:
return {
"paired": True,
"device": {
"id": device.id,
"device_type": device.device_type,
"deployed": device.deployed,
"project_id": device.project_id,
"location": device.location or device.address
}
}
return {"paired": False, "device": None}
@router.get("/{modem_id}/paired-device-html", response_class=HTMLResponse)
async def get_paired_device_html(modem_id: str, request: Request, db: Session = Depends(get_db)):
"""
Get HTML partial showing the device paired with this modem.
Used by unit_detail.html for modems.
"""
# Check modem exists
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
return HTMLResponse('<p class="text-red-500">Modem not found</p>')
# Find device paired with this modem
device = db.query(RosterUnit).filter(
RosterUnit.deployed_with_modem_id == modem_id,
RosterUnit.retired == False
).first()
return templates.TemplateResponse("partials/modem_paired_device.html", {
"request": request,
"modem_id": modem_id,
"device": device
})
@router.get("/{modem_id}/ping")
async def ping_modem(modem_id: str, db: Session = Depends(get_db)):
"""
Test modem connectivity with a simple ping.
Returns response time and connection status.
"""
# Get modem from database
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
return {"status": "error", "detail": f"Modem {modem_id} not found"}
if not modem.ip_address:
return {"status": "error", "detail": f"Modem {modem_id} has no IP address configured"}
try:
# Ping the modem (1 packet, 2 second timeout)
start_time = time.time()
result = subprocess.run(
["ping", "-c", "1", "-W", "2", modem.ip_address],
capture_output=True,
text=True,
timeout=3
)
response_time = int((time.time() - start_time) * 1000) # Convert to milliseconds
if result.returncode == 0:
return {
"status": "success",
"modem_id": modem_id,
"ip_address": modem.ip_address,
"response_time_ms": response_time,
"message": "Modem is responding"
}
else:
return {
"status": "error",
"modem_id": modem_id,
"ip_address": modem.ip_address,
"detail": "Modem not responding to ping"
}
except subprocess.TimeoutExpired:
return {
"status": "error",
"modem_id": modem_id,
"ip_address": modem.ip_address,
"detail": "Ping timeout"
}
except Exception as e:
logger.error(f"Failed to ping modem {modem_id}: {e}")
return {
"status": "error",
"modem_id": modem_id,
"detail": str(e)
}
@router.get("/{modem_id}/diagnostics")
async def get_modem_diagnostics(modem_id: str, db: Session = Depends(get_db)):
"""
Get modem diagnostics (signal strength, data usage, uptime).
Currently returns placeholders. When ModemManager is available,
this endpoint will query it for real diagnostics.
"""
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
if not modem:
return {"status": "error", "detail": f"Modem {modem_id} not found"}
# TODO: Query ModemManager backend when available
return {
"status": "unavailable",
"message": "ModemManager integration not yet available",
"modem_id": modem_id,
"signal_strength_dbm": None,
"data_usage_mb": None,
"uptime_seconds": None,
"carrier": None,
"connection_type": None # LTE, 5G, etc.
}

View File

@@ -6,7 +6,6 @@ and unit assignments within projects.
"""
from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
@@ -24,9 +23,9 @@ from backend.models import (
RosterUnit,
RecordingSession,
)
from backend.templates_config import templates
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
templates = Jinja2Templates(directory="templates")
# ============================================================================
@@ -90,6 +89,40 @@ async def get_project_locations(
})
@router.get("/locations-json")
async def get_project_locations_json(
project_id: str,
db: Session = Depends(get_db),
location_type: Optional[str] = Query(None),
):
"""
Get all monitoring locations for a project as JSON.
Used by the schedule modal to populate location dropdown.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
query = db.query(MonitoringLocation).filter_by(project_id=project_id)
if location_type:
query = query.filter_by(location_type=location_type)
locations = query.order_by(MonitoringLocation.name).all()
return [
{
"id": loc.id,
"name": loc.name,
"location_type": loc.location_type,
"description": loc.description,
"address": loc.address,
"coordinates": loc.coordinates,
}
for loc in locations
]
@router.post("/locations/create")
async def create_location(
project_id: str,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,465 @@
"""
Recurring Schedules Router
API endpoints for managing recurring monitoring schedules.
"""
from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
import json
from backend.database import get_db
from backend.models import RecurringSchedule, MonitoringLocation, Project, RosterUnit
from backend.services.recurring_schedule_service import get_recurring_schedule_service
from backend.templates_config import templates
router = APIRouter(prefix="/api/projects/{project_id}/recurring-schedules", tags=["recurring-schedules"])
# ============================================================================
# List and Get
# ============================================================================
@router.get("/")
async def list_recurring_schedules(
project_id: str,
db: Session = Depends(get_db),
enabled_only: bool = Query(False),
):
"""
List all recurring schedules for a project.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
query = db.query(RecurringSchedule).filter_by(project_id=project_id)
if enabled_only:
query = query.filter_by(enabled=True)
schedules = query.order_by(RecurringSchedule.created_at.desc()).all()
return {
"schedules": [
{
"id": s.id,
"name": s.name,
"schedule_type": s.schedule_type,
"device_type": s.device_type,
"location_id": s.location_id,
"unit_id": s.unit_id,
"enabled": s.enabled,
"weekly_pattern": json.loads(s.weekly_pattern) if s.weekly_pattern else None,
"interval_type": s.interval_type,
"cycle_time": s.cycle_time,
"include_download": s.include_download,
"timezone": s.timezone,
"next_occurrence": s.next_occurrence.isoformat() if s.next_occurrence else None,
"last_generated_at": s.last_generated_at.isoformat() if s.last_generated_at else None,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
for s in schedules
],
"count": len(schedules),
}
@router.get("/{schedule_id}")
async def get_recurring_schedule(
project_id: str,
schedule_id: str,
db: Session = Depends(get_db),
):
"""
Get a specific recurring schedule.
"""
schedule = db.query(RecurringSchedule).filter_by(
id=schedule_id,
project_id=project_id,
).first()
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
# Get related location and unit info
location = db.query(MonitoringLocation).filter_by(id=schedule.location_id).first()
unit = None
if schedule.unit_id:
unit = db.query(RosterUnit).filter_by(id=schedule.unit_id).first()
return {
"id": schedule.id,
"name": schedule.name,
"schedule_type": schedule.schedule_type,
"device_type": schedule.device_type,
"location_id": schedule.location_id,
"location_name": location.name if location else None,
"unit_id": schedule.unit_id,
"unit_name": unit.id if unit else None,
"enabled": schedule.enabled,
"weekly_pattern": json.loads(schedule.weekly_pattern) if schedule.weekly_pattern else None,
"interval_type": schedule.interval_type,
"cycle_time": schedule.cycle_time,
"include_download": schedule.include_download,
"timezone": schedule.timezone,
"next_occurrence": schedule.next_occurrence.isoformat() if schedule.next_occurrence else None,
"last_generated_at": schedule.last_generated_at.isoformat() if schedule.last_generated_at else None,
"created_at": schedule.created_at.isoformat() if schedule.created_at else None,
"updated_at": schedule.updated_at.isoformat() if schedule.updated_at else None,
}
# ============================================================================
# Create
# ============================================================================
@router.post("/")
async def create_recurring_schedule(
project_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Create recurring schedules for one or more locations.
Body for weekly_calendar (supports multiple locations):
{
"name": "Weeknight Monitoring",
"schedule_type": "weekly_calendar",
"location_ids": ["uuid1", "uuid2"], // Array of location IDs
"weekly_pattern": {
"monday": {"enabled": true, "start": "19:00", "end": "07:00"},
"tuesday": {"enabled": false},
...
},
"include_download": true,
"auto_increment_index": true,
"timezone": "America/New_York"
}
Body for simple_interval (supports multiple locations):
{
"name": "24/7 Continuous",
"schedule_type": "simple_interval",
"location_ids": ["uuid1", "uuid2"], // Array of location IDs
"interval_type": "daily",
"cycle_time": "00:00",
"include_download": true,
"auto_increment_index": true,
"timezone": "America/New_York"
}
Legacy single location support (backwards compatible):
{
"name": "...",
"location_id": "uuid", // Single location ID
...
}
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
data = await request.json()
# Support both location_ids (array) and location_id (single) for backwards compatibility
location_ids = data.get("location_ids", [])
if not location_ids and data.get("location_id"):
location_ids = [data.get("location_id")]
if not location_ids:
raise HTTPException(status_code=400, detail="At least one location is required")
# Validate all locations exist
locations = db.query(MonitoringLocation).filter(
MonitoringLocation.id.in_(location_ids),
MonitoringLocation.project_id == project_id,
).all()
if len(locations) != len(location_ids):
raise HTTPException(status_code=404, detail="One or more locations not found")
service = get_recurring_schedule_service(db)
created_schedules = []
base_name = data.get("name", "Unnamed Schedule")
# Create a schedule for each location
for location in locations:
# Determine device type from location
device_type = "slm" if location.location_type == "sound" else "seismograph"
# Append location name if multiple locations
schedule_name = f"{base_name} - {location.name}" if len(locations) > 1 else base_name
schedule = service.create_schedule(
project_id=project_id,
location_id=location.id,
name=schedule_name,
schedule_type=data.get("schedule_type", "weekly_calendar"),
device_type=device_type,
unit_id=data.get("unit_id"),
weekly_pattern=data.get("weekly_pattern"),
interval_type=data.get("interval_type"),
cycle_time=data.get("cycle_time"),
include_download=data.get("include_download", True),
auto_increment_index=data.get("auto_increment_index", True),
timezone=data.get("timezone", "America/New_York"),
)
# Generate actions immediately so they appear right away
generated_actions = service.generate_actions_for_schedule(schedule, horizon_days=7)
created_schedules.append({
"schedule_id": schedule.id,
"location_id": location.id,
"location_name": location.name,
"actions_generated": len(generated_actions),
})
total_actions = sum(s.get("actions_generated", 0) for s in created_schedules)
return JSONResponse({
"success": True,
"schedules": created_schedules,
"count": len(created_schedules),
"actions_generated": total_actions,
"message": f"Created {len(created_schedules)} recurring schedule(s) with {total_actions} upcoming actions",
})
# ============================================================================
# Update
# ============================================================================
@router.put("/{schedule_id}")
async def update_recurring_schedule(
project_id: str,
schedule_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Update a recurring schedule.
"""
schedule = db.query(RecurringSchedule).filter_by(
id=schedule_id,
project_id=project_id,
).first()
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
data = await request.json()
service = get_recurring_schedule_service(db)
# Build update kwargs
update_kwargs = {}
for field in ["name", "weekly_pattern", "interval_type", "cycle_time",
"include_download", "auto_increment_index", "timezone", "unit_id"]:
if field in data:
update_kwargs[field] = data[field]
updated = service.update_schedule(schedule_id, **update_kwargs)
return {
"success": True,
"schedule_id": updated.id,
"message": "Schedule updated successfully",
}
# ============================================================================
# Delete
# ============================================================================
@router.delete("/{schedule_id}")
async def delete_recurring_schedule(
project_id: str,
schedule_id: str,
db: Session = Depends(get_db),
):
"""
Delete a recurring schedule.
"""
service = get_recurring_schedule_service(db)
deleted = service.delete_schedule(schedule_id)
if not deleted:
raise HTTPException(status_code=404, detail="Schedule not found")
return {
"success": True,
"message": "Schedule deleted successfully",
}
# ============================================================================
# Enable/Disable
# ============================================================================
@router.post("/{schedule_id}/enable")
async def enable_schedule(
project_id: str,
schedule_id: str,
db: Session = Depends(get_db),
):
"""
Enable a disabled schedule.
"""
service = get_recurring_schedule_service(db)
schedule = service.enable_schedule(schedule_id)
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
return {
"success": True,
"schedule_id": schedule.id,
"enabled": schedule.enabled,
"message": "Schedule enabled",
}
@router.post("/{schedule_id}/disable")
async def disable_schedule(
project_id: str,
schedule_id: str,
db: Session = Depends(get_db),
):
"""
Disable a schedule.
"""
service = get_recurring_schedule_service(db)
schedule = service.disable_schedule(schedule_id)
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
return {
"success": True,
"schedule_id": schedule.id,
"enabled": schedule.enabled,
"message": "Schedule disabled",
}
# ============================================================================
# Preview Generated Actions
# ============================================================================
@router.post("/{schedule_id}/generate-preview")
async def preview_generated_actions(
project_id: str,
schedule_id: str,
db: Session = Depends(get_db),
days: int = Query(7, ge=1, le=30),
):
"""
Preview what actions would be generated without saving them.
"""
schedule = db.query(RecurringSchedule).filter_by(
id=schedule_id,
project_id=project_id,
).first()
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
service = get_recurring_schedule_service(db)
actions = service.generate_actions_for_schedule(
schedule,
horizon_days=days,
preview_only=True,
)
return {
"schedule_id": schedule_id,
"schedule_name": schedule.name,
"preview_days": days,
"actions": [
{
"action_type": a.action_type,
"scheduled_time": a.scheduled_time.isoformat(),
"notes": a.notes,
}
for a in actions
],
"action_count": len(actions),
}
# ============================================================================
# Manual Generation Trigger
# ============================================================================
@router.post("/{schedule_id}/generate")
async def generate_actions_now(
project_id: str,
schedule_id: str,
db: Session = Depends(get_db),
days: int = Query(7, ge=1, le=30),
):
"""
Manually trigger action generation for a schedule.
"""
schedule = db.query(RecurringSchedule).filter_by(
id=schedule_id,
project_id=project_id,
).first()
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
if not schedule.enabled:
raise HTTPException(status_code=400, detail="Schedule is disabled")
service = get_recurring_schedule_service(db)
actions = service.generate_actions_for_schedule(
schedule,
horizon_days=days,
preview_only=False,
)
return {
"success": True,
"schedule_id": schedule_id,
"generated_count": len(actions),
"message": f"Generated {len(actions)} scheduled actions",
}
# ============================================================================
# HTML Partials
# ============================================================================
@router.get("/partials/list", response_class=HTMLResponse)
async def get_schedule_list_partial(
project_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Return HTML partial for schedule list.
"""
schedules = db.query(RecurringSchedule).filter_by(
project_id=project_id
).order_by(RecurringSchedule.created_at.desc()).all()
# Enrich with location info
schedule_data = []
for s in schedules:
location = db.query(MonitoringLocation).filter_by(id=s.location_id).first()
schedule_data.append({
"schedule": s,
"location": location,
"pattern": json.loads(s.weekly_pattern) if s.weekly_pattern else None,
})
return templates.TemplateResponse("partials/projects/recurring_schedule_list.html", {
"request": request,
"project_id": project_id,
"schedules": schedule_data,
})

View File

@@ -0,0 +1,187 @@
"""
Report Templates Router
CRUD operations for report template management.
Templates store time filter presets and report configuration for reuse.
"""
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from datetime import datetime
from typing import Optional
import uuid
from backend.database import get_db
from backend.models import ReportTemplate
router = APIRouter(prefix="/api/report-templates", tags=["report-templates"])
@router.get("")
async def list_templates(
project_id: Optional[str] = None,
db: Session = Depends(get_db),
):
"""
List all report templates.
Optionally filter by project_id (includes global templates with project_id=None).
"""
query = db.query(ReportTemplate)
if project_id:
# Include global templates (project_id=None) AND project-specific templates
query = query.filter(
(ReportTemplate.project_id == None) | (ReportTemplate.project_id == project_id)
)
templates = query.order_by(ReportTemplate.name).all()
return [
{
"id": t.id,
"name": t.name,
"project_id": t.project_id,
"report_title": t.report_title,
"start_time": t.start_time,
"end_time": t.end_time,
"start_date": t.start_date,
"end_date": t.end_date,
"created_at": t.created_at.isoformat() if t.created_at else None,
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
}
for t in templates
]
@router.post("")
async def create_template(
data: dict,
db: Session = Depends(get_db),
):
"""
Create a new report template.
Request body:
- name: Template name (required)
- project_id: Optional project ID for project-specific template
- report_title: Default report title
- start_time: Start time filter (HH:MM format)
- end_time: End time filter (HH:MM format)
- start_date: Start date filter (YYYY-MM-DD format)
- end_date: End date filter (YYYY-MM-DD format)
"""
name = data.get("name")
if not name:
raise HTTPException(status_code=400, detail="Template name is required")
template = ReportTemplate(
id=str(uuid.uuid4()),
name=name,
project_id=data.get("project_id"),
report_title=data.get("report_title", "Background Noise Study"),
start_time=data.get("start_time"),
end_time=data.get("end_time"),
start_date=data.get("start_date"),
end_date=data.get("end_date"),
)
db.add(template)
db.commit()
db.refresh(template)
return {
"id": template.id,
"name": template.name,
"project_id": template.project_id,
"report_title": template.report_title,
"start_time": template.start_time,
"end_time": template.end_time,
"start_date": template.start_date,
"end_date": template.end_date,
"created_at": template.created_at.isoformat() if template.created_at else None,
}
@router.get("/{template_id}")
async def get_template(
template_id: str,
db: Session = Depends(get_db),
):
"""Get a specific report template by ID."""
template = db.query(ReportTemplate).filter_by(id=template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
return {
"id": template.id,
"name": template.name,
"project_id": template.project_id,
"report_title": template.report_title,
"start_time": template.start_time,
"end_time": template.end_time,
"start_date": template.start_date,
"end_date": template.end_date,
"created_at": template.created_at.isoformat() if template.created_at else None,
"updated_at": template.updated_at.isoformat() if template.updated_at else None,
}
@router.put("/{template_id}")
async def update_template(
template_id: str,
data: dict,
db: Session = Depends(get_db),
):
"""Update an existing report template."""
template = db.query(ReportTemplate).filter_by(id=template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
# Update fields if provided
if "name" in data:
template.name = data["name"]
if "project_id" in data:
template.project_id = data["project_id"]
if "report_title" in data:
template.report_title = data["report_title"]
if "start_time" in data:
template.start_time = data["start_time"]
if "end_time" in data:
template.end_time = data["end_time"]
if "start_date" in data:
template.start_date = data["start_date"]
if "end_date" in data:
template.end_date = data["end_date"]
template.updated_at = datetime.utcnow()
db.commit()
db.refresh(template)
return {
"id": template.id,
"name": template.name,
"project_id": template.project_id,
"report_title": template.report_title,
"start_time": template.start_time,
"end_time": template.end_time,
"start_date": template.start_date,
"end_date": template.end_date,
"updated_at": template.updated_at.isoformat() if template.updated_at else None,
}
@router.delete("/{template_id}")
async def delete_template(
template_id: str,
db: Session = Depends(get_db),
):
"""Delete a report template."""
template = db.query(ReportTemplate).filter_by(id=template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Template not found")
db.delete(template)
db.commit()
return JSONResponse({"status": "success", "message": "Template deleted"})

View File

@@ -2,20 +2,32 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from typing import Dict, Any
import asyncio
import logging
import random
from backend.database import get_db
from backend.services.snapshot import emit_status_snapshot
from backend.services.slm_status_sync import sync_slm_status_to_emitters
router = APIRouter(prefix="/api", tags=["roster"])
logger = logging.getLogger(__name__)
@router.get("/status-snapshot")
def get_status_snapshot(db: Session = Depends(get_db)):
async def get_status_snapshot(db: Session = Depends(get_db)):
"""
Calls emit_status_snapshot() to get current fleet status.
This will be replaced with real Series3 emitter logic later.
Syncs SLM status from SLMM before generating snapshot.
"""
# Sync SLM status from SLMM (with timeout to prevent blocking)
try:
await asyncio.wait_for(sync_slm_status_to_emitters(), timeout=2.0)
except asyncio.TimeoutError:
logger.warning("SLM status sync timed out, using cached data")
except Exception as e:
logger.warning(f"SLM status sync failed: {e}")
return emit_status_snapshot()

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request, Query
from fastapi.exceptions import RequestValidationError
from sqlalchemy.orm import Session
from datetime import datetime, date
@@ -10,6 +10,7 @@ import os
from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
from backend.services.slmm_sync import sync_slm_to_slmm
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
logger = logging.getLogger(__name__)
@@ -150,6 +151,8 @@ async def add_roster_unit(
ip_address: str = Form(None),
phone_number: str = Form(None),
hardware_model: str = Form(None),
deployment_type: str = Form(None), # "seismograph" | "slm" - what device type modem is deployed with
deployed_with_unit_id: str = Form(None), # ID of seismograph/SLM this modem is deployed with
# Sound Level Meter-specific fields
slm_host: str = Form(None),
slm_tcp_port: str = Form(None),
@@ -209,6 +212,7 @@ async def add_roster_unit(
ip_address=ip_address if ip_address else None,
phone_number=phone_number if phone_number else None,
hardware_model=hardware_model if hardware_model else None,
deployment_type=deployment_type if deployment_type else None,
# Sound Level Meter-specific fields
slm_host=slm_host if slm_host else None,
slm_tcp_port=slm_tcp_port_int,
@@ -219,6 +223,23 @@ async def add_roster_unit(
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
)
# Auto-fill location data from modem if pairing and fields are empty
if deployed_with_modem_id:
modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem"
).first()
if modem:
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id
db.add(unit)
db.commit()
@@ -259,6 +280,145 @@ def get_modems_list(db: Session = Depends(get_db)):
]
@router.get("/search/modems")
def search_modems(
request: Request,
q: str = Query("", description="Search term"),
deployed_only: bool = Query(False, description="Only show deployed modems"),
exclude_retired: bool = Query(True, description="Exclude retired modems"),
limit: int = Query(10, le=50),
db: Session = Depends(get_db)
):
"""
Search modems by ID, IP address, or note. Returns HTML partial for HTMX dropdown.
Used by modem picker component to find modems to link with seismographs/SLMs.
"""
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
query = db.query(RosterUnit).filter(RosterUnit.device_type == "modem")
if deployed_only:
query = query.filter(RosterUnit.deployed == True)
if exclude_retired:
query = query.filter(RosterUnit.retired == False)
# Search by ID, IP address, or note
if q and q.strip():
search_term = f"%{q.strip()}%"
query = query.filter(
(RosterUnit.id.ilike(search_term)) |
(RosterUnit.ip_address.ilike(search_term)) |
(RosterUnit.note.ilike(search_term))
)
modems = query.order_by(RosterUnit.id).limit(limit).all()
# Build results
results = []
for modem in modems:
# Build display text: ID - IP - Note (if available)
display_parts = [modem.id]
if modem.ip_address:
display_parts.append(modem.ip_address)
if modem.note:
display_parts.append(modem.note)
display = " - ".join(display_parts)
results.append({
"id": modem.id,
"ip_address": modem.ip_address or "",
"phone_number": modem.phone_number or "",
"note": modem.note or "",
"deployed": modem.deployed,
"display": display
})
# Determine if we should show "no results" message
show_empty = len(results) == 0 and q and q.strip()
return templates.TemplateResponse(
"partials/modem_search_results.html",
{
"request": request,
"modems": results,
"query": q,
"show_empty": show_empty
}
)
@router.get("/search/units")
def search_units(
request: Request,
q: str = Query("", description="Search term"),
device_type: str = Query(None, description="Filter by device type: seismograph, modem, slm"),
deployed_only: bool = Query(False, description="Only show deployed units"),
exclude_retired: bool = Query(True, description="Exclude retired units"),
limit: int = Query(10, le=50),
db: Session = Depends(get_db)
):
"""
Search roster units by ID or note. Returns HTML partial for HTMX dropdown.
Used by unit picker component to find seismographs/SLMs to link with modems.
"""
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="templates")
query = db.query(RosterUnit)
# Apply filters
if device_type:
query = query.filter(RosterUnit.device_type == device_type)
if deployed_only:
query = query.filter(RosterUnit.deployed == True)
if exclude_retired:
query = query.filter(RosterUnit.retired == False)
# Search by ID or note
if q and q.strip():
search_term = f"%{q.strip()}%"
query = query.filter(
(RosterUnit.id.ilike(search_term)) |
(RosterUnit.note.ilike(search_term))
)
units = query.order_by(RosterUnit.id).limit(limit).all()
# Build results
results = []
for unit in units:
results.append({
"id": unit.id,
"device_type": unit.device_type or "seismograph",
"note": unit.note or "",
"deployed": unit.deployed,
"display": f"{unit.id}" + (f" - {unit.note}" if unit.note else "")
})
# Determine if we should show "no results" message
show_empty = len(results) == 0 and q and q.strip()
return templates.TemplateResponse(
"partials/unit_search_results.html",
{
"request": request,
"units": results,
"query": q,
"show_empty": show_empty
}
)
@router.get("/{unit_id}")
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"""Get a single roster unit by ID"""
@@ -283,6 +443,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"ip_address": unit.ip_address or "",
"phone_number": unit.phone_number or "",
"hardware_model": unit.hardware_model or "",
"deployment_type": unit.deployment_type or "",
"deployed_with_unit_id": unit.deployed_with_unit_id or "",
"slm_host": unit.slm_host or "",
"slm_tcp_port": unit.slm_tcp_port or "",
"slm_ftp_port": unit.slm_ftp_port or "",
@@ -295,7 +457,7 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
@router.post("/edit/{unit_id}")
def edit_roster_unit(
async def edit_roster_unit(
unit_id: str,
device_type: str = Form("seismograph"),
unit_type: str = Form("series3"),
@@ -314,6 +476,8 @@ def edit_roster_unit(
ip_address: str = Form(None),
phone_number: str = Form(None),
hardware_model: str = Form(None),
deployment_type: str = Form(None),
deployed_with_unit_id: str = Form(None),
# Sound Level Meter-specific fields
slm_host: str = Form(None),
slm_tcp_port: str = Form(None),
@@ -323,6 +487,14 @@ def edit_roster_unit(
slm_frequency_weighting: str = Form(None),
slm_time_weighting: str = Form(None),
slm_measurement_range: str = Form(None),
# Cascade options - sync fields to paired device
cascade_to_unit_id: str = Form(None),
cascade_deployed: str = Form(None),
cascade_retired: str = Form(None),
cascade_project: str = Form(None),
cascade_location: str = Form(None),
cascade_coordinates: str = Form(None),
cascade_note: str = Form(None),
db: Session = Depends(get_db)
):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
@@ -374,10 +546,29 @@ def edit_roster_unit(
unit.next_calibration_due = next_cal_date
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
# Auto-fill location data from modem if pairing and fields are empty
if deployed_with_modem_id:
modem = db.query(RosterUnit).filter(
RosterUnit.id == deployed_with_modem_id,
RosterUnit.device_type == "modem"
).first()
if modem:
# Only fill if the device field is empty
if not unit.location and modem.location:
unit.location = modem.location
if not unit.address and modem.address:
unit.address = modem.address
if not unit.coordinates and modem.coordinates:
unit.coordinates = modem.coordinates
if not unit.project_id and modem.project_id:
unit.project_id = modem.project_id
# Modem-specific fields
unit.ip_address = ip_address if ip_address else None
unit.phone_number = phone_number if phone_number else None
unit.hardware_model = hardware_model if hardware_model else None
unit.deployment_type = deployment_type if deployment_type else None
unit.deployed_with_unit_id = deployed_with_unit_id if deployed_with_unit_id else None
# Sound Level Meter-specific fields
unit.slm_host = slm_host if slm_host else None
@@ -403,12 +594,93 @@ def edit_roster_unit(
old_status_text = "retired" if old_retired else "active"
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
# Handle cascade to paired device
cascaded_unit_id = None
if cascade_to_unit_id and cascade_to_unit_id.strip():
paired_unit = db.query(RosterUnit).filter(RosterUnit.id == cascade_to_unit_id).first()
if paired_unit:
cascaded_unit_id = paired_unit.id
# Cascade deployed status
if cascade_deployed in ['true', 'True', '1', 'yes']:
old_paired_deployed = paired_unit.deployed
paired_unit.deployed = deployed_bool
paired_unit.last_updated = datetime.utcnow()
if old_paired_deployed != deployed_bool:
status_text = "deployed" if deployed_bool else "benched"
old_status_text = "deployed" if old_paired_deployed else "benched"
record_history(db, paired_unit.id, "deployed_change", "deployed",
old_status_text, status_text, f"cascade from {unit_id}")
# Cascade retired status
if cascade_retired in ['true', 'True', '1', 'yes']:
old_paired_retired = paired_unit.retired
paired_unit.retired = retired_bool
paired_unit.last_updated = datetime.utcnow()
if old_paired_retired != retired_bool:
status_text = "retired" if retired_bool else "active"
old_status_text = "retired" if old_paired_retired else "active"
record_history(db, paired_unit.id, "retired_change", "retired",
old_status_text, status_text, f"cascade from {unit_id}")
# Cascade project
if cascade_project in ['true', 'True', '1', 'yes']:
old_paired_project = paired_unit.project_id
paired_unit.project_id = project_id
paired_unit.last_updated = datetime.utcnow()
if old_paired_project != project_id:
record_history(db, paired_unit.id, "project_change", "project_id",
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
# Cascade address/location
if cascade_location in ['true', 'True', '1', 'yes']:
old_paired_address = paired_unit.address
old_paired_location = paired_unit.location
paired_unit.address = address
paired_unit.location = location
paired_unit.last_updated = datetime.utcnow()
if old_paired_address != address:
record_history(db, paired_unit.id, "address_change", "address",
old_paired_address or "", address or "", f"cascade from {unit_id}")
# Cascade coordinates
if cascade_coordinates in ['true', 'True', '1', 'yes']:
old_paired_coords = paired_unit.coordinates
paired_unit.coordinates = coordinates
paired_unit.last_updated = datetime.utcnow()
if old_paired_coords != coordinates:
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
# Cascade note
if cascade_note in ['true', 'True', '1', 'yes']:
old_paired_note = paired_unit.note
paired_unit.note = note
paired_unit.last_updated = datetime.utcnow()
if old_paired_note != note:
record_history(db, paired_unit.id, "note_change", "note",
old_paired_note or "", note or "", f"cascade from {unit_id}")
db.commit()
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
# Sync SLM polling config to SLMM when deployed/retired status changes
# This ensures benched units stop being polled
if device_type == "slm" and (old_deployed != deployed_bool or old_retired != retired_bool):
db.refresh(unit) # Refresh to get committed values
try:
await sync_slm_to_slmm(unit)
logger.info(f"Synced SLM {unit_id} polling config to SLMM (deployed={deployed_bool}, retired={retired_bool})")
except Exception as e:
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
response = {"message": "Unit updated", "id": unit_id, "device_type": device_type}
if cascaded_unit_id:
response["cascaded_to"] = cascaded_unit_id
return response
@router.post("/set-deployed/{unit_id}")
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
async def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
unit = get_or_create_roster_unit(db, unit_id)
old_deployed = unit.deployed
unit.deployed = deployed
@@ -429,11 +701,21 @@ def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends
)
db.commit()
# Sync SLM polling config to SLMM when deployed status changes
if unit.device_type == "slm" and old_deployed != deployed:
db.refresh(unit)
try:
await sync_slm_to_slmm(unit)
logger.info(f"Synced SLM {unit_id} polling config to SLMM (deployed={deployed})")
except Exception as e:
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
return {"message": "Updated", "id": unit_id, "deployed": deployed}
@router.post("/set-retired/{unit_id}")
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
async def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
unit = get_or_create_roster_unit(db, unit_id)
old_retired = unit.retired
unit.retired = retired
@@ -454,6 +736,16 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g
)
db.commit()
# Sync SLM polling config to SLMM when retired status changes
if unit.device_type == "slm" and old_retired != retired:
db.refresh(unit)
try:
await sync_slm_to_slmm(unit)
logger.info(f"Synced SLM {unit_id} polling config to SLMM (retired={retired})")
except Exception as e:
logger.warning(f"Failed to sync SLM {unit_id} polling config to SLMM: {e}")
return {"message": "Updated", "id": unit_id, "retired": retired}
@@ -531,6 +823,37 @@ def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
return {"message": "Updated", "id": unit_id, "note": note}
def _parse_bool(value: str) -> bool:
"""Parse boolean from CSV string value."""
return value.lower() in ('true', '1', 'yes') if value else False
def _parse_int(value: str) -> int | None:
"""Parse integer from CSV string value, return None if empty or invalid."""
if not value or not value.strip():
return None
try:
return int(value.strip())
except ValueError:
return None
def _parse_date(value: str) -> date | None:
"""Parse date from CSV string value (YYYY-MM-DD format)."""
if not value or not value.strip():
return None
try:
return datetime.strptime(value.strip(), '%Y-%m-%d').date()
except ValueError:
return None
def _get_csv_value(row: dict, key: str, default=None):
"""Get value from CSV row, return default if empty."""
value = row.get(key, '').strip() if row.get(key) else ''
return value if value else default
@router.post("/import-csv")
async def import_csv(
file: UploadFile = File(...),
@@ -541,13 +864,40 @@ async def import_csv(
Import roster units from CSV file.
Expected CSV columns (unit_id is required, others are optional):
- unit_id: Unique identifier for the unit
- unit_type: Type of unit (default: "series3")
- deployed: Boolean for deployment status (default: False)
- retired: Boolean for retirement status (default: False)
Common fields (all device types):
- unit_id: Unique identifier for the unit (REQUIRED)
- device_type: "seismograph", "modem", or "slm" (default: "seismograph")
- unit_type: Sub-type (e.g., "series3", "series4" for seismographs)
- deployed: Boolean (true/false/yes/no/1/0)
- retired: Boolean
- note: Notes about the unit
- project_id: Project identifier
- location: Location description
- address: Street address
- coordinates: GPS coordinates (lat;lon or lat,lon)
Seismograph-specific:
- last_calibrated: Date (YYYY-MM-DD)
- next_calibration_due: Date (YYYY-MM-DD)
- deployed_with_modem_id: ID of paired modem
Modem-specific:
- ip_address: Device IP address
- phone_number: SIM card phone number
- hardware_model: Hardware model (e.g., IBR900, RV55)
SLM-specific:
- slm_host: Device IP or hostname
- slm_tcp_port: TCP control port (default 2255)
- slm_ftp_port: FTP port (default 21)
- slm_model: Device model (NL-43, NL-53)
- slm_serial_number: Serial number
- slm_frequency_weighting: A, C, or Z
- slm_time_weighting: F (Fast), S (Slow), I (Impulse)
- slm_measurement_range: e.g., "30-130 dB"
Lines starting with # are treated as comments and skipped.
Args:
file: CSV file upload
@@ -560,6 +910,46 @@ async def import_csv(
# Read file content
contents = await file.read()
csv_text = contents.decode('utf-8')
# Filter out comment lines (starting with #)
lines = csv_text.split('\n')
filtered_lines = [line for line in lines if not line.strip().startswith('#')]
csv_text = '\n'.join(filtered_lines)
# First pass: validate for duplicates and empty unit_ids
csv_reader = csv.DictReader(io.StringIO(csv_text))
seen_unit_ids = {} # unit_id -> list of row numbers
empty_unit_id_rows = []
for row_num, row in enumerate(csv_reader, start=2):
unit_id = row.get('unit_id', '').strip()
if not unit_id:
empty_unit_id_rows.append(row_num)
else:
if unit_id not in seen_unit_ids:
seen_unit_ids[unit_id] = []
seen_unit_ids[unit_id].append(row_num)
# Check for validation errors
validation_errors = []
# Report empty unit_ids
if empty_unit_id_rows:
validation_errors.append(f"Empty unit_id on row(s): {', '.join(map(str, empty_unit_id_rows))}")
# Report duplicates
duplicates = {uid: rows for uid, rows in seen_unit_ids.items() if len(rows) > 1}
if duplicates:
for uid, rows in duplicates.items():
validation_errors.append(f"Duplicate unit_id '{uid}' on rows: {', '.join(map(str, rows))}")
if validation_errors:
raise HTTPException(
status_code=400,
detail="CSV validation failed:\n" + "\n".join(validation_errors)
)
# Second pass: actually import the data
csv_reader = csv.DictReader(io.StringIO(csv_text))
results = {
@@ -580,6 +970,9 @@ async def import_csv(
})
continue
# Determine device type
device_type = _get_csv_value(row, 'device_type', 'seismograph')
# Check if unit exists
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
@@ -588,31 +981,90 @@ async def import_csv(
results["skipped"].append(unit_id)
continue
# Update existing unit
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3')
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired
existing_unit.note = row.get('note', existing_unit.note or '')
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
existing_unit.location = row.get('location', existing_unit.location)
existing_unit.address = row.get('address', existing_unit.address)
existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates)
# Update existing unit - common fields
existing_unit.device_type = device_type
existing_unit.unit_type = _get_csv_value(row, 'unit_type', existing_unit.unit_type or 'series3')
existing_unit.deployed = _parse_bool(row.get('deployed', '')) if row.get('deployed') else existing_unit.deployed
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
existing_unit.last_updated = datetime.utcnow()
# Seismograph-specific fields
if row.get('last_calibrated'):
existing_unit.last_calibrated = _parse_date(row.get('last_calibrated'))
if row.get('next_calibration_due'):
existing_unit.next_calibration_due = _parse_date(row.get('next_calibration_due'))
if row.get('deployed_with_modem_id'):
existing_unit.deployed_with_modem_id = _get_csv_value(row, 'deployed_with_modem_id')
# Modem-specific fields
if row.get('ip_address'):
existing_unit.ip_address = _get_csv_value(row, 'ip_address')
if row.get('phone_number'):
existing_unit.phone_number = _get_csv_value(row, 'phone_number')
if row.get('hardware_model'):
existing_unit.hardware_model = _get_csv_value(row, 'hardware_model')
if row.get('deployment_type'):
existing_unit.deployment_type = _get_csv_value(row, 'deployment_type')
if row.get('deployed_with_unit_id'):
existing_unit.deployed_with_unit_id = _get_csv_value(row, 'deployed_with_unit_id')
# SLM-specific fields
if row.get('slm_host'):
existing_unit.slm_host = _get_csv_value(row, 'slm_host')
if row.get('slm_tcp_port'):
existing_unit.slm_tcp_port = _parse_int(row.get('slm_tcp_port'))
if row.get('slm_ftp_port'):
existing_unit.slm_ftp_port = _parse_int(row.get('slm_ftp_port'))
if row.get('slm_model'):
existing_unit.slm_model = _get_csv_value(row, 'slm_model')
if row.get('slm_serial_number'):
existing_unit.slm_serial_number = _get_csv_value(row, 'slm_serial_number')
if row.get('slm_frequency_weighting'):
existing_unit.slm_frequency_weighting = _get_csv_value(row, 'slm_frequency_weighting')
if row.get('slm_time_weighting'):
existing_unit.slm_time_weighting = _get_csv_value(row, 'slm_time_weighting')
if row.get('slm_measurement_range'):
existing_unit.slm_measurement_range = _get_csv_value(row, 'slm_measurement_range')
results["updated"].append(unit_id)
else:
# Create new unit
# Create new unit with all fields
new_unit = RosterUnit(
id=unit_id,
unit_type=row.get('unit_type', 'series3'),
deployed=row.get('deployed', '').lower() in ('true', '1', 'yes'),
retired=row.get('retired', '').lower() in ('true', '1', 'yes'),
note=row.get('note', ''),
project_id=row.get('project_id'),
location=row.get('location'),
address=row.get('address'),
coordinates=row.get('coordinates'),
last_updated=datetime.utcnow()
device_type=device_type,
unit_type=_get_csv_value(row, 'unit_type', 'series3'),
deployed=_parse_bool(row.get('deployed', '')),
retired=_parse_bool(row.get('retired', '')),
note=_get_csv_value(row, 'note', ''),
project_id=_get_csv_value(row, 'project_id'),
location=_get_csv_value(row, 'location'),
address=_get_csv_value(row, 'address'),
coordinates=_get_csv_value(row, 'coordinates'),
last_updated=datetime.utcnow(),
# Seismograph fields
last_calibrated=_parse_date(row.get('last_calibrated', '')),
next_calibration_due=_parse_date(row.get('next_calibration_due', '')),
deployed_with_modem_id=_get_csv_value(row, 'deployed_with_modem_id'),
# Modem fields
ip_address=_get_csv_value(row, 'ip_address'),
phone_number=_get_csv_value(row, 'phone_number'),
hardware_model=_get_csv_value(row, 'hardware_model'),
deployment_type=_get_csv_value(row, 'deployment_type'),
deployed_with_unit_id=_get_csv_value(row, 'deployed_with_unit_id'),
# SLM fields
slm_host=_get_csv_value(row, 'slm_host'),
slm_tcp_port=_parse_int(row.get('slm_tcp_port', '')),
slm_ftp_port=_parse_int(row.get('slm_ftp_port', '')),
slm_model=_get_csv_value(row, 'slm_model'),
slm_serial_number=_get_csv_value(row, 'slm_serial_number'),
slm_frequency_weighting=_get_csv_value(row, 'slm_frequency_weighting'),
slm_time_weighting=_get_csv_value(row, 'slm_time_weighting'),
slm_measurement_range=_get_csv_value(row, 'slm_measurement_range'),
)
db.add(new_unit)
results["added"].append(unit_id)
@@ -735,3 +1187,145 @@ def delete_history_entry(history_id: int, db: Session = Depends(get_db)):
db.delete(history_entry)
db.commit()
return {"message": "History entry deleted", "id": history_id}
@router.post("/pair-devices")
async def pair_devices(
request: Request,
db: Session = Depends(get_db)
):
"""
Create a bidirectional pairing between a recorder (seismograph/SLM) and a modem.
Sets:
- recorder.deployed_with_modem_id = modem_id
- modem.deployed_with_unit_id = recorder_id
Also clears any previous pairings for both devices.
"""
data = await request.json()
recorder_id = data.get("recorder_id")
modem_id = data.get("modem_id")
if not recorder_id or not modem_id:
raise HTTPException(status_code=400, detail="Both recorder_id and modem_id are required")
# Get or create the units
recorder = db.query(RosterUnit).filter(RosterUnit.id == recorder_id).first()
modem = db.query(RosterUnit).filter(RosterUnit.id == modem_id).first()
if not recorder:
raise HTTPException(status_code=404, detail=f"Recorder {recorder_id} not found in roster")
if not modem:
raise HTTPException(status_code=404, detail=f"Modem {modem_id} not found in roster")
# Validate device types
if recorder.device_type == "modem":
raise HTTPException(status_code=400, detail=f"{recorder_id} is a modem, not a recorder")
if modem.device_type != "modem":
raise HTTPException(status_code=400, detail=f"{modem_id} is not a modem (type: {modem.device_type})")
# Clear any previous pairings
# If recorder was paired with a different modem, clear that modem's link
if recorder.deployed_with_modem_id and recorder.deployed_with_modem_id != modem_id:
old_modem = db.query(RosterUnit).filter(RosterUnit.id == recorder.deployed_with_modem_id).first()
if old_modem and old_modem.deployed_with_unit_id == recorder_id:
record_history(db, old_modem.id, "update", "deployed_with_unit_id",
old_modem.deployed_with_unit_id, None, "pair_devices", f"Cleared by new pairing")
old_modem.deployed_with_unit_id = None
# If modem was paired with a different recorder, clear that recorder's link
if modem.deployed_with_unit_id and modem.deployed_with_unit_id != recorder_id:
old_recorder = db.query(RosterUnit).filter(RosterUnit.id == modem.deployed_with_unit_id).first()
if old_recorder and old_recorder.deployed_with_modem_id == modem_id:
record_history(db, old_recorder.id, "update", "deployed_with_modem_id",
old_recorder.deployed_with_modem_id, None, "pair_devices", f"Cleared by new pairing")
old_recorder.deployed_with_modem_id = None
# Record history for the pairing
old_recorder_modem = recorder.deployed_with_modem_id
old_modem_unit = modem.deployed_with_unit_id
# Set the new pairing
recorder.deployed_with_modem_id = modem_id
modem.deployed_with_unit_id = recorder_id
# Record history
if old_recorder_modem != modem_id:
record_history(db, recorder_id, "update", "deployed_with_modem_id",
old_recorder_modem, modem_id, "pair_devices", f"Paired with modem")
if old_modem_unit != recorder_id:
record_history(db, modem_id, "update", "deployed_with_unit_id",
old_modem_unit, recorder_id, "pair_devices", f"Paired with recorder")
db.commit()
logger.info(f"Paired {recorder_id} with modem {modem_id}")
# If SLM, sync to SLMM cache
if recorder.device_type == "slm":
await sync_slm_to_slmm_cache(
unit_id=recorder_id,
host=recorder.slm_host,
tcp_port=recorder.slm_tcp_port,
ftp_port=recorder.slm_ftp_port,
deployed_with_modem_id=modem_id,
db=db
)
return {
"success": True,
"message": f"Paired {recorder_id} with {modem_id}",
"recorder_id": recorder_id,
"modem_id": modem_id
}
@router.post("/unpair-devices")
async def unpair_devices(
request: Request,
db: Session = Depends(get_db)
):
"""
Remove the bidirectional pairing between a recorder and modem.
Clears:
- recorder.deployed_with_modem_id
- modem.deployed_with_unit_id
"""
data = await request.json()
recorder_id = data.get("recorder_id")
modem_id = data.get("modem_id")
if not recorder_id or not modem_id:
raise HTTPException(status_code=400, detail="Both recorder_id and modem_id are required")
recorder = db.query(RosterUnit).filter(RosterUnit.id == recorder_id).first()
modem = db.query(RosterUnit).filter(RosterUnit.id == modem_id).first()
changes_made = False
if recorder and recorder.deployed_with_modem_id == modem_id:
record_history(db, recorder_id, "update", "deployed_with_modem_id",
recorder.deployed_with_modem_id, None, "unpair_devices", "Unpairing")
recorder.deployed_with_modem_id = None
changes_made = True
if modem and modem.deployed_with_unit_id == recorder_id:
record_history(db, modem_id, "update", "deployed_with_unit_id",
modem.deployed_with_unit_id, None, "unpair_devices", "Unpairing")
modem.deployed_with_unit_id = None
changes_made = True
if changes_made:
db.commit()
logger.info(f"Unpaired {recorder_id} from modem {modem_id}")
return {
"success": True,
"message": f"Unpaired {recorder_id} from {modem_id}"
}
else:
return {
"success": False,
"message": "No pairing found between these devices"
}

View File

@@ -5,7 +5,6 @@ Handles scheduled actions for automated recording control.
"""
from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
@@ -23,9 +22,9 @@ from backend.models import (
RosterUnit,
)
from backend.services.scheduler import get_scheduler
from backend.templates_config import templates
router = APIRouter(prefix="/api/projects/{project_id}/scheduler", tags=["scheduler"])
templates = Jinja2Templates(directory="templates")
# ============================================================================

View File

@@ -5,13 +5,12 @@ Provides endpoints for the seismograph-specific dashboard
from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import RosterUnit
from backend.templates_config import templates
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
templates = Jinja2Templates(directory="templates")
@router.get("/stats", response_class=HTMLResponse)

View File

@@ -5,7 +5,6 @@ Provides API endpoints for the Sound Level Meters dashboard page.
"""
from fastapi import APIRouter, Request, Depends, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
@@ -18,11 +17,11 @@ import os
from backend.database import get_db
from backend.models import RosterUnit
from backend.routers.roster_edit import sync_slm_to_slmm_cache
from backend.templates_config import templates
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
templates = Jinja2Templates(directory="templates")
# SLMM backend URL - configurable via environment variable
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
@@ -168,23 +167,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
measurement_state = state_data.get("measurement_state", "Unknown")
is_measuring = state_data.get("is_measuring", False)
# If measuring, sync start time from FTP to database (fixes wrong timestamps)
if is_measuring:
try:
sync_response = await client.post(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time",
timeout=10.0
)
if sync_response.status_code == 200:
sync_data = sync_response.json()
logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}")
else:
logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}")
except Exception as e:
# Don't fail the whole request if sync fails
logger.warning(f"Could not sync start time for {unit_id}: {e}")
# Get live status (now with corrected start time)
# Get live status (measurement_start_time is already stored in SLMM database)
status_response = await client.get(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
)

View File

@@ -6,7 +6,6 @@ Provides endpoints for SLM dashboard cards, detail pages, and real-time data.
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session
from datetime import datetime
import httpx
@@ -15,11 +14,11 @@ import os
from backend.database import get_db
from backend.models import RosterUnit
from backend.templates_config import templates
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/slm", tags=["slm-ui"])
templates = Jinja2Templates(directory="templates")
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://172.19.0.1:8100")

View File

@@ -0,0 +1,462 @@
"""
Alert Service
Manages in-app alerts for device status changes and system events.
Provides foundation for future notification channels (email, webhook).
"""
import json
import uuid
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from backend.models import Alert, RosterUnit
logger = logging.getLogger(__name__)
class AlertService:
"""
Service for managing alerts.
Handles alert lifecycle:
- Create alerts from various triggers
- Query active alerts
- Acknowledge/resolve/dismiss alerts
- (Future) Dispatch to notification channels
"""
def __init__(self, db: Session):
self.db = db
def create_alert(
self,
alert_type: str,
title: str,
message: str = None,
severity: str = "warning",
unit_id: str = None,
project_id: str = None,
location_id: str = None,
schedule_id: str = None,
metadata: dict = None,
expires_hours: int = 24,
) -> Alert:
"""
Create a new alert.
Args:
alert_type: Type of alert (device_offline, device_online, schedule_failed)
title: Short alert title
message: Detailed description
severity: info, warning, or critical
unit_id: Related unit ID (optional)
project_id: Related project ID (optional)
location_id: Related location ID (optional)
schedule_id: Related schedule ID (optional)
metadata: Additional JSON data
expires_hours: Hours until auto-expiry (default 24)
Returns:
Created Alert instance
"""
alert = Alert(
id=str(uuid.uuid4()),
alert_type=alert_type,
title=title,
message=message,
severity=severity,
unit_id=unit_id,
project_id=project_id,
location_id=location_id,
schedule_id=schedule_id,
alert_metadata=json.dumps(metadata) if metadata else None,
status="active",
expires_at=datetime.utcnow() + timedelta(hours=expires_hours),
)
self.db.add(alert)
self.db.commit()
self.db.refresh(alert)
logger.info(f"Created alert: {alert.title} ({alert.alert_type})")
return alert
def create_device_offline_alert(
self,
unit_id: str,
consecutive_failures: int = 0,
last_error: str = None,
) -> Optional[Alert]:
"""
Create alert when device becomes unreachable.
Only creates if no active offline alert exists for this device.
Args:
unit_id: The unit that went offline
consecutive_failures: Number of consecutive poll failures
last_error: Last error message from polling
Returns:
Created Alert or None if alert already exists
"""
# Check if active offline alert already exists
existing = self.db.query(Alert).filter(
and_(
Alert.unit_id == unit_id,
Alert.alert_type == "device_offline",
Alert.status == "active",
)
).first()
if existing:
logger.debug(f"Offline alert already exists for {unit_id}")
return None
# Get unit info for title
unit = self.db.query(RosterUnit).filter_by(id=unit_id).first()
unit_name = unit.id if unit else unit_id
# Determine severity based on failure count
severity = "critical" if consecutive_failures >= 5 else "warning"
return self.create_alert(
alert_type="device_offline",
title=f"{unit_name} is offline",
message=f"Device has been unreachable after {consecutive_failures} failed connection attempts."
+ (f" Last error: {last_error}" if last_error else ""),
severity=severity,
unit_id=unit_id,
metadata={
"consecutive_failures": consecutive_failures,
"last_error": last_error,
},
expires_hours=48, # Offline alerts stay longer
)
def resolve_device_offline_alert(self, unit_id: str) -> Optional[Alert]:
"""
Auto-resolve offline alert when device comes back online.
Also creates an "device_online" info alert to notify user.
Args:
unit_id: The unit that came back online
Returns:
The resolved Alert or None if no alert existed
"""
# Find active offline alert
alert = self.db.query(Alert).filter(
and_(
Alert.unit_id == unit_id,
Alert.alert_type == "device_offline",
Alert.status == "active",
)
).first()
if not alert:
return None
# Resolve the offline alert
alert.status = "resolved"
alert.resolved_at = datetime.utcnow()
self.db.commit()
logger.info(f"Resolved offline alert for {unit_id}")
# Create online notification
unit = self.db.query(RosterUnit).filter_by(id=unit_id).first()
unit_name = unit.id if unit else unit_id
self.create_alert(
alert_type="device_online",
title=f"{unit_name} is back online",
message="Device connection has been restored.",
severity="info",
unit_id=unit_id,
expires_hours=6, # Info alerts expire quickly
)
return alert
def create_schedule_failed_alert(
self,
schedule_id: str,
action_type: str,
unit_id: str = None,
error_message: str = None,
project_id: str = None,
location_id: str = None,
) -> Alert:
"""
Create alert when a scheduled action fails.
Args:
schedule_id: The ScheduledAction or RecurringSchedule ID
action_type: start, stop, download
unit_id: Related unit
error_message: Error from execution
project_id: Related project
location_id: Related location
Returns:
Created Alert
"""
return self.create_alert(
alert_type="schedule_failed",
title=f"Scheduled {action_type} failed",
message=error_message or f"The scheduled {action_type} action did not complete successfully.",
severity="warning",
unit_id=unit_id,
project_id=project_id,
location_id=location_id,
schedule_id=schedule_id,
metadata={"action_type": action_type},
expires_hours=24,
)
def create_schedule_completed_alert(
self,
schedule_id: str,
action_type: str,
unit_id: str = None,
project_id: str = None,
location_id: str = None,
metadata: dict = None,
) -> Alert:
"""
Create alert when a scheduled action completes successfully.
Args:
schedule_id: The ScheduledAction ID
action_type: start, stop, download
unit_id: Related unit
project_id: Related project
location_id: Related location
metadata: Additional info (e.g., downloaded folder, index numbers)
Returns:
Created Alert
"""
# Build descriptive message based on action type and metadata
if action_type == "stop" and metadata:
download_folder = metadata.get("downloaded_folder")
download_success = metadata.get("download_success", False)
if download_success and download_folder:
message = f"Measurement stopped and data downloaded ({download_folder})"
elif download_success is False and metadata.get("download_attempted"):
message = "Measurement stopped but download failed"
else:
message = "Measurement stopped successfully"
elif action_type == "start" and metadata:
new_index = metadata.get("new_index")
if new_index is not None:
message = f"Measurement started (index {new_index:04d})"
else:
message = "Measurement started successfully"
else:
message = f"Scheduled {action_type} completed successfully"
return self.create_alert(
alert_type="schedule_completed",
title=f"Scheduled {action_type} completed",
message=message,
severity="info",
unit_id=unit_id,
project_id=project_id,
location_id=location_id,
schedule_id=schedule_id,
metadata={"action_type": action_type, **(metadata or {})},
expires_hours=12, # Info alerts expire quickly
)
def get_active_alerts(
self,
project_id: str = None,
unit_id: str = None,
alert_type: str = None,
min_severity: str = None,
limit: int = 50,
) -> List[Alert]:
"""
Query active alerts with optional filters.
Args:
project_id: Filter by project
unit_id: Filter by unit
alert_type: Filter by alert type
min_severity: Minimum severity (info, warning, critical)
limit: Maximum results
Returns:
List of matching alerts
"""
query = self.db.query(Alert).filter(Alert.status == "active")
if project_id:
query = query.filter(Alert.project_id == project_id)
if unit_id:
query = query.filter(Alert.unit_id == unit_id)
if alert_type:
query = query.filter(Alert.alert_type == alert_type)
if min_severity:
# Map severity to numeric for comparison
severity_levels = {"info": 1, "warning": 2, "critical": 3}
min_level = severity_levels.get(min_severity, 1)
if min_level == 2:
query = query.filter(Alert.severity.in_(["warning", "critical"]))
elif min_level == 3:
query = query.filter(Alert.severity == "critical")
return query.order_by(Alert.created_at.desc()).limit(limit).all()
def get_all_alerts(
self,
status: str = None,
project_id: str = None,
unit_id: str = None,
alert_type: str = None,
limit: int = 50,
offset: int = 0,
) -> List[Alert]:
"""
Query all alerts with optional filters (includes non-active).
Args:
status: Filter by status (active, acknowledged, resolved, dismissed)
project_id: Filter by project
unit_id: Filter by unit
alert_type: Filter by alert type
limit: Maximum results
offset: Pagination offset
Returns:
List of matching alerts
"""
query = self.db.query(Alert)
if status:
query = query.filter(Alert.status == status)
if project_id:
query = query.filter(Alert.project_id == project_id)
if unit_id:
query = query.filter(Alert.unit_id == unit_id)
if alert_type:
query = query.filter(Alert.alert_type == alert_type)
return (
query.order_by(Alert.created_at.desc())
.offset(offset)
.limit(limit)
.all()
)
def get_active_alert_count(self) -> int:
"""Get count of active alerts for badge display."""
return self.db.query(Alert).filter(Alert.status == "active").count()
def acknowledge_alert(self, alert_id: str) -> Optional[Alert]:
"""
Mark alert as acknowledged.
Args:
alert_id: Alert to acknowledge
Returns:
Updated Alert or None if not found
"""
alert = self.db.query(Alert).filter_by(id=alert_id).first()
if not alert:
return None
alert.status = "acknowledged"
alert.acknowledged_at = datetime.utcnow()
self.db.commit()
logger.info(f"Acknowledged alert: {alert.title}")
return alert
def dismiss_alert(self, alert_id: str) -> Optional[Alert]:
"""
Dismiss alert (user chose to ignore).
Args:
alert_id: Alert to dismiss
Returns:
Updated Alert or None if not found
"""
alert = self.db.query(Alert).filter_by(id=alert_id).first()
if not alert:
return None
alert.status = "dismissed"
self.db.commit()
logger.info(f"Dismissed alert: {alert.title}")
return alert
def resolve_alert(self, alert_id: str) -> Optional[Alert]:
"""
Manually resolve an alert.
Args:
alert_id: Alert to resolve
Returns:
Updated Alert or None if not found
"""
alert = self.db.query(Alert).filter_by(id=alert_id).first()
if not alert:
return None
alert.status = "resolved"
alert.resolved_at = datetime.utcnow()
self.db.commit()
logger.info(f"Resolved alert: {alert.title}")
return alert
def cleanup_expired_alerts(self) -> int:
"""
Remove alerts past their expiration time.
Returns:
Number of alerts cleaned up
"""
now = datetime.utcnow()
expired = self.db.query(Alert).filter(
and_(
Alert.expires_at.isnot(None),
Alert.expires_at < now,
Alert.status == "active",
)
).all()
count = len(expired)
for alert in expired:
alert.status = "dismissed"
if count > 0:
self.db.commit()
logger.info(f"Cleaned up {count} expired alerts")
return count
def get_alert_service(db: Session) -> AlertService:
"""Get an AlertService instance with the given database session."""
return AlertService(db)

View File

@@ -289,6 +289,74 @@ class DeviceController:
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# FTP Control
# ========================================================================
async def enable_ftp(
self,
unit_id: str,
device_type: str,
) -> Dict[str, Any]:
"""
Enable FTP server on device.
Must be called before downloading files.
Args:
unit_id: Unit identifier
device_type: "slm" | "seismograph"
Returns:
Response dict with status
"""
if device_type == "slm":
try:
return await self.slmm_client.enable_ftp(unit_id)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph FTP not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
async def disable_ftp(
self,
unit_id: str,
device_type: str,
) -> Dict[str, Any]:
"""
Disable FTP server on device.
Args:
unit_id: Unit identifier
device_type: "slm" | "seismograph"
Returns:
Response dict with status
"""
if device_type == "slm":
try:
return await self.slmm_client.disable_ftp(unit_id)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph FTP not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# Device Configuration
# ========================================================================
@@ -333,6 +401,157 @@ class DeviceController:
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# Store/Index Management
# ========================================================================
async def increment_index(
self,
unit_id: str,
device_type: str,
) -> Dict[str, Any]:
"""
Increment the store/index number on a device.
For SLMs, this increments the store name to prevent "overwrite data?" prompts.
Should be called before starting a new measurement if auto_increment_index is enabled.
Args:
unit_id: Unit identifier
device_type: "slm" | "seismograph"
Returns:
Response dict with old_index and new_index
"""
if device_type == "slm":
try:
return await self.slmm_client.increment_index(unit_id)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
# Seismographs may not have the same concept of store index
return {
"status": "not_applicable",
"message": "Index increment not applicable for seismographs",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
async def get_index_number(
self,
unit_id: str,
device_type: str,
) -> Dict[str, Any]:
"""
Get current store/index number from device.
Args:
unit_id: Unit identifier
device_type: "slm" | "seismograph"
Returns:
Response dict with current index_number
"""
if device_type == "slm":
try:
return await self.slmm_client.get_index_number(unit_id)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_applicable",
"message": "Index number not applicable for seismographs",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# Cycle Commands (for scheduled automation)
# ========================================================================
async def start_cycle(
self,
unit_id: str,
device_type: str,
sync_clock: bool = True,
) -> Dict[str, Any]:
"""
Execute complete start cycle for scheduled automation.
This handles the full pre-recording workflow:
1. Sync device clock to server time
2. Find next safe index (with overwrite protection)
3. Start measurement
Args:
unit_id: Unit identifier
device_type: "slm" | "seismograph"
sync_clock: Whether to sync device clock to server time
Returns:
Response dict from device module
"""
if device_type == "slm":
try:
return await self.slmm_client.start_cycle(unit_id, sync_clock)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph start cycle not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
async def stop_cycle(
self,
unit_id: str,
device_type: str,
download: bool = True,
) -> Dict[str, Any]:
"""
Execute complete stop cycle for scheduled automation.
This handles the full post-recording workflow:
1. Stop measurement
2. Enable FTP
3. Download measurement folder
4. Verify download
Args:
unit_id: Unit identifier
device_type: "slm" | "seismograph"
download: Whether to download measurement data
Returns:
Response dict from device module
"""
if device_type == "slm":
try:
return await self.slmm_client.stop_cycle(unit_id, download)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph stop cycle not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# Health Check
# ========================================================================

View File

@@ -0,0 +1,184 @@
"""
Device Status Monitor
Background task that monitors device reachability via SLMM polling status
and triggers alerts when devices go offline or come back online.
This service bridges SLMM's device polling with Terra-View's alert system.
"""
import asyncio
import logging
from datetime import datetime
from typing import Optional, Dict
from backend.database import SessionLocal
from backend.services.slmm_client import get_slmm_client, SLMMClientError
from backend.services.alert_service import get_alert_service
logger = logging.getLogger(__name__)
class DeviceStatusMonitor:
"""
Monitors device reachability via SLMM's polling status endpoint.
Detects state transitions (online→offline, offline→online) and
triggers AlertService to create/resolve alerts.
Usage:
monitor = DeviceStatusMonitor()
await monitor.start() # Start background monitoring
monitor.stop() # Stop monitoring
"""
def __init__(self, check_interval: int = 60):
"""
Initialize the monitor.
Args:
check_interval: Seconds between status checks (default: 60)
"""
self.check_interval = check_interval
self.running = False
self.task: Optional[asyncio.Task] = None
self.slmm_client = get_slmm_client()
# Track previous device states to detect transitions
self._device_states: Dict[str, bool] = {}
async def start(self):
"""Start the monitoring background task."""
if self.running:
logger.warning("DeviceStatusMonitor is already running")
return
self.running = True
self.task = asyncio.create_task(self._monitor_loop())
logger.info(f"DeviceStatusMonitor started (checking every {self.check_interval}s)")
def stop(self):
"""Stop the monitoring background task."""
self.running = False
if self.task:
self.task.cancel()
logger.info("DeviceStatusMonitor stopped")
async def _monitor_loop(self):
"""Main monitoring loop."""
while self.running:
try:
await self._check_all_devices()
except Exception as e:
logger.error(f"Error in device status monitor: {e}", exc_info=True)
# Sleep in small intervals for graceful shutdown
for _ in range(self.check_interval):
if not self.running:
break
await asyncio.sleep(1)
logger.info("DeviceStatusMonitor loop exited")
async def _check_all_devices(self):
"""
Fetch polling status from SLMM and detect state transitions.
Uses GET /api/slmm/_polling/status (proxied to SLMM)
"""
try:
# Get status from SLMM
status_response = await self.slmm_client.get_polling_status()
devices = status_response.get("devices", [])
if not devices:
logger.debug("No devices in polling status response")
return
db = SessionLocal()
try:
alert_service = get_alert_service(db)
for device in devices:
unit_id = device.get("unit_id")
if not unit_id:
continue
is_reachable = device.get("is_reachable", True)
previous_reachable = self._device_states.get(unit_id)
# Skip if this is the first check (no previous state)
if previous_reachable is None:
self._device_states[unit_id] = is_reachable
logger.debug(f"Initial state for {unit_id}: reachable={is_reachable}")
continue
# Detect offline transition (was online, now offline)
if previous_reachable and not is_reachable:
logger.warning(f"Device {unit_id} went OFFLINE")
alert_service.create_device_offline_alert(
unit_id=unit_id,
consecutive_failures=device.get("consecutive_failures", 0),
last_error=device.get("last_error"),
)
# Detect online transition (was offline, now online)
elif not previous_reachable and is_reachable:
logger.info(f"Device {unit_id} came back ONLINE")
alert_service.resolve_device_offline_alert(unit_id)
# Update tracked state
self._device_states[unit_id] = is_reachable
# Cleanup expired alerts while we're here
alert_service.cleanup_expired_alerts()
finally:
db.close()
except SLMMClientError as e:
logger.warning(f"Could not reach SLMM for status check: {e}")
except Exception as e:
logger.error(f"Error checking device status: {e}", exc_info=True)
def get_tracked_devices(self) -> Dict[str, bool]:
"""
Get the current tracked device states.
Returns:
Dict mapping unit_id to is_reachable status
"""
return dict(self._device_states)
def clear_tracked_devices(self):
"""Clear all tracked device states (useful for testing)."""
self._device_states.clear()
# Singleton instance
_monitor_instance: Optional[DeviceStatusMonitor] = None
def get_device_status_monitor() -> DeviceStatusMonitor:
"""
Get the device status monitor singleton instance.
Returns:
DeviceStatusMonitor instance
"""
global _monitor_instance
if _monitor_instance is None:
_monitor_instance = DeviceStatusMonitor()
return _monitor_instance
async def start_device_status_monitor():
"""Start the global device status monitor."""
monitor = get_device_status_monitor()
await monitor.start()
def stop_device_status_monitor():
"""Stop the global device status monitor."""
monitor = get_device_status_monitor()
monitor.stop()

View File

@@ -0,0 +1,559 @@
"""
Recurring Schedule Service
Manages recurring schedule definitions and generates ScheduledAction
instances based on patterns (weekly calendar, simple interval).
"""
import json
import uuid
import logging
from datetime import datetime, timedelta, date, time
from typing import Optional, List, Dict, Any, Tuple
from zoneinfo import ZoneInfo
from sqlalchemy.orm import Session
from sqlalchemy import and_
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment
logger = logging.getLogger(__name__)
# Day name mapping
DAY_NAMES = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
class RecurringScheduleService:
"""
Service for managing recurring schedules and generating ScheduledActions.
Supports two schedule types:
- weekly_calendar: Specific days with start/end times
- simple_interval: Daily stop/download/restart cycles for 24/7 monitoring
"""
def __init__(self, db: Session):
self.db = db
def create_schedule(
self,
project_id: str,
location_id: str,
name: str,
schedule_type: str,
device_type: str = "slm",
unit_id: str = None,
weekly_pattern: dict = None,
interval_type: str = None,
cycle_time: str = None,
include_download: bool = True,
auto_increment_index: bool = True,
timezone: str = "America/New_York",
) -> RecurringSchedule:
"""
Create a new recurring schedule.
Args:
project_id: Project ID
location_id: Monitoring location ID
name: Schedule name
schedule_type: "weekly_calendar" or "simple_interval"
device_type: "slm" or "seismograph"
unit_id: Specific unit (optional, can use assignment)
weekly_pattern: Dict of day patterns for weekly_calendar
interval_type: "daily" or "hourly" for simple_interval
cycle_time: Time string "HH:MM" for cycle
include_download: Whether to download data on cycle
auto_increment_index: Whether to auto-increment store index before start
timezone: Timezone for schedule times
Returns:
Created RecurringSchedule
"""
schedule = RecurringSchedule(
id=str(uuid.uuid4()),
project_id=project_id,
location_id=location_id,
unit_id=unit_id,
name=name,
schedule_type=schedule_type,
device_type=device_type,
weekly_pattern=json.dumps(weekly_pattern) if weekly_pattern else None,
interval_type=interval_type,
cycle_time=cycle_time,
include_download=include_download,
auto_increment_index=auto_increment_index,
enabled=True,
timezone=timezone,
)
# Calculate next occurrence
schedule.next_occurrence = self._calculate_next_occurrence(schedule)
self.db.add(schedule)
self.db.commit()
self.db.refresh(schedule)
logger.info(f"Created recurring schedule: {name} ({schedule_type})")
return schedule
def update_schedule(
self,
schedule_id: str,
**kwargs,
) -> Optional[RecurringSchedule]:
"""
Update a recurring schedule.
Args:
schedule_id: Schedule to update
**kwargs: Fields to update
Returns:
Updated schedule or None
"""
schedule = self.db.query(RecurringSchedule).filter_by(id=schedule_id).first()
if not schedule:
return None
for key, value in kwargs.items():
if hasattr(schedule, key):
if key == "weekly_pattern" and isinstance(value, dict):
value = json.dumps(value)
setattr(schedule, key, value)
# Recalculate next occurrence
schedule.next_occurrence = self._calculate_next_occurrence(schedule)
self.db.commit()
self.db.refresh(schedule)
logger.info(f"Updated recurring schedule: {schedule.name}")
return schedule
def delete_schedule(self, schedule_id: str) -> bool:
"""
Delete a recurring schedule and its pending generated actions.
Args:
schedule_id: Schedule to delete
Returns:
True if deleted, False if not found
"""
schedule = self.db.query(RecurringSchedule).filter_by(id=schedule_id).first()
if not schedule:
return False
# Delete pending generated actions for this schedule
# The schedule_id is stored in the notes field as JSON
pending_actions = self.db.query(ScheduledAction).filter(
and_(
ScheduledAction.execution_status == "pending",
ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'),
)
).all()
deleted_count = len(pending_actions)
for action in pending_actions:
self.db.delete(action)
self.db.delete(schedule)
self.db.commit()
logger.info(f"Deleted recurring schedule: {schedule.name} (and {deleted_count} pending actions)")
return True
def enable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]:
"""Enable a disabled schedule."""
return self.update_schedule(schedule_id, enabled=True)
def disable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]:
"""Disable a schedule."""
return self.update_schedule(schedule_id, enabled=False)
def generate_actions_for_schedule(
self,
schedule: RecurringSchedule,
horizon_days: int = 7,
preview_only: bool = False,
) -> List[ScheduledAction]:
"""
Generate ScheduledAction entries for the next N days based on pattern.
Args:
schedule: The recurring schedule
horizon_days: Days ahead to generate
preview_only: If True, don't save to DB (for preview)
Returns:
List of generated ScheduledAction instances
"""
if not schedule.enabled:
return []
if schedule.schedule_type == "weekly_calendar":
actions = self._generate_weekly_calendar_actions(schedule, horizon_days)
elif schedule.schedule_type == "simple_interval":
actions = self._generate_interval_actions(schedule, horizon_days)
else:
logger.warning(f"Unknown schedule type: {schedule.schedule_type}")
return []
if not preview_only and actions:
for action in actions:
self.db.add(action)
schedule.last_generated_at = datetime.utcnow()
schedule.next_occurrence = self._calculate_next_occurrence(schedule)
self.db.commit()
logger.info(f"Generated {len(actions)} actions for schedule: {schedule.name}")
return actions
def _generate_weekly_calendar_actions(
self,
schedule: RecurringSchedule,
horizon_days: int,
) -> List[ScheduledAction]:
"""
Generate actions from weekly calendar pattern.
Pattern format:
{
"monday": {"enabled": true, "start": "19:00", "end": "07:00"},
"tuesday": {"enabled": false},
...
}
"""
if not schedule.weekly_pattern:
return []
try:
pattern = json.loads(schedule.weekly_pattern)
except json.JSONDecodeError:
logger.error(f"Invalid weekly_pattern JSON for schedule {schedule.id}")
return []
actions = []
tz = ZoneInfo(schedule.timezone)
now_utc = datetime.utcnow()
now_local = now_utc.replace(tzinfo=ZoneInfo("UTC")).astimezone(tz)
# Get unit_id (from schedule or assignment)
unit_id = self._resolve_unit_id(schedule)
for day_offset in range(horizon_days):
check_date = now_local.date() + timedelta(days=day_offset)
day_name = DAY_NAMES[check_date.weekday()]
day_config = pattern.get(day_name, {})
if not day_config.get("enabled", False):
continue
start_time_str = day_config.get("start")
end_time_str = day_config.get("end")
if not start_time_str or not end_time_str:
continue
# Parse times
start_time = self._parse_time(start_time_str)
end_time = self._parse_time(end_time_str)
if not start_time or not end_time:
continue
# Create start datetime in local timezone
start_local = datetime.combine(check_date, start_time, tzinfo=tz)
start_utc = start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
# Handle overnight schedules (end time is next day)
if end_time <= start_time:
end_date = check_date + timedelta(days=1)
else:
end_date = check_date
end_local = datetime.combine(end_date, end_time, tzinfo=tz)
end_utc = end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
# Skip if start time has already passed
if start_utc <= now_utc:
continue
# Check if action already exists
if self._action_exists(schedule.project_id, schedule.location_id, "start", start_utc):
continue
# Build notes with automation metadata
start_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"auto_increment_index": schedule.auto_increment_index,
})
# Create START action
start_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="start",
device_type=schedule.device_type,
scheduled_time=start_utc,
execution_status="pending",
notes=start_notes,
)
actions.append(start_action)
# Create STOP action
stop_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
})
stop_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="stop",
device_type=schedule.device_type,
scheduled_time=end_utc,
execution_status="pending",
notes=stop_notes,
)
actions.append(stop_action)
# Create DOWNLOAD action if enabled (1 minute after stop)
if schedule.include_download:
download_time = end_utc + timedelta(minutes=1)
download_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"schedule_type": "weekly_calendar",
})
download_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="download",
device_type=schedule.device_type,
scheduled_time=download_time,
execution_status="pending",
notes=download_notes,
)
actions.append(download_action)
return actions
def _generate_interval_actions(
self,
schedule: RecurringSchedule,
horizon_days: int,
) -> List[ScheduledAction]:
"""
Generate actions from simple interval pattern.
For daily cycles: stop, download (optional), start at cycle_time each day.
"""
if not schedule.cycle_time:
return []
cycle_time = self._parse_time(schedule.cycle_time)
if not cycle_time:
return []
actions = []
tz = ZoneInfo(schedule.timezone)
now_utc = datetime.utcnow()
now_local = now_utc.replace(tzinfo=ZoneInfo("UTC")).astimezone(tz)
# Get unit_id
unit_id = self._resolve_unit_id(schedule)
for day_offset in range(horizon_days):
check_date = now_local.date() + timedelta(days=day_offset)
# Create cycle datetime in local timezone
cycle_local = datetime.combine(check_date, cycle_time, tzinfo=tz)
cycle_utc = cycle_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
# Skip if time has passed
if cycle_utc <= now_utc:
continue
# Check if action already exists
if self._action_exists(schedule.project_id, schedule.location_id, "stop", cycle_utc):
continue
# Build notes with metadata
stop_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"cycle_type": "daily",
})
# Create STOP action
stop_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="stop",
device_type=schedule.device_type,
scheduled_time=cycle_utc,
execution_status="pending",
notes=stop_notes,
)
actions.append(stop_action)
# Create DOWNLOAD action if enabled (1 minute after stop)
if schedule.include_download:
download_time = cycle_utc + timedelta(minutes=1)
download_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"cycle_type": "daily",
})
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)
# Create START action (2 minutes after stop, or 1 minute after download)
start_offset = 2 if schedule.include_download else 1
start_time = cycle_utc + timedelta(minutes=start_offset)
start_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"cycle_type": "daily",
"auto_increment_index": schedule.auto_increment_index,
})
start_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="start",
device_type=schedule.device_type,
scheduled_time=start_time,
execution_status="pending",
notes=start_notes,
)
actions.append(start_action)
return actions
def _calculate_next_occurrence(self, schedule: RecurringSchedule) -> Optional[datetime]:
"""Calculate when the next action should occur."""
if not schedule.enabled:
return None
tz = ZoneInfo(schedule.timezone)
now_utc = datetime.utcnow()
now_local = now_utc.replace(tzinfo=ZoneInfo("UTC")).astimezone(tz)
if schedule.schedule_type == "weekly_calendar" and schedule.weekly_pattern:
try:
pattern = json.loads(schedule.weekly_pattern)
except:
return None
# Find next enabled day
for day_offset in range(8): # Check up to a week ahead
check_date = now_local.date() + timedelta(days=day_offset)
day_name = DAY_NAMES[check_date.weekday()]
day_config = pattern.get(day_name, {})
if day_config.get("enabled") and day_config.get("start"):
start_time = self._parse_time(day_config["start"])
if start_time:
start_local = datetime.combine(check_date, start_time, tzinfo=tz)
start_utc = start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
if start_utc > now_utc:
return start_utc
elif schedule.schedule_type == "simple_interval" and schedule.cycle_time:
cycle_time = self._parse_time(schedule.cycle_time)
if cycle_time:
# Find next cycle time
for day_offset in range(2):
check_date = now_local.date() + timedelta(days=day_offset)
cycle_local = datetime.combine(check_date, cycle_time, tzinfo=tz)
cycle_utc = cycle_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
if cycle_utc > now_utc:
return cycle_utc
return None
def _resolve_unit_id(self, schedule: RecurringSchedule) -> Optional[str]:
"""Get unit_id from schedule or active assignment."""
if schedule.unit_id:
return schedule.unit_id
# Try to get from active assignment
assignment = self.db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == schedule.location_id,
UnitAssignment.status == "active",
)
).first()
return assignment.unit_id if assignment else None
def _action_exists(
self,
project_id: str,
location_id: str,
action_type: str,
scheduled_time: datetime,
) -> bool:
"""Check if an action already exists for this time slot."""
# Allow 5-minute window for duplicate detection
time_window_start = scheduled_time - timedelta(minutes=5)
time_window_end = scheduled_time + timedelta(minutes=5)
exists = self.db.query(ScheduledAction).filter(
and_(
ScheduledAction.project_id == project_id,
ScheduledAction.location_id == location_id,
ScheduledAction.action_type == action_type,
ScheduledAction.scheduled_time >= time_window_start,
ScheduledAction.scheduled_time <= time_window_end,
ScheduledAction.execution_status == "pending",
)
).first()
return exists is not None
@staticmethod
def _parse_time(time_str: str) -> Optional[time]:
"""Parse time string "HH:MM" to time object."""
try:
parts = time_str.split(":")
return time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
return None
def get_schedules_for_project(self, project_id: str) -> List[RecurringSchedule]:
"""Get all recurring schedules for a project."""
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
def get_enabled_schedules(self) -> List[RecurringSchedule]:
"""Get all enabled recurring schedules."""
return self.db.query(RecurringSchedule).filter_by(enabled=True).all()
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:
"""Get a RecurringScheduleService instance."""
return RecurringScheduleService(db)

View File

@@ -4,22 +4,30 @@ Scheduler Service
Executes scheduled actions for Projects system.
Monitors pending scheduled actions and executes them by calling device modules (SLMM/SFM).
Extended to support recurring schedules:
- Generates ScheduledActions from RecurringSchedule patterns
- Cleans up old completed/failed actions
This service runs as a background task in FastAPI, checking for pending actions
every minute and executing them when their scheduled time arrives.
"""
import asyncio
import json
import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_
from backend.database import SessionLocal
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule
from backend.services.device_controller import get_device_controller, DeviceControllerError
from backend.services.alert_service import get_alert_service
import uuid
logger = logging.getLogger(__name__)
class SchedulerService:
"""
@@ -62,11 +70,26 @@ class SchedulerService:
async def _run_loop(self):
"""Main scheduler loop."""
# Track when we last generated recurring actions (do this once per hour)
last_generation_check = datetime.utcnow() - timedelta(hours=1)
while self.running:
try:
# Execute pending actions
await self.execute_pending_actions()
# Generate actions from recurring schedules (every hour)
now = datetime.utcnow()
if (now - last_generation_check).total_seconds() >= 3600:
await self.generate_recurring_actions()
last_generation_check = now
# Cleanup old actions (also every hour, during generation cycle)
if (now - last_generation_check).total_seconds() < 60:
await self.cleanup_old_actions()
except Exception as e:
print(f"Scheduler error: {e}")
logger.error(f"Scheduler error: {e}", exc_info=True)
# Continue running even if there's an error
await asyncio.sleep(self.check_interval)
@@ -175,6 +198,21 @@ class SchedulerService:
print(f"✓ Action {action.id} completed successfully")
# Create success alert
try:
alert_service = get_alert_service(db)
alert_metadata = response.get("cycle_response", {}) if isinstance(response, dict) else {}
alert_service.create_schedule_completed_alert(
schedule_id=action.id,
action_type=action.action_type,
unit_id=unit_id,
project_id=action.project_id,
location_id=action.location_id,
metadata=alert_metadata,
)
except Exception as alert_err:
logger.warning(f"Failed to create success alert: {alert_err}")
except Exception as e:
# Mark action as failed
action.execution_status = "failed"
@@ -185,6 +223,20 @@ class SchedulerService:
print(f"✗ Action {action.id} failed: {e}")
# Create failure alert
try:
alert_service = get_alert_service(db)
alert_service.create_schedule_failed_alert(
schedule_id=action.id,
action_type=action.action_type,
unit_id=unit_id if 'unit_id' in dir() else action.unit_id,
error_message=str(e),
project_id=action.project_id,
location_id=action.location_id,
)
except Exception as alert_err:
logger.warning(f"Failed to create failure alert: {alert_err}")
return result
async def _execute_start(
@@ -193,12 +245,19 @@ class SchedulerService:
unit_id: str,
db: Session,
) -> Dict[str, Any]:
"""Execute a 'start' action."""
# Start recording via device controller
response = await self.device_controller.start_recording(
"""Execute a 'start' action using the start_cycle command.
start_cycle handles:
1. Sync device clock to server time
2. Find next safe index (with overwrite protection)
3. Start measurement
"""
# Execute the full start cycle via device controller
# SLMM handles clock sync, index increment, and start
cycle_response = await self.device_controller.start_cycle(
unit_id,
action.device_type,
config={}, # TODO: Load config from action.notes or metadata
sync_clock=True,
)
# Create recording session
@@ -210,14 +269,17 @@ class SchedulerService:
session_type="sound" if action.device_type == "slm" else "vibration",
started_at=datetime.utcnow(),
status="recording",
session_metadata=json.dumps({"scheduled_action_id": action.id}),
session_metadata=json.dumps({
"scheduled_action_id": action.id,
"cycle_response": cycle_response,
}),
)
db.add(session)
return {
"status": "started",
"session_id": session.id,
"device_response": response,
"cycle_response": cycle_response,
}
async def _execute_stop(
@@ -226,11 +288,29 @@ class SchedulerService:
unit_id: str,
db: Session,
) -> Dict[str, Any]:
"""Execute a 'stop' action."""
# Stop recording via device controller
response = await self.device_controller.stop_recording(
"""Execute a 'stop' action using the stop_cycle command.
stop_cycle handles:
1. Stop measurement
2. Enable FTP
3. Download measurement folder
4. Verify download
"""
# Parse notes for download preference
include_download = True
try:
if action.notes:
notes_data = json.loads(action.notes)
include_download = notes_data.get("include_download", True)
except json.JSONDecodeError:
pass # Notes is plain text, not JSON
# Execute the full stop cycle via device controller
# SLMM handles stop, FTP enable, and download
cycle_response = await self.device_controller.stop_cycle(
unit_id,
action.device_type,
download=include_download,
)
# Find and update the active recording session
@@ -248,11 +328,20 @@ class SchedulerService:
active_session.duration_seconds = int(
(active_session.stopped_at - active_session.started_at).total_seconds()
)
# Store download info in session metadata
if cycle_response.get("download_success"):
try:
metadata = json.loads(active_session.session_metadata or "{}")
metadata["downloaded_folder"] = cycle_response.get("downloaded_folder")
metadata["local_path"] = cycle_response.get("local_path")
active_session.session_metadata = json.dumps(metadata)
except json.JSONDecodeError:
pass
return {
"status": "stopped",
"session_id": active_session.id if active_session else None,
"device_response": response,
"cycle_response": cycle_response,
}
async def _execute_download(
@@ -261,7 +350,14 @@ class SchedulerService:
unit_id: str,
db: Session,
) -> Dict[str, Any]:
"""Execute a 'download' action."""
"""Execute a 'download' action.
This handles standalone download actions (not part of stop_cycle).
The workflow is:
1. Enable FTP on device
2. Download current measurement folder
3. (Optionally disable FTP - left enabled for now)
"""
# Get project and location info for file path
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
project = db.query(Project).filter_by(id=action.project_id).first()
@@ -269,8 +365,8 @@ class SchedulerService:
if not location or not project:
raise Exception("Project or location not found")
# Build destination path
# Example: data/Projects/{project-id}/sound/{location-name}/session-{timestamp}/
# Build destination path (for logging/metadata reference)
# Actual download location is managed by SLMM (data/downloads/{unit_id}/)
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
@@ -279,12 +375,18 @@ class SchedulerService:
f"{location.name}/session-{session_timestamp}/"
)
# Download files via device controller
# Step 1: Enable FTP on device
logger.info(f"Enabling FTP on {unit_id} for download")
await self.device_controller.enable_ftp(unit_id, action.device_type)
# Step 2: Download current measurement folder
# The slmm_client.download_files() now automatically determines the correct
# folder based on the device's current index number
response = await self.device_controller.download_files(
unit_id,
action.device_type,
destination_path,
files=None, # Download all files
files=None, # Download all files in current measurement folder
)
# TODO: Create DataFile records for downloaded files
@@ -295,6 +397,90 @@ class SchedulerService:
"device_response": response,
}
# ========================================================================
# Recurring Schedule Generation
# ========================================================================
async def generate_recurring_actions(self) -> int:
"""
Generate ScheduledActions from all enabled recurring schedules.
Runs once per hour to generate actions for the next 7 days.
Returns:
Total number of actions generated
"""
db = SessionLocal()
total_generated = 0
try:
from backend.services.recurring_schedule_service import get_recurring_schedule_service
service = get_recurring_schedule_service(db)
schedules = service.get_enabled_schedules()
if not schedules:
logger.debug("No enabled recurring schedules found")
return 0
logger.info(f"Generating actions for {len(schedules)} recurring schedule(s)")
for schedule in schedules:
try:
actions = service.generate_actions_for_schedule(schedule, horizon_days=7)
total_generated += len(actions)
except Exception as e:
logger.error(f"Error generating actions for schedule {schedule.id}: {e}")
if total_generated > 0:
logger.info(f"Generated {total_generated} scheduled actions from recurring schedules")
except Exception as e:
logger.error(f"Error in generate_recurring_actions: {e}", exc_info=True)
finally:
db.close()
return total_generated
async def cleanup_old_actions(self, retention_days: int = 30) -> int:
"""
Remove old completed/failed actions to prevent database bloat.
Args:
retention_days: Keep actions newer than this many days
Returns:
Number of actions cleaned up
"""
db = SessionLocal()
cleaned = 0
try:
cutoff = datetime.utcnow() - timedelta(days=retention_days)
old_actions = db.query(ScheduledAction).filter(
and_(
ScheduledAction.execution_status.in_(["completed", "failed", "cancelled"]),
ScheduledAction.executed_at < cutoff,
)
).all()
cleaned = len(old_actions)
for action in old_actions:
db.delete(action)
if cleaned > 0:
db.commit()
logger.info(f"Cleaned up {cleaned} old scheduled actions (>{retention_days} days)")
except Exception as e:
logger.error(f"Error cleaning up old actions: {e}")
db.rollback()
finally:
db.close()
return cleaned
# ========================================================================
# Manual Execution (for testing/debugging)
# ========================================================================

View File

@@ -0,0 +1,125 @@
"""
SLM Status Synchronization Service
Syncs SLM device status from SLMM backend to Terra-View's Emitter table.
This bridges SLMM's polling data with Terra-View's status snapshot system.
SLMM tracks device reachability via background polling. This service
fetches that data and creates/updates Emitter records so SLMs appear
correctly in the dashboard status snapshot.
"""
import logging
from datetime import datetime, timezone
from typing import Dict, Any
from backend.database import get_db_session
from backend.models import Emitter
from backend.services.slmm_client import get_slmm_client, SLMMClientError
logger = logging.getLogger(__name__)
async def sync_slm_status_to_emitters() -> Dict[str, Any]:
"""
Fetch SLM status from SLMM and sync to Terra-View's Emitter table.
For each device in SLMM's polling status:
- If last_success exists, create/update Emitter with that timestamp
- If not reachable, update Emitter with last known timestamp (or None)
Returns:
Dict with synced_count, error_count, errors list
"""
client = get_slmm_client()
synced = 0
errors = []
try:
# Get polling status from SLMM
status_response = await client.get_polling_status()
# Handle nested response structure
data = status_response.get("data", status_response)
devices = data.get("devices", [])
if not devices:
logger.debug("No SLM devices in SLMM polling status")
return {"synced_count": 0, "error_count": 0, "errors": []}
db = get_db_session()
try:
for device in devices:
unit_id = device.get("unit_id")
if not unit_id:
continue
try:
# Get or create Emitter record
emitter = db.query(Emitter).filter(Emitter.id == unit_id).first()
# Determine last_seen from SLMM data
last_success_str = device.get("last_success")
is_reachable = device.get("is_reachable", False)
if last_success_str:
# Parse ISO format timestamp
last_seen = datetime.fromisoformat(
last_success_str.replace("Z", "+00:00")
)
# Convert to naive UTC for consistency with existing code
if last_seen.tzinfo:
last_seen = last_seen.astimezone(timezone.utc).replace(tzinfo=None)
else:
last_seen = None
# Status will be recalculated by snapshot.py based on time thresholds
# Just store a provisional status here
status = "OK" if is_reachable else "Missing"
# Store last error message if available
last_error = device.get("last_error") or ""
if emitter:
# Update existing record
emitter.last_seen = last_seen
emitter.status = status
emitter.unit_type = "slm"
emitter.last_file = last_error
else:
# Create new record
emitter = Emitter(
id=unit_id,
unit_type="slm",
last_seen=last_seen,
last_file=last_error,
status=status
)
db.add(emitter)
synced += 1
except Exception as e:
errors.append(f"{unit_id}: {str(e)}")
logger.error(f"Error syncing SLM {unit_id}: {e}")
db.commit()
finally:
db.close()
if synced > 0:
logger.info(f"Synced {synced} SLM device(s) to Emitter table")
except SLMMClientError as e:
logger.warning(f"Could not reach SLMM for status sync: {e}")
errors.append(f"SLMM unreachable: {str(e)}")
except Exception as e:
logger.error(f"Error in SLM status sync: {e}", exc_info=True)
errors.append(str(e))
return {
"synced_count": synced,
"error_count": len(errors),
"errors": errors
}

View File

@@ -9,13 +9,14 @@ that handles TCP/FTP communication with Rion NL-43/NL-53 devices.
"""
import httpx
import os
from typing import Optional, Dict, Any, List
from datetime import datetime
import json
# SLMM backend base URLs
SLMM_BASE_URL = "http://localhost:8100"
# SLMM backend base URLs - use environment variable if set (for Docker)
SLMM_BASE_URL = os.environ.get("SLMM_BASE_URL", "http://localhost:8100")
SLMM_API_BASE = f"{SLMM_BASE_URL}/api/nl43"
@@ -276,6 +277,124 @@ class SLMMClient:
"""
return await self._request("POST", f"/{unit_id}/reset")
# ========================================================================
# Store/Index Management
# ========================================================================
async def get_index_number(self, unit_id: str) -> Dict[str, Any]:
"""
Get current store/index number from device.
Args:
unit_id: Unit identifier
Returns:
Dict with current index_number (store name)
"""
return await self._request("GET", f"/{unit_id}/index-number")
async def set_index_number(
self,
unit_id: str,
index_number: int,
) -> Dict[str, Any]:
"""
Set store/index number on device.
Args:
unit_id: Unit identifier
index_number: New index number to set
Returns:
Confirmation response
"""
return await self._request(
"PUT",
f"/{unit_id}/index-number",
data={"index_number": index_number},
)
async def check_overwrite_status(self, unit_id: str) -> Dict[str, Any]:
"""
Check if data exists at the current store index.
Args:
unit_id: Unit identifier
Returns:
Dict with:
- overwrite_status: "None" (safe) or "Exist" (would overwrite)
- will_overwrite: bool
- safe_to_store: bool
"""
return await self._request("GET", f"/{unit_id}/overwrite-check")
async def increment_index(self, unit_id: str, max_attempts: int = 100) -> Dict[str, Any]:
"""
Find and set the next available (unused) store/index number.
Checks the current index - if it would overwrite existing data,
increments until finding an unused index number.
Args:
unit_id: Unit identifier
max_attempts: Maximum number of indices to try before giving up
Returns:
Dict with old_index, new_index, and attempts_made
"""
# Get current index
current = await self.get_index_number(unit_id)
old_index = current.get("index_number", 0)
# Check if current index is safe
overwrite_check = await self.check_overwrite_status(unit_id)
if overwrite_check.get("safe_to_store", False):
# Current index is safe, no need to increment
return {
"success": True,
"old_index": old_index,
"new_index": old_index,
"unit_id": unit_id,
"already_safe": True,
"attempts_made": 0,
}
# Need to find an unused index
attempts = 0
test_index = old_index + 1
while attempts < max_attempts:
# Set the new index
await self.set_index_number(unit_id, test_index)
# Check if this index is safe
overwrite_check = await self.check_overwrite_status(unit_id)
attempts += 1
if overwrite_check.get("safe_to_store", False):
return {
"success": True,
"old_index": old_index,
"new_index": test_index,
"unit_id": unit_id,
"already_safe": False,
"attempts_made": attempts,
}
# Try next index (wrap around at 9999)
test_index = (test_index + 1) % 10000
# Avoid infinite loops if we've wrapped around
if test_index == old_index:
break
# Could not find a safe index
raise SLMMDeviceError(
f"Could not find unused store index for {unit_id} after {attempts} attempts. "
f"Consider downloading and clearing data from the device."
)
# ========================================================================
# Device Settings
# ========================================================================
@@ -359,9 +478,118 @@ class SLMMClient:
return await self._request("GET", f"/{unit_id}/settings")
# ========================================================================
# Data Download (Future)
# FTP Control
# ========================================================================
async def enable_ftp(self, unit_id: str) -> Dict[str, Any]:
"""
Enable FTP server on device.
Must be called before downloading files. FTP and TCP can work in tandem.
Args:
unit_id: Unit identifier
Returns:
Dict with status message
"""
return await self._request("POST", f"/{unit_id}/ftp/enable")
async def disable_ftp(self, unit_id: str) -> Dict[str, Any]:
"""
Disable FTP server on device.
Args:
unit_id: Unit identifier
Returns:
Dict with status message
"""
return await self._request("POST", f"/{unit_id}/ftp/disable")
async def get_ftp_status(self, unit_id: str) -> Dict[str, Any]:
"""
Get FTP server status on device.
Args:
unit_id: Unit identifier
Returns:
Dict with ftp_enabled status
"""
return await self._request("GET", f"/{unit_id}/ftp/status")
# ========================================================================
# Data Download
# ========================================================================
async def download_file(
self,
unit_id: str,
remote_path: str,
) -> Dict[str, Any]:
"""
Download a single file from unit via FTP.
Args:
unit_id: Unit identifier
remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav")
Returns:
Binary file content (as response)
"""
data = {"remote_path": remote_path}
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
async def download_folder(
self,
unit_id: str,
remote_path: str,
) -> Dict[str, Any]:
"""
Download an entire folder from unit via FTP as a ZIP archive.
Useful for downloading complete measurement sessions (e.g., Auto_0000 folders).
Args:
unit_id: Unit identifier
remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000")
Returns:
Dict with local_path, folder_name, file_count, zip_size_bytes
"""
data = {"remote_path": remote_path}
return await self._request("POST", f"/{unit_id}/ftp/download-folder", data=data)
async def download_current_measurement(
self,
unit_id: str,
) -> Dict[str, Any]:
"""
Download the current measurement folder based on device's index number.
This is the recommended method for scheduled downloads - it automatically
determines which folder to download based on the device's current store index.
Args:
unit_id: Unit identifier
Returns:
Dict with local_path, folder_name, file_count, zip_size_bytes, index_number
"""
# Get current index number from device
index_info = await self.get_index_number(unit_id)
index_number = index_info.get("index_number", 0)
# Format as Auto_XXXX folder name
folder_name = f"Auto_{index_number:04d}"
remote_path = f"/NL43_DATA/{folder_name}"
# Download the folder
result = await self.download_folder(unit_id, remote_path)
result["index_number"] = index_number
return result
async def download_files(
self,
unit_id: str,
@@ -369,23 +597,153 @@ class SLMMClient:
files: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Download files from unit via FTP.
Download measurement files from unit via FTP.
NOTE: This endpoint doesn't exist in SLMM yet. Will need to implement.
This method automatically determines the current measurement folder and downloads it.
The destination_path parameter is logged for reference but actual download location
is managed by SLMM (data/downloads/{unit_id}/).
Args:
unit_id: Unit identifier
destination_path: Local path to save files
files: List of filenames to download, or None for all
destination_path: Reference path (for logging/metadata, not used by SLMM)
files: Ignored - always downloads the current measurement folder
Returns:
Dict with downloaded files list and metadata
Dict with download result including local_path, folder_name, etc.
"""
data = {
"destination_path": destination_path,
"files": files or "all",
}
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
# Use the new method that automatically determines what to download
result = await self.download_current_measurement(unit_id)
result["requested_destination"] = destination_path
return result
# ========================================================================
# Cycle Commands (for scheduled automation)
# ========================================================================
async def start_cycle(
self,
unit_id: str,
sync_clock: bool = True,
) -> Dict[str, Any]:
"""
Execute complete start cycle on device via SLMM.
This handles the full pre-recording workflow:
1. Sync device clock to server time
2. Find next safe index (with overwrite protection)
3. Start measurement
Args:
unit_id: Unit identifier
sync_clock: Whether to sync device clock to server time
Returns:
Dict with clock_synced, old_index, new_index, started, etc.
"""
return await self._request(
"POST",
f"/{unit_id}/start-cycle",
data={"sync_clock": sync_clock},
)
async def stop_cycle(
self,
unit_id: str,
download: bool = True,
download_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
Execute complete stop cycle on device via SLMM.
This handles the full post-recording workflow:
1. Stop measurement
2. Enable FTP
3. Download measurement folder (if download=True)
4. Verify download
Args:
unit_id: Unit identifier
download: Whether to download measurement data
download_path: Custom path for downloaded ZIP (optional)
Returns:
Dict with stopped, ftp_enabled, download_success, local_path, etc.
"""
data = {"download": download}
if download_path:
data["download_path"] = download_path
return await self._request(
"POST",
f"/{unit_id}/stop-cycle",
data=data,
)
# ========================================================================
# Polling Status (for device monitoring/alerts)
# ========================================================================
async def get_polling_status(self) -> Dict[str, Any]:
"""
Get global polling status from SLMM.
Returns device reachability information for all polled devices.
Used by DeviceStatusMonitor to detect offline/online transitions.
Returns:
Dict with devices list containing:
- unit_id
- is_reachable
- consecutive_failures
- last_poll_attempt
- last_success
- last_error
"""
try:
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(f"{self.base_url}/api/nl43/_polling/status")
response.raise_for_status()
return response.json()
except httpx.ConnectError:
raise SLMMConnectionError("Cannot connect to SLMM for polling status")
except Exception as e:
raise SLMMClientError(f"Failed to get polling status: {str(e)}")
async def get_device_polling_config(self, unit_id: str) -> Dict[str, Any]:
"""
Get polling configuration for a specific device.
Args:
unit_id: Unit identifier
Returns:
Dict with poll_enabled and poll_interval_seconds
"""
return await self._request("GET", f"/{unit_id}/polling/config")
async def update_device_polling_config(
self,
unit_id: str,
poll_enabled: Optional[bool] = None,
poll_interval_seconds: Optional[int] = None,
) -> Dict[str, Any]:
"""
Update polling configuration for a device.
Args:
unit_id: Unit identifier
poll_enabled: Enable/disable polling
poll_interval_seconds: Polling interval (10-3600)
Returns:
Updated config
"""
config = {}
if poll_enabled is not None:
config["poll_enabled"] = poll_enabled
if poll_interval_seconds is not None:
config["poll_interval_seconds"] = poll_interval_seconds
return await self._request("PUT", f"/{unit_id}/polling/config", data=config)
# ========================================================================
# Health Check

View File

@@ -36,6 +36,10 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
logger.warning(f"SLM {unit.id} has no host configured, skipping SLMM sync")
return False
# Disable polling if unit is benched (deployed=False) or retired
# Only actively deployed units should be polled
should_poll = unit.deployed and not unit.retired
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.put(
@@ -47,8 +51,8 @@ async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
"ftp_enabled": True,
"ftp_username": "USER", # Default NL43 credentials
"ftp_password": "0000",
"poll_enabled": not unit.retired, # Disable polling for retired units
"poll_interval_seconds": 60, # Default interval
"poll_enabled": should_poll, # Disable polling for benched or retired units
"poll_interval_seconds": 3600, # Default to 1 hour polling
}
)

View File

@@ -108,6 +108,7 @@ def emit_status_snapshot():
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
"deployed_with_modem_id": r.deployed_with_modem_id,
"deployed_with_unit_id": r.deployed_with_unit_id,
"ip_address": r.ip_address,
"phone_number": r.phone_number,
"hardware_model": r.hardware_model,
@@ -137,6 +138,7 @@ def emit_status_snapshot():
"last_calibrated": None,
"next_calibration_due": None,
"deployed_with_modem_id": None,
"deployed_with_unit_id": None,
"ip_address": None,
"phone_number": None,
"hardware_model": None,
@@ -146,6 +148,34 @@ def emit_status_snapshot():
"coordinates": "",
}
# --- Derive modem status from paired devices ---
# Modems don't have their own check-in system, so we inherit status
# from whatever device they're paired with (seismograph or SLM)
# Check both directions: modem.deployed_with_unit_id OR device.deployed_with_modem_id
for unit_id, unit_data in units.items():
if unit_data.get("device_type") == "modem" and not unit_data.get("retired"):
paired_unit_id = None
roster_unit = roster.get(unit_id)
# First, check if modem has deployed_with_unit_id set
if roster_unit and roster_unit.deployed_with_unit_id:
paired_unit_id = roster_unit.deployed_with_unit_id
else:
# Fallback: check if any device has this modem in deployed_with_modem_id
for other_id, other_roster in roster.items():
if other_roster.deployed_with_modem_id == unit_id:
paired_unit_id = other_id
break
if paired_unit_id:
paired_unit = units.get(paired_unit_id)
if paired_unit:
# Inherit status from paired device
unit_data["status"] = paired_unit.get("status", "Missing")
unit_data["age"] = paired_unit.get("age", "N/A")
unit_data["last"] = paired_unit.get("last")
unit_data["derived_from"] = paired_unit_id
# Separate buckets for UI
active_units = {
uid: u for uid, u in units.items()

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -0,0 +1,39 @@
"""
Shared Jinja2 templates configuration.
All routers should import `templates` from this module to get consistent
filter and global function registration.
"""
from fastapi.templating import Jinja2Templates
# Import timezone utilities
from backend.utils.timezone import (
format_local_datetime, format_local_time,
get_user_timezone, get_timezone_abbreviation
)
def jinja_local_datetime(dt, fmt="%Y-%m-%d %H:%M"):
"""Jinja filter to convert UTC datetime to local timezone."""
return format_local_datetime(dt, fmt)
def jinja_local_time(dt):
"""Jinja filter to format time in local timezone."""
return format_local_time(dt)
def jinja_timezone_abbr():
"""Jinja global to get current timezone abbreviation."""
return get_timezone_abbreviation()
# Create templates instance
templates = Jinja2Templates(directory="templates")
# Register Jinja filters and globals
templates.env.filters["local_datetime"] = jinja_local_datetime
templates.env.filters["local_time"] = jinja_local_time
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
templates.env.globals["get_user_timezone"] = get_user_timezone

View File

@@ -0,0 +1 @@
# Utils package

173
backend/utils/timezone.py Normal file
View File

@@ -0,0 +1,173 @@
"""
Timezone utilities for Terra-View.
Provides consistent timezone handling throughout the application.
All database times are stored in UTC; this module converts for display.
"""
from datetime import datetime
from zoneinfo import ZoneInfo
from typing import Optional
from backend.database import SessionLocal
from backend.models import UserPreferences
# Default timezone if none set
DEFAULT_TIMEZONE = "America/New_York"
def get_user_timezone() -> str:
"""
Get the user's configured timezone from preferences.
Returns:
Timezone string (e.g., "America/New_York")
"""
db = SessionLocal()
try:
prefs = db.query(UserPreferences).filter_by(id=1).first()
if prefs and prefs.timezone:
return prefs.timezone
return DEFAULT_TIMEZONE
finally:
db.close()
def get_timezone_info(tz_name: str = None) -> ZoneInfo:
"""
Get ZoneInfo object for the specified or user's timezone.
Args:
tz_name: Timezone name, or None to use user preference
Returns:
ZoneInfo object
"""
if tz_name is None:
tz_name = get_user_timezone()
try:
return ZoneInfo(tz_name)
except Exception:
return ZoneInfo(DEFAULT_TIMEZONE)
def utc_to_local(dt: datetime, tz_name: str = None) -> datetime:
"""
Convert a UTC datetime to local timezone.
Args:
dt: Datetime in UTC (naive or aware)
tz_name: Target timezone, or None to use user preference
Returns:
Datetime in local timezone
"""
if dt is None:
return None
tz = get_timezone_info(tz_name)
# Assume naive datetime is UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
return dt.astimezone(tz)
def local_to_utc(dt: datetime, tz_name: str = None) -> datetime:
"""
Convert a local datetime to UTC.
Args:
dt: Datetime in local timezone (naive or aware)
tz_name: Source timezone, or None to use user preference
Returns:
Datetime in UTC (naive, for database storage)
"""
if dt is None:
return None
tz = get_timezone_info(tz_name)
# Assume naive datetime is in local timezone
if dt.tzinfo is None:
dt = dt.replace(tzinfo=tz)
# Convert to UTC and strip tzinfo for database storage
return dt.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
def format_local_datetime(dt: datetime, fmt: str = "%Y-%m-%d %H:%M", tz_name: str = None) -> str:
"""
Format a UTC datetime as local time string.
Args:
dt: Datetime in UTC
fmt: strftime format string
tz_name: Target timezone, or None to use user preference
Returns:
Formatted datetime string in local time
"""
if dt is None:
return "N/A"
local_dt = utc_to_local(dt, tz_name)
return local_dt.strftime(fmt)
def format_local_time(dt: datetime, tz_name: str = None) -> str:
"""
Format a UTC datetime as local time (HH:MM format).
Args:
dt: Datetime in UTC
tz_name: Target timezone
Returns:
Time string in HH:MM format
"""
return format_local_datetime(dt, "%H:%M", tz_name)
def format_local_date(dt: datetime, tz_name: str = None) -> str:
"""
Format a UTC datetime as local date (YYYY-MM-DD format).
Args:
dt: Datetime in UTC
tz_name: Target timezone
Returns:
Date string
"""
return format_local_datetime(dt, "%Y-%m-%d", tz_name)
def get_timezone_abbreviation(tz_name: str = None) -> str:
"""
Get the abbreviation for a timezone (e.g., EST, EDT, PST).
Args:
tz_name: Timezone name, or None to use user preference
Returns:
Timezone abbreviation
"""
tz = get_timezone_info(tz_name)
now = datetime.now(tz)
return now.strftime("%Z")
# Common US timezone choices for settings dropdown
TIMEZONE_CHOICES = [
("America/New_York", "Eastern Time (ET)"),
("America/Chicago", "Central Time (CT)"),
("America/Denver", "Mountain Time (MT)"),
("America/Los_Angeles", "Pacific Time (PT)"),
("America/Anchorage", "Alaska Time (AKT)"),
("Pacific/Honolulu", "Hawaii Time (HT)"),
("UTC", "UTC"),
]

View File

@@ -1,7 +1,7 @@
services:
# --- TERRA-VIEW PRODUCTION ---
terra-view-prod:
terra-view:
build: .
container_name: terra-view
ports:

View File

@@ -7,3 +7,4 @@ jinja2==3.1.2
aiofiles==23.2.1
Pillow==10.1.0
httpx==0.25.2
openpyxl==3.1.2

View File

@@ -1,6 +1,23 @@
unit_id,unit_type,deployed,retired,note,project_id,location
BE1234,series3,true,false,Primary unit at main site,PROJ-001,San Francisco CA
BE5678,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA
BE9012,series3,false,false,In maintenance,PROJ-002,Workshop
BE3456,series3,true,false,,PROJ-003,New York NY
BE7890,series3,false,true,Decommissioned 2024,,Storage
unit_id,device_type,unit_type,deployed,retired,note,project_id,location,address,coordinates,last_calibrated,next_calibration_due,deployed_with_modem_id,ip_address,phone_number,hardware_model,slm_host,slm_tcp_port,slm_ftp_port,slm_model,slm_serial_number,slm_frequency_weighting,slm_time_weighting,slm_measurement_range
# ============================================
# SEISMOGRAPHS (device_type=seismograph)
# ============================================
BE1234,seismograph,series3,true,false,Primary unit at main site,PROJ-001,San Francisco CA,123 Market St,37.7749;-122.4194,2025-06-15,2026-06-15,MDM001,,,,,,,,,,,
BE5678,seismograph,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA,456 Sunset Blvd,34.0522;-118.2437,2025-03-01,2026-03-01,MDM002,,,,,,,,,,,
BE9012,seismograph,series4,false,false,In maintenance - needs calibration,PROJ-002,Workshop,789 Industrial Way,,,,,,,,,,,,,,
BE3456,seismograph,series3,true,false,,PROJ-003,New York NY,101 Broadway,40.7128;-74.0060,2025-01-10,2026-01-10,,,,,,,,,,,
BE7890,seismograph,series3,false,true,Decommissioned 2024,,Storage,Warehouse B,,,,,,,,,,,,,,,
# ============================================
# MODEMS (device_type=modem)
# ============================================
MDM001,modem,,true,false,Cradlepoint at SF site,PROJ-001,San Francisco CA,123 Market St,37.7749;-122.4194,,,,,192.168.1.100,+1-555-0101,IBR900,,,,,,,
MDM002,modem,,true,false,Sierra Wireless at LA site,PROJ-001,Los Angeles CA,456 Sunset Blvd,34.0522;-118.2437,,,,,10.0.0.50,+1-555-0102,RV55,,,,,,,
MDM003,modem,,false,false,Spare modem in storage,,,Storage,Warehouse A,,,,,,+1-555-0103,IBR600,,,,,,,
MDM004,modem,,true,false,NYC backup modem,PROJ-003,New York NY,101 Broadway,40.7128;-74.0060,,,,,172.16.0.25,+1-555-0104,IBR1700,,,,,,,
# ============================================
# SOUND LEVEL METERS (device_type=slm)
# ============================================
SLM001,slm,,true,false,NL-43 at construction site A,PROJ-004,Downtown Site,500 Main St,40.7589;-73.9851,,,,,,,,192.168.10.101,2255,21,NL-43,12345678,A,F,30-130 dB
SLM002,slm,,true,false,NL-43 at construction site B,PROJ-004,Midtown Site,600 Park Ave,40.7614;-73.9776,,,MDM004,,,,,192.168.10.102,2255,21,NL-43,12345679,A,S,30-130 dB
SLM003,slm,,false,false,NL-53 spare unit,,,Storage,Warehouse A,,,,,,,,,,,NL-53,98765432,C,F,25-138 dB
SLM004,slm,,true,false,NL-43 nighttime monitoring,PROJ-005,Residential Area,200 Quiet Lane,40.7484;-73.9857,,,,,,,,10.0.5.50,2255,21,NL-43,11112222,A,S,30-130 dB
1 unit_id unit_id,device_type,unit_type,deployed,retired,note,project_id,location,address,coordinates,last_calibrated,next_calibration_due,deployed_with_modem_id,ip_address,phone_number,hardware_model,slm_host,slm_tcp_port,slm_ftp_port,slm_model,slm_serial_number,slm_frequency_weighting,slm_time_weighting,slm_measurement_range unit_type deployed retired note project_id location
2 BE1234 # ============================================ series3 true false Primary unit at main site PROJ-001 San Francisco CA
3 BE5678 # SEISMOGRAPHS (device_type=seismograph) series3 true false Backup sensor PROJ-001 Los Angeles CA
4 BE9012 # ============================================ series3 false false In maintenance PROJ-002 Workshop
5 BE3456 BE1234,seismograph,series3,true,false,Primary unit at main site,PROJ-001,San Francisco CA,123 Market St,37.7749;-122.4194,2025-06-15,2026-06-15,MDM001,,,,,,,,,,, series3 true false PROJ-003 New York NY
6 BE7890 BE5678,seismograph,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA,456 Sunset Blvd,34.0522;-118.2437,2025-03-01,2026-03-01,MDM002,,,,,,,,,,, series3 false true Decommissioned 2024 Storage
7 BE9012,seismograph,series4,false,false,In maintenance - needs calibration,PROJ-002,Workshop,789 Industrial Way,,,,,,,,,,,,,,
8 BE3456,seismograph,series3,true,false,,PROJ-003,New York NY,101 Broadway,40.7128;-74.0060,2025-01-10,2026-01-10,,,,,,,,,,,
9 BE7890,seismograph,series3,false,true,Decommissioned 2024,,Storage,Warehouse B,,,,,,,,,,,,,,,
10 # ============================================
11 # MODEMS (device_type=modem)
12 # ============================================
13 MDM001,modem,,true,false,Cradlepoint at SF site,PROJ-001,San Francisco CA,123 Market St,37.7749;-122.4194,,,,,192.168.1.100,+1-555-0101,IBR900,,,,,,,
14 MDM002,modem,,true,false,Sierra Wireless at LA site,PROJ-001,Los Angeles CA,456 Sunset Blvd,34.0522;-118.2437,,,,,10.0.0.50,+1-555-0102,RV55,,,,,,,
15 MDM003,modem,,false,false,Spare modem in storage,,,Storage,Warehouse A,,,,,,+1-555-0103,IBR600,,,,,,,
16 MDM004,modem,,true,false,NYC backup modem,PROJ-003,New York NY,101 Broadway,40.7128;-74.0060,,,,,172.16.0.25,+1-555-0104,IBR1700,,,,,,,
17 # ============================================
18 # SOUND LEVEL METERS (device_type=slm)
19 # ============================================
20 SLM001,slm,,true,false,NL-43 at construction site A,PROJ-004,Downtown Site,500 Main St,40.7589;-73.9851,,,,,,,,192.168.10.101,2255,21,NL-43,12345678,A,F,30-130 dB
21 SLM002,slm,,true,false,NL-43 at construction site B,PROJ-004,Midtown Site,600 Park Ave,40.7614;-73.9776,,,MDM004,,,,,192.168.10.102,2255,21,NL-43,12345679,A,S,30-130 dB
22 SLM003,slm,,false,false,NL-53 spare unit,,,Storage,Warehouse A,,,,,,,,,,,NL-53,98765432,C,F,25-138 dB
23 SLM004,slm,,true,false,NL-43 nighttime monitoring,PROJ-005,Residential Area,200 Quiet Lane,40.7484;-73.9857,,,,,,,,10.0.5.50,2255,21,NL-43,11112222,A,S,30-130 dB

View File

@@ -20,6 +20,9 @@
<!-- PWA Manifest -->
<link rel="manifest" href="/static/manifest.json">
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-192.png">
<meta name="theme-color" content="#f48b1c">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@@ -68,7 +71,7 @@
{% block extra_head %}{% endblock %}
</head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<body class="bg-gray-100 dark:bg-slate-800 text-gray-900 dark:text-gray-100">
<!-- Offline Indicator -->
<div id="offlineIndicator" class="offline-indicator">
@@ -85,10 +88,10 @@
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
<!-- Logo -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange">
Seismo<br>
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
</h1>
<a href="/" class="block">
<img src="/static/terra-view-logo-light.png" srcset="/static/terra-view-logo-light.png 1x, /static/terra-view-logo-light@2x.png 2x" alt="Terra-View" class="block dark:hidden w-44 h-auto">
<img src="/static/terra-view-logo-dark.png" srcset="/static/terra-view-logo-dark.png 1x, /static/terra-view-logo-dark@2x.png 2x" alt="Terra-View" class="hidden dark:block w-44 h-auto">
</a>
<div class="flex items-center justify-between mt-2">
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
{% if environment == 'development' %}
@@ -127,6 +130,20 @@
Sound Level Meters
</a>
<a href="/modems" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/modems' %}bg-gray-100 dark:bg-gray-700{% endif %}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
Modems
</a>
<a href="/pair-devices" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/pair-devices' %}bg-gray-100 dark:bg-gray-700{% endif %}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
Pair Devices
</a>
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
@@ -374,10 +391,10 @@
</script>
<!-- Offline Database -->
<script src="/static/offline-db.js?v=0.4.3"></script>
<script src="/static/offline-db.js?v=0.5.1"></script>
<!-- Mobile JavaScript -->
<script src="/static/mobile.js?v=0.4.3"></script>
<script src="/static/mobile.js?v=0.5.1"></script>
{% block extra_scripts %}{% endblock %}
</body>

View File

@@ -27,10 +27,10 @@
hx-swap="none"
hx-on::after-request="updateDashboard(event)">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<!-- Fleet Summary Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-summary-card">
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
<div class="flex items-center gap-2">
@@ -118,7 +118,7 @@
</div>
<!-- Recent Alerts Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-alerts-card">
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
<div class="flex items-center gap-2">
@@ -138,7 +138,7 @@
</div>
<!-- Recently Called In Units Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-callins-card">
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-callins-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
<div class="flex items-center gap-2">
@@ -162,10 +162,95 @@
</div>
</div>
<!-- Today's Scheduled Actions Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="todays-actions-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('todays-actions')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z">
</path>
</svg>
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="todays-actions-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
<div class="card-content" id="todays-actions-content"
hx-get="/dashboard/todays-actions"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
</div>
</div>
</div>
<!-- Dashboard Filters -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-4 mb-4" id="dashboard-filters-card">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Filter Dashboard</h3>
<button onclick="resetFilters()" class="text-xs text-gray-500 hover:text-seismo-orange dark:hover:text-seismo-orange transition-colors">
Reset Filters
</button>
</div>
<div class="flex flex-wrap gap-6">
<!-- Device Type Filters -->
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wide">Device Type</span>
<div class="flex gap-4">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-seismograph" checked
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-gray-700 dark:text-gray-300">Seismographs</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-slm" checked
class="rounded border-gray-300 text-purple-600 focus:ring-purple-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-gray-700 dark:text-gray-300">SLMs</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-modem" checked
class="rounded border-gray-300 text-cyan-600 focus:ring-cyan-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-gray-700 dark:text-gray-300">Modems</span>
</label>
</div>
</div>
<!-- Status Filters -->
<div class="flex flex-col gap-1">
<span class="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wide">Status</span>
<div class="flex gap-4">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-ok" checked
class="rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-green-600 dark:text-green-400">OK</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-pending" checked
class="rounded border-gray-300 text-yellow-600 focus:ring-yellow-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-yellow-600 dark:text-yellow-400">Pending</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="filter-missing" checked
class="rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600 dark:bg-slate-800"
onchange="applyFilters()">
<span class="text-sm text-red-600 dark:text-red-400">Missing</span>
</label>
</div>
</div>
</div>
</div>
<!-- Fleet Map -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="fleet-map-card">
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="fleet-map-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
<div class="flex items-center gap-2">
@@ -181,7 +266,7 @@
</div>
<!-- Recent Photos Section -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="recent-photos-card">
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
<div class="flex items-center gap-2">
@@ -201,7 +286,7 @@
</div>
<!-- Fleet Status Section with Tabs -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card">
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-status-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
@@ -279,6 +364,254 @@
<script>
// ===== Dashboard Filtering System =====
let currentSnapshotData = null; // Store latest snapshot data for re-filtering
// Filter state - tracks which device types and statuses to show
const filters = {
deviceTypes: {
seismograph: true,
sound_level_meter: true,
modem: true
},
statuses: {
OK: true,
Pending: true,
Missing: true
}
};
// Load saved filter preferences from localStorage
function loadFilterPreferences() {
const saved = localStorage.getItem('dashboardFilters');
if (saved) {
try {
const parsed = JSON.parse(saved);
if (parsed.deviceTypes) Object.assign(filters.deviceTypes, parsed.deviceTypes);
if (parsed.statuses) Object.assign(filters.statuses, parsed.statuses);
} catch (e) {
console.error('Error loading filter preferences:', e);
}
}
// Sync checkboxes with loaded state
const seismoCheck = document.getElementById('filter-seismograph');
const slmCheck = document.getElementById('filter-slm');
const modemCheck = document.getElementById('filter-modem');
const okCheck = document.getElementById('filter-ok');
const pendingCheck = document.getElementById('filter-pending');
const missingCheck = document.getElementById('filter-missing');
if (seismoCheck) seismoCheck.checked = filters.deviceTypes.seismograph;
if (slmCheck) slmCheck.checked = filters.deviceTypes.sound_level_meter;
if (modemCheck) modemCheck.checked = filters.deviceTypes.modem;
if (okCheck) okCheck.checked = filters.statuses.OK;
if (pendingCheck) pendingCheck.checked = filters.statuses.Pending;
if (missingCheck) missingCheck.checked = filters.statuses.Missing;
}
// Save filter preferences to localStorage
function saveFilterPreferences() {
localStorage.setItem('dashboardFilters', JSON.stringify(filters));
}
// Apply filters - called when any checkbox changes
function applyFilters() {
// Update filter state from checkboxes
const seismoCheck = document.getElementById('filter-seismograph');
const slmCheck = document.getElementById('filter-slm');
const modemCheck = document.getElementById('filter-modem');
const okCheck = document.getElementById('filter-ok');
const pendingCheck = document.getElementById('filter-pending');
const missingCheck = document.getElementById('filter-missing');
if (seismoCheck) filters.deviceTypes.seismograph = seismoCheck.checked;
if (slmCheck) filters.deviceTypes.sound_level_meter = slmCheck.checked;
if (modemCheck) filters.deviceTypes.modem = modemCheck.checked;
if (okCheck) filters.statuses.OK = okCheck.checked;
if (pendingCheck) filters.statuses.Pending = pendingCheck.checked;
if (missingCheck) filters.statuses.Missing = missingCheck.checked;
saveFilterPreferences();
// Re-render with current data and filters
if (currentSnapshotData) {
renderFilteredDashboard(currentSnapshotData);
}
}
// Reset all filters to show everything
function resetFilters() {
filters.deviceTypes = { seismograph: true, sound_level_meter: true, modem: true };
filters.statuses = { OK: true, Pending: true, Missing: true };
// Update all checkboxes
const checkboxes = [
'filter-seismograph', 'filter-slm', 'filter-modem',
'filter-ok', 'filter-pending', 'filter-missing'
];
checkboxes.forEach(id => {
const el = document.getElementById(id);
if (el) el.checked = true;
});
saveFilterPreferences();
if (currentSnapshotData) {
renderFilteredDashboard(currentSnapshotData);
}
}
// Check if a unit passes the current filters
function unitPassesFilter(unit) {
const deviceType = unit.device_type || 'seismograph';
const status = unit.status || 'Missing';
// Check device type filter
if (!filters.deviceTypes[deviceType]) {
return false;
}
// Check status filter
if (!filters.statuses[status]) {
return false;
}
return true;
}
// Get display label for device type
function getDeviceTypeLabel(deviceType) {
switch(deviceType) {
case 'sound_level_meter': return 'SLM';
case 'modem': return 'Modem';
default: return 'Seismograph';
}
}
// Render dashboard with filtered data
function renderFilteredDashboard(data) {
// Filter active units for alerts
const filteredActive = {};
Object.entries(data.active || {}).forEach(([id, unit]) => {
if (unitPassesFilter(unit)) {
filteredActive[id] = unit;
}
});
// Update alerts with filtered data
updateAlertsFiltered(filteredActive);
// Update map with filtered data
updateFleetMapFiltered(data.units);
}
// Update the Recent Alerts section with filtering
function updateAlertsFiltered(filteredActive) {
const alertsList = document.getElementById('alerts-list');
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing');
if (!missingUnits.length) {
// Check if this is because of filters or genuinely no alerts
const anyMissing = currentSnapshotData && Object.values(currentSnapshotData.active || {}).some(u => u.status === 'Missing');
if (anyMissing) {
alertsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No alerts match current filters</p>';
} else {
alertsList.innerHTML = '<p class="text-sm text-green-600 dark:text-green-400">All units reporting normally</p>';
}
} else {
let alertsHtml = '';
missingUnits.forEach(([id, unit]) => {
const deviceLabel = getDeviceTypeLabel(unit.device_type);
alertsHtml += `
<div class="flex items-start space-x-2 text-sm">
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
<div>
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
<span class="text-xs text-gray-500 ml-1">(${deviceLabel})</span>
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
</div>
</div>`;
});
alertsList.innerHTML = alertsHtml;
}
}
// Update map with filtered data
function updateFleetMapFiltered(allUnits) {
if (!fleetMap) return;
// Clear existing markers
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
fleetMarkers = [];
// Get deployed units with coordinates that pass the filter
const deployedUnits = Object.entries(allUnits || {})
.filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
if (deployedUnits.length === 0) {
return;
}
const bounds = [];
deployedUnits.forEach(([id, unit]) => {
const coords = parseLocation(unit.coordinates);
if (coords) {
const [lat, lon] = coords;
// Color based on status
const markerColor = unit.status === 'OK' ? 'green' :
unit.status === 'Pending' ? 'orange' : 'red';
// Different marker style per device type
const deviceType = unit.device_type || 'seismograph';
let radius = 8;
let weight = 2;
if (deviceType === 'modem') {
radius = 6;
weight = 2;
} else if (deviceType === 'sound_level_meter') {
radius = 8;
weight = 3;
}
const marker = L.circleMarker([lat, lon], {
radius: radius,
fillColor: markerColor,
color: '#fff',
weight: weight,
opacity: 1,
fillOpacity: 0.8
}).addTo(fleetMap);
// Popup with device type
const deviceLabel = getDeviceTypeLabel(deviceType);
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg">${id}</h3>
<p class="text-sm text-gray-600">${deviceLabel}</p>
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
</div>
`);
fleetMarkers.push(marker);
bounds.push([lat, lon]);
}
});
// Fit bounds if we have markers
if (bounds.length > 0) {
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
fleetMap.fitBounds(bounds, { padding: padding });
fleetMapInitialized = true;
}
}
// Toggle card collapse/expand (mobile only)
function toggleCard(cardName) {
// Only work on mobile
@@ -316,7 +649,7 @@ function toggleCard(cardName) {
// Restore card states from localStorage on page load
function restoreCardStates() {
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'todays-actions', 'fleet-map', 'fleet-status'];
cardNames.forEach(cardName => {
const content = document.getElementById(`${cardName}-content`);
@@ -343,8 +676,17 @@ if (document.readyState === 'loading') {
function updateDashboard(event) {
try {
// Only process responses from /api/status-snapshot
const requestUrl = event.detail.xhr.responseURL || event.detail.pathInfo?.requestPath;
if (!requestUrl || !requestUrl.includes('/api/status-snapshot')) {
return; // Ignore responses from other endpoints (like /dashboard/todays-actions)
}
const data = JSON.parse(event.detail.xhr.response);
// Store data for filter re-application
currentSnapshotData = data;
// Update "Last updated" timestamp with timezone
const now = new Date();
const timezone = localStorage.getItem('timezone') || 'America/New_York';
@@ -356,7 +698,7 @@ function updateDashboard(event) {
timeZoneName: 'short'
});
// ===== Fleet summary numbers =====
// ===== Fleet summary numbers (always unfiltered) =====
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
@@ -364,9 +706,10 @@ function updateDashboard(event) {
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
// ===== Device type counts =====
// ===== Device type counts (always unfiltered) =====
let seismoCount = 0;
let slmCount = 0;
let modemCount = 0;
Object.values(data.units || {}).forEach(unit => {
if (unit.retired) return; // Don't count retired units
const deviceType = unit.device_type || 'seismograph';
@@ -374,46 +717,26 @@ function updateDashboard(event) {
seismoCount++;
} else if (deviceType === 'sound_level_meter') {
slmCount++;
} else if (deviceType === 'modem') {
modemCount++;
}
});
document.getElementById('seismo-count').textContent = seismoCount;
document.getElementById('slm-count').textContent = slmCount;
// ===== Alerts =====
const alertsList = document.getElementById('alerts-list');
// Only show alerts for deployed units that are MISSING (not pending)
const missingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Missing');
if (!missingUnits.length) {
alertsList.innerHTML =
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
} else {
let alertsHtml = '';
missingUnits.forEach(([id, unit]) => {
alertsHtml += `
<div class="flex items-start space-x-2 text-sm">
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
<div>
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
</div>
</div>`;
});
alertsList.innerHTML = alertsHtml;
}
// ===== Update Fleet Map =====
updateFleetMap(data);
// ===== Apply filters and render map + alerts =====
renderFilteredDashboard(data);
} catch (err) {
console.error("Dashboard update error:", err);
}
}
// Handle tab switching
// Handle tab switching and initialize components
document.addEventListener('DOMContentLoaded', function() {
// Load filter preferences
loadFilterPreferences();
const tabButtons = document.querySelectorAll('.tab-button');
tabButtons.forEach(button => {
@@ -453,64 +776,6 @@ function initFleetMap() {
}, 100);
}
function updateFleetMap(data) {
if (!fleetMap) return;
// Clear existing markers
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
fleetMarkers = [];
// Get deployed units with coordinates data
const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.coordinates);
if (deployedUnits.length === 0) {
return;
}
const bounds = [];
deployedUnits.forEach(([id, unit]) => {
const coords = parseLocation(unit.coordinates);
if (coords) {
const [lat, lon] = coords;
// Create marker with custom color based on status
const markerColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red';
const marker = L.circleMarker([lat, lon], {
radius: 8,
fillColor: markerColor,
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(fleetMap);
// Add popup with unit info
marker.bindPopup(`
<div class="p-2">
<h3 class="font-bold text-lg">${id}</h3>
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
<p class="text-sm">Type: ${unit.device_type}</p>
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details →</a>
</div>
`);
fleetMarkers.push(marker);
bounds.push([lat, lon]);
}
});
// Fit map to show all markers
if (bounds.length > 0) {
// Use different padding for mobile vs desktop
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
fleetMap.fitBounds(bounds, { padding: padding });
fleetMapInitialized = true;
}
}
function parseLocation(location) {
if (!location) return null;

102
templates/modems.html Normal file
View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Field Modems - Terra-View{% endblock %}
{% block content %}
<div class="mb-8">
<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="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
Field Modems
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage network connectivity devices for field equipment</p>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
hx-get="/api/modem-dashboard/stats"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<!-- Stats will be loaded here -->
<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>
<!-- Modem List -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Modems</h2>
<div class="flex items-center gap-4">
<!-- Search -->
<div class="relative">
<input type="text"
id="modem-search"
placeholder="Search modems..."
class="pl-9 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-transparent"
hx-get="/api/modem-dashboard/units"
hx-trigger="keyup changed delay:300ms"
hx-target="#modem-list"
hx-include="[name='search']"
name="search">
<svg class="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<a href="/roster?device_type=modem" class="text-sm text-seismo-orange hover:underline">
Add modem in roster
</a>
</div>
</div>
<div id="modem-list"
hx-get="/api/modem-dashboard/units"
hx-trigger="load, every 30s"
hx-swap="innerHTML">
<p class="text-gray-500 dark:text-gray-400">Loading modems...</p>
</div>
</div>
<script>
// Ping a modem and show result
async function pingModem(modemId) {
const btn = document.getElementById(`ping-btn-${modemId}`);
const resultDiv = document.getElementById(`ping-result-${modemId}`);
// Show loading state
const originalText = btn.textContent;
btn.textContent = 'Pinging...';
btn.disabled = true;
resultDiv.classList.remove('hidden');
resultDiv.className = 'mt-2 text-xs text-gray-500';
resultDiv.textContent = 'Testing connection...';
try {
const response = await fetch(`/api/modem-dashboard/${modemId}/ping`);
const data = await response.json();
if (data.status === 'success') {
resultDiv.className = 'mt-2 text-xs text-green-600 dark:text-green-400';
resultDiv.innerHTML = `<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1"></span>Online (${data.response_time_ms}ms)`;
} else {
resultDiv.className = 'mt-2 text-xs text-red-600 dark:text-red-400';
resultDiv.innerHTML = `<span class="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>${data.detail || 'Offline'}`;
}
} catch (error) {
resultDiv.className = 'mt-2 text-xs text-red-600 dark:text-red-400';
resultDiv.textContent = 'Error: ' + error.message;
}
// Restore button
btn.textContent = originalText;
btn.disabled = false;
// Hide result after 10 seconds
setTimeout(() => {
resultDiv.classList.add('hidden');
}, 10000);
}
</script>
{% endblock %}

View File

@@ -123,7 +123,7 @@
{% 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.strftime('%Y-%m-%d %H:%M') if location.created_at else 'N/A' }}</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>
@@ -150,7 +150,7 @@
{% 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.strftime('%Y-%m-%d %H:%M') if assignment.assigned_at else 'N/A' }}</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>

566
templates/pair_devices.html Normal file
View File

@@ -0,0 +1,566 @@
{% extends "base.html" %}
{% block title %}Pair Devices - Terra-View{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Pair Devices</h1>
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
Select a recorder (seismograph or SLM) and a modem to create a bidirectional pairing.
</p>
</div>
<!-- Selection Summary Bar -->
<div id="selection-bar" class="mb-6 p-4 bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">Recorder:</span>
<span id="selected-recorder" class="font-mono font-medium text-gray-900 dark:text-white">None selected</span>
</div>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"></path>
</svg>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">Modem:</span>
<span id="selected-modem" class="font-mono font-medium text-gray-900 dark:text-white">None selected</span>
</div>
</div>
<div class="flex items-center gap-3">
<button id="clear-selection-btn"
onclick="clearSelection()"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
Clear
</button>
<button id="pair-btn"
onclick="pairDevices()"
class="px-4 py-2 text-sm font-medium text-white bg-seismo-orange rounded-lg hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
disabled>
Pair Devices
</button>
</div>
</div>
</div>
<!-- Two Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column: Recorders (Seismographs + SLMs) -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
Recorders
<span id="recorder-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ recorders|length }})</span>
</h2>
</div>
<!-- Recorder Search & Filters -->
<div class="space-y-2">
<input type="text" id="recorder-search" placeholder="Search by ID..."
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
oninput="filterRecorders()">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="recorder-hide-paired" onchange="filterRecorders()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
<span class="text-xs text-gray-600 dark:text-gray-400">Hide paired</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="recorder-deployed-only" onchange="filterRecorders()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
<span class="text-xs text-gray-600 dark:text-gray-400">Deployed only</span>
</label>
</div>
</div>
</div>
<div class="max-h-[600px] overflow-y-auto">
<div id="recorders-list" class="divide-y divide-gray-200 dark:divide-gray-700">
{% for unit in recorders %}
<div class="device-row recorder-row p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
data-id="{{ unit.id }}"
data-deployed="{{ unit.deployed|lower }}"
data-paired-with="{{ unit.deployed_with_modem_id or '' }}"
data-device-type="{{ unit.device_type }}"
onclick="selectRecorder('{{ unit.id }}')">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full flex items-center justify-center
{% if unit.device_type == 'slm' %}bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400
{% else %}bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400{% endif %}">
{% if unit.device_type == 'slm' %}
<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="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg>
{% else %}
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
{% endif %}
</div>
<div>
<div class="font-mono font-medium text-gray-900 dark:text-white">{{ unit.id }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ unit.device_type|capitalize }}
{% if not unit.deployed %}<span class="text-yellow-600 dark:text-yellow-400">(Benched)</span>{% endif %}
</div>
</div>
</div>
<div class="flex items-center gap-2">
{% if unit.deployed_with_modem_id %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
→ {{ unit.deployed_with_modem_id }}
</span>
{% endif %}
<div class="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center selection-indicator">
<svg class="w-3 h-3 text-seismo-orange hidden check-icon" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
</div>
</div>
</div>
</div>
{% else %}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No recorders found in roster
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Right Column: Modems -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-3">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
Modems
<span id="modem-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ modems|length }})</span>
</h2>
</div>
<!-- Modem Search & Filters -->
<div class="space-y-2">
<input type="text" id="modem-search" placeholder="Search by ID, IP, or phone..."
class="w-full px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
oninput="filterModems()">
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="modem-hide-paired" onchange="filterModems()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
<span class="text-xs text-gray-600 dark:text-gray-400">Hide paired</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="modem-deployed-only" onchange="filterModems()" class="rounded border-gray-300 dark:border-gray-600 text-seismo-orange focus:ring-seismo-orange">
<span class="text-xs text-gray-600 dark:text-gray-400">Deployed only</span>
</label>
</div>
</div>
</div>
<div class="max-h-[600px] overflow-y-auto">
<div id="modems-list" class="divide-y divide-gray-200 dark:divide-gray-700">
{% for unit in modems %}
<div class="device-row modem-row p-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer transition-colors"
data-id="{{ unit.id }}"
data-deployed="{{ unit.deployed|lower }}"
data-paired-with="{{ unit.deployed_with_unit_id or '' }}"
data-ip="{{ unit.ip_address or '' }}"
data-phone="{{ unit.phone_number or '' }}"
onclick="selectModem('{{ unit.id }}')">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center text-amber-600 dark:text-amber-400">
<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="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
</div>
<div>
<div class="font-mono font-medium text-gray-900 dark:text-white">{{ unit.id }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{% if unit.ip_address %}<span class="font-mono">{{ unit.ip_address }}</span>{% endif %}
{% if unit.phone_number %}{% if unit.ip_address %} · {% endif %}{{ unit.phone_number }}{% endif %}
{% if not unit.ip_address and not unit.phone_number %}Modem{% endif %}
{% if not unit.deployed %}<span class="text-yellow-600 dark:text-yellow-400">(Benched)</span>{% endif %}
</div>
</div>
</div>
<div class="flex items-center gap-2">
{% if unit.deployed_with_unit_id %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400">
← {{ unit.deployed_with_unit_id }}
</span>
{% endif %}
<div class="w-5 h-5 rounded-full border-2 border-gray-300 dark:border-gray-600 flex items-center justify-center selection-indicator">
<svg class="w-3 h-3 text-seismo-orange hidden check-icon" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
</div>
</div>
</div>
</div>
{% else %}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No modems found in roster
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Existing Pairings Section -->
<div class="mt-8 bg-white dark:bg-slate-800 rounded-lg shadow border border-gray-200 dark:border-gray-700">
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
Existing Pairings
<span id="pairing-count" class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ pairings|length }})</span>
</h2>
</div>
<div class="max-h-[400px] overflow-y-auto">
<div id="pairings-list" class="divide-y divide-gray-200 dark:divide-gray-700">
{% for pairing in pairings %}
<div class="pairing-row p-3 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50">
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-sm font-mono rounded bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">
{{ pairing.recorder_id }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ pairing.recorder_type }}</span>
</div>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"></path>
</svg>
<div class="flex items-center gap-2">
<span class="px-2 py-1 text-sm font-mono rounded bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400">
{{ pairing.modem_id }}
</span>
{% if pairing.modem_ip %}
<span class="text-xs font-mono text-gray-500 dark:text-gray-400">{{ pairing.modem_ip }}</span>
{% endif %}
</div>
</div>
<button onclick="unpairDevices('{{ pairing.recorder_id }}', '{{ pairing.modem_id }}')"
class="p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title="Unpair devices">
<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="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
{% else %}
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
No pairings found. Select a recorder and modem above to create one.
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Toast notification -->
<div id="toast" class="fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform translate-y-full opacity-0 transition-all duration-300 z-50"></div>
<script>
let selectedRecorder = null;
let selectedModem = null;
function selectRecorder(id) {
// Deselect previous
document.querySelectorAll('.recorder-row').forEach(row => {
row.classList.remove('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
row.querySelector('.selection-indicator').classList.remove('border-seismo-orange', 'bg-seismo-orange');
row.querySelector('.selection-indicator').classList.add('border-gray-300', 'dark:border-gray-600');
row.querySelector('.check-icon').classList.add('hidden');
});
// Toggle selection
if (selectedRecorder === id) {
selectedRecorder = null;
document.getElementById('selected-recorder').textContent = 'None selected';
} else {
selectedRecorder = id;
document.getElementById('selected-recorder').textContent = id;
// Highlight selected
const row = document.querySelector(`.recorder-row[data-id="${id}"]`);
if (row) {
row.classList.add('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
row.querySelector('.selection-indicator').classList.remove('border-gray-300', 'dark:border-gray-600');
row.querySelector('.selection-indicator').classList.add('border-seismo-orange', 'bg-seismo-orange');
row.querySelector('.check-icon').classList.remove('hidden');
}
}
updateButtons();
}
function selectModem(id) {
// Deselect previous
document.querySelectorAll('.modem-row').forEach(row => {
row.classList.remove('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
row.querySelector('.selection-indicator').classList.remove('border-seismo-orange', 'bg-seismo-orange');
row.querySelector('.selection-indicator').classList.add('border-gray-300', 'dark:border-gray-600');
row.querySelector('.check-icon').classList.add('hidden');
});
// Toggle selection
if (selectedModem === id) {
selectedModem = null;
document.getElementById('selected-modem').textContent = 'None selected';
} else {
selectedModem = id;
document.getElementById('selected-modem').textContent = id;
// Highlight selected
const row = document.querySelector(`.modem-row[data-id="${id}"]`);
if (row) {
row.classList.add('bg-seismo-orange/10', 'dark:bg-seismo-orange/20');
row.querySelector('.selection-indicator').classList.remove('border-gray-300', 'dark:border-gray-600');
row.querySelector('.selection-indicator').classList.add('border-seismo-orange', 'bg-seismo-orange');
row.querySelector('.check-icon').classList.remove('hidden');
}
}
updateButtons();
}
function updateButtons() {
const pairBtn = document.getElementById('pair-btn');
const clearBtn = document.getElementById('clear-selection-btn');
pairBtn.disabled = !(selectedRecorder && selectedModem);
clearBtn.disabled = !(selectedRecorder || selectedModem);
}
function clearSelection() {
if (selectedRecorder) selectRecorder(selectedRecorder);
if (selectedModem) selectModem(selectedModem);
}
function filterRecorders() {
const searchTerm = document.getElementById('recorder-search').value.toLowerCase().trim();
const hidePaired = document.getElementById('recorder-hide-paired').checked;
const deployedOnly = document.getElementById('recorder-deployed-only').checked;
let visibleRecorders = 0;
document.querySelectorAll('.recorder-row').forEach(row => {
const id = row.dataset.id.toLowerCase();
const pairedWith = row.dataset.pairedWith;
const deployed = row.dataset.deployed === 'true';
let show = true;
if (searchTerm && !id.includes(searchTerm)) show = false;
if (hidePaired && pairedWith) show = false;
if (deployedOnly && !deployed) show = false;
row.style.display = show ? '' : 'none';
if (show) visibleRecorders++;
});
document.getElementById('recorder-count').textContent = `(${visibleRecorders})`;
}
function filterModems() {
const searchTerm = document.getElementById('modem-search').value.toLowerCase().trim();
const hidePaired = document.getElementById('modem-hide-paired').checked;
const deployedOnly = document.getElementById('modem-deployed-only').checked;
let visibleModems = 0;
document.querySelectorAll('.modem-row').forEach(row => {
const id = row.dataset.id.toLowerCase();
const ip = (row.dataset.ip || '').toLowerCase();
const phone = (row.dataset.phone || '').toLowerCase();
const pairedWith = row.dataset.pairedWith;
const deployed = row.dataset.deployed === 'true';
let show = true;
if (searchTerm && !id.includes(searchTerm) && !ip.includes(searchTerm) && !phone.includes(searchTerm)) show = false;
if (hidePaired && pairedWith) show = false;
if (deployedOnly && !deployed) show = false;
row.style.display = show ? '' : 'none';
if (show) visibleModems++;
});
document.getElementById('modem-count').textContent = `(${visibleModems})`;
}
function saveScrollPositions() {
const recordersList = document.getElementById('recorders-list').parentElement;
const modemsList = document.getElementById('modems-list').parentElement;
const pairingsList = document.getElementById('pairings-list').parentElement;
sessionStorage.setItem('pairDevices_recorderScroll', recordersList.scrollTop);
sessionStorage.setItem('pairDevices_modemScroll', modemsList.scrollTop);
sessionStorage.setItem('pairDevices_pairingScroll', pairingsList.scrollTop);
// Save recorder filter state
sessionStorage.setItem('pairDevices_recorderSearch', document.getElementById('recorder-search').value);
sessionStorage.setItem('pairDevices_recorderHidePaired', document.getElementById('recorder-hide-paired').checked);
sessionStorage.setItem('pairDevices_recorderDeployedOnly', document.getElementById('recorder-deployed-only').checked);
// Save modem filter state
sessionStorage.setItem('pairDevices_modemSearch', document.getElementById('modem-search').value);
sessionStorage.setItem('pairDevices_modemHidePaired', document.getElementById('modem-hide-paired').checked);
sessionStorage.setItem('pairDevices_modemDeployedOnly', document.getElementById('modem-deployed-only').checked);
}
function restoreScrollPositions() {
const recorderScroll = sessionStorage.getItem('pairDevices_recorderScroll');
const modemScroll = sessionStorage.getItem('pairDevices_modemScroll');
const pairingScroll = sessionStorage.getItem('pairDevices_pairingScroll');
if (recorderScroll) {
document.getElementById('recorders-list').parentElement.scrollTop = parseInt(recorderScroll);
}
if (modemScroll) {
document.getElementById('modems-list').parentElement.scrollTop = parseInt(modemScroll);
}
if (pairingScroll) {
document.getElementById('pairings-list').parentElement.scrollTop = parseInt(pairingScroll);
}
// Restore recorder filter state
const recorderSearch = sessionStorage.getItem('pairDevices_recorderSearch');
const recorderHidePaired = sessionStorage.getItem('pairDevices_recorderHidePaired');
const recorderDeployedOnly = sessionStorage.getItem('pairDevices_recorderDeployedOnly');
if (recorderSearch) document.getElementById('recorder-search').value = recorderSearch;
if (recorderHidePaired === 'true') document.getElementById('recorder-hide-paired').checked = true;
if (recorderDeployedOnly === 'true') document.getElementById('recorder-deployed-only').checked = true;
// Restore modem filter state
const modemSearch = sessionStorage.getItem('pairDevices_modemSearch');
const modemHidePaired = sessionStorage.getItem('pairDevices_modemHidePaired');
const modemDeployedOnly = sessionStorage.getItem('pairDevices_modemDeployedOnly');
if (modemSearch) document.getElementById('modem-search').value = modemSearch;
if (modemHidePaired === 'true') document.getElementById('modem-hide-paired').checked = true;
if (modemDeployedOnly === 'true') document.getElementById('modem-deployed-only').checked = true;
// Apply filters if any were set
if (recorderSearch || recorderHidePaired === 'true' || recorderDeployedOnly === 'true') {
filterRecorders();
}
if (modemSearch || modemHidePaired === 'true' || modemDeployedOnly === 'true') {
filterModems();
}
// Clear stored values
sessionStorage.removeItem('pairDevices_recorderScroll');
sessionStorage.removeItem('pairDevices_modemScroll');
sessionStorage.removeItem('pairDevices_pairingScroll');
sessionStorage.removeItem('pairDevices_recorderSearch');
sessionStorage.removeItem('pairDevices_recorderHidePaired');
sessionStorage.removeItem('pairDevices_recorderDeployedOnly');
sessionStorage.removeItem('pairDevices_modemSearch');
sessionStorage.removeItem('pairDevices_modemHidePaired');
sessionStorage.removeItem('pairDevices_modemDeployedOnly');
}
// Restore scroll positions on page load
document.addEventListener('DOMContentLoaded', restoreScrollPositions);
async function pairDevices() {
if (!selectedRecorder || !selectedModem) return;
const pairBtn = document.getElementById('pair-btn');
pairBtn.disabled = true;
pairBtn.textContent = 'Pairing...';
try {
const response = await fetch('/api/roster/pair-devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recorder_id: selectedRecorder,
modem_id: selectedModem
})
});
const result = await response.json();
if (response.ok) {
showToast(`Paired ${selectedRecorder} with ${selectedModem}`, 'success');
// Save scroll positions before reload
saveScrollPositions();
setTimeout(() => window.location.reload(), 500);
} else {
showToast(result.detail || 'Failed to pair devices', 'error');
}
} catch (error) {
showToast('Error pairing devices: ' + error.message, 'error');
} finally {
pairBtn.disabled = false;
pairBtn.textContent = 'Pair Devices';
}
}
async function unpairDevices(recorderId, modemId) {
if (!confirm(`Unpair ${recorderId} from ${modemId}?`)) return;
try {
const response = await fetch('/api/roster/unpair-devices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recorder_id: recorderId,
modem_id: modemId
})
});
const result = await response.json();
if (response.ok) {
showToast(`Unpaired ${recorderId} from ${modemId}`, 'success');
// Save scroll positions before reload
saveScrollPositions();
setTimeout(() => window.location.reload(), 500);
} else {
showToast(result.detail || 'Failed to unpair devices', 'error');
}
} catch (error) {
showToast('Error unpairing devices: ' + error.message, 'error');
}
}
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = 'fixed bottom-4 right-4 px-4 py-3 rounded-lg shadow-lg transform transition-all duration-300 z-50';
if (type === 'success') {
toast.classList.add('bg-green-500', 'text-white');
} else if (type === 'error') {
toast.classList.add('bg-red-500', 'text-white');
} else {
toast.classList.add('bg-gray-800', 'text-white');
}
// Show
toast.classList.remove('translate-y-full', 'opacity-0');
// Hide after 3 seconds
setTimeout(() => {
toast.classList.add('translate-y-full', 'opacity-0');
}, 3000);
}
</script>
<style>
.bg-seismo-orange\/10 {
background-color: rgb(249 115 22 / 0.1);
}
.dark\:bg-seismo-orange\/20:is(.dark *) {
background-color: rgb(249 115 22 / 0.2);
}
</style>
{% endblock %}

View File

@@ -0,0 +1,87 @@
<!-- Alert Dropdown Content -->
<!-- Loaded via HTMX into the alert dropdown in the navbar -->
<div class="max-h-96 overflow-y-auto">
{% if alerts %}
{% for item in alerts %}
<div class="p-3 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors
{% if item.alert.severity == 'critical' %}bg-red-50 dark:bg-red-900/20{% endif %}">
<div class="flex items-start gap-3">
<!-- Severity icon -->
{% if item.alert.severity == 'critical' %}
<span class="text-red-500 flex-shrink-0 mt-0.5">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</span>
{% elif item.alert.severity == 'warning' %}
<span class="text-yellow-500 flex-shrink-0 mt-0.5">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</span>
{% else %}
<span class="text-blue-500 flex-shrink-0 mt-0.5">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
</span>
{% endif %}
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{{ item.alert.title }}
</p>
{% if item.alert.message %}
<p class="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">
{{ item.alert.message }}
</p>
{% endif %}
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
{{ item.time_ago }}
</p>
</div>
<!-- Actions -->
<div class="flex items-center gap-1 flex-shrink-0">
<button hx-post="/api/alerts/{{ item.alert.id }}/acknowledge"
hx-swap="none"
hx-on::after-request="htmx.trigger('#alert-dropdown-content', 'refresh')"
class="p-1.5 text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded transition-colors"
title="Acknowledge">
<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="M5 13l4 4L19 7"/>
</svg>
</button>
<button hx-post="/api/alerts/{{ item.alert.id }}/dismiss"
hx-swap="none"
hx-on::after-request="htmx.trigger('#alert-dropdown-content', 'refresh')"
class="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded transition-colors"
title="Dismiss">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="p-8 text-center">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-gray-500 dark:text-gray-400 text-sm">No active alerts</p>
<p class="text-gray-400 dark:text-gray-500 text-xs mt-1">All systems operational</p>
</div>
{% endif %}
</div>
<!-- View all link -->
{% if total_count > 0 %}
<div class="p-3 border-t border-gray-200 dark:border-gray-700 text-center bg-gray-50 dark:bg-gray-800/50">
<a href="/alerts" class="text-sm text-seismo-orange hover:text-seismo-navy dark:hover:text-orange-300 font-medium">
View all {{ total_count }} alert{{ 's' if total_count != 1 else '' }}
</a>
</div>
{% endif %}

View File

@@ -0,0 +1,125 @@
<!-- Alert List Partial -->
<!-- Full list of alerts for the alerts page -->
<div class="space-y-3">
{% if alerts %}
{% for item in alerts %}
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4
{% if item.alert.severity == 'critical' and item.alert.status == 'active' %}border-l-4 border-l-red-500{% endif %}
{% if item.alert.severity == 'warning' and item.alert.status == 'active' %}border-l-4 border-l-yellow-500{% endif %}
{% if item.alert.status != 'active' %}opacity-60{% endif %}">
<div class="flex items-start gap-4">
<!-- Severity icon -->
<div class="flex-shrink-0">
{% if item.alert.severity == 'critical' %}
<div class="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</div>
{% elif item.alert.severity == 'warning' %}
<div class="w-10 h-10 rounded-full bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
</div>
{% else %}
<div class="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
</div>
{% endif %}
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ item.alert.title }}
</h3>
<!-- Status badge -->
{% if item.alert.status == 'active' %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
Active
</span>
{% elif item.alert.status == 'acknowledged' %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300">
Acknowledged
</span>
{% elif item.alert.status == 'resolved' %}
<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">
Resolved
</span>
{% elif item.alert.status == 'dismissed' %}
<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">
Dismissed
</span>
{% endif %}
</div>
{% if item.alert.message %}
<p class="text-sm text-gray-600 dark:text-gray-300 mb-2">
{{ item.alert.message }}
</p>
{% endif %}
<div class="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span>{{ item.time_ago }}</span>
{% if item.alert.unit_id %}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
</svg>
{{ item.alert.unit_id }}
</span>
{% endif %}
<span class="capitalize">{{ item.alert.alert_type | replace('_', ' ') }}</span>
</div>
</div>
<!-- Actions -->
{% if item.alert.status == 'active' %}
<div class="flex items-center gap-2 flex-shrink-0">
<button hx-post="/api/alerts/{{ item.alert.id }}/acknowledge"
hx-swap="none"
hx-on::after-request="htmx.trigger('#alert-list', 'refresh')"
class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Acknowledge
</button>
<button hx-post="/api/alerts/{{ item.alert.id }}/resolve"
hx-swap="none"
hx-on::after-request="htmx.trigger('#alert-list', 'refresh')"
class="px-3 py-1.5 text-sm bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg hover:bg-green-200 dark:hover:bg-green-900/50 transition-colors">
Resolve
</button>
<button hx-post="/api/alerts/{{ item.alert.id }}/dismiss"
hx-swap="none"
hx-on::after-request="htmx.trigger('#alert-list', 'refresh')"
class="px-3 py-1.5 text-sm text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
title="Dismiss">
<svg class="w-5 h-5" 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"/>
</svg>
</button>
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No alerts</h3>
<p class="text-gray-500 dark:text-gray-400">
{% if status_filter %}
No {{ status_filter }} alerts found.
{% else %}
All systems are operating normally.
{% endif %}
</p>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,131 @@
<!-- Today's Scheduled Actions - Dashboard Card Content -->
<!-- Summary stats -->
<div class="flex items-center gap-4 mb-4 text-sm">
{% if pending_count > 0 %}
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 bg-yellow-400 rounded-full"></span>
<span class="text-gray-600 dark:text-gray-400">{{ pending_count }} pending</span>
</div>
{% endif %}
{% if completed_count > 0 %}
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 bg-green-400 rounded-full"></span>
<span class="text-gray-600 dark:text-gray-400">{{ completed_count }} completed</span>
</div>
{% endif %}
{% if failed_count > 0 %}
<div class="flex items-center gap-1.5">
<span class="w-2 h-2 bg-red-400 rounded-full"></span>
<span class="text-gray-600 dark:text-gray-400">{{ failed_count }} failed</span>
</div>
{% endif %}
{% if total_count == 0 %}
<span class="text-gray-500 dark:text-gray-400">No actions scheduled for today</span>
{% endif %}
</div>
<!-- Actions list -->
{% if actions %}
<div class="space-y-2 max-h-64 overflow-y-auto">
{% for item in actions %}
<div class="flex items-center gap-3 p-2 rounded-lg
{% if item.action.execution_status == 'pending' %}bg-yellow-50 dark:bg-yellow-900/20
{% elif item.action.execution_status == 'completed' %}bg-green-50 dark:bg-green-900/20
{% elif item.action.execution_status == 'failed' %}bg-red-50 dark:bg-red-900/20
{% else %}bg-gray-50 dark:bg-gray-700/50{% endif %}">
<!-- Action type icon -->
<div class="flex-shrink-0">
{% if item.action.action_type == 'start' %}
<div class="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
</svg>
</div>
{% elif item.action.action_type == 'stop' %}
<div class="w-8 h-8 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<svg class="w-4 h-4 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"/>
</svg>
</div>
{% elif item.action.action_type == 'download' %}
<div class="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</div>
{% endif %}
</div>
<!-- Action details -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm text-gray-900 dark:text-white capitalize">{{ item.action.action_type }}</span>
<!-- Status indicator -->
{% if item.action.execution_status == 'pending' %}
<span class="text-xs text-yellow-600 dark:text-yellow-400">
{{ item.action.scheduled_time|local_datetime('%H:%M') }}
</span>
{% elif item.action.execution_status == 'completed' %}
<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
{% elif item.action.execution_status == 'failed' %}
<svg class="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
</svg>
{% endif %}
</div>
<!-- Location/Project info -->
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
{% if item.location %}
<a href="/projects/{{ item.action.project_id }}/nrl/{{ item.location.id }}"
class="hover:text-seismo-orange">
{{ item.location.name }}
</a>
{% elif item.project %}
<a href="/projects/{{ item.project.id }}" class="hover:text-seismo-orange">
{{ item.project.name }}
</a>
{% endif %}
</div>
<!-- Result details for completed/failed -->
{% if item.action.execution_status == 'completed' and item.result %}
{% if item.result.cycle_response and item.result.cycle_response.downloaded_folder %}
<div class="text-xs text-green-600 dark:text-green-400">
{{ item.result.cycle_response.downloaded_folder }}
{% if item.result.cycle_response.download_success %}downloaded{% endif %}
</div>
{% endif %}
{% elif item.action.execution_status == 'failed' and item.action.error_message %}
<div class="text-xs text-red-600 dark:text-red-400 truncate" title="{{ item.action.error_message }}">
{{ item.action.error_message[:50] }}{% if item.action.error_message|length > 50 %}...{% endif %}
</div>
{% endif %}
</div>
<!-- Time -->
<div class="flex-shrink-0 text-right">
{% if item.action.execution_status == 'pending' %}
<span class="text-xs text-gray-500 dark:text-gray-400">Scheduled</span>
{% elif item.action.executed_at %}
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ item.action.executed_at|local_datetime('%H:%M') }}
</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-6 text-gray-500 dark:text-gray-400">
<svg class="w-10 h-10 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<p class="text-sm">No scheduled actions for today</p>
</div>
{% endif %}

View File

@@ -104,8 +104,13 @@
{% if unit.phone_number %}
<div>{{ unit.phone_number }}</div>
{% endif %}
{% if unit.hardware_model %}
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
{% if unit.deployed_with_unit_id %}
<div>
<span class="text-gray-500 dark:text-gray-500">Linked:</span>
<a href="/unit/{{ unit.deployed_with_unit_id }}" class="text-seismo-orange hover:underline font-medium">
{{ unit.deployed_with_unit_id }}
</a>
</div>
{% endif %}
{% else %}
{% if unit.next_calibration_due %}
@@ -126,7 +131,7 @@
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400 last-seen-cell" data-iso="{{ unit.last_seen }}">{{ unit.last_seen }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm
@@ -345,6 +350,39 @@
</style>
<script>
(function() {
// User's configured timezone from settings (defaults to America/New_York)
const userTimezone = '{{ user_timezone | default("America/New_York") }}';
// Format ISO timestamp to human-readable format in user's timezone
function formatLastSeenLocal(isoString) {
if (!isoString || isoString === 'Never' || isoString === 'N/A') {
return isoString || 'Never';
}
try {
const date = new Date(isoString);
if (isNaN(date.getTime())) return isoString;
// Format in user's configured timezone
return date.toLocaleString('en-US', {
timeZone: userTimezone,
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
} catch (e) {
return isoString;
}
}
// Format all last-seen cells on page load
document.querySelectorAll('.last-seen-cell').forEach(cell => {
const isoDate = cell.getAttribute('data-iso');
cell.textContent = formatLastSeenLocal(isoDate);
});
// Update timestamp
const timestampElement = document.getElementById('last-updated');
if (timestampElement) {
@@ -365,20 +403,23 @@
};
return acc;
}, {});
})();
// Sorting state
let currentSort = { column: null, direction: 'asc' };
// Sorting state (needs to persist across swaps)
if (typeof window.currentSort === 'undefined') {
window.currentSort = { column: null, direction: 'asc' };
}
function sortTable(column) {
const tbody = document.getElementById('roster-tbody');
const rows = Array.from(tbody.getElementsByTagName('tr'));
// Determine sort direction
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
if (window.currentSort.column === column) {
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
window.currentSort.column = column;
window.currentSort.direction = 'asc';
}
// Sort rows
@@ -406,8 +447,8 @@
bVal = bVal.toLowerCase();
}
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
@@ -443,10 +484,10 @@
});
// Set current indicator
if (currentSort.column) {
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
if (window.currentSort.column) {
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
if (indicator) {
indicator.className = `sort-indicator ${currentSort.direction}`;
indicator.className = `sort-indicator ${window.currentSort.direction}`;
}
}
}

View File

@@ -0,0 +1,127 @@
<!-- Modem List -->
{% if modems %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">IP Address</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Phone</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Paired Device</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
{% for modem in modems %}
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-2">
<a href="/unit/{{ modem.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
{{ modem.id }}
</a>
{% if modem.hardware_model %}
<span class="text-xs text-gray-500 dark:text-gray-400">({{ modem.hardware_model }})</span>
{% endif %}
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if modem.status == "retired" %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
Retired
</span>
{% elif modem.status == "benched" %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
Benched
</span>
{% elif modem.status == "in_use" %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
In Use
</span>
{% elif modem.status == "spare" %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300">
Spare
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">
{% if modem.ip_address %}
<span class="font-mono text-gray-900 dark:text-gray-300">{{ modem.ip_address }}</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if modem.phone_number %}
{{ modem.phone_number }}
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">
{% if modem.paired_device %}
<a href="/unit/{{ modem.paired_device.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
{{ modem.paired_device.id }}
<span class="text-gray-500 dark:text-gray-400">({{ modem.paired_device.device_type }})</span>
</a>
{% else %}
<span class="text-gray-400 dark:text-gray-600">None</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
{% if modem.project_id %}
<span class="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded text-xs mr-1">{{ modem.project_id }}</span>
{% endif %}
{% if modem.location %}
<span class="truncate max-w-xs inline-block" title="{{ modem.location }}">{{ modem.location }}</span>
{% elif not modem.project_id %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
<div class="flex items-center justify-end gap-2">
<button onclick="pingModem('{{ modem.id }}')"
id="ping-btn-{{ modem.id }}"
class="text-xs px-2 py-1 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded transition-colors">
Ping
</button>
<a href="/unit/{{ modem.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
View →
</a>
</div>
<!-- Ping Result (hidden by default) -->
<div id="ping-result-{{ modem.id }}" class="mt-1 text-xs hidden"></div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if search %}
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
Found {{ modems|length }} modem(s) matching "{{ search }}"
</div>
{% endif %}
{% else %}
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
<p>No modems found</p>
{% if search %}
<button onclick="document.getElementById('modem-search').value = ''; htmx.trigger('#modem-search', 'keyup');"
class="mt-3 text-blue-600 dark:text-blue-400 hover:underline">
Clear search
</button>
{% else %}
<p class="text-sm mt-1">Add modems from the <a href="/roster" class="text-seismo-orange hover:underline">Fleet Roster</a></p>
{% endif %}
</div>
{% endif %}

View File

@@ -0,0 +1,51 @@
<!-- Paired Device Info for Modem Detail Page -->
{% if device %}
<div class="flex items-center gap-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
{% if device.device_type == "slm" %}
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
{% else %}
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
</svg>
{% endif %}
</div>
<div class="flex-1">
<p class="text-sm text-gray-500 dark:text-gray-400">Currently paired with</p>
<a href="/unit/{{ device.id }}" class="text-lg font-semibold text-green-700 dark:text-green-400 hover:underline">
{{ device.id }}
</a>
<div class="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-400">
<span class="capitalize">{{ device.device_type }}</span>
{% if device.project_id %}
<span class="text-gray-400">|</span>
<span>{{ device.project_id }}</span>
{% endif %}
{% if device.deployed %}
<span class="px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs rounded">Deployed</span>
{% else %}
<span class="px-1.5 py-0.5 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs rounded">Benched</span>
{% endif %}
</div>
</div>
<a href="/unit/{{ device.id }}" class="text-gray-400 hover:text-seismo-orange transition-colors">
<svg class="w-5 h-5" 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>
</div>
{% else %}
<div class="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
<svg class="w-6 h-6 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>
</div>
<div class="flex-1">
<p class="text-gray-600 dark:text-gray-400">No device currently paired</p>
<p class="text-sm text-gray-500 dark:text-gray-500">This modem is available for assignment</p>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,128 @@
{#
Modem Picker Component
A reusable HTMX-based autocomplete for selecting modems.
Usage: include "partials/modem_picker.html" with context
Variables available in context:
- selected_modem_id: Pre-selected modem ID (optional)
- selected_modem_display: Display text for pre-selected modem (optional)
- input_name: Name attribute for the hidden input (default: "deployed_with_modem_id")
- picker_id: Unique ID suffix for multiple pickers on same page (default: "")
#}
{% set picker_id = picker_id|default("") %}
{% set input_name = input_name|default("deployed_with_modem_id") %}
{% set selected_modem_id = selected_modem_id|default("") %}
{% set selected_modem_display = selected_modem_display|default("") %}
<div class="modem-picker relative" id="modem-picker-container{{ picker_id }}">
<!-- Hidden input for form submission (stores modem ID) -->
<input type="hidden"
name="{{ input_name }}"
id="modem-picker-value{{ picker_id }}"
value="{{ selected_modem_id }}">
<!-- Search Input -->
<div class="relative">
<input type="text"
id="modem-picker-search{{ picker_id }}"
placeholder="Search by modem ID, IP, or note..."
class="w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
autocomplete="off"
value="{{ selected_modem_display }}"
hx-get="/api/roster/search/modems"
hx-trigger="keyup changed delay:300ms, focus"
hx-target="#modem-picker-dropdown{{ picker_id }}"
hx-vals='{"picker_id": "{{ picker_id }}"}'
name="q"
onfocus="showModemDropdown('{{ picker_id }}')"
oninput="handleModemSearchInput('{{ picker_id }}', this.value)">
<!-- Search icon -->
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<!-- Clear button (shown when modem is selected) -->
<button type="button"
id="modem-picker-clear{{ picker_id }}"
class="absolute inset-y-0 right-8 flex items-center pr-1 {{ 'hidden' if not selected_modem_id else '' }}"
onclick="clearModemSelection('{{ picker_id }}')"
title="Clear selection">
<svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" 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>
<!-- Dropdown Results Container -->
<div id="modem-picker-dropdown{{ picker_id }}"
class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
<!-- Results loaded via HTMX -->
</div>
</div>
<script>
{# Modem picker functions - defined once, work for any picker_id #}
if (typeof selectModem === 'undefined') {
function selectModem(modemId, displayText, pickerId = '') {
const valueInput = document.getElementById('modem-picker-value' + pickerId);
const searchInput = document.getElementById('modem-picker-search' + pickerId);
const dropdown = document.getElementById('modem-picker-dropdown' + pickerId);
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
if (valueInput) valueInput.value = modemId;
if (searchInput) searchInput.value = displayText;
if (dropdown) dropdown.classList.add('hidden');
if (clearBtn) clearBtn.classList.remove('hidden');
}
function clearModemSelection(pickerId = '') {
const valueInput = document.getElementById('modem-picker-value' + pickerId);
const searchInput = document.getElementById('modem-picker-search' + pickerId);
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
if (valueInput) valueInput.value = '';
if (searchInput) {
searchInput.value = '';
searchInput.focus();
}
if (clearBtn) clearBtn.classList.add('hidden');
}
function showModemDropdown(pickerId = '') {
const dropdown = document.getElementById('modem-picker-dropdown' + pickerId);
if (dropdown) dropdown.classList.remove('hidden');
}
function hideModemDropdown(pickerId = '') {
const dropdown = document.getElementById('modem-picker-dropdown' + pickerId);
if (dropdown) dropdown.classList.add('hidden');
}
function handleModemSearchInput(pickerId, value) {
const valueInput = document.getElementById('modem-picker-value' + pickerId);
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
// If user clears the search box, also clear the hidden value
if (!value.trim()) {
if (valueInput) valueInput.value = '';
if (clearBtn) clearBtn.classList.add('hidden');
}
}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
const pickers = document.querySelectorAll('.modem-picker');
pickers.forEach(picker => {
if (!picker.contains(event.target)) {
const dropdown = picker.querySelector('[id^="modem-picker-dropdown"]');
if (dropdown) dropdown.classList.add('hidden');
}
});
});
}
</script>

View File

@@ -0,0 +1,61 @@
{#
Modem Search Results Partial
Rendered by /api/roster/search/modems endpoint for HTMX dropdown.
Variables:
- modems: List of modem dicts with id, ip_address, phone_number, note, deployed, display
- query: The search query string
- show_empty: Boolean - show "no results" message
#}
{% set picker_id = request.query_params.get('picker_id', '') %}
{% if modems %}
{% for modem in modems %}
<div class="px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0 transition-colors"
onclick="selectModem('{{ modem.id }}', '{{ modem.display|e }}', '{{ picker_id }}')">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 dark:text-white truncate">
<span class="text-seismo-orange font-semibold">{{ modem.id }}</span>
{% if modem.ip_address %}
<span class="text-gray-400 mx-1">-</span>
<span class="text-gray-600 dark:text-gray-400 font-mono text-sm">{{ modem.ip_address }}</span>
{% endif %}
</div>
{% if modem.note %}
<div class="text-sm text-gray-500 dark:text-gray-400 truncate">
{{ modem.note }}
</div>
{% endif %}
</div>
<div class="flex items-center gap-2">
{% if not modem.deployed %}
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
Benched
</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% endif %}
{% if show_empty %}
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<p class="text-sm">No modems found matching "{{ query }}"</p>
</div>
{% endif %}
{% if not modems and not show_empty %}
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<p class="text-sm">Start typing to search modems...</p>
<p class="text-xs mt-1">Search by modem ID, IP address, or note</p>
</div>
{% endif %}

View File

@@ -0,0 +1,63 @@
<!-- Modem summary stat cards -->
<!-- Total Modems Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Total Modems</p>
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ total_count }}</p>
</div>
<div class="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
<svg class="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
</div>
</div>
</div>
<!-- In Use Card (paired with device) -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">In Use</p>
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{{ in_use_count }}</p>
</div>
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Paired with a device</p>
</div>
<!-- Spare Card (deployed but not paired) -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Spare</p>
<p class="text-3xl font-bold text-seismo-orange mt-1">{{ spare_count }}</p>
</div>
<div class="bg-orange-100 dark:bg-orange-900/30 p-3 rounded-lg">
<svg class="w-8 h-8 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
</svg>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Available for assignment</p>
</div>
<!-- Benched Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Benched</p>
<p class="text-3xl font-bold text-gray-500 dark:text-gray-400 mt-1">{{ benched_count }}</p>
</div>
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
<svg class="w-8 h-8 text-gray-600 dark: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>
</div>
</div>
</div>

View File

@@ -0,0 +1,233 @@
{#
Quick Create Project Modal
Allows inline creation of a new project from the project picker.
Include this modal in pages that use the project picker.
#}
<div id="quickCreateProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-md w-full mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex justify-between items-center">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
<button type="button" onclick="closeCreateProjectModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<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>
<form id="quickCreateProjectForm" class="p-6 space-y-4">
<!-- Hidden field to track which picker opened this modal -->
<input type="hidden" id="qcp-picker-id" value="">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Project Number
<span class="text-gray-400 font-normal">(xxxx-YY)</span>
</label>
<input type="text"
name="project_number"
id="qcp-project-number"
pattern="\d{4}-\d{2}"
placeholder="2567-23"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">TMI internal project number (optional)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Client Name <span class="text-red-500">*</span>
</label>
<input type="text"
name="client_name"
id="qcp-client-name"
required
placeholder="PJ Dick, Turner Construction, etc."
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Project Name <span class="text-red-500">*</span>
</label>
<input type="text"
name="name"
id="qcp-project-name"
required
placeholder="RKM Hall, CMU Campus, Building 7, etc."
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Site or building name</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Project Type <span class="text-red-500">*</span>
</label>
<select name="project_type_id"
id="qcp-project-type"
required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<option value="vibration_monitoring">Vibration Monitoring</option>
<option value="sound_monitoring">Sound Monitoring</option>
<option value="combined">Combined (Vibration + Sound)</option>
</select>
</div>
<div id="qcp-error" class="hidden p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm">
</div>
<div class="flex gap-3 pt-2">
<button type="submit"
id="qcp-submit-btn"
class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Create & Select
</button>
<button type="button"
onclick="closeCreateProjectModal()"
class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg font-medium transition-colors">
Cancel
</button>
</div>
</form>
</div>
</div>
<script>
// Quick create project modal functions
if (typeof openCreateProjectModal === 'undefined') {
function openCreateProjectModal(searchQuery, pickerId = '') {
const modal = document.getElementById('quickCreateProjectModal');
const pickerIdInput = document.getElementById('qcp-picker-id');
const projectNumInput = document.getElementById('qcp-project-number');
const clientNameInput = document.getElementById('qcp-client-name');
const projectNameInput = document.getElementById('qcp-project-name');
const errorDiv = document.getElementById('qcp-error');
// Store which picker opened this
if (pickerIdInput) pickerIdInput.value = pickerId;
// Reset form
document.getElementById('quickCreateProjectForm').reset();
if (errorDiv) errorDiv.classList.add('hidden');
// Try to parse the search query intelligently
if (searchQuery) {
// Check if it looks like a project number (xxxx-YY pattern)
const projectNumMatch = searchQuery.match(/(\d{4}-\d{2})/);
if (projectNumMatch) {
if (projectNumInput) projectNumInput.value = projectNumMatch[1];
// If there's more after the number, use it as client name
const remainder = searchQuery.replace(projectNumMatch[1], '').replace(/^[\s\-]+/, '').trim();
if (remainder && clientNameInput) clientNameInput.value = remainder;
} else {
// Not a project number - assume it's client or project name
// If short (likely a name fragment), put it in client name
if (clientNameInput) clientNameInput.value = searchQuery;
}
}
// Show modal
if (modal) modal.classList.remove('hidden');
// Focus the first empty required field
if (clientNameInput && !clientNameInput.value) {
clientNameInput.focus();
} else if (projectNameInput) {
projectNameInput.focus();
}
}
function closeCreateProjectModal() {
const modal = document.getElementById('quickCreateProjectModal');
if (modal) modal.classList.add('hidden');
}
// Handle quick create form submission
document.getElementById('quickCreateProjectForm')?.addEventListener('submit', async function(e) {
e.preventDefault();
const submitBtn = document.getElementById('qcp-submit-btn');
const errorDiv = document.getElementById('qcp-error');
const pickerId = document.getElementById('qcp-picker-id')?.value || '';
// Show loading state
const originalBtnText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = `
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Creating...
`;
if (errorDiv) errorDiv.classList.add('hidden');
const formData = new FormData(this);
try {
const response = await fetch('/api/projects/create', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
// Build display text from form values
const parts = [];
const projectNumber = formData.get('project_number');
const clientName = formData.get('client_name');
const projectName = formData.get('name');
if (projectNumber) parts.push(projectNumber);
if (clientName) parts.push(clientName);
if (projectName) parts.push(projectName);
const displayText = parts.join(' - ');
// Select the newly created project in the picker
selectProject(result.project_id, displayText, pickerId);
// Close modal
closeCreateProjectModal();
} else {
// Show error
if (errorDiv) {
errorDiv.textContent = result.detail || result.message || 'Failed to create project';
errorDiv.classList.remove('hidden');
}
}
} catch (error) {
if (errorDiv) {
errorDiv.textContent = `Error: ${error.message}`;
errorDiv.classList.remove('hidden');
}
} finally {
// Restore button
submitBtn.disabled = false;
submitBtn.innerHTML = originalBtnText;
}
});
// Close modal on backdrop click
document.getElementById('quickCreateProjectModal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeCreateProjectModal();
}
});
// Close modal on Escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const modal = document.getElementById('quickCreateProjectModal');
if (modal && !modal.classList.contains('hidden')) {
closeCreateProjectModal();
}
}
});
}
</script>

View File

@@ -0,0 +1,128 @@
{#
Project Picker Component
A reusable HTMX-based autocomplete for selecting projects.
Usage: include "partials/project_picker.html" with context
Variables available in context:
- selected_project_id: Pre-selected project UUID (optional)
- selected_project_display: Display text for pre-selected project (optional)
- input_name: Name attribute for the hidden input (default: "project_id")
- picker_id: Unique ID suffix for multiple pickers on same page (default: "")
#}
{% set picker_id = picker_id|default("") %}
{% set input_name = input_name|default("project_id") %}
{% set selected_project_id = selected_project_id|default("") %}
{% set selected_project_display = selected_project_display|default("") %}
<div class="project-picker relative" id="project-picker-container{{ picker_id }}">
<!-- Hidden input for form submission (stores project UUID) -->
<input type="hidden"
name="{{ input_name }}"
id="project-picker-value{{ picker_id }}"
value="{{ selected_project_id }}">
<!-- Search Input -->
<div class="relative">
<input type="text"
id="project-picker-search{{ picker_id }}"
placeholder="Search by project number, client, or name..."
class="w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
autocomplete="off"
value="{{ selected_project_display }}"
hx-get="/api/projects/search"
hx-trigger="keyup changed delay:300ms, focus"
hx-target="#project-picker-dropdown{{ picker_id }}"
hx-vals='{"picker_id": "{{ picker_id }}"}'
name="q"
onfocus="showProjectDropdown('{{ picker_id }}')"
oninput="handleProjectSearchInput('{{ picker_id }}', this.value)">
<!-- Search icon -->
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<!-- Clear button (shown when project is selected) -->
<button type="button"
id="project-picker-clear{{ picker_id }}"
class="absolute inset-y-0 right-8 flex items-center pr-1 {{ 'hidden' if not selected_project_id else '' }}"
onclick="clearProjectSelection('{{ picker_id }}')"
title="Clear selection">
<svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" 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>
<!-- Dropdown Results Container -->
<div id="project-picker-dropdown{{ picker_id }}"
class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
<!-- Results loaded via HTMX -->
</div>
</div>
<script>
// Project picker functions - defined once, work for any picker_id
if (typeof selectProject === 'undefined') {
function selectProject(projectId, displayText, pickerId = '') {
const valueInput = document.getElementById('project-picker-value' + pickerId);
const searchInput = document.getElementById('project-picker-search' + pickerId);
const dropdown = document.getElementById('project-picker-dropdown' + pickerId);
const clearBtn = document.getElementById('project-picker-clear' + pickerId);
if (valueInput) valueInput.value = projectId;
if (searchInput) searchInput.value = displayText;
if (dropdown) dropdown.classList.add('hidden');
if (clearBtn) clearBtn.classList.remove('hidden');
}
function clearProjectSelection(pickerId = '') {
const valueInput = document.getElementById('project-picker-value' + pickerId);
const searchInput = document.getElementById('project-picker-search' + pickerId);
const clearBtn = document.getElementById('project-picker-clear' + pickerId);
if (valueInput) valueInput.value = '';
if (searchInput) {
searchInput.value = '';
searchInput.focus();
}
if (clearBtn) clearBtn.classList.add('hidden');
}
function showProjectDropdown(pickerId = '') {
const dropdown = document.getElementById('project-picker-dropdown' + pickerId);
if (dropdown) dropdown.classList.remove('hidden');
}
function hideProjectDropdown(pickerId = '') {
const dropdown = document.getElementById('project-picker-dropdown' + pickerId);
if (dropdown) dropdown.classList.add('hidden');
}
function handleProjectSearchInput(pickerId, value) {
const valueInput = document.getElementById('project-picker-value' + pickerId);
const clearBtn = document.getElementById('project-picker-clear' + pickerId);
// If user clears the search box, also clear the hidden value
if (!value.trim()) {
if (valueInput) valueInput.value = '';
if (clearBtn) clearBtn.classList.add('hidden');
}
}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
const pickers = document.querySelectorAll('.project-picker');
pickers.forEach(picker => {
if (!picker.contains(event.target)) {
const dropdown = picker.querySelector('[id^="project-picker-dropdown"]');
if (dropdown) dropdown.classList.add('hidden');
}
});
});
}
</script>

View File

@@ -0,0 +1,69 @@
<!--
Project Search Results Partial
Rendered by /api/projects/search endpoint for HTMX dropdown.
Variables:
- projects: List of project dicts with id, project_number, client_name, name, display, status
- query: The search query string
- show_create: Boolean - show "Create new project" option when no matches
-->
{% set picker_id = request.query_params.get('picker_id', '') %}
{% if projects %}
{% for project in projects %}
<div class="px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0 transition-colors"
onclick="selectProject('{{ project.id }}', '{{ project.display|e }}', '{{ picker_id }}')">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 dark:text-white truncate">
{% if project.project_number %}
<span class="text-seismo-orange font-semibold">{{ project.project_number }}</span>
{% if project.client_name or project.name %}
<span class="text-gray-400 mx-1">-</span>
{% endif %}
{% endif %}
{% if project.client_name %}
<span>{{ project.client_name }}</span>
{% endif %}
</div>
{% if project.name %}
<div class="text-sm text-gray-500 dark:text-gray-400 truncate">
{{ project.name }}
</div>
{% endif %}
</div>
{% if project.status == 'completed' %}
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
Completed
</span>
{% endif %}
</div>
</div>
{% endfor %}
{% endif %}
{% if show_create %}
<div class="px-4 py-3 hover:bg-green-50 dark:hover:bg-green-900/30 cursor-pointer border-t border-gray-200 dark:border-gray-600 transition-colors"
onclick="openCreateProjectModal('{{ query|e }}', '{{ picker_id }}')">
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
<span class="font-medium">Create new project "{{ query }}"</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-7">
No matching projects found. Click to create a new one.
</p>
</div>
{% endif %}
{% if not projects and not show_create %}
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<p class="text-sm">Start typing to search projects...</p>
<p class="text-xs mt-1">Search by project number, client name, or project name</p>
</div>
{% endif %}

View File

@@ -10,7 +10,7 @@
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Location: {{ item.location.name }}</p>
{% endif %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Assigned: {% if item.assignment.assigned_at %}{{ item.assignment.assigned_at.strftime('%Y-%m-%d %H:%M') }}{% else %}Unknown{% endif %}
Assigned: {% if item.assignment.assigned_at %}{{ item.assignment.assigned_at|local_datetime }}{% else %}Unknown{% endif %}
</p>
</div>
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">

View File

@@ -22,6 +22,16 @@
</span>
</div>
<div class="flex items-center gap-2">
<button onclick="showFTPSettings('{{ unit_item.unit.id }}')"
id="settings-ftp-{{ unit_item.unit.id }}"
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
title="Configure FTP credentials">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
Settings
</button>
<button onclick="enableFTP('{{ unit_item.unit.id }}')"
id="enable-ftp-{{ unit_item.unit.id }}"
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
@@ -605,3 +615,6 @@ setTimeout(function() {
{% endfor %}
}, 100);
</script>
<!-- Include the unified SLM Settings Modal -->
{% include 'partials/slm_settings_modal.html' %}

View File

@@ -81,7 +81,7 @@
{% for action in upcoming_actions %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time.strftime('%Y-%m-%d %H:%M') }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time|local_datetime }} {{ timezone_abbr() }}</p>
{% if action.description %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
{% endif %}

View File

@@ -1,4 +1,6 @@
<div class="mb-8">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
<div class="flex items-center gap-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
@@ -12,3 +14,17 @@
{% endif %}
</div>
</div>
<!-- Project Actions -->
<div class="flex items-center gap-3">
{% if project_type and project_type.id == 'sound_monitoring' %}
<a href="/api/projects/{{ project.id }}/generate-combined-report"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Generate Combined Report
</a>
{% endif %}
</div>
</div>
</div>

View File

@@ -0,0 +1,151 @@
<!-- Recurring Schedule List -->
<!-- Displays all recurring schedules for a project -->
<div class="space-y-4">
{% if schedules %}
{% for item in schedules %}
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4
{% if not item.schedule.enabled %}opacity-60{% endif %}">
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-2">
<h4 class="text-base font-semibold text-gray-900 dark:text-white">
{{ item.schedule.name }}
</h4>
<!-- Type badge -->
{% if item.schedule.schedule_type == 'weekly_calendar' %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
Weekly
</span>
{% else %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
24/7 Cycle
</span>
{% endif %}
<!-- Status badge -->
{% if item.schedule.enabled %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
Active
</span>
{% else %}
<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">
Disabled
</span>
{% endif %}
</div>
<!-- Location info -->
{% if item.location %}
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
<span class="text-gray-500">Location:</span>
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
{{ item.location.name }}
</a>
</div>
{% endif %}
<!-- Schedule details -->
<div class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
{% if item.schedule.schedule_type == 'weekly_calendar' and item.pattern %}
<div class="flex flex-wrap gap-2">
{% set days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] %}
{% set day_abbr = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] %}
{% for day in days %}
{% if item.pattern.get(day, {}).get('enabled') %}
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 rounded">
{{ day_abbr[loop.index0] }}
{{ item.pattern[day].get('start', '') }}-{{ item.pattern[day].get('end', '') }}
</span>
{% endif %}
{% endfor %}
</div>
{% elif item.schedule.schedule_type == 'simple_interval' %}
<div>
Cycle at {{ item.schedule.cycle_time or '00:00' }} daily
{% if item.schedule.include_download %}
(with download)
{% endif %}
</div>
{% endif %}
{% if item.schedule.next_occurrence %}
<div class="text-xs">
<span class="text-gray-400">Next:</span>
{{ item.schedule.next_occurrence|local_datetime }} {{ timezone_abbr() }}
</div>
{% endif %}
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
{% if item.schedule.enabled %}
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable"
hx-swap="none"
hx-on::after-request="htmx.trigger('#recurring-schedule-list', 'refresh')"
class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Disable
</button>
{% else %}
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/enable"
hx-swap="none"
hx-on::after-request="htmx.trigger('#recurring-schedule-list', 'refresh')"
class="px-3 py-1.5 text-sm bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg hover:bg-green-200 dark:hover:bg-green-900/50 transition-colors">
Enable
</button>
{% endif %}
<button onclick="editSchedule('{{ item.schedule.id }}')"
class="px-3 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
Edit
</button>
<button hx-delete="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}"
hx-confirm="Delete this recurring schedule?"
hx-swap="none"
hx-on::after-request="htmx.trigger('#recurring-schedule-list', 'refresh')"
class="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No recurring schedules</h3>
<p class="text-gray-500 dark:text-gray-400 mb-4">
Create a schedule to automate monitoring start/stop times.
</p>
<button onclick="showCreateScheduleModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
Create Schedule
</button>
</div>
{% endif %}
</div>
<script>
function editSchedule(scheduleId) {
// For now, redirect to a future edit page or show details
// The edit modal will be implemented later
alert('Edit schedule: ' + scheduleId + '\n\nNote: Full edit functionality coming soon. For now, you can delete and recreate the schedule.');
}
function showCreateScheduleModal() {
// Call the parent page's openScheduleModal function
if (typeof openScheduleModal === 'function') {
openScheduleModal();
} else {
alert('Please use the "Create Schedule" button in the Schedules tab.');
}
}
</script>

View File

@@ -0,0 +1,231 @@
<!-- Weekly Calendar Schedule Editor -->
<!-- Used in modals/forms for creating/editing weekly_calendar type schedules -->
<div id="schedule-calendar-editor" class="space-y-4">
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Weekly Schedule</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
Select which days to monitor and set start/end times for each day.
For overnight monitoring (e.g., 7pm to 7am), the end time will be on the following day.
</p>
</div>
<!-- Day rows -->
<div class="space-y-3">
{% set days = [
('monday', 'Monday'),
('tuesday', 'Tuesday'),
('wednesday', 'Wednesday'),
('thursday', 'Thursday'),
('friday', 'Friday'),
('saturday', 'Saturday'),
('sunday', 'Sunday')
] %}
{% for day_key, day_name in days %}
<div class="flex items-center gap-4 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<!-- Day toggle -->
<label class="flex items-center gap-2 w-28 cursor-pointer">
<input type="checkbox"
id="day-{{ day_key }}"
name="weekly_pattern[{{ day_key }}][enabled]"
class="rounded text-seismo-orange focus:ring-seismo-orange"
onchange="toggleDayTimes('{{ day_key }}', this.checked)"
{% if pattern and pattern.get(day_key, {}).get('enabled') %}checked{% endif %}>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ day_name }}</span>
</label>
<!-- Time inputs -->
<div class="flex items-center gap-2 day-times flex-1" id="times-{{ day_key }}"
{% if not pattern or not pattern.get(day_key, {}).get('enabled') %}style="opacity: 0.4; pointer-events: none;"{% endif %}>
<label class="text-xs text-gray-500 dark:text-gray-400">Start:</label>
<input type="time"
name="weekly_pattern[{{ day_key }}][start]"
value="{{ pattern.get(day_key, {}).get('start', '19:00') if pattern else '19:00' }}"
class="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">
<span class="text-gray-400 mx-1">to</span>
<label class="text-xs text-gray-500 dark:text-gray-400">End:</label>
<input type="time"
name="weekly_pattern[{{ day_key }}][end]"
value="{{ pattern.get(day_key, {}).get('end', '07:00') if pattern else '07:00' }}"
class="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2" id="overnight-hint-{{ day_key }}"
style="display: none;">
(next day)
</span>
</div>
</div>
{% endfor %}
</div>
<!-- Quick select buttons -->
<div class="flex flex-wrap gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
<span class="text-xs text-gray-500 dark:text-gray-400 mr-2">Quick select:</span>
<button type="button" onclick="selectWeekdays()"
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
Weekdays
</button>
<button type="button" onclick="selectWeekends()"
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
Weekends
</button>
<button type="button" onclick="selectAllDays()"
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
All Days
</button>
<button type="button" onclick="clearAllDays()"
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
Clear All
</button>
</div>
<!-- Automation Options -->
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Automation Options</h5>
<div class="space-y-3">
<!-- Download data option -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox"
name="include_download"
id="include_download_calendar"
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
{% if include_download is not defined or include_download %}checked{% endif %}>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Download data after each monitoring period
</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, measurement data will be downloaded via FTP after each stop.
Disable if you prefer to download manually or if FTP is not configured.
</p>
</div>
</label>
</div>
<!-- Auto-increment index option -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox"
name="auto_increment_index"
id="auto_increment_index_calendar"
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
{% if auto_increment_index is not defined or auto_increment_index %}checked{% endif %}>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Auto-increment store index before each start
</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, the system will find an unused store/index number before starting.
This prevents "overwrite existing data?" prompts on the device.
</p>
</div>
</label>
</div>
</div>
</div>
</div>
<script>
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
const weekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
const weekends = ['saturday', 'sunday'];
function toggleDayTimes(day, enabled) {
const timesDiv = document.getElementById('times-' + day);
if (enabled) {
timesDiv.style.opacity = '1';
timesDiv.style.pointerEvents = 'auto';
} else {
timesDiv.style.opacity = '0.4';
timesDiv.style.pointerEvents = 'none';
}
updateOvernightHints();
}
function setDayEnabled(day, enabled) {
const checkbox = document.getElementById('day-' + day);
checkbox.checked = enabled;
toggleDayTimes(day, enabled);
}
function selectWeekdays() {
days.forEach(day => setDayEnabled(day, weekdays.includes(day)));
}
function selectWeekends() {
days.forEach(day => setDayEnabled(day, weekends.includes(day)));
}
function selectAllDays() {
days.forEach(day => setDayEnabled(day, true));
}
function clearAllDays() {
days.forEach(day => setDayEnabled(day, false));
}
function updateOvernightHints() {
days.forEach(day => {
const startInput = document.querySelector(`input[name="weekly_pattern[${day}][start]"]`);
const endInput = document.querySelector(`input[name="weekly_pattern[${day}][end]"]`);
const hint = document.getElementById('overnight-hint-' + day);
if (startInput && endInput && hint) {
const start = startInput.value;
const end = endInput.value;
hint.style.display = (end && start && end <= start) ? 'inline' : 'none';
}
});
}
// Update hints on time change
document.querySelectorAll('input[type="time"]').forEach(input => {
input.addEventListener('change', updateOvernightHints);
});
// Initial update
updateOvernightHints();
// Function to collect form data as JSON
function getWeeklyPatternData() {
const pattern = {};
days.forEach(day => {
const checkbox = document.getElementById('day-' + day);
const startInput = document.querySelector(`input[name="weekly_pattern[${day}][start]"]`);
const endInput = document.querySelector(`input[name="weekly_pattern[${day}][end]"]`);
pattern[day] = {
enabled: checkbox.checked,
start: startInput.value,
end: endInput.value
};
});
return pattern;
}
// Function to get auto-increment setting for calendar mode
function getCalendarAutoIncrement() {
const checkbox = document.getElementById('auto_increment_index_calendar');
return checkbox ? checkbox.checked : true;
}
// Function to get include_download setting for calendar mode
function getCalendarIncludeDownload() {
const checkbox = document.getElementById('include_download_calendar');
return checkbox ? checkbox.checked : true;
}
// Function to get all calendar options as object
function getCalendarOptions() {
return {
weekly_pattern: getWeeklyPatternData(),
auto_increment_index: getCalendarAutoIncrement(),
include_download: getCalendarIncludeDownload()
};
}
</script>

View File

@@ -0,0 +1,158 @@
<!-- Simple Interval Schedule Editor -->
<!-- Used for 24/7 continuous monitoring with daily stop/download/restart cycles -->
<div id="schedule-interval-editor" class="space-y-4">
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Continuous Monitoring (24/7)</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
For uninterrupted monitoring. The device will automatically stop, download data,
and restart at the configured cycle time each day.
</p>
</div>
<!-- Info box -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div class="flex gap-3">
<svg class="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div class="text-sm text-blue-700 dark:text-blue-300">
<p class="font-medium mb-1">How it works:</p>
<ol class="list-decimal list-inside space-y-1 text-xs">
<li>At the cycle time, the measurement will <strong>stop</strong></li>
<li>If enabled, data will be <strong>downloaded</strong> via FTP</li>
<li>The measurement will <strong>restart</strong> automatically</li>
</ol>
</div>
</div>
</div>
<!-- Cycle time -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Daily Cycle Time
</label>
<div class="flex items-center gap-4">
<input type="time"
name="cycle_time"
id="cycle_time"
value="{{ cycle_time or '00:00' }}"
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 focus:ring-seismo-orange focus:border-seismo-orange">
<span class="text-sm text-gray-500 dark:text-gray-400">
Time when stop/download/restart cycle runs
</span>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
Recommended: midnight (00:00) to minimize disruption to data collection
</p>
</div>
<!-- Download option -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox"
name="include_download"
id="include_download"
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
{% if include_download is not defined or include_download %}checked{% endif %}>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Download data before restart
</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, measurement data will be downloaded via FTP during the cycle.
Disable if you prefer to download manually or if FTP is not configured.
</p>
</div>
</label>
</div>
<!-- Auto-increment index option -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox"
name="auto_increment_index"
id="auto_increment_index_interval"
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
{% if auto_increment_index is not defined or auto_increment_index %}checked{% endif %}>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Auto-increment store index before restart
</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, the store/index number is incremented before starting a new measurement.
This prevents "overwrite existing data?" prompts on the device.
</p>
</div>
</label>
</div>
<!-- Interval type (hidden for now, default to daily) -->
<input type="hidden" name="interval_type" value="daily">
<!-- Cycle preview -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Cycle Sequence Preview</h5>
<div class="flex items-center gap-2 text-sm">
<div class="flex items-center gap-2">
<span class="w-6 h-6 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center text-xs text-red-700 dark:text-red-300">1</span>
<span class="text-gray-600 dark:text-gray-400">Stop</span>
</div>
<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"/>
</svg>
<div class="flex items-center gap-2" id="download-step">
<span class="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-xs text-blue-700 dark:text-blue-300">2</span>
<span class="text-gray-600 dark:text-gray-400">Download</span>
</div>
<svg class="w-4 h-4 text-gray-400" id="download-arrow" 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"/>
</svg>
<div class="flex items-center gap-2">
<span class="w-6 h-6 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center text-xs text-green-700 dark:text-green-300" id="start-step-num">3</span>
<span class="text-gray-600 dark:text-gray-400">Start</span>
</div>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3" id="cycle-timing">
At <span id="preview-time">00:00</span>: Stop → Download (1 min) → Start (2 min)
</p>
</div>
</div>
<script>
// Update preview when download checkbox changes
document.getElementById('include_download').addEventListener('change', function() {
const downloadStep = document.getElementById('download-step');
const downloadArrow = document.getElementById('download-arrow');
const startStepNum = document.getElementById('start-step-num');
const cycleTiming = document.getElementById('cycle-timing');
const timeValue = document.getElementById('cycle_time').value || '00:00';
if (this.checked) {
downloadStep.style.display = 'flex';
downloadArrow.style.display = 'block';
startStepNum.textContent = '3';
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Download (1 min) → Start (2 min)`;
} else {
downloadStep.style.display = 'none';
downloadArrow.style.display = 'none';
startStepNum.textContent = '2';
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Start (1 min)`;
}
});
// Update preview time when cycle time changes
document.getElementById('cycle_time').addEventListener('change', function() {
document.getElementById('preview-time').textContent = this.value || '00:00';
});
// Function to get interval data as object
function getIntervalData() {
return {
interval_type: 'daily',
cycle_time: document.getElementById('cycle_time').value,
include_download: document.getElementById('include_download').checked,
auto_increment_index: document.getElementById('auto_increment_index_interval').checked
};
}
</script>

View File

@@ -1,98 +1,189 @@
<!-- Scheduled Actions List -->
{% if schedules %}
<div class="space-y-4">
{% for item in schedules %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<!-- Scheduled Actions List - Grouped by Date -->
{% if schedules_by_date %}
<div class="space-y-6">
{% for date_key, date_group in schedules_by_date.items() %}
<div>
<!-- Date Header -->
<div class="flex items-center gap-3 mb-3">
<div class="flex-shrink-0 w-10 h-10 bg-seismo-orange/10 dark:bg-seismo-orange/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ date_group.date_display }}</h3>
<p class="text-xs text-gray-500">{{ date_group.actions|length }} action{{ 's' if date_group.actions|length != 1 else '' }}</p>
</div>
</div>
<!-- Actions for this date -->
<div class="space-y-3 ml-13 pl-3 border-l-2 border-gray-200 dark:border-gray-700">
{% for item in date_group.actions %}
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3 mb-2">
<h4 class="font-semibold text-gray-900 dark:text-white">
{{ item.schedule.action_type }}
</h4>
<!-- Action type with icon -->
{% if item.schedule.action_type == 'start' %}
<span class="flex items-center gap-1.5 text-green-600 dark:text-green-400 font-semibold">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
</svg>
Start
</span>
{% elif item.schedule.action_type == 'stop' %}
<span class="flex items-center gap-1.5 text-red-600 dark:text-red-400 font-semibold">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"/>
</svg>
Stop
</span>
{% elif item.schedule.action_type == 'download' %}
<span class="flex items-center gap-1.5 text-blue-600 dark:text-blue-400 font-semibold">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
Download
</span>
{% else %}
<span class="font-semibold text-gray-900 dark:text-white">{{ item.schedule.action_type }}</span>
{% endif %}
<!-- Status badge -->
{% if item.schedule.execution_status == 'pending' %}
<span class="px-2 py-1 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
</span>
{% elif item.schedule.execution_status == 'completed' %}
<span class="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">
<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
</span>
{% elif item.schedule.execution_status == 'failed' %}
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full">
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full">
Failed
</span>
{% elif item.schedule.execution_status == 'cancelled' %}
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
Cancelled
</span>
{% endif %}
</div>
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
<div class="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
<!-- Time -->
<div class="flex items-center gap-1">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ item.schedule.scheduled_time|local_datetime('%H:%M') if item.schedule.scheduled_time else 'N/A' }}</span>
</div>
<!-- Location -->
{% if item.location %}
<div>
<span class="text-xs text-gray-500">Location:</span>
<div class="flex items-center gap-1">
<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="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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
class="text-seismo-orange hover:text-seismo-navy font-medium">
{{ item.location.name }}
</a>
</div>
{% endif %}
<div>
<span class="text-xs text-gray-500">Scheduled:</span>
<span class="ml-1">{{ item.schedule.scheduled_time.strftime('%Y-%m-%d %H:%M') if item.schedule.scheduled_time else 'N/A' }}</span>
</div>
{% if item.schedule.executed_at %}
<div>
<span class="text-xs text-gray-500">Executed:</span>
<span class="ml-1">{{ item.schedule.executed_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div>
{% endif %}
{% if item.schedule.created_at %}
<div>
<span class="text-xs text-gray-500">Created:</span>
<span class="ml-1">{{ item.schedule.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
<div class="flex items-center gap-1">
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>Executed {{ item.schedule.executed_at|local_datetime('%H:%M') }}</span>
</div>
{% endif %}
</div>
{% if item.schedule.description %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
{{ item.schedule.description }}
</p>
{% endif %}
{% if item.schedule.result_message %}
<div class="mt-2 text-xs">
<span class="text-gray-500">Result:</span>
<span class="ml-1 text-gray-700 dark:text-gray-300">{{ item.schedule.result_message }}</span>
{% if item.schedule.error_message %}
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs">
<span class="text-red-600 dark:text-red-400 font-medium">Error:</span>
<span class="ml-1 text-red-700 dark:text-red-300">{{ item.schedule.error_message }}</span>
</div>
{% endif %}
</div>
<!-- Execution result details for completed/failed actions -->
{% if item.result and item.schedule.execution_status in ['completed', 'failed'] %}
<div class="mt-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded text-xs space-y-1">
{% if item.result.cycle_response %}
{% set cycle = item.result.cycle_response %}
{% if cycle.new_index is defined and cycle.new_index is not none %}
<div class="flex items-center gap-2">
<span class="text-gray-500 dark:text-gray-400">Index:</span>
<span class="font-mono text-gray-700 dark:text-gray-300">{{ '%04d'|format(cycle.new_index) }}</span>
{% if cycle.old_index is defined and cycle.old_index is not none %}
<span class="text-gray-400">(was {{ '%04d'|format(cycle.old_index) }})</span>
{% endif %}
</div>
{% endif %}
{% if cycle.downloaded_folder %}
<div class="flex items-center gap-2">
<span class="text-gray-500 dark:text-gray-400">Folder:</span>
<span class="font-mono text-gray-700 dark:text-gray-300">{{ cycle.downloaded_folder }}</span>
{% if cycle.download_success %}
<span class="text-green-600 dark:text-green-400">Downloaded</span>
{% elif cycle.download_attempted %}
<span class="text-red-600 dark:text-red-400">Download failed</span>
{% endif %}
</div>
{% endif %}
{% if cycle.clock_synced %}
<div class="flex items-center gap-2">
<span class="text-gray-500 dark:text-gray-400">Clock synced:</span>
<span class="text-green-600 dark:text-green-400">Yes</span>
</div>
{% endif %}
{% elif item.result.device_response %}
{% set dev = item.result.device_response %}
<div class="flex items-center gap-2">
<span class="text-gray-500 dark:text-gray-400">Device:</span>
<span class="text-gray-700 dark:text-gray-300">{{ dev.message or dev.status }}</span>
</div>
{% endif %}
{% if item.result.session_id %}
<div class="flex items-center gap-2">
<span class="text-gray-500 dark:text-gray-400">Session:</span>
<span class="font-mono text-xs text-gray-600 dark:text-gray-400">{{ item.result.session_id[:8] }}...</span>
</div>
{% endif %}
</div>
{% endif %}
</div>
<!-- Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
{% if item.schedule.execution_status == 'pending' %}
<button onclick="executeSchedule('{{ item.schedule.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
Execute Now
class="p-2 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded-lg transition-colors"
title="Execute Now">
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button>
<button onclick="cancelSchedule('{{ item.schedule.id }}')"
class="px-3 py-1 text-xs bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 rounded-lg hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors">
Cancel
class="p-2 text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title="Cancel">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
{% endif %}
<button onclick="viewScheduleDetails('{{ item.schedule.id }}')"
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Details
</button>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-12">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -141,9 +232,4 @@ function cancelSchedule(scheduleId) {
alert('Error cancelling schedule: ' + error);
});
}
function viewScheduleDetails(scheduleId) {
// TODO: Implement schedule details modal
alert('Schedule details coming soon: ' + scheduleId);
}
</script>

View File

@@ -41,13 +41,13 @@
<div>
<span class="text-xs text-gray-500">Started:</span>
<span class="ml-1">{{ item.session.started_at.strftime('%Y-%m-%d %H:%M') if item.session.started_at else 'N/A' }}</span>
<span class="ml-1">{{ item.session.started_at|local_datetime if item.session.started_at else 'N/A' }}</span>
</div>
{% if item.session.stopped_at %}
<div>
<span class="text-xs text-gray-500">Ended:</span>
<span class="ml-1">{{ item.session.stopped_at.strftime('%Y-%m-%d %H:%M') }}</span>
<span class="ml-1">{{ item.session.stopped_at|local_datetime }}</span>
</div>
{% endif %}

View File

@@ -24,7 +24,7 @@
</svg>
<div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ session.started_at.strftime('%Y-%m-%d %H:%M') if session.started_at else 'Unknown Date' }}
{{ session.started_at|local_datetime if session.started_at else 'Unknown Date' }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
@@ -42,6 +42,24 @@
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ session.status or 'unknown' }}
</span>
<!-- Download All Files in Session -->
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
title="Download all files in this session">
<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-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download All
</button>
<!-- Delete Session -->
<button onclick="event.stopPropagation(); confirmDeleteSession('{{ session.id }}', '{{ files|length }}')"
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center gap-1"
title="Delete session and all files">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Delete
</button>
</div>
</div>
</div>
@@ -72,6 +90,10 @@
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
{% elif file.file_type == 'measurement' %}
<svg class="w-6 h-6 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
{% else %}
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
@@ -95,6 +117,7 @@
<span class="px-1.5 py-0.5 rounded font-medium
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
{% elif file.file_type == 'measurement' %}bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
@@ -102,6 +125,17 @@
{{ file.file_type or 'unknown' }}
</span>
{# Leq vs Lp badge for RND files #}
{% if file.file_path and '_Leq_' in file.file_path %}
<span class="px-1.5 py-0.5 rounded font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
Leq (15-min avg)
</span>
{% elif file.file_path and '_Lp' in file.file_path and file.file_path.endswith('.rnd') %}
<span class="px-1.5 py-0.5 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300">
Lp (instant)
</span>
{% endif %}
<!-- File Size -->
<span class="mx-1"></span>
{% if file.file_size_bytes %}
@@ -121,7 +155,7 @@
<!-- Download Time -->
{% if file.downloaded_at %}
<span class="mx-1"></span>
{{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }}
{{ file.downloaded_at|local_datetime }}
{% endif %}
<!-- Source Info from Metadata -->
@@ -141,9 +175,31 @@
</div>
</div>
<!-- Download Button -->
<!-- Action Buttons -->
{% if exists %}
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
<div class="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-2">
{% if file.file_type == 'measurement' or file.file_path.endswith('.rnd') %}
<a href="/api/projects/{{ project_id }}/files/{{ file.id }}/view-rnd"
onclick="event.stopPropagation();"
class="px-3 py-1 text-xs bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center">
<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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
View
</a>
{% endif %}
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
{% if '_Leq_' in file.file_path %}
<a href="/api/projects/{{ project_id }}/files/{{ file.id }}/generate-report"
onclick="event.stopPropagation();"
class="px-3 py-1 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center"
title="Generate Excel Report">
<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="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Report
</a>
{% endif %}
<button onclick="event.stopPropagation(); downloadFile('{{ file.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -151,6 +207,13 @@
</svg>
Download
</button>
<button onclick="event.stopPropagation(); confirmDeleteFile('{{ file.id }}', '{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}')"
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
title="Delete this file">
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
{% endif %}
</div>
@@ -197,4 +260,56 @@ function toggleSession(sessionId, headerElement) {
function downloadFile(fileId) {
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
}
function downloadSessionFiles(sessionId) {
window.location.href = `/api/projects/{{ project_id }}/sessions/${sessionId}/download-all`;
}
function confirmDeleteFile(fileId, fileName) {
if (confirm(`Are you sure you want to delete "${fileName}"?\n\nThis action cannot be undone.`)) {
deleteFile(fileId);
}
}
async function deleteFile(fileId) {
try {
const response = await fetch(`/api/projects/{{ project_id }}/files/${fileId}`, {
method: 'DELETE'
});
if (response.ok) {
// Reload the files list
window.location.reload();
} else {
const data = await response.json();
alert(`Failed to delete file: ${data.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error deleting file: ${error.message}`);
}
}
function confirmDeleteSession(sessionId, fileCount) {
if (confirm(`Are you sure you want to delete this session and all ${fileCount} file(s)?\n\nThis action cannot be undone.`)) {
deleteSession(sessionId);
}
}
async function deleteSession(sessionId) {
try {
const response = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
method: 'DELETE'
});
if (response.ok) {
// Reload the files list
window.location.reload();
} else {
const data = await response.json();
alert(`Failed to delete session: ${data.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error deleting session: ${error.message}`);
}
}
</script>

View File

@@ -60,7 +60,7 @@
{% if item.assignment.assigned_at %}
<div class="col-span-2">
<span class="text-xs text-gray-500">Assigned:</span>
<span class="ml-1">{{ item.assignment.assigned_at.strftime('%Y-%m-%d %H:%M') }}</span>
<span class="ml-1">{{ item.assignment.assigned_at|local_datetime }}</span>
</div>
{% endif %}
</div>

View File

@@ -337,6 +337,7 @@
</style>
<script>
(function() {
// Update timestamp
const timestampElement = document.getElementById('last-updated');
if (timestampElement) {
@@ -357,20 +358,23 @@
};
return acc;
}, {});
})();
// Sorting state
let currentSort = { column: null, direction: 'asc' };
// Sorting state (needs to persist across swaps)
if (typeof window.currentSort === 'undefined') {
window.currentSort = { column: null, direction: 'asc' };
}
function sortTable(column) {
const tbody = document.getElementById('roster-tbody');
const rows = Array.from(tbody.getElementsByTagName('tr'));
// Determine sort direction
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
if (window.currentSort.column === column) {
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
window.currentSort.column = column;
window.currentSort.direction = 'asc';
}
// Sort rows
@@ -398,8 +402,8 @@
bVal = bVal.toLowerCase();
}
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
@@ -435,10 +439,10 @@
});
// Set current indicator
if (currentSort.column) {
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
if (window.currentSort.column) {
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
if (indicator) {
indicator.className = `sort-indicator ${currentSort.direction}`;
indicator.className = `sort-indicator ${window.currentSort.direction}`;
}
}
}

View File

@@ -51,7 +51,7 @@
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{% if unit.slm_last_check %}
Last check: {{ unit.slm_last_check.strftime('%Y-%m-%d %H:%M') }}
Last check: {{ unit.slm_last_check|local_datetime }}
{% else %}
No recent check-in
{% endif %}

View File

@@ -1307,123 +1307,6 @@ window.addEventListener('beforeunload', function() {
// Timer will resume on next page load if measurement is still active
stopMeasurementTimer();
});
// ========================================
// Settings Modal
// ========================================
async function openSettingsModal(unitId) {
const modal = document.getElementById('settings-modal');
const errorDiv = document.getElementById('settings-error');
const successDiv = document.getElementById('settings-success');
// Clear previous messages
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
// Store unit ID
document.getElementById('settings-unit-id').value = unitId;
// Load current SLMM config
try {
const response = await fetch(`/api/slmm/${unitId}/config`);
if (!response.ok) {
throw new Error('Failed to load configuration');
}
const result = await response.json();
const config = result.data || {};
// Populate form fields
document.getElementById('settings-host').value = config.host || '';
document.getElementById('settings-tcp-port').value = config.tcp_port || 2255;
document.getElementById('settings-ftp-port').value = config.ftp_port || 21;
document.getElementById('settings-ftp-username').value = config.ftp_username || '';
document.getElementById('settings-ftp-password').value = config.ftp_password || '';
document.getElementById('settings-tcp-enabled').checked = config.tcp_enabled !== false;
document.getElementById('settings-ftp-enabled').checked = config.ftp_enabled === true;
document.getElementById('settings-web-enabled').checked = config.web_enabled === true;
modal.classList.remove('hidden');
} catch (error) {
console.error('Failed to load SLMM config:', error);
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
errorDiv.classList.remove('hidden');
modal.classList.remove('hidden');
}
}
function closeSettingsModal() {
document.getElementById('settings-modal').classList.add('hidden');
}
document.getElementById('settings-form').addEventListener('submit', async function(e) {
e.preventDefault();
const unitId = document.getElementById('settings-unit-id').value;
const errorDiv = document.getElementById('settings-error');
const successDiv = document.getElementById('settings-success');
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
// Gather form data
const configData = {
host: document.getElementById('settings-host').value.trim(),
tcp_port: parseInt(document.getElementById('settings-tcp-port').value),
ftp_port: parseInt(document.getElementById('settings-ftp-port').value),
ftp_username: document.getElementById('settings-ftp-username').value.trim() || null,
ftp_password: document.getElementById('settings-ftp-password').value || null,
tcp_enabled: document.getElementById('settings-tcp-enabled').checked,
ftp_enabled: document.getElementById('settings-ftp-enabled').checked,
web_enabled: document.getElementById('settings-web-enabled').checked
};
// Validation
if (!configData.host) {
errorDiv.textContent = 'Host/IP address is required';
errorDiv.classList.remove('hidden');
return;
}
if (configData.tcp_port < 1 || configData.tcp_port > 65535) {
errorDiv.textContent = 'TCP port must be between 1 and 65535';
errorDiv.classList.remove('hidden');
return;
}
if (configData.ftp_port < 1 || configData.ftp_port > 65535) {
errorDiv.textContent = 'FTP port must be between 1 and 65535';
errorDiv.classList.remove('hidden');
return;
}
try {
const response = await fetch(`/api/slmm/${unitId}/config`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(configData)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to update configuration');
}
successDiv.textContent = 'Configuration saved successfully!';
successDiv.classList.remove('hidden');
// Close modal after 1.5 seconds
setTimeout(() => {
closeSettingsModal();
// Optionally reload the page to reflect changes
// window.location.reload();
}, 1500);
} catch (error) {
errorDiv.textContent = error.message;
errorDiv.classList.remove('hidden');
}
});
// ========================================
// FTP Browser Modal
// ========================================
@@ -2201,125 +2084,6 @@ document.getElementById('preview-modal')?.addEventListener('click', function(e)
});
</script>
<!-- Settings Modal -->
<div id="settings-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center overflow-y-auto">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl m-4 my-8">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-xl font-bold text-gray-900 dark:text-white">SLM Configuration</h3>
<button onclick="closeSettingsModal()" 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="settings-form" class="p-6 space-y-6">
<input type="hidden" id="settings-unit-id">
<!-- Network Configuration -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Network Configuration</h4>
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host / IP Address</label>
<input type="text" id="settings-host"
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"
placeholder="e.g., 192.168.1.100" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
<input type="number" id="settings-tcp-port"
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"
placeholder="2255" min="1" max="65535" required>
<p class="text-xs text-gray-500 mt-1">Default: 2255 for NL-43/NL-53</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
<input type="number" id="settings-ftp-port"
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"
placeholder="21" min="1" max="65535" required>
<p class="text-xs text-gray-500 mt-1">Standard FTP port (default: 21)</p>
</div>
</div>
</div>
<!-- FTP Credentials -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">FTP Credentials</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username</label>
<input type="text" id="settings-ftp-username"
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"
placeholder="anonymous">
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Password</label>
<input type="password" id="settings-ftp-password"
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"
placeholder="••••••••">
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
</div>
</div>
</div>
<!-- Protocol Toggles -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Protocol Settings</h4>
<div class="space-y-3">
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
<div>
<span class="font-medium text-gray-900 dark:text-white">TCP Communication</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable TCP control commands</p>
</div>
<input type="checkbox" id="settings-tcp-enabled"
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
</label>
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
<div>
<span class="font-medium text-gray-900 dark:text-white">FTP File Transfer</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable FTP file browsing and downloads</p>
</div>
<input type="checkbox" id="settings-ftp-enabled"
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
</label>
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
<div>
<span class="font-medium text-gray-900 dark:text-white">Web Interface</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable web UI access (future feature)</p>
</div>
<input type="checkbox" id="settings-web-enabled"
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
</label>
</div>
</div>
<div id="settings-error" class="hidden text-sm p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg"></div>
<div id="settings-success" class="hidden text-sm p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-lg"></div>
<div class="flex justify-end gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
<button type="button" onclick="closeSettingsModal()"
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-orange-600 text-white rounded-lg font-medium">
Save Configuration
</button>
</div>
</form>
</div>
</div>
<!-- FTP Browser Modal -->
<div id="ftp-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-4xl max-h-[90vh] overflow-hidden m-4 flex flex-col">
@@ -2407,3 +2171,6 @@ document.getElementById('preview-modal')?.addEventListener('click', function(e)
</div>
</div>
</div>
<!-- Unified SLM Settings Modal -->
{% include 'partials/slm_settings_modal.html' %}

View File

@@ -0,0 +1,534 @@
<!-- Unified SLM Settings Modal - Include this partial where SLM settings are needed -->
<!-- Usage: include 'partials/slm_settings_modal.html' (with Jinja braces) -->
<!-- Then call: openSLMSettingsModal(unitId) from JavaScript -->
<div id="slm-settings-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center overflow-y-auto">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl m-4 my-8">
<!-- Header -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-3">
<svg class="w-8 h-8 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
</svg>
<div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white">SLM Configuration</h3>
<p id="slm-settings-unit-display" class="text-sm text-gray-500 dark:text-gray-400"></p>
</div>
</div>
<button onclick="closeSLMSettingsModal()" 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="slm-settings-form" onsubmit="saveSLMSettings(event)" class="p-6 space-y-6">
<input type="hidden" id="slm-settings-unit-id">
<!-- Network Configuration -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
</svg>
Network Configuration
</h4>
<div class="space-y-4">
<!-- Modem Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connected via Modem</label>
<div class="flex gap-2">
<select id="slm-settings-modem" class="flex-1 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="">Select a modem...</option>
<!-- Modems loaded dynamically -->
</select>
<button type="button" onclick="testModemConnection()" id="slm-settings-test-modem-btn"
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
disabled title="Test modem connectivity">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem this SLM is connected through</p>
</div>
<!-- Port Configuration -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
<input type="number" id="slm-settings-tcp-port"
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"
placeholder="2255" min="1" max="65535" value="2255">
<p class="text-xs text-gray-500 mt-1">Control port (default: 2255)</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
<input type="number" id="slm-settings-ftp-port"
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"
placeholder="21" min="1" max="65535" value="21">
<p class="text-xs text-gray-500 mt-1">File transfer (default: 21)</p>
</div>
</div>
</div>
</div>
<!-- FTP Credentials -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
</svg>
FTP Credentials
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username</label>
<input type="text" id="slm-settings-ftp-username"
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"
placeholder="anonymous">
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Password</label>
<input type="password" id="slm-settings-ftp-password"
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"
placeholder="Leave blank to keep existing">
<p class="text-xs text-gray-500 mt-1">Leave blank to keep existing</p>
</div>
</div>
</div>
<!-- Device Information -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<svg class="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
</svg>
Device Information
</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
<select id="slm-settings-model" 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="">Select model...</option>
<option value="NL-43">NL-43</option>
<option value="NL-53">NL-53</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
<input type="text" id="slm-settings-serial"
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"
placeholder="e.g., SN123456">
</div>
</div>
<div class="grid grid-cols-3 gap-4 mt-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
<select id="slm-settings-freq-weighting" 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="">Select...</option>
<option value="A">A-weighting</option>
<option value="C">C-weighting</option>
<option value="Z">Z-weighting (Linear)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
<select id="slm-settings-time-weighting" 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="">Select...</option>
<option value="Fast">Fast (125ms)</option>
<option value="Slow">Slow (1s)</option>
<option value="Impulse">Impulse</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
<select id="slm-settings-range" 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="">Select...</option>
<option value="30-130">30-130 dB</option>
<option value="40-140">40-140 dB</option>
<option value="50-140">50-140 dB</option>
</select>
</div>
</div>
</div>
<!-- FTP Enable Toggle -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<label class="flex items-center justify-between cursor-pointer">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-orange-500" 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>
<div>
<span class="font-medium text-gray-900 dark:text-white">FTP File Transfer</span>
<p class="text-xs text-gray-500 dark:text-gray-400">Enable FTP for file browsing and downloads</p>
</div>
</div>
<input type="checkbox" id="slm-settings-ftp-enabled"
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
</label>
</div>
<!-- Status Messages -->
<div id="slm-settings-error" class="hidden text-sm p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg"></div>
<div id="slm-settings-success" class="hidden text-sm p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-lg"></div>
<!-- Action Buttons -->
<div class="flex justify-end gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
<button type="button" onclick="closeSLMSettingsModal()"
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="button" onclick="testSLMConnection()"
class="px-6 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg">
Test SLM Connection
</button>
<button type="submit" id="slm-settings-save-btn"
class="px-6 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
Save Configuration
</button>
</div>
</form>
</div>
</div>
<script>
// ========================================
// Unified SLM Settings Modal JavaScript
// ========================================
let slmSettingsModems = []; // Cache modems list
// Open the SLM Settings Modal
async function openSLMSettingsModal(unitId) {
const modal = document.getElementById('slm-settings-modal');
const errorDiv = document.getElementById('slm-settings-error');
const successDiv = document.getElementById('slm-settings-success');
// Clear previous messages
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
// Store unit ID
document.getElementById('slm-settings-unit-id').value = unitId;
document.getElementById('slm-settings-unit-display').textContent = unitId;
// Load modems list if not cached
if (slmSettingsModems.length === 0) {
await loadModemsForSLMSettings();
}
// Load current config from both Terra-View and SLMM
try {
// Fetch Terra-View unit data
const unitResponse = await fetch(`/api/roster/${unitId}`);
const unitData = unitResponse.ok ? await unitResponse.json() : {};
// Fetch SLMM config
const slmmResponse = await fetch(`/api/slmm/${unitId}/config`);
const slmmResult = slmmResponse.ok ? await slmmResponse.json() : {};
const slmmData = slmmResult.data || slmmResult || {};
// Populate form fields
// Modem selection
const modemSelect = document.getElementById('slm-settings-modem');
modemSelect.value = unitData.deployed_with_modem_id || '';
updateTestModemButton();
// Ports
document.getElementById('slm-settings-tcp-port').value = unitData.slm_tcp_port || slmmData.tcp_port || 2255;
document.getElementById('slm-settings-ftp-port').value = unitData.slm_ftp_port || slmmData.ftp_port || 21;
// FTP credentials from SLMM
document.getElementById('slm-settings-ftp-username').value = slmmData.ftp_username || '';
document.getElementById('slm-settings-ftp-password').value = ''; // Don't pre-fill
// Device info from Terra-View
document.getElementById('slm-settings-model').value = unitData.slm_model || '';
document.getElementById('slm-settings-serial').value = unitData.slm_serial_number || '';
document.getElementById('slm-settings-freq-weighting').value = unitData.slm_frequency_weighting || '';
document.getElementById('slm-settings-time-weighting').value = unitData.slm_time_weighting || '';
document.getElementById('slm-settings-range').value = unitData.slm_measurement_range || '';
// FTP enabled from SLMM
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
} catch (error) {
console.error('Failed to load SLM settings:', error);
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
errorDiv.classList.remove('hidden');
}
modal.classList.remove('hidden');
}
// Close the modal
function closeSLMSettingsModal() {
document.getElementById('slm-settings-modal').classList.add('hidden');
}
// Alias for backwards compatibility with existing code
function showFTPSettings(unitId) {
openSLMSettingsModal(unitId);
}
function closeFTPSettings() {
closeSLMSettingsModal();
}
function openSettingsModal(unitId) {
openSLMSettingsModal(unitId);
}
function closeSettingsModal() {
closeSLMSettingsModal();
}
function openConfigModal(unitId) {
openSLMSettingsModal(unitId);
}
function closeConfigModal() {
closeSLMSettingsModal();
}
function openDeviceConfigModal(unitId) {
openSLMSettingsModal(unitId);
}
function closeDeviceConfigModal() {
closeSLMSettingsModal();
}
// Load modems for dropdown
async function loadModemsForSLMSettings() {
try {
const response = await fetch('/api/roster/modems');
slmSettingsModems = await response.json();
const select = document.getElementById('slm-settings-modem');
// Clear existing options except first
select.innerHTML = '<option value="">Select a modem...</option>';
slmSettingsModems.forEach(modem => {
const option = document.createElement('option');
option.value = modem.id;
const ipText = modem.ip_address ? ` (${modem.ip_address})` : '';
const deployedText = modem.deployed ? '' : ' [Benched]';
option.textContent = modem.id + ipText + deployedText;
select.appendChild(option);
});
} catch (error) {
console.error('Failed to load modems:', error);
}
}
// Update test modem button state based on selection
function updateTestModemButton() {
const modemSelect = document.getElementById('slm-settings-modem');
const testBtn = document.getElementById('slm-settings-test-modem-btn');
testBtn.disabled = !modemSelect.value;
}
// Listen for modem selection changes
document.getElementById('slm-settings-modem')?.addEventListener('change', updateTestModemButton);
// Test modem connection
async function testModemConnection() {
const modemId = document.getElementById('slm-settings-modem').value;
if (!modemId) return;
const errorDiv = document.getElementById('slm-settings-error');
const successDiv = document.getElementById('slm-settings-success');
errorDiv.classList.add('hidden');
successDiv.textContent = 'Pinging modem...';
successDiv.classList.remove('hidden');
try {
const response = await fetch(`/api/slm-dashboard/test-modem/${modemId}`);
const data = await response.json();
if (response.ok && data.status === 'success') {
const ipAddr = data.ip_address || modemId;
const respTime = data.response_time || 'N/A';
successDiv.textContent = `✓ Modem responding! ${ipAddr} - ${respTime}ms`;
} else {
successDiv.classList.add('hidden');
errorDiv.textContent = '⚠ Modem not responding: ' + (data.detail || 'Unknown error');
errorDiv.classList.remove('hidden');
}
} catch (error) {
successDiv.classList.add('hidden');
errorDiv.textContent = 'Failed to ping modem: ' + error.message;
errorDiv.classList.remove('hidden');
}
}
// Test SLM connection
async function testSLMConnection() {
const unitId = document.getElementById('slm-settings-unit-id').value;
const errorDiv = document.getElementById('slm-settings-error');
const successDiv = document.getElementById('slm-settings-success');
errorDiv.classList.add('hidden');
successDiv.textContent = 'Testing SLM connection...';
successDiv.classList.remove('hidden');
try {
const response = await fetch(`/api/slmm/${unitId}/status`);
const data = await response.json();
if (response.ok && data.status === 'online') {
successDiv.textContent = '✓ SLM connection successful! Device is responding.';
} else {
successDiv.classList.add('hidden');
errorDiv.textContent = '⚠ SLM not responding or offline. Check network settings.';
errorDiv.classList.remove('hidden');
}
} catch (error) {
successDiv.classList.add('hidden');
errorDiv.textContent = 'Connection test failed: ' + error.message;
errorDiv.classList.remove('hidden');
}
}
// Save SLM settings
async function saveSLMSettings(event) {
event.preventDefault();
const unitId = document.getElementById('slm-settings-unit-id').value;
const saveBtn = document.getElementById('slm-settings-save-btn');
const errorDiv = document.getElementById('slm-settings-error');
const successDiv = document.getElementById('slm-settings-success');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
// Get selected modem and resolve its IP
const modemId = document.getElementById('slm-settings-modem').value;
let modemIp = '';
if (modemId) {
const modem = slmSettingsModems.find(m => m.id === modemId);
modemIp = modem?.ip_address || '';
}
// Validation
if (!modemId) {
errorDiv.textContent = 'Please select a modem';
errorDiv.classList.remove('hidden');
saveBtn.disabled = false;
saveBtn.textContent = 'Save Configuration';
return;
}
if (!modemIp) {
errorDiv.textContent = 'Selected modem has no IP address configured';
errorDiv.classList.remove('hidden');
saveBtn.disabled = false;
saveBtn.textContent = 'Save Configuration';
return;
}
const tcpPort = parseInt(document.getElementById('slm-settings-tcp-port').value) || 2255;
const ftpPort = parseInt(document.getElementById('slm-settings-ftp-port').value) || 21;
if (tcpPort < 1 || tcpPort > 65535 || ftpPort < 1 || ftpPort > 65535) {
errorDiv.textContent = 'Port values must be between 1 and 65535';
errorDiv.classList.remove('hidden');
saveBtn.disabled = false;
saveBtn.textContent = 'Save Configuration';
return;
}
try {
// 1. Update Terra-View database (device info + modem assignment)
const terraViewData = {
deployed_with_modem_id: modemId,
slm_model: document.getElementById('slm-settings-model').value || null,
slm_serial_number: document.getElementById('slm-settings-serial').value || null,
slm_frequency_weighting: document.getElementById('slm-settings-freq-weighting').value || null,
slm_time_weighting: document.getElementById('slm-settings-time-weighting').value || null,
slm_measurement_range: document.getElementById('slm-settings-range').value || null,
slm_tcp_port: tcpPort,
slm_ftp_port: ftpPort
};
const terraResponse = await fetch(`/api/slm-dashboard/config/${unitId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(terraViewData)
});
if (!terraResponse.ok) {
throw new Error('Failed to save Terra-View configuration');
}
// 2. Update SLMM config (network + FTP credentials)
const slmmData = {
host: modemIp,
tcp_port: tcpPort,
ftp_port: ftpPort,
ftp_username: document.getElementById('slm-settings-ftp-username').value.trim() || null,
ftp_enabled: document.getElementById('slm-settings-ftp-enabled').checked
};
// Only include password if entered
const password = document.getElementById('slm-settings-ftp-password').value;
if (password) {
slmmData.ftp_password = password;
}
const slmmResponse = await fetch(`/api/slmm/${unitId}/config`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(slmmData)
});
if (!slmmResponse.ok) {
const errData = await slmmResponse.json().catch(() => ({}));
throw new Error(errData.detail || 'Failed to save SLMM configuration');
}
successDiv.textContent = 'Configuration saved successfully!';
successDiv.classList.remove('hidden');
// Close modal after delay and refresh if needed
setTimeout(() => {
closeSLMSettingsModal();
// Try to refresh any FTP status or unit lists on the page
if (typeof checkFTPStatus === 'function') {
checkFTPStatus(unitId);
}
if (typeof htmx !== 'undefined') {
htmx.trigger('#slm-list', 'load');
}
}, 1500);
} catch (error) {
errorDiv.textContent = 'Error: ' + error.message;
errorDiv.classList.remove('hidden');
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save Configuration';
}
}
// Alias for backwards compatibility
async function saveFTPSettings(event) {
return saveSLMSettings(event);
}
// Close modal on background click
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeSLMSettingsModal();
}
});
</script>

View File

@@ -0,0 +1,132 @@
{#
Unit Picker Component
A reusable HTMX-based autocomplete for selecting seismographs/SLMs.
Usage: include "partials/unit_picker.html" with context
Variables available in context:
- selected_unit_id: Pre-selected unit ID (optional)
- selected_unit_display: Display text for pre-selected unit (optional)
- input_name: Name attribute for the hidden input (default: "deployed_with_unit_id")
- picker_id: Unique ID suffix for multiple pickers on same page (default: "")
- device_type_filter: Filter by device type: "seismograph", "slm", or empty for all (default: "")
- deployed_only: Only show deployed units (default: false)
#}
{% set picker_id = picker_id|default("") %}
{% set input_name = input_name|default("deployed_with_unit_id") %}
{% set selected_unit_id = selected_unit_id|default("") %}
{% set selected_unit_display = selected_unit_display|default("") %}
{% set device_type_filter = device_type_filter|default("") %}
{% set deployed_only = deployed_only|default(false) %}
<div class="unit-picker relative" id="unit-picker-container{{ picker_id }}">
<!-- Hidden input for form submission (stores unit ID) -->
<input type="hidden"
name="{{ input_name }}"
id="unit-picker-value{{ picker_id }}"
value="{{ selected_unit_id }}">
<!-- Search Input -->
<div class="relative">
<input type="text"
id="unit-picker-search{{ picker_id }}"
placeholder="Search by unit ID or note..."
class="w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
autocomplete="off"
value="{{ selected_unit_display }}"
hx-get="/api/roster/search/units"
hx-trigger="keyup changed delay:300ms, focus"
hx-target="#unit-picker-dropdown{{ picker_id }}"
hx-vals='{"picker_id": "{{ picker_id }}", "device_type": "{{ device_type_filter }}", "deployed_only": "{{ deployed_only|lower }}"}'
name="q"
onfocus="showUnitDropdown('{{ picker_id }}')"
oninput="handleUnitSearchInput('{{ picker_id }}', this.value)">
<!-- Search icon -->
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<!-- Clear button (shown when unit is selected) -->
<button type="button"
id="unit-picker-clear{{ picker_id }}"
class="absolute inset-y-0 right-8 flex items-center pr-1 {{ 'hidden' if not selected_unit_id else '' }}"
onclick="clearUnitSelection('{{ picker_id }}')"
title="Clear selection">
<svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" 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>
<!-- Dropdown Results Container -->
<div id="unit-picker-dropdown{{ picker_id }}"
class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
<!-- Results loaded via HTMX -->
</div>
</div>
<script>
{# Unit picker functions - defined once, work for any picker_id #}
if (typeof selectUnit === 'undefined') {
function selectUnit(unitId, displayText, pickerId = '') {
const valueInput = document.getElementById('unit-picker-value' + pickerId);
const searchInput = document.getElementById('unit-picker-search' + pickerId);
const dropdown = document.getElementById('unit-picker-dropdown' + pickerId);
const clearBtn = document.getElementById('unit-picker-clear' + pickerId);
if (valueInput) valueInput.value = unitId;
if (searchInput) searchInput.value = displayText;
if (dropdown) dropdown.classList.add('hidden');
if (clearBtn) clearBtn.classList.remove('hidden');
}
function clearUnitSelection(pickerId = '') {
const valueInput = document.getElementById('unit-picker-value' + pickerId);
const searchInput = document.getElementById('unit-picker-search' + pickerId);
const clearBtn = document.getElementById('unit-picker-clear' + pickerId);
if (valueInput) valueInput.value = '';
if (searchInput) {
searchInput.value = '';
searchInput.focus();
}
if (clearBtn) clearBtn.classList.add('hidden');
}
function showUnitDropdown(pickerId = '') {
const dropdown = document.getElementById('unit-picker-dropdown' + pickerId);
if (dropdown) dropdown.classList.remove('hidden');
}
function hideUnitDropdown(pickerId = '') {
const dropdown = document.getElementById('unit-picker-dropdown' + pickerId);
if (dropdown) dropdown.classList.add('hidden');
}
function handleUnitSearchInput(pickerId, value) {
const valueInput = document.getElementById('unit-picker-value' + pickerId);
const clearBtn = document.getElementById('unit-picker-clear' + pickerId);
// If user clears the search box, also clear the hidden value
if (!value.trim()) {
if (valueInput) valueInput.value = '';
if (clearBtn) clearBtn.classList.add('hidden');
}
}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
const pickers = document.querySelectorAll('.unit-picker');
pickers.forEach(picker => {
if (!picker.contains(event.target)) {
const dropdown = picker.querySelector('[id^="unit-picker-dropdown"]');
if (dropdown) dropdown.classList.add('hidden');
}
});
});
}
</script>

View File

@@ -0,0 +1,66 @@
{#
Unit Search Results Partial
Rendered by /api/roster/search/units endpoint for HTMX dropdown.
Variables:
- units: List of unit dicts with id, device_type, note, deployed, display
- query: The search query string
- show_empty: Boolean - show "no results" message
#}
{% set picker_id = request.query_params.get('picker_id', '') %}
{% if units %}
{% for unit in units %}
<div class="px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0 transition-colors"
onclick="selectUnit('{{ unit.id }}', '{{ unit.display|e }}', '{{ picker_id }}')">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="font-medium text-gray-900 dark:text-white truncate">
<span class="text-seismo-orange font-semibold">{{ unit.id }}</span>
</div>
{% if unit.note %}
<div class="text-sm text-gray-500 dark:text-gray-400 truncate">
{{ unit.note }}
</div>
{% endif %}
</div>
<div class="flex items-center gap-2">
{% if unit.device_type == 'seismograph' %}
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-300 rounded">
Seismo
</span>
{% elif unit.device_type == 'slm' %}
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-300 rounded">
SLM
</span>
{% endif %}
{% if not unit.deployed %}
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
Benched
</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
{% endif %}
{% if show_empty %}
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<p class="text-sm">No units found matching "{{ query }}"</p>
</div>
{% endif %}
{% if not units and not show_empty %}
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
<svg class="w-8 h-8 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<p class="text-sm">Start typing to search units...</p>
<p class="text-xs mt-1">Search by unit ID or note</p>
</div>
{% endif %}

View File

@@ -132,23 +132,55 @@
<!-- Schedules Tab -->
<div id="schedules-tab" class="tab-panel hidden">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<!-- Recurring Schedules Section -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Scheduled Actions</h2>
<div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recurring Schedules</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Automated patterns that generate scheduled actions
</p>
</div>
<button onclick="openScheduleModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Schedule Action
Create Schedule
</button>
</div>
<div id="project-schedules"
hx-get="/api/projects/{{ project_id }}/schedules"
hx-trigger="load, every 30s"
<div id="recurring-schedule-list"
hx-get="/api/projects/{{ project_id }}/recurring-schedules/partials/list"
hx-trigger="load, refresh from:#recurring-schedule-list"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading schedules...</div>
<div class="text-center py-8 text-gray-500">Loading recurring schedules...</div>
</div>
</div>
<!-- Scheduled Actions Section -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h2 id="schedules-title" class="text-xl font-semibold text-gray-900 dark:text-white">Upcoming Actions</h2>
<p id="schedules-subtitle" class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Scheduled start/stop/download actions
</p>
</div>
<select id="schedules-filter" onchange="filterScheduledActions()"
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">
<option value="pending">Pending</option>
<option value="all">All</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
</select>
</div>
<div id="project-schedules"
hx-get="/api/projects/{{ project_id }}/schedules?status=pending"
hx-trigger="load, every 30s, refresh from:#project-schedules"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading scheduled actions...</div>
</div>
</div>
</div>
@@ -378,6 +410,122 @@
</div>
</div>
<!-- Schedule Modal -->
<div id="schedule-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-3xl 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">Create Recurring Schedule</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Set up automated monitoring schedules</p>
</div>
<button onclick="closeScheduleModal()" 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="schedule-form" class="p-6 space-y-6">
<!-- Schedule Name -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Schedule Name</label>
<input type="text" name="schedule_name" id="schedule-name"
placeholder="e.g., Weeknight Monitoring"
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>
<!-- Location Selection (Multiple) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Locations
<span class="text-xs font-normal text-gray-500 ml-2">(select one or more)</span>
</label>
<div id="schedule-locations-container"
class="max-h-48 overflow-y-auto border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 p-2 space-y-1">
<div class="text-gray-500 text-sm py-2 text-center">Loading locations...</div>
</div>
<p id="schedule-location-empty" class="hidden text-xs text-gray-500 mt-2">
No locations available. Create a location first.
</p>
<p id="schedule-location-error" class="hidden text-xs text-red-500 mt-2">
Please select at least one location.
</p>
</div>
<!-- Schedule Type Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Schedule Type</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label class="relative cursor-pointer">
<input type="radio" name="schedule_type" value="weekly_calendar" class="peer sr-only" checked onchange="toggleScheduleType('weekly_calendar')">
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
<div class="flex items-center gap-3 mb-2">
<svg class="w-6 h-6 text-gray-500 peer-checked:text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">Weekly Calendar</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Select specific days with start/end times. Ideal for weeknight monitoring (Mon-Fri 7pm-7am).
</p>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" name="schedule_type" value="simple_interval" class="peer sr-only" onchange="toggleScheduleType('simple_interval')">
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
<div class="flex items-center gap-3 mb-2">
<svg class="w-6 h-6 text-gray-500 peer-checked:text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">24/7 Continuous</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Continuous monitoring with daily stop/download/restart cycle at a set time.
</p>
</div>
</label>
</div>
</div>
<!-- Weekly Calendar Editor -->
<div id="schedule-weekly-wrapper">
{% include "partials/projects/schedule_calendar.html" %}
</div>
<!-- Simple Interval Editor -->
<div id="schedule-interval-wrapper" class="hidden">
{% include "partials/projects/schedule_interval.html" %}
</div>
<!-- Timezone -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Timezone</label>
<select name="timezone" id="schedule-timezone"
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="America/New_York">Eastern (America/New_York)</option>
<option value="America/Chicago">Central (America/Chicago)</option>
<option value="America/Denver">Mountain (America/Denver)</option>
<option value="America/Los_Angeles">Pacific (America/Los_Angeles)</option>
<option value="UTC">UTC</option>
</select>
</div>
<div id="schedule-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeScheduleModal()"
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">
Create Schedule
</button>
</div>
</form>
</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">
@@ -809,11 +957,34 @@ function filterFiles() {
});
}
// Utility functions
function openScheduleModal() {
alert('Schedule modal coming soon');
function filterScheduledActions() {
const filter = document.getElementById('schedules-filter').value;
const url = filter === 'all'
? `/api/projects/${projectId}/schedules`
: `/api/projects/${projectId}/schedules?status=${filter}`;
// Update section title based on filter
const titleEl = document.getElementById('schedules-title');
const subtitleEl = document.getElementById('schedules-subtitle');
const titles = {
'pending': { title: 'Upcoming Actions', subtitle: 'Scheduled start/stop/download actions' },
'completed': { title: 'Completed Actions', subtitle: 'Successfully executed actions' },
'failed': { title: 'Failed Actions', subtitle: 'Actions that encountered errors' },
'all': { title: 'All Actions', subtitle: 'Complete action history' }
};
const config = titles[filter] || titles['all'];
titleEl.textContent = config.title;
subtitleEl.textContent = config.subtitle;
htmx.ajax('GET', url, {
target: '#project-schedules',
swap: 'innerHTML'
});
}
// Utility functions
function exportProjectData() {
window.location.href = `/api/projects/${projectId}/export`;
}
@@ -825,11 +996,239 @@ function archiveProject() {
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
}
// ============================================================================
// Schedule Modal Functions
// ============================================================================
async function openScheduleModal() {
// Reset form
document.getElementById('schedule-name').value = '';
document.getElementById('schedule-locations-container').innerHTML = '<div class="text-gray-500 text-sm py-2 text-center">Loading locations...</div>';
document.getElementById('schedule-location-empty').classList.add('hidden');
document.getElementById('schedule-location-error').classList.add('hidden');
document.getElementById('schedule-error').classList.add('hidden');
// Reset to weekly calendar type
document.querySelector('input[name="schedule_type"][value="weekly_calendar"]').checked = true;
toggleScheduleType('weekly_calendar');
// Reset calendar checkboxes
if (typeof clearAllDays === 'function') {
clearAllDays();
}
// Show modal
document.getElementById('schedule-modal').classList.remove('hidden');
// Load locations
await loadScheduleLocations();
}
function closeScheduleModal() {
document.getElementById('schedule-modal').classList.add('hidden');
}
async function loadScheduleLocations() {
const container = document.getElementById('schedule-locations-container');
const emptyMsg = document.getElementById('schedule-location-empty');
const errorMsg = document.getElementById('schedule-location-error');
// Reset state
emptyMsg.classList.add('hidden');
errorMsg.classList.add('hidden');
try {
const response = await fetch(`/api/projects/${projectId}/locations-json`);
if (!response.ok) {
throw new Error('Failed to load locations');
}
const locations = await response.json();
if (!locations.length) {
container.innerHTML = '<div class="text-gray-500 text-sm py-2 text-center">No locations available</div>';
emptyMsg.classList.remove('hidden');
return;
}
// Build checkboxes for each location
container.innerHTML = locations.map(loc => `
<label class="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer">
<input type="checkbox"
name="schedule_locations"
value="${loc.id}"
data-name="${loc.name}"
data-type="${loc.location_type}"
class="rounded text-seismo-orange focus:ring-seismo-orange">
<span class="text-sm text-gray-900 dark:text-white">${loc.name}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">(${loc.location_type})</span>
</label>
`).join('');
// Add select all / clear all buttons if more than one location
if (locations.length > 1) {
container.insertAdjacentHTML('afterbegin', `
<div class="flex gap-2 pb-2 mb-2 border-b border-gray-200 dark:border-gray-600">
<button type="button" onclick="selectAllLocations()" class="text-xs text-seismo-orange hover:underline">Select All</button>
<span class="text-gray-400">|</span>
<button type="button" onclick="clearAllLocations()" class="text-xs text-gray-500 hover:underline">Clear All</button>
</div>
`);
}
} catch (err) {
console.error('Failed to load locations:', err);
container.innerHTML = '<div class="text-red-500 text-sm py-2 text-center">Error loading locations</div>';
}
}
function selectAllLocations() {
document.querySelectorAll('input[name="schedule_locations"]').forEach(cb => cb.checked = true);
}
function clearAllLocations() {
document.querySelectorAll('input[name="schedule_locations"]').forEach(cb => cb.checked = false);
}
function getSelectedLocationIds() {
const checkboxes = document.querySelectorAll('input[name="schedule_locations"]:checked');
return Array.from(checkboxes).map(cb => cb.value);
}
function toggleScheduleType(type) {
const weeklyEditor = document.getElementById('schedule-weekly-wrapper');
const intervalEditor = document.getElementById('schedule-interval-wrapper');
if (type === 'weekly_calendar') {
weeklyEditor.classList.remove('hidden');
intervalEditor.classList.add('hidden');
} else {
weeklyEditor.classList.add('hidden');
intervalEditor.classList.remove('hidden');
}
}
// Schedule form submission
document.getElementById('schedule-form').addEventListener('submit', async function(e) {
e.preventDefault();
const name = document.getElementById('schedule-name').value.trim();
const locationIds = getSelectedLocationIds();
const scheduleType = document.querySelector('input[name="schedule_type"]:checked').value;
const timezone = document.getElementById('schedule-timezone').value;
// Hide previous errors
document.getElementById('schedule-location-error').classList.add('hidden');
if (!name) {
showScheduleError('Please enter a schedule name.');
return;
}
if (!locationIds.length) {
document.getElementById('schedule-location-error').classList.remove('hidden');
showScheduleError('Please select at least one location.');
return;
}
// Build payload based on schedule type
const payload = {
name: name,
location_ids: locationIds, // Array of location IDs
schedule_type: scheduleType,
timezone: timezone,
};
if (scheduleType === 'weekly_calendar') {
// Get weekly pattern from the calendar editor
if (typeof getWeeklyPatternData === 'function') {
payload.weekly_pattern = getWeeklyPatternData();
} else {
showScheduleError('Calendar editor not loaded properly.');
return;
}
// Validate at least one day is selected
const hasEnabledDay = Object.values(payload.weekly_pattern).some(day => day.enabled);
if (!hasEnabledDay) {
showScheduleError('Please select at least one day for monitoring.');
return;
}
// Get auto-increment setting for calendar mode
if (typeof getCalendarAutoIncrement === 'function') {
payload.auto_increment_index = getCalendarAutoIncrement();
} else {
payload.auto_increment_index = true;
}
// Get include_download setting for calendar mode
if (typeof getCalendarIncludeDownload === 'function') {
payload.include_download = getCalendarIncludeDownload();
} else {
payload.include_download = true;
}
} else {
// Get interval data
if (typeof getIntervalData === 'function') {
const intervalData = getIntervalData();
payload.interval_type = intervalData.interval_type;
payload.cycle_time = intervalData.cycle_time;
payload.include_download = intervalData.include_download;
payload.auto_increment_index = intervalData.auto_increment_index;
} else {
showScheduleError('Interval editor not loaded properly.');
return;
}
}
try {
const response = await fetch(`/api/projects/${projectId}/recurring-schedules/`, {
method: 'POST',
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 create schedule');
}
const result = await response.json();
// Close modal and refresh schedules list
closeScheduleModal();
// Refresh both the recurring schedules list and scheduled actions
htmx.ajax('GET', `/api/projects/${projectId}/recurring-schedules/partials/list`, {
target: '#recurring-schedule-list',
swap: 'innerHTML'
});
htmx.ajax('GET', `/api/projects/${projectId}/schedules?status=pending`, {
target: '#project-schedules',
swap: 'innerHTML'
});
// Show success message
console.log('Schedule(s) created:', result.message);
} catch (err) {
showScheduleError(err.message || 'Failed to create schedule.');
}
});
function showScheduleError(message) {
const errorEl = document.getElementById('schedule-error');
errorEl.textContent = message;
errorEl.classList.remove('hidden');
}
// ============================================================================
// Keyboard shortcuts
// ============================================================================
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeLocationModal();
closeAssignModal();
closeScheduleModal();
}
});
@@ -846,6 +1245,12 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
}
});
document.getElementById('schedule-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeScheduleModal();
}
});
// Load project details on page load
document.addEventListener('DOMContentLoaded', function() {
loadProjectDetails();

View File

@@ -0,0 +1,309 @@
{% extends "base.html" %}
{% block title %}Report Preview - {{ project.name if project else 'Sound Level Data' }}{% endblock %}
{% block content %}
<!-- jspreadsheet CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/jspreadsheet.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.css" />
<div class="min-h-screen bg-gray-100 dark:bg-slate-900">
<!-- Header -->
<div class="bg-white dark:bg-slate-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
Report Preview & Editor
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{% if file %}{{ file.file_path.split('/')[-1] }}{% endif %}
{% if location %} @ {{ location.name }}{% endif %}
{% if start_time and end_time %} | Time: {{ start_time }} - {{ end_time }}{% endif %}
| {{ filtered_count }} of {{ original_count }} rows
</p>
</div>
<div class="flex items-center gap-3">
<button onclick="downloadReport()"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">
<svg class="w-5 h-5" 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-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download Excel
</button>
<a href="/api/projects/{{ project_id }}/files/{{ file_id }}/view-rnd"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
Back to Viewer
</a>
</div>
</div>
</div>
</div>
<!-- Report Info Section -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Report Title</label>
<input type="text" id="edit-report-title" value="{{ report_title }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label>
<input type="text" id="edit-project-name" value="{{ project_name }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Client Name</label>
<input type="text" id="edit-client-name" value="{{ client_name }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Location</label>
<input type="text" id="edit-location-name" value="{{ location_name }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
</div>
</div>
<!-- Spreadsheet Editor -->
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Data Table</h2>
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span>Right-click for options</span>
<span class="text-gray-300 dark:text-gray-600">|</span>
<span>Double-click to edit</span>
</div>
</div>
<div id="spreadsheet" class="overflow-x-auto"></div>
</div>
<!-- Help Text -->
<div class="mt-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">Editing Tips</h3>
<ul class="text-sm text-blue-700 dark:text-blue-400 list-disc list-inside space-y-1">
<li>Double-click any cell to edit its value</li>
<li>Use the Comments column to add notes about specific measurements</li>
<li>Right-click a row to delete it from the report</li>
<li>Right-click to add new rows if needed</li>
<li>Press Enter to confirm edits, Escape to cancel</li>
</ul>
</div>
</div>
</div>
<!-- jspreadsheet JS -->
<script src="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/index.min.js"></script>
<script>
// Initialize spreadsheet data from server
const initialData = {{ spreadsheet_data | tojson }};
// Create jspreadsheet instance
let spreadsheet = null;
document.addEventListener('DOMContentLoaded', function() {
spreadsheet = jspreadsheet(document.getElementById('spreadsheet'), {
data: initialData,
columns: [
{ title: 'Test #', width: 80, type: 'numeric' },
{ title: 'Date', width: 110, type: 'text' },
{ title: 'Time', width: 90, type: 'text' },
{ title: 'LAmax (dBA)', width: 100, type: 'numeric' },
{ title: 'LA01 (dBA)', width: 100, type: 'numeric' },
{ title: 'LA10 (dBA)', width: 100, type: 'numeric' },
{ title: 'Comments', width: 250, type: 'text' }
],
allowInsertRow: true,
allowDeleteRow: true,
allowInsertColumn: false,
allowDeleteColumn: false,
rowDrag: true,
columnSorting: true,
search: true,
pagination: 50,
paginationOptions: [25, 50, 100, 200],
defaultColWidth: 100,
minDimensions: [7, 1],
tableOverflow: true,
tableWidth: '100%',
contextMenu: function(instance, col, row, e) {
const items = [];
if (row !== null) {
items.push({
title: 'Insert row above',
onclick: function() {
instance.insertRow(1, row, true);
}
});
items.push({
title: 'Insert row below',
onclick: function() {
instance.insertRow(1, row + 1, false);
}
});
items.push({
title: 'Delete this row',
onclick: function() {
instance.deleteRow(row);
}
});
}
return items;
},
style: {
A: 'text-align: center;',
B: 'text-align: center;',
C: 'text-align: center;',
D: 'text-align: right;',
E: 'text-align: right;',
F: 'text-align: right;',
}
});
});
async function downloadReport() {
// Get current data from spreadsheet
const data = spreadsheet.getData();
// Get report settings
const reportTitle = document.getElementById('edit-report-title').value;
const projectName = document.getElementById('edit-project-name').value;
const clientName = document.getElementById('edit-client-name').value;
const locationName = document.getElementById('edit-location-name').value;
// Build time filter info
let timeFilter = '';
{% if start_time and end_time %}
timeFilter = 'Time Filter: {{ start_time }} - {{ end_time }}';
{% if start_date or end_date %}
timeFilter += ' | Date Range: {{ start_date or "start" }} to {{ end_date or "end" }}';
{% endif %}
timeFilter += ' | {{ filtered_count }} of {{ original_count }} rows';
{% endif %}
// Send to server to generate Excel
try {
const response = await fetch('/api/projects/{{ project_id }}/files/{{ file_id }}/generate-from-preview', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
data: data,
report_title: reportTitle,
project_name: projectName,
client_name: clientName,
location_name: locationName,
time_filter: timeFilter
})
});
if (response.ok) {
// Download the file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
// Get filename from Content-Disposition header if available
const contentDisposition = response.headers.get('Content-Disposition');
let filename = 'report.xlsx';
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match) filename = match[1];
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} else {
const error = await response.json();
alert('Error generating report: ' + (error.detail || 'Unknown error'));
}
} catch (error) {
alert('Error generating report: ' + error.message);
}
}
</script>
<style>
/* Custom styles for jspreadsheet to match dark mode */
.dark .jexcel {
background-color: #1e293b;
color: #e2e8f0;
}
.dark .jexcel thead td {
background-color: #334155 !important;
color: #e2e8f0 !important;
border-color: #475569 !important;
}
.dark .jexcel tbody td {
background-color: #1e293b;
color: #e2e8f0;
border-color: #475569;
}
.dark .jexcel tbody td:hover {
background-color: #334155;
}
.dark .jexcel tbody tr:nth-child(even) td {
background-color: #0f172a;
}
.dark .jexcel_pagination {
background-color: #1e293b;
color: #e2e8f0;
border-color: #475569;
}
.dark .jexcel_pagination a {
color: #e2e8f0;
}
.dark .jexcel_search {
background-color: #1e293b;
color: #e2e8f0;
border-color: #475569;
}
.dark .jexcel_search input {
background-color: #334155;
color: #e2e8f0;
border-color: #475569;
}
.dark .jexcel_content {
background-color: #1e293b;
}
.dark .jexcel_contextmenu {
background-color: #1e293b;
border-color: #475569;
}
.dark .jexcel_contextmenu a {
color: #e2e8f0;
}
.dark .jexcel_contextmenu a:hover {
background-color: #334155;
}
/* Ensure proper sizing */
.jexcel_content {
max-height: 600px;
overflow: auto;
}
</style>
{% endblock %}

856
templates/rnd_viewer.html Normal file
View File

@@ -0,0 +1,856 @@
{% extends "base.html" %}
{% block title %}{{ filename }} - Sound Level Data Viewer{% endblock %}
{% block extra_head %}
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
<style>
.data-table {
max-height: 500px;
overflow-y: auto;
}
.data-table table {
font-size: 0.75rem;
}
.data-table th {
position: sticky;
top: 0;
z-index: 10;
}
.metric-card {
transition: transform 0.2s;
}
.metric-card:hover {
transform: translateY(-2px);
}
</style>
{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto">
<!-- Header with breadcrumb -->
<div class="mb-6">
<nav class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-2">
<a href="/projects" class="hover:text-seismo-orange">Projects</a>
<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="M9 5l7 7-7 7"></path>
</svg>
<a href="/projects/{{ project_id }}" class="hover:text-seismo-orange">{{ project.name if project else 'Project' }}</a>
<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="M9 5l7 7-7 7"></path>
</svg>
<span class="text-gray-900 dark:text-white">{{ filename }}</span>
</nav>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
<svg class="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
{{ filename }}
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Sound Level Meter Measurement Data
{% if unit %} - {{ unit.id }}{% endif %}
{% if location %} @ {{ location.name }}{% endif %}
</p>
</div>
<div class="flex items-center gap-3">
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
{% if file and '_Leq_' in file.file_path %}
<!-- Generate Excel Report Button -->
<button onclick="openReportModal()"
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Generate Excel Report
</button>
{% endif %}
<a href="/api/projects/{{ project_id }}/files/{{ file_id }}/download"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-2">
<svg class="w-5 h-5" 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-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download RND
</a>
<a href="/projects/{{ project_id }}"
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
Back to Project
</a>
</div>
</div>
</div>
<!-- Loading State -->
<div id="loading-state" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-12 text-center">
<svg class="w-12 h-12 mx-auto mb-4 text-seismo-orange animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400">Loading measurement data...</p>
</div>
<!-- Error State -->
<div id="error-state" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-12 text-center">
<svg class="w-12 h-12 mx-auto mb-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<p class="text-red-500 font-semibold mb-2">Error Loading Data</p>
<p id="error-message" class="text-gray-500 dark:text-gray-400"></p>
</div>
<!-- Data Content (hidden until loaded) -->
<div id="data-content" class="hidden space-y-6">
<!-- Summary Cards -->
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">File Type</div>
<div id="summary-file-type" class="text-lg font-bold text-gray-900 dark:text-white mt-1">-</div>
</div>
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total Rows</div>
<div id="summary-rows" class="text-lg font-bold text-gray-900 dark:text-white mt-1">-</div>
</div>
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Start Time</div>
<div id="summary-start" class="text-sm font-bold text-gray-900 dark:text-white mt-1">-</div>
</div>
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">End Time</div>
<div id="summary-end" class="text-sm font-bold text-gray-900 dark:text-white mt-1">-</div>
</div>
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4 bg-blue-50 dark:bg-blue-900/20">
<div class="text-xs text-blue-600 dark:text-blue-400 uppercase tracking-wide">Avg Level</div>
<div id="summary-avg" class="text-lg font-bold text-blue-700 dark:text-blue-300 mt-1">- dB</div>
</div>
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4 bg-red-50 dark:bg-red-900/20">
<div class="text-xs text-red-600 dark:text-red-400 uppercase tracking-wide">Max Level</div>
<div id="summary-max" class="text-lg font-bold text-red-700 dark:text-red-300 mt-1">- dB</div>
</div>
</div>
<!-- Chart -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Sound Level Over Time</h2>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-500 dark:text-gray-400">Show:</label>
<select id="chart-metric-select" class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="all">All Metrics</option>
<option value="leq">Leq Only</option>
<option value="lmax">Lmax Only</option>
<option value="lmin">Lmin Only</option>
<option value="lpeak">Lpeak Only</option>
</select>
</div>
</div>
<div class="relative" style="height: 400px;">
<canvas id="soundLevelChart"></canvas>
</div>
</div>
<!-- Data Table -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Measurement Data</h2>
<div class="flex items-center gap-4">
<input type="text" id="table-search" placeholder="Search..."
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-white w-48">
<span id="row-count" class="text-sm text-gray-500 dark:text-gray-400"></span>
</div>
</div>
<div class="data-table">
<table class="w-full">
<thead id="table-header" class="bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-400 text-xs uppercase">
<!-- Headers will be inserted here -->
</thead>
<tbody id="table-body" class="divide-y divide-gray-100 dark:divide-gray-700">
<!-- Data rows will be inserted here -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let chartInstance = null;
let allData = [];
let allHeaders = [];
// Load data on page load
document.addEventListener('DOMContentLoaded', function() {
loadRndData();
});
async function loadRndData() {
try {
const response = await fetch('/api/projects/{{ project_id }}/files/{{ file_id }}/rnd-data');
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Failed to load data');
}
// Store data globally
allData = result.data;
allHeaders = result.headers;
// Update summary cards
updateSummary(result.summary);
// Render chart
renderChart(result.data, result.summary.file_type);
// Render table
renderTable(result.headers, result.data);
// Show content, hide loading
document.getElementById('loading-state').classList.add('hidden');
document.getElementById('data-content').classList.remove('hidden');
} catch (error) {
console.error('Error loading RND data:', error);
document.getElementById('loading-state').classList.add('hidden');
document.getElementById('error-state').classList.remove('hidden');
document.getElementById('error-message').textContent = error.message;
}
}
function updateSummary(summary) {
document.getElementById('summary-file-type').textContent =
summary.file_type === 'leq' ? 'Leq (Time-Averaged)' :
summary.file_type === 'lp' ? 'Lp (Instantaneous)' : 'Unknown';
document.getElementById('summary-rows').textContent = summary.total_rows.toLocaleString();
document.getElementById('summary-start').textContent = summary.time_start || '-';
document.getElementById('summary-end').textContent = summary.time_end || '-';
// Find the main metric based on file type
const avgKey = summary.file_type === 'leq' ? 'Leq(Main)_avg' : 'Lp(Main)_avg';
const maxKey = summary.file_type === 'leq' ? 'Lmax(Main)_max' : 'Lmax(Main)_max';
if (summary[avgKey] !== undefined) {
document.getElementById('summary-avg').textContent = summary[avgKey].toFixed(1) + ' dB';
}
if (summary[maxKey] !== undefined) {
document.getElementById('summary-max').textContent = summary[maxKey].toFixed(1) + ' dB';
} else if (summary['Lpeak(Main)_max'] !== undefined) {
document.getElementById('summary-max').textContent = summary['Lpeak(Main)_max'].toFixed(1) + ' dB';
}
}
function renderChart(data, fileType) {
const ctx = document.getElementById('soundLevelChart').getContext('2d');
// Prepare datasets based on file type
const datasets = [];
const labels = data.map(row => row['Start Time'] || '');
// Define metrics to chart based on file type
const metricsConfig = fileType === 'leq' ? [
{ key: 'Leq(Main)', label: 'Leq', color: 'rgb(59, 130, 246)', fill: false },
{ key: 'Lmax(Main)', label: 'Lmax', color: 'rgb(239, 68, 68)', fill: false },
{ key: 'Lmin(Main)', label: 'Lmin', color: 'rgb(34, 197, 94)', fill: false },
{ key: 'Lpeak(Main)', label: 'Lpeak', color: 'rgb(168, 85, 247)', fill: false },
] : [
{ key: 'Lp(Main)', label: 'Lp', color: 'rgb(59, 130, 246)', fill: false },
{ key: 'Leq(Main)', label: 'Leq', color: 'rgb(249, 115, 22)', fill: false },
{ key: 'Lmax(Main)', label: 'Lmax', color: 'rgb(239, 68, 68)', fill: false },
{ key: 'Lmin(Main)', label: 'Lmin', color: 'rgb(34, 197, 94)', fill: false },
{ key: 'Lpeak(Main)', label: 'Lpeak', color: 'rgb(168, 85, 247)', fill: false },
];
metricsConfig.forEach(metric => {
const values = data.map(row => {
const val = row[metric.key];
return typeof val === 'number' ? val : null;
});
// Only add if we have data
if (values.some(v => v !== null)) {
datasets.push({
label: metric.label,
data: values,
borderColor: metric.color,
backgroundColor: metric.color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
fill: metric.fill,
tension: 0.1,
pointRadius: data.length > 100 ? 0 : 2,
borderWidth: 1.5,
});
}
});
// Downsample if too many points
let chartLabels = labels;
let chartDatasets = datasets;
if (data.length > 1000) {
const sampleRate = Math.ceil(data.length / 1000);
chartLabels = labels.filter((_, i) => i % sampleRate === 0);
chartDatasets = datasets.map(ds => ({
...ds,
data: ds.data.filter((_, i) => i % sampleRate === 0)
}));
}
chartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: chartLabels,
datasets: chartDatasets
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
position: 'top',
labels: {
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
usePointStyle: true,
}
},
tooltip: {
backgroundColor: document.documentElement.classList.contains('dark') ? '#1f2937' : '#ffffff',
titleColor: document.documentElement.classList.contains('dark') ? '#f9fafb' : '#111827',
bodyColor: document.documentElement.classList.contains('dark') ? '#d1d5db' : '#4b5563',
borderColor: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
borderWidth: 1,
callbacks: {
label: function(context) {
return context.dataset.label + ': ' + (context.parsed.y !== null ? context.parsed.y.toFixed(1) + ' dB' : 'N/A');
}
}
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: 'Time',
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
},
ticks: {
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
maxTicksLimit: 10,
maxRotation: 45,
},
grid: {
color: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
}
},
y: {
display: true,
title: {
display: true,
text: 'Sound Level (dB)',
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
},
ticks: {
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
},
grid: {
color: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
}
}
}
}
});
// Chart metric filter
document.getElementById('chart-metric-select').addEventListener('change', function(e) {
const value = e.target.value;
chartInstance.data.datasets.forEach((ds, i) => {
if (value === 'all') {
ds.hidden = false;
} else if (value === 'leq') {
ds.hidden = !['Leq', 'Lp'].includes(ds.label);
} else if (value === 'lmax') {
ds.hidden = ds.label !== 'Lmax';
} else if (value === 'lmin') {
ds.hidden = ds.label !== 'Lmin';
} else if (value === 'lpeak') {
ds.hidden = ds.label !== 'Lpeak';
}
});
chartInstance.update();
});
}
function renderTable(headers, data) {
const headerRow = document.getElementById('table-header');
const tbody = document.getElementById('table-body');
// Render headers
headerRow.innerHTML = '<tr>' + headers.map(h =>
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}</th>`
).join('') + '</tr>';
// Render rows (limit to first 500 for performance)
const displayData = data.slice(0, 500);
tbody.innerHTML = displayData.map(row =>
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
headers.map(h => {
const val = row[h];
let displayVal = val;
if (val === null || val === undefined) {
displayVal = '-';
} else if (typeof val === 'number') {
displayVal = val.toFixed(1);
}
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
}).join('') +
'</tr>'
).join('');
// Update row count
document.getElementById('row-count').textContent =
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows` : `${data.length.toLocaleString()} rows`;
// Search functionality
document.getElementById('table-search').addEventListener('input', function(e) {
const searchTerm = e.target.value.toLowerCase();
const filtered = data.filter(row =>
Object.values(row).some(v =>
String(v).toLowerCase().includes(searchTerm)
)
);
const displayFiltered = filtered.slice(0, 500);
tbody.innerHTML = displayFiltered.map(row =>
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
headers.map(h => {
const val = row[h];
let displayVal = val;
if (val === null || val === undefined) {
displayVal = '-';
} else if (typeof val === 'number') {
displayVal = val.toFixed(1);
}
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
}).join('') +
'</tr>'
).join('');
document.getElementById('row-count').textContent =
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;
});
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Report Generation Modal Functions
let reportTemplates = [];
async function loadTemplates() {
try {
const response = await fetch('/api/report-templates?project_id={{ project_id }}');
if (response.ok) {
reportTemplates = await response.json();
populateTemplateDropdown();
}
} catch (error) {
console.error('Error loading templates:', error);
}
}
function populateTemplateDropdown() {
const select = document.getElementById('template-select');
if (!select) return;
select.innerHTML = '<option value="">-- Select a template --</option>';
reportTemplates.forEach(template => {
const option = document.createElement('option');
option.value = template.id;
option.textContent = template.name;
option.dataset.config = JSON.stringify(template);
select.appendChild(option);
});
}
function applyTemplate() {
const select = document.getElementById('template-select');
const selectedOption = select.options[select.selectedIndex];
if (!selectedOption.value) return;
const template = JSON.parse(selectedOption.dataset.config);
if (template.report_title) {
document.getElementById('report-title').value = template.report_title;
}
if (template.start_time) {
document.getElementById('start-time').value = template.start_time;
}
if (template.end_time) {
document.getElementById('end-time').value = template.end_time;
}
if (template.start_date) {
document.getElementById('start-date').value = template.start_date;
}
if (template.end_date) {
document.getElementById('end-date').value = template.end_date;
}
// Update preset buttons
updatePresetButtons();
}
function setTimePreset(preset) {
const startTimeInput = document.getElementById('start-time');
const endTimeInput = document.getElementById('end-time');
// Remove active state from all preset buttons
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.classList.remove('bg-emerald-600', 'text-white');
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
});
// Set time values based on preset
switch(preset) {
case 'night':
startTimeInput.value = '19:00';
endTimeInput.value = '07:00';
break;
case 'day':
startTimeInput.value = '07:00';
endTimeInput.value = '19:00';
break;
case 'all':
startTimeInput.value = '';
endTimeInput.value = '';
break;
case 'custom':
// Just enable custom input, don't change values
break;
}
// Highlight active preset
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
if (activeBtn) {
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
activeBtn.classList.add('bg-emerald-600', 'text-white');
}
}
function updatePresetButtons() {
const startTime = document.getElementById('start-time').value;
const endTime = document.getElementById('end-time').value;
// Remove active state from all
document.querySelectorAll('.preset-btn').forEach(btn => {
btn.classList.remove('bg-emerald-600', 'text-white');
btn.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
});
// Check which preset matches
let preset = 'custom';
if (startTime === '19:00' && endTime === '07:00') preset = 'night';
else if (startTime === '07:00' && endTime === '19:00') preset = 'day';
else if (!startTime && !endTime) preset = 'all';
const activeBtn = document.querySelector(`[data-preset="${preset}"]`);
if (activeBtn) {
activeBtn.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300');
activeBtn.classList.add('bg-emerald-600', 'text-white');
}
}
function openReportModal() {
document.getElementById('report-modal').classList.remove('hidden');
loadTemplates();
// Pre-fill fields if available
const locationInput = document.getElementById('report-location');
if (locationInput && !locationInput.value) {
locationInput.value = '{{ location.name if location else "" }}';
}
const projectInput = document.getElementById('report-project');
if (projectInput && !projectInput.value) {
projectInput.value = '{{ project.name if project else "" }}';
}
const clientInput = document.getElementById('report-client');
if (clientInput && !clientInput.value) {
clientInput.value = '{{ project.client_name if project and project.client_name else "" }}';
}
// Set default to "All Day"
setTimePreset('all');
}
function closeReportModal() {
document.getElementById('report-modal').classList.add('hidden');
}
function generateReport(preview = false) {
const reportTitle = document.getElementById('report-title').value || 'Background Noise Study';
const projectName = document.getElementById('report-project').value || '';
const clientName = document.getElementById('report-client').value || '';
const locationName = document.getElementById('report-location').value || '';
const startTime = document.getElementById('start-time').value || '';
const endTime = document.getElementById('end-time').value || '';
const startDate = document.getElementById('start-date').value || '';
const endDate = document.getElementById('end-date').value || '';
// Build the URL with query parameters
const params = new URLSearchParams({
report_title: reportTitle,
project_name: projectName,
client_name: clientName,
location_name: locationName,
start_time: startTime,
end_time: endTime,
start_date: startDate,
end_date: endDate
});
if (preview) {
// Open preview page
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/preview-report?${params.toString()}`;
} else {
// Direct download
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/generate-report?${params.toString()}`;
}
closeReportModal();
}
async function saveAsTemplate() {
const name = prompt('Enter a name for this template:');
if (!name) return;
const templateData = {
name: name,
project_id: '{{ project_id }}',
report_title: document.getElementById('report-title').value || 'Background Noise Study',
start_time: document.getElementById('start-time').value || null,
end_time: document.getElementById('end-time').value || null,
start_date: document.getElementById('start-date').value || null,
end_date: document.getElementById('end-date').value || null
};
try {
const response = await fetch('/api/report-templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(templateData)
});
if (response.ok) {
alert('Template saved successfully!');
loadTemplates();
} else {
alert('Failed to save template');
}
} catch (error) {
alert('Error saving template: ' + error.message);
}
}
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeReportModal();
}
});
// Update preset buttons when time inputs change
document.addEventListener('DOMContentLoaded', function() {
const startTimeInput = document.getElementById('start-time');
const endTimeInput = document.getElementById('end-time');
if (startTimeInput) startTimeInput.addEventListener('change', updatePresetButtons);
if (endTimeInput) endTimeInput.addEventListener('change', updatePresetButtons);
});
</script>
<!-- Report Generation Modal -->
<div id="report-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 transition-opacity" onclick="closeReportModal()"></div>
<!-- Modal panel -->
<div class="inline-block align-bottom bg-white dark:bg-slate-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
<div class="bg-white dark:bg-slate-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 sm:mx-0 sm:h-10 sm:w-10">
<svg class="h-6 w-6 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
Generate Excel Report
</h3>
<div class="mt-4 space-y-4">
<!-- Template Selection -->
<div class="flex items-center gap-2">
<div class="flex-1">
<label for="template-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Load Template
</label>
<select id="template-select" onchange="applyTemplate()"
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
<option value="">-- Select a template --</option>
</select>
</div>
<button type="button" onclick="saveAsTemplate()"
class="mt-6 px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600"
title="Save current settings as template">
<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="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
</svg>
</button>
</div>
<!-- Report Title -->
<div>
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Report Title
</label>
<input type="text" id="report-title" value="Background Noise Study"
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<!-- Project and Client in a row -->
<div class="grid grid-cols-2 gap-3">
<div>
<label for="report-project" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Project Name
</label>
<input type="text" id="report-project" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<div>
<label for="report-client" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Client Name
</label>
<input type="text" id="report-client" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
</div>
<!-- Location Name -->
<div>
<label for="report-location" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Location Name
</label>
<input type="text" id="report-location" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<!-- Time Filter Section -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Time Filter
</label>
<!-- Preset Buttons -->
<div class="flex gap-2 mb-3">
<button type="button" onclick="setTimePreset('night')" data-preset="night"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
7PM - 7AM
</button>
<button type="button" onclick="setTimePreset('day')" data-preset="day"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
7AM - 7PM
</button>
<button type="button" onclick="setTimePreset('all')" data-preset="all"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700 transition-colors">
All Day
</button>
<button type="button" onclick="setTimePreset('custom')" data-preset="custom"
class="preset-btn px-3 py-1.5 text-sm rounded-md bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
Custom
</button>
</div>
<!-- Custom Time Inputs -->
<div class="grid grid-cols-2 gap-3">
<div>
<label for="start-time" class="block text-xs text-gray-500 dark:text-gray-400">Start Time</label>
<input type="time" id="start-time" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<div>
<label for="end-time" class="block text-xs text-gray-500 dark:text-gray-400">End Time</label>
<input type="time" id="end-time" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
</div>
</div>
<!-- Date Range (Optional) -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Date Range <span class="text-gray-400 font-normal">(optional)</span>
</label>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="start-date" class="block text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="date" id="start-date" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
<div>
<label for="end-date" class="block text-xs text-gray-500 dark:text-gray-400">To</label>
<input type="date" id="end-date" value=""
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
</div>
</div>
</div>
<!-- Info about what's included -->
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3">
<p class="text-xs text-gray-500 dark:text-gray-400">
<strong>Report will include:</strong>
</p>
<ul class="mt-1 text-xs text-gray-500 dark:text-gray-400 list-disc list-inside space-y-0.5">
<li>Data table (Test #, Date, Time, LAmax, LA01, LA10, Comments)</li>
<li>Line chart visualization</li>
<li>Time period summary (Evening, Night, Morning, Daytime)</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 sm:px-6 flex flex-col sm:flex-row-reverse gap-2">
<button type="button" onclick="generateReport(false)"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-emerald-600 text-base font-medium text-white hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
<svg class="w-4 h-4 mr-2" 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-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download Excel
</button>
<button type="button" onclick="generateReport(true)"
class="w-full inline-flex justify-center rounded-md border border-emerald-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
Preview & Edit
</button>
<button type="button" onclick="closeReportModal()"
class="w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
Cancel
</button>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -131,10 +131,8 @@
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
<input type="text" name="project_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
placeholder="PROJ-001">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
{% include "partials/project_picker.html" with context %}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
@@ -159,8 +157,8 @@
</div>
<div id="modemPairingField" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<input type="text" name="deployed_with_modem_id" placeholder="Modem ID"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
{% set picker_id = "-add-seismo" %}
{% include "partials/modem_picker.html" with context %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
</div>
</div>
@@ -183,6 +181,21 @@
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
<select name="deployment_type"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<option value="">Not assigned</option>
<option value="seismograph">Seismograph</option>
<option value="slm">Sound Level Meter (SLM)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
{% set picker_id = "-add-modem" %}
{% set device_type_filter = "" %}
{% include "partials/unit_picker.html" with context %}
</div>
</div>
<!-- Sound Level Meter-specific fields -->
@@ -297,9 +310,9 @@
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
<input type="text" name="project_id" id="editProjectId"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
{% set picker_id = "-edit" %}
{% include "partials/project_picker.html" with context %}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
@@ -327,8 +340,8 @@
</div>
<div id="editModemPairingField" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<input type="text" name="deployed_with_modem_id" id="editDeployedWithModemId" placeholder="Modem ID"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
{% set picker_id = "-edit-seismo" %}
{% include "partials/modem_picker.html" with context %}
</div>
</div>
@@ -350,6 +363,21 @@
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
<select name="deployment_type" id="editDeploymentType"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<option value="">Not assigned</option>
<option value="seismograph">Seismograph</option>
<option value="slm">Sound Level Meter (SLM)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
{% set picker_id = "-edit-modem" %}
{% set device_type_filter = "" %}
{% include "partials/unit_picker.html" with context %}
</div>
</div>
<!-- Sound Level Meter-specific fields -->
@@ -419,6 +447,55 @@
<textarea name="note" id="editNote" rows="3"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
</div>
<!-- Cascade to Paired Device Section -->
<div id="editCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
Also update paired device: <span id="editPairedDeviceName" class="text-seismo-orange"></span>
</span>
</div>
<input type="hidden" name="cascade_to_unit_id" id="editCascadeToUnitId" value="">
<div class="grid grid-cols-2 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_deployed" id="editCascadeDeployed" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed status</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_retired" id="editCascadeRetired" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Retired status</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_project" id="editCascadeProject" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_location" id="editCascadeLocation" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_coordinates" id="editCascadeCoordinates" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_note" id="editCascadeNote" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Notes</span>
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Check the fields you want to sync to the paired device
</p>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
Save Changes
@@ -650,11 +727,7 @@
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
closeAddUnitModal();
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
// Show success message
alert('Unit added successfully!');
} else {
@@ -692,6 +765,33 @@
function closeEditUnitModal() {
document.getElementById('editUnitModal').classList.add('hidden');
document.getElementById('editUnitForm').reset();
// Also clear the project picker
const projectPickerValue = document.getElementById('project-picker-value-edit');
const projectPickerSearch = document.getElementById('project-picker-search-edit');
const projectPickerClear = document.getElementById('project-picker-clear-edit');
if (projectPickerValue) projectPickerValue.value = '';
if (projectPickerSearch) projectPickerSearch.value = '';
if (projectPickerClear) projectPickerClear.classList.add('hidden');
}
// Fetch project display name for edit modal
async function fetchProjectDisplayForEdit(projectId) {
if (!projectId) return '';
try {
const response = await fetch(`/api/projects/${projectId}`);
if (response.ok) {
const project = await response.json();
const parts = [
project.project_number,
project.client_name,
project.name
].filter(Boolean);
return parts.join(' - ') || projectId;
}
} catch (e) {
console.error('Failed to fetch project:', e);
}
return projectId;
}
// Toggle device-specific fields in edit modal
@@ -753,7 +853,23 @@
document.getElementById('editUnitId').value = unit.id;
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
document.getElementById('editUnitType').value = unit.unit_type;
document.getElementById('editProjectId').value = unit.project_id;
// Populate project picker (uses -edit suffix)
const projectPickerValue = document.getElementById('project-picker-value-edit');
const projectPickerSearch = document.getElementById('project-picker-search-edit');
const projectPickerClear = document.getElementById('project-picker-clear-edit');
if (projectPickerValue) projectPickerValue.value = unit.project_id || '';
if (unit.project_id) {
// Fetch project display name
fetchProjectDisplayForEdit(unit.project_id).then(displayText => {
if (projectPickerSearch) projectPickerSearch.value = displayText;
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
});
} else {
if (projectPickerSearch) projectPickerSearch.value = '';
if (projectPickerClear) projectPickerClear.classList.add('hidden');
}
document.getElementById('editAddress').value = unit.address;
document.getElementById('editCoordinates').value = unit.coordinates;
document.getElementById('editNote').value = unit.note;
@@ -765,12 +881,62 @@
// Seismograph fields
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
document.getElementById('editDeployedWithModemId').value = unit.deployed_with_modem_id;
// Populate modem picker for seismograph (uses -edit-seismo suffix)
const modemPickerValue = document.getElementById('modem-picker-value-edit-seismo');
const modemPickerSearch = document.getElementById('modem-picker-search-edit-seismo');
const modemPickerClear = document.getElementById('modem-picker-clear-edit-seismo');
if (modemPickerValue) modemPickerValue.value = unit.deployed_with_modem_id || '';
if (unit.deployed_with_modem_id) {
// Fetch modem display (ID + IP + note)
fetch(`/api/roster/${unit.deployed_with_modem_id}`)
.then(r => r.ok ? r.json() : null)
.then(modem => {
if (modem && modemPickerSearch) {
let display = modem.id;
if (modem.ip_address) display += ` - ${modem.ip_address}`;
if (modem.note) display += ` - ${modem.note}`;
modemPickerSearch.value = display;
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
}
})
.catch(() => {
if (modemPickerSearch) modemPickerSearch.value = unit.deployed_with_modem_id;
});
} else {
if (modemPickerSearch) modemPickerSearch.value = '';
if (modemPickerClear) modemPickerClear.classList.add('hidden');
}
// Modem fields
document.getElementById('editIpAddress').value = unit.ip_address;
document.getElementById('editPhoneNumber').value = unit.phone_number;
document.getElementById('editHardwareModel').value = unit.hardware_model;
document.getElementById('editDeploymentType').value = unit.deployment_type || '';
// Populate unit picker for modem (uses -edit-modem suffix)
const unitPickerValue = document.getElementById('unit-picker-value-edit-modem');
const unitPickerSearch = document.getElementById('unit-picker-search-edit-modem');
const unitPickerClear = document.getElementById('unit-picker-clear-edit-modem');
if (unitPickerValue) unitPickerValue.value = unit.deployed_with_unit_id || '';
if (unit.deployed_with_unit_id) {
// Fetch unit display (ID + note)
fetch(`/api/roster/${unit.deployed_with_unit_id}`)
.then(r => r.ok ? r.json() : null)
.then(linkedUnit => {
if (linkedUnit && unitPickerSearch) {
const display = linkedUnit.note ? `${linkedUnit.id} - ${linkedUnit.note}` : linkedUnit.id;
unitPickerSearch.value = display;
if (unitPickerClear) unitPickerClear.classList.remove('hidden');
}
})
.catch(() => {
if (unitPickerSearch) unitPickerSearch.value = unit.deployed_with_unit_id;
});
} else {
if (unitPickerSearch) unitPickerSearch.value = '';
if (unitPickerClear) unitPickerClear.classList.add('hidden');
}
// SLM fields
document.getElementById('editSlmModel').value = unit.slm_model || '';
@@ -781,6 +947,35 @@
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
// Cascade section - show if there's a paired device
const cascadeSection = document.getElementById('editCascadeSection');
const cascadeToUnitId = document.getElementById('editCascadeToUnitId');
const pairedDeviceName = document.getElementById('editPairedDeviceName');
// Determine paired device based on device type
let pairedUnitId = null;
if (unit.device_type === 'modem' && unit.deployed_with_unit_id) {
pairedUnitId = unit.deployed_with_unit_id;
} else if ((unit.device_type === 'seismograph' || unit.device_type === 'sound_level_meter') && unit.deployed_with_modem_id) {
pairedUnitId = unit.deployed_with_modem_id;
}
if (pairedUnitId) {
cascadeToUnitId.value = pairedUnitId;
pairedDeviceName.textContent = pairedUnitId;
cascadeSection.classList.remove('hidden');
// Reset checkboxes
document.getElementById('editCascadeDeployed').checked = false;
document.getElementById('editCascadeRetired').checked = false;
document.getElementById('editCascadeProject').checked = false;
document.getElementById('editCascadeLocation').checked = false;
document.getElementById('editCascadeCoordinates').checked = false;
document.getElementById('editCascadeNote').checked = false;
} else {
cascadeToUnitId.value = '';
cascadeSection.classList.add('hidden');
}
// Store unit ID for form submission
document.getElementById('editUnitForm').dataset.unitId = unitId;
@@ -814,11 +1009,7 @@
if (response.ok) {
closeEditUnitModal();
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
alert('Unit updated successfully!');
} else {
const result = await response.json();
@@ -846,11 +1037,7 @@
});
if (response.ok) {
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
} else {
const result = await response.json();
@@ -878,11 +1065,7 @@
});
if (response.ok) {
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
alert(`Unit ${unitId} moved to ignore list`);
} else {
const result = await response.json();
@@ -905,11 +1088,7 @@
});
if (response.ok) {
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
alert(`Unit ${unitId} deleted successfully`);
} else {
const result = await response.json();
@@ -948,11 +1127,7 @@
`;
resultDiv.classList.remove('hidden');
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
// Close modal after 2 seconds
setTimeout(() => closeImportModal(), 2000);
@@ -968,35 +1143,35 @@
}
});
// Handle roster tab switching with auto-refresh
let currentRosterEndpoint = '/partials/roster-deployed'; // Default to deployed tab
document.addEventListener('DOMContentLoaded', function() {
const tabButtons = document.querySelectorAll('.roster-tab-button');
tabButtons.forEach(button => {
button.addEventListener('click', function() {
// Remove active-roster-tab class from all buttons
tabButtons.forEach(btn => btn.classList.remove('active-roster-tab'));
// Add active-roster-tab class to clicked button
this.classList.add('active-roster-tab');
// Update current endpoint for auto-refresh
currentRosterEndpoint = this.getAttribute('data-endpoint');
});
});
// Auto-refresh the current active tab every 10 seconds
setInterval(() => {
const rosterContent = document.getElementById('roster-content');
if (rosterContent) {
// Use HTMX to trigger a refresh of the current endpoint
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
// Refresh device list (applies current client-side filters after load)
function refreshDeviceList() {
htmx.ajax('GET', '/partials/devices-all', {
target: '#device-content',
swap: 'innerHTML'
}).then(() => {
// Re-apply filters after content loads
setTimeout(filterDevices, 100);
});
}
}, 10000); // 10 seconds
// Check if any modal is currently open
function isAnyModalOpen() {
const modalIds = ['addUnitModal', 'editUnitModal', 'renameUnitModal', 'importModal', 'quickCreateProjectModal'];
return modalIds.some(id => {
const modal = document.getElementById(id);
return modal && !modal.classList.contains('hidden');
});
}
document.addEventListener('DOMContentLoaded', function() {
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
setInterval(() => {
const deviceContent = document.getElementById('device-content');
if (deviceContent && !isAnyModalOpen()) {
// Only auto-refresh if no modal is open
refreshDeviceList();
}
}, 30000);
});
// Un-ignore Unit (remove from ignored list)
@@ -1345,4 +1520,7 @@
}
</style>
<!-- Include Project Create Modal for inline project creation -->
{% include "partials/project_create_modal.html" %}
{% endblock %}

View File

@@ -77,7 +77,7 @@
{% if not from_project and not from_nrl %}
<!-- Configure button only shown in administrative context (accessed from roster/SLM dashboard) -->
<div class="flex gap-3">
<button onclick="openConfigModal()"
<button onclick="openSLMSettingsModal('{{ unit_id }}')"
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
@@ -104,73 +104,7 @@
</div>
</div>
<!-- Configuration Modal -->
<div id="config-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">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Configure {{ unit_id }}</h2>
<button onclick="closeConfigModal()" 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>
<div id="config-modal-content"
hx-get="/api/slm-dashboard/config/{{ unit_id }}"
hx-trigger="load"
hx-swap="innerHTML">
<!-- Loading skeleton -->
<div class="p-6 space-y-4 animate-pulse">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
</div>
</div>
</div>
</div>
<script>
// Modal functions
function openConfigModal() {
const modal = document.getElementById('config-modal');
modal.classList.remove('hidden');
// Reload config when opening
htmx.ajax('GET', '/api/slm-dashboard/config/{{ unit_id }}', {
target: '#config-modal-content',
swap: 'innerHTML'
});
}
function closeConfigModal() {
document.getElementById('config-modal').classList.add('hidden');
}
// Keyboard shortcut
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeConfigModal();
}
});
// Click outside to close
document.getElementById('config-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeConfigModal();
}
});
// Listen for config updates to refresh live view
document.body.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.pathInfo.requestPath.includes('/config/') && event.detail.successful) {
// Refresh live view after config update
htmx.ajax('GET', '/api/slm-dashboard/live-view/{{ unit_id }}', {
target: '#live-view-content',
swap: 'innerHTML'
});
closeConfigModal();
}
});
</script>
<!-- Unified SLM Settings Modal -->
{% include 'partials/slm_settings_modal.html' %}
{% endblock %}

View File

@@ -137,27 +137,8 @@
</div>
</div>
<!-- Configuration Modal -->
<div id="slm-config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-slate-800 rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-6">
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
<button onclick="closeDeviceConfigModal()" 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>
<div id="slm-config-modal-content">
<div class="animate-pulse space-y-4">
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
</div>
</div>
</div>
</div>
<!-- Unified SLM Settings Modal -->
{% include 'partials/slm_settings_modal.html' %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
@@ -365,32 +346,22 @@ function updateDashboardChart(data) {
}
}
// Configuration modal
// Configuration modal - use unified SLM settings modal
function openDeviceConfigModal(unitId) {
const modal = document.getElementById('slm-config-modal');
modal.classList.remove('hidden');
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
target: '#slm-config-modal-content',
swap: 'innerHTML'
});
// Call the unified modal function from slm_settings_modal.html
if (typeof openSLMSettingsModal === 'function') {
openSLMSettingsModal(unitId);
} else {
console.error('openSLMSettingsModal not found');
}
}
function closeDeviceConfigModal() {
document.getElementById('slm-config-modal').classList.add('hidden');
// Call the unified modal close function
if (typeof closeSLMSettingsModal === 'function') {
closeSLMSettingsModal();
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeDeviceConfigModal();
}
});
document.getElementById('slm-config-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeDeviceConfigModal();
}
});
// Cleanup on page unload
window.addEventListener('beforeunload', function() {

View File

@@ -43,7 +43,7 @@
</button>
</div>
<div class="flex gap-3">
<button id="editButton" onclick="window.location.href='/roster?edit=' + unitId" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
<button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
@@ -121,8 +121,13 @@
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project ID</label>
<p id="viewProjectId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project</label>
<p id="viewProjectContainer" class="mt-1">
<a id="viewProjectLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
<span id="viewProjectText">--</span>
</a>
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
</p>
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
@@ -148,7 +153,12 @@
</div>
<div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
<p id="viewDeployedWithModemId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
<p id="viewDeployedWithModemContainer" class="mt-1">
<a id="viewDeployedWithModemLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
<span id="viewDeployedWithModemText">--</span>
</a>
<span id="viewDeployedWithModemNoLink" class="text-gray-900 dark:text-white font-medium">--</span>
</p>
</div>
</div>
</div>
@@ -172,6 +182,48 @@
</div>
</div>
<!-- Paired Device (for modems only) -->
<div id="viewPairedDeviceSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Paired Device</h3>
<div id="pairedDeviceInfo">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
</div>
</div>
<!-- Connectivity (for modems only) -->
<div id="viewConnectivitySection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connectivity</h3>
<div class="flex items-center gap-4 mb-4">
<button onclick="pingModem()" id="modemPingBtn"
class="px-4 py-2 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded-lg flex items-center gap-2 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
</svg>
Ping Test
</button>
<span id="modemPingResult" class="text-sm text-gray-500 dark:text-gray-400">--</span>
</div>
<!-- Future Diagnostics Placeholders -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 opacity-60">
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
<p class="text-xs text-gray-500 dark:text-gray-400">Signal Strength</p>
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- dBm</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
</div>
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
<p class="text-xs text-gray-500 dark:text-gray-400">Data Usage</p>
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- MB</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
</div>
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
<p class="text-xs text-gray-500 dark:text-gray-400">Uptime</p>
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">--</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
</div>
</div>
</div>
<!-- Notes -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
@@ -251,11 +303,11 @@
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<!-- Project ID -->
<!-- Project -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
<input type="text" name="project_id" id="projectId"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
{% set picker_id = "-detail" %}
{% include "partials/project_picker.html" with context %}
</div>
<!-- Address -->
@@ -289,8 +341,20 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<input type="text" name="deployed_with_modem_id" id="deployedWithModemId" placeholder="Modem ID"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<div class="flex gap-2">
<div class="flex-1">
{% set picker_id = "-detail-seismo" %}
{% set input_name = "deployed_with_modem_id" %}
{% include "partials/modem_picker.html" with context %}
</div>
<button type="button" onclick="openPairDeviceModal('seismograph')"
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors flex items-center gap-1"
title="Pair with modem">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
@@ -364,11 +428,20 @@
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<select name="deployed_with_modem_id" id="slmDeployedWithModemId"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<option value="">No modem assigned</option>
<!-- Options will be populated by JavaScript -->
</select>
<div class="flex gap-2">
<div class="flex-1">
{% set picker_id = "-detail-slm" %}
{% set input_name = "deployed_with_modem_id" %}
{% include "partials/modem_picker.html" with context %}
</div>
<button type="button" onclick="openPairDeviceModal('sound_level_meter')"
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors flex items-center gap-1"
title="Pair with modem">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem that provides network connectivity for this SLM</p>
</div>
</div>
@@ -395,6 +468,54 @@
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
</div>
<!-- Cascade to Paired Device Section -->
<div id="detailCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
Also update paired device: <span id="detailPairedDeviceName" class="text-seismo-orange"></span>
</span>
</div>
<input type="hidden" name="cascade_to_unit_id" id="detailCascadeToUnitId" value="">
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_deployed" id="detailCascadeDeployed" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed status</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_retired" id="detailCascadeRetired" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Retired status</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_project" id="detailCascadeProject" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_location" id="detailCascadeLocation" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_coordinates" id="detailCascadeCoordinates" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Notes</span>
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Check the fields you want to sync to the paired device
</p>
</div>
<!-- Save/Cancel Buttons -->
<div class="flex gap-3">
<button type="submit" class="flex-1 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
@@ -415,6 +536,64 @@ let currentSnapshot = null;
let unitMap = null;
let mapMarker = null;
// Fetch project display name (combines project_number, client_name, name)
async function fetchProjectDisplay(projectId) {
if (!projectId) return '';
try {
const response = await fetch(`/api/projects/${projectId}`);
if (response.ok) {
const project = await response.json();
const parts = [
project.project_number,
project.client_name,
project.name
].filter(Boolean);
return parts.join(' - ') || projectId;
}
} catch (e) {
console.error('Failed to fetch project:', e);
}
return projectId;
}
// Fetch modem display name (combines id, ip_address, hardware_model)
// Also returns the actual modem ID if found (for updating picker value)
async function fetchModemDisplay(modemIdOrIp) {
if (!modemIdOrIp) return { display: '', modemId: '' };
try {
// First try direct lookup by ID
let response = await fetch(`/api/roster/${encodeURIComponent(modemIdOrIp)}`);
if (response.ok) {
const modem = await response.json();
const parts = [modem.id];
if (modem.ip_address) parts.push(`(${modem.ip_address})`);
if (modem.hardware_model) parts.push(`- ${modem.hardware_model}`);
return { display: parts.join(' ') || modemIdOrIp, modemId: modem.id };
}
// If not found, maybe it's an IP address - search for it
response = await fetch(`/api/roster/search/modems?q=${encodeURIComponent(modemIdOrIp)}`);
if (response.ok) {
// The search returns HTML, so we need to look up differently
// Try fetching all modems and find by IP
const modemsResponse = await fetch('/api/roster/modems');
if (modemsResponse.ok) {
const modems = await modemsResponse.json();
const modem = modems.find(m => m.ip_address === modemIdOrIp);
if (modem) {
const parts = [modem.id];
if (modem.ip_address) parts.push(`(${modem.ip_address})`);
if (modem.hardware_model) parts.push(`- ${modem.hardware_model}`);
return { display: parts.join(' ') || modemIdOrIp, modemId: modem.id };
}
}
}
} catch (e) {
console.error('Failed to fetch modem:', e);
}
return { display: modemIdOrIp, modemId: modemIdOrIp };
}
// Load unit data on page load
async function loadUnitData() {
try {
@@ -536,14 +715,60 @@ function populateViewMode() {
// Basic info
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
document.getElementById('viewProjectId').textContent = currentUnit.project_id || '--';
// Project display with clickable link
const projectId = currentUnit.project_id;
const projectLink = document.getElementById('viewProjectLink');
const projectNoLink = document.getElementById('viewProjectNoLink');
const projectText = document.getElementById('viewProjectText');
if (projectId) {
// Fetch project display name and show link
fetchProjectDisplay(projectId).then(displayText => {
if (projectText) projectText.textContent = displayText;
if (projectLink) {
projectLink.href = `/projects/${projectId}`;
projectLink.classList.remove('hidden');
}
if (projectNoLink) projectNoLink.classList.add('hidden');
});
} else {
if (projectNoLink) {
projectNoLink.textContent = 'Not assigned';
projectNoLink.classList.remove('hidden');
}
if (projectLink) projectLink.classList.add('hidden');
}
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
// Seismograph fields
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
document.getElementById('viewDeployedWithModemId').textContent = currentUnit.deployed_with_modem_id || '--';
// Deployed with modem - show as clickable link
const modemLink = document.getElementById('viewDeployedWithModemLink');
const modemNoLink = document.getElementById('viewDeployedWithModemNoLink');
const modemText = document.getElementById('viewDeployedWithModemText');
if (currentUnit.deployed_with_modem_id) {
// Fetch modem info to get the actual ID (in case stored as IP) and display text
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
if (modemText) modemText.textContent = result.display;
if (modemLink) {
modemLink.href = `/unit/${encodeURIComponent(result.modemId)}`;
modemLink.classList.remove('hidden');
}
if (modemNoLink) modemNoLink.classList.add('hidden');
});
} else {
if (modemNoLink) {
modemNoLink.textContent = '--';
modemNoLink.classList.remove('hidden');
}
if (modemLink) modemLink.classList.add('hidden');
}
// Modem fields
document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
@@ -557,9 +782,15 @@ function populateViewMode() {
if (currentUnit.device_type === 'modem') {
document.getElementById('viewSeismographFields').classList.add('hidden');
document.getElementById('viewModemFields').classList.remove('hidden');
document.getElementById('viewPairedDeviceSection').classList.remove('hidden');
document.getElementById('viewConnectivitySection').classList.remove('hidden');
// Load paired device info
loadPairedDevice();
} else {
document.getElementById('viewSeismographFields').classList.remove('hidden');
document.getElementById('viewModemFields').classList.add('hidden');
document.getElementById('viewPairedDeviceSection').classList.add('hidden');
document.getElementById('viewConnectivitySection').classList.add('hidden');
}
}
@@ -567,7 +798,22 @@ function populateViewMode() {
function populateEditForm() {
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
document.getElementById('unitType').value = currentUnit.unit_type || '';
document.getElementById('projectId').value = currentUnit.project_id || '';
// Populate project picker (uses -detail suffix)
const projectPickerValue = document.getElementById('project-picker-value-detail');
const projectPickerSearch = document.getElementById('project-picker-search-detail');
const projectPickerClear = document.getElementById('project-picker-clear-detail');
if (projectPickerValue) projectPickerValue.value = currentUnit.project_id || '';
if (currentUnit.project_id) {
fetchProjectDisplay(currentUnit.project_id).then(displayText => {
if (projectPickerSearch) projectPickerSearch.value = displayText;
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
});
} else {
if (projectPickerSearch) projectPickerSearch.value = '';
if (projectPickerClear) projectPickerClear.classList.add('hidden');
}
document.getElementById('address').value = currentUnit.address || '';
document.getElementById('coordinates').value = currentUnit.coordinates || '';
document.getElementById('deployed').checked = currentUnit.deployed;
@@ -577,7 +823,24 @@ function populateEditForm() {
// Seismograph fields
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
document.getElementById('deployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
// Populate modem picker for seismograph (uses -detail-seismo suffix)
const modemPickerValue = document.getElementById('modem-picker-value-detail-seismo');
const modemPickerSearch = document.getElementById('modem-picker-search-detail-seismo');
const modemPickerClear = document.getElementById('modem-picker-clear-detail-seismo');
if (currentUnit.deployed_with_modem_id) {
// Fetch modem display info (handles both modem ID and IP address lookups)
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
// Update the hidden value with the actual modem ID (in case it was stored as IP)
if (modemPickerValue) modemPickerValue.value = result.modemId || currentUnit.deployed_with_modem_id;
if (modemPickerSearch) modemPickerSearch.value = result.display;
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
});
} else {
if (modemPickerValue) modemPickerValue.value = '';
if (modemPickerSearch) modemPickerSearch.value = '';
if (modemPickerClear) modemPickerClear.classList.add('hidden');
}
// Modem fields
document.getElementById('ipAddress').value = currentUnit.ip_address || '';
@@ -591,10 +854,69 @@ function populateEditForm() {
document.getElementById('slmFrequencyWeighting').value = currentUnit.slm_frequency_weighting || '';
document.getElementById('slmTimeWeighting').value = currentUnit.slm_time_weighting || '';
document.getElementById('slmMeasurementRange').value = currentUnit.slm_measurement_range || '';
document.getElementById('slmDeployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
// Populate modem picker for SLM (uses -detail-slm suffix)
const slmModemPickerValue = document.getElementById('modem-picker-value-detail-slm');
const slmModemPickerSearch = document.getElementById('modem-picker-search-detail-slm');
const slmModemPickerClear = document.getElementById('modem-picker-clear-detail-slm');
if (currentUnit.deployed_with_modem_id) {
// Fetch modem display info (handles both modem ID and IP address lookups)
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
// Update the hidden value with the actual modem ID (in case it was stored as IP)
if (slmModemPickerValue) slmModemPickerValue.value = result.modemId || currentUnit.deployed_with_modem_id;
if (slmModemPickerSearch) slmModemPickerSearch.value = result.display;
if (slmModemPickerClear) slmModemPickerClear.classList.remove('hidden');
});
} else {
if (slmModemPickerValue) slmModemPickerValue.value = '';
if (slmModemPickerSearch) slmModemPickerSearch.value = '';
if (slmModemPickerClear) slmModemPickerClear.classList.add('hidden');
}
// Show/hide fields based on device type
toggleDetailFields();
// Check for paired device and show cascade section if applicable
checkAndShowCascadeSection();
}
// Check for paired device and show/hide cascade section
async function checkAndShowCascadeSection() {
const cascadeSection = document.getElementById('detailCascadeSection');
const cascadeToUnitId = document.getElementById('detailCascadeToUnitId');
const pairedDeviceName = document.getElementById('detailPairedDeviceName');
if (!cascadeSection) return;
// Reset cascade section
cascadeSection.classList.add('hidden');
if (cascadeToUnitId) cascadeToUnitId.value = '';
if (pairedDeviceName) pairedDeviceName.textContent = '';
// Reset checkboxes
['detailCascadeDeployed', 'detailCascadeRetired', 'detailCascadeProject',
'detailCascadeLocation', 'detailCascadeCoordinates', 'detailCascadeNote'].forEach(id => {
const checkbox = document.getElementById(id);
if (checkbox) checkbox.checked = false;
});
let pairedUnitId = null;
// Check based on device type
if (currentUnit.device_type === 'modem' && currentUnit.deployed_with_unit_id) {
// Modem is paired with a seismograph or SLM
pairedUnitId = currentUnit.deployed_with_unit_id;
} else if ((currentUnit.device_type === 'seismograph' || currentUnit.device_type === 'sound_level_meter') && currentUnit.deployed_with_modem_id) {
// Seismograph or SLM is paired with a modem
pairedUnitId = currentUnit.deployed_with_modem_id;
}
if (pairedUnitId) {
// Show cascade section
cascadeSection.classList.remove('hidden');
if (cascadeToUnitId) cascadeToUnitId.value = pairedUnitId;
if (pairedDeviceName) pairedDeviceName.textContent = pairedUnitId;
}
}
// Toggle device-specific fields
@@ -641,6 +963,16 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
e.preventDefault();
const formData = new FormData(this);
const deviceType = formData.get('device_type');
// Fix: FormData contains both modem picker hidden inputs (seismo and slm).
// We need to ensure only the correct one is submitted based on device type.
// Delete all deployed_with_modem_id entries and re-add the correct one.
const modemId = getCorrectModemPickerValue(deviceType);
formData.delete('deployed_with_modem_id');
if (modemId) {
formData.append('deployed_with_modem_id', modemId);
}
try {
const response = await fetch(`/api/roster/edit/${unitId}`, {
@@ -662,6 +994,19 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
}
});
// Get the correct modem picker value based on device type
function getCorrectModemPickerValue(deviceType) {
if (deviceType === 'seismograph') {
const picker = document.getElementById('modem-picker-value-detail-seismo');
return picker ? picker.value : '';
} else if (deviceType === 'sound_level_meter') {
const picker = document.getElementById('modem-picker-value-detail-slm');
return picker ? picker.value : '';
}
// Modems don't have a deployed_with_modem_id
return '';
}
// Delete unit
async function deleteUnit() {
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
@@ -999,10 +1344,297 @@ async function deleteHistoryEntry(historyId) {
}
}
// Load paired device info for modems
async function loadPairedDevice() {
try {
const response = await fetch(`/api/modem-dashboard/${unitId}/paired-device-html`);
if (response.ok) {
const html = await response.text();
document.getElementById('pairedDeviceInfo').innerHTML = html;
}
} catch (error) {
console.error('Error loading paired device:', error);
document.getElementById('pairedDeviceInfo').innerHTML = '<p class="text-red-500 text-sm">Failed to load paired device info</p>';
}
}
// Ping modem and show result
async function pingModem() {
const btn = document.getElementById('modemPingBtn');
const resultSpan = document.getElementById('modemPingResult');
// Show loading state
const originalText = btn.innerHTML;
btn.innerHTML = `
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Pinging...
`;
btn.disabled = true;
resultSpan.textContent = 'Testing connection...';
resultSpan.className = 'text-sm text-gray-500 dark:text-gray-400';
try {
const response = await fetch(`/api/modem-dashboard/${unitId}/ping`);
const data = await response.json();
if (data.status === 'success') {
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1"></span>Online (${data.response_time_ms}ms)`;
resultSpan.className = 'text-sm text-green-600 dark:text-green-400';
} else {
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>${data.detail || 'Offline'}`;
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
}
} catch (error) {
resultSpan.textContent = 'Error: ' + error.message;
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
}
// Restore button
btn.innerHTML = originalText;
btn.disabled = false;
}
// Load data when page loads
loadUnitData().then(() => {
loadPhotos();
loadUnitHistory();
});
// ===== Pair Device Modal Functions =====
let pairModalModems = []; // Cache loaded modems
let pairModalDeviceType = ''; // Current device type
function openPairDeviceModal(deviceType) {
const modal = document.getElementById('pairDeviceModal');
const searchInput = document.getElementById('pairModemSearch');
const hideBenchedCheckbox = document.getElementById('pairHideBenched');
if (!modal) return;
pairModalDeviceType = deviceType;
// Reset search and filter
if (searchInput) searchInput.value = '';
if (hideBenchedCheckbox) hideBenchedCheckbox.checked = false;
// Show modal
modal.classList.remove('hidden');
// Focus search input
setTimeout(() => {
if (searchInput) searchInput.focus();
}, 100);
// Load available modems
loadAvailableModems();
}
function closePairDeviceModal() {
const modal = document.getElementById('pairDeviceModal');
if (modal) modal.classList.add('hidden');
pairModalModems = [];
}
async function loadAvailableModems() {
const listContainer = document.getElementById('pairModemList');
listContainer.innerHTML = '<div class="text-center py-4"><div class="animate-spin inline-block w-6 h-6 border-2 border-seismo-orange border-t-transparent rounded-full"></div><p class="mt-2 text-sm text-gray-500">Loading modems...</p></div>';
try {
const response = await fetch('/api/roster/modems');
if (!response.ok) throw new Error('Failed to load modems');
pairModalModems = await response.json();
if (pairModalModems.length === 0) {
listContainer.innerHTML = '<p class="text-center py-4 text-gray-500">No modems found in roster</p>';
return;
}
// Render the list
renderModemList();
} catch (error) {
listContainer.innerHTML = `<p class="text-center py-4 text-red-500">Error: ${error.message}</p>`;
}
}
function filterPairModemList() {
renderModemList();
}
function renderModemList() {
const listContainer = document.getElementById('pairModemList');
const searchInput = document.getElementById('pairModemSearch');
const hideBenchedCheckbox = document.getElementById('pairHideBenched');
const searchTerm = (searchInput?.value || '').toLowerCase().trim();
const hideBenched = hideBenchedCheckbox?.checked || false;
// Filter modems
let filteredModems = pairModalModems.filter(modem => {
// Filter by benched status
if (hideBenched && !modem.deployed) return false;
// Filter by search term
if (searchTerm) {
const searchFields = [
modem.id,
modem.ip_address || '',
modem.phone_number || '',
modem.note || ''
].join(' ').toLowerCase();
if (!searchFields.includes(searchTerm)) return false;
}
return true;
});
if (filteredModems.length === 0) {
listContainer.innerHTML = '<p class="text-center py-4 text-gray-500">No modems match your criteria</p>';
return;
}
// Build modem list
let html = '';
for (const modem of filteredModems) {
const displayParts = [modem.id];
if (modem.ip_address) displayParts.push(`(${modem.ip_address})`);
if (modem.note) displayParts.push(`- ${modem.note.substring(0, 30)}${modem.note.length > 30 ? '...' : ''}`);
const deployedBadge = modem.deployed
? '<span class="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 text-xs rounded">Deployed</span>'
: '<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 text-xs rounded">Benched</span>';
const pairedBadge = modem.deployed_with_unit_id
? `<span class="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs rounded">Paired: ${modem.deployed_with_unit_id}</span>`
: '';
html += `
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0"
onclick="selectModemForPairing('${modem.id}', '${displayParts.join(' ').replace(/'/g, "\\'")}')">
<div class="flex-1">
<div class="font-medium text-gray-900 dark:text-white">
<span class="text-seismo-orange">${modem.id}</span>
${modem.ip_address ? `<span class="text-gray-400 ml-2 font-mono text-sm">${modem.ip_address}</span>` : ''}
</div>
${modem.note ? `<div class="text-sm text-gray-500 dark:text-gray-400 truncate">${modem.note}</div>` : ''}
</div>
<div class="flex gap-2 ml-3">
${deployedBadge}
${pairedBadge}
</div>
</div>
`;
}
listContainer.innerHTML = html;
}
function selectModemForPairing(modemId, displayText) {
// Update the correct picker based on device type
let pickerId = '';
if (pairModalDeviceType === 'seismograph') {
pickerId = '-detail-seismo';
} else if (pairModalDeviceType === 'sound_level_meter') {
pickerId = '-detail-slm';
}
const valueInput = document.getElementById('modem-picker-value' + pickerId);
const searchInput = document.getElementById('modem-picker-search' + pickerId);
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
if (valueInput) valueInput.value = modemId;
if (searchInput) searchInput.value = displayText;
if (clearBtn) clearBtn.classList.remove('hidden');
// Close modal
closePairDeviceModal();
}
// Clear pairing (unpair device from modem)
function clearPairing(deviceType) {
let pickerId = '';
if (deviceType === 'seismograph') {
pickerId = '-detail-seismo';
} else if (deviceType === 'sound_level_meter') {
pickerId = '-detail-slm';
}
const valueInput = document.getElementById('modem-picker-value' + pickerId);
const searchInput = document.getElementById('modem-picker-search' + pickerId);
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
if (valueInput) valueInput.value = '';
if (searchInput) searchInput.value = '';
if (clearBtn) clearBtn.classList.add('hidden');
closePairDeviceModal();
}
</script>
<!-- Pair Device Modal -->
<div id="pairDeviceModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="flex items-center justify-center min-h-screen px-4">
<!-- Backdrop -->
<div class="fixed inset-0 bg-black/50" onclick="closePairDeviceModal()"></div>
<!-- Modal Content -->
<div class="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Pair with Modem</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">Select a modem to pair with this device</p>
</div>
<button onclick="closePairDeviceModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<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>
<!-- Search and Filter -->
<div class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900/50">
<div class="flex gap-3 items-center">
<!-- Search Input -->
<div class="flex-1 relative">
<input type="text"
id="pairModemSearch"
placeholder="Search by ID, IP, or note..."
oninput="filterPairModemList()"
class="w-full px-4 py-2 pl-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange text-sm">
<svg class="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<!-- Hide Benched Toggle -->
<label class="flex items-center gap-2 cursor-pointer whitespace-nowrap">
<input type="checkbox"
id="pairHideBenched"
onchange="filterPairModemList()"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-600 dark:text-gray-400">Deployed only</span>
</label>
</div>
</div>
<!-- Modem List -->
<div id="pairModemList" class="max-h-80 overflow-y-auto">
<!-- Populated by JavaScript -->
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900">
<button onclick="closePairDeviceModal()" class="w-full px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Include Project Create Modal for inline project creation -->
{% include "partials/project_create_modal.html" %}
{% endblock %}