Compare commits
3 Commits
38c600aca3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ce0f6115d | |||
|
|
6492fdff82 | ||
|
|
44d7841852 |
25
CHANGELOG.md
25
CHANGELOG.md
@@ -1,10 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Seismo Fleet Manager will be documented in this file.
|
||||
All notable changes to Terra-View will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.5.1] - 2026-01-27
|
||||
|
||||
### Added
|
||||
- **Dashboard Schedule View**: Today's scheduled actions now display directly on the main dashboard
|
||||
- New "Today's Actions" panel showing upcoming and past scheduled events
|
||||
- Schedule list partial for project-specific schedule views
|
||||
- API endpoint for fetching today's schedule data
|
||||
- **New Branding Assets**: Complete logo rework for Terra-View
|
||||
- New Terra-View logos for light and dark themes
|
||||
- Retina-ready (@2x) logo variants
|
||||
- Updated favicons (16px and 32px)
|
||||
- Refreshed PWA icons (72px through 512px)
|
||||
|
||||
### Changed
|
||||
- **Dashboard Layout**: Reorganized to include schedule information panel
|
||||
- **Base Template**: Updated to use new Terra-View logos with theme-aware switching
|
||||
|
||||
## [0.5.0] - 2026-01-23
|
||||
|
||||
_Note: This version was not formally released; changes were included in v0.5.1._
|
||||
|
||||
## [0.4.4] - 2026-01-23
|
||||
|
||||
### Added
|
||||
@@ -378,6 +399,8 @@ No database migration required for v0.4.0. All new features use existing databas
|
||||
- Photo management per unit
|
||||
- Automated status categorization (OK/Pending/Missing)
|
||||
|
||||
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
|
||||
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
|
||||
[0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4
|
||||
[0.4.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.2...v0.4.3
|
||||
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Seismo Fleet Manager v0.4.4
|
||||
# Terra-View v0.5.1
|
||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||
|
||||
## Features
|
||||
@@ -571,9 +571,11 @@ MIT
|
||||
|
||||
## Version
|
||||
|
||||
**Current: 0.4.4** — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
|
||||
**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.3 — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
|
||||
Previous: 0.4.4 — Recurring 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)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backend.database import engine, Base, get_db
|
||||
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler
|
||||
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
from backend.models import IgnoredUnit
|
||||
|
||||
@@ -29,7 +29,7 @@ Base.metadata.create_all(bind=engine)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Initialize FastAPI app
|
||||
VERSION = "0.4.3"
|
||||
VERSION = "0.5.1"
|
||||
app = FastAPI(
|
||||
title="Seismo Fleet Manager",
|
||||
description="Backend API for managing seismograph fleet status",
|
||||
@@ -92,6 +92,7 @@ app.include_router(slmm.router)
|
||||
app.include_router(slm_ui.router)
|
||||
app.include_router(slm_dashboard.router)
|
||||
app.include_router(seismo_dashboard.router)
|
||||
app.include_router(modem_dashboard.router)
|
||||
|
||||
from backend.routers import settings
|
||||
app.include_router(settings.router)
|
||||
@@ -216,6 +217,12 @@ async def seismographs_page(request: Request):
|
||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/modems", response_class=HTMLResponse)
|
||||
async def modems_page(request: Request):
|
||||
"""Field modems management dashboard"""
|
||||
return templates.TemplateResponse("modems.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/projects", response_class=HTMLResponse)
|
||||
async def projects_page(request: Request):
|
||||
"""Projects management and overview"""
|
||||
|
||||
84
backend/migrate_add_deployment_type.py
Normal file
84
backend/migrate_add_deployment_type.py
Normal 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()
|
||||
80
backend/migrate_add_project_number.py
Normal file
80
backend/migrate_add_project_number.py
Normal 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()
|
||||
@@ -50,6 +50,8 @@ class RosterUnit(Base):
|
||||
ip_address = Column(String, nullable=True)
|
||||
phone_number = Column(String, nullable=True)
|
||||
hardware_model = Column(String, nullable=True)
|
||||
deployment_type = Column(String, nullable=True) # "seismograph" | "slm" - what type of device this modem is deployed with
|
||||
deployed_with_unit_id = Column(String, nullable=True) # ID of seismograph/SLM this modem is deployed with
|
||||
|
||||
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
||||
slm_host = Column(String, nullable=True) # Device IP or hostname
|
||||
@@ -137,17 +139,26 @@ class Project(Base):
|
||||
"""
|
||||
Projects: top-level organization for monitoring work.
|
||||
Type-aware to enable/disable features based on project_type_id.
|
||||
|
||||
Project naming convention:
|
||||
- project_number: TMI internal ID format xxxx-YY (e.g., "2567-23")
|
||||
- client_name: Client/contractor name (e.g., "PJ Dick")
|
||||
- name: Project/site name (e.g., "RKM Hall", "CMU Campus")
|
||||
|
||||
Display format: "2567-23 - PJ Dick - RKM Hall"
|
||||
Users can search by any of these fields.
|
||||
"""
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
name = Column(String, nullable=False, unique=True)
|
||||
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
|
||||
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
||||
description = Column(Text, nullable=True)
|
||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
||||
status = Column(String, default="active") # active, completed, archived
|
||||
|
||||
# Project metadata
|
||||
client_name = Column(String, nullable=True)
|
||||
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||
site_address = Column(String, nullable=True)
|
||||
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
||||
start_date = Column(Date, nullable=True)
|
||||
|
||||
286
backend/routers/modem_dashboard.py
Normal file
286
backend/routers/modem_dashboard.py
Normal 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.
|
||||
}
|
||||
@@ -11,7 +11,7 @@ Provides API endpoints for the Projects system:
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_
|
||||
from sqlalchemy import func, and_, or_
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
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
|
||||
# ============================================================================
|
||||
@@ -161,6 +262,7 @@ async def create_project(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
project_number=form_data.get("project_number"), # TMI ID: xxxx-YY format
|
||||
name=form_data.get("name"),
|
||||
description=form_data.get("description"),
|
||||
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 {
|
||||
"id": project.id,
|
||||
"project_number": project.project_number,
|
||||
"name": project.name,
|
||||
"description": project.description,
|
||||
"project_type_id": project.project_type_id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request, Query
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, date
|
||||
@@ -150,6 +150,8 @@ async def add_roster_unit(
|
||||
ip_address: str = Form(None),
|
||||
phone_number: str = Form(None),
|
||||
hardware_model: str = Form(None),
|
||||
deployment_type: str = Form(None), # "seismograph" | "slm" - what device type modem is deployed with
|
||||
deployed_with_unit_id: str = Form(None), # ID of seismograph/SLM this modem is deployed with
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host: str = Form(None),
|
||||
slm_tcp_port: str = Form(None),
|
||||
@@ -209,6 +211,7 @@ async def add_roster_unit(
|
||||
ip_address=ip_address if ip_address else None,
|
||||
phone_number=phone_number if phone_number else None,
|
||||
hardware_model=hardware_model if hardware_model else None,
|
||||
deployment_type=deployment_type if deployment_type else None,
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host=slm_host if slm_host else None,
|
||||
slm_tcp_port=slm_tcp_port_int,
|
||||
@@ -219,6 +222,23 @@ async def add_roster_unit(
|
||||
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
||||
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||
)
|
||||
|
||||
# Auto-fill location data from modem if pairing and fields are empty
|
||||
if deployed_with_modem_id:
|
||||
modem = db.query(RosterUnit).filter(
|
||||
RosterUnit.id == deployed_with_modem_id,
|
||||
RosterUnit.device_type == "modem"
|
||||
).first()
|
||||
if modem:
|
||||
if not unit.location and modem.location:
|
||||
unit.location = modem.location
|
||||
if not unit.address and modem.address:
|
||||
unit.address = modem.address
|
||||
if not unit.coordinates and modem.coordinates:
|
||||
unit.coordinates = modem.coordinates
|
||||
if not unit.project_id and modem.project_id:
|
||||
unit.project_id = modem.project_id
|
||||
|
||||
db.add(unit)
|
||||
db.commit()
|
||||
|
||||
@@ -259,6 +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}")
|
||||
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""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 "",
|
||||
"phone_number": unit.phone_number or "",
|
||||
"hardware_model": unit.hardware_model or "",
|
||||
"deployment_type": unit.deployment_type or "",
|
||||
"deployed_with_unit_id": unit.deployed_with_unit_id or "",
|
||||
"slm_host": unit.slm_host or "",
|
||||
"slm_tcp_port": unit.slm_tcp_port or "",
|
||||
"slm_ftp_port": unit.slm_ftp_port or "",
|
||||
@@ -314,6 +475,8 @@ def edit_roster_unit(
|
||||
ip_address: str = Form(None),
|
||||
phone_number: str = Form(None),
|
||||
hardware_model: str = Form(None),
|
||||
deployment_type: str = Form(None),
|
||||
deployed_with_unit_id: str = Form(None),
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host: str = Form(None),
|
||||
slm_tcp_port: str = Form(None),
|
||||
@@ -323,6 +486,14 @@ def edit_roster_unit(
|
||||
slm_frequency_weighting: str = Form(None),
|
||||
slm_time_weighting: str = Form(None),
|
||||
slm_measurement_range: str = Form(None),
|
||||
# Cascade options - sync fields to paired device
|
||||
cascade_to_unit_id: str = Form(None),
|
||||
cascade_deployed: str = Form(None),
|
||||
cascade_retired: str = Form(None),
|
||||
cascade_project: str = Form(None),
|
||||
cascade_location: str = Form(None),
|
||||
cascade_coordinates: str = Form(None),
|
||||
cascade_note: str = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
@@ -374,10 +545,29 @@ def edit_roster_unit(
|
||||
unit.next_calibration_due = next_cal_date
|
||||
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||
|
||||
# Auto-fill location data from modem if pairing and fields are empty
|
||||
if deployed_with_modem_id:
|
||||
modem = db.query(RosterUnit).filter(
|
||||
RosterUnit.id == deployed_with_modem_id,
|
||||
RosterUnit.device_type == "modem"
|
||||
).first()
|
||||
if modem:
|
||||
# Only fill if the device field is empty
|
||||
if not unit.location and modem.location:
|
||||
unit.location = modem.location
|
||||
if not unit.address and modem.address:
|
||||
unit.address = modem.address
|
||||
if not unit.coordinates and modem.coordinates:
|
||||
unit.coordinates = modem.coordinates
|
||||
if not unit.project_id and modem.project_id:
|
||||
unit.project_id = modem.project_id
|
||||
|
||||
# Modem-specific fields
|
||||
unit.ip_address = ip_address if ip_address else None
|
||||
unit.phone_number = phone_number if phone_number else None
|
||||
unit.hardware_model = hardware_model if hardware_model else None
|
||||
unit.deployment_type = deployment_type if deployment_type else None
|
||||
unit.deployed_with_unit_id = deployed_with_unit_id if deployed_with_unit_id else None
|
||||
|
||||
# Sound Level Meter-specific fields
|
||||
unit.slm_host = slm_host if slm_host else None
|
||||
@@ -403,8 +593,79 @@ def edit_roster_unit(
|
||||
old_status_text = "retired" if old_retired else "active"
|
||||
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
||||
|
||||
# Handle cascade to paired device
|
||||
cascaded_unit_id = None
|
||||
if cascade_to_unit_id and cascade_to_unit_id.strip():
|
||||
paired_unit = db.query(RosterUnit).filter(RosterUnit.id == cascade_to_unit_id).first()
|
||||
if paired_unit:
|
||||
cascaded_unit_id = paired_unit.id
|
||||
|
||||
# Cascade deployed status
|
||||
if cascade_deployed in ['true', 'True', '1', 'yes']:
|
||||
old_paired_deployed = paired_unit.deployed
|
||||
paired_unit.deployed = deployed_bool
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_deployed != deployed_bool:
|
||||
status_text = "deployed" if deployed_bool else "benched"
|
||||
old_status_text = "deployed" if old_paired_deployed else "benched"
|
||||
record_history(db, paired_unit.id, "deployed_change", "deployed",
|
||||
old_status_text, status_text, f"cascade from {unit_id}")
|
||||
|
||||
# Cascade retired status
|
||||
if cascade_retired in ['true', 'True', '1', 'yes']:
|
||||
old_paired_retired = paired_unit.retired
|
||||
paired_unit.retired = retired_bool
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_retired != retired_bool:
|
||||
status_text = "retired" if retired_bool else "active"
|
||||
old_status_text = "retired" if old_paired_retired else "active"
|
||||
record_history(db, paired_unit.id, "retired_change", "retired",
|
||||
old_status_text, status_text, f"cascade from {unit_id}")
|
||||
|
||||
# Cascade project
|
||||
if cascade_project in ['true', 'True', '1', 'yes']:
|
||||
old_paired_project = paired_unit.project_id
|
||||
paired_unit.project_id = project_id
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_project != project_id:
|
||||
record_history(db, paired_unit.id, "project_change", "project_id",
|
||||
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
|
||||
|
||||
# Cascade address/location
|
||||
if cascade_location in ['true', 'True', '1', 'yes']:
|
||||
old_paired_address = paired_unit.address
|
||||
old_paired_location = paired_unit.location
|
||||
paired_unit.address = address
|
||||
paired_unit.location = location
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_address != address:
|
||||
record_history(db, paired_unit.id, "address_change", "address",
|
||||
old_paired_address or "", address or "", f"cascade from {unit_id}")
|
||||
|
||||
# Cascade coordinates
|
||||
if cascade_coordinates in ['true', 'True', '1', 'yes']:
|
||||
old_paired_coords = paired_unit.coordinates
|
||||
paired_unit.coordinates = coordinates
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_coords != coordinates:
|
||||
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
|
||||
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
|
||||
|
||||
# Cascade note
|
||||
if cascade_note in ['true', 'True', '1', 'yes']:
|
||||
old_paired_note = paired_unit.note
|
||||
paired_unit.note = note
|
||||
paired_unit.last_updated = datetime.utcnow()
|
||||
if old_paired_note != note:
|
||||
record_history(db, paired_unit.id, "note_change", "note",
|
||||
old_paired_note or "", note or "", f"cascade from {unit_id}")
|
||||
|
||||
db.commit()
|
||||
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||
|
||||
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}")
|
||||
@@ -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}
|
||||
|
||||
|
||||
def _parse_bool(value: str) -> bool:
|
||||
"""Parse boolean from CSV string value."""
|
||||
return value.lower() in ('true', '1', 'yes') if value else False
|
||||
|
||||
|
||||
def _parse_int(value: str) -> int | None:
|
||||
"""Parse integer from CSV string value, return None if empty or invalid."""
|
||||
if not value or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return int(value.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date | None:
|
||||
"""Parse date from CSV string value (YYYY-MM-DD format)."""
|
||||
if not value or not value.strip():
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(value.strip(), '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_csv_value(row: dict, key: str, default=None):
|
||||
"""Get value from CSV row, return default if empty."""
|
||||
value = row.get(key, '').strip() if row.get(key) else ''
|
||||
return value if value else default
|
||||
|
||||
|
||||
@router.post("/import-csv")
|
||||
async def import_csv(
|
||||
file: UploadFile = File(...),
|
||||
@@ -541,13 +833,40 @@ async def import_csv(
|
||||
Import roster units from CSV file.
|
||||
|
||||
Expected CSV columns (unit_id is required, others are optional):
|
||||
- unit_id: Unique identifier for the unit
|
||||
- unit_type: Type of unit (default: "series3")
|
||||
- deployed: Boolean for deployment status (default: False)
|
||||
- retired: Boolean for retirement status (default: False)
|
||||
|
||||
Common fields (all device types):
|
||||
- unit_id: Unique identifier for the unit (REQUIRED)
|
||||
- device_type: "seismograph", "modem", or "slm" (default: "seismograph")
|
||||
- unit_type: Sub-type (e.g., "series3", "series4" for seismographs)
|
||||
- deployed: Boolean (true/false/yes/no/1/0)
|
||||
- retired: Boolean
|
||||
- note: Notes about the unit
|
||||
- project_id: Project identifier
|
||||
- location: Location description
|
||||
- address: Street address
|
||||
- coordinates: GPS coordinates (lat;lon or lat,lon)
|
||||
|
||||
Seismograph-specific:
|
||||
- last_calibrated: Date (YYYY-MM-DD)
|
||||
- next_calibration_due: Date (YYYY-MM-DD)
|
||||
- deployed_with_modem_id: ID of paired modem
|
||||
|
||||
Modem-specific:
|
||||
- ip_address: Device IP address
|
||||
- phone_number: SIM card phone number
|
||||
- hardware_model: Hardware model (e.g., IBR900, RV55)
|
||||
|
||||
SLM-specific:
|
||||
- slm_host: Device IP or hostname
|
||||
- slm_tcp_port: TCP control port (default 2255)
|
||||
- slm_ftp_port: FTP port (default 21)
|
||||
- slm_model: Device model (NL-43, NL-53)
|
||||
- slm_serial_number: Serial number
|
||||
- slm_frequency_weighting: A, C, or Z
|
||||
- slm_time_weighting: F (Fast), S (Slow), I (Impulse)
|
||||
- slm_measurement_range: e.g., "30-130 dB"
|
||||
|
||||
Lines starting with # are treated as comments and skipped.
|
||||
|
||||
Args:
|
||||
file: CSV file upload
|
||||
@@ -560,6 +879,46 @@ async def import_csv(
|
||||
# Read file content
|
||||
contents = await file.read()
|
||||
csv_text = contents.decode('utf-8')
|
||||
|
||||
# Filter out comment lines (starting with #)
|
||||
lines = csv_text.split('\n')
|
||||
filtered_lines = [line for line in lines if not line.strip().startswith('#')]
|
||||
csv_text = '\n'.join(filtered_lines)
|
||||
|
||||
# First pass: validate for duplicates and empty unit_ids
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||
seen_unit_ids = {} # unit_id -> list of row numbers
|
||||
empty_unit_id_rows = []
|
||||
|
||||
for row_num, row in enumerate(csv_reader, start=2):
|
||||
unit_id = row.get('unit_id', '').strip()
|
||||
if not unit_id:
|
||||
empty_unit_id_rows.append(row_num)
|
||||
else:
|
||||
if unit_id not in seen_unit_ids:
|
||||
seen_unit_ids[unit_id] = []
|
||||
seen_unit_ids[unit_id].append(row_num)
|
||||
|
||||
# Check for validation errors
|
||||
validation_errors = []
|
||||
|
||||
# Report empty unit_ids
|
||||
if empty_unit_id_rows:
|
||||
validation_errors.append(f"Empty unit_id on row(s): {', '.join(map(str, empty_unit_id_rows))}")
|
||||
|
||||
# Report duplicates
|
||||
duplicates = {uid: rows for uid, rows in seen_unit_ids.items() if len(rows) > 1}
|
||||
if duplicates:
|
||||
for uid, rows in duplicates.items():
|
||||
validation_errors.append(f"Duplicate unit_id '{uid}' on rows: {', '.join(map(str, rows))}")
|
||||
|
||||
if validation_errors:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="CSV validation failed:\n" + "\n".join(validation_errors)
|
||||
)
|
||||
|
||||
# Second pass: actually import the data
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||
|
||||
results = {
|
||||
@@ -580,6 +939,9 @@ async def import_csv(
|
||||
})
|
||||
continue
|
||||
|
||||
# Determine device type
|
||||
device_type = _get_csv_value(row, 'device_type', 'seismograph')
|
||||
|
||||
# Check if unit exists
|
||||
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
|
||||
@@ -588,31 +950,90 @@ async def import_csv(
|
||||
results["skipped"].append(unit_id)
|
||||
continue
|
||||
|
||||
# Update existing unit
|
||||
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3')
|
||||
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed
|
||||
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired
|
||||
existing_unit.note = row.get('note', existing_unit.note or '')
|
||||
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
|
||||
existing_unit.location = row.get('location', existing_unit.location)
|
||||
existing_unit.address = row.get('address', existing_unit.address)
|
||||
existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates)
|
||||
# Update existing unit - common fields
|
||||
existing_unit.device_type = device_type
|
||||
existing_unit.unit_type = _get_csv_value(row, 'unit_type', existing_unit.unit_type or 'series3')
|
||||
existing_unit.deployed = _parse_bool(row.get('deployed', '')) if row.get('deployed') else existing_unit.deployed
|
||||
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
|
||||
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
|
||||
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
|
||||
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
|
||||
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
|
||||
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
|
||||
existing_unit.last_updated = datetime.utcnow()
|
||||
|
||||
# Seismograph-specific fields
|
||||
if row.get('last_calibrated'):
|
||||
existing_unit.last_calibrated = _parse_date(row.get('last_calibrated'))
|
||||
if row.get('next_calibration_due'):
|
||||
existing_unit.next_calibration_due = _parse_date(row.get('next_calibration_due'))
|
||||
if row.get('deployed_with_modem_id'):
|
||||
existing_unit.deployed_with_modem_id = _get_csv_value(row, 'deployed_with_modem_id')
|
||||
|
||||
# Modem-specific fields
|
||||
if row.get('ip_address'):
|
||||
existing_unit.ip_address = _get_csv_value(row, 'ip_address')
|
||||
if row.get('phone_number'):
|
||||
existing_unit.phone_number = _get_csv_value(row, 'phone_number')
|
||||
if row.get('hardware_model'):
|
||||
existing_unit.hardware_model = _get_csv_value(row, 'hardware_model')
|
||||
if row.get('deployment_type'):
|
||||
existing_unit.deployment_type = _get_csv_value(row, 'deployment_type')
|
||||
if row.get('deployed_with_unit_id'):
|
||||
existing_unit.deployed_with_unit_id = _get_csv_value(row, 'deployed_with_unit_id')
|
||||
|
||||
# SLM-specific fields
|
||||
if row.get('slm_host'):
|
||||
existing_unit.slm_host = _get_csv_value(row, 'slm_host')
|
||||
if row.get('slm_tcp_port'):
|
||||
existing_unit.slm_tcp_port = _parse_int(row.get('slm_tcp_port'))
|
||||
if row.get('slm_ftp_port'):
|
||||
existing_unit.slm_ftp_port = _parse_int(row.get('slm_ftp_port'))
|
||||
if row.get('slm_model'):
|
||||
existing_unit.slm_model = _get_csv_value(row, 'slm_model')
|
||||
if row.get('slm_serial_number'):
|
||||
existing_unit.slm_serial_number = _get_csv_value(row, 'slm_serial_number')
|
||||
if row.get('slm_frequency_weighting'):
|
||||
existing_unit.slm_frequency_weighting = _get_csv_value(row, 'slm_frequency_weighting')
|
||||
if row.get('slm_time_weighting'):
|
||||
existing_unit.slm_time_weighting = _get_csv_value(row, 'slm_time_weighting')
|
||||
if row.get('slm_measurement_range'):
|
||||
existing_unit.slm_measurement_range = _get_csv_value(row, 'slm_measurement_range')
|
||||
|
||||
results["updated"].append(unit_id)
|
||||
else:
|
||||
# Create new unit
|
||||
# Create new unit with all fields
|
||||
new_unit = RosterUnit(
|
||||
id=unit_id,
|
||||
unit_type=row.get('unit_type', 'series3'),
|
||||
deployed=row.get('deployed', '').lower() in ('true', '1', 'yes'),
|
||||
retired=row.get('retired', '').lower() in ('true', '1', 'yes'),
|
||||
note=row.get('note', ''),
|
||||
project_id=row.get('project_id'),
|
||||
location=row.get('location'),
|
||||
address=row.get('address'),
|
||||
coordinates=row.get('coordinates'),
|
||||
last_updated=datetime.utcnow()
|
||||
device_type=device_type,
|
||||
unit_type=_get_csv_value(row, 'unit_type', 'series3'),
|
||||
deployed=_parse_bool(row.get('deployed', '')),
|
||||
retired=_parse_bool(row.get('retired', '')),
|
||||
note=_get_csv_value(row, 'note', ''),
|
||||
project_id=_get_csv_value(row, 'project_id'),
|
||||
location=_get_csv_value(row, 'location'),
|
||||
address=_get_csv_value(row, 'address'),
|
||||
coordinates=_get_csv_value(row, 'coordinates'),
|
||||
last_updated=datetime.utcnow(),
|
||||
# Seismograph fields
|
||||
last_calibrated=_parse_date(row.get('last_calibrated', '')),
|
||||
next_calibration_due=_parse_date(row.get('next_calibration_due', '')),
|
||||
deployed_with_modem_id=_get_csv_value(row, 'deployed_with_modem_id'),
|
||||
# Modem fields
|
||||
ip_address=_get_csv_value(row, 'ip_address'),
|
||||
phone_number=_get_csv_value(row, 'phone_number'),
|
||||
hardware_model=_get_csv_value(row, 'hardware_model'),
|
||||
deployment_type=_get_csv_value(row, 'deployment_type'),
|
||||
deployed_with_unit_id=_get_csv_value(row, 'deployed_with_unit_id'),
|
||||
# SLM fields
|
||||
slm_host=_get_csv_value(row, 'slm_host'),
|
||||
slm_tcp_port=_parse_int(row.get('slm_tcp_port', '')),
|
||||
slm_ftp_port=_parse_int(row.get('slm_ftp_port', '')),
|
||||
slm_model=_get_csv_value(row, 'slm_model'),
|
||||
slm_serial_number=_get_csv_value(row, 'slm_serial_number'),
|
||||
slm_frequency_weighting=_get_csv_value(row, 'slm_frequency_weighting'),
|
||||
slm_time_weighting=_get_csv_value(row, 'slm_time_weighting'),
|
||||
slm_measurement_range=_get_csv_value(row, 'slm_measurement_range'),
|
||||
)
|
||||
db.add(new_unit)
|
||||
results["added"].append(unit_id)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
services:
|
||||
|
||||
# --- TERRA-VIEW PRODUCTION ---
|
||||
terra-view-prod:
|
||||
terra-view:
|
||||
build: .
|
||||
container_name: terra-view
|
||||
ports:
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
unit_id,unit_type,deployed,retired,note,project_id,location
|
||||
BE1234,series3,true,false,Primary unit at main site,PROJ-001,San Francisco CA
|
||||
BE5678,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA
|
||||
BE9012,series3,false,false,In maintenance,PROJ-002,Workshop
|
||||
BE3456,series3,true,false,,PROJ-003,New York NY
|
||||
BE7890,series3,false,true,Decommissioned 2024,,Storage
|
||||
unit_id,device_type,unit_type,deployed,retired,note,project_id,location,address,coordinates,last_calibrated,next_calibration_due,deployed_with_modem_id,ip_address,phone_number,hardware_model,slm_host,slm_tcp_port,slm_ftp_port,slm_model,slm_serial_number,slm_frequency_weighting,slm_time_weighting,slm_measurement_range
|
||||
# ============================================
|
||||
# SEISMOGRAPHS (device_type=seismograph)
|
||||
# ============================================
|
||||
BE1234,seismograph,series3,true,false,Primary unit at main site,PROJ-001,San Francisco CA,123 Market St,37.7749;-122.4194,2025-06-15,2026-06-15,MDM001,,,,,,,,,,,
|
||||
BE5678,seismograph,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA,456 Sunset Blvd,34.0522;-118.2437,2025-03-01,2026-03-01,MDM002,,,,,,,,,,,
|
||||
BE9012,seismograph,series4,false,false,In maintenance - needs calibration,PROJ-002,Workshop,789 Industrial Way,,,,,,,,,,,,,,
|
||||
BE3456,seismograph,series3,true,false,,PROJ-003,New York NY,101 Broadway,40.7128;-74.0060,2025-01-10,2026-01-10,,,,,,,,,,,
|
||||
BE7890,seismograph,series3,false,true,Decommissioned 2024,,Storage,Warehouse B,,,,,,,,,,,,,,,
|
||||
# ============================================
|
||||
# MODEMS (device_type=modem)
|
||||
# ============================================
|
||||
MDM001,modem,,true,false,Cradlepoint at SF site,PROJ-001,San Francisco CA,123 Market St,37.7749;-122.4194,,,,,192.168.1.100,+1-555-0101,IBR900,,,,,,,
|
||||
MDM002,modem,,true,false,Sierra Wireless at LA site,PROJ-001,Los Angeles CA,456 Sunset Blvd,34.0522;-118.2437,,,,,10.0.0.50,+1-555-0102,RV55,,,,,,,
|
||||
MDM003,modem,,false,false,Spare modem in storage,,,Storage,Warehouse A,,,,,,+1-555-0103,IBR600,,,,,,,
|
||||
MDM004,modem,,true,false,NYC backup modem,PROJ-003,New York NY,101 Broadway,40.7128;-74.0060,,,,,172.16.0.25,+1-555-0104,IBR1700,,,,,,,
|
||||
# ============================================
|
||||
# SOUND LEVEL METERS (device_type=slm)
|
||||
# ============================================
|
||||
SLM001,slm,,true,false,NL-43 at construction site A,PROJ-004,Downtown Site,500 Main St,40.7589;-73.9851,,,,,,,,192.168.10.101,2255,21,NL-43,12345678,A,F,30-130 dB
|
||||
SLM002,slm,,true,false,NL-43 at construction site B,PROJ-004,Midtown Site,600 Park Ave,40.7614;-73.9776,,,MDM004,,,,,192.168.10.102,2255,21,NL-43,12345679,A,S,30-130 dB
|
||||
SLM003,slm,,false,false,NL-53 spare unit,,,Storage,Warehouse A,,,,,,,,,,,NL-53,98765432,C,F,25-138 dB
|
||||
SLM004,slm,,true,false,NL-43 nighttime monitoring,PROJ-005,Residential Area,200 Quiet Lane,40.7484;-73.9857,,,,,,,,10.0.5.50,2255,21,NL-43,11112222,A,S,30-130 dB
|
||||
|
||||
|
@@ -130,6 +130,13 @@
|
||||
Sound Level Meters
|
||||
</a>
|
||||
|
||||
<a href="/modems" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/modems' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
Modems
|
||||
</a>
|
||||
|
||||
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
@@ -377,10 +384,10 @@
|
||||
</script>
|
||||
|
||||
<!-- Offline Database -->
|
||||
<script src="/static/offline-db.js?v=0.4.3"></script>
|
||||
<script src="/static/offline-db.js?v=0.5.1"></script>
|
||||
|
||||
<!-- Mobile JavaScript -->
|
||||
<script src="/static/mobile.js?v=0.4.3"></script>
|
||||
<script src="/static/mobile.js?v=0.5.1"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
108
templates/modems.html
Normal file
108
templates/modems.html
Normal 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 %}
|
||||
89
templates/partials/modem_list.html
Normal file
89
templates/partials/modem_list.html
Normal 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 %}
|
||||
51
templates/partials/modem_paired_device.html
Normal file
51
templates/partials/modem_paired_device.html
Normal 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 %}
|
||||
128
templates/partials/modem_picker.html
Normal file
128
templates/partials/modem_picker.html
Normal 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>
|
||||
61
templates/partials/modem_search_results.html
Normal file
61
templates/partials/modem_search_results.html
Normal 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 %}
|
||||
63
templates/partials/modem_stats.html
Normal file
63
templates/partials/modem_stats.html
Normal 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>
|
||||
233
templates/partials/project_create_modal.html
Normal file
233
templates/partials/project_create_modal.html
Normal 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>
|
||||
128
templates/partials/project_picker.html
Normal file
128
templates/partials/project_picker.html
Normal 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>
|
||||
69
templates/partials/project_search_results.html
Normal file
69
templates/partials/project_search_results.html
Normal 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 %}
|
||||
132
templates/partials/unit_picker.html
Normal file
132
templates/partials/unit_picker.html
Normal 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>
|
||||
66
templates/partials/unit_search_results.html
Normal file
66
templates/partials/unit_search_results.html
Normal 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 %}
|
||||
@@ -131,10 +131,8 @@
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||
<input type="text" name="project_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
||||
placeholder="PROJ-001">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||
{% include "partials/project_picker.html" with context %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
|
||||
@@ -159,8 +157,8 @@
|
||||
</div>
|
||||
<div id="modemPairingField" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||
<input type="text" name="deployed_with_modem_id" placeholder="Modem ID"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
{% set picker_id = "-add-seismo" %}
|
||||
{% include "partials/modem_picker.html" with context %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,6 +181,21 @@
|
||||
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
||||
<select name="deployment_type"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<option value="">Not assigned</option>
|
||||
<option value="seismograph">Seismograph</option>
|
||||
<option value="slm">Sound Level Meter (SLM)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
|
||||
{% set picker_id = "-add-modem" %}
|
||||
{% set device_type_filter = "" %}
|
||||
{% include "partials/unit_picker.html" with context %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sound Level Meter-specific fields -->
|
||||
@@ -297,9 +310,9 @@
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||
<input type="text" name="project_id" id="editProjectId"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||
{% set picker_id = "-edit" %}
|
||||
{% include "partials/project_picker.html" with context %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||
@@ -327,8 +340,8 @@
|
||||
</div>
|
||||
<div id="editModemPairingField" class="hidden">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||
<input type="text" name="deployed_with_modem_id" id="editDeployedWithModemId" placeholder="Modem ID"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
{% set picker_id = "-edit-seismo" %}
|
||||
{% include "partials/modem_picker.html" with context %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -350,6 +363,21 @@
|
||||
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
||||
<select name="deployment_type" id="editDeploymentType"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<option value="">Not assigned</option>
|
||||
<option value="seismograph">Seismograph</option>
|
||||
<option value="slm">Sound Level Meter (SLM)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
|
||||
{% set picker_id = "-edit-modem" %}
|
||||
{% set device_type_filter = "" %}
|
||||
{% include "partials/unit_picker.html" with context %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sound Level Meter-specific fields -->
|
||||
@@ -419,6 +447,55 @@
|
||||
<textarea name="note" id="editNote" rows="3"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Cascade to Paired Device Section -->
|
||||
<div id="editCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||
</svg>
|
||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Also update paired device: <span id="editPairedDeviceName" class="text-seismo-orange"></span>
|
||||
</span>
|
||||
</div>
|
||||
<input type="hidden" name="cascade_to_unit_id" id="editCascadeToUnitId" value="">
|
||||
<div class="grid grid-cols-2 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_deployed" id="editCascadeDeployed" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed status</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_retired" id="editCascadeRetired" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Retired status</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_project" id="editCascadeProject" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_location" id="editCascadeLocation" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_coordinates" id="editCascadeCoordinates" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="cascade_note" id="editCascadeNote" value="true"
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Notes</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Check the fields you want to sync to the paired device
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||
Save Changes
|
||||
@@ -650,11 +727,7 @@
|
||||
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.successful) {
|
||||
closeAddUnitModal();
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
// Show success message
|
||||
alert('Unit added successfully!');
|
||||
} else {
|
||||
@@ -692,6 +765,33 @@
|
||||
function closeEditUnitModal() {
|
||||
document.getElementById('editUnitModal').classList.add('hidden');
|
||||
document.getElementById('editUnitForm').reset();
|
||||
// Also clear the project picker
|
||||
const projectPickerValue = document.getElementById('project-picker-value-edit');
|
||||
const projectPickerSearch = document.getElementById('project-picker-search-edit');
|
||||
const projectPickerClear = document.getElementById('project-picker-clear-edit');
|
||||
if (projectPickerValue) projectPickerValue.value = '';
|
||||
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Fetch project display name for edit modal
|
||||
async function fetchProjectDisplayForEdit(projectId) {
|
||||
if (!projectId) return '';
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`);
|
||||
if (response.ok) {
|
||||
const project = await response.json();
|
||||
const parts = [
|
||||
project.project_number,
|
||||
project.client_name,
|
||||
project.name
|
||||
].filter(Boolean);
|
||||
return parts.join(' - ') || projectId;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch project:', e);
|
||||
}
|
||||
return projectId;
|
||||
}
|
||||
|
||||
// Toggle device-specific fields in edit modal
|
||||
@@ -753,7 +853,23 @@
|
||||
document.getElementById('editUnitId').value = unit.id;
|
||||
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
|
||||
document.getElementById('editUnitType').value = unit.unit_type;
|
||||
document.getElementById('editProjectId').value = unit.project_id;
|
||||
|
||||
// Populate project picker (uses -edit suffix)
|
||||
const projectPickerValue = document.getElementById('project-picker-value-edit');
|
||||
const projectPickerSearch = document.getElementById('project-picker-search-edit');
|
||||
const projectPickerClear = document.getElementById('project-picker-clear-edit');
|
||||
if (projectPickerValue) projectPickerValue.value = unit.project_id || '';
|
||||
if (unit.project_id) {
|
||||
// Fetch project display name
|
||||
fetchProjectDisplayForEdit(unit.project_id).then(displayText => {
|
||||
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
||||
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
||||
});
|
||||
} else {
|
||||
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('editAddress').value = unit.address;
|
||||
document.getElementById('editCoordinates').value = unit.coordinates;
|
||||
document.getElementById('editNote').value = unit.note;
|
||||
@@ -765,12 +881,62 @@
|
||||
// Seismograph fields
|
||||
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
|
||||
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
|
||||
document.getElementById('editDeployedWithModemId').value = unit.deployed_with_modem_id;
|
||||
|
||||
// Populate modem picker for seismograph (uses -edit-seismo suffix)
|
||||
const modemPickerValue = document.getElementById('modem-picker-value-edit-seismo');
|
||||
const modemPickerSearch = document.getElementById('modem-picker-search-edit-seismo');
|
||||
const modemPickerClear = document.getElementById('modem-picker-clear-edit-seismo');
|
||||
if (modemPickerValue) modemPickerValue.value = unit.deployed_with_modem_id || '';
|
||||
if (unit.deployed_with_modem_id) {
|
||||
// Fetch modem display (ID + IP + note)
|
||||
fetch(`/api/roster/${unit.deployed_with_modem_id}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(modem => {
|
||||
if (modem && modemPickerSearch) {
|
||||
let display = modem.id;
|
||||
if (modem.ip_address) display += ` - ${modem.ip_address}`;
|
||||
if (modem.note) display += ` - ${modem.note}`;
|
||||
modemPickerSearch.value = display;
|
||||
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (modemPickerSearch) modemPickerSearch.value = unit.deployed_with_modem_id;
|
||||
});
|
||||
} else {
|
||||
if (modemPickerSearch) modemPickerSearch.value = '';
|
||||
if (modemPickerClear) modemPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Modem fields
|
||||
document.getElementById('editIpAddress').value = unit.ip_address;
|
||||
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
||||
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
||||
document.getElementById('editDeploymentType').value = unit.deployment_type || '';
|
||||
|
||||
// Populate unit picker for modem (uses -edit-modem suffix)
|
||||
const unitPickerValue = document.getElementById('unit-picker-value-edit-modem');
|
||||
const unitPickerSearch = document.getElementById('unit-picker-search-edit-modem');
|
||||
const unitPickerClear = document.getElementById('unit-picker-clear-edit-modem');
|
||||
if (unitPickerValue) unitPickerValue.value = unit.deployed_with_unit_id || '';
|
||||
if (unit.deployed_with_unit_id) {
|
||||
// Fetch unit display (ID + note)
|
||||
fetch(`/api/roster/${unit.deployed_with_unit_id}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(linkedUnit => {
|
||||
if (linkedUnit && unitPickerSearch) {
|
||||
const display = linkedUnit.note ? `${linkedUnit.id} - ${linkedUnit.note}` : linkedUnit.id;
|
||||
unitPickerSearch.value = display;
|
||||
if (unitPickerClear) unitPickerClear.classList.remove('hidden');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (unitPickerSearch) unitPickerSearch.value = unit.deployed_with_unit_id;
|
||||
});
|
||||
} else {
|
||||
if (unitPickerSearch) unitPickerSearch.value = '';
|
||||
if (unitPickerClear) unitPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
// SLM fields
|
||||
document.getElementById('editSlmModel').value = unit.slm_model || '';
|
||||
@@ -781,6 +947,35 @@
|
||||
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
|
||||
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
|
||||
|
||||
// Cascade section - show if there's a paired device
|
||||
const cascadeSection = document.getElementById('editCascadeSection');
|
||||
const cascadeToUnitId = document.getElementById('editCascadeToUnitId');
|
||||
const pairedDeviceName = document.getElementById('editPairedDeviceName');
|
||||
|
||||
// Determine paired device based on device type
|
||||
let pairedUnitId = null;
|
||||
if (unit.device_type === 'modem' && unit.deployed_with_unit_id) {
|
||||
pairedUnitId = unit.deployed_with_unit_id;
|
||||
} else if ((unit.device_type === 'seismograph' || unit.device_type === 'sound_level_meter') && unit.deployed_with_modem_id) {
|
||||
pairedUnitId = unit.deployed_with_modem_id;
|
||||
}
|
||||
|
||||
if (pairedUnitId) {
|
||||
cascadeToUnitId.value = pairedUnitId;
|
||||
pairedDeviceName.textContent = pairedUnitId;
|
||||
cascadeSection.classList.remove('hidden');
|
||||
// Reset checkboxes
|
||||
document.getElementById('editCascadeDeployed').checked = false;
|
||||
document.getElementById('editCascadeRetired').checked = false;
|
||||
document.getElementById('editCascadeProject').checked = false;
|
||||
document.getElementById('editCascadeLocation').checked = false;
|
||||
document.getElementById('editCascadeCoordinates').checked = false;
|
||||
document.getElementById('editCascadeNote').checked = false;
|
||||
} else {
|
||||
cascadeToUnitId.value = '';
|
||||
cascadeSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Store unit ID for form submission
|
||||
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
||||
|
||||
@@ -814,11 +1009,7 @@
|
||||
|
||||
if (response.ok) {
|
||||
closeEditUnitModal();
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
alert('Unit updated successfully!');
|
||||
} else {
|
||||
const result = await response.json();
|
||||
@@ -846,11 +1037,7 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
|
||||
} else {
|
||||
const result = await response.json();
|
||||
@@ -878,11 +1065,7 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
alert(`Unit ${unitId} moved to ignore list`);
|
||||
} else {
|
||||
const result = await response.json();
|
||||
@@ -905,11 +1088,7 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
alert(`Unit ${unitId} deleted successfully`);
|
||||
} else {
|
||||
const result = await response.json();
|
||||
@@ -948,11 +1127,7 @@
|
||||
`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
// Trigger roster refresh for current active tab
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
refreshDeviceList();
|
||||
|
||||
// Close modal after 2 seconds
|
||||
setTimeout(() => closeImportModal(), 2000);
|
||||
@@ -968,35 +1143,26 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Handle roster tab switching with auto-refresh
|
||||
let currentRosterEndpoint = '/partials/roster-deployed'; // Default to deployed tab
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tabButtons = document.querySelectorAll('.roster-tab-button');
|
||||
|
||||
tabButtons.forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
// Remove active-roster-tab class from all buttons
|
||||
tabButtons.forEach(btn => btn.classList.remove('active-roster-tab'));
|
||||
// Add active-roster-tab class to clicked button
|
||||
this.classList.add('active-roster-tab');
|
||||
|
||||
// Update current endpoint for auto-refresh
|
||||
currentRosterEndpoint = this.getAttribute('data-endpoint');
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-refresh the current active tab every 10 seconds
|
||||
setInterval(() => {
|
||||
const rosterContent = document.getElementById('roster-content');
|
||||
if (rosterContent) {
|
||||
// Use HTMX to trigger a refresh of the current endpoint
|
||||
htmx.ajax('GET', currentRosterEndpoint, {
|
||||
target: '#roster-content',
|
||||
// Refresh device list (applies current client-side filters after load)
|
||||
function refreshDeviceList() {
|
||||
htmx.ajax('GET', '/partials/devices-all', {
|
||||
target: '#device-content',
|
||||
swap: 'innerHTML'
|
||||
}).then(() => {
|
||||
// Re-apply filters after content loads
|
||||
setTimeout(filterDevices, 100);
|
||||
});
|
||||
}
|
||||
}, 10000); // 10 seconds
|
||||
|
||||
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)
|
||||
@@ -1345,4 +1511,7 @@
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Include Project Create Modal for inline project creation -->
|
||||
{% include "partials/project_create_modal.html" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -121,8 +121,13 @@
|
||||
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project ID</label>
|
||||
<p id="viewProjectId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project</label>
|
||||
<p id="viewProjectContainer" class="mt-1">
|
||||
<a id="viewProjectLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
||||
<span id="viewProjectText">--</span>
|
||||
</a>
|
||||
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||
@@ -172,6 +177,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Paired Device (for modems only) -->
|
||||
<div id="viewPairedDeviceSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Paired Device</h3>
|
||||
<div id="pairedDeviceInfo">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connectivity (for modems only) -->
|
||||
<div id="viewConnectivitySection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connectivity</h3>
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<button onclick="pingModem()" id="modemPingBtn"
|
||||
class="px-4 py-2 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded-lg flex items-center gap-2 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||
</svg>
|
||||
Ping Test
|
||||
</button>
|
||||
<span id="modemPingResult" class="text-sm text-gray-500 dark:text-gray-400">--</span>
|
||||
</div>
|
||||
|
||||
<!-- Future Diagnostics Placeholders -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 opacity-60">
|
||||
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Signal Strength</p>
|
||||
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- dBm</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||
</div>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Data Usage</p>
|
||||
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- MB</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||
</div>
|
||||
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Uptime</p>
|
||||
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">--</p>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||
@@ -251,11 +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">
|
||||
</div>
|
||||
|
||||
<!-- Project ID -->
|
||||
<!-- Project -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||
<input type="text" name="project_id" id="projectId"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||
{% set picker_id = "-detail" %}
|
||||
{% include "partials/project_picker.html" with context %}
|
||||
</div>
|
||||
|
||||
<!-- Address -->
|
||||
@@ -415,6 +462,26 @@ let currentSnapshot = null;
|
||||
let unitMap = null;
|
||||
let mapMarker = null;
|
||||
|
||||
// Fetch project display name (combines project_number, client_name, name)
|
||||
async function fetchProjectDisplay(projectId) {
|
||||
if (!projectId) return '';
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}`);
|
||||
if (response.ok) {
|
||||
const project = await response.json();
|
||||
const parts = [
|
||||
project.project_number,
|
||||
project.client_name,
|
||||
project.name
|
||||
].filter(Boolean);
|
||||
return parts.join(' - ') || projectId;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch project:', e);
|
||||
}
|
||||
return projectId;
|
||||
}
|
||||
|
||||
// Load unit data on page load
|
||||
async function loadUnitData() {
|
||||
try {
|
||||
@@ -536,7 +603,31 @@ function populateViewMode() {
|
||||
// Basic info
|
||||
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
||||
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
|
||||
document.getElementById('viewProjectId').textContent = currentUnit.project_id || '--';
|
||||
|
||||
// Project display with clickable link
|
||||
const projectId = currentUnit.project_id;
|
||||
const projectLink = document.getElementById('viewProjectLink');
|
||||
const projectNoLink = document.getElementById('viewProjectNoLink');
|
||||
const projectText = document.getElementById('viewProjectText');
|
||||
|
||||
if (projectId) {
|
||||
// Fetch project display name and show link
|
||||
fetchProjectDisplay(projectId).then(displayText => {
|
||||
if (projectText) projectText.textContent = displayText;
|
||||
if (projectLink) {
|
||||
projectLink.href = `/projects/${projectId}`;
|
||||
projectLink.classList.remove('hidden');
|
||||
}
|
||||
if (projectNoLink) projectNoLink.classList.add('hidden');
|
||||
});
|
||||
} else {
|
||||
if (projectNoLink) {
|
||||
projectNoLink.textContent = 'Not assigned';
|
||||
projectNoLink.classList.remove('hidden');
|
||||
}
|
||||
if (projectLink) projectLink.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
||||
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
||||
|
||||
@@ -557,9 +648,15 @@ function populateViewMode() {
|
||||
if (currentUnit.device_type === 'modem') {
|
||||
document.getElementById('viewSeismographFields').classList.add('hidden');
|
||||
document.getElementById('viewModemFields').classList.remove('hidden');
|
||||
document.getElementById('viewPairedDeviceSection').classList.remove('hidden');
|
||||
document.getElementById('viewConnectivitySection').classList.remove('hidden');
|
||||
// Load paired device info
|
||||
loadPairedDevice();
|
||||
} else {
|
||||
document.getElementById('viewSeismographFields').classList.remove('hidden');
|
||||
document.getElementById('viewModemFields').classList.add('hidden');
|
||||
document.getElementById('viewPairedDeviceSection').classList.add('hidden');
|
||||
document.getElementById('viewConnectivitySection').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,7 +664,22 @@ function populateViewMode() {
|
||||
function populateEditForm() {
|
||||
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
|
||||
document.getElementById('unitType').value = currentUnit.unit_type || '';
|
||||
document.getElementById('projectId').value = currentUnit.project_id || '';
|
||||
|
||||
// Populate project picker (uses -detail suffix)
|
||||
const projectPickerValue = document.getElementById('project-picker-value-detail');
|
||||
const projectPickerSearch = document.getElementById('project-picker-search-detail');
|
||||
const projectPickerClear = document.getElementById('project-picker-clear-detail');
|
||||
if (projectPickerValue) projectPickerValue.value = currentUnit.project_id || '';
|
||||
if (currentUnit.project_id) {
|
||||
fetchProjectDisplay(currentUnit.project_id).then(displayText => {
|
||||
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
||||
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
||||
});
|
||||
} else {
|
||||
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||
}
|
||||
|
||||
document.getElementById('address').value = currentUnit.address || '';
|
||||
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
||||
document.getElementById('deployed').checked = currentUnit.deployed;
|
||||
@@ -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
|
||||
loadUnitData().then(() => {
|
||||
loadPhotos();
|
||||
loadUnitHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Include Project Create Modal for inline project creation -->
|
||||
{% include "partials/project_create_modal.html" %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user