6 Commits

Author SHA1 Message Date
7ce0f6115d Merge pull request 'Update main to 0.5.1. See changelog.' (#18) from dev into main
## [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
2026-01-27 22:29:56 -05: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
44 changed files with 2962 additions and 135 deletions

View File

@@ -1,10 +1,48 @@
# Changelog # 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/), 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). 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 ## [0.4.3] - 2026-01-14
### Added ### Added
@@ -361,6 +399,9 @@ No database migration required for v0.4.0. All new features use existing databas
- Photo management per unit - Photo management per unit
- Automated status categorization (OK/Pending/Missing) - 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.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.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 [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. 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 ## Features
@@ -571,9 +571,13 @@ MIT
## Version ## 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) 0.4.1 — Sound Level Meter integration with full management UI for SLM units (2026-01-05)

View File

@@ -18,7 +18,7 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from backend.database import engine, Base, get_db 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.services.snapshot import emit_status_snapshot
from backend.models import IgnoredUnit from backend.models import IgnoredUnit
@@ -29,7 +29,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production") ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.4.3" VERSION = "0.5.1"
app = FastAPI( app = FastAPI(
title="Seismo Fleet Manager", title="Seismo Fleet Manager",
description="Backend API for managing seismograph fleet status", description="Backend API for managing seismograph fleet status",
@@ -92,6 +92,7 @@ app.include_router(slmm.router)
app.include_router(slm_ui.router) app.include_router(slm_ui.router)
app.include_router(slm_dashboard.router) app.include_router(slm_dashboard.router)
app.include_router(seismo_dashboard.router) app.include_router(seismo_dashboard.router)
app.include_router(modem_dashboard.router)
from backend.routers import settings from backend.routers import settings
app.include_router(settings.router) app.include_router(settings.router)
@@ -216,6 +217,12 @@ async def seismographs_page(request: Request):
return templates.TemplateResponse("seismographs.html", {"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("/projects", response_class=HTMLResponse) @app.get("/projects", response_class=HTMLResponse)
async def projects_page(request: Request): async def projects_page(request: Request):
"""Projects management and overview""" """Projects management and overview"""

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

@@ -50,6 +50,8 @@ class RosterUnit(Base):
ip_address = Column(String, nullable=True) ip_address = Column(String, nullable=True)
phone_number = Column(String, nullable=True) phone_number = Column(String, nullable=True)
hardware_model = 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) # Sound Level Meter-specific fields (nullable for seismographs and modems)
slm_host = Column(String, nullable=True) # Device IP or hostname slm_host = Column(String, nullable=True) # Device IP or hostname
@@ -137,17 +139,26 @@ class Project(Base):
""" """
Projects: top-level organization for monitoring work. Projects: top-level organization for monitoring work.
Type-aware to enable/disable features based on project_type_id. 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" __tablename__ = "projects"
id = Column(String, primary_key=True, index=True) # UUID 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) description = Column(Text, nullable=True)
project_type_id = Column(String, nullable=False) # FK to ProjectType.id project_type_id = Column(String, nullable=False) # FK to ProjectType.id
status = Column(String, default="active") # active, completed, archived status = Column(String, default="active") # active, completed, archived
# Project metadata # 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_address = Column(String, nullable=True)
site_coordinates = Column(String, nullable=True) # "lat,lon" site_coordinates = Column(String, nullable=True) # "lat,lon"
start_date = Column(Date, nullable=True) start_date = Column(Date, nullable=True)

View File

@@ -1,7 +1,12 @@
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
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.services.snapshot import emit_status_snapshot
from backend.templates_config import templates from backend.templates_config import templates
from backend.utils.timezone import utc_to_local, local_to_utc, get_user_timezone
router = APIRouter() router = APIRouter()
@@ -22,3 +27,71 @@ def dashboard_benched(request: Request):
"partials/benched_table.html", "partials/benched_table.html",
{"request": request, "units": snapshot["benched"]} {"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

@@ -11,7 +11,7 @@ Provides API endpoints for the Projects system:
from fastapi import APIRouter, Request, Depends, HTTPException, Query from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func, and_ from sqlalchemy import func, and_, or_
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from collections import OrderedDict from collections import OrderedDict
@@ -147,6 +147,107 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
}) })
# ============================================================================
# Project Search (Smart Autocomplete)
# ============================================================================
def _build_project_display(project: Project) -> str:
"""Build display string from project fields: 'xxxx-YY - Client - Name'"""
parts = []
if project.project_number:
parts.append(project.project_number)
if project.client_name:
parts.append(project.client_name)
if project.name:
parts.append(project.name)
return " - ".join(parts) if parts else project.id
@router.get("/search", response_class=HTMLResponse)
async def search_projects(
request: Request,
q: str = Query("", description="Search term"),
db: Session = Depends(get_db),
limit: int = Query(10, le=50),
):
"""
Fuzzy search across project fields for autocomplete.
Searches: project_number, client_name, name (project/site name)
Returns HTML partial for HTMX dropdown.
"""
if not q.strip():
# Return recent active projects when no search term
projects = db.query(Project).filter(
Project.status != "archived"
).order_by(Project.updated_at.desc()).limit(limit).all()
else:
search_term = f"%{q}%"
projects = db.query(Project).filter(
and_(
Project.status != "archived",
or_(
Project.project_number.ilike(search_term),
Project.client_name.ilike(search_term),
Project.name.ilike(search_term),
)
)
).order_by(Project.updated_at.desc()).limit(limit).all()
# Build display data for each project
projects_data = [{
"id": p.id,
"project_number": p.project_number,
"client_name": p.client_name,
"name": p.name,
"display": _build_project_display(p),
"status": p.status,
} for p in projects]
return templates.TemplateResponse("partials/project_search_results.html", {
"request": request,
"projects": projects_data,
"query": q,
"show_create": len(projects) == 0 and q.strip(),
})
@router.get("/search-json")
async def search_projects_json(
q: str = Query("", description="Search term"),
db: Session = Depends(get_db),
limit: int = Query(10, le=50),
):
"""
Fuzzy search across project fields - JSON response.
For programmatic/API consumption.
"""
if not q.strip():
projects = db.query(Project).filter(
Project.status != "archived"
).order_by(Project.updated_at.desc()).limit(limit).all()
else:
search_term = f"%{q}%"
projects = db.query(Project).filter(
and_(
Project.status != "archived",
or_(
Project.project_number.ilike(search_term),
Project.client_name.ilike(search_term),
Project.name.ilike(search_term),
)
)
).order_by(Project.updated_at.desc()).limit(limit).all()
return [{
"id": p.id,
"project_number": p.project_number,
"client_name": p.client_name,
"name": p.name,
"display": _build_project_display(p),
"status": p.status,
} for p in projects]
# ============================================================================ # ============================================================================
# Project CRUD # Project CRUD
# ============================================================================ # ============================================================================
@@ -161,6 +262,7 @@ async def create_project(request: Request, db: Session = Depends(get_db)):
project = Project( project = Project(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
project_number=form_data.get("project_number"), # TMI ID: xxxx-YY format
name=form_data.get("name"), name=form_data.get("name"),
description=form_data.get("description"), description=form_data.get("description"),
project_type_id=form_data.get("project_type_id"), project_type_id=form_data.get("project_type_id"),
@@ -197,6 +299,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
return { return {
"id": project.id, "id": project.id,
"project_number": project.project_number,
"name": project.name, "name": project.name,
"description": project.description, "description": project.description,
"project_type_id": project.project_type_id, "project_type_id": project.project_type_id,
@@ -493,9 +596,18 @@ async def get_project_schedules(
"actions": [], "actions": [],
} }
# Parse module_response for display
result_data = None
if schedule.module_response:
try:
result_data = json.loads(schedule.module_response)
except json.JSONDecodeError:
pass
schedules_by_date[date_key]["actions"].append({ schedules_by_date[date_key]["actions"].append({
"schedule": schedule, "schedule": schedule,
"location": location, "location": location,
"result": result_data,
}) })
return templates.TemplateResponse("partials/projects/schedule_list.html", { return templates.TemplateResponse("partials/projects/schedule_list.html", {

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 fastapi.exceptions import RequestValidationError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime, date from datetime import datetime, date
@@ -150,6 +150,8 @@ async def add_roster_unit(
ip_address: str = Form(None), ip_address: str = Form(None),
phone_number: str = Form(None), phone_number: str = Form(None),
hardware_model: 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 # Sound Level Meter-specific fields
slm_host: str = Form(None), slm_host: str = Form(None),
slm_tcp_port: str = Form(None), slm_tcp_port: str = Form(None),
@@ -209,6 +211,7 @@ async def add_roster_unit(
ip_address=ip_address if ip_address else None, ip_address=ip_address if ip_address else None,
phone_number=phone_number if phone_number else None, phone_number=phone_number if phone_number else None,
hardware_model=hardware_model if hardware_model 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 # Sound Level Meter-specific fields
slm_host=slm_host if slm_host else None, slm_host=slm_host if slm_host else None,
slm_tcp_port=slm_tcp_port_int, slm_tcp_port=slm_tcp_port_int,
@@ -219,6 +222,23 @@ async def add_roster_unit(
slm_time_weighting=slm_time_weighting if slm_time_weighting else None, slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
slm_measurement_range=slm_measurement_range if slm_measurement_range 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.add(unit)
db.commit() db.commit()
@@ -259,6 +279,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}") @router.get("/{unit_id}")
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)): def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"""Get a single roster unit by ID""" """Get a single roster unit by ID"""
@@ -283,6 +442,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"ip_address": unit.ip_address or "", "ip_address": unit.ip_address or "",
"phone_number": unit.phone_number or "", "phone_number": unit.phone_number or "",
"hardware_model": unit.hardware_model 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_host": unit.slm_host or "",
"slm_tcp_port": unit.slm_tcp_port or "", "slm_tcp_port": unit.slm_tcp_port or "",
"slm_ftp_port": unit.slm_ftp_port or "", "slm_ftp_port": unit.slm_ftp_port or "",
@@ -314,6 +475,8 @@ def edit_roster_unit(
ip_address: str = Form(None), ip_address: str = Form(None),
phone_number: str = Form(None), phone_number: str = Form(None),
hardware_model: 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 # Sound Level Meter-specific fields
slm_host: str = Form(None), slm_host: str = Form(None),
slm_tcp_port: str = Form(None), slm_tcp_port: str = Form(None),
@@ -323,6 +486,14 @@ def edit_roster_unit(
slm_frequency_weighting: str = Form(None), slm_frequency_weighting: str = Form(None),
slm_time_weighting: str = Form(None), slm_time_weighting: str = Form(None),
slm_measurement_range: 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) db: Session = Depends(get_db)
): ):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
@@ -374,10 +545,29 @@ def edit_roster_unit(
unit.next_calibration_due = next_cal_date unit.next_calibration_due = next_cal_date
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None 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 # Modem-specific fields
unit.ip_address = ip_address if ip_address else None unit.ip_address = ip_address if ip_address else None
unit.phone_number = phone_number if phone_number else None unit.phone_number = phone_number if phone_number else None
unit.hardware_model = hardware_model if hardware_model 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 # Sound Level Meter-specific fields
unit.slm_host = slm_host if slm_host else None unit.slm_host = slm_host if slm_host else None
@@ -403,8 +593,79 @@ def edit_roster_unit(
old_status_text = "retired" if old_retired else "active" old_status_text = "retired" if old_retired else "active"
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual") 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() db.commit()
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
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}") @router.post("/set-deployed/{unit_id}")
@@ -531,6 +792,37 @@ def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
return {"message": "Updated", "id": unit_id, "note": note} 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") @router.post("/import-csv")
async def import_csv( async def import_csv(
file: UploadFile = File(...), file: UploadFile = File(...),
@@ -541,13 +833,40 @@ async def import_csv(
Import roster units from CSV file. Import roster units from CSV file.
Expected CSV columns (unit_id is required, others are optional): Expected CSV columns (unit_id is required, others are optional):
- unit_id: Unique identifier for the unit
- unit_type: Type of unit (default: "series3") Common fields (all device types):
- deployed: Boolean for deployment status (default: False) - unit_id: Unique identifier for the unit (REQUIRED)
- retired: Boolean for retirement status (default: False) - 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 - note: Notes about the unit
- project_id: Project identifier - project_id: Project identifier
- location: Location description - 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: Args:
file: CSV file upload file: CSV file upload
@@ -560,6 +879,46 @@ async def import_csv(
# Read file content # Read file content
contents = await file.read() contents = await file.read()
csv_text = contents.decode('utf-8') 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)) csv_reader = csv.DictReader(io.StringIO(csv_text))
results = { results = {
@@ -580,6 +939,9 @@ async def import_csv(
}) })
continue continue
# Determine device type
device_type = _get_csv_value(row, 'device_type', 'seismograph')
# Check if unit exists # Check if unit exists
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
@@ -588,31 +950,90 @@ async def import_csv(
results["skipped"].append(unit_id) results["skipped"].append(unit_id)
continue continue
# Update existing unit # Update existing unit - common fields
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3') existing_unit.device_type = device_type
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed existing_unit.unit_type = _get_csv_value(row, 'unit_type', existing_unit.unit_type or 'series3')
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired existing_unit.deployed = _parse_bool(row.get('deployed', '')) if row.get('deployed') else existing_unit.deployed
existing_unit.note = row.get('note', existing_unit.note or '') existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
existing_unit.project_id = row.get('project_id', existing_unit.project_id) existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
existing_unit.location = row.get('location', existing_unit.location) existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
existing_unit.address = row.get('address', existing_unit.address) existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates) 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() 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) results["updated"].append(unit_id)
else: else:
# Create new unit # Create new unit with all fields
new_unit = RosterUnit( new_unit = RosterUnit(
id=unit_id, id=unit_id,
unit_type=row.get('unit_type', 'series3'), device_type=device_type,
deployed=row.get('deployed', '').lower() in ('true', '1', 'yes'), unit_type=_get_csv_value(row, 'unit_type', 'series3'),
retired=row.get('retired', '').lower() in ('true', '1', 'yes'), deployed=_parse_bool(row.get('deployed', '')),
note=row.get('note', ''), retired=_parse_bool(row.get('retired', '')),
project_id=row.get('project_id'), note=_get_csv_value(row, 'note', ''),
location=row.get('location'), project_id=_get_csv_value(row, 'project_id'),
address=row.get('address'), location=_get_csv_value(row, 'location'),
coordinates=row.get('coordinates'), address=_get_csv_value(row, 'address'),
last_updated=datetime.utcnow() 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) db.add(new_unit)
results["added"].append(unit_id) results["added"].append(unit_id)

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

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

View File

@@ -1,6 +1,23 @@
unit_id,unit_type,deployed,retired,note,project_id,location 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
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 # SEISMOGRAPHS (device_type=seismograph)
BE9012,series3,false,false,In maintenance,PROJ-002,Workshop # ============================================
BE3456,series3,true,false,,PROJ-003,New York NY 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,,,,,,,,,,,
BE7890,series3,false,true,Decommissioned 2024,,Storage 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 --> <!-- PWA Manifest -->
<link rel="manifest" href="/static/manifest.json"> <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="theme-color" content="#f48b1c">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
@@ -68,7 +71,7 @@
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </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 --> <!-- Offline Indicator -->
<div id="offlineIndicator" class="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"> <aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
<!-- Logo --> <!-- Logo -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700"> <div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange"> <a href="/" class="block">
Seismo<br> <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">
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span> <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">
</h1> </a>
<div class="flex items-center justify-between mt-2"> <div class="flex items-center justify-between mt-2">
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p> <p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
{% if environment == 'development' %} {% if environment == 'development' %}
@@ -127,6 +130,13 @@
Sound Level Meters Sound Level Meters
</a> </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="/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 %}"> <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"> <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> <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 +384,10 @@
</script> </script>
<!-- Offline Database --> <!-- 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 --> <!-- 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 %} {% block extra_scripts %}{% endblock %}
</body> </body>

View File

@@ -27,10 +27,10 @@
hx-swap="none" hx-swap="none"
hx-on::after-request="updateDashboard(event)"> 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 --> <!-- 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')"> <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> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -118,7 +118,7 @@
</div> </div>
<!-- Recent Alerts Card --> <!-- 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')"> <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> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -138,7 +138,7 @@
</div> </div>
<!-- Recently Called In Units Card --> <!-- 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')"> <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> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -162,10 +162,33 @@
</div> </div>
</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> </div>
<!-- Fleet Map --> <!-- 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')"> <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> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -181,7 +204,7 @@
</div> </div>
<!-- Recent Photos Section --> <!-- 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')"> <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> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -201,7 +224,7 @@
</div> </div>
<!-- Fleet Status Section with Tabs --> <!-- 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')"> <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> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
@@ -316,7 +339,7 @@ function toggleCard(cardName) {
// Restore card states from localStorage on page load // Restore card states from localStorage on page load
function restoreCardStates() { function restoreCardStates() {
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}'); 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 => { cardNames.forEach(cardName => {
const content = document.getElementById(`${cardName}-content`); const content = document.getElementById(`${cardName}-content`);

108
templates/modems.html Normal file
View File

@@ -0,0 +1,108 @@
{% 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">
<div class="animate-pulse space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
</div>
</div>
</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

@@ -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

@@ -0,0 +1,89 @@
<!-- Modem List -->
{% if modems %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for modem in modems %}
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<a href="/unit/{{ modem.id }}" class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange">
{{ modem.id }}
</a>
{% if modem.hardware_model %}
<span class="text-xs text-gray-500 dark:text-gray-400">{{ modem.hardware_model }}</span>
{% endif %}
</div>
{% if modem.ip_address %}
<p class="text-sm text-gray-600 dark:text-gray-400 font-mono mt-1">{{ modem.ip_address }}</p>
{% else %}
<p class="text-sm text-red-500 mt-1">No IP configured</p>
{% endif %}
{% if modem.phone_number %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ modem.phone_number }}</p>
{% endif %}
</div>
<!-- Status Badge -->
{% if modem.status == "retired" %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
{% elif modem.status == "benched" %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
{% elif modem.status == "in_use" %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">In Use</span>
{% elif modem.status == "spare" %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Spare</span>
{% endif %}
</div>
<!-- Paired Device -->
{% if modem.paired_device %}
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
<p class="text-xs text-gray-500 dark:text-gray-400">Paired with:</p>
<a href="/unit/{{ modem.paired_device.id }}" class="text-sm text-seismo-orange hover:underline">
{{ modem.paired_device.id }}
<span class="text-gray-500">({{ modem.paired_device.device_type }})</span>
</a>
</div>
{% endif %}
<!-- Location if available -->
{% if modem.location or modem.project_id %}
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{% if modem.project_id %}
<span class="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">{{ modem.project_id }}</span>
{% endif %}
{% if modem.location %}
{{ modem.location }}
{% endif %}
</div>
{% endif %}
<!-- Quick Actions -->
<div class="mt-3 flex gap-2">
<button onclick="pingModem('{{ modem.id }}')"
id="ping-btn-{{ modem.id }}"
class="text-xs px-3 py-1.5 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-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 rounded transition-colors">
Details
</a>
</div>
<!-- Ping Result (hidden by default) -->
<div id="ping-result-{{ modem.id }}" class="mt-2 text-xs hidden"></div>
</div>
{% endfor %}
</div>
{% 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>
<p class="text-sm mt-1">Add modems from the <a href="/roster" class="text-seismo-orange hover:underline">Fleet Roster</a></p>
</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

@@ -108,6 +108,53 @@
<span class="ml-1 text-red-700 dark:text-red-300">{{ item.schedule.error_message }}</span> <span class="ml-1 text-red-700 dark:text-red-300">{{ item.schedule.error_message }}</span>
</div> </div>
{% endif %} {% endif %}
<!-- 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> </div>
<!-- Actions --> <!-- Actions -->

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

@@ -162,9 +162,9 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6"> <div class="flex items-center justify-between mb-6">
<div> <div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Upcoming Actions</h2> <h2 id="schedules-title" class="text-xl font-semibold text-gray-900 dark:text-white">Upcoming Actions</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p id="schedules-subtitle" class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Individual scheduled start/stop/download actions Scheduled start/stop/download actions
</p> </p>
</div> </div>
<select id="schedules-filter" onchange="filterScheduledActions()" <select id="schedules-filter" onchange="filterScheduledActions()"
@@ -963,6 +963,21 @@ function filterScheduledActions() {
? `/api/projects/${projectId}/schedules` ? `/api/projects/${projectId}/schedules`
: `/api/projects/${projectId}/schedules?status=${filter}`; : `/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, { htmx.ajax('GET', url, {
target: '#project-schedules', target: '#project-schedules',
swap: 'innerHTML' swap: 'innerHTML'

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"> 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>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
<input type="text" name="project_id" {% include "partials/project_picker.html" with context %}
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">
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
@@ -159,8 +157,8 @@
</div> </div>
<div id="modemPairingField" class="hidden"> <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> <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" {% set picker_id = "-add-seismo" %}
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"> {% 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> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
</div> </div>
</div> </div>
@@ -183,6 +181,21 @@
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV" <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"> 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>
<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> </div>
<!-- Sound Level Meter-specific fields --> <!-- 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"> 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>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
<input type="text" name="project_id" id="editProjectId" {% set picker_id = "-edit" %}
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"> {% include "partials/project_picker.html" with context %}
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
@@ -327,8 +340,8 @@
</div> </div>
<div id="editModemPairingField" class="hidden"> <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> <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" {% set picker_id = "-edit-seismo" %}
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"> {% include "partials/modem_picker.html" with context %}
</div> </div>
</div> </div>
@@ -350,6 +363,21 @@
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV" <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"> 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>
<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> </div>
<!-- Sound Level Meter-specific fields --> <!-- Sound Level Meter-specific fields -->
@@ -419,6 +447,55 @@
<textarea name="note" id="editNote" rows="3" <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> 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> </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"> <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"> <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 Save Changes
@@ -650,11 +727,7 @@
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) { document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) { if (event.detail.successful) {
closeAddUnitModal(); closeAddUnitModal();
// Trigger roster refresh for current active tab refreshDeviceList();
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
// Show success message // Show success message
alert('Unit added successfully!'); alert('Unit added successfully!');
} else { } else {
@@ -692,6 +765,33 @@
function closeEditUnitModal() { function closeEditUnitModal() {
document.getElementById('editUnitModal').classList.add('hidden'); document.getElementById('editUnitModal').classList.add('hidden');
document.getElementById('editUnitForm').reset(); 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 // Toggle device-specific fields in edit modal
@@ -753,7 +853,23 @@
document.getElementById('editUnitId').value = unit.id; document.getElementById('editUnitId').value = unit.id;
document.getElementById('editDeviceTypeSelect').value = unit.device_type; document.getElementById('editDeviceTypeSelect').value = unit.device_type;
document.getElementById('editUnitType').value = unit.unit_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('editAddress').value = unit.address;
document.getElementById('editCoordinates').value = unit.coordinates; document.getElementById('editCoordinates').value = unit.coordinates;
document.getElementById('editNote').value = unit.note; document.getElementById('editNote').value = unit.note;
@@ -765,12 +881,62 @@
// Seismograph fields // Seismograph fields
document.getElementById('editLastCalibrated').value = unit.last_calibrated; document.getElementById('editLastCalibrated').value = unit.last_calibrated;
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due; 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 // Modem fields
document.getElementById('editIpAddress').value = unit.ip_address; document.getElementById('editIpAddress').value = unit.ip_address;
document.getElementById('editPhoneNumber').value = unit.phone_number; document.getElementById('editPhoneNumber').value = unit.phone_number;
document.getElementById('editHardwareModel').value = unit.hardware_model; 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 // SLM fields
document.getElementById('editSlmModel').value = unit.slm_model || ''; document.getElementById('editSlmModel').value = unit.slm_model || '';
@@ -781,6 +947,35 @@
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || ''; document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_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 // Store unit ID for form submission
document.getElementById('editUnitForm').dataset.unitId = unitId; document.getElementById('editUnitForm').dataset.unitId = unitId;
@@ -814,11 +1009,7 @@
if (response.ok) { if (response.ok) {
closeEditUnitModal(); closeEditUnitModal();
// Trigger roster refresh for current active tab refreshDeviceList();
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
alert('Unit updated successfully!'); alert('Unit updated successfully!');
} else { } else {
const result = await response.json(); const result = await response.json();
@@ -846,11 +1037,7 @@
}); });
if (response.ok) { if (response.ok) {
// Trigger roster refresh for current active tab refreshDeviceList();
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`); alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
} else { } else {
const result = await response.json(); const result = await response.json();
@@ -878,11 +1065,7 @@
}); });
if (response.ok) { if (response.ok) {
// Trigger roster refresh for current active tab refreshDeviceList();
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
alert(`Unit ${unitId} moved to ignore list`); alert(`Unit ${unitId} moved to ignore list`);
} else { } else {
const result = await response.json(); const result = await response.json();
@@ -905,11 +1088,7 @@
}); });
if (response.ok) { if (response.ok) {
// Trigger roster refresh for current active tab refreshDeviceList();
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
alert(`Unit ${unitId} deleted successfully`); alert(`Unit ${unitId} deleted successfully`);
} else { } else {
const result = await response.json(); const result = await response.json();
@@ -948,11 +1127,7 @@
`; `;
resultDiv.classList.remove('hidden'); resultDiv.classList.remove('hidden');
// Trigger roster refresh for current active tab refreshDeviceList();
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
// Close modal after 2 seconds // Close modal after 2 seconds
setTimeout(() => closeImportModal(), 2000); setTimeout(() => closeImportModal(), 2000);
@@ -968,35 +1143,26 @@
} }
}); });
// Handle roster tab switching with auto-refresh // Refresh device list (applies current client-side filters after load)
let currentRosterEndpoint = '/partials/roster-deployed'; // Default to deployed tab function refreshDeviceList() {
htmx.ajax('GET', '/partials/devices-all', {
document.addEventListener('DOMContentLoaded', function() { target: '#device-content',
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',
swap: 'innerHTML' swap: 'innerHTML'
}).then(() => {
// Re-apply filters after content loads
setTimeout(filterDevices, 100);
}); });
} }
}, 10000); // 10 seconds
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 && !document.querySelector('.modal:not(.hidden)')) {
// Only auto-refresh if no modal is open
refreshDeviceList();
}
}, 30000);
}); });
// Un-ignore Unit (remove from ignored list) // Un-ignore Unit (remove from ignored list)
@@ -1345,4 +1511,7 @@
} }
</style> </style>
<!-- Include Project Create Modal for inline project creation -->
{% include "partials/project_create_modal.html" %}
{% endblock %} {% endblock %}

