Compare commits
6 Commits
8431784708
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ce0f6115d | |||
|
|
6492fdff82 | ||
|
|
44d7841852 | ||
|
|
38c600aca3 | ||
|
|
eeda94926f | ||
|
|
57be9bf1f1 |
43
CHANGELOG.md
@@ -1,10 +1,48 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Seismo Fleet Manager will be documented in this file.
|
All notable changes to Terra-View will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.5.1] - 2026-01-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Dashboard Schedule View**: Today's scheduled actions now display directly on the main dashboard
|
||||||
|
- New "Today's Actions" panel showing upcoming and past scheduled events
|
||||||
|
- Schedule list partial for project-specific schedule views
|
||||||
|
- API endpoint for fetching today's schedule data
|
||||||
|
- **New Branding Assets**: Complete logo rework for Terra-View
|
||||||
|
- New Terra-View logos for light and dark themes
|
||||||
|
- Retina-ready (@2x) logo variants
|
||||||
|
- Updated favicons (16px and 32px)
|
||||||
|
- Refreshed PWA icons (72px through 512px)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Dashboard Layout**: Reorganized to include schedule information panel
|
||||||
|
- **Base Template**: Updated to use new Terra-View logos with theme-aware switching
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-01-23
|
||||||
|
|
||||||
|
_Note: This version was not formally released; changes were included in v0.5.1._
|
||||||
|
|
||||||
|
## [0.4.4] - 2026-01-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Recurring schedules**: New scheduler service, recurring schedule APIs, and schedule templates (calendar/interval/list).
|
||||||
|
- **Alerts UI + backend**: Alerting service plus dropdown/list templates for surfacing notifications.
|
||||||
|
- **Report templates + viewers**: CRUD API for report templates, report preview screen, and RND file viewer.
|
||||||
|
- **SLM tooling**: SLM settings modal and SLM project report generator workflow.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Project data management**: Unified files view, refreshed FTP browser, and new project header/templates for file/session/unit/assignment lists.
|
||||||
|
- **Device/SLM sync**: Standardized SLM device types and tightened SLMM sync paths.
|
||||||
|
- **Docs/scripts**: Cleanup pass and expanded device-type documentation.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Scheduler actions**: Strict command definitions so actions run reliably.
|
||||||
|
- **Project view title**: Resolved JSON string rendering in project headers.
|
||||||
|
|
||||||
## [0.4.3] - 2026-01-14
|
## [0.4.3] - 2026-01-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -361,6 +399,9 @@ No database migration required for v0.4.0. All new features use existing databas
|
|||||||
- Photo management per unit
|
- Photo management per unit
|
||||||
- Automated status categorization (OK/Pending/Missing)
|
- Automated status categorization (OK/Pending/Missing)
|
||||||
|
|
||||||
|
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
|
||||||
|
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
|
||||||
|
[0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4
|
||||||
[0.4.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.2...v0.4.3
|
[0.4.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.2...v0.4.3
|
||||||
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
||||||
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
||||||
|
|||||||
10
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Seismo Fleet Manager v0.4.3
|
# Terra-View v0.5.1
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -571,9 +571,13 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.4.3** — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
|
**Current: 0.5.1** — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
|
||||||
|
|
||||||
Previous: 0.4.2 — SLM configuration interface with TCP/FTP controls, modem diagnostics, and dashboard endpoints for Sound Level Meters (2026-01-05)
|
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)
|
||||||
|
|
||||||
0.4.1 — Sound Level Meter integration with full management UI for SLM units (2026-01-05)
|
0.4.1 — Sound Level Meter integration with full management UI for SLM units (2026-01-05)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from backend.database import engine, Base, get_db
|
from backend.database import engine, Base, get_db
|
||||||
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler
|
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.models import IgnoredUnit
|
from backend.models import IgnoredUnit
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.4.3"
|
VERSION = "0.5.1"
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Seismo Fleet Manager",
|
title="Seismo Fleet Manager",
|
||||||
description="Backend API for managing seismograph fleet status",
|
description="Backend API for managing seismograph fleet status",
|
||||||
@@ -92,6 +92,7 @@ app.include_router(slmm.router)
|
|||||||
app.include_router(slm_ui.router)
|
app.include_router(slm_ui.router)
|
||||||
app.include_router(slm_dashboard.router)
|
app.include_router(slm_dashboard.router)
|
||||||
app.include_router(seismo_dashboard.router)
|
app.include_router(seismo_dashboard.router)
|
||||||
|
app.include_router(modem_dashboard.router)
|
||||||
|
|
||||||
from backend.routers import settings
|
from backend.routers import settings
|
||||||
app.include_router(settings.router)
|
app.include_router(settings.router)
|
||||||
@@ -216,6 +217,12 @@ async def seismographs_page(request: Request):
|
|||||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/modems", response_class=HTMLResponse)
|
||||||
|
async def modems_page(request: Request):
|
||||||
|
"""Field modems management dashboard"""
|
||||||
|
return templates.TemplateResponse("modems.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects", response_class=HTMLResponse)
|
@app.get("/projects", response_class=HTMLResponse)
|
||||||
async def projects_page(request: Request):
|
async def projects_page(request: Request):
|
||||||
"""Projects management and overview"""
|
"""Projects management and overview"""
|
||||||
|
|||||||
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
@@ -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)
|
ip_address = Column(String, nullable=True)
|
||||||
phone_number = Column(String, nullable=True)
|
phone_number = Column(String, nullable=True)
|
||||||
hardware_model = Column(String, nullable=True)
|
hardware_model = Column(String, nullable=True)
|
||||||
|
deployment_type = Column(String, nullable=True) # "seismograph" | "slm" - what type of device this modem is deployed with
|
||||||
|
deployed_with_unit_id = Column(String, nullable=True) # ID of seismograph/SLM this modem is deployed with
|
||||||
|
|
||||||
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
||||||
slm_host = Column(String, nullable=True) # Device IP or hostname
|
slm_host = Column(String, nullable=True) # Device IP or hostname
|
||||||
@@ -137,17 +139,26 @@ class Project(Base):
|
|||||||
"""
|
"""
|
||||||
Projects: top-level organization for monitoring work.
|
Projects: top-level organization for monitoring work.
|
||||||
Type-aware to enable/disable features based on project_type_id.
|
Type-aware to enable/disable features based on project_type_id.
|
||||||
|
|
||||||
|
Project naming convention:
|
||||||
|
- project_number: TMI internal ID format xxxx-YY (e.g., "2567-23")
|
||||||
|
- client_name: Client/contractor name (e.g., "PJ Dick")
|
||||||
|
- name: Project/site name (e.g., "RKM Hall", "CMU Campus")
|
||||||
|
|
||||||
|
Display format: "2567-23 - PJ Dick - RKM Hall"
|
||||||
|
Users can search by any of these fields.
|
||||||
"""
|
"""
|
||||||
__tablename__ = "projects"
|
__tablename__ = "projects"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
name = Column(String, nullable=False, unique=True)
|
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
|
||||||
|
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
||||||
status = Column(String, default="active") # active, completed, archived
|
status = Column(String, default="active") # active, completed, archived
|
||||||
|
|
||||||
# Project metadata
|
# Project metadata
|
||||||
client_name = Column(String, nullable=True)
|
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||||
site_address = Column(String, nullable=True)
|
site_address = Column(String, nullable=True)
|
||||||
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
||||||
start_date = Column(Date, nullable=True)
|
start_date = Column(Date, nullable=True)
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import ScheduledAction, MonitoringLocation, Project
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.templates_config import templates
|
from backend.templates_config import templates
|
||||||
|
from backend.utils.timezone import utc_to_local, local_to_utc, get_user_timezone
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -22,3 +27,71 @@ def dashboard_benched(request: Request):
|
|||||||
"partials/benched_table.html",
|
"partials/benched_table.html",
|
||||||
{"request": request, "units": snapshot["benched"]}
|
{"request": request, "units": snapshot["benched"]}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/todays-actions")
|
||||||
|
def dashboard_todays_actions(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get today's scheduled actions for the dashboard card.
|
||||||
|
Shows upcoming, completed, and failed actions for today.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
# Get today's date range in local timezone
|
||||||
|
tz = ZoneInfo(get_user_timezone())
|
||||||
|
now_local = datetime.now(tz)
|
||||||
|
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
today_end_local = today_start_local + timedelta(days=1)
|
||||||
|
|
||||||
|
# Convert to UTC for database query
|
||||||
|
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Query today's actions
|
||||||
|
actions = db.query(ScheduledAction).filter(
|
||||||
|
ScheduledAction.scheduled_time >= today_start_utc,
|
||||||
|
ScheduledAction.scheduled_time < today_end_utc,
|
||||||
|
).order_by(ScheduledAction.scheduled_time.asc()).all()
|
||||||
|
|
||||||
|
# Enrich with location/project info and parse results
|
||||||
|
enriched_actions = []
|
||||||
|
for action in actions:
|
||||||
|
location = None
|
||||||
|
project = None
|
||||||
|
if action.location_id:
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
|
||||||
|
if action.project_id:
|
||||||
|
project = db.query(Project).filter_by(id=action.project_id).first()
|
||||||
|
|
||||||
|
# Parse module_response for result details
|
||||||
|
result_data = None
|
||||||
|
if action.module_response:
|
||||||
|
try:
|
||||||
|
result_data = json.loads(action.module_response)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
enriched_actions.append({
|
||||||
|
"action": action,
|
||||||
|
"location": location,
|
||||||
|
"project": project,
|
||||||
|
"result": result_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Count by status
|
||||||
|
pending_count = sum(1 for a in actions if a.execution_status == "pending")
|
||||||
|
completed_count = sum(1 for a in actions if a.execution_status == "completed")
|
||||||
|
failed_count = sum(1 for a in actions if a.execution_status == "failed")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/dashboard/todays_actions.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"actions": enriched_actions,
|
||||||
|
"pending_count": pending_count,
|
||||||
|
"completed_count": completed_count,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"total_count": len(actions),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
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 import APIRouter, Request, Depends, HTTPException, Query
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func, and_
|
from sqlalchemy import func, and_, or_
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
@@ -147,6 +147,107 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Project Search (Smart Autocomplete)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
def _build_project_display(project: Project) -> str:
|
||||||
|
"""Build display string from project fields: 'xxxx-YY - Client - Name'"""
|
||||||
|
parts = []
|
||||||
|
if project.project_number:
|
||||||
|
parts.append(project.project_number)
|
||||||
|
if project.client_name:
|
||||||
|
parts.append(project.client_name)
|
||||||
|
if project.name:
|
||||||
|
parts.append(project.name)
|
||||||
|
return " - ".join(parts) if parts else project.id
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search", response_class=HTMLResponse)
|
||||||
|
async def search_projects(
|
||||||
|
request: Request,
|
||||||
|
q: str = Query("", description="Search term"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
limit: int = Query(10, le=50),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fuzzy search across project fields for autocomplete.
|
||||||
|
Searches: project_number, client_name, name (project/site name)
|
||||||
|
Returns HTML partial for HTMX dropdown.
|
||||||
|
"""
|
||||||
|
if not q.strip():
|
||||||
|
# Return recent active projects when no search term
|
||||||
|
projects = db.query(Project).filter(
|
||||||
|
Project.status != "archived"
|
||||||
|
).order_by(Project.updated_at.desc()).limit(limit).all()
|
||||||
|
else:
|
||||||
|
search_term = f"%{q}%"
|
||||||
|
projects = db.query(Project).filter(
|
||||||
|
and_(
|
||||||
|
Project.status != "archived",
|
||||||
|
or_(
|
||||||
|
Project.project_number.ilike(search_term),
|
||||||
|
Project.client_name.ilike(search_term),
|
||||||
|
Project.name.ilike(search_term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(Project.updated_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
# Build display data for each project
|
||||||
|
projects_data = [{
|
||||||
|
"id": p.id,
|
||||||
|
"project_number": p.project_number,
|
||||||
|
"client_name": p.client_name,
|
||||||
|
"name": p.name,
|
||||||
|
"display": _build_project_display(p),
|
||||||
|
"status": p.status,
|
||||||
|
} for p in projects]
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/project_search_results.html", {
|
||||||
|
"request": request,
|
||||||
|
"projects": projects_data,
|
||||||
|
"query": q,
|
||||||
|
"show_create": len(projects) == 0 and q.strip(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search-json")
|
||||||
|
async def search_projects_json(
|
||||||
|
q: str = Query("", description="Search term"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
limit: int = Query(10, le=50),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Fuzzy search across project fields - JSON response.
|
||||||
|
For programmatic/API consumption.
|
||||||
|
"""
|
||||||
|
if not q.strip():
|
||||||
|
projects = db.query(Project).filter(
|
||||||
|
Project.status != "archived"
|
||||||
|
).order_by(Project.updated_at.desc()).limit(limit).all()
|
||||||
|
else:
|
||||||
|
search_term = f"%{q}%"
|
||||||
|
projects = db.query(Project).filter(
|
||||||
|
and_(
|
||||||
|
Project.status != "archived",
|
||||||
|
or_(
|
||||||
|
Project.project_number.ilike(search_term),
|
||||||
|
Project.client_name.ilike(search_term),
|
||||||
|
Project.name.ilike(search_term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).order_by(Project.updated_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"id": p.id,
|
||||||
|
"project_number": p.project_number,
|
||||||
|
"client_name": p.client_name,
|
||||||
|
"name": p.name,
|
||||||
|
"display": _build_project_display(p),
|
||||||
|
"status": p.status,
|
||||||
|
} for p in projects]
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Project CRUD
|
# Project CRUD
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -161,6 +262,7 @@ async def create_project(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
project = Project(
|
project = Project(
|
||||||
id=str(uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
|
project_number=form_data.get("project_number"), # TMI ID: xxxx-YY format
|
||||||
name=form_data.get("name"),
|
name=form_data.get("name"),
|
||||||
description=form_data.get("description"),
|
description=form_data.get("description"),
|
||||||
project_type_id=form_data.get("project_type_id"),
|
project_type_id=form_data.get("project_type_id"),
|
||||||
@@ -197,6 +299,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"id": project.id,
|
"id": project.id,
|
||||||
|
"project_number": project.project_number,
|
||||||
"name": project.name,
|
"name": project.name,
|
||||||
"description": project.description,
|
"description": project.description,
|
||||||
"project_type_id": project.project_type_id,
|
"project_type_id": project.project_type_id,
|
||||||
@@ -493,9 +596,18 @@ async def get_project_schedules(
|
|||||||
"actions": [],
|
"actions": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Parse module_response for display
|
||||||
|
result_data = None
|
||||||
|
if schedule.module_response:
|
||||||
|
try:
|
||||||
|
result_data = json.loads(schedule.module_response)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
schedules_by_date[date_key]["actions"].append({
|
schedules_by_date[date_key]["actions"].append({
|
||||||
"schedule": schedule,
|
"schedule": schedule,
|
||||||
"location": location,
|
"location": location,
|
||||||
|
"result": result_data,
|
||||||
})
|
})
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/projects/schedule_list.html", {
|
return templates.TemplateResponse("partials/projects/schedule_list.html", {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
|
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request, Query
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
@@ -150,6 +150,8 @@ async def add_roster_unit(
|
|||||||
ip_address: str = Form(None),
|
ip_address: str = Form(None),
|
||||||
phone_number: str = Form(None),
|
phone_number: str = Form(None),
|
||||||
hardware_model: str = Form(None),
|
hardware_model: str = Form(None),
|
||||||
|
deployment_type: str = Form(None), # "seismograph" | "slm" - what device type modem is deployed with
|
||||||
|
deployed_with_unit_id: str = Form(None), # ID of seismograph/SLM this modem is deployed with
|
||||||
# Sound Level Meter-specific fields
|
# Sound Level Meter-specific fields
|
||||||
slm_host: str = Form(None),
|
slm_host: str = Form(None),
|
||||||
slm_tcp_port: str = Form(None),
|
slm_tcp_port: str = Form(None),
|
||||||
@@ -209,6 +211,7 @@ async def add_roster_unit(
|
|||||||
ip_address=ip_address if ip_address else None,
|
ip_address=ip_address if ip_address else None,
|
||||||
phone_number=phone_number if phone_number else None,
|
phone_number=phone_number if phone_number else None,
|
||||||
hardware_model=hardware_model if hardware_model else None,
|
hardware_model=hardware_model if hardware_model else None,
|
||||||
|
deployment_type=deployment_type if deployment_type else None,
|
||||||
# Sound Level Meter-specific fields
|
# Sound Level Meter-specific fields
|
||||||
slm_host=slm_host if slm_host else None,
|
slm_host=slm_host if slm_host else None,
|
||||||
slm_tcp_port=slm_tcp_port_int,
|
slm_tcp_port=slm_tcp_port_int,
|
||||||
@@ -219,6 +222,23 @@ async def add_roster_unit(
|
|||||||
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
||||||
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Auto-fill location data from modem if pairing and fields are empty
|
||||||
|
if deployed_with_modem_id:
|
||||||
|
modem = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
|
RosterUnit.device_type == "modem"
|
||||||
|
).first()
|
||||||
|
if modem:
|
||||||
|
if not unit.location and modem.location:
|
||||||
|
unit.location = modem.location
|
||||||
|
if not unit.address and modem.address:
|
||||||
|
unit.address = modem.address
|
||||||
|
if not unit.coordinates and modem.coordinates:
|
||||||
|
unit.coordinates = modem.coordinates
|
||||||
|
if not unit.project_id and modem.project_id:
|
||||||
|
unit.project_id = modem.project_id
|
||||||
|
|
||||||
db.add(unit)
|
db.add(unit)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -259,6 +279,145 @@ def get_modems_list(db: Session = Depends(get_db)):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search/modems")
|
||||||
|
def search_modems(
|
||||||
|
request: Request,
|
||||||
|
q: str = Query("", description="Search term"),
|
||||||
|
deployed_only: bool = Query(False, description="Only show deployed modems"),
|
||||||
|
exclude_retired: bool = Query(True, description="Exclude retired modems"),
|
||||||
|
limit: int = Query(10, le=50),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Search modems by ID, IP address, or note. Returns HTML partial for HTMX dropdown.
|
||||||
|
|
||||||
|
Used by modem picker component to find modems to link with seismographs/SLMs.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
query = db.query(RosterUnit).filter(RosterUnit.device_type == "modem")
|
||||||
|
|
||||||
|
if deployed_only:
|
||||||
|
query = query.filter(RosterUnit.deployed == True)
|
||||||
|
|
||||||
|
if exclude_retired:
|
||||||
|
query = query.filter(RosterUnit.retired == False)
|
||||||
|
|
||||||
|
# Search by ID, IP address, or note
|
||||||
|
if q and q.strip():
|
||||||
|
search_term = f"%{q.strip()}%"
|
||||||
|
query = query.filter(
|
||||||
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
|
(RosterUnit.ip_address.ilike(search_term)) |
|
||||||
|
(RosterUnit.note.ilike(search_term))
|
||||||
|
)
|
||||||
|
|
||||||
|
modems = query.order_by(RosterUnit.id).limit(limit).all()
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
results = []
|
||||||
|
for modem in modems:
|
||||||
|
# Build display text: ID - IP - Note (if available)
|
||||||
|
display_parts = [modem.id]
|
||||||
|
if modem.ip_address:
|
||||||
|
display_parts.append(modem.ip_address)
|
||||||
|
if modem.note:
|
||||||
|
display_parts.append(modem.note)
|
||||||
|
display = " - ".join(display_parts)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": modem.id,
|
||||||
|
"ip_address": modem.ip_address or "",
|
||||||
|
"phone_number": modem.phone_number or "",
|
||||||
|
"note": modem.note or "",
|
||||||
|
"deployed": modem.deployed,
|
||||||
|
"display": display
|
||||||
|
})
|
||||||
|
|
||||||
|
# Determine if we should show "no results" message
|
||||||
|
show_empty = len(results) == 0 and q and q.strip()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/modem_search_results.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"modems": results,
|
||||||
|
"query": q,
|
||||||
|
"show_empty": show_empty
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search/units")
|
||||||
|
def search_units(
|
||||||
|
request: Request,
|
||||||
|
q: str = Query("", description="Search term"),
|
||||||
|
device_type: str = Query(None, description="Filter by device type: seismograph, modem, slm"),
|
||||||
|
deployed_only: bool = Query(False, description="Only show deployed units"),
|
||||||
|
exclude_retired: bool = Query(True, description="Exclude retired units"),
|
||||||
|
limit: int = Query(10, le=50),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Search roster units by ID or note. Returns HTML partial for HTMX dropdown.
|
||||||
|
|
||||||
|
Used by unit picker component to find seismographs/SLMs to link with modems.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
query = db.query(RosterUnit)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if device_type:
|
||||||
|
query = query.filter(RosterUnit.device_type == device_type)
|
||||||
|
|
||||||
|
if deployed_only:
|
||||||
|
query = query.filter(RosterUnit.deployed == True)
|
||||||
|
|
||||||
|
if exclude_retired:
|
||||||
|
query = query.filter(RosterUnit.retired == False)
|
||||||
|
|
||||||
|
# Search by ID or note
|
||||||
|
if q and q.strip():
|
||||||
|
search_term = f"%{q.strip()}%"
|
||||||
|
query = query.filter(
|
||||||
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
|
(RosterUnit.note.ilike(search_term))
|
||||||
|
)
|
||||||
|
|
||||||
|
units = query.order_by(RosterUnit.id).limit(limit).all()
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
results = []
|
||||||
|
for unit in units:
|
||||||
|
results.append({
|
||||||
|
"id": unit.id,
|
||||||
|
"device_type": unit.device_type or "seismograph",
|
||||||
|
"note": unit.note or "",
|
||||||
|
"deployed": unit.deployed,
|
||||||
|
"display": f"{unit.id}" + (f" - {unit.note}" if unit.note else "")
|
||||||
|
})
|
||||||
|
|
||||||
|
# Determine if we should show "no results" message
|
||||||
|
show_empty = len(results) == 0 and q and q.strip()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/unit_search_results.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"units": results,
|
||||||
|
"query": q,
|
||||||
|
"show_empty": show_empty
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{unit_id}")
|
@router.get("/{unit_id}")
|
||||||
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""Get a single roster unit by ID"""
|
"""Get a single roster unit by ID"""
|
||||||
@@ -283,6 +442,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"ip_address": unit.ip_address or "",
|
"ip_address": unit.ip_address or "",
|
||||||
"phone_number": unit.phone_number or "",
|
"phone_number": unit.phone_number or "",
|
||||||
"hardware_model": unit.hardware_model or "",
|
"hardware_model": unit.hardware_model or "",
|
||||||
|
"deployment_type": unit.deployment_type or "",
|
||||||
|
"deployed_with_unit_id": unit.deployed_with_unit_id or "",
|
||||||
"slm_host": unit.slm_host or "",
|
"slm_host": unit.slm_host or "",
|
||||||
"slm_tcp_port": unit.slm_tcp_port or "",
|
"slm_tcp_port": unit.slm_tcp_port or "",
|
||||||
"slm_ftp_port": unit.slm_ftp_port or "",
|
"slm_ftp_port": unit.slm_ftp_port or "",
|
||||||
@@ -314,6 +475,8 @@ def edit_roster_unit(
|
|||||||
ip_address: str = Form(None),
|
ip_address: str = Form(None),
|
||||||
phone_number: str = Form(None),
|
phone_number: str = Form(None),
|
||||||
hardware_model: str = Form(None),
|
hardware_model: str = Form(None),
|
||||||
|
deployment_type: str = Form(None),
|
||||||
|
deployed_with_unit_id: str = Form(None),
|
||||||
# Sound Level Meter-specific fields
|
# Sound Level Meter-specific fields
|
||||||
slm_host: str = Form(None),
|
slm_host: str = Form(None),
|
||||||
slm_tcp_port: str = Form(None),
|
slm_tcp_port: str = Form(None),
|
||||||
@@ -323,6 +486,14 @@ def edit_roster_unit(
|
|||||||
slm_frequency_weighting: str = Form(None),
|
slm_frequency_weighting: str = Form(None),
|
||||||
slm_time_weighting: str = Form(None),
|
slm_time_weighting: str = Form(None),
|
||||||
slm_measurement_range: str = Form(None),
|
slm_measurement_range: str = Form(None),
|
||||||
|
# Cascade options - sync fields to paired device
|
||||||
|
cascade_to_unit_id: str = Form(None),
|
||||||
|
cascade_deployed: str = Form(None),
|
||||||
|
cascade_retired: str = Form(None),
|
||||||
|
cascade_project: str = Form(None),
|
||||||
|
cascade_location: str = Form(None),
|
||||||
|
cascade_coordinates: str = Form(None),
|
||||||
|
cascade_note: str = Form(None),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
@@ -374,10 +545,29 @@ def edit_roster_unit(
|
|||||||
unit.next_calibration_due = next_cal_date
|
unit.next_calibration_due = next_cal_date
|
||||||
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||||
|
|
||||||
|
# Auto-fill location data from modem if pairing and fields are empty
|
||||||
|
if deployed_with_modem_id:
|
||||||
|
modem = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
|
RosterUnit.device_type == "modem"
|
||||||
|
).first()
|
||||||
|
if modem:
|
||||||
|
# Only fill if the device field is empty
|
||||||
|
if not unit.location and modem.location:
|
||||||
|
unit.location = modem.location
|
||||||
|
if not unit.address and modem.address:
|
||||||
|
unit.address = modem.address
|
||||||
|
if not unit.coordinates and modem.coordinates:
|
||||||
|
unit.coordinates = modem.coordinates
|
||||||
|
if not unit.project_id and modem.project_id:
|
||||||
|
unit.project_id = modem.project_id
|
||||||
|
|
||||||
# Modem-specific fields
|
# Modem-specific fields
|
||||||
unit.ip_address = ip_address if ip_address else None
|
unit.ip_address = ip_address if ip_address else None
|
||||||
unit.phone_number = phone_number if phone_number else None
|
unit.phone_number = phone_number if phone_number else None
|
||||||
unit.hardware_model = hardware_model if hardware_model else None
|
unit.hardware_model = hardware_model if hardware_model else None
|
||||||
|
unit.deployment_type = deployment_type if deployment_type else None
|
||||||
|
unit.deployed_with_unit_id = deployed_with_unit_id if deployed_with_unit_id else None
|
||||||
|
|
||||||
# Sound Level Meter-specific fields
|
# Sound Level Meter-specific fields
|
||||||
unit.slm_host = slm_host if slm_host else None
|
unit.slm_host = slm_host if slm_host else None
|
||||||
@@ -403,8 +593,79 @@ def edit_roster_unit(
|
|||||||
old_status_text = "retired" if old_retired else "active"
|
old_status_text = "retired" if old_retired else "active"
|
||||||
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
|
# Handle cascade to paired device
|
||||||
|
cascaded_unit_id = None
|
||||||
|
if cascade_to_unit_id and cascade_to_unit_id.strip():
|
||||||
|
paired_unit = db.query(RosterUnit).filter(RosterUnit.id == cascade_to_unit_id).first()
|
||||||
|
if paired_unit:
|
||||||
|
cascaded_unit_id = paired_unit.id
|
||||||
|
|
||||||
|
# Cascade deployed status
|
||||||
|
if cascade_deployed in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_deployed = paired_unit.deployed
|
||||||
|
paired_unit.deployed = deployed_bool
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_deployed != deployed_bool:
|
||||||
|
status_text = "deployed" if deployed_bool else "benched"
|
||||||
|
old_status_text = "deployed" if old_paired_deployed else "benched"
|
||||||
|
record_history(db, paired_unit.id, "deployed_change", "deployed",
|
||||||
|
old_status_text, status_text, f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade retired status
|
||||||
|
if cascade_retired in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_retired = paired_unit.retired
|
||||||
|
paired_unit.retired = retired_bool
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_retired != retired_bool:
|
||||||
|
status_text = "retired" if retired_bool else "active"
|
||||||
|
old_status_text = "retired" if old_paired_retired else "active"
|
||||||
|
record_history(db, paired_unit.id, "retired_change", "retired",
|
||||||
|
old_status_text, status_text, f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade project
|
||||||
|
if cascade_project in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_project = paired_unit.project_id
|
||||||
|
paired_unit.project_id = project_id
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_project != project_id:
|
||||||
|
record_history(db, paired_unit.id, "project_change", "project_id",
|
||||||
|
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade address/location
|
||||||
|
if cascade_location in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_address = paired_unit.address
|
||||||
|
old_paired_location = paired_unit.location
|
||||||
|
paired_unit.address = address
|
||||||
|
paired_unit.location = location
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_address != address:
|
||||||
|
record_history(db, paired_unit.id, "address_change", "address",
|
||||||
|
old_paired_address or "", address or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade coordinates
|
||||||
|
if cascade_coordinates in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_coords = paired_unit.coordinates
|
||||||
|
paired_unit.coordinates = coordinates
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_coords != coordinates:
|
||||||
|
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
|
||||||
|
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade note
|
||||||
|
if cascade_note in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_note = paired_unit.note
|
||||||
|
paired_unit.note = note
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_note != note:
|
||||||
|
record_history(db, paired_unit.id, "note_change", "note",
|
||||||
|
old_paired_note or "", note or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
|
||||||
|
response = {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||||
|
if cascaded_unit_id:
|
||||||
|
response["cascaded_to"] = cascaded_unit_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/set-deployed/{unit_id}")
|
@router.post("/set-deployed/{unit_id}")
|
||||||
@@ -531,6 +792,37 @@ def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
|
|||||||
return {"message": "Updated", "id": unit_id, "note": note}
|
return {"message": "Updated", "id": unit_id, "note": note}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool(value: str) -> bool:
|
||||||
|
"""Parse boolean from CSV string value."""
|
||||||
|
return value.lower() in ('true', '1', 'yes') if value else False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int(value: str) -> int | None:
|
||||||
|
"""Parse integer from CSV string value, return None if empty or invalid."""
|
||||||
|
if not value or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value.strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: str) -> date | None:
|
||||||
|
"""Parse date from CSV string value (YYYY-MM-DD format)."""
|
||||||
|
if not value or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value.strip(), '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_csv_value(row: dict, key: str, default=None):
|
||||||
|
"""Get value from CSV row, return default if empty."""
|
||||||
|
value = row.get(key, '').strip() if row.get(key) else ''
|
||||||
|
return value if value else default
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import-csv")
|
@router.post("/import-csv")
|
||||||
async def import_csv(
|
async def import_csv(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -541,13 +833,40 @@ async def import_csv(
|
|||||||
Import roster units from CSV file.
|
Import roster units from CSV file.
|
||||||
|
|
||||||
Expected CSV columns (unit_id is required, others are optional):
|
Expected CSV columns (unit_id is required, others are optional):
|
||||||
- unit_id: Unique identifier for the unit
|
|
||||||
- unit_type: Type of unit (default: "series3")
|
Common fields (all device types):
|
||||||
- deployed: Boolean for deployment status (default: False)
|
- unit_id: Unique identifier for the unit (REQUIRED)
|
||||||
- retired: Boolean for retirement status (default: False)
|
- device_type: "seismograph", "modem", or "slm" (default: "seismograph")
|
||||||
|
- unit_type: Sub-type (e.g., "series3", "series4" for seismographs)
|
||||||
|
- deployed: Boolean (true/false/yes/no/1/0)
|
||||||
|
- retired: Boolean
|
||||||
- note: Notes about the unit
|
- note: Notes about the unit
|
||||||
- project_id: Project identifier
|
- project_id: Project identifier
|
||||||
- location: Location description
|
- location: Location description
|
||||||
|
- address: Street address
|
||||||
|
- coordinates: GPS coordinates (lat;lon or lat,lon)
|
||||||
|
|
||||||
|
Seismograph-specific:
|
||||||
|
- last_calibrated: Date (YYYY-MM-DD)
|
||||||
|
- next_calibration_due: Date (YYYY-MM-DD)
|
||||||
|
- deployed_with_modem_id: ID of paired modem
|
||||||
|
|
||||||
|
Modem-specific:
|
||||||
|
- ip_address: Device IP address
|
||||||
|
- phone_number: SIM card phone number
|
||||||
|
- hardware_model: Hardware model (e.g., IBR900, RV55)
|
||||||
|
|
||||||
|
SLM-specific:
|
||||||
|
- slm_host: Device IP or hostname
|
||||||
|
- slm_tcp_port: TCP control port (default 2255)
|
||||||
|
- slm_ftp_port: FTP port (default 21)
|
||||||
|
- slm_model: Device model (NL-43, NL-53)
|
||||||
|
- slm_serial_number: Serial number
|
||||||
|
- slm_frequency_weighting: A, C, or Z
|
||||||
|
- slm_time_weighting: F (Fast), S (Slow), I (Impulse)
|
||||||
|
- slm_measurement_range: e.g., "30-130 dB"
|
||||||
|
|
||||||
|
Lines starting with # are treated as comments and skipped.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file: CSV file upload
|
file: CSV file upload
|
||||||
@@ -560,6 +879,46 @@ async def import_csv(
|
|||||||
# Read file content
|
# Read file content
|
||||||
contents = await file.read()
|
contents = await file.read()
|
||||||
csv_text = contents.decode('utf-8')
|
csv_text = contents.decode('utf-8')
|
||||||
|
|
||||||
|
# Filter out comment lines (starting with #)
|
||||||
|
lines = csv_text.split('\n')
|
||||||
|
filtered_lines = [line for line in lines if not line.strip().startswith('#')]
|
||||||
|
csv_text = '\n'.join(filtered_lines)
|
||||||
|
|
||||||
|
# First pass: validate for duplicates and empty unit_ids
|
||||||
|
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
seen_unit_ids = {} # unit_id -> list of row numbers
|
||||||
|
empty_unit_id_rows = []
|
||||||
|
|
||||||
|
for row_num, row in enumerate(csv_reader, start=2):
|
||||||
|
unit_id = row.get('unit_id', '').strip()
|
||||||
|
if not unit_id:
|
||||||
|
empty_unit_id_rows.append(row_num)
|
||||||
|
else:
|
||||||
|
if unit_id not in seen_unit_ids:
|
||||||
|
seen_unit_ids[unit_id] = []
|
||||||
|
seen_unit_ids[unit_id].append(row_num)
|
||||||
|
|
||||||
|
# Check for validation errors
|
||||||
|
validation_errors = []
|
||||||
|
|
||||||
|
# Report empty unit_ids
|
||||||
|
if empty_unit_id_rows:
|
||||||
|
validation_errors.append(f"Empty unit_id on row(s): {', '.join(map(str, empty_unit_id_rows))}")
|
||||||
|
|
||||||
|
# Report duplicates
|
||||||
|
duplicates = {uid: rows for uid, rows in seen_unit_ids.items() if len(rows) > 1}
|
||||||
|
if duplicates:
|
||||||
|
for uid, rows in duplicates.items():
|
||||||
|
validation_errors.append(f"Duplicate unit_id '{uid}' on rows: {', '.join(map(str, rows))}")
|
||||||
|
|
||||||
|
if validation_errors:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="CSV validation failed:\n" + "\n".join(validation_errors)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second pass: actually import the data
|
||||||
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
|
||||||
results = {
|
results = {
|
||||||
@@ -580,6 +939,9 @@ async def import_csv(
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Determine device type
|
||||||
|
device_type = _get_csv_value(row, 'device_type', 'seismograph')
|
||||||
|
|
||||||
# Check if unit exists
|
# Check if unit exists
|
||||||
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
|
||||||
@@ -588,31 +950,90 @@ async def import_csv(
|
|||||||
results["skipped"].append(unit_id)
|
results["skipped"].append(unit_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Update existing unit
|
# Update existing unit - common fields
|
||||||
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3')
|
existing_unit.device_type = device_type
|
||||||
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed
|
existing_unit.unit_type = _get_csv_value(row, 'unit_type', existing_unit.unit_type or 'series3')
|
||||||
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired
|
existing_unit.deployed = _parse_bool(row.get('deployed', '')) if row.get('deployed') else existing_unit.deployed
|
||||||
existing_unit.note = row.get('note', existing_unit.note or '')
|
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
|
||||||
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
|
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
|
||||||
existing_unit.location = row.get('location', existing_unit.location)
|
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
|
||||||
existing_unit.address = row.get('address', existing_unit.address)
|
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
|
||||||
existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates)
|
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
|
||||||
|
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
|
||||||
existing_unit.last_updated = datetime.utcnow()
|
existing_unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Seismograph-specific fields
|
||||||
|
if row.get('last_calibrated'):
|
||||||
|
existing_unit.last_calibrated = _parse_date(row.get('last_calibrated'))
|
||||||
|
if row.get('next_calibration_due'):
|
||||||
|
existing_unit.next_calibration_due = _parse_date(row.get('next_calibration_due'))
|
||||||
|
if row.get('deployed_with_modem_id'):
|
||||||
|
existing_unit.deployed_with_modem_id = _get_csv_value(row, 'deployed_with_modem_id')
|
||||||
|
|
||||||
|
# Modem-specific fields
|
||||||
|
if row.get('ip_address'):
|
||||||
|
existing_unit.ip_address = _get_csv_value(row, 'ip_address')
|
||||||
|
if row.get('phone_number'):
|
||||||
|
existing_unit.phone_number = _get_csv_value(row, 'phone_number')
|
||||||
|
if row.get('hardware_model'):
|
||||||
|
existing_unit.hardware_model = _get_csv_value(row, 'hardware_model')
|
||||||
|
if row.get('deployment_type'):
|
||||||
|
existing_unit.deployment_type = _get_csv_value(row, 'deployment_type')
|
||||||
|
if row.get('deployed_with_unit_id'):
|
||||||
|
existing_unit.deployed_with_unit_id = _get_csv_value(row, 'deployed_with_unit_id')
|
||||||
|
|
||||||
|
# SLM-specific fields
|
||||||
|
if row.get('slm_host'):
|
||||||
|
existing_unit.slm_host = _get_csv_value(row, 'slm_host')
|
||||||
|
if row.get('slm_tcp_port'):
|
||||||
|
existing_unit.slm_tcp_port = _parse_int(row.get('slm_tcp_port'))
|
||||||
|
if row.get('slm_ftp_port'):
|
||||||
|
existing_unit.slm_ftp_port = _parse_int(row.get('slm_ftp_port'))
|
||||||
|
if row.get('slm_model'):
|
||||||
|
existing_unit.slm_model = _get_csv_value(row, 'slm_model')
|
||||||
|
if row.get('slm_serial_number'):
|
||||||
|
existing_unit.slm_serial_number = _get_csv_value(row, 'slm_serial_number')
|
||||||
|
if row.get('slm_frequency_weighting'):
|
||||||
|
existing_unit.slm_frequency_weighting = _get_csv_value(row, 'slm_frequency_weighting')
|
||||||
|
if row.get('slm_time_weighting'):
|
||||||
|
existing_unit.slm_time_weighting = _get_csv_value(row, 'slm_time_weighting')
|
||||||
|
if row.get('slm_measurement_range'):
|
||||||
|
existing_unit.slm_measurement_range = _get_csv_value(row, 'slm_measurement_range')
|
||||||
|
|
||||||
results["updated"].append(unit_id)
|
results["updated"].append(unit_id)
|
||||||
else:
|
else:
|
||||||
# Create new unit
|
# Create new unit with all fields
|
||||||
new_unit = RosterUnit(
|
new_unit = RosterUnit(
|
||||||
id=unit_id,
|
id=unit_id,
|
||||||
unit_type=row.get('unit_type', 'series3'),
|
device_type=device_type,
|
||||||
deployed=row.get('deployed', '').lower() in ('true', '1', 'yes'),
|
unit_type=_get_csv_value(row, 'unit_type', 'series3'),
|
||||||
retired=row.get('retired', '').lower() in ('true', '1', 'yes'),
|
deployed=_parse_bool(row.get('deployed', '')),
|
||||||
note=row.get('note', ''),
|
retired=_parse_bool(row.get('retired', '')),
|
||||||
project_id=row.get('project_id'),
|
note=_get_csv_value(row, 'note', ''),
|
||||||
location=row.get('location'),
|
project_id=_get_csv_value(row, 'project_id'),
|
||||||
address=row.get('address'),
|
location=_get_csv_value(row, 'location'),
|
||||||
coordinates=row.get('coordinates'),
|
address=_get_csv_value(row, 'address'),
|
||||||
last_updated=datetime.utcnow()
|
coordinates=_get_csv_value(row, 'coordinates'),
|
||||||
|
last_updated=datetime.utcnow(),
|
||||||
|
# Seismograph fields
|
||||||
|
last_calibrated=_parse_date(row.get('last_calibrated', '')),
|
||||||
|
next_calibration_due=_parse_date(row.get('next_calibration_due', '')),
|
||||||
|
deployed_with_modem_id=_get_csv_value(row, 'deployed_with_modem_id'),
|
||||||
|
# Modem fields
|
||||||
|
ip_address=_get_csv_value(row, 'ip_address'),
|
||||||
|
phone_number=_get_csv_value(row, 'phone_number'),
|
||||||
|
hardware_model=_get_csv_value(row, 'hardware_model'),
|
||||||
|
deployment_type=_get_csv_value(row, 'deployment_type'),
|
||||||
|
deployed_with_unit_id=_get_csv_value(row, 'deployed_with_unit_id'),
|
||||||
|
# SLM fields
|
||||||
|
slm_host=_get_csv_value(row, 'slm_host'),
|
||||||
|
slm_tcp_port=_parse_int(row.get('slm_tcp_port', '')),
|
||||||
|
slm_ftp_port=_parse_int(row.get('slm_ftp_port', '')),
|
||||||
|
slm_model=_get_csv_value(row, 'slm_model'),
|
||||||
|
slm_serial_number=_get_csv_value(row, 'slm_serial_number'),
|
||||||
|
slm_frequency_weighting=_get_csv_value(row, 'slm_frequency_weighting'),
|
||||||
|
slm_time_weighting=_get_csv_value(row, 'slm_time_weighting'),
|
||||||
|
slm_measurement_range=_get_csv_value(row, 'slm_measurement_range'),
|
||||||
)
|
)
|
||||||
db.add(new_unit)
|
db.add(new_unit)
|
||||||
results["added"].append(unit_id)
|
results["added"].append(unit_id)
|
||||||
|
|||||||
BIN
backend/static/icons/favicon-16.png
Normal file
|
After Width: | Height: | Size: 424 B |
BIN
backend/static/icons/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.0 KiB |
BIN
backend/static/terra-view-logo-dark.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
backend/static/terra-view-logo-dark@2x.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
backend/static/terra-view-logo-light.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
backend/static/terra-view-logo-light@2x.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
# --- TERRA-VIEW PRODUCTION ---
|
# --- TERRA-VIEW PRODUCTION ---
|
||||||
terra-view-prod:
|
terra-view:
|
||||||
build: .
|
build: .
|
||||||
container_name: terra-view
|
container_name: terra-view
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
unit_id,unit_type,deployed,retired,note,project_id,location
|
unit_id,device_type,unit_type,deployed,retired,note,project_id,location,address,coordinates,last_calibrated,next_calibration_due,deployed_with_modem_id,ip_address,phone_number,hardware_model,slm_host,slm_tcp_port,slm_ftp_port,slm_model,slm_serial_number,slm_frequency_weighting,slm_time_weighting,slm_measurement_range
|
||||||
BE1234,series3,true,false,Primary unit at main site,PROJ-001,San Francisco CA
|
# ============================================
|
||||||
BE5678,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA
|
# SEISMOGRAPHS (device_type=seismograph)
|
||||||
BE9012,series3,false,false,In maintenance,PROJ-002,Workshop
|
# ============================================
|
||||||
BE3456,series3,true,false,,PROJ-003,New York NY
|
BE1234,seismograph,series3,true,false,Primary unit at main site,PROJ-001,San Francisco CA,123 Market St,37.7749;-122.4194,2025-06-15,2026-06-15,MDM001,,,,,,,,,,,
|
||||||
BE7890,series3,false,true,Decommissioned 2024,,Storage
|
BE5678,seismograph,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA,456 Sunset Blvd,34.0522;-118.2437,2025-03-01,2026-03-01,MDM002,,,,,,,,,,,
|
||||||
|
BE9012,seismograph,series4,false,false,In maintenance - needs calibration,PROJ-002,Workshop,789 Industrial Way,,,,,,,,,,,,,,
|
||||||
|
BE3456,seismograph,series3,true,false,,PROJ-003,New York NY,101 Broadway,40.7128;-74.0060,2025-01-10,2026-01-10,,,,,,,,,,,
|
||||||
|
BE7890,seismograph,series3,false,true,Decommissioned 2024,,Storage,Warehouse B,,,,,,,,,,,,,,,
|
||||||
|
# ============================================
|
||||||
|
# MODEMS (device_type=modem)
|
||||||
|
# ============================================
|
||||||
|
MDM001,modem,,true,false,Cradlepoint at SF site,PROJ-001,San Francisco CA,123 Market St,37.7749;-122.4194,,,,,192.168.1.100,+1-555-0101,IBR900,,,,,,,
|
||||||
|
MDM002,modem,,true,false,Sierra Wireless at LA site,PROJ-001,Los Angeles CA,456 Sunset Blvd,34.0522;-118.2437,,,,,10.0.0.50,+1-555-0102,RV55,,,,,,,
|
||||||
|
MDM003,modem,,false,false,Spare modem in storage,,,Storage,Warehouse A,,,,,,+1-555-0103,IBR600,,,,,,,
|
||||||
|
MDM004,modem,,true,false,NYC backup modem,PROJ-003,New York NY,101 Broadway,40.7128;-74.0060,,,,,172.16.0.25,+1-555-0104,IBR1700,,,,,,,
|
||||||
|
# ============================================
|
||||||
|
# SOUND LEVEL METERS (device_type=slm)
|
||||||
|
# ============================================
|
||||||
|
SLM001,slm,,true,false,NL-43 at construction site A,PROJ-004,Downtown Site,500 Main St,40.7589;-73.9851,,,,,,,,192.168.10.101,2255,21,NL-43,12345678,A,F,30-130 dB
|
||||||
|
SLM002,slm,,true,false,NL-43 at construction site B,PROJ-004,Midtown Site,600 Park Ave,40.7614;-73.9776,,,MDM004,,,,,192.168.10.102,2255,21,NL-43,12345679,A,S,30-130 dB
|
||||||
|
SLM003,slm,,false,false,NL-53 spare unit,,,Storage,Warehouse A,,,,,,,,,,,NL-53,98765432,C,F,25-138 dB
|
||||||
|
SLM004,slm,,true,false,NL-43 nighttime monitoring,PROJ-005,Residential Area,200 Quiet Lane,40.7484;-73.9857,,,,,,,,10.0.5.50,2255,21,NL-43,11112222,A,S,30-130 dB
|
||||||
|
|||||||
|
@@ -20,6 +20,9 @@
|
|||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="/static/manifest.json">
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-192.png">
|
||||||
<meta name="theme-color" content="#f48b1c">
|
<meta name="theme-color" content="#f48b1c">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
@@ -68,7 +71,7 @@
|
|||||||
|
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
<body class="bg-gray-100 dark:bg-slate-800 text-gray-900 dark:text-gray-100">
|
||||||
|
|
||||||
<!-- Offline Indicator -->
|
<!-- Offline Indicator -->
|
||||||
<div id="offlineIndicator" class="offline-indicator">
|
<div id="offlineIndicator" class="offline-indicator">
|
||||||
@@ -85,10 +88,10 @@
|
|||||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange">
|
<a href="/" class="block">
|
||||||
Seismo<br>
|
<img src="/static/terra-view-logo-light.png" srcset="/static/terra-view-logo-light.png 1x, /static/terra-view-logo-light@2x.png 2x" alt="Terra-View" class="block dark:hidden w-44 h-auto">
|
||||||
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
|
<img src="/static/terra-view-logo-dark.png" srcset="/static/terra-view-logo-dark.png 1x, /static/terra-view-logo-dark@2x.png 2x" alt="Terra-View" class="hidden dark:block w-44 h-auto">
|
||||||
</h1>
|
</a>
|
||||||
<div class="flex items-center justify-between mt-2">
|
<div class="flex items-center justify-between mt-2">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
||||||
{% if environment == 'development' %}
|
{% if environment == 'development' %}
|
||||||
@@ -127,6 +130,13 @@
|
|||||||
Sound Level Meters
|
Sound Level Meters
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/modems" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/modems' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
Modems
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||||
@@ -374,10 +384,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Offline Database -->
|
<!-- Offline Database -->
|
||||||
<script src="/static/offline-db.js?v=0.4.3"></script>
|
<script src="/static/offline-db.js?v=0.5.1"></script>
|
||||||
|
|
||||||
<!-- Mobile JavaScript -->
|
<!-- Mobile JavaScript -->
|
||||||
<script src="/static/mobile.js?v=0.4.3"></script>
|
<script src="/static/mobile.js?v=0.5.1"></script>
|
||||||
|
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -27,10 +27,10 @@
|
|||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on::after-request="updateDashboard(event)">
|
hx-on::after-request="updateDashboard(event)">
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
|
||||||
<!-- Fleet Summary Card -->
|
<!-- Fleet Summary Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-summary-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Alerts Card -->
|
<!-- Recent Alerts Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-alerts-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recently Called In Units Card -->
|
<!-- Recently Called In Units Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-callins-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-callins-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -162,10 +162,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Today's Scheduled Actions Card -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="todays-actions-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('todays-actions')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="todays-actions-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" id="todays-actions-content"
|
||||||
|
hx-get="/dashboard/todays-actions"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fleet Map -->
|
<!-- Fleet Map -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="fleet-map-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="fleet-map-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -181,7 +204,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Photos Section -->
|
<!-- Recent Photos Section -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="recent-photos-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -201,7 +224,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fleet Status Section with Tabs -->
|
<!-- Fleet Status Section with Tabs -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-status-card">
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
||||||
@@ -316,7 +339,7 @@ function toggleCard(cardName) {
|
|||||||
// Restore card states from localStorage on page load
|
// Restore card states from localStorage on page load
|
||||||
function restoreCardStates() {
|
function restoreCardStates() {
|
||||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'todays-actions', 'fleet-map', 'fleet-status'];
|
||||||
|
|
||||||
cardNames.forEach(cardName => {
|
cardNames.forEach(cardName => {
|
||||||
const content = document.getElementById(`${cardName}-content`);
|
const content = document.getElementById(`${cardName}-content`);
|
||||||
|
|||||||
108
templates/modems.html
Normal file
@@ -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 %}
|
||||||
131
templates/partials/dashboard/todays_actions.html
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<!-- Today's Scheduled Actions - Dashboard Card Content -->
|
||||||
|
|
||||||
|
<!-- Summary stats -->
|
||||||
|
<div class="flex items-center gap-4 mb-4 text-sm">
|
||||||
|
{% if pending_count > 0 %}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2 h-2 bg-yellow-400 rounded-full"></span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ pending_count }} pending</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if completed_count > 0 %}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2 h-2 bg-green-400 rounded-full"></span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ completed_count }} completed</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if failed_count > 0 %}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2 h-2 bg-red-400 rounded-full"></span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ failed_count }} failed</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if total_count == 0 %}
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">No actions scheduled for today</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions list -->
|
||||||
|
{% if actions %}
|
||||||
|
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{% for item in actions %}
|
||||||
|
<div class="flex items-center gap-3 p-2 rounded-lg
|
||||||
|
{% if item.action.execution_status == 'pending' %}bg-yellow-50 dark:bg-yellow-900/20
|
||||||
|
{% elif item.action.execution_status == 'completed' %}bg-green-50 dark:bg-green-900/20
|
||||||
|
{% elif item.action.execution_status == 'failed' %}bg-red-50 dark:bg-red-900/20
|
||||||
|
{% else %}bg-gray-50 dark:bg-gray-700/50{% endif %}">
|
||||||
|
|
||||||
|
<!-- Action type icon -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if item.action.action_type == 'start' %}
|
||||||
|
<div class="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% elif item.action.action_type == 'stop' %}
|
||||||
|
<div class="w-8 h-8 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% elif item.action.action_type == 'download' %}
|
||||||
|
<div class="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action details -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-sm text-gray-900 dark:text-white capitalize">{{ item.action.action_type }}</span>
|
||||||
|
|
||||||
|
<!-- Status indicator -->
|
||||||
|
{% if item.action.execution_status == 'pending' %}
|
||||||
|
<span class="text-xs text-yellow-600 dark:text-yellow-400">
|
||||||
|
{{ item.action.scheduled_time|local_datetime('%H:%M') }}
|
||||||
|
</span>
|
||||||
|
{% elif item.action.execution_status == 'completed' %}
|
||||||
|
<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% elif item.action.execution_status == 'failed' %}
|
||||||
|
<svg class="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location/Project info -->
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{% if item.location %}
|
||||||
|
<a href="/projects/{{ item.action.project_id }}/nrl/{{ item.location.id }}"
|
||||||
|
class="hover:text-seismo-orange">
|
||||||
|
{{ item.location.name }}
|
||||||
|
</a>
|
||||||
|
{% elif item.project %}
|
||||||
|
<a href="/projects/{{ item.project.id }}" class="hover:text-seismo-orange">
|
||||||
|
{{ item.project.name }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result details for completed/failed -->
|
||||||
|
{% if item.action.execution_status == 'completed' and item.result %}
|
||||||
|
{% if item.result.cycle_response and item.result.cycle_response.downloaded_folder %}
|
||||||
|
<div class="text-xs text-green-600 dark:text-green-400">
|
||||||
|
{{ item.result.cycle_response.downloaded_folder }}
|
||||||
|
{% if item.result.cycle_response.download_success %}downloaded{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif item.action.execution_status == 'failed' and item.action.error_message %}
|
||||||
|
<div class="text-xs text-red-600 dark:text-red-400 truncate" title="{{ item.action.error_message }}">
|
||||||
|
{{ item.action.error_message[:50] }}{% if item.action.error_message|length > 50 %}...{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time -->
|
||||||
|
<div class="flex-shrink-0 text-right">
|
||||||
|
{% if item.action.execution_status == 'pending' %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">Scheduled</span>
|
||||||
|
{% elif item.action.executed_at %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ item.action.executed_at|local_datetime('%H:%M') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-10 h-10 mx-auto mb-2 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">No scheduled actions for today</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 %}
|
||||||
@@ -108,6 +108,53 @@
|
|||||||
<span class="ml-1 text-red-700 dark:text-red-300">{{ item.schedule.error_message }}</span>
|
<span class="ml-1 text-red-700 dark:text-red-300">{{ item.schedule.error_message }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Execution result details for completed/failed actions -->
|
||||||
|
{% if item.result and item.schedule.execution_status in ['completed', 'failed'] %}
|
||||||
|
<div class="mt-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded text-xs space-y-1">
|
||||||
|
{% if item.result.cycle_response %}
|
||||||
|
{% set cycle = item.result.cycle_response %}
|
||||||
|
{% if cycle.new_index is defined and cycle.new_index is not none %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Index:</span>
|
||||||
|
<span class="font-mono text-gray-700 dark:text-gray-300">{{ '%04d'|format(cycle.new_index) }}</span>
|
||||||
|
{% if cycle.old_index is defined and cycle.old_index is not none %}
|
||||||
|
<span class="text-gray-400">(was {{ '%04d'|format(cycle.old_index) }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if cycle.downloaded_folder %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Folder:</span>
|
||||||
|
<span class="font-mono text-gray-700 dark:text-gray-300">{{ cycle.downloaded_folder }}</span>
|
||||||
|
{% if cycle.download_success %}
|
||||||
|
<span class="text-green-600 dark:text-green-400">Downloaded</span>
|
||||||
|
{% elif cycle.download_attempted %}
|
||||||
|
<span class="text-red-600 dark:text-red-400">Download failed</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if cycle.clock_synced %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Clock synced:</span>
|
||||||
|
<span class="text-green-600 dark:text-green-400">Yes</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif item.result.device_response %}
|
||||||
|
{% set dev = item.result.device_response %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Device:</span>
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">{{ dev.message or dev.status }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.result.session_id %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Session:</span>
|
||||||
|
<span class="font-mono text-xs text-gray-600 dark:text-gray-400">{{ item.result.session_id[:8] }}...</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
|
|||||||
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
@@ -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 %}
|
||||||
@@ -162,9 +162,9 @@
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Upcoming Actions</h2>
|
<h2 id="schedules-title" class="text-xl font-semibold text-gray-900 dark:text-white">Upcoming Actions</h2>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p id="schedules-subtitle" class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Individual scheduled start/stop/download actions
|
Scheduled start/stop/download actions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<select id="schedules-filter" onchange="filterScheduledActions()"
|
<select id="schedules-filter" onchange="filterScheduledActions()"
|
||||||
@@ -963,6 +963,21 @@ function filterScheduledActions() {
|
|||||||
? `/api/projects/${projectId}/schedules`
|
? `/api/projects/${projectId}/schedules`
|
||||||
: `/api/projects/${projectId}/schedules?status=${filter}`;
|
: `/api/projects/${projectId}/schedules?status=${filter}`;
|
||||||
|
|
||||||
|
// Update section title based on filter
|
||||||
|
const titleEl = document.getElementById('schedules-title');
|
||||||
|
const subtitleEl = document.getElementById('schedules-subtitle');
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
'pending': { title: 'Upcoming Actions', subtitle: 'Scheduled start/stop/download actions' },
|
||||||
|
'completed': { title: 'Completed Actions', subtitle: 'Successfully executed actions' },
|
||||||
|
'failed': { title: 'Failed Actions', subtitle: 'Actions that encountered errors' },
|
||||||
|
'all': { title: 'All Actions', subtitle: 'Complete action history' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = titles[filter] || titles['all'];
|
||||||
|
titleEl.textContent = config.title;
|
||||||
|
subtitleEl.textContent = config.subtitle;
|
||||||
|
|
||||||
htmx.ajax('GET', url, {
|
htmx.ajax('GET', url, {
|
||||||
target: '#project-schedules',
|
target: '#project-schedules',
|
||||||
swap: 'innerHTML'
|
swap: 'innerHTML'
|
||||||
|
|||||||
@@ -131,10 +131,8 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||||
<input type="text" name="project_id"
|
{% include "partials/project_picker.html" with context %}
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
|
||||||
placeholder="PROJ-001">
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
|
||||||
@@ -159,8 +157,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="modemPairingField" class="hidden">
|
<div id="modemPairingField" class="hidden">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
<input type="text" name="deployed_with_modem_id" placeholder="Modem ID"
|
{% set picker_id = "-add-seismo" %}
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
{% include "partials/modem_picker.html" with context %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,6 +181,21 @@
|
|||||||
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
|
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
||||||
|
<select name="deployment_type"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="">Not assigned</option>
|
||||||
|
<option value="seismograph">Seismograph</option>
|
||||||
|
<option value="slm">Sound Level Meter (SLM)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
|
||||||
|
{% set picker_id = "-add-modem" %}
|
||||||
|
{% set device_type_filter = "" %}
|
||||||
|
{% include "partials/unit_picker.html" with context %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sound Level Meter-specific fields -->
|
<!-- Sound Level Meter-specific fields -->
|
||||||
@@ -297,9 +310,9 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||||
<input type="text" name="project_id" id="editProjectId"
|
{% set picker_id = "-edit" %}
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
{% include "partials/project_picker.html" with context %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
@@ -327,8 +340,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="editModemPairingField" class="hidden">
|
<div id="editModemPairingField" class="hidden">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
<input type="text" name="deployed_with_modem_id" id="editDeployedWithModemId" placeholder="Modem ID"
|
{% set picker_id = "-edit-seismo" %}
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
{% include "partials/modem_picker.html" with context %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -350,6 +363,21 @@
|
|||||||
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
|
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
||||||
|
<select name="deployment_type" id="editDeploymentType"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="">Not assigned</option>
|
||||||
|
<option value="seismograph">Seismograph</option>
|
||||||
|
<option value="slm">Sound Level Meter (SLM)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
|
||||||
|
{% set picker_id = "-edit-modem" %}
|
||||||
|
{% set device_type_filter = "" %}
|
||||||
|
{% include "partials/unit_picker.html" with context %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sound Level Meter-specific fields -->
|
<!-- Sound Level Meter-specific fields -->
|
||||||
@@ -419,6 +447,55 @@
|
|||||||
<textarea name="note" id="editNote" rows="3"
|
<textarea name="note" id="editNote" rows="3"
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cascade to Paired Device Section -->
|
||||||
|
<div id="editCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Also update paired device: <span id="editPairedDeviceName" class="text-seismo-orange"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="cascade_to_unit_id" id="editCascadeToUnitId" value="">
|
||||||
|
<div class="grid grid-cols-2 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_deployed" id="editCascadeDeployed" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed status</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_retired" id="editCascadeRetired" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Retired status</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_project" id="editCascadeProject" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_location" id="editCascadeLocation" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_coordinates" id="editCascadeCoordinates" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_note" id="editCascadeNote" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Notes</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
Check the fields you want to sync to the paired device
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-4">
|
<div class="flex gap-3 pt-4">
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
Save Changes
|
Save Changes
|
||||||
@@ -650,11 +727,7 @@
|
|||||||
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
|
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
|
||||||
if (event.detail.successful) {
|
if (event.detail.successful) {
|
||||||
closeAddUnitModal();
|
closeAddUnitModal();
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
// Show success message
|
// Show success message
|
||||||
alert('Unit added successfully!');
|
alert('Unit added successfully!');
|
||||||
} else {
|
} else {
|
||||||
@@ -692,6 +765,33 @@
|
|||||||
function closeEditUnitModal() {
|
function closeEditUnitModal() {
|
||||||
document.getElementById('editUnitModal').classList.add('hidden');
|
document.getElementById('editUnitModal').classList.add('hidden');
|
||||||
document.getElementById('editUnitForm').reset();
|
document.getElementById('editUnitForm').reset();
|
||||||
|
// Also clear the project picker
|
||||||
|
const projectPickerValue = document.getElementById('project-picker-value-edit');
|
||||||
|
const projectPickerSearch = document.getElementById('project-picker-search-edit');
|
||||||
|
const projectPickerClear = document.getElementById('project-picker-clear-edit');
|
||||||
|
if (projectPickerValue) projectPickerValue.value = '';
|
||||||
|
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||||
|
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch project display name for edit modal
|
||||||
|
async function fetchProjectDisplayForEdit(projectId) {
|
||||||
|
if (!projectId) return '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const project = await response.json();
|
||||||
|
const parts = [
|
||||||
|
project.project_number,
|
||||||
|
project.client_name,
|
||||||
|
project.name
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(' - ') || projectId;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch project:', e);
|
||||||
|
}
|
||||||
|
return projectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle device-specific fields in edit modal
|
// Toggle device-specific fields in edit modal
|
||||||
@@ -753,7 +853,23 @@
|
|||||||
document.getElementById('editUnitId').value = unit.id;
|
document.getElementById('editUnitId').value = unit.id;
|
||||||
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
|
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
|
||||||
document.getElementById('editUnitType').value = unit.unit_type;
|
document.getElementById('editUnitType').value = unit.unit_type;
|
||||||
document.getElementById('editProjectId').value = unit.project_id;
|
|
||||||
|
// Populate project picker (uses -edit suffix)
|
||||||
|
const projectPickerValue = document.getElementById('project-picker-value-edit');
|
||||||
|
const projectPickerSearch = document.getElementById('project-picker-search-edit');
|
||||||
|
const projectPickerClear = document.getElementById('project-picker-clear-edit');
|
||||||
|
if (projectPickerValue) projectPickerValue.value = unit.project_id || '';
|
||||||
|
if (unit.project_id) {
|
||||||
|
// Fetch project display name
|
||||||
|
fetchProjectDisplayForEdit(unit.project_id).then(displayText => {
|
||||||
|
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
||||||
|
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||||
|
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('editAddress').value = unit.address;
|
document.getElementById('editAddress').value = unit.address;
|
||||||
document.getElementById('editCoordinates').value = unit.coordinates;
|
document.getElementById('editCoordinates').value = unit.coordinates;
|
||||||
document.getElementById('editNote').value = unit.note;
|
document.getElementById('editNote').value = unit.note;
|
||||||
@@ -765,12 +881,62 @@
|
|||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
|
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
|
||||||
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
|
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
|
||||||
document.getElementById('editDeployedWithModemId').value = unit.deployed_with_modem_id;
|
|
||||||
|
// Populate modem picker for seismograph (uses -edit-seismo suffix)
|
||||||
|
const modemPickerValue = document.getElementById('modem-picker-value-edit-seismo');
|
||||||
|
const modemPickerSearch = document.getElementById('modem-picker-search-edit-seismo');
|
||||||
|
const modemPickerClear = document.getElementById('modem-picker-clear-edit-seismo');
|
||||||
|
if (modemPickerValue) modemPickerValue.value = unit.deployed_with_modem_id || '';
|
||||||
|
if (unit.deployed_with_modem_id) {
|
||||||
|
// Fetch modem display (ID + IP + note)
|
||||||
|
fetch(`/api/roster/${unit.deployed_with_modem_id}`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(modem => {
|
||||||
|
if (modem && modemPickerSearch) {
|
||||||
|
let display = modem.id;
|
||||||
|
if (modem.ip_address) display += ` - ${modem.ip_address}`;
|
||||||
|
if (modem.note) display += ` - ${modem.note}`;
|
||||||
|
modemPickerSearch.value = display;
|
||||||
|
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (modemPickerSearch) modemPickerSearch.value = unit.deployed_with_modem_id;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (modemPickerSearch) modemPickerSearch.value = '';
|
||||||
|
if (modemPickerClear) modemPickerClear.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Modem fields
|
// Modem fields
|
||||||
document.getElementById('editIpAddress').value = unit.ip_address;
|
document.getElementById('editIpAddress').value = unit.ip_address;
|
||||||
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
||||||
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
||||||
|
document.getElementById('editDeploymentType').value = unit.deployment_type || '';
|
||||||
|
|
||||||
|
// Populate unit picker for modem (uses -edit-modem suffix)
|
||||||
|
const unitPickerValue = document.getElementById('unit-picker-value-edit-modem');
|
||||||
|
const unitPickerSearch = document.getElementById('unit-picker-search-edit-modem');
|
||||||
|
const unitPickerClear = document.getElementById('unit-picker-clear-edit-modem');
|
||||||
|
if (unitPickerValue) unitPickerValue.value = unit.deployed_with_unit_id || '';
|
||||||
|
if (unit.deployed_with_unit_id) {
|
||||||
|
// Fetch unit display (ID + note)
|
||||||
|
fetch(`/api/roster/${unit.deployed_with_unit_id}`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(linkedUnit => {
|
||||||
|
if (linkedUnit && unitPickerSearch) {
|
||||||
|
const display = linkedUnit.note ? `${linkedUnit.id} - ${linkedUnit.note}` : linkedUnit.id;
|
||||||
|
unitPickerSearch.value = display;
|
||||||
|
if (unitPickerClear) unitPickerClear.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (unitPickerSearch) unitPickerSearch.value = unit.deployed_with_unit_id;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (unitPickerSearch) unitPickerSearch.value = '';
|
||||||
|
if (unitPickerClear) unitPickerClear.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// SLM fields
|
// SLM fields
|
||||||
document.getElementById('editSlmModel').value = unit.slm_model || '';
|
document.getElementById('editSlmModel').value = unit.slm_model || '';
|
||||||
@@ -781,6 +947,35 @@
|
|||||||
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
|
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
|
||||||
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
|
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
|
||||||
|
|
||||||
|
// Cascade section - show if there's a paired device
|
||||||
|
const cascadeSection = document.getElementById('editCascadeSection');
|
||||||
|
const cascadeToUnitId = document.getElementById('editCascadeToUnitId');
|
||||||
|
const pairedDeviceName = document.getElementById('editPairedDeviceName');
|
||||||
|
|
||||||
|
// Determine paired device based on device type
|
||||||
|
let pairedUnitId = null;
|
||||||
|
if (unit.device_type === 'modem' && unit.deployed_with_unit_id) {
|
||||||
|
pairedUnitId = unit.deployed_with_unit_id;
|
||||||
|
} else if ((unit.device_type === 'seismograph' || unit.device_type === 'sound_level_meter') && unit.deployed_with_modem_id) {
|
||||||
|
pairedUnitId = unit.deployed_with_modem_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pairedUnitId) {
|
||||||
|
cascadeToUnitId.value = pairedUnitId;
|
||||||
|
pairedDeviceName.textContent = pairedUnitId;
|
||||||
|
cascadeSection.classList.remove('hidden');
|
||||||
|
// Reset checkboxes
|
||||||
|
document.getElementById('editCascadeDeployed').checked = false;
|
||||||
|
document.getElementById('editCascadeRetired').checked = false;
|
||||||
|
document.getElementById('editCascadeProject').checked = false;
|
||||||
|
document.getElementById('editCascadeLocation').checked = false;
|
||||||
|
document.getElementById('editCascadeCoordinates').checked = false;
|
||||||
|
document.getElementById('editCascadeNote').checked = false;
|
||||||
|
} else {
|
||||||
|
cascadeToUnitId.value = '';
|
||||||
|
cascadeSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Store unit ID for form submission
|
// Store unit ID for form submission
|
||||||
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
||||||
|
|
||||||
@@ -814,11 +1009,7 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
closeEditUnitModal();
|
closeEditUnitModal();
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
alert('Unit updated successfully!');
|
alert('Unit updated successfully!');
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -846,11 +1037,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
|
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -878,11 +1065,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
alert(`Unit ${unitId} moved to ignore list`);
|
alert(`Unit ${unitId} moved to ignore list`);
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -905,11 +1088,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
alert(`Unit ${unitId} deleted successfully`);
|
alert(`Unit ${unitId} deleted successfully`);
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -948,11 +1127,7 @@
|
|||||||
`;
|
`;
|
||||||
resultDiv.classList.remove('hidden');
|
resultDiv.classList.remove('hidden');
|
||||||
|
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal after 2 seconds
|
// Close modal after 2 seconds
|
||||||
setTimeout(() => closeImportModal(), 2000);
|
setTimeout(() => closeImportModal(), 2000);
|
||||||
@@ -968,35 +1143,26 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle roster tab switching with auto-refresh
|
// Refresh device list (applies current client-side filters after load)
|
||||||
let currentRosterEndpoint = '/partials/roster-deployed'; // Default to deployed tab
|
function refreshDeviceList() {
|
||||||
|
htmx.ajax('GET', '/partials/devices-all', {
|
||||||
|
target: '#device-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
}).then(() => {
|
||||||
|
// Re-apply filters after content loads
|
||||||
|
setTimeout(filterDevices, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const tabButtons = document.querySelectorAll('.roster-tab-button');
|
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
|
||||||
|
|
||||||
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(() => {
|
setInterval(() => {
|
||||||
const rosterContent = document.getElementById('roster-content');
|
const deviceContent = document.getElementById('device-content');
|
||||||
if (rosterContent) {
|
if (deviceContent && !document.querySelector('.modal:not(.hidden)')) {
|
||||||
// Use HTMX to trigger a refresh of the current endpoint
|
// Only auto-refresh if no modal is open
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
refreshDeviceList();
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, 10000); // 10 seconds
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Un-ignore Unit (remove from ignored list)
|
// Un-ignore Unit (remove from ignored list)
|
||||||
@@ -1345,4 +1511,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Include Project Create Modal for inline project creation -->
|
||||||
|
{% include "partials/project_create_modal.html" %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -121,8 +121,13 @@
|
|||||||
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project ID</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project</label>
|
||||||
<p id="viewProjectId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
<p id="viewProjectContainer" class="mt-1">
|
||||||
|
<a id="viewProjectLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
||||||
|
<span id="viewProjectText">--</span>
|
||||||
|
</a>
|
||||||
|
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||||
@@ -172,6 +177,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Paired Device (for modems only) -->
|
||||||
|
<div id="viewPairedDeviceSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Paired Device</h3>
|
||||||
|
<div id="pairedDeviceInfo">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connectivity (for modems only) -->
|
||||||
|
<div id="viewConnectivitySection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connectivity</h3>
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
|
<button onclick="pingModem()" id="modemPingBtn"
|
||||||
|
class="px-4 py-2 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded-lg flex items-center gap-2 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
Ping Test
|
||||||
|
</button>
|
||||||
|
<span id="modemPingResult" class="text-sm text-gray-500 dark:text-gray-400">--</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Future Diagnostics Placeholders -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 opacity-60">
|
||||||
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Signal Strength</p>
|
||||||
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- dBm</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Data Usage</p>
|
||||||
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- MB</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Uptime</p>
|
||||||
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">--</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||||
@@ -251,11 +298,11 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Project ID -->
|
<!-- Project -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||||
<input type="text" name="project_id" id="projectId"
|
{% set picker_id = "-detail" %}
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
{% include "partials/project_picker.html" with context %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Address -->
|
<!-- Address -->
|
||||||
@@ -415,6 +462,26 @@ let currentSnapshot = null;
|
|||||||
let unitMap = null;
|
let unitMap = null;
|
||||||
let mapMarker = null;
|
let mapMarker = null;
|
||||||
|
|
||||||
|
// Fetch project display name (combines project_number, client_name, name)
|
||||||
|
async function fetchProjectDisplay(projectId) {
|
||||||
|
if (!projectId) return '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const project = await response.json();
|
||||||
|
const parts = [
|
||||||
|
project.project_number,
|
||||||
|
project.client_name,
|
||||||
|
project.name
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(' - ') || projectId;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch project:', e);
|
||||||
|
}
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
|
|
||||||
// Load unit data on page load
|
// Load unit data on page load
|
||||||
async function loadUnitData() {
|
async function loadUnitData() {
|
||||||
try {
|
try {
|
||||||
@@ -536,7 +603,31 @@ function populateViewMode() {
|
|||||||
// Basic info
|
// Basic info
|
||||||
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
||||||
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
|
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
|
||||||
document.getElementById('viewProjectId').textContent = currentUnit.project_id || '--';
|
|
||||||
|
// Project display with clickable link
|
||||||
|
const projectId = currentUnit.project_id;
|
||||||
|
const projectLink = document.getElementById('viewProjectLink');
|
||||||
|
const projectNoLink = document.getElementById('viewProjectNoLink');
|
||||||
|
const projectText = document.getElementById('viewProjectText');
|
||||||
|
|
||||||
|
if (projectId) {
|
||||||
|
// Fetch project display name and show link
|
||||||
|
fetchProjectDisplay(projectId).then(displayText => {
|
||||||
|
if (projectText) projectText.textContent = displayText;
|
||||||
|
if (projectLink) {
|
||||||
|
projectLink.href = `/projects/${projectId}`;
|
||||||
|
projectLink.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (projectNoLink) projectNoLink.classList.add('hidden');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (projectNoLink) {
|
||||||
|
projectNoLink.textContent = 'Not assigned';
|
||||||
|
projectNoLink.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (projectLink) projectLink.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
||||||
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
||||||
|
|
||||||
@@ -557,9 +648,15 @@ function populateViewMode() {
|
|||||||
if (currentUnit.device_type === 'modem') {
|
if (currentUnit.device_type === 'modem') {
|
||||||
document.getElementById('viewSeismographFields').classList.add('hidden');
|
document.getElementById('viewSeismographFields').classList.add('hidden');
|
||||||
document.getElementById('viewModemFields').classList.remove('hidden');
|
document.getElementById('viewModemFields').classList.remove('hidden');
|
||||||
|
document.getElementById('viewPairedDeviceSection').classList.remove('hidden');
|
||||||
|
document.getElementById('viewConnectivitySection').classList.remove('hidden');
|
||||||
|
// Load paired device info
|
||||||
|
loadPairedDevice();
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('viewSeismographFields').classList.remove('hidden');
|
document.getElementById('viewSeismographFields').classList.remove('hidden');
|
||||||
document.getElementById('viewModemFields').classList.add('hidden');
|
document.getElementById('viewModemFields').classList.add('hidden');
|
||||||
|
document.getElementById('viewPairedDeviceSection').classList.add('hidden');
|
||||||
|
document.getElementById('viewConnectivitySection').classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +664,22 @@ function populateViewMode() {
|
|||||||
function populateEditForm() {
|
function populateEditForm() {
|
||||||
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
|
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
|
||||||
document.getElementById('unitType').value = currentUnit.unit_type || '';
|
document.getElementById('unitType').value = currentUnit.unit_type || '';
|
||||||
document.getElementById('projectId').value = currentUnit.project_id || '';
|
|
||||||
|
// Populate project picker (uses -detail suffix)
|
||||||
|
const projectPickerValue = document.getElementById('project-picker-value-detail');
|
||||||
|
const projectPickerSearch = document.getElementById('project-picker-search-detail');
|
||||||
|
const projectPickerClear = document.getElementById('project-picker-clear-detail');
|
||||||
|
if (projectPickerValue) projectPickerValue.value = currentUnit.project_id || '';
|
||||||
|
if (currentUnit.project_id) {
|
||||||
|
fetchProjectDisplay(currentUnit.project_id).then(displayText => {
|
||||||
|
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
||||||
|
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||||
|
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('address').value = currentUnit.address || '';
|
document.getElementById('address').value = currentUnit.address || '';
|
||||||
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
||||||
document.getElementById('deployed').checked = currentUnit.deployed;
|
document.getElementById('deployed').checked = currentUnit.deployed;
|
||||||
@@ -999,10 +1111,66 @@ async function deleteHistoryEntry(historyId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load paired device info for modems
|
||||||
|
async function loadPairedDevice() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/modem-dashboard/${unitId}/paired-device-html`);
|
||||||
|
if (response.ok) {
|
||||||
|
const html = await response.text();
|
||||||
|
document.getElementById('pairedDeviceInfo').innerHTML = html;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading paired device:', error);
|
||||||
|
document.getElementById('pairedDeviceInfo').innerHTML = '<p class="text-red-500 text-sm">Failed to load paired device info</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping modem and show result
|
||||||
|
async function pingModem() {
|
||||||
|
const btn = document.getElementById('modemPingBtn');
|
||||||
|
const resultSpan = document.getElementById('modemPingResult');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = `
|
||||||
|
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Pinging...
|
||||||
|
`;
|
||||||
|
btn.disabled = true;
|
||||||
|
resultSpan.textContent = 'Testing connection...';
|
||||||
|
resultSpan.className = 'text-sm text-gray-500 dark:text-gray-400';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/modem-dashboard/${unitId}/ping`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1"></span>Online (${data.response_time_ms}ms)`;
|
||||||
|
resultSpan.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
} else {
|
||||||
|
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>${data.detail || 'Offline'}`;
|
||||||
|
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultSpan.textContent = 'Error: ' + error.message;
|
||||||
|
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore button
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Load data when page loads
|
// Load data when page loads
|
||||||
loadUnitData().then(() => {
|
loadUnitData().then(() => {
|
||||||
loadPhotos();
|
loadPhotos();
|
||||||
loadUnitHistory();
|
loadUnitHistory();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Include Project Create Modal for inline project creation -->
|
||||||
|
{% include "partials/project_create_modal.html" %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||