View File

@@ -121,8 +121,13 @@
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p> <p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
</div> </div>
<div> <div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project ID</label> <label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project</label>
<p id="viewProjectId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p> <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>
<div> <div>
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label> <label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
@@ -172,6 +177,48 @@
</div> </div>
</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 --> <!-- Notes -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6"> <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> <label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
@@ -251,11 +298,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"> 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>
<!-- Project ID --> <!-- Project -->
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
<input type="text" name="project_id" id="projectId" {% set picker_id = "-detail" %}
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"> {% include "partials/project_picker.html" with context %}
</div> </div>
<!-- Address --> <!-- Address -->
@@ -415,6 +462,26 @@ let currentSnapshot = null;
let unitMap = null; let unitMap = null;
let mapMarker = 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;
}
// Load unit data on page load // Load unit data on page load
async function loadUnitData() { async function loadUnitData() {
try { try {
@@ -536,7 +603,31 @@ function populateViewMode() {
// Basic info // Basic info
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--'; document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
document.getElementById('viewUnitType').textContent = currentUnit.unit_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('viewAddress').textContent = currentUnit.address || '--';
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--'; document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
@@ -557,9 +648,15 @@ function populateViewMode() {
if (currentUnit.device_type === 'modem') { if (currentUnit.device_type === 'modem') {
document.getElementById('viewSeismographFields').classList.add('hidden'); document.getElementById('viewSeismographFields').classList.add('hidden');
document.getElementById('viewModemFields').classList.remove('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 { } else {
document.getElementById('viewSeismographFields').classList.remove('hidden'); document.getElementById('viewSeismographFields').classList.remove('hidden');
document.getElementById('viewModemFields').classList.add('hidden'); document.getElementById('viewModemFields').classList.add('hidden');
document.getElementById('viewPairedDeviceSection').classList.add('hidden');
document.getElementById('viewConnectivitySection').classList.add('hidden');
} }
} }
@@ -567,7 +664,22 @@ function populateViewMode() {
function populateEditForm() { function populateEditForm() {
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph'; document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
document.getElementById('unitType').value = currentUnit.unit_type || ''; 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('address').value = currentUnit.address || '';
document.getElementById('coordinates').value = currentUnit.coordinates || ''; document.getElementById('coordinates').value = currentUnit.coordinates || '';
document.getElementById('deployed').checked = currentUnit.deployed; document.getElementById('deployed').checked = currentUnit.deployed;
@@ -999,10 +1111,66 @@ 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 // Load data when page loads
loadUnitData().then(() => { loadUnitData().then(() => {
loadPhotos(); loadPhotos();
loadUnitHistory(); loadUnitHistory();
}); });
</script> </script>
<!-- Include Project Create Modal for inline project creation -->
{% include "partials/project_create_modal.html" %}
{% endblock %} {% endblock %}