31 Commits

Author SHA1 Message Date
b15d434fce Merge pull request 'version bump 0.6.1' (#28) from dev into main
Reviewed-on: #28
2026-02-15 23:47:22 -05:00
serversdwn
70ef43de11 version bump to 0.6.1 2026-02-16 04:46:09 +00:00
7b4e12c127 Merge pull request 'merge dev 0.6.1 to main.' (#27) from dev into main
Reviewed-on: #27
2026-02-15 23:44:19 -05:00
serversdwn
24473c9ca3 chore: version bump to 0.6.0 2026-02-16 04:30:50 +00:00
serversdwn
caabfd0c42 fix: One off scheduler time now set in local time rather than UTC 2026-02-13 17:02:00 +00:00
serversdwn
ebe60d2b7d feat: enhance roster unit management with bidirectional pairing sync
fix: scheduler one off
2026-02-11 20:13:27 +00:00
serversdwn
842e9d6f61 feat: add support for one-off recording schedules with start and end datetime 2026-02-10 07:08:03 +00:00
742a98a8ed Merge pull request 'version bump to 0.6' (#26) from dev into main
Reviewed-on: #26
2026-02-06 16:42:06 -05:00
3b29c4d645 Merge branch 'main' into dev 2026-02-06 16:41:34 -05:00
serversdwn
63d9c59873 doc/chore: v0.6.0 version bump 2026-02-06 21:25:49 +00:00
serversdwn
794bfc00dc Merge branch 'dev' of ssh://10.0.0.2:2222/serversdown/terra-view into dev 2026-02-06 21:19:51 +00:00
serversdwn
89662d2fa5 Change: user sets date of previous calibration, not upcoming expire dates.
- seismograph list page enhanced with better visabilty, filtering, sorting, and calibration dates color coded.
2026-02-06 21:17:14 +00:00
serversdwn
eb0a99796d add: Calander and reservation mode implemented. 2026-02-06 20:40:31 +00:00
b47e69e609 Merge pull request 'Merge dev v0.5.1 before 0.6 update with calender.' (#25) from dev into main
Reviewed-on: #25
2026-02-06 14:56:12 -05:00
1cb25b6c17 Merge branch 'main' into dev 2026-02-06 14:55:54 -05:00
serversdwn
e515bff1a9 fix: tab state persists in url hash. Settings save nolonger reload the page. Scheduler management now cascades to individual events. 2026-02-04 18:12:18 +00:00
serversdwn
f296806fd1 add: link to modem login page in unit-detail page 2026-02-03 23:10:23 +00:00
serversdwn
24da5ab79f add: Modem model # now its own config. allowing for different options on different model #s 2026-02-02 21:15:27 +00:00
serversdwn
305540f564 fix: SLM modal field now only contains correct fields. IP address is passed via modem pairing.
Add: Fuzzy-search modem pairing for slms
2026-02-01 20:39:34 +00:00
serversdwn
639b485c28 Fix: mobile type display in roster and device tables incorrect. 2026-02-01 07:21:34 +00:00
serversdwn
d78bafb76e fix: improved 24hr cycle via scheduler. Should help prevent issues with DLs. 2026-01-31 22:31:34 +00:00
serversdwn
8373cff10d added: Pairing options now available from the modem page. 2026-01-29 23:04:18 +00:00
serversdwn
4957a08198 fix: improvedr pair status sharing. 2026-01-29 16:37:59 +00:00
serversdwn
05482bd903 Add:
- pair_devices.html template for device pairing interface
- SLMM device control lock prevents flooding nl43.
Fix:
- Polling intervals for SLMM.
- modem view now list
- device pairing much improved.
- various other tweaks through out UI.
- SLMM Scheduled downloads fixed.
2026-01-29 07:50:13 +00:00
serversdwn
5ee6f5eb28 feat: Enhance dashboard with filtering options and sync SLM status
- Added a new filtering system to the dashboard for device types and statuses.
- Implemented asynchronous SLM status synchronization to update the Emitter table.
- Updated the status snapshot endpoint to sync SLM status before generating the snapshot.
- Refactored the dashboard HTML to include filter controls and JavaScript for managing filter state.
- Improved the unit detail page to handle modem associations and cascade updates to paired devices.
- Removed redundant code related to syncing start time for measuring devices.
2026-01-28 20:02:10 +00:00
7ce0f6115d Merge pull request 'Update main to 0.5.1. See changelog.' (#18) from dev into main
## [0.5.1] - 2026-01-27

### Added
- **Dashboard Schedule View**: Today's scheduled actions now display directly on the main dashboard
  - New "Today's Actions" panel showing upcoming and past scheduled events
  - Schedule list partial for project-specific schedule views
  - API endpoint for fetching today's schedule data
- **New Branding Assets**: Complete logo rework for Terra-View
  - New Terra-View logos for light and dark themes
  - Retina-ready (@2x) logo variants
  - Updated favicons (16px and 32px)
  - Refreshed PWA icons (72px through 512px)

### Changed
- **Dashboard Layout**: Reorganized to include schedule information panel
- **Base Template**: Updated to use new Terra-View logos with theme-aware switching
2026-01-27 22:29:56 -05:00
serversdwn
6492fdff82 BIG update: Update to 0.5.1. Added:
-Project management
-Modem Managerment
-Modem/unit pairing

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

and more
2026-01-28 03:26:52 +00:00
serversdwn
38c600aca3 Feat: schedule added to dashboard view. logo rework 2026-01-23 19:07:42 +00:00
serversdwn
eeda94926f doc: update to 0.4.4 (again) 2026-01-23 08:46:46 +00:00
serversdwn
57be9bf1f1 Docs: update to 0.4.4 2026-01-23 08:26:02 +00:00
75 changed files with 10193 additions and 507 deletions

View File

@@ -1,10 +1,94 @@
# Changelog
All notable changes to Seismo Fleet Manager will be documented in this file.
All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.6.1] - 2026-02-16
### Added
- **One-Off Recording Schedules**: Support for scheduling single recordings with specific start and end datetimes
- **Bidirectional Pairing Sync**: Pairing a device with a modem now automatically updates both sides, clearing stale pairings when reassigned
- **Auto-Fill Notes from Modem**: Notes are now copied from modem to paired device when fields are empty
- **SLMM Download Requests**: New `_download_request` method in SLMM client for binary file downloads with local save
### Fixed
- **Scheduler Timezone**: One-off scheduler times now use local time instead of UTC
- **Pairing Consistency**: Old device references are properly cleared when a modem is re-paired to a new device
## [0.6.0] - 2026-02-06
### Added
- **Calendar & Reservation Mode**: Fleet calendar view with reservation system for scheduling device deployments
- **Device Pairing Interface**: New two-column pairing page (`/pair-devices`) for linking recorders (seismographs/SLMs) with modems
- Visual pairing interface with drag-and-drop style interactions
- Fuzzy-search modem pairing for SLMs
- Pairing options now accessible from modem page
- Improved pair status sharing across views
- **Modem Dashboard Enhancements**:
- Modem model number now a dedicated configuration field with per-model options
- Direct link to modem login page from unit detail view
- Modem view converted to list format
- **Seismograph List Improvements**:
- Enhanced visibility with better filtering and sorting
- Calibration dates now color-coded for quick status assessment
- User sets date of previous calibration (not expiry) for clearer workflow
- **SLMM Device Control Lock**: Prevents command flooding to NL-43 devices
### Changed
- **Calibration Date UX**: Users now set the date of the previous calibration rather than upcoming expiry dates - more intuitive workflow
- **Settings Persistence**: Settings save no longer reloads the page
- **Tab State**: Tab state now persists in URL hash for better navigation
- **Scheduler Management**: Schedule changes now cascade to individual events
- **Dashboard Filtering**: Enhanced dashboard with additional filtering options and SLM status sync
- **SLMM Polling Intervals**: Fixed and improved polling intervals for better responsiveness
- **24-Hour Scheduler Cycle**: Improved cycle handling to prevent issues with scheduled downloads
### Fixed
- **SLM Modal Fields**: Modal now only contains correct device-specific fields
- **IP Address Handling**: IP address correctly passed via modem pairing
- **Mobile Type Display**: Fixed incorrect device type display in roster and device tables
- **SLMM Scheduled Downloads**: Fixed issues with scheduled download operations
## [0.5.1] - 2026-01-27
### Added
- **Dashboard Schedule View**: Today's scheduled actions now display directly on the main dashboard
- New "Today's Actions" panel showing upcoming and past scheduled events
- Schedule list partial for project-specific schedule views
- API endpoint for fetching today's schedule data
- **New Branding Assets**: Complete logo rework for Terra-View
- New Terra-View logos for light and dark themes
- Retina-ready (@2x) logo variants
- Updated favicons (16px and 32px)
- Refreshed PWA icons (72px through 512px)
### Changed
- **Dashboard Layout**: Reorganized to include schedule information panel
- **Base Template**: Updated to use new Terra-View logos with theme-aware switching
## [0.5.0] - 2026-01-23
_Note: This version was not formally released; changes were included in v0.5.1._
## [0.4.4] - 2026-01-23
### Added
- **Recurring schedules**: New scheduler service, recurring schedule APIs, and schedule templates (calendar/interval/list).
- **Alerts UI + backend**: Alerting service plus dropdown/list templates for surfacing notifications.
- **Report templates + viewers**: CRUD API for report templates, report preview screen, and RND file viewer.
- **SLM tooling**: SLM settings modal and SLM project report generator workflow.
### Changed
- **Project data management**: Unified files view, refreshed FTP browser, and new project header/templates for file/session/unit/assignment lists.
- **Device/SLM sync**: Standardized SLM device types and tightened SLMM sync paths.
- **Docs/scripts**: Cleanup pass and expanded device-type documentation.
### Fixed
- **Scheduler actions**: Strict command definitions so actions run reliably.
- **Project view title**: Resolved JSON string rendering in project headers.
## [0.4.3] - 2026-01-14
### Added
@@ -361,6 +445,10 @@ No database migration required for v0.4.0. All new features use existing databas
- Photo management per unit
- Automated status categorization (OK/Pending/Missing)
[0.6.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.1...v0.6.0
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
[0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4
[0.4.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.2...v0.4.3
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1

View File

@@ -1,4 +1,4 @@
# Seismo Fleet Manager v0.4.3
# Terra-View v0.6.1
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features
@@ -496,6 +496,19 @@ docker compose down -v
## Release Highlights
### v0.6.1 — 2026-02-16
- **One-Off Recording Schedules**: Schedule single recordings with specific start/end datetimes
- **Bidirectional Pairing Sync**: Device-modem pairing now updates both sides automatically
- **Scheduler Timezone Fix**: One-off schedule times use local time instead of UTC
### v0.6.0 — 2026-02-06
- **Calendar & Reservation Mode**: Fleet calendar view with device deployment scheduling and reservation system
- **Device Pairing Interface**: New `/pair-devices` page with two-column layout for linking recorders with modems, fuzzy-search, and visual pairing workflow
- **Calibration UX Overhaul**: Users now set date of previous calibration (not expiry); seismograph list enhanced with color-coded calibration status, filtering, and sorting
- **Modem Dashboard**: Model number as dedicated config, modem login links, list view format, and pairing options accessible from modem page
- **SLMM Improvements**: Device control lock prevents command flooding, fixed polling intervals and scheduled downloads
- **UI Polish**: Tab state persists in URL hash, settings save without reload, scheduler changes cascade to events, fixed mobile type display
### v0.4.3 — 2026-01-14
- **Sound Level Meter workflow**: Roster manager surfaces SLM metadata, supports rename actions, and adds return-to-project navigation plus schedule/unit templates for project planning.
- **Project insight panels**: Project dashboards now expose file and session lists so teams can see what each project stores before diving into units.
@@ -571,9 +584,17 @@ MIT
## Version
**Current: 0.4.3**SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
**Current: 0.6.1**One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
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.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
Previous: 0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
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)

View File

@@ -18,9 +18,10 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
from backend.database import engine, Base, get_db
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
from backend.services.snapshot import emit_status_snapshot
from backend.models import IgnoredUnit
from backend.utils.timezone import get_user_timezone
# Create database tables
Base.metadata.create_all(bind=engine)
@@ -29,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app
VERSION = "0.4.3"
VERSION = "0.6.1"
app = FastAPI(
title="Seismo Fleet Manager",
description="Backend API for managing seismograph fleet status",
@@ -92,6 +93,7 @@ app.include_router(slmm.router)
app.include_router(slm_ui.router)
app.include_router(slm_dashboard.router)
app.include_router(seismo_dashboard.router)
app.include_router(modem_dashboard.router)
from backend.routers import settings
app.include_router(settings.router)
@@ -113,6 +115,10 @@ app.include_router(alerts.router)
from backend.routers import recurring_schedules
app.include_router(recurring_schedules.router)
# Fleet Calendar router
from backend.routers import fleet_calendar
app.include_router(fleet_calendar.router)
# Start scheduler service and device status monitor on application startup
from backend.services.scheduler import start_scheduler, stop_scheduler
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
@@ -216,6 +222,73 @@ async def seismographs_page(request: Request):
return templates.TemplateResponse("seismographs.html", {"request": request})
@app.get("/modems", response_class=HTMLResponse)
async def modems_page(request: Request):
"""Field modems management dashboard"""
return templates.TemplateResponse("modems.html", {"request": request})
@app.get("/pair-devices", response_class=HTMLResponse)
async def pair_devices_page(request: Request, db: Session = Depends(get_db)):
"""
Device pairing page - two-column layout for pairing recorders with modems.
"""
from backend.models import RosterUnit
# Get all non-retired recorders (seismographs and SLMs)
recorders = db.query(RosterUnit).filter(
RosterUnit.retired == False,
RosterUnit.device_type.in_(["seismograph", "slm", None]) # None defaults to seismograph
).order_by(RosterUnit.id).all()
# Get all non-retired modems
modems = db.query(RosterUnit).filter(
RosterUnit.retired == False,
RosterUnit.device_type == "modem"
).order_by(RosterUnit.id).all()
# Build existing pairings list
pairings = []
for recorder in recorders:
if recorder.deployed_with_modem_id:
modem = next((m for m in modems if m.id == recorder.deployed_with_modem_id), None)
pairings.append({
"recorder_id": recorder.id,
"recorder_type": (recorder.device_type or "seismograph").upper(),
"modem_id": recorder.deployed_with_modem_id,
"modem_ip": modem.ip_address if modem else None
})
# Convert to dicts for template
recorders_data = [
{
"id": r.id,
"device_type": r.device_type or "seismograph",
"deployed": r.deployed,
"deployed_with_modem_id": r.deployed_with_modem_id
}
for r in recorders
]
modems_data = [
{
"id": m.id,
"deployed": m.deployed,
"deployed_with_unit_id": m.deployed_with_unit_id,
"ip_address": m.ip_address,
"phone_number": m.phone_number
}
for m in modems
]
return templates.TemplateResponse("pair_devices.html", {
"request": request,
"recorders": recorders_data,
"modems": modems_data,
"pairings": pairings
})
@app.get("/projects", response_class=HTMLResponse)
async def projects_page(request: Request):
"""Projects management and overview"""
@@ -580,6 +653,7 @@ async def devices_all_partial(request: Request):
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
@@ -603,6 +677,7 @@ async def devices_all_partial(request: Request):
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
@@ -626,6 +701,7 @@ async def devices_all_partial(request: Request):
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
@@ -649,6 +725,7 @@ async def devices_all_partial(request: Request):
"last_calibrated": None,
"next_calibration_due": None,
"deployed_with_modem_id": None,
"deployed_with_unit_id": None,
"ip_address": None,
"phone_number": None,
"hardware_model": None,
@@ -671,7 +748,8 @@ async def devices_all_partial(request: Request):
return templates.TemplateResponse("partials/devices_table.html", {
"request": request,
"units": units_list,
"timestamp": datetime.now().strftime("%H:%M:%S")
"timestamp": datetime.now().strftime("%H:%M:%S"),
"user_timezone": get_user_timezone()
})

View File

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

View File

@@ -0,0 +1,103 @@
"""
Migration script to add job reservations for the Fleet Calendar feature.
This creates two tables:
- job_reservations: Track future unit assignments for jobs/projects
- job_reservation_units: Link specific units to reservations
Run this script once to migrate an existing database.
"""
import sqlite3
import os
# Database path
DB_PATH = "./data/seismo_fleet.db"
def migrate_database():
"""Create the job_reservations and job_reservation_units tables"""
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 job_reservations table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
if cursor.fetchone():
print("Migration already applied - job_reservations table exists")
conn.close()
return
print("Creating job_reservations table...")
try:
# Create job_reservations table
cursor.execute("""
CREATE TABLE job_reservations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_id TEXT,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
assignment_type TEXT NOT NULL DEFAULT 'quantity',
device_type TEXT DEFAULT 'seismograph',
quantity_needed INTEGER,
notes TEXT,
color TEXT DEFAULT '#3B82F6',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
print(" Created job_reservations table")
# Create indexes for job_reservations
cursor.execute("CREATE INDEX idx_job_reservations_project_id ON job_reservations(project_id)")
print(" Created index on project_id")
cursor.execute("CREATE INDEX idx_job_reservations_dates ON job_reservations(start_date, end_date)")
print(" Created index on dates")
# Create job_reservation_units table
print("Creating job_reservation_units table...")
cursor.execute("""
CREATE TABLE job_reservation_units (
id TEXT PRIMARY KEY,
reservation_id TEXT NOT NULL,
unit_id TEXT NOT NULL,
assignment_source TEXT DEFAULT 'specific',
assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (reservation_id) REFERENCES job_reservations(id),
FOREIGN KEY (unit_id) REFERENCES roster(id)
)
""")
print(" Created job_reservation_units table")
# Create indexes for job_reservation_units
cursor.execute("CREATE INDEX idx_job_reservation_units_reservation_id ON job_reservation_units(reservation_id)")
print(" Created index on reservation_id")
cursor.execute("CREATE INDEX idx_job_reservation_units_unit_id ON job_reservation_units(unit_id)")
print(" Created index on unit_id")
conn.commit()
print("\nMigration completed successfully!")
print("You can now use the Fleet Calendar to manage unit reservations.")
except sqlite3.Error as e:
print(f"\nError during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
migrate_database()

View File

@@ -0,0 +1,73 @@
"""
Migration: Add one-off schedule fields to recurring_schedules table
Adds start_datetime and end_datetime columns for one-off recording schedules.
Run this script once to update existing databases:
python -m backend.migrate_add_oneoff_schedule_fields
"""
import sqlite3
import os
DB_PATH = "data/seismo_fleet.db"
def migrate():
"""Add one-off schedule columns to recurring_schedules table."""
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return False
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
try:
cursor.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='recurring_schedules'
""")
if not cursor.fetchone():
print("recurring_schedules table does not exist yet. Will be created on app startup.")
conn.close()
return True
cursor.execute("PRAGMA table_info(recurring_schedules)")
columns = [row[1] for row in cursor.fetchall()]
added = False
if "start_datetime" not in columns:
print("Adding start_datetime column to recurring_schedules table...")
cursor.execute("""
ALTER TABLE recurring_schedules
ADD COLUMN start_datetime DATETIME NULL
""")
added = True
if "end_datetime" not in columns:
print("Adding end_datetime column to recurring_schedules table...")
cursor.execute("""
ALTER TABLE recurring_schedules
ADD COLUMN end_datetime DATETIME NULL
""")
added = True
if added:
conn.commit()
print("Successfully added one-off schedule columns.")
else:
print("One-off schedule columns already exist.")
conn.close()
return True
except Exception as e:
print(f"Migration failed: {e}")
conn.close()
return False
if __name__ == "__main__":
success = migrate()
exit(0 if success else 1)

View File

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

View File

@@ -0,0 +1,89 @@
"""
Migration: Add TBD date support to job reservations
Adds columns:
- job_reservations.estimated_end_date: For planning when end is TBD
- job_reservations.end_date_tbd: Boolean flag for TBD end dates
- job_reservation_units.unit_start_date: Unit-specific start (for swaps)
- job_reservation_units.unit_end_date: Unit-specific end (for swaps)
- job_reservation_units.unit_end_tbd: Unit-specific TBD flag
- job_reservation_units.notes: Notes for the assignment
Also makes job_reservations.end_date nullable.
"""
import sqlite3
import sys
from pathlib import Path
def migrate(db_path: str):
"""Run the migration."""
print(f"Migrating database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if job_reservations table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
if not cursor.fetchone():
print("job_reservations table does not exist. Skipping migration.")
return
# Get existing columns in job_reservations
cursor.execute("PRAGMA table_info(job_reservations)")
existing_cols = {row[1] for row in cursor.fetchall()}
# Add new columns to job_reservations if they don't exist
if 'estimated_end_date' not in existing_cols:
print("Adding estimated_end_date column to job_reservations...")
cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_end_date DATE")
if 'end_date_tbd' not in existing_cols:
print("Adding end_date_tbd column to job_reservations...")
cursor.execute("ALTER TABLE job_reservations ADD COLUMN end_date_tbd BOOLEAN DEFAULT 0")
# Get existing columns in job_reservation_units
cursor.execute("PRAGMA table_info(job_reservation_units)")
unit_cols = {row[1] for row in cursor.fetchall()}
# Add new columns to job_reservation_units if they don't exist
if 'unit_start_date' not in unit_cols:
print("Adding unit_start_date column to job_reservation_units...")
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_start_date DATE")
if 'unit_end_date' not in unit_cols:
print("Adding unit_end_date column to job_reservation_units...")
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_end_date DATE")
if 'unit_end_tbd' not in unit_cols:
print("Adding unit_end_tbd column to job_reservation_units...")
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN unit_end_tbd BOOLEAN DEFAULT 0")
if 'notes' not in unit_cols:
print("Adding notes column to job_reservation_units...")
cursor.execute("ALTER TABLE job_reservation_units ADD COLUMN notes TEXT")
conn.commit()
print("Migration completed successfully!")
except Exception as e:
print(f"Migration failed: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
# Default to dev database
db_path = "./data-dev/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not Path(db_path).exists():
print(f"Database not found: {db_path}")
sys.exit(1)
migrate(db_path)

View File

@@ -0,0 +1,105 @@
"""
Migration: Make job_reservations.end_date nullable for TBD support
SQLite doesn't support ALTER COLUMN, so we need to:
1. Create a new table with the correct schema
2. Copy data
3. Drop old table
4. Rename new table
"""
import sqlite3
import sys
from pathlib import Path
def migrate(db_path: str):
"""Run the migration."""
print(f"Migrating database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if job_reservations table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
if not cursor.fetchone():
print("job_reservations table does not exist. Skipping migration.")
return
# Check current schema
cursor.execute("PRAGMA table_info(job_reservations)")
columns = cursor.fetchall()
col_info = {row[1]: row for row in columns}
# Check if end_date is already nullable (notnull=0)
if 'end_date' in col_info and col_info['end_date'][3] == 0:
print("end_date is already nullable. Skipping table recreation.")
return
print("Recreating job_reservations table with nullable end_date...")
# Create new table with correct schema
cursor.execute("""
CREATE TABLE job_reservations_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
project_id TEXT,
start_date DATE NOT NULL,
end_date DATE,
estimated_end_date DATE,
end_date_tbd BOOLEAN DEFAULT 0,
assignment_type TEXT NOT NULL DEFAULT 'quantity',
device_type TEXT DEFAULT 'seismograph',
quantity_needed INTEGER,
notes TEXT,
color TEXT DEFAULT '#3B82F6',
created_at DATETIME,
updated_at DATETIME
)
""")
# Copy existing data
cursor.execute("""
INSERT INTO job_reservations_new
SELECT
id, name, project_id, start_date, end_date,
COALESCE(estimated_end_date, NULL) as estimated_end_date,
COALESCE(end_date_tbd, 0) as end_date_tbd,
assignment_type, device_type, quantity_needed, notes, color,
created_at, updated_at
FROM job_reservations
""")
# Drop old table
cursor.execute("DROP TABLE job_reservations")
# Rename new table
cursor.execute("ALTER TABLE job_reservations_new RENAME TO job_reservations")
# Recreate index
cursor.execute("CREATE INDEX IF NOT EXISTS ix_job_reservations_id ON job_reservations (id)")
cursor.execute("CREATE INDEX IF NOT EXISTS ix_job_reservations_project_id ON job_reservations (project_id)")
conn.commit()
print("Migration completed successfully!")
except Exception as e:
print(f"Migration failed: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
# Default to dev database
db_path = "./data-dev/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not Path(db_path).exists():
print(f"Database not found: {db_path}")
sys.exit(1)
migrate(db_path)

View File

@@ -50,6 +50,8 @@ class RosterUnit(Base):
ip_address = Column(String, nullable=True)
phone_number = Column(String, nullable=True)
hardware_model = Column(String, nullable=True)
deployment_type = Column(String, nullable=True) # "seismograph" | "slm" - what type of device this modem is deployed with
deployed_with_unit_id = Column(String, nullable=True) # ID of seismograph/SLM this modem is deployed with
# Sound Level Meter-specific fields (nullable for seismographs and modems)
slm_host = Column(String, nullable=True) # Device IP or hostname
@@ -137,17 +139,26 @@ class Project(Base):
"""
Projects: top-level organization for monitoring work.
Type-aware to enable/disable features based on project_type_id.
Project naming convention:
- project_number: TMI internal ID format xxxx-YY (e.g., "2567-23")
- client_name: Client/contractor name (e.g., "PJ Dick")
- name: Project/site name (e.g., "RKM Hall", "CMU Campus")
Display format: "2567-23 - PJ Dick - RKM Hall"
Users can search by any of these fields.
"""
__tablename__ = "projects"
id = Column(String, primary_key=True, index=True) # UUID
name = Column(String, nullable=False, unique=True)
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
description = Column(Text, nullable=True)
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
status = Column(String, default="active") # active, completed, archived
# Project metadata
client_name = Column(String, nullable=True)
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
site_address = Column(String, nullable=True)
site_coordinates = Column(String, nullable=True) # "lat,lon"
start_date = Column(Date, nullable=True)
@@ -218,7 +229,7 @@ class ScheduledAction(Base):
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (nullable if location-based)
action_type = Column(String, nullable=False) # start, stop, download, calibrate
action_type = Column(String, nullable=False) # start, stop, download, cycle, calibrate
device_type = Column(String, nullable=False) # "slm" | "seismograph"
scheduled_time = Column(DateTime, nullable=False, index=True)
@@ -310,9 +321,10 @@ class RecurringSchedule(Base):
"""
Recurring schedule definitions for automated sound monitoring.
Supports two schedule types:
Supports three schedule types:
- "weekly_calendar": Select specific days with start/end times (e.g., Mon/Wed/Fri 7pm-7am)
- "simple_interval": For 24/7 monitoring with daily stop/download/restart cycles
- "one_off": Single recording session with specific start and end date/time
"""
__tablename__ = "recurring_schedules"
@@ -322,7 +334,7 @@ class RecurringSchedule(Base):
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (optional, can use assignment)
name = Column(String, nullable=False) # "Weeknight Monitoring", "24/7 Continuous"
schedule_type = Column(String, nullable=False) # "weekly_calendar" | "simple_interval"
schedule_type = Column(String, nullable=False) # "weekly_calendar" | "simple_interval" | "one_off"
device_type = Column(String, nullable=False) # "slm" | "seismograph"
# Weekly Calendar fields (schedule_type = "weekly_calendar")
@@ -338,7 +350,11 @@ class RecurringSchedule(Base):
cycle_time = Column(String, nullable=True) # "00:00" - time to run stop/download/restart
include_download = Column(Boolean, default=True) # Download data before restart
# Automation options (applies to both schedule types)
# One-Off fields (schedule_type = "one_off")
start_datetime = Column(DateTime, nullable=True) # Exact start date+time (stored as UTC)
end_datetime = Column(DateTime, nullable=True) # Exact end date+time (stored as UTC)
# Automation options (applies to all schedule types)
auto_increment_index = Column(Boolean, default=True) # Auto-increment store/index number before start
# When True: prevents "overwrite data?" prompts by using a new index each time
@@ -391,3 +407,72 @@ class Alert(Base):
created_at = Column(DateTime, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time
# ============================================================================
# Fleet Calendar & Job Reservations
# ============================================================================
class JobReservation(Base):
"""
Job reservations: reserve units for future jobs/projects.
Supports two assignment modes:
- "specific": Pick exact units (SN-001, SN-002, etc.)
- "quantity": Reserve a number of units (e.g., "need 8 seismographs")
Used by the Fleet Calendar to visualize unit availability over time.
"""
__tablename__ = "job_reservations"
id = Column(String, primary_key=True, index=True) # UUID
name = Column(String, nullable=False) # "Job A - March deployment"
project_id = Column(String, nullable=True, index=True) # Optional FK to Project
# Date range for the reservation
start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=True) # Nullable = TBD / ongoing
estimated_end_date = Column(Date, nullable=True) # For planning when end is TBD
end_date_tbd = Column(Boolean, default=False) # True = end date unknown
# Assignment type: "specific" or "quantity"
assignment_type = Column(String, nullable=False, default="quantity")
# For quantity reservations
device_type = Column(String, default="seismograph") # seismograph | slm
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
# Metadata
notes = Column(Text, nullable=True)
color = Column(String, default="#3B82F6") # For calendar display (blue default)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class JobReservationUnit(Base):
"""
Links specific units to job reservations.
Used when:
- assignment_type="specific": Units are directly assigned
- assignment_type="quantity": Units can be filled in later
Supports unit swaps: same reservation can have multiple units with
different date ranges (e.g., BE17353 Feb-Jun, then BE18438 Jun-Nov).
"""
__tablename__ = "job_reservation_units"
id = Column(String, primary_key=True, index=True) # UUID
reservation_id = Column(String, nullable=False, index=True) # FK to JobReservation
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit
# Unit-specific date range (for swaps) - defaults to reservation dates if null
unit_start_date = Column(Date, nullable=True) # When this specific unit starts
unit_end_date = Column(Date, nullable=True) # When this unit ends (swap out date)
unit_end_tbd = Column(Boolean, default=False) # True = end unknown (until cal expires or job ends)
# Track how this assignment was made
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
assigned_at = Column(DateTime, default=datetime.utcnow)
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.

View File

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

View File

@@ -0,0 +1,610 @@
"""
Fleet Calendar Router
API endpoints for the Fleet Calendar feature:
- Calendar page and data
- Job reservation CRUD
- Unit assignment management
- Availability checking
"""
from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session
from datetime import datetime, date, timedelta
from typing import Optional, List
import uuid
import logging
from backend.database import get_db
from backend.models import (
RosterUnit, JobReservation, JobReservationUnit,
UserPreferences, Project
)
from backend.templates_config import templates
from backend.services.fleet_calendar_service import (
get_day_summary,
get_calendar_year_data,
get_rolling_calendar_data,
check_calibration_conflicts,
get_available_units_for_period,
get_calibration_status
)
router = APIRouter(tags=["fleet-calendar"])
logger = logging.getLogger(__name__)
# ============================================================================
# Calendar Page
# ============================================================================
@router.get("/fleet-calendar", response_class=HTMLResponse)
async def fleet_calendar_page(
request: Request,
year: Optional[int] = None,
month: Optional[int] = None,
device_type: str = "seismograph",
db: Session = Depends(get_db)
):
"""Main Fleet Calendar page with rolling 12-month view."""
today = date.today()
# Default to current month as the start
if year is None:
year = today.year
if month is None:
month = today.month
# Get calendar data for 12 months starting from year/month
calendar_data = get_rolling_calendar_data(db, year, month, device_type)
# Get projects for the reservation form dropdown
projects = db.query(Project).filter(
Project.status == "active"
).order_by(Project.name).all()
# Calculate prev/next month navigation
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
return templates.TemplateResponse(
"fleet_calendar.html",
{
"request": request,
"start_year": year,
"start_month": month,
"prev_year": prev_year,
"prev_month": prev_month,
"next_year": next_year,
"next_month": next_month,
"device_type": device_type,
"calendar_data": calendar_data,
"projects": projects,
"today": today.isoformat()
}
)
# ============================================================================
# Calendar Data API
# ============================================================================
@router.get("/api/fleet-calendar/data", response_class=JSONResponse)
async def get_calendar_data(
year: int,
device_type: str = "seismograph",
db: Session = Depends(get_db)
):
"""Get calendar data for a specific year."""
return get_calendar_year_data(db, year, device_type)
@router.get("/api/fleet-calendar/day/{date_str}", response_class=HTMLResponse)
async def get_day_detail(
request: Request,
date_str: str,
device_type: str = "seismograph",
db: Session = Depends(get_db)
):
"""Get detailed view for a specific day (HTMX partial)."""
try:
check_date = date.fromisoformat(date_str)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
day_data = get_day_summary(db, check_date, device_type)
# Get projects for display names
projects = {p.id: p for p in db.query(Project).all()}
return templates.TemplateResponse(
"partials/fleet_calendar/day_detail.html",
{
"request": request,
"day_data": day_data,
"date_str": date_str,
"date_display": check_date.strftime("%B %d, %Y"),
"device_type": device_type,
"projects": projects
}
)
# ============================================================================
# Reservation CRUD
# ============================================================================
@router.post("/api/fleet-calendar/reservations", response_class=JSONResponse)
async def create_reservation(
request: Request,
db: Session = Depends(get_db)
):
"""Create a new job reservation."""
data = await request.json()
# Validate required fields
required = ["name", "start_date", "assignment_type"]
for field in required:
if field not in data:
raise HTTPException(status_code=400, detail=f"Missing required field: {field}")
# Need either end_date or end_date_tbd
end_date_tbd = data.get("end_date_tbd", False)
if not end_date_tbd and not data.get("end_date"):
raise HTTPException(status_code=400, detail="End date is required unless marked as TBD")
try:
start_date = date.fromisoformat(data["start_date"])
end_date = date.fromisoformat(data["end_date"]) if data.get("end_date") else None
estimated_end_date = date.fromisoformat(data["estimated_end_date"]) if data.get("estimated_end_date") else None
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if end_date and end_date < start_date:
raise HTTPException(status_code=400, detail="End date must be after start date")
if estimated_end_date and estimated_end_date < start_date:
raise HTTPException(status_code=400, detail="Estimated end date must be after start date")
reservation = JobReservation(
id=str(uuid.uuid4()),
name=data["name"],
project_id=data.get("project_id"),
start_date=start_date,
end_date=end_date,
estimated_end_date=estimated_end_date,
end_date_tbd=end_date_tbd,
assignment_type=data["assignment_type"],
device_type=data.get("device_type", "seismograph"),
quantity_needed=data.get("quantity_needed"),
notes=data.get("notes"),
color=data.get("color", "#3B82F6")
)
db.add(reservation)
# If specific units were provided, assign them
if data.get("unit_ids") and data["assignment_type"] == "specific":
for unit_id in data["unit_ids"]:
assignment = JobReservationUnit(
id=str(uuid.uuid4()),
reservation_id=reservation.id,
unit_id=unit_id,
assignment_source="specific"
)
db.add(assignment)
db.commit()
logger.info(f"Created reservation: {reservation.name} ({reservation.id})")
return {
"success": True,
"reservation_id": reservation.id,
"message": f"Created reservation: {reservation.name}"
}
@router.get("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse)
async def get_reservation(
reservation_id: str,
db: Session = Depends(get_db)
):
"""Get a specific reservation with its assigned units."""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
# Get assigned units
assignments = db.query(JobReservationUnit).filter_by(
reservation_id=reservation_id
).all()
unit_ids = [a.unit_id for a in assignments]
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
return {
"id": reservation.id,
"name": reservation.name,
"project_id": reservation.project_id,
"start_date": reservation.start_date.isoformat(),
"end_date": reservation.end_date.isoformat() if reservation.end_date else None,
"estimated_end_date": reservation.estimated_end_date.isoformat() if reservation.estimated_end_date else None,
"end_date_tbd": reservation.end_date_tbd,
"assignment_type": reservation.assignment_type,
"device_type": reservation.device_type,
"quantity_needed": reservation.quantity_needed,
"notes": reservation.notes,
"color": reservation.color,
"assigned_units": [
{
"id": u.id,
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
"deployed": u.deployed
}
for u in units
]
}
@router.put("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse)
async def update_reservation(
reservation_id: str,
request: Request,
db: Session = Depends(get_db)
):
"""Update an existing reservation."""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
data = await request.json()
# Update fields if provided
if "name" in data:
reservation.name = data["name"]
if "project_id" in data:
reservation.project_id = data["project_id"]
if "start_date" in data:
reservation.start_date = date.fromisoformat(data["start_date"])
if "end_date" in data:
reservation.end_date = date.fromisoformat(data["end_date"]) if data["end_date"] else None
if "estimated_end_date" in data:
reservation.estimated_end_date = date.fromisoformat(data["estimated_end_date"]) if data["estimated_end_date"] else None
if "end_date_tbd" in data:
reservation.end_date_tbd = data["end_date_tbd"]
if "assignment_type" in data:
reservation.assignment_type = data["assignment_type"]
if "quantity_needed" in data:
reservation.quantity_needed = data["quantity_needed"]
if "notes" in data:
reservation.notes = data["notes"]
if "color" in data:
reservation.color = data["color"]
reservation.updated_at = datetime.utcnow()
db.commit()
logger.info(f"Updated reservation: {reservation.name} ({reservation.id})")
return {
"success": True,
"message": f"Updated reservation: {reservation.name}"
}
@router.delete("/api/fleet-calendar/reservations/{reservation_id}", response_class=JSONResponse)
async def delete_reservation(
reservation_id: str,
db: Session = Depends(get_db)
):
"""Delete a reservation and its unit assignments."""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
# Delete unit assignments first
db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete()
# Delete the reservation
db.delete(reservation)
db.commit()
logger.info(f"Deleted reservation: {reservation.name} ({reservation_id})")
return {
"success": True,
"message": "Reservation deleted"
}
# ============================================================================
# Unit Assignment
# ============================================================================
@router.post("/api/fleet-calendar/reservations/{reservation_id}/assign-units", response_class=JSONResponse)
async def assign_units_to_reservation(
reservation_id: str,
request: Request,
db: Session = Depends(get_db)
):
"""Assign specific units to a reservation."""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
data = await request.json()
unit_ids = data.get("unit_ids", [])
if not unit_ids:
raise HTTPException(status_code=400, detail="No units specified")
# Verify units exist
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
found_ids = {u.id for u in units}
missing = set(unit_ids) - found_ids
if missing:
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
# Check for conflicts (already assigned to overlapping reservations)
conflicts = []
for unit_id in unit_ids:
# Check if unit is already assigned to this reservation
existing = db.query(JobReservationUnit).filter_by(
reservation_id=reservation_id,
unit_id=unit_id
).first()
if existing:
continue # Already assigned, skip
# Check overlapping reservations
overlapping = db.query(JobReservation).join(
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
).filter(
JobReservationUnit.unit_id == unit_id,
JobReservation.id != reservation_id,
JobReservation.start_date <= reservation.end_date,
JobReservation.end_date >= reservation.start_date
).first()
if overlapping:
conflicts.append({
"unit_id": unit_id,
"conflict_reservation": overlapping.name,
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
})
continue
# Add assignment
assignment = JobReservationUnit(
id=str(uuid.uuid4()),
reservation_id=reservation_id,
unit_id=unit_id,
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific"
)
db.add(assignment)
db.commit()
# Check for calibration conflicts
cal_conflicts = check_calibration_conflicts(db, reservation_id)
assigned_count = db.query(JobReservationUnit).filter_by(
reservation_id=reservation_id
).count()
return {
"success": True,
"assigned_count": assigned_count,
"conflicts": conflicts,
"calibration_warnings": cal_conflicts,
"message": f"Assigned {len(unit_ids) - len(conflicts)} units"
}
@router.delete("/api/fleet-calendar/reservations/{reservation_id}/units/{unit_id}", response_class=JSONResponse)
async def remove_unit_from_reservation(
reservation_id: str,
unit_id: str,
db: Session = Depends(get_db)
):
"""Remove a unit from a reservation."""
assignment = db.query(JobReservationUnit).filter_by(
reservation_id=reservation_id,
unit_id=unit_id
).first()
if not assignment:
raise HTTPException(status_code=404, detail="Unit assignment not found")
db.delete(assignment)
db.commit()
return {
"success": True,
"message": f"Removed {unit_id} from reservation"
}
# ============================================================================
# Availability & Conflicts
# ============================================================================
@router.get("/api/fleet-calendar/availability", response_class=JSONResponse)
async def check_availability(
start_date: str,
end_date: str,
device_type: str = "seismograph",
exclude_reservation_id: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Get units available for a specific date range."""
try:
start = date.fromisoformat(start_date)
end = date.fromisoformat(end_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
available = get_available_units_for_period(
db, start, end, device_type, exclude_reservation_id
)
return {
"start_date": start_date,
"end_date": end_date,
"device_type": device_type,
"available_units": available,
"count": len(available)
}
@router.get("/api/fleet-calendar/reservations/{reservation_id}/conflicts", response_class=JSONResponse)
async def get_reservation_conflicts(
reservation_id: str,
db: Session = Depends(get_db)
):
"""Check for calibration conflicts in a reservation."""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
conflicts = check_calibration_conflicts(db, reservation_id)
return {
"reservation_id": reservation_id,
"reservation_name": reservation.name,
"conflicts": conflicts,
"has_conflicts": len(conflicts) > 0
}
# ============================================================================
# HTMX Partials
# ============================================================================
@router.get("/api/fleet-calendar/reservations-list", response_class=HTMLResponse)
async def get_reservations_list(
request: Request,
year: Optional[int] = None,
month: Optional[int] = None,
device_type: str = "seismograph",
db: Session = Depends(get_db)
):
"""Get list of reservations as HTMX partial."""
from sqlalchemy import or_
today = date.today()
if year is None:
year = today.year
if month is None:
month = today.month
# Calculate 12-month window
start_date = date(year, month, 1)
# End date is 12 months later
end_year = year + ((month + 10) // 12)
end_month = ((month + 10) % 12) + 1
if end_month == 12:
end_date = date(end_year, 12, 31)
else:
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
# Include TBD reservations that started before window end
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= end_date,
or_(
JobReservation.end_date >= start_date,
JobReservation.end_date == None # TBD reservations
)
).order_by(JobReservation.start_date).all()
# Get assignment counts
reservation_data = []
for res in reservations:
assigned_count = db.query(JobReservationUnit).filter_by(
reservation_id=res.id
).count()
# Check for calibration conflicts
conflicts = check_calibration_conflicts(db, res.id)
reservation_data.append({
"reservation": res,
"assigned_count": assigned_count,
"has_conflicts": len(conflicts) > 0,
"conflict_count": len(conflicts)
})
return templates.TemplateResponse(
"partials/fleet_calendar/reservations_list.html",
{
"request": request,
"reservations": reservation_data,
"year": year,
"device_type": device_type
}
)
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
async def get_available_units_partial(
request: Request,
start_date: str,
end_date: str,
device_type: str = "seismograph",
reservation_id: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Get available units as HTMX partial for the assignment modal."""
try:
start = date.fromisoformat(start_date)
end = date.fromisoformat(end_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format")
available = get_available_units_for_period(
db, start, end, device_type, reservation_id
)
return templates.TemplateResponse(
"partials/fleet_calendar/available_units.html",
{
"request": request,
"units": available,
"start_date": start_date,
"end_date": end_date,
"device_type": device_type,
"reservation_id": reservation_id
}
)
@router.get("/api/fleet-calendar/month/{year}/{month}", response_class=HTMLResponse)
async def get_month_partial(
request: Request,
year: int,
month: int,
device_type: str = "seismograph",
db: Session = Depends(get_db)
):
"""Get a single month calendar as HTMX partial."""
calendar_data = get_calendar_year_data(db, year, device_type)
month_data = calendar_data["months"].get(month)
if not month_data:
raise HTTPException(status_code=404, detail="Invalid month")
return templates.TemplateResponse(
"partials/fleet_calendar/month_grid.html",
{
"request": request,
"year": year,
"month": month,
"month_data": month_data,
"device_type": device_type,
"today": date.today().isoformat()
}
)

View File

@@ -0,0 +1,429 @@
"""
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.
}
@router.get("/{modem_id}/pairable-devices")
async def get_pairable_devices(
modem_id: str,
db: Session = Depends(get_db),
search: str = Query(None),
hide_paired: bool = Query(True)
):
"""
Get list of devices (seismographs and SLMs) that can be paired with this modem.
Used by the device picker modal in unit_detail.html.
"""
# 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"}
# Query seismographs and SLMs
query = db.query(RosterUnit).filter(
RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]),
RosterUnit.retired == False
)
# Filter by search term if provided
if search:
search_term = f"%{search}%"
query = query.filter(
(RosterUnit.id.ilike(search_term)) |
(RosterUnit.project_id.ilike(search_term)) |
(RosterUnit.location.ilike(search_term)) |
(RosterUnit.address.ilike(search_term)) |
(RosterUnit.note.ilike(search_term))
)
devices = query.order_by(
RosterUnit.deployed.desc(),
RosterUnit.device_type.asc(),
RosterUnit.id.asc()
).all()
# Build device list
device_list = []
for device in devices:
# Skip already paired devices if hide_paired is True
is_paired_to_other = (
device.deployed_with_modem_id is not None and
device.deployed_with_modem_id != modem_id
)
is_paired_to_this = device.deployed_with_modem_id == modem_id
if hide_paired and is_paired_to_other:
continue
device_list.append({
"id": device.id,
"device_type": device.device_type,
"deployed": device.deployed,
"project_id": device.project_id,
"location": device.location or device.address,
"note": device.note,
"paired_modem_id": device.deployed_with_modem_id,
"is_paired_to_this": is_paired_to_this,
"is_paired_to_other": is_paired_to_other
})
return {"devices": device_list, "modem_id": modem_id}
@router.post("/{modem_id}/pair")
async def pair_device_to_modem(
modem_id: str,
db: Session = Depends(get_db),
device_id: str = Query(..., description="ID of the device to pair")
):
"""
Pair a device (seismograph or SLM) to this modem.
Updates the device's deployed_with_modem_id field.
"""
# 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 the device
device = db.query(RosterUnit).filter(
RosterUnit.id == device_id,
RosterUnit.device_type.in_(["seismograph", "sound_level_meter"]),
RosterUnit.retired == False
).first()
if not device:
return {"status": "error", "detail": f"Device {device_id} not found"}
# Unpair any device currently paired to this modem
currently_paired = db.query(RosterUnit).filter(
RosterUnit.deployed_with_modem_id == modem_id
).all()
for paired_device in currently_paired:
paired_device.deployed_with_modem_id = None
# Pair the new device
device.deployed_with_modem_id = modem_id
db.commit()
return {
"status": "success",
"modem_id": modem_id,
"device_id": device_id,
"message": f"Device {device_id} paired to modem {modem_id}"
}
@router.post("/{modem_id}/unpair")
async def unpair_device_from_modem(modem_id: str, db: Session = Depends(get_db)):
"""
Unpair any device currently paired to this modem.
"""
# 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 and unpair device
device = db.query(RosterUnit).filter(
RosterUnit.deployed_with_modem_id == modem_id
).first()
if device:
old_device_id = device.id
device.deployed_with_modem_id = None
db.commit()
return {
"status": "success",
"modem_id": modem_id,
"unpaired_device_id": old_device_id,
"message": f"Device {old_device_id} unpaired from modem {modem_id}"
}
return {
"status": "success",
"modem_id": modem_id,
"message": "No device was paired to this modem"
}

View File

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

View File

@@ -186,6 +186,41 @@ async def create_recurring_schedule(
created_schedules = []
base_name = data.get("name", "Unnamed Schedule")
# Parse one-off datetime fields if applicable
one_off_start = None
one_off_end = None
if data.get("schedule_type") == "one_off":
from zoneinfo import ZoneInfo
tz = ZoneInfo(data.get("timezone", "America/New_York"))
start_dt_str = data.get("start_datetime")
end_dt_str = data.get("end_datetime")
if not start_dt_str or not end_dt_str:
raise HTTPException(status_code=400, detail="One-off schedules require start and end date/time")
try:
start_local = datetime.fromisoformat(start_dt_str).replace(tzinfo=tz)
end_local = datetime.fromisoformat(end_dt_str).replace(tzinfo=tz)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid datetime format")
duration = end_local - start_local
if duration.total_seconds() < 900:
raise HTTPException(status_code=400, detail="Duration must be at least 15 minutes")
if duration.total_seconds() > 86400:
raise HTTPException(status_code=400, detail="Duration cannot exceed 24 hours")
from datetime import timezone as dt_timezone
now_local = datetime.now(tz)
if start_local <= now_local:
raise HTTPException(status_code=400, detail="Start time must be in the future")
# Convert to UTC for storage
one_off_start = start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
one_off_end = end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
# Create a schedule for each location
for location in locations:
# Determine device type from location
@@ -207,6 +242,8 @@ async def create_recurring_schedule(
include_download=data.get("include_download", True),
auto_increment_index=data.get("auto_increment_index", True),
timezone=data.get("timezone", "America/New_York"),
start_datetime=one_off_start,
end_datetime=one_off_end,
)
# Generate actions immediately so they appear right away
@@ -330,19 +367,35 @@ async def disable_schedule(
db: Session = Depends(get_db),
):
"""
Disable a schedule.
Disable a schedule and cancel all its pending actions.
"""
service = get_recurring_schedule_service(db)
# Count pending actions before disabling (for response message)
from sqlalchemy import and_
from backend.models import ScheduledAction
pending_count = db.query(ScheduledAction).filter(
and_(
ScheduledAction.execution_status == "pending",
ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'),
)
).count()
schedule = service.disable_schedule(schedule_id)
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
message = "Schedule disabled"
if pending_count > 0:
message += f" and {pending_count} pending action(s) cancelled"
return {
"success": True,
"schedule_id": schedule.id,
"enabled": schedule.enabled,
"message": "Schedule disabled",
"cancelled_actions": pending_count,
"message": message,
}

View File

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

View File

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

View File

@@ -3,6 +3,8 @@ Seismograph Dashboard API Router
Provides endpoints for the seismograph-specific dashboard
"""
from datetime import date
from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
@@ -49,10 +51,14 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
async def get_seismo_units(
request: Request,
db: Session = Depends(get_db),
search: str = Query(None)
search: str = Query(None),
sort: str = Query("id"),
order: str = Query("asc"),
status: str = Query(None),
modem: str = Query(None)
):
"""
Returns HTML partial with filterable seismograph unit list
Returns HTML partial with filterable and sortable seismograph unit list
"""
query = db.query(RosterUnit).filter_by(
device_type="seismograph",
@@ -61,20 +67,52 @@ async def get_seismo_units(
# Apply search filter
if search:
search_lower = search.lower()
query = query.filter(
(RosterUnit.id.ilike(f"%{search}%")) |
(RosterUnit.note.ilike(f"%{search}%")) |
(RosterUnit.address.ilike(f"%{search}%"))
)
seismos = query.order_by(RosterUnit.id).all()
# Apply status filter
if status == "deployed":
query = query.filter(RosterUnit.deployed == True)
elif status == "benched":
query = query.filter(RosterUnit.deployed == False)
# Apply modem filter
if modem == "with":
query = query.filter(RosterUnit.deployed_with_modem_id.isnot(None))
elif modem == "without":
query = query.filter(RosterUnit.deployed_with_modem_id.is_(None))
# Apply sorting
sort_column_map = {
"id": RosterUnit.id,
"status": RosterUnit.deployed,
"modem": RosterUnit.deployed_with_modem_id,
"location": RosterUnit.address,
"last_calibrated": RosterUnit.last_calibrated,
"notes": RosterUnit.note
}
sort_column = sort_column_map.get(sort, RosterUnit.id)
if order == "desc":
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column.asc())
seismos = query.all()
return templates.TemplateResponse(
"partials/seismo_unit_list.html",
{
"request": request,
"units": seismos,
"search": search or ""
"search": search or "",
"sort": sort,
"order": order,
"status": status or "",
"modem": modem or "",
"today": date.today()
}
)

View File

@@ -167,23 +167,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
measurement_state = state_data.get("measurement_state", "Unknown")
is_measuring = state_data.get("is_measuring", False)
# If measuring, sync start time from FTP to database (fixes wrong timestamps)
if is_measuring:
try:
sync_response = await client.post(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time",
timeout=10.0
)
if sync_response.status_code == 200:
sync_data = sync_response.json()
logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}")
else:
logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}")
except Exception as e:
# Don't fail the whole request if sync fails
logger.warning(f"Could not sync start time for {unit_id}: {e}")
# Get live status (now with corrected start time)
# Get live status (measurement_start_time is already stored in SLMM database)
status_response = await client.get(
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
)

View File

@@ -199,7 +199,7 @@ class AlertService:
Args:
schedule_id: The ScheduledAction or RecurringSchedule ID
action_type: start, stop, download
action_type: start, stop, download, cycle
unit_id: Related unit
error_message: Error from execution
project_id: Related project
@@ -235,7 +235,7 @@ class AlertService:
Args:
schedule_id: The ScheduledAction ID
action_type: start, stop, download
action_type: start, stop, download, cycle
unit_id: Related unit
project_id: Related project
location_id: Related location

View File

@@ -289,6 +289,74 @@ class DeviceController:
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# FTP Control
# ========================================================================
async def enable_ftp(
self,
unit_id: str,
device_type: str,
) -> Dict[str, Any]:
"""
Enable FTP server on device.
Must be called before downloading files.
Args:
unit_id: Unit identifier
device_type: "slm" | "seismograph"
Returns:
Response dict with status
"""
if device_type == "slm":
try:
return await self.slmm_client.enable_ftp(unit_id)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph FTP not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
async def disable_ftp(
self,
unit_id: str,
device_type: str,
) -> Dict[str, Any]:
"""
Disable FTP server on device.
Args:
unit_id: Unit identifier
device_type: "slm" | "seismograph"
Returns:
Response dict with status
"""
if device_type == "slm":
try:
return await self.slmm_client.disable_ftp(unit_id)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph FTP not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# Device Configuration
# ========================================================================

View File

@@ -0,0 +1,668 @@
"""
Fleet Calendar Service
Business logic for:
- Calculating unit availability on any given date
- Calibration status tracking (valid, expiring soon, expired)
- Job reservation management
- Conflict detection (calibration expires mid-job)
"""
from datetime import date, datetime, timedelta
from typing import Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from backend.models import (
RosterUnit, JobReservation, JobReservationUnit,
UserPreferences, Project
)
def get_calibration_status(
unit: RosterUnit,
check_date: date,
warning_days: int = 30
) -> str:
"""
Determine calibration status for a unit on a specific date.
Returns:
"valid" - Calibration is good on this date
"expiring_soon" - Within warning_days of expiry
"expired" - Calibration has expired
"needs_calibration" - No calibration date set
"""
if not unit.last_calibrated:
return "needs_calibration"
# Calculate expiry date (1 year from last calibration)
expiry_date = unit.last_calibrated + timedelta(days=365)
if check_date >= expiry_date:
return "expired"
elif check_date >= expiry_date - timedelta(days=warning_days):
return "expiring_soon"
else:
return "valid"
def get_unit_reservations_on_date(
db: Session,
unit_id: str,
check_date: date
) -> List[JobReservation]:
"""Get all reservations that include this unit on the given date."""
# Get reservation IDs that have this unit assigned
assigned_reservation_ids = db.query(JobReservationUnit.reservation_id).filter(
JobReservationUnit.unit_id == unit_id
).subquery()
# Get reservations that:
# 1. Have this unit assigned AND date is within range
reservations = db.query(JobReservation).filter(
JobReservation.id.in_(assigned_reservation_ids),
JobReservation.start_date <= check_date,
JobReservation.end_date >= check_date
).all()
return reservations
def is_unit_available_on_date(
db: Session,
unit: RosterUnit,
check_date: date,
warning_days: int = 30
) -> Tuple[bool, str, Optional[str]]:
"""
Check if a unit is available on a specific date.
Returns:
(is_available, status, reservation_name)
- is_available: True if unit can be assigned to new work
- status: "available", "reserved", "expired", "retired", "needs_calibration"
- reservation_name: Name of blocking reservation (if any)
"""
# Check if retired
if unit.retired:
return False, "retired", None
# Check calibration status
cal_status = get_calibration_status(unit, check_date, warning_days)
if cal_status == "expired":
return False, "expired", None
if cal_status == "needs_calibration":
return False, "needs_calibration", None
# Check if already reserved
reservations = get_unit_reservations_on_date(db, unit.id, check_date)
if reservations:
return False, "reserved", reservations[0].name
# Unit is available (even if expiring soon - that's just a warning)
return True, "available", None
def get_day_summary(
db: Session,
check_date: date,
device_type: str = "seismograph"
) -> Dict:
"""
Get a complete summary of fleet status for a specific day.
Returns dict with:
- available_units: List of available unit IDs with calibration info
- reserved_units: List of reserved unit IDs with reservation info
- expired_units: List of units with expired calibration
- expiring_soon_units: List of units expiring within warning period
- reservations: List of active reservations on this date
- counts: Summary counts
"""
# Get user preferences for warning days
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
# Get all non-retired units of the specified device type
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
available_units = []
reserved_units = []
expired_units = []
expiring_soon_units = []
needs_calibration_units = []
cal_expiring_today = [] # Units whose calibration expires ON this day
for unit in units:
is_avail, status, reservation_name = is_unit_available_on_date(
db, unit, check_date, warning_days
)
cal_status = get_calibration_status(unit, check_date, warning_days)
expiry_date = None
if unit.last_calibrated:
expiry_date = (unit.last_calibrated + timedelta(days=365)).isoformat()
unit_info = {
"id": unit.id,
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"expiry_date": expiry_date,
"calibration_status": cal_status,
"deployed": unit.deployed,
"note": unit.note or ""
}
# Check if calibration expires ON this specific day
if unit.last_calibrated:
unit_expiry_date = unit.last_calibrated + timedelta(days=365)
if unit_expiry_date == check_date:
cal_expiring_today.append(unit_info)
if status == "available":
available_units.append(unit_info)
if cal_status == "expiring_soon":
expiring_soon_units.append(unit_info)
elif status == "reserved":
unit_info["reservation_name"] = reservation_name
reserved_units.append(unit_info)
if cal_status == "expiring_soon":
expiring_soon_units.append(unit_info)
elif status == "expired":
expired_units.append(unit_info)
elif status == "needs_calibration":
needs_calibration_units.append(unit_info)
# Get active reservations on this date
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= check_date,
JobReservation.end_date >= check_date
).all()
reservation_list = []
for res in reservations:
# Count assigned units for this reservation
assigned_count = db.query(JobReservationUnit).filter(
JobReservationUnit.reservation_id == res.id
).count()
reservation_list.append({
"id": res.id,
"name": res.name,
"start_date": res.start_date.isoformat(),
"end_date": res.end_date.isoformat(),
"assignment_type": res.assignment_type,
"quantity_needed": res.quantity_needed,
"assigned_count": assigned_count,
"color": res.color,
"project_id": res.project_id
})
return {
"date": check_date.isoformat(),
"device_type": device_type,
"available_units": available_units,
"reserved_units": reserved_units,
"expired_units": expired_units,
"expiring_soon_units": expiring_soon_units,
"needs_calibration_units": needs_calibration_units,
"cal_expiring_today": cal_expiring_today,
"reservations": reservation_list,
"counts": {
"available": len(available_units),
"reserved": len(reserved_units),
"expired": len(expired_units),
"expiring_soon": len(expiring_soon_units),
"needs_calibration": len(needs_calibration_units),
"cal_expiring_today": len(cal_expiring_today),
"total": len(units)
}
}
def get_calendar_year_data(
db: Session,
year: int,
device_type: str = "seismograph"
) -> Dict:
"""
Get calendar data for an entire year.
For performance, this returns summary counts per day rather than
full unit lists. Use get_day_summary() for detailed day data.
"""
# Get user preferences
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
# Get all units
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
# Get all reservations that overlap with this year
# Include TBD reservations (end_date is null) that started before year end
year_start = date(year, 1, 1)
year_end = date(year, 12, 31)
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= year_end,
or_(
JobReservation.end_date >= year_start,
JobReservation.end_date == None # TBD reservations
)
).all()
# Get all unit assignments for these reservations
reservation_ids = [r.id for r in reservations]
assignments = db.query(JobReservationUnit).filter(
JobReservationUnit.reservation_id.in_(reservation_ids)
).all() if reservation_ids else []
# Build a lookup: unit_id -> list of (start_date, end_date, reservation_name)
# For TBD reservations, use estimated_end_date if available, or a far future date
unit_reservations = {}
for res in reservations:
res_assignments = [a for a in assignments if a.reservation_id == res.id]
for assignment in res_assignments:
unit_id = assignment.unit_id
# Use unit-specific dates if set, otherwise use reservation dates
start_d = assignment.unit_start_date or res.start_date
if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd):
# TBD: use estimated date or far future for availability calculation
end_d = res.estimated_end_date or date(year + 5, 12, 31)
else:
end_d = assignment.unit_end_date or res.end_date or date(year + 5, 12, 31)
if unit_id not in unit_reservations:
unit_reservations[unit_id] = []
unit_reservations[unit_id].append((start_d, end_d, res.name))
# Generate data for each month
months_data = {}
for month in range(1, 13):
# Get first and last day of month
first_day = date(year, month, 1)
if month == 12:
last_day = date(year, 12, 31)
else:
last_day = date(year, month + 1, 1) - timedelta(days=1)
days_data = {}
current_day = first_day
while current_day <= last_day:
available = 0
reserved = 0
expired = 0
expiring_soon = 0
needs_cal = 0
cal_expiring_on_day = 0 # Units whose calibration expires ON this day
cal_expired_on_day = 0 # Units whose calibration expired ON this day
for unit in units:
# Check calibration
cal_status = get_calibration_status(unit, current_day, warning_days)
# Check if calibration expires/expired ON this specific day
if unit.last_calibrated:
unit_expiry = unit.last_calibrated + timedelta(days=365)
if unit_expiry == current_day:
cal_expiring_on_day += 1
# Check if expired yesterday (first day of being expired)
elif unit_expiry == current_day - timedelta(days=1):
cal_expired_on_day += 1
if cal_status == "expired":
expired += 1
continue
if cal_status == "needs_calibration":
needs_cal += 1
continue
# Check if reserved
is_reserved = False
if unit.id in unit_reservations:
for start_d, end_d, _ in unit_reservations[unit.id]:
if start_d <= current_day <= end_d:
is_reserved = True
break
if is_reserved:
reserved += 1
else:
available += 1
if cal_status == "expiring_soon":
expiring_soon += 1
days_data[current_day.day] = {
"available": available,
"reserved": reserved,
"expired": expired,
"expiring_soon": expiring_soon,
"needs_calibration": needs_cal,
"cal_expiring_on_day": cal_expiring_on_day,
"cal_expired_on_day": cal_expired_on_day
}
current_day += timedelta(days=1)
months_data[month] = {
"name": first_day.strftime("%B"),
"short_name": first_day.strftime("%b"),
"days": days_data,
"first_weekday": first_day.weekday(), # 0=Monday, 6=Sunday
"num_days": last_day.day
}
# Also include reservation summary for the year
reservation_list = []
for res in reservations:
assigned_count = len([a for a in assignments if a.reservation_id == res.id])
reservation_list.append({
"id": res.id,
"name": res.name,
"start_date": res.start_date.isoformat(),
"end_date": res.end_date.isoformat(),
"quantity_needed": res.quantity_needed,
"assigned_count": assigned_count,
"color": res.color
})
return {
"year": year,
"device_type": device_type,
"months": months_data,
"reservations": reservation_list,
"total_units": len(units)
}
def get_rolling_calendar_data(
db: Session,
start_year: int,
start_month: int,
device_type: str = "seismograph"
) -> Dict:
"""
Get calendar data for 12 months starting from a specific month/year.
This supports the rolling calendar view where users can scroll through
months one at a time, viewing any 12-month window.
"""
# Get user preferences
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
# Get all units
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
# Calculate the date range for 12 months
first_date = date(start_year, start_month, 1)
# Calculate end date (12 months later)
end_year = start_year + 1 if start_month == 1 else start_year
end_month = 12 if start_month == 1 else start_month - 1
if start_month == 1:
end_year = start_year
end_month = 12
else:
# 12 months from start_month means we end at start_month - 1 next year
end_year = start_year + 1
end_month = start_month - 1
# Actually, simpler: go 11 months forward from start
end_year = start_year + ((start_month + 10) // 12)
end_month = ((start_month + 10) % 12) + 1
if end_month == 12:
last_date = date(end_year, 12, 31)
else:
last_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
# Get all reservations that overlap with this 12-month range
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= last_date,
or_(
JobReservation.end_date >= first_date,
JobReservation.end_date == None # TBD reservations
)
).all()
# Get all unit assignments for these reservations
reservation_ids = [r.id for r in reservations]
assignments = db.query(JobReservationUnit).filter(
JobReservationUnit.reservation_id.in_(reservation_ids)
).all() if reservation_ids else []
# Build a lookup: unit_id -> list of (start_date, end_date, reservation_name)
unit_reservations = {}
for res in reservations:
res_assignments = [a for a in assignments if a.reservation_id == res.id]
for assignment in res_assignments:
unit_id = assignment.unit_id
start_d = assignment.unit_start_date or res.start_date
if assignment.unit_end_tbd or (assignment.unit_end_date is None and res.end_date_tbd):
end_d = res.estimated_end_date or date(start_year + 5, 12, 31)
else:
end_d = assignment.unit_end_date or res.end_date or date(start_year + 5, 12, 31)
if unit_id not in unit_reservations:
unit_reservations[unit_id] = []
unit_reservations[unit_id].append((start_d, end_d, res.name))
# Generate data for each of the 12 months
months_data = []
current_year = start_year
current_month = start_month
for i in range(12):
# Calculate this month's year and month
m_year = start_year + ((start_month - 1 + i) // 12)
m_month = ((start_month - 1 + i) % 12) + 1
first_day = date(m_year, m_month, 1)
if m_month == 12:
last_day = date(m_year, 12, 31)
else:
last_day = date(m_year, m_month + 1, 1) - timedelta(days=1)
days_data = {}
current_day = first_day
while current_day <= last_day:
available = 0
reserved = 0
expired = 0
expiring_soon = 0
needs_cal = 0
cal_expiring_on_day = 0
cal_expired_on_day = 0
for unit in units:
cal_status = get_calibration_status(unit, current_day, warning_days)
if unit.last_calibrated:
unit_expiry = unit.last_calibrated + timedelta(days=365)
if unit_expiry == current_day:
cal_expiring_on_day += 1
elif unit_expiry == current_day - timedelta(days=1):
cal_expired_on_day += 1
if cal_status == "expired":
expired += 1
continue
if cal_status == "needs_calibration":
needs_cal += 1
continue
is_reserved = False
if unit.id in unit_reservations:
for start_d, end_d, _ in unit_reservations[unit.id]:
if start_d <= current_day <= end_d:
is_reserved = True
break
if is_reserved:
reserved += 1
else:
available += 1
if cal_status == "expiring_soon":
expiring_soon += 1
days_data[current_day.day] = {
"available": available,
"reserved": reserved,
"expired": expired,
"expiring_soon": expiring_soon,
"needs_calibration": needs_cal,
"cal_expiring_on_day": cal_expiring_on_day,
"cal_expired_on_day": cal_expired_on_day
}
current_day += timedelta(days=1)
months_data.append({
"year": m_year,
"month": m_month,
"name": first_day.strftime("%B"),
"short_name": first_day.strftime("%b"),
"year_short": first_day.strftime("%y"),
"days": days_data,
"first_weekday": first_day.weekday(),
"num_days": last_day.day
})
return {
"start_year": start_year,
"start_month": start_month,
"device_type": device_type,
"months": months_data,
"total_units": len(units)
}
def check_calibration_conflicts(
db: Session,
reservation_id: str
) -> List[Dict]:
"""
Check if any units assigned to a reservation will have their
calibration expire during the reservation period.
Returns list of conflicts with unit info and expiry date.
"""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
return []
# Get assigned units
assigned = db.query(JobReservationUnit).filter_by(
reservation_id=reservation_id
).all()
conflicts = []
for assignment in assigned:
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if not unit or not unit.last_calibrated:
continue
expiry_date = unit.last_calibrated + timedelta(days=365)
# Check if expiry falls within reservation period
if reservation.start_date < expiry_date <= reservation.end_date:
conflicts.append({
"unit_id": unit.id,
"last_calibrated": unit.last_calibrated.isoformat(),
"expiry_date": expiry_date.isoformat(),
"reservation_name": reservation.name,
"days_into_job": (expiry_date - reservation.start_date).days
})
return conflicts
def get_available_units_for_period(
db: Session,
start_date: date,
end_date: date,
device_type: str = "seismograph",
exclude_reservation_id: Optional[str] = None
) -> List[Dict]:
"""
Get units that are available for the entire specified period.
A unit is available if:
- Not retired
- Calibration is valid through the end date
- Not assigned to any other reservation that overlaps the period
"""
prefs = db.query(UserPreferences).filter_by(id=1).first()
warning_days = prefs.calibration_warning_days if prefs else 30
units = db.query(RosterUnit).filter(
RosterUnit.device_type == device_type,
RosterUnit.retired == False
).all()
# Get reservations that overlap with this period
overlapping_reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= end_date,
JobReservation.end_date >= start_date
)
if exclude_reservation_id:
overlapping_reservations = overlapping_reservations.filter(
JobReservation.id != exclude_reservation_id
)
overlapping_reservations = overlapping_reservations.all()
# Get all units assigned to overlapping reservations
reserved_unit_ids = set()
for res in overlapping_reservations:
assigned = db.query(JobReservationUnit).filter_by(
reservation_id=res.id
).all()
for a in assigned:
reserved_unit_ids.add(a.unit_id)
available_units = []
for unit in units:
# Check if already reserved
if unit.id in reserved_unit_ids:
continue
# Check calibration through end of period
if not unit.last_calibrated:
continue # Needs calibration
expiry_date = unit.last_calibrated + timedelta(days=365)
if expiry_date <= end_date:
continue # Calibration expires during period
cal_status = get_calibration_status(unit, end_date, warning_days)
available_units.append({
"id": unit.id,
"last_calibrated": unit.last_calibrated.isoformat(),
"expiry_date": expiry_date.isoformat(),
"calibration_status": cal_status,
"deployed": unit.deployed,
"note": unit.note or ""
})
return available_units

View File

@@ -49,6 +49,8 @@ class RecurringScheduleService:
include_download: bool = True,
auto_increment_index: bool = True,
timezone: str = "America/New_York",
start_datetime: datetime = None,
end_datetime: datetime = None,
) -> RecurringSchedule:
"""
Create a new recurring schedule.
@@ -57,7 +59,7 @@ class RecurringScheduleService:
project_id: Project ID
location_id: Monitoring location ID
name: Schedule name
schedule_type: "weekly_calendar" or "simple_interval"
schedule_type: "weekly_calendar", "simple_interval", or "one_off"
device_type: "slm" or "seismograph"
unit_id: Specific unit (optional, can use assignment)
weekly_pattern: Dict of day patterns for weekly_calendar
@@ -66,6 +68,8 @@ class RecurringScheduleService:
include_download: Whether to download data on cycle
auto_increment_index: Whether to auto-increment store index before start
timezone: Timezone for schedule times
start_datetime: Start date+time in UTC (one_off only)
end_datetime: End date+time in UTC (one_off only)
Returns:
Created RecurringSchedule
@@ -85,6 +89,8 @@ class RecurringScheduleService:
auto_increment_index=auto_increment_index,
enabled=True,
timezone=timezone,
start_datetime=start_datetime,
end_datetime=end_datetime,
)
# Calculate next occurrence
@@ -169,8 +175,25 @@ class RecurringScheduleService:
return self.update_schedule(schedule_id, enabled=True)
def disable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]:
"""Disable a schedule."""
return self.update_schedule(schedule_id, enabled=False)
"""Disable a schedule and cancel its pending actions."""
schedule = self.update_schedule(schedule_id, enabled=False)
if schedule:
# Cancel all pending actions generated by this schedule
pending_actions = self.db.query(ScheduledAction).filter(
and_(
ScheduledAction.execution_status == "pending",
ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'),
)
).all()
for action in pending_actions:
action.execution_status = "cancelled"
if pending_actions:
self.db.commit()
logger.info(f"Cancelled {len(pending_actions)} pending actions for disabled schedule {schedule.name}")
return schedule
def generate_actions_for_schedule(
self,
@@ -196,6 +219,8 @@ class RecurringScheduleService:
actions = self._generate_weekly_calendar_actions(schedule, horizon_days)
elif schedule.schedule_type == "simple_interval":
actions = self._generate_interval_actions(schedule, horizon_days)
elif schedule.schedule_type == "one_off":
actions = self._generate_one_off_actions(schedule)
else:
logger.warning(f"Unknown schedule type: {schedule.schedule_type}")
return []
@@ -384,61 +409,71 @@ class RecurringScheduleService:
if cycle_utc <= now_utc:
continue
# Check if action already exists
if self._action_exists(schedule.project_id, schedule.location_id, "stop", cycle_utc):
# Check if cycle action already exists
if self._action_exists(schedule.project_id, schedule.location_id, "cycle", cycle_utc):
continue
# Build notes with metadata
stop_notes = json.dumps({
# Build notes with metadata for cycle action
cycle_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"cycle_type": "daily",
"include_download": schedule.include_download,
"auto_increment_index": schedule.auto_increment_index,
})
# Create STOP action
stop_action = ScheduledAction(
# Create single CYCLE action that handles stop -> download -> start
# The scheduler's _execute_cycle method handles the full workflow with delays
cycle_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="stop",
action_type="cycle",
device_type=schedule.device_type,
scheduled_time=cycle_utc,
execution_status="pending",
notes=stop_notes,
notes=cycle_notes,
)
actions.append(stop_action)
actions.append(cycle_action)
# Create DOWNLOAD action if enabled (1 minute after stop)
if schedule.include_download:
download_time = cycle_utc + timedelta(minutes=1)
download_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"cycle_type": "daily",
})
download_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="download",
device_type=schedule.device_type,
scheduled_time=download_time,
execution_status="pending",
notes=download_notes,
)
actions.append(download_action)
return actions
# Create START action (2 minutes after stop, or 1 minute after download)
start_offset = 2 if schedule.include_download else 1
start_time = cycle_utc + timedelta(minutes=start_offset)
def _generate_one_off_actions(
self,
schedule: RecurringSchedule,
) -> List[ScheduledAction]:
"""
Generate start and stop actions for a one-off recording.
Unlike recurring types, this generates exactly one start and one stop action
using the schedule's start_datetime and end_datetime directly.
"""
if not schedule.start_datetime or not schedule.end_datetime:
logger.warning(f"One-off schedule {schedule.id} missing start/end datetime")
return []
actions = []
now_utc = datetime.utcnow()
unit_id = self._resolve_unit_id(schedule)
# Skip if end time has already passed
if schedule.end_datetime <= now_utc:
return []
# Check if actions already exist for this schedule
if self._action_exists(schedule.project_id, schedule.location_id, "start", schedule.start_datetime):
return []
# Create START action (only if start time hasn't passed)
if schedule.start_datetime > now_utc:
start_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"cycle_type": "daily",
"schedule_type": "one_off",
"auto_increment_index": schedule.auto_increment_index,
})
start_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
@@ -446,12 +481,33 @@ class RecurringScheduleService:
unit_id=unit_id,
action_type="start",
device_type=schedule.device_type,
scheduled_time=start_time,
scheduled_time=schedule.start_datetime,
execution_status="pending",
notes=start_notes,
)
actions.append(start_action)
# Create STOP action
stop_notes = json.dumps({
"schedule_name": schedule.name,
"schedule_id": schedule.id,
"schedule_type": "one_off",
"include_download": schedule.include_download,
})
stop_action = ScheduledAction(
id=str(uuid.uuid4()),
project_id=schedule.project_id,
location_id=schedule.location_id,
unit_id=unit_id,
action_type="stop",
device_type=schedule.device_type,
scheduled_time=schedule.end_datetime,
execution_status="pending",
notes=stop_notes,
)
actions.append(stop_action)
return actions
def _calculate_next_occurrence(self, schedule: RecurringSchedule) -> Optional[datetime]:
@@ -494,6 +550,13 @@ class RecurringScheduleService:
if cycle_utc > now_utc:
return cycle_utc
elif schedule.schedule_type == "one_off":
if schedule.start_datetime and schedule.start_datetime > now_utc:
return schedule.start_datetime
elif schedule.end_datetime and schedule.end_datetime > now_utc:
return schedule.end_datetime
return None
return None
def _resolve_unit_id(self, schedule: RecurringSchedule) -> Optional[str]:

View File

@@ -185,6 +185,8 @@ class SchedulerService:
response = await self._execute_stop(action, unit_id, db)
elif action.action_type == "download":
response = await self._execute_download(action, unit_id, db)
elif action.action_type == "cycle":
response = await self._execute_cycle(action, unit_id, db)
else:
raise Exception(f"Unknown action type: {action.action_type}")
@@ -350,7 +352,14 @@ class SchedulerService:
unit_id: str,
db: Session,
) -> Dict[str, Any]:
"""Execute a 'download' action."""
"""Execute a 'download' action.
This handles standalone download actions (not part of stop_cycle).
The workflow is:
1. Enable FTP on device
2. Download current measurement folder
3. (Optionally disable FTP - left enabled for now)
"""
# Get project and location info for file path
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
project = db.query(Project).filter_by(id=action.project_id).first()
@@ -358,8 +367,8 @@ class SchedulerService:
if not location or not project:
raise Exception("Project or location not found")
# Build destination path
# Example: data/Projects/{project-id}/sound/{location-name}/session-{timestamp}/
# Build destination path (for logging/metadata reference)
# Actual download location is managed by SLMM (data/downloads/{unit_id}/)
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
@@ -368,12 +377,23 @@ class SchedulerService:
f"{location.name}/session-{session_timestamp}/"
)
# Download files via device controller
# Step 1: Disable FTP first to reset any stale connection state
# Then enable FTP on device
logger.info(f"Resetting FTP on {unit_id} for download (disable then enable)")
try:
await self.device_controller.disable_ftp(unit_id, action.device_type)
except Exception as e:
logger.warning(f"FTP disable failed (may already be off): {e}")
await self.device_controller.enable_ftp(unit_id, action.device_type)
# Step 2: Download current measurement folder
# The slmm_client.download_files() now automatically determines the correct
# folder based on the device's current index number
response = await self.device_controller.download_files(
unit_id,
action.device_type,
destination_path,
files=None, # Download all files
files=None, # Download all files in current measurement folder
)
# TODO: Create DataFile records for downloaded files
@@ -384,6 +404,200 @@ class SchedulerService:
"device_response": response,
}
async def _execute_cycle(
self,
action: ScheduledAction,
unit_id: str,
db: Session,
) -> Dict[str, Any]:
"""Execute a full 'cycle' action: stop -> download -> start.
This combines stop, download, and start into a single action with
appropriate delays between steps to ensure device stability.
Workflow:
0. Pause background polling to prevent command conflicts
1. Stop measurement (wait 10s)
2. Disable FTP to reset state (wait 10s)
3. Enable FTP (wait 10s)
4. Download current measurement folder
5. Wait 30s for device to settle
6. Start new measurement cycle
7. Re-enable background polling
Total time: ~70-90 seconds depending on download size
"""
logger.info(f"[CYCLE] === Starting full cycle for {unit_id} ===")
result = {
"status": "cycle_complete",
"steps": {},
"old_session_id": None,
"new_session_id": None,
"polling_paused": False,
}
# Step 0: Pause background polling for this device to prevent command conflicts
# NL-43 devices only support one TCP connection at a time
logger.info(f"[CYCLE] Step 0: Pausing background polling for {unit_id}")
polling_was_enabled = False
try:
if action.device_type == "slm":
# Get current polling state to restore later
from backend.services.slmm_client import get_slmm_client
slmm = get_slmm_client()
try:
polling_config = await slmm.get_device_polling_config(unit_id)
polling_was_enabled = polling_config.get("poll_enabled", False)
except Exception:
polling_was_enabled = True # Assume enabled if can't check
# Disable polling during cycle
await slmm.update_device_polling_config(unit_id, poll_enabled=False)
result["polling_paused"] = True
logger.info(f"[CYCLE] Background polling paused for {unit_id}")
except Exception as e:
logger.warning(f"[CYCLE] Failed to pause polling (continuing anyway): {e}")
try:
# Step 1: Stop measurement
logger.info(f"[CYCLE] Step 1/7: Stopping measurement on {unit_id}")
try:
stop_response = await self.device_controller.stop_recording(unit_id, action.device_type)
result["steps"]["stop"] = {"success": True, "response": stop_response}
logger.info(f"[CYCLE] Measurement stopped, waiting 10s...")
except Exception as e:
logger.warning(f"[CYCLE] Stop failed (may already be stopped): {e}")
result["steps"]["stop"] = {"success": False, "error": str(e)}
await asyncio.sleep(10)
# Step 2: Disable FTP to reset any stale state
logger.info(f"[CYCLE] Step 2/7: Disabling FTP on {unit_id}")
try:
await self.device_controller.disable_ftp(unit_id, action.device_type)
result["steps"]["ftp_disable"] = {"success": True}
logger.info(f"[CYCLE] FTP disabled, waiting 10s...")
except Exception as e:
logger.warning(f"[CYCLE] FTP disable failed (may already be off): {e}")
result["steps"]["ftp_disable"] = {"success": False, "error": str(e)}
await asyncio.sleep(10)
# Step 3: Enable FTP
logger.info(f"[CYCLE] Step 3/7: Enabling FTP on {unit_id}")
try:
await self.device_controller.enable_ftp(unit_id, action.device_type)
result["steps"]["ftp_enable"] = {"success": True}
logger.info(f"[CYCLE] FTP enabled, waiting 10s...")
except Exception as e:
logger.error(f"[CYCLE] FTP enable failed: {e}")
result["steps"]["ftp_enable"] = {"success": False, "error": str(e)}
# Continue anyway - download will fail but we can still try to start
await asyncio.sleep(10)
# Step 4: Download current measurement folder
logger.info(f"[CYCLE] Step 4/7: Downloading measurement data from {unit_id}")
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
project = db.query(Project).filter_by(id=action.project_id).first()
if location and project:
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
destination_path = (
f"data/Projects/{project.id}/{location_type_dir}/"
f"{location.name}/session-{session_timestamp}/"
)
try:
download_response = await self.device_controller.download_files(
unit_id,
action.device_type,
destination_path,
files=None,
)
result["steps"]["download"] = {"success": True, "response": download_response}
logger.info(f"[CYCLE] Download complete")
except Exception as e:
logger.error(f"[CYCLE] Download failed: {e}")
result["steps"]["download"] = {"success": False, "error": str(e)}
else:
result["steps"]["download"] = {"success": False, "error": "Project or location not found"}
# Close out the old recording session
active_session = db.query(RecordingSession).filter(
and_(
RecordingSession.location_id == action.location_id,
RecordingSession.unit_id == unit_id,
RecordingSession.status == "recording",
)
).first()
if active_session:
active_session.stopped_at = datetime.utcnow()
active_session.status = "completed"
active_session.duration_seconds = int(
(active_session.stopped_at - active_session.started_at).total_seconds()
)
result["old_session_id"] = active_session.id
# Step 5: Wait for device to settle before starting new measurement
logger.info(f"[CYCLE] Step 5/7: Waiting 30s for device to settle...")
await asyncio.sleep(30)
# Step 6: Start new measurement cycle
logger.info(f"[CYCLE] Step 6/7: Starting new measurement on {unit_id}")
try:
cycle_response = await self.device_controller.start_cycle(
unit_id,
action.device_type,
sync_clock=True,
)
result["steps"]["start"] = {"success": True, "response": cycle_response}
# Create new recording session
new_session = RecordingSession(
id=str(uuid.uuid4()),
project_id=action.project_id,
location_id=action.location_id,
unit_id=unit_id,
session_type="sound" if action.device_type == "slm" else "vibration",
started_at=datetime.utcnow(),
status="recording",
session_metadata=json.dumps({
"scheduled_action_id": action.id,
"cycle_response": cycle_response,
"action_type": "cycle",
}),
)
db.add(new_session)
result["new_session_id"] = new_session.id
logger.info(f"[CYCLE] New measurement started, session {new_session.id}")
except Exception as e:
logger.error(f"[CYCLE] Start failed: {e}")
result["steps"]["start"] = {"success": False, "error": str(e)}
raise # Re-raise to mark the action as failed
finally:
# Step 7: Re-enable background polling (always runs, even on failure)
if result.get("polling_paused") and polling_was_enabled:
logger.info(f"[CYCLE] Step 7/7: Re-enabling background polling for {unit_id}")
try:
if action.device_type == "slm":
from backend.services.slmm_client import get_slmm_client
slmm = get_slmm_client()
await slmm.update_device_polling_config(unit_id, poll_enabled=True)
logger.info(f"[CYCLE] Background polling re-enabled for {unit_id}")
except Exception as e:
logger.error(f"[CYCLE] Failed to re-enable polling: {e}")
# Don't raise - cycle completed, just log the error
logger.info(f"[CYCLE] === Cycle complete for {unit_id} ===")
return result
# ========================================================================
# Recurring Schedule Generation
# ========================================================================
@@ -414,6 +628,15 @@ class SchedulerService:
for schedule in schedules:
try:
# Auto-disable one-off schedules whose end time has passed
if schedule.schedule_type == "one_off" and schedule.end_datetime:
if schedule.end_datetime <= datetime.utcnow():
schedule.enabled = False
schedule.next_occurrence = None
db.commit()
logger.info(f"Auto-disabled completed one-off schedule: {schedule.name}")
continue
actions = service.generate_actions_for_schedule(schedule, horizon_days=7)
total_generated += len(actions)
except Exception as e:

View File

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

View File

@@ -109,7 +109,71 @@ class SLMMClient:
f"SLMM operation failed: {error_detail}"
)
except Exception as e:
raise SLMMClientError(f"Unexpected error: {str(e)}")
error_msg = str(e) if str(e) else type(e).__name__
raise SLMMClientError(f"Unexpected error: {error_msg}")
async def _download_request(
self,
endpoint: str,
data: Dict[str, Any],
unit_id: str,
) -> Dict[str, Any]:
"""
Make a download request to SLMM that returns binary file content (not JSON).
Saves the file locally and returns metadata about the download.
"""
url = f"{self.api_base}{endpoint}"
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0)) as client:
response = await client.post(url, json=data)
response.raise_for_status()
# Determine filename from Content-Disposition header or generate one
content_disp = response.headers.get("content-disposition", "")
filename = None
if "filename=" in content_disp:
filename = content_disp.split("filename=")[-1].strip('" ')
if not filename:
remote_path = data.get("remote_path", "download")
base = os.path.basename(remote_path.rstrip("/"))
filename = f"{base}.zip" if not base.endswith(".zip") else base
# Save to local downloads directory
download_dir = os.path.join("data", "downloads", unit_id)
os.makedirs(download_dir, exist_ok=True)
local_path = os.path.join(download_dir, filename)
with open(local_path, "wb") as f:
f.write(response.content)
return {
"success": True,
"local_path": local_path,
"filename": filename,
"size_bytes": len(response.content),
}
except httpx.ConnectError as e:
raise SLMMConnectionError(
f"Cannot connect to SLMM backend at {self.base_url}. "
f"Is SLMM running? Error: {str(e)}"
)
except httpx.HTTPStatusError as e:
error_detail = "Unknown error"
try:
error_data = e.response.json()
error_detail = error_data.get("detail", str(error_data))
except Exception:
error_detail = e.response.text or str(e)
raise SLMMDeviceError(f"SLMM download failed: {error_detail}")
except (SLMMConnectionError, SLMMDeviceError):
raise
except Exception as e:
error_msg = str(e) if str(e) else type(e).__name__
raise SLMMClientError(f"Download error: {error_msg}")
# ========================================================================
# Unit Management
@@ -478,9 +542,130 @@ class SLMMClient:
return await self._request("GET", f"/{unit_id}/settings")
# ========================================================================
# Data Download (Future)
# FTP Control
# ========================================================================
async def enable_ftp(self, unit_id: str) -> Dict[str, Any]:
"""
Enable FTP server on device.
Must be called before downloading files. FTP and TCP can work in tandem.
Args:
unit_id: Unit identifier
Returns:
Dict with status message
"""
return await self._request("POST", f"/{unit_id}/ftp/enable")
async def disable_ftp(self, unit_id: str) -> Dict[str, Any]:
"""
Disable FTP server on device.
Args:
unit_id: Unit identifier
Returns:
Dict with status message
"""
return await self._request("POST", f"/{unit_id}/ftp/disable")
async def get_ftp_status(self, unit_id: str) -> Dict[str, Any]:
"""
Get FTP server status on device.
Args:
unit_id: Unit identifier
Returns:
Dict with ftp_enabled status
"""
return await self._request("GET", f"/{unit_id}/ftp/status")
# ========================================================================
# Data Download
# ========================================================================
async def download_file(
self,
unit_id: str,
remote_path: str,
) -> Dict[str, Any]:
"""
Download a single file from unit via FTP.
Args:
unit_id: Unit identifier
remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav")
Returns:
Dict with local_path, filename, size_bytes
"""
return await self._download_request(
f"/{unit_id}/ftp/download",
{"remote_path": remote_path},
unit_id,
)
async def download_folder(
self,
unit_id: str,
remote_path: str,
) -> Dict[str, Any]:
"""
Download an entire folder from unit via FTP as a ZIP archive.
Useful for downloading complete measurement sessions (e.g., Auto_0000 folders).
Args:
unit_id: Unit identifier
remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000")
Returns:
Dict with local_path, folder_name, size_bytes
"""
return await self._download_request(
f"/{unit_id}/ftp/download-folder",
{"remote_path": remote_path},
unit_id,
)
async def download_current_measurement(
self,
unit_id: str,
) -> Dict[str, Any]:
"""
Download the current measurement folder based on device's index number.
This is the recommended method for scheduled downloads - it automatically
determines which folder to download based on the device's current store index.
Args:
unit_id: Unit identifier
Returns:
Dict with local_path, folder_name, file_count, zip_size_bytes, index_number
"""
# Get current index number from device
index_info = await self.get_index_number(unit_id)
index_number_raw = index_info.get("index_number", 0)
# Convert to int - device returns string like "0000" or "0001"
try:
index_number = int(index_number_raw)
except (ValueError, TypeError):
index_number = 0
# Format as Auto_XXXX folder name
folder_name = f"Auto_{index_number:04d}"
remote_path = f"/NL43_DATA/{folder_name}"
# Download the folder
result = await self.download_folder(unit_id, remote_path)
result["index_number"] = index_number
return result
async def download_files(
self,
unit_id: str,
@@ -488,23 +673,24 @@ class SLMMClient:
files: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Download files from unit via FTP.
Download measurement files from unit via FTP.
NOTE: This endpoint doesn't exist in SLMM yet. Will need to implement.
This method automatically determines the current measurement folder and downloads it.
The destination_path parameter is logged for reference but actual download location
is managed by SLMM (data/downloads/{unit_id}/).
Args:
unit_id: Unit identifier
destination_path: Local path to save files
files: List of filenames to download, or None for all
destination_path: Reference path (for logging/metadata, not used by SLMM)
files: Ignored - always downloads the current measurement folder
Returns:
Dict with downloaded files list and metadata
Dict with download result including local_path, folder_name, etc.
"""
data = {
"destination_path": destination_path,
"files": files or "all",
}
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
# Use the new method that automatically determines what to download
result = await self.download_current_measurement(unit_id)
result["requested_destination"] = destination_path
return result
# ========================================================================
# Cycle Commands (for scheduled automation)

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,7 +1,7 @@
services:
# --- TERRA-VIEW PRODUCTION ---
terra-view-prod:
terra-view:
build: .
container_name: terra-view
ports:
@@ -25,26 +25,28 @@ services:
start_period: 40s
# --- TERRA-VIEW DEVELOPMENT ---
# terra-view-dev:
# build: .
# container_name: terra-view-dev
# ports:
# - "1001:8001"
# volumes:
# - ./data-dev:/app/data
# environment:
# - PYTHONUNBUFFERED=1
# - ENVIRONMENT=development
# - SLMM_BASE_URL=http://slmm:8100
# restart: unless-stopped
# depends_on:
# - slmm
# healthcheck:
# test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
# interval: 30s
# timeout: 10s
# retries: 3
# start_period: 40s
terra-view-dev:
build: .
container_name: terra-view-dev
ports:
- "1001:8001"
volumes:
- ./data-dev:/app/data
environment:
- PYTHONUNBUFFERED=1
- ENVIRONMENT=development
- SLMM_BASE_URL=http://host.docker.internal:8100
restart: unless-stopped
depends_on:
- slmm
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# --- SLMM (Sound Level Meter Manager) ---
slmm:

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,811 @@
{% extends "base.html" %}
{% block title %}Fleet Calendar - Terra-View{% endblock %}
{% block extra_head %}
<style>
/* Calendar grid layout */
.calendar-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
}
@media (max-width: 1280px) {
.calendar-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@media (max-width: 768px) {
.calendar-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 480px) {
.calendar-grid {
grid-template-columns: 1fr;
}
}
/* Month card */
.month-card {
background: white;
border-radius: 0.5rem;
padding: 0.75rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.dark .month-card {
background: rgb(30 41 59);
}
/* Day grid */
.day-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
font-size: 0.75rem;
}
.day-header {
text-align: center;
font-weight: 600;
color: #6b7280;
padding: 0.25rem 0;
}
.dark .day-header {
color: #9ca3af;
}
/* Day cell */
.day-cell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
cursor: pointer;
position: relative;
transition: all 0.15s ease;
font-size: 0.7rem;
}
.day-cell:hover {
transform: scale(1.1);
z-index: 10;
}
.day-cell.today {
ring: 2px;
ring-color: #f48b1c;
font-weight: bold;
}
/* Default neutral day style */
.day-cell.neutral {
background-color: #f3f4f6;
color: #374151;
}
.dark .day-cell.neutral {
background-color: rgba(55, 65, 81, 0.5);
color: #d1d5db;
}
/* Status indicator dot */
.day-cell .status-dot {
position: absolute;
top: 2px;
right: 2px;
width: 5px;
height: 5px;
border-radius: 50%;
}
.day-cell .status-dot.expired {
background-color: #ef4444;
}
/* Reservation mode colors - applied dynamically */
.day-cell.reservation-available {
background-color: #dcfce7;
color: #166534;
}
.dark .day-cell.reservation-available {
background-color: rgba(34, 197, 94, 0.2);
color: #86efac;
}
.day-cell.reservation-partial {
background-color: #fef3c7;
color: #92400e;
}
.dark .day-cell.reservation-partial {
background-color: rgba(245, 158, 11, 0.2);
color: #fcd34d;
}
.day-cell.reservation-unavailable {
background-color: #fee2e2;
color: #991b1b;
}
.dark .day-cell.reservation-unavailable {
background-color: rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
/* Legacy status colors (kept for day detail) */
.day-cell.some-reserved {
background-color: #dbeafe;
color: #1e40af;
}
.dark .day-cell.some-reserved {
background-color: rgba(59, 130, 246, 0.2);
color: #93c5fd;
}
.day-cell.empty {
background: transparent;
cursor: default;
}
.day-cell.empty:hover {
transform: none;
}
/* Reservation bar */
.reservation-bar {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
margin-bottom: 0.5rem;
cursor: pointer;
transition: opacity 0.15s ease;
}
.reservation-bar:hover {
opacity: 0.8;
}
/* Slide-over panel */
.slide-panel {
position: fixed;
top: 0;
right: 0;
width: 100%;
max-width: 28rem;
height: 100vh;
background: white;
box-shadow: -4px 0 15px rgba(0,0,0,0.1);
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 50;
overflow-y: auto;
}
.dark .slide-panel {
background: rgb(30 41 59);
}
.slide-panel.open {
transform: translateX(0);
}
.panel-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.3);
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
z-index: 40;
}
.panel-backdrop.open {
opacity: 1;
visibility: visible;
}
</style>
{% endblock %}
{% block content %}
<div class="mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Calendar</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Plan unit assignments and track calibrations</p>
</div>
</div>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 shadow">
<p class="text-sm text-gray-500 dark:text-gray-400">Total Units</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ calendar_data.total_units }}</p>
</div>
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4 shadow">
<p class="text-sm text-green-600 dark:text-green-400">Available Today</p>
<p class="text-2xl font-bold text-green-700 dark:text-green-300" id="available-today">--</p>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 shadow">
<p class="text-sm text-blue-600 dark:text-blue-400">Reserved Today</p>
<p class="text-2xl font-bold text-blue-700 dark:text-blue-300" id="reserved-today">--</p>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 shadow">
<p class="text-sm text-yellow-600 dark:text-yellow-400">Expiring Soon</p>
<p class="text-2xl font-bold text-yellow-700 dark:text-yellow-300" id="expiring-today">--</p>
</div>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4 shadow">
<p class="text-sm text-red-600 dark:text-red-400">Cal. Expired</p>
<p class="text-2xl font-bold text-red-700 dark:text-red-300" id="expired-today">--</p>
</div>
</div>
<!-- Action Bar -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
<div class="flex items-center gap-4">
<!-- Device Type Toggle -->
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1">
<a href="/fleet-calendar?year={{ start_year }}&month={{ start_month }}&device_type=seismograph"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors {% if device_type == 'seismograph' %}bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow{% else %}text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white{% endif %}">
Seismographs
</a>
<a href="/fleet-calendar?year={{ start_year }}&month={{ start_month }}&device_type=slm"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors {% if device_type == 'slm' %}bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow{% else %}text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white{% endif %}">
SLMs
</a>
</div>
<!-- Legend -->
<div class="hidden md:flex items-center gap-4 text-sm" id="main-legend">
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-gray-200 dark:bg-gray-600 relative">
<span class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-red-500"></span>
</span>
Cal expires
</span>
</div>
<!-- Reservation mode legend (hidden by default) -->
<div class="hidden md:hidden items-center gap-4 text-sm" id="reservation-legend">
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-green-200 dark:bg-green-700"></span>
Available
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-yellow-200 dark:bg-yellow-700"></span>
Partial
</span>
<span class="flex items-center gap-1">
<span class="w-3 h-3 rounded bg-red-200 dark:bg-red-700"></span>
Unavailable
</span>
</div>
</div>
<button onclick="openReservationModal()"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
New Reservation
</button>
</div>
<!-- Calendar Grid -->
<div class="calendar-grid mb-8">
{% for month_data in calendar_data.months %}
<div class="month-card">
<h3 class="font-semibold text-gray-900 dark:text-white mb-2 text-center">
{{ month_data.short_name }} '{{ month_data.year_short }}
</h3>
<div class="day-grid">
<!-- Day headers -->
<div class="day-header">S</div>
<div class="day-header">M</div>
<div class="day-header">T</div>
<div class="day-header">W</div>
<div class="day-header">T</div>
<div class="day-header">F</div>
<div class="day-header">S</div>
<!-- Empty cells for alignment (first_weekday is 0=Mon, we need 0=Sun) -->
{% set first_day_offset = (month_data.first_weekday + 1) % 7 %}
{% for i in range(first_day_offset) %}
<div class="day-cell empty"></div>
{% endfor %}
<!-- Day cells -->
{% for day_num in range(1, month_data.num_days + 1) %}
{% set day_data = month_data.days[day_num] %}
{% set date_str = '%04d-%02d-%02d'|format(month_data.year, month_data.month, day_num) %}
{% set is_today = date_str == today %}
{% set has_cal_expiring = day_data.cal_expiring_on_day is defined and day_data.cal_expiring_on_day > 0 %}
<div class="day-cell neutral {% if is_today %}today ring-2 ring-seismo-orange{% endif %}"
data-date="{{ date_str }}"
data-available="{{ day_data.available }}"
onclick="openDayPanel('{{ date_str }}')"
title="Available: {{ day_data.available }}, Reserved: {{ day_data.reserved }}{% if has_cal_expiring %}, Cal expires: {{ day_data.cal_expiring_on_day }}{% endif %}">
{{ day_num }}
{% if has_cal_expiring %}
<span class="status-dot expired"></span>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<!-- Month Navigation (centered below calendar) -->
<div class="flex items-center justify-center gap-3 mb-8">
<a href="/fleet-calendar?year={{ prev_year }}&month={{ prev_month }}&device_type={{ device_type }}"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Previous month">
<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="M15 19l-7-7 7-7"/>
</svg>
</a>
<span class="text-lg font-bold text-gray-900 dark:text-white px-3">
{{ calendar_data.months[0].name }} {{ calendar_data.months[0].year }} - {{ calendar_data.months[11].name }} {{ calendar_data.months[11].year }}
</span>
<a href="/fleet-calendar?year={{ next_year }}&month={{ next_month }}&device_type={{ device_type }}"
class="px-3 py-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300"
title="Next month">
<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"/>
</svg>
</a>
<a href="/fleet-calendar?device_type={{ device_type }}"
class="ml-2 px-4 py-2 rounded-lg bg-seismo-orange text-white hover:bg-orange-600">
Today
</a>
</div>
<!-- Active Reservations -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Reservations</h2>
<div id="reservations-list"
hx-get="/api/fleet-calendar/reservations-list?year={{ start_year }}&month={{ start_month }}&device_type={{ device_type }}"
hx-trigger="load"
hx-swap="innerHTML">
<p class="text-gray-500">Loading reservations...</p>
</div>
</div>
<!-- Day Detail Slide Panel -->
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
<div id="day-panel" class="slide-panel">
<div class="p-6" id="day-panel-content">
<!-- Content loaded via HTMX -->
</div>
</div>
<!-- Reservation Modal -->
<div id="reservation-modal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeReservationModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto" onclick="event.stopPropagation()">
<div class="p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">New Reservation</h2>
<button onclick="closeReservationModal()" 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"/>
</svg>
</button>
</div>
<form id="reservation-form" onsubmit="submitReservation(event)">
<!-- Name -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Reservation Name *</label>
<input type="text" name="name" required
placeholder="e.g., Job A - March Deployment"
class="w-full px-3 py-2 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-blue-500">
</div>
<!-- Project (optional) -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Link to Project (optional)</label>
<select name="project_id"
class="w-full px-3 py-2 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-blue-500">
<option value="">-- No project --</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<!-- Date Range -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date *</label>
<input type="date" name="start_date" required
class="w-full px-3 py-2 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-blue-500">
</div>
<div class="mb-4">
<div class="flex items-center justify-between mb-1">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">End Date</label>
<label class="flex items-center gap-2 cursor-pointer text-sm">
<input type="checkbox" name="end_date_tbd" id="end_date_tbd"
onchange="toggleEndDateTBD()"
class="w-4 h-4 text-blue-600 focus:ring-blue-500 rounded border-gray-300 dark:border-gray-600">
<span class="text-gray-600 dark:text-gray-400">TBD / Ongoing</span>
</label>
</div>
<input type="date" name="end_date" id="end_date_input"
class="w-full px-3 py-2 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-blue-500">
</div>
<!-- Estimated End Date (shown when TBD is checked) -->
<div id="estimated-end-section" class="mb-4 hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Estimated End Date <span class="text-gray-400 font-normal">(for planning)</span>
</label>
<input type="date" name="estimated_end_date"
class="w-full px-3 py-2 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-blue-500">
<p class="text-xs text-gray-500 mt-1">Used for calendar visualization only. Can be updated later.</p>
</div>
<!-- Assignment Type -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assignment Type *</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="assignment_type" value="quantity" checked
onchange="toggleAssignmentType(this.value)"
class="text-blue-600 focus:ring-blue-500">
<span class="text-gray-700 dark:text-gray-300">Reserve quantity</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="assignment_type" value="specific"
onchange="toggleAssignmentType(this.value)"
class="text-blue-600 focus:ring-blue-500">
<span class="text-gray-700 dark:text-gray-300">Pick specific units</span>
</label>
</div>
</div>
<!-- Quantity (shown for quantity type) -->
<div id="quantity-section" class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Units Needed</label>
<input type="number" name="quantity_needed" min="1" value="1"
onchange="updateCalendarAvailability()"
oninput="updateCalendarAvailability()"
class="w-32 px-3 py-2 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-blue-500">
<p class="text-xs text-gray-500 mt-1">Calendar will highlight availability based on quantity</p>
</div>
<!-- Specific Units (shown for specific type) -->
<div id="specific-section" class="mb-4 hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Select Units</label>
<div id="available-units-list" class="max-h-48 overflow-y-auto border border-gray-300 dark:border-gray-600 rounded-lg p-2">
<p class="text-gray-500 text-sm">Select dates first to see available units</p>
</div>
</div>
<!-- Color -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label>
<div class="flex gap-2">
{% for color in ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'] %}
<label class="cursor-pointer">
<input type="radio" name="color" value="{{ color }}" {% if loop.first %}checked{% endif %} class="sr-only peer">
<span class="block w-8 h-8 rounded-full peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-900 dark:peer-checked:ring-white"
style="background-color: {{ color }}"></span>
</label>
{% endfor %}
</div>
</div>
<!-- Notes -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes</label>
<textarea name="notes" rows="2"
placeholder="Optional notes about this reservation"
class="w-full px-3 py-2 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-blue-500"></textarea>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3">
<button type="button" onclick="closeReservationModal()"
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg">
Cancel
</button>
<button type="submit"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
Create Reservation
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
const deviceType = '{{ device_type }}';
const startYear = {{ start_year }};
const startMonth = {{ start_month }};
// Load today's stats
document.addEventListener('DOMContentLoaded', function() {
loadTodayStats();
});
async function loadTodayStats() {
try {
const today = new Date().toISOString().split('T')[0];
const response = await fetch(`/api/fleet-calendar/day/${today}?device_type=${deviceType}`);
// Just update the stats from the day data response headers or a separate call
// For now, calculate from calendar data
const todayData = findTodayInCalendar();
if (todayData) {
document.getElementById('available-today').textContent = todayData.available;
document.getElementById('reserved-today').textContent = todayData.reserved;
document.getElementById('expiring-today').textContent = todayData.expiring_soon;
document.getElementById('expired-today').textContent = todayData.expired;
}
} catch (error) {
console.error('Error loading today stats:', error);
}
}
function findTodayInCalendar() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth() + 1;
const day = today.getDate();
const calendarData = {{ calendar_data | tojson }};
// months is now an array, find the matching month
const monthData = calendarData.months.find(m => m.year === year && m.month === month);
if (monthData && monthData.days[day]) {
return monthData.days[day];
}
return null;
}
function openDayPanel(dateStr) {
const panel = document.getElementById('day-panel');
const backdrop = document.getElementById('panel-backdrop');
const content = document.getElementById('day-panel-content');
// Load content via HTMX
htmx.ajax('GET', `/api/fleet-calendar/day/${dateStr}?device_type=${deviceType}`, {
target: '#day-panel-content',
swap: 'innerHTML'
});
panel.classList.add('open');
backdrop.classList.add('open');
}
function closeDayPanel() {
const panel = document.getElementById('day-panel');
const backdrop = document.getElementById('panel-backdrop');
panel.classList.remove('open');
backdrop.classList.remove('open');
}
let reservationModeActive = false;
function openReservationModal() {
document.getElementById('reservation-modal').classList.remove('hidden');
reservationModeActive = true;
// Show reservation legend, hide main legend
document.getElementById('main-legend').classList.add('md:hidden');
document.getElementById('main-legend').classList.remove('md:flex');
document.getElementById('reservation-legend').classList.remove('md:hidden');
document.getElementById('reservation-legend').classList.add('md:flex');
// Trigger availability update
updateCalendarAvailability();
}
function closeReservationModal() {
document.getElementById('reservation-modal').classList.add('hidden');
document.getElementById('reservation-form').reset();
reservationModeActive = false;
// Restore main legend
document.getElementById('main-legend').classList.remove('md:hidden');
document.getElementById('main-legend').classList.add('md:flex');
document.getElementById('reservation-legend').classList.add('md:hidden');
document.getElementById('reservation-legend').classList.remove('md:flex');
// Reset calendar colors
resetCalendarColors();
}
function resetCalendarColors() {
document.querySelectorAll('.day-cell:not(.empty)').forEach(cell => {
cell.classList.remove('reservation-available', 'reservation-partial', 'reservation-unavailable');
cell.classList.add('neutral');
});
}
function updateCalendarAvailability() {
if (!reservationModeActive) return;
const quantityInput = document.querySelector('input[name="quantity_needed"]');
const quantity = parseInt(quantityInput?.value) || 1;
// Update each day cell based on available units vs quantity needed
document.querySelectorAll('.day-cell:not(.empty)').forEach(cell => {
const available = parseInt(cell.dataset.available) || 0;
// Remove all status classes
cell.classList.remove('neutral', 'reservation-available', 'reservation-partial', 'reservation-unavailable');
if (available >= quantity) {
cell.classList.add('reservation-available');
} else if (available > 0) {
cell.classList.add('reservation-partial');
} else {
cell.classList.add('reservation-unavailable');
}
});
}
function toggleEndDateTBD() {
const checkbox = document.getElementById('end_date_tbd');
const endDateInput = document.getElementById('end_date_input');
const estimatedSection = document.getElementById('estimated-end-section');
if (checkbox.checked) {
endDateInput.disabled = true;
endDateInput.value = '';
endDateInput.classList.add('opacity-50', 'cursor-not-allowed');
estimatedSection.classList.remove('hidden');
} else {
endDateInput.disabled = false;
endDateInput.classList.remove('opacity-50', 'cursor-not-allowed');
estimatedSection.classList.add('hidden');
}
}
function toggleAssignmentType(type) {
const quantitySection = document.getElementById('quantity-section');
const specificSection = document.getElementById('specific-section');
if (type === 'quantity') {
quantitySection.classList.remove('hidden');
specificSection.classList.add('hidden');
} else {
quantitySection.classList.add('hidden');
specificSection.classList.remove('hidden');
// Load available units based on selected dates
loadAvailableUnits();
}
}
async function loadAvailableUnits() {
const startDate = document.querySelector('input[name="start_date"]').value;
const endDate = document.querySelector('input[name="end_date"]').value;
if (!startDate || !endDate) {
document.getElementById('available-units-list').innerHTML =
'<p class="text-gray-500 text-sm">Select dates first to see available units</p>';
return;
}
try {
const response = await fetch(
`/api/fleet-calendar/availability?start_date=${startDate}&end_date=${endDate}&device_type=${deviceType}`
);
const data = await response.json();
if (data.available_units.length === 0) {
document.getElementById('available-units-list').innerHTML =
'<p class="text-gray-500 text-sm">No units available for this period</p>';
return;
}
let html = '';
for (const unit of data.available_units) {
html += `
<label class="flex items-center gap-2 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer">
<input type="checkbox" name="unit_ids" value="${unit.id}"
class="text-blue-600 focus:ring-blue-500 rounded">
<span class="text-gray-900 dark:text-white font-medium">${unit.id}</span>
<span class="text-gray-500 text-sm">Cal: ${unit.last_calibrated || 'N/A'}</span>
${unit.calibration_status === 'expiring_soon' ?
'<span class="text-yellow-600 text-xs">Expiring soon</span>' : ''}
</label>
`;
}
document.getElementById('available-units-list').innerHTML = html;
} catch (error) {
console.error('Error loading available units:', error);
}
}
// Watch for date changes to reload available units
document.addEventListener('DOMContentLoaded', function() {
const startInput = document.querySelector('input[name="start_date"]');
const endInput = document.querySelector('input[name="end_date"]');
if (startInput && endInput) {
startInput.addEventListener('change', function() {
if (document.querySelector('input[name="assignment_type"]:checked').value === 'specific') {
loadAvailableUnits();
}
});
endInput.addEventListener('change', function() {
if (document.querySelector('input[name="assignment_type"]:checked').value === 'specific') {
loadAvailableUnits();
}
});
}
});
async function submitReservation(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const endDateTbd = formData.get('end_date_tbd') === 'on';
const data = {
name: formData.get('name'),
project_id: formData.get('project_id') || null,
start_date: formData.get('start_date'),
end_date: endDateTbd ? null : formData.get('end_date'),
end_date_tbd: endDateTbd,
estimated_end_date: endDateTbd ? (formData.get('estimated_end_date') || null) : null,
assignment_type: formData.get('assignment_type'),
device_type: deviceType,
color: formData.get('color'),
notes: formData.get('notes') || null
};
// Validate: need either end_date or TBD checked
if (!data.end_date && !data.end_date_tbd) {
alert('Please enter an end date or check "TBD / Ongoing"');
return;
}
if (data.assignment_type === 'quantity') {
data.quantity_needed = parseInt(formData.get('quantity_needed'));
} else {
data.unit_ids = formData.getAll('unit_ids');
}
try {
const response = await fetch('/api/fleet-calendar/reservations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
closeReservationModal();
// Reload the page to refresh calendar
window.location.reload();
} else {
alert('Error creating reservation: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
console.error('Error:', error);
alert('Error creating reservation');
}
}
// Close panels on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeDayPanel();
closeReservationModal();
}
});
</script>
{% endblock %}

102
templates/modems.html Normal file
View File

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

566
templates/pair_devices.html Normal file
View File

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

View File

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

View File

@@ -60,7 +60,9 @@
data-note="{{ unit.note if unit.note else '' }}">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
{% if unit.status == 'OK' %}
{% if not unit.deployed %}
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
{% elif unit.status == 'OK' %}
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
{% elif unit.status == 'Pending' %}
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
@@ -104,14 +106,19 @@
{% if unit.phone_number %}
<div>{{ unit.phone_number }}</div>
{% endif %}
{% if unit.hardware_model %}
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
{% if unit.deployed_with_unit_id %}
<div>
<span class="text-gray-500 dark:text-gray-500">Linked:</span>
<a href="/unit/{{ unit.deployed_with_unit_id }}" class="text-seismo-orange hover:underline font-medium">
{{ unit.deployed_with_unit_id }}
</a>
</div>
{% endif %}
{% else %}
{% if unit.next_calibration_due %}
{% if unit.last_calibrated %}
<div>
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
<span class="font-medium">{{ unit.next_calibration_due }}</span>
<span class="text-gray-500 dark:text-gray-500">Last Cal:</span>
<span class="font-medium">{{ unit.last_calibrated }}</span>
</div>
{% endif %}
{% if unit.deployed_with_modem_id %}
@@ -126,7 +133,7 @@
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400 last-seen-cell" data-iso="{{ unit.last_seen }}">{{ unit.last_seen }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm
@@ -203,7 +210,9 @@
<!-- Header: Status Dot + Unit ID + Status Badge -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
{% if unit.status == 'OK' %}
{% if not unit.deployed %}
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
{% elif unit.status == 'OK' %}
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
{% elif unit.status == 'Pending' %}
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
@@ -230,6 +239,10 @@
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
Modem
</span>
{% elif unit.device_type == 'slm' %}
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
SLM
</span>
{% else %}
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
Seismograph
@@ -345,6 +358,39 @@
</style>
<script>
(function() {
// User's configured timezone from settings (defaults to America/New_York)
const userTimezone = '{{ user_timezone | default("America/New_York") }}';
// Format ISO timestamp to human-readable format in user's timezone
function formatLastSeenLocal(isoString) {
if (!isoString || isoString === 'Never' || isoString === 'N/A') {
return isoString || 'Never';
}
try {
const date = new Date(isoString);
if (isNaN(date.getTime())) return isoString;
// Format in user's configured timezone
return date.toLocaleString('en-US', {
timeZone: userTimezone,
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true
});
} catch (e) {
return isoString;
}
}
// Format all last-seen cells on page load
document.querySelectorAll('.last-seen-cell').forEach(cell => {
const isoDate = cell.getAttribute('data-iso');
cell.textContent = formatLastSeenLocal(isoDate);
});
// Update timestamp
const timestampElement = document.getElementById('last-updated');
if (timestampElement) {
@@ -365,20 +411,23 @@
};
return acc;
}, {});
})();
// Sorting state
let currentSort = { column: null, direction: 'asc' };
// Sorting state (needs to persist across swaps)
if (typeof window.currentSort === 'undefined') {
window.currentSort = { column: null, direction: 'asc' };
}
function sortTable(column) {
const tbody = document.getElementById('roster-tbody');
const rows = Array.from(tbody.getElementsByTagName('tr'));
// Determine sort direction
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
if (window.currentSort.column === column) {
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
window.currentSort.column = column;
window.currentSort.direction = 'asc';
}
// Sort rows
@@ -406,8 +455,8 @@
bVal = bVal.toLowerCase();
}
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
@@ -443,10 +492,10 @@
});
// Set current indicator
if (currentSort.column) {
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
if (window.currentSort.column) {
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
if (indicator) {
indicator.className = `sort-indicator ${currentSort.direction}`;
indicator.className = `sort-indicator ${window.currentSort.direction}`;
}
}
}

View File

@@ -0,0 +1,40 @@
<!-- Available Units for Assignment -->
{% if units %}
<div class="space-y-1">
{% for unit in units %}
<label class="flex items-center gap-3 p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded cursor-pointer">
<input type="checkbox" name="unit_ids" value="{{ unit.id }}"
class="w-4 h-4 text-blue-600 focus:ring-blue-500 rounded border-gray-300 dark:border-gray-600">
<span class="font-medium text-gray-900 dark:text-white">{{ unit.id }}</span>
<span class="text-sm text-gray-500 dark:text-gray-400 flex-1">
{% if unit.last_calibrated %}
Cal: {{ unit.last_calibrated }}
{% else %}
No cal date
{% endif %}
</span>
{% if unit.calibration_status == 'expiring_soon' %}
<span class="text-xs px-2 py-0.5 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400 rounded-full">
Expiring soon
</span>
{% endif %}
{% if unit.deployed %}
<span class="text-xs px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded-full">
Deployed
</span>
{% else %}
<span class="text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded-full">
Benched
</span>
{% endif %}
</label>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500 dark:text-gray-400 text-sm py-4 text-center">
No units available for this date range.
{% if start_date and end_date %}
<br><span class="text-xs">All units are either reserved, have expired calibrations, or are retired.</span>
{% endif %}
</p>
{% endif %}

View File

@@ -0,0 +1,186 @@
<!-- Day Detail Panel Content -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ date_display }}</h2>
<button onclick="closeDayPanel()" 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"/>
</svg>
</button>
</div>
<!-- Summary Stats -->
<div class="grid grid-cols-2 gap-3 mb-6">
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-green-700 dark:text-green-300">{{ day_data.counts.available }}</p>
<p class="text-xs text-green-600 dark:text-green-400">Available</p>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-blue-700 dark:text-blue-300">{{ day_data.counts.reserved }}</p>
<p class="text-xs text-blue-600 dark:text-blue-400">Reserved</p>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-yellow-700 dark:text-yellow-300">{{ day_data.counts.expiring_soon }}</p>
<p class="text-xs text-yellow-600 dark:text-yellow-400">Expiring Soon</p>
</div>
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-3 text-center">
<p class="text-2xl font-bold text-red-700 dark:text-red-300">{{ day_data.counts.expired }}</p>
<p class="text-xs text-red-600 dark:text-red-400">Cal. Expired</p>
</div>
</div>
<!-- Calibration Expiring TODAY - Important alert -->
{% if day_data.cal_expiring_today %}
<div class="mb-6 p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 rounded-lg">
<h3 class="text-sm font-semibold text-red-700 dark:text-red-400 mb-2 flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Calibration Expires Today ({{ day_data.cal_expiring_today|length }})
</h3>
<div class="space-y-1">
{% for unit in day_data.cal_expiring_today %}
<div class="flex items-center justify-between p-2 bg-white dark:bg-gray-800 rounded text-sm">
<a href="/unit/{{ unit.id }}" class="font-medium text-red-600 dark:text-red-400 hover:underline">
{{ unit.id }}
</a>
<span class="text-red-500 text-xs">
Last cal: {{ unit.last_calibrated }}
</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Reservations on this date -->
{% if day_data.reservations %}
<div class="mb-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Reservations</h3>
{% for res in day_data.reservations %}
<div class="reservation-bar mb-2" style="background-color: {{ res.color }}20; border-left: 4px solid {{ res.color }};">
<div class="flex-1">
<p class="font-medium text-gray-900 dark:text-white">{{ res.name }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ res.start_date }} - {{ res.end_date }}
</p>
</div>
<div class="text-right">
<p class="font-semibold text-gray-900 dark:text-white">
{% if res.assignment_type == 'quantity' %}
{{ res.assigned_count }}/{{ res.quantity_needed or '?' }}
{% else %}
{{ res.assigned_count }} units
{% endif %}
</p>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- Available Units -->
{% if day_data.available_units %}
<div class="mb-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Available Units ({{ day_data.available_units|length }})
</h3>
<div class="max-h-48 overflow-y-auto space-y-1">
{% for unit in day_data.available_units %}
<div class="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700/50 rounded text-sm">
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.id }}
</a>
<span class="text-gray-500 dark:text-gray-400 text-xs">
{% if unit.last_calibrated %}
Cal: {{ unit.last_calibrated }}
{% else %}
No cal date
{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Reserved Units -->
{% if day_data.reserved_units %}
<div class="mb-6">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
Reserved Units ({{ day_data.reserved_units|length }})
</h3>
<div class="max-h-48 overflow-y-auto space-y-1">
{% for unit in day_data.reserved_units %}
<div class="flex items-center justify-between p-2 bg-blue-50 dark:bg-blue-900/20 rounded text-sm">
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
{{ unit.id }}
</a>
<span class="text-blue-600 dark:text-blue-400 text-xs">
{{ unit.reservation_name }}
</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Calibration Expired -->
{% if day_data.expired_units %}
<div class="mb-6">
<h3 class="text-sm font-semibold text-red-600 dark:text-red-400 mb-3">
Calibration Expired ({{ day_data.expired_units|length }})
</h3>
<div class="max-h-48 overflow-y-auto space-y-1">
{% for unit in day_data.expired_units %}
<div class="flex items-center justify-between p-2 bg-red-50 dark:bg-red-900/20 rounded text-sm">
<a href="/unit/{{ unit.id }}" class="font-medium text-red-600 dark:text-red-400 hover:underline">
{{ unit.id }}
</a>
<span class="text-red-500 text-xs">
Expired: {{ unit.expiry_date }}
</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Needs Calibration -->
{% if day_data.needs_calibration_units %}
<div class="mb-6">
<h3 class="text-sm font-semibold text-gray-600 dark:text-gray-400 mb-3">
Needs Calibration Date ({{ day_data.needs_calibration_units|length }})
</h3>
<div class="max-h-32 overflow-y-auto space-y-1">
{% for unit in day_data.needs_calibration_units %}
<div class="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700/50 rounded text-sm">
<a href="/unit/{{ unit.id }}" class="font-medium text-gray-600 dark:text-gray-400 hover:underline">
{{ unit.id }}
</a>
<span class="text-gray-400 text-xs">No cal date set</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Expiring Soon (informational) -->
{% if day_data.expiring_soon_units %}
<div class="mb-6">
<h3 class="text-sm font-semibold text-yellow-600 dark:text-yellow-400 mb-3">
Calibration Expiring Soon ({{ day_data.expiring_soon_units|length }})
</h3>
<div class="max-h-32 overflow-y-auto space-y-1">
{% for unit in day_data.expiring_soon_units %}
<div class="flex items-center justify-between p-2 bg-yellow-50 dark:bg-yellow-900/20 rounded text-sm">
<a href="/unit/{{ unit.id }}" class="font-medium text-yellow-700 dark:text-yellow-400 hover:underline">
{{ unit.id }}
</a>
<span class="text-yellow-600 text-xs">
Expires: {{ unit.expiry_date }}
</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,103 @@
<!-- Reservations List -->
{% if reservations %}
<div class="space-y-3">
{% for item in reservations %}
{% set res = item.reservation %}
<div class="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
style="border-left: 4px solid {{ res.color }};">
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
{% if item.has_conflicts %}
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-full"
title="{{ item.conflict_count }} unit(s) have calibration expiring during this job">
{{ item.conflict_count }} conflict{{ 's' if item.conflict_count != 1 else '' }}
</span>
{% endif %}
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ res.start_date.strftime('%b %d, %Y') }} -
{% if res.end_date %}
{{ res.end_date.strftime('%b %d, %Y') }}
{% elif res.end_date_tbd %}
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
{% if res.estimated_end_date %}
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
{% endif %}
{% else %}
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
{% endif %}
</p>
{% if res.notes %}
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">{{ res.notes }}</p>
{% endif %}
</div>
<div class="text-right ml-4">
<p class="text-lg font-bold text-gray-900 dark:text-white">
{% if res.assignment_type == 'quantity' %}
{{ item.assigned_count }}/{{ res.quantity_needed or '?' }}
{% else %}
{{ item.assigned_count }}
{% endif %}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ 'units needed' if res.assignment_type == 'quantity' else 'units assigned' }}
</p>
</div>
<div class="ml-4 flex items-center gap-2">
<button onclick="editReservation('{{ res.id }}')"
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="Edit reservation">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}')"
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="Delete reservation">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
{% endfor %}
</div>
<script>
async function deleteReservation(id, name) {
if (!confirm(`Delete reservation "${name}"?\n\nThis will remove all unit assignments.`)) {
return;
}
try {
const response = await fetch(`/api/fleet-calendar/reservations/${id}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
} else {
const data = await response.json();
alert('Error: ' + (data.detail || 'Failed to delete'));
}
} catch (error) {
console.error('Error:', error);
alert('Error deleting reservation');
}
}
function editReservation(id) {
// For now, just show alert - can implement edit modal later
alert('Edit functionality coming soon. For now, delete and recreate the reservation.');
}
</script>
{% else %}
<div class="text-center py-8">
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<p class="text-gray-500 dark:text-gray-400">No reservations for {{ year }}</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Reservation" to plan unit assignments</p>
</div>
{% endif %}

View File

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

View File

@@ -0,0 +1,64 @@
<!-- 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" or device.device_type == "sound_level_meter" %}
<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 | replace("_", " ") }}</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>
<div class="flex items-center gap-2">
<button onclick="openModemPairDeviceModal()"
class="px-3 py-1.5 text-sm bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
Edit Pairing
</button>
<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>
</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>
<button onclick="openModemPairDeviceModal()"
class="px-4 py-2 text-sm bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
Pair Device
</button>
</div>
{% endif %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,188 @@
<!-- File List for NRL - Simple flat list of files with session info -->
{% if files %}
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{% for file_data in files %}
{% set file = file_data.file %}
{% set session = file_data.session %}
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group">
<!-- File Icon -->
{% if file.file_type == 'audio' %}
<svg class="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
</svg>
{% elif file.file_type == 'archive' %}
<svg class="w-6 h-6 text-purple-500" 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>
{% elif file.file_type == 'log' %}
<svg class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
{% elif file.file_type == 'image' %}
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
{% elif file.file_type == 'measurement' %}
<svg class="w-6 h-6 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
{% else %}
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
{% endif %}
<!-- File Info -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<div class="font-medium text-gray-900 dark:text-white truncate">
{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
<!-- File Type Badge -->
<span class="px-1.5 py-0.5 rounded font-medium
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
{% elif file.file_type == 'measurement' %}bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ file.file_type or 'unknown' }}
</span>
{# Leq vs Lp badge for RND files #}
{% if file.file_path and '_Leq_' in file.file_path %}
<span class="px-1.5 py-0.5 rounded font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
Leq (15-min avg)
</span>
{% elif file.file_path and '_Lp' in file.file_path and file.file_path.endswith('.rnd') %}
<span class="px-1.5 py-0.5 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300">
Lp (instant)
</span>
{% endif %}
<!-- File Size -->
<span class="mx-1"></span>
{% if file.file_size_bytes %}
{% if file.file_size_bytes < 1024 %}
{{ file.file_size_bytes }} B
{% elif file.file_size_bytes < 1048576 %}
{{ "%.1f"|format(file.file_size_bytes / 1024) }} KB
{% elif file.file_size_bytes < 1073741824 %}
{{ "%.1f"|format(file.file_size_bytes / 1048576) }} MB
{% else %}
{{ "%.2f"|format(file.file_size_bytes / 1073741824) }} GB
{% endif %}
{% else %}
Unknown size
{% endif %}
<!-- Session Info -->
{% if session %}
<span class="mx-1"></span>
<span class="text-gray-400">Session: {{ session.started_at|local_datetime if session.started_at else 'Unknown' }}</span>
{% endif %}
<!-- Download Time -->
{% if file.downloaded_at %}
<span class="mx-1"></span>
{{ file.downloaded_at|local_datetime }}
{% endif %}
<!-- Checksum Indicator -->
{% if file.checksum %}
<span class="mx-1" title="SHA256: {{ file.checksum[:16] }}...">
<svg class="w-3 h-3 inline text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 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"></path>
</svg>
</span>
{% endif %}
</div>
</div>
<!-- Action Buttons -->
<div class="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-2">
{% if file.file_type == 'measurement' or (file.file_path and file.file_path.endswith('.rnd')) %}
<a href="/api/projects/{{ project_id }}/files/{{ file.id }}/view-rnd"
onclick="event.stopPropagation();"
class="px-3 py-1 text-xs bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
View
</a>
{% endif %}
{# Only show Report button for Leq files #}
{% if file.file_path and '_Leq_' in file.file_path %}
<a href="/api/projects/{{ project_id }}/files/{{ file.id }}/generate-report"
onclick="event.stopPropagation();"
class="px-3 py-1 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center"
title="Generate Excel Report">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Report
</a>
{% endif %}
<button onclick="event.stopPropagation(); downloadFile('{{ file.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Download
</button>
<button onclick="event.stopPropagation(); confirmDeleteFile('{{ file.id }}', '{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}')"
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
title="Delete this file">
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- Empty State -->
<div class="px-6 py-12 text-center">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500">
Files will appear here once they are downloaded from the sound level meter
</p>
</div>
{% endif %}
<script>
function downloadFile(fileId) {
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
}
function confirmDeleteFile(fileId, fileName) {
if (confirm(`Are you sure you want to delete "${fileName}"?\n\nThis action cannot be undone.`)) {
deleteFile(fileId);
}
}
async function deleteFile(fileId) {
try {
const response = await fetch(`/api/projects/{{ project_id }}/files/${fileId}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
} else {
const data = await response.json();
alert(`Failed to delete file: ${data.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error deleting file: ${error.message}`);
}
}
</script>

View File

@@ -18,6 +18,10 @@
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
Weekly
</span>
{% elif item.schedule.schedule_type == 'one_off' %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
One-Off
</span>
{% else %}
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
24/7 Cycle
@@ -69,6 +73,20 @@
(with download)
{% endif %}
</div>
{% elif item.schedule.schedule_type == 'one_off' %}
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-amber-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
{% if item.schedule.start_datetime %}
{{ item.schedule.start_datetime|local_datetime }} {{ timezone_abbr() }}
&rarr;
{{ item.schedule.end_datetime|local_datetime }} {{ timezone_abbr() }}
{% endif %}
{% if item.schedule.include_download %}
(with download)
{% endif %}
</div>
{% endif %}
{% if item.schedule.next_occurrence %}

View File

@@ -114,7 +114,7 @@
</div>
</div>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3" id="cycle-timing">
At <span id="preview-time">00:00</span>: Stop → Download (1 min) → Start (2 min)
At <span id="preview-time">00:00</span>: Stop → Download → Start (~70 sec total)
</p>
</div>
</div>
@@ -132,12 +132,12 @@ document.getElementById('include_download').addEventListener('change', function(
downloadStep.style.display = 'flex';
downloadArrow.style.display = 'block';
startStepNum.textContent = '3';
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Download (1 min) → Start (2 min)`;
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Download → Start (~70 sec total)`;
} else {
downloadStep.style.display = 'none';
downloadArrow.style.display = 'none';
startStepNum.textContent = '2';
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Start (1 min)`;
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Start (~40 sec total)`;
}
});

View File

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

View File

@@ -0,0 +1,204 @@
<!-- One-Off Recording Schedule Editor -->
<!-- Used for single start/stop recordings with a specific date+time range -->
<div id="schedule-oneoff-editor" class="space-y-4">
<div class="mb-4">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
<p class="text-xs text-gray-500 dark:text-gray-400">
Schedule a single recording session with a specific start and end time.
Duration can be between 15 minutes and 24 hours.
</p>
</div>
<!-- Info box -->
<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
<div class="flex gap-3">
<svg class="w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
</svg>
<div class="text-sm text-amber-700 dark:text-amber-300">
<p class="font-medium mb-1">How it works:</p>
<ol class="list-decimal list-inside space-y-1 text-xs">
<li>At the start time, the measurement will <strong>start</strong></li>
<li>At the end time, the measurement will <strong>stop</strong></li>
<li>If enabled, data will be <strong>downloaded</strong> via FTP after stop</li>
<li>The schedule will be <strong>auto-disabled</strong> after completion</li>
</ol>
</div>
</div>
</div>
<!-- Start date/time -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Start Date & Time
</label>
<input type="datetime-local"
name="start_datetime"
id="oneoff_start_datetime"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
Must be in the future
</p>
</div>
<!-- End date/time -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
End Date & Time
</label>
<input type="datetime-local"
name="end_datetime"
id="oneoff_end_datetime"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">
</div>
<!-- Duration preview -->
<div id="oneoff-duration-preview" class="hidden bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<div class="flex items-center gap-3">
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Duration: </span>
<span id="oneoff-duration-text" class="text-sm text-gray-600 dark:text-gray-400"></span>
</div>
</div>
<p id="oneoff-duration-error" class="hidden text-xs text-red-500 mt-2"></p>
</div>
<!-- Download option -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox"
name="include_download"
id="include_download_oneoff"
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
checked>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Download data after recording ends
</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, measurement data will be downloaded via FTP after the recording stops.
</p>
</div>
</label>
</div>
<!-- Auto-increment index option -->
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
<label class="flex items-start gap-3 cursor-pointer">
<input type="checkbox"
name="auto_increment_index"
id="auto_increment_index_oneoff"
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
checked>
<div>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
Auto-increment store index before start
</span>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, the store/index number is incremented before starting.
This prevents "overwrite existing data?" prompts on the device.
</p>
</div>
</label>
</div>
</div>
<script>
// Set min datetime to now (prevent past selections)
function setMinDatetime() {
const now = new Date();
now.setMinutes(Math.ceil(now.getMinutes() / 15) * 15);
now.setSeconds(0);
now.setMilliseconds(0);
const minStr = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}T${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
document.getElementById('oneoff_start_datetime').min = minStr;
document.getElementById('oneoff_end_datetime').min = minStr;
}
setMinDatetime();
// Update duration preview when dates change
function updateDurationPreview() {
const startInput = document.getElementById('oneoff_start_datetime');
const endInput = document.getElementById('oneoff_end_datetime');
const preview = document.getElementById('oneoff-duration-preview');
const durationText = document.getElementById('oneoff-duration-text');
const errorText = document.getElementById('oneoff-duration-error');
if (!startInput.value || !endInput.value) {
preview.classList.add('hidden');
return;
}
const start = new Date(startInput.value);
const end = new Date(endInput.value);
const diffMs = end - start;
const diffMinutes = diffMs / (1000 * 60);
preview.classList.remove('hidden');
errorText.classList.add('hidden');
if (diffMinutes <= 0) {
durationText.textContent = 'Invalid';
errorText.textContent = 'End time must be after start time.';
errorText.classList.remove('hidden');
return;
}
if (diffMinutes < 15) {
const mins = Math.round(diffMinutes);
durationText.textContent = `${mins} minute${mins !== 1 ? 's' : ''}`;
errorText.textContent = 'Minimum duration is 15 minutes.';
errorText.classList.remove('hidden');
return;
}
if (diffMinutes > 1440) {
const hours = Math.round(diffMinutes / 60 * 10) / 10;
durationText.textContent = `${hours} hours`;
errorText.textContent = 'Maximum duration is 24 hours.';
errorText.classList.remove('hidden');
return;
}
// Valid duration
if (diffMinutes < 60) {
durationText.textContent = `${Math.round(diffMinutes)} minutes`;
} else {
const hours = Math.floor(diffMinutes / 60);
const mins = Math.round(diffMinutes % 60);
durationText.textContent = mins > 0
? `${hours} hour${hours !== 1 ? 's' : ''} ${mins} min`
: `${hours} hour${hours !== 1 ? 's' : ''}`;
}
}
document.getElementById('oneoff_start_datetime').addEventListener('change', function() {
// Auto-set end to start + 1 hour if end is empty
const endInput = document.getElementById('oneoff_end_datetime');
if (!endInput.value) {
const start = new Date(this.value);
start.setHours(start.getHours() + 1);
endInput.value = start.toISOString().slice(0, 16);
}
// Update min of end input
endInput.min = this.value;
updateDurationPreview();
});
document.getElementById('oneoff_end_datetime').addEventListener('change', updateDurationPreview);
// Function to get one-off data as object (called by parent form)
function getOneOffData() {
return {
start_datetime: document.getElementById('oneoff_start_datetime').value,
end_datetime: document.getElementById('oneoff_end_datetime').value,
include_download: document.getElementById('include_download_oneoff').checked,
auto_increment_index: document.getElementById('auto_increment_index_oneoff').checked,
};
}
</script>

View File

@@ -58,7 +58,9 @@
data-note="{{ unit.note if unit.note else '' }}">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
{% if unit.status == 'OK' %}
{% if not unit.deployed %}
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
{% elif unit.status == 'OK' %}
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
{% elif unit.status == 'Pending' %}
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
@@ -83,6 +85,10 @@
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
Modem
</span>
{% elif unit.device_type == 'slm' %}
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
SLM
</span>
{% else %}
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
Seismograph
@@ -102,10 +108,10 @@
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
{% endif %}
{% else %}
{% if unit.next_calibration_due %}
{% if unit.last_calibrated %}
<div>
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
<span class="font-medium">{{ unit.next_calibration_due }}</span>
<span class="text-gray-500 dark:text-gray-500">Last Cal:</span>
<span class="font-medium">{{ unit.last_calibrated }}</span>
</div>
{% endif %}
{% if unit.deployed_with_modem_id %}
@@ -195,7 +201,9 @@
<!-- Header: Status Dot + Unit ID + Status Badge -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
{% if unit.status == 'OK' %}
{% if not unit.deployed %}
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
{% elif unit.status == 'OK' %}
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
{% elif unit.status == 'Pending' %}
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
@@ -222,6 +230,10 @@
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
Modem
</span>
{% elif unit.device_type == 'slm' %}
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
SLM
</span>
{% else %}
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
Seismograph
@@ -337,6 +349,7 @@
</style>
<script>
(function() {
// Update timestamp
const timestampElement = document.getElementById('last-updated');
if (timestampElement) {
@@ -357,20 +370,23 @@
};
return acc;
}, {});
})();
// Sorting state
let currentSort = { column: null, direction: 'asc' };
// Sorting state (needs to persist across swaps)
if (typeof window.currentSort === 'undefined') {
window.currentSort = { column: null, direction: 'asc' };
}
function sortTable(column) {
const tbody = document.getElementById('roster-tbody');
const rows = Array.from(tbody.getElementsByTagName('tr'));
// Determine sort direction
if (currentSort.column === column) {
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
if (window.currentSort.column === column) {
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
} else {
currentSort.column = column;
currentSort.direction = 'asc';
window.currentSort.column = column;
window.currentSort.direction = 'asc';
}
// Sort rows
@@ -398,8 +414,8 @@
bVal = bVal.toLowerCase();
}
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
return 0;
});
@@ -435,10 +451,10 @@
});
// Set current indicator
if (currentSort.column) {
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
if (window.currentSort.column) {
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
if (indicator) {
indicator.className = `sort-indicator ${currentSort.direction}`;
indicator.className = `sort-indicator ${window.currentSort.direction}`;
}
}
}

View File

@@ -1,13 +1,92 @@
{% if units %}
{% if units is defined %}
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Modem</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Notes</th>
{% set next_order = 'desc' if (sort == 'id' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=id&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Unit ID
{% if sort == 'id' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'status' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=status&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Status
{% if sort == 'status' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'modem' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=modem&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Modem
{% if sort == 'modem' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'location' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=location&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Location
{% if sort == 'location' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'last_calibrated' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=last_calibrated&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Last Calibrated
{% if sort == 'last_calibrated' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
{% set next_order = 'desc' if (sort == 'notes' and order == 'asc') else 'asc' %}
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-slate-600"
hx-get="/api/seismo-dashboard/units?sort=notes&order={{ next_order }}&search={{ search }}&status={{ status }}&modem={{ modem }}"
hx-target="#seismo-units-list"
hx-swap="innerHTML">
<span class="flex items-center gap-1">
Notes
{% if sort == 'notes' %}
<svg class="w-4 h-4 {% if order == 'desc' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"></path>
</svg>
{% endif %}
</span>
</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
@@ -54,6 +133,27 @@
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
{% if unit.last_calibrated %}
<span class="inline-flex items-center gap-1.5">
{% if unit.next_calibration_due and today %}
{% set days_until = (unit.next_calibration_due - today).days %}
{% if days_until < 0 %}
<span class="w-2 h-2 rounded-full bg-red-500" title="Calibration expired {{ -days_until }} days ago"></span>
{% elif days_until <= 14 %}
<span class="w-2 h-2 rounded-full bg-yellow-500" title="Calibration expires in {{ days_until }} days"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-green-500" title="Calibration valid ({{ days_until }} days remaining)"></span>
{% endif %}
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-400" title="No expiry date set"></span>
{% endif %}
{{ unit.last_calibrated.strftime('%Y-%m-%d') }}
</span>
{% else %}
<span class="text-gray-400 dark:text-gray-600"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
{% if unit.note %}
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
@@ -72,9 +172,12 @@
</table>
</div>
{% if search %}
{% if search or status or modem %}
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
Found {{ units|length }} seismograph(s) matching "{{ search }}"
Found {{ units|length }} seismograph(s)
{% if search %} matching "{{ search }}"{% endif %}
{% if status %} ({{ status }}){% endif %}
{% if modem %} ({{ 'with modem' if modem == 'with' else 'without modem' }}){% endif %}
</div>
{% endif %}

View File

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

View File

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

View File

@@ -162,9 +162,9 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-6">
<div>
<h2 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">
Individual scheduled start/stop/download actions
<h2 id="schedules-title" class="text-xl font-semibold text-gray-900 dark:text-white">Upcoming Actions</h2>
<p id="schedules-subtitle" class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Scheduled start/stop/download actions
</p>
</div>
<select id="schedules-filter" onchange="filterScheduledActions()"
@@ -311,6 +311,7 @@
</div>
</div>
<div id="settings-success" class="hidden text-sm text-green-600 dark:text-green-400"></div>
<div id="settings-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
@@ -455,7 +456,7 @@
<!-- Schedule Type Selection -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Schedule Type</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<label class="relative cursor-pointer">
<input type="radio" name="schedule_type" value="weekly_calendar" class="peer sr-only" checked onchange="toggleScheduleType('weekly_calendar')">
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
@@ -484,6 +485,20 @@
</p>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" name="schedule_type" value="one_off" class="peer sr-only" onchange="toggleScheduleType('one_off')">
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
<div class="flex items-center gap-3 mb-2">
<svg class="w-6 h-6 text-gray-500 peer-checked:text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Single recording session with a specific start and end date/time (15 min - 24 hrs).
</p>
</div>
</label>
</div>
</div>
@@ -497,6 +512,11 @@
{% include "partials/projects/schedule_interval.html" %}
</div>
<!-- One-Off Editor -->
<div id="schedule-oneoff-wrapper" class="hidden">
{% include "partials/projects/schedule_oneoff.html" %}
</div>
<!-- Timezone -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Timezone</label>
@@ -606,6 +626,9 @@ function switchTab(tabName) {
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
button.classList.add('border-seismo-orange', 'text-seismo-orange');
}
// Persist active tab in URL hash so refresh stays on this tab
history.replaceState(null, '', `#${tabName}`);
}
// Load project details
@@ -677,12 +700,20 @@ document.getElementById('project-settings-form').addEventListener('submit', asyn
throw new Error('Failed to update project');
}
// Reload page to show updated data
window.location.reload();
// Refresh header and dashboard without full page reload
refreshProjectDashboard();
// Show success feedback
const successEl = document.getElementById('settings-success');
successEl.textContent = 'Settings saved.';
successEl.classList.remove('hidden');
document.getElementById('settings-error').classList.add('hidden');
setTimeout(() => successEl.classList.add('hidden'), 3000);
} catch (err) {
const errorEl = document.getElementById('settings-error');
errorEl.textContent = err.message || 'Failed to update project.';
errorEl.classList.remove('hidden');
document.getElementById('settings-success').classList.add('hidden');
}
});
@@ -963,6 +994,21 @@ function filterScheduledActions() {
? `/api/projects/${projectId}/schedules`
: `/api/projects/${projectId}/schedules?status=${filter}`;
// Update section title based on filter
const titleEl = document.getElementById('schedules-title');
const subtitleEl = document.getElementById('schedules-subtitle');
const titles = {
'pending': { title: 'Upcoming Actions', subtitle: 'Scheduled start/stop/download actions' },
'completed': { title: 'Completed Actions', subtitle: 'Successfully executed actions' },
'failed': { title: 'Failed Actions', subtitle: 'Actions that encountered errors' },
'all': { title: 'All Actions', subtitle: 'Complete action history' }
};
const config = titles[filter] || titles['all'];
titleEl.textContent = config.title;
subtitleEl.textContent = config.subtitle;
htmx.ajax('GET', url, {
target: '#project-schedules',
swap: 'innerHTML'
@@ -1081,13 +1127,18 @@ function getSelectedLocationIds() {
function toggleScheduleType(type) {
const weeklyEditor = document.getElementById('schedule-weekly-wrapper');
const intervalEditor = document.getElementById('schedule-interval-wrapper');
const oneoffEditor = document.getElementById('schedule-oneoff-wrapper');
weeklyEditor.classList.add('hidden');
intervalEditor.classList.add('hidden');
oneoffEditor.classList.add('hidden');
if (type === 'weekly_calendar') {
weeklyEditor.classList.remove('hidden');
intervalEditor.classList.add('hidden');
} else {
weeklyEditor.classList.add('hidden');
} else if (type === 'simple_interval') {
intervalEditor.classList.remove('hidden');
} else if (type === 'one_off') {
oneoffEditor.classList.remove('hidden');
}
}
@@ -1151,7 +1202,7 @@ document.getElementById('schedule-form').addEventListener('submit', async functi
} else {
payload.include_download = true;
}
} else {
} else if (scheduleType === 'simple_interval') {
// Get interval data
if (typeof getIntervalData === 'function') {
const intervalData = getIntervalData();
@@ -1163,6 +1214,45 @@ document.getElementById('schedule-form').addEventListener('submit', async functi
showScheduleError('Interval editor not loaded properly.');
return;
}
} else if (scheduleType === 'one_off') {
// Get one-off data
if (typeof getOneOffData === 'function') {
const oneOffData = getOneOffData();
if (!oneOffData.start_datetime || !oneOffData.end_datetime) {
showScheduleError('Please select both start and end date/time.');
return;
}
const start = new Date(oneOffData.start_datetime);
const end = new Date(oneOffData.end_datetime);
const diffMinutes = (end - start) / (1000 * 60);
if (diffMinutes <= 0) {
showScheduleError('End time must be after start time.');
return;
}
if (diffMinutes < 15) {
showScheduleError('Duration must be at least 15 minutes.');
return;
}
if (diffMinutes > 1440) {
showScheduleError('Duration cannot exceed 24 hours.');
return;
}
if (start <= new Date()) {
showScheduleError('Start time must be in the future.');
return;
}
payload.start_datetime = oneOffData.start_datetime;
payload.end_datetime = oneOffData.end_datetime;
payload.include_download = oneOffData.include_download;
payload.auto_increment_index = oneOffData.auto_increment_index;
} else {
showScheduleError('One-off editor not loaded properly.');
return;
}
}
try {
@@ -1236,9 +1326,16 @@ document.getElementById('schedule-modal')?.addEventListener('click', function(e)
}
});
// Load project details on page load
// Load project details on page load and restore active tab from URL hash
document.addEventListener('DOMContentLoaded', function() {
loadProjectDetails();
// Restore tab from URL hash (e.g. #schedules, #settings)
const hash = window.location.hash.replace('#', '');
const validTabs = ['overview', 'locations', 'units', 'schedules', 'sessions', 'data', 'settings'];
if (hash && validTabs.includes(hash)) {
switchTab(hash);
}
});
</script>
{% endblock %}

View File

@@ -122,7 +122,7 @@
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="seismograph">Seismograph</option>
<option value="modem">Modem</option>
<option value="sound_level_meter">Sound Level Meter</option>
<option value="slm">Sound Level Meter</option>
</select>
</div>
<div>
@@ -131,10 +131,8 @@
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
<input type="text" name="project_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
placeholder="PROJ-001">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
{% include "partials/project_picker.html" with context %}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
@@ -147,20 +145,16 @@
<div id="seismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
<input type="date" name="last_calibrated"
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date of Last Calibration</label>
<input type="date" name="last_calibrated" id="addLastCalibrated"
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">Next calibration due date will be calculated automatically</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
<input type="date" name="next_calibration_due"
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">Typically 1 year after last calibration</p>
</div>
<input type="hidden" name="next_calibration_due" id="addNextCalibrationDue">
<div id="modemPairingField" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<input type="text" name="deployed_with_modem_id" placeholder="Modem ID"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
{% set picker_id = "-add-seismo" %}
{% include "partials/modem_picker.html" with context %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
</div>
</div>
@@ -180,8 +174,28 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hardware Model</label>
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
<select name="hardware_model"
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="">Select model...</option>
<option value="RV50">RV50</option>
<option value="RV55">RV55</option>
<option value="RX55">RX55</option>
</select>
</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>
@@ -193,21 +207,6 @@
<input type="text" name="slm_model" placeholder="NL-43"
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">Host/IP Address</label>
<input type="text" name="slm_host" placeholder="192.168.1.100"
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">TCP Port</label>
<input type="number" name="slm_tcp_port" placeholder="2255"
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">FTP Port</label>
<input type="number" name="slm_ftp_port" placeholder="21"
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">Serial Number</label>
<input type="text" name="slm_serial_number" placeholder="SN123456"
@@ -231,6 +230,12 @@
<option value="I">I (Impulse)</option>
</select>
</div>
<div id="slmModemPairingField" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
{% set picker_id = "-add-slm" %}
{% include "partials/modem_picker.html" with context %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">SLM connects via modem's IP address</p>
</div>
</div>
<div class="flex items-center gap-4">
@@ -288,7 +293,7 @@
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="seismograph">Seismograph</option>
<option value="modem">Modem</option>
<option value="sound_level_meter">Sound Level Meter</option>
<option value="slm">Sound Level Meter</option>
</select>
</div>
<div>
@@ -297,9 +302,9 @@
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
<input type="text" name="project_id" id="editProjectId"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
{% set picker_id = "-edit" %}
{% include "partials/project_picker.html" with context %}
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
@@ -316,19 +321,16 @@
<div id="editSeismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Date of Last Calibration</label>
<input type="date" name="last_calibrated" id="editLastCalibrated"
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">Next calibration due date will be calculated automatically</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
<input type="date" name="next_calibration_due" id="editNextCalibrationDue"
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>
<input type="hidden" name="next_calibration_due" id="editNextCalibrationDue">
<div id="editModemPairingField" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
<input type="text" name="deployed_with_modem_id" id="editDeployedWithModemId" placeholder="Modem ID"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
{% set picker_id = "-edit-seismo" %}
{% include "partials/modem_picker.html" with context %}
</div>
</div>
@@ -347,8 +349,28 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hardware Model</label>
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
<select name="hardware_model" id="editHardwareModel"
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="">Select model...</option>
<option value="RV50">RV50</option>
<option value="RV55">RV55</option>
<option value="RX55">RX55</option>
</select>
</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>
@@ -360,21 +382,6 @@
<input type="text" name="slm_model" id="editSlmModel" placeholder="NL-43"
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">Host/IP Address</label>
<input type="text" name="slm_host" id="editSlmHost" placeholder="192.168.1.100"
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">TCP Port</label>
<input type="number" name="slm_tcp_port" id="editSlmTcpPort" placeholder="2255"
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">FTP Port</label>
<input type="number" name="slm_ftp_port" id="editSlmFtpPort" placeholder="21"
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">Serial Number</label>
<input type="text" name="slm_serial_number" id="editSlmSerialNumber" placeholder="SN123456"
@@ -400,6 +407,12 @@
<option value="I">I (Impulse)</option>
</select>
</div>
<div id="editSlmModemPairingField" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
{% set picker_id = "-edit-slm" %}
{% include "partials/modem_picker.html" with context %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">SLM connects via modem's IP address</p>
</div>
</div>
<div class="flex items-center gap-4">
@@ -419,6 +432,55 @@
<textarea name="note" id="editNote" rows="3"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
</div>
<!-- Cascade to Paired Device Section -->
<div id="editCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
</svg>
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
Also update paired device: <span id="editPairedDeviceName" class="text-seismo-orange"></span>
</span>
</div>
<input type="hidden" name="cascade_to_unit_id" id="editCascadeToUnitId" value="">
<div class="grid grid-cols-2 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_deployed" id="editCascadeDeployed" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed status</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_retired" id="editCascadeRetired" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Retired status</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_project" id="editCascadeProject" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_location" id="editCascadeLocation" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_coordinates" id="editCascadeCoordinates" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="cascade_note" id="editCascadeNote" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Notes</span>
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Check the fields you want to sync to the paired device
</p>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
Save Changes
@@ -529,6 +591,58 @@
</div>
<script>
// Calibration interval in days (default 365, will be loaded from preferences)
let calibrationIntervalDays = 365;
// Load calibration interval from preferences
async function loadCalibrationInterval() {
try {
const response = await fetch('/api/settings/preferences');
if (response.ok) {
const prefs = await response.json();
calibrationIntervalDays = prefs.calibration_interval_days || 365;
}
} catch (e) {
console.error('Failed to load calibration interval:', e);
}
}
// Calculate next calibration due date from last calibrated date
function calculateNextCalibrationDue(lastCalibratedStr) {
if (!lastCalibratedStr) return '';
const lastCalibrated = new Date(lastCalibratedStr);
const nextDue = new Date(lastCalibrated);
nextDue.setDate(nextDue.getDate() + calibrationIntervalDays);
return nextDue.toISOString().split('T')[0];
}
// Setup auto-calculation for calibration fields
function setupCalibrationAutoCalc() {
// Add form
const addLastCal = document.getElementById('addLastCalibrated');
const addNextCal = document.getElementById('addNextCalibrationDue');
if (addLastCal && addNextCal) {
addLastCal.addEventListener('change', function() {
addNextCal.value = calculateNextCalibrationDue(this.value);
});
}
// Edit form
const editLastCal = document.getElementById('editLastCalibrated');
const editNextCal = document.getElementById('editNextCalibrationDue');
if (editLastCal && editNextCal) {
editLastCal.addEventListener('change', function() {
editNextCal.value = calculateNextCalibrationDue(this.value);
});
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
loadCalibrationInterval();
setupCalibrationAutoCalc();
});
// Add Unit Modal
function openAddUnitModal() {
document.getElementById('addUnitModal').classList.remove('hidden');
@@ -564,7 +678,7 @@
setFieldsDisabled(seismoFields, true);
setFieldsDisabled(modemFields, false);
setFieldsDisabled(slmFields, true);
} else if (deviceType === 'sound_level_meter') {
} else if (deviceType === 'slm') {
seismoFields.classList.add('hidden');
modemFields.classList.add('hidden');
slmFields.classList.remove('hidden');
@@ -572,6 +686,7 @@
setFieldsDisabled(seismoFields, true);
setFieldsDisabled(modemFields, true);
setFieldsDisabled(slmFields, false);
toggleModemPairing(); // Check if modem pairing should be shown
}
}
@@ -584,17 +699,26 @@
});
}
// Toggle modem pairing field visibility (only for deployed seismographs)
// Toggle modem pairing field visibility (only for deployed seismographs and SLMs)
function toggleModemPairing() {
const deviceType = document.getElementById('deviceTypeSelect').value;
const deployedCheckbox = document.getElementById('deployedCheckbox');
const modemPairingField = document.getElementById('modemPairingField');
const slmModemPairingField = document.getElementById('slmModemPairingField');
// Seismograph modem pairing
if (deviceType === 'seismograph' && deployedCheckbox.checked) {
modemPairingField.classList.remove('hidden');
} else {
modemPairingField.classList.add('hidden');
}
// SLM modem pairing
if (deviceType === 'slm' && deployedCheckbox.checked) {
slmModemPairingField.classList.remove('hidden');
} else {
slmModemPairingField.classList.add('hidden');
}
}
// Add unknown unit to roster
@@ -648,13 +772,11 @@
// Handle Add Unit form submission
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
// Only handle the form's own POST request, not child HTMX requests (e.g. project picker search)
if (event.detail.elt !== this) return;
if (event.detail.successful) {
closeAddUnitModal();
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
// Show success message
alert('Unit added successfully!');
} else {
@@ -692,6 +814,33 @@
function closeEditUnitModal() {
document.getElementById('editUnitModal').classList.add('hidden');
document.getElementById('editUnitForm').reset();
// Also clear the project picker
const projectPickerValue = document.getElementById('project-picker-value-edit');
const projectPickerSearch = document.getElementById('project-picker-search-edit');
const projectPickerClear = document.getElementById('project-picker-clear-edit');
if (projectPickerValue) projectPickerValue.value = '';
if (projectPickerSearch) projectPickerSearch.value = '';
if (projectPickerClear) projectPickerClear.classList.add('hidden');
}
// Fetch project display name for edit modal
async function fetchProjectDisplayForEdit(projectId) {
if (!projectId) return '';
try {
const response = await fetch(`/api/projects/${projectId}`);
if (response.ok) {
const project = await response.json();
const parts = [
project.project_number,
project.client_name,
project.name
].filter(Boolean);
return parts.join(' - ') || projectId;
}
} catch (e) {
console.error('Failed to fetch project:', e);
}
return projectId;
}
// Toggle device-specific fields in edit modal
@@ -716,13 +865,14 @@
setFieldsDisabled(seismoFields, true);
setFieldsDisabled(modemFields, false);
setFieldsDisabled(slmFields, true);
} else if (deviceType === 'sound_level_meter') {
} else if (deviceType === 'slm') {
seismoFields.classList.add('hidden');
modemFields.classList.add('hidden');
slmFields.classList.remove('hidden');
setFieldsDisabled(seismoFields, true);
setFieldsDisabled(modemFields, true);
setFieldsDisabled(slmFields, false);
toggleEditModemPairing(); // Check if modem pairing should be shown
}
}
@@ -731,12 +881,21 @@
const deviceType = document.getElementById('editDeviceTypeSelect').value;
const deployedCheckbox = document.getElementById('editDeployedCheckbox');
const modemPairingField = document.getElementById('editModemPairingField');
const slmModemPairingField = document.getElementById('editSlmModemPairingField');
// Seismograph modem pairing
if (deviceType === 'seismograph' && deployedCheckbox.checked) {
modemPairingField.classList.remove('hidden');
} else {
modemPairingField.classList.add('hidden');
}
// SLM modem pairing
if (deviceType === 'slm' && deployedCheckbox.checked) {
slmModemPairingField.classList.remove('hidden');
} else {
slmModemPairingField.classList.add('hidden');
}
}
// Edit Unit - Fetch data and populate form
@@ -753,7 +912,23 @@
document.getElementById('editUnitId').value = unit.id;
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
document.getElementById('editUnitType').value = unit.unit_type;
document.getElementById('editProjectId').value = unit.project_id;
// Populate project picker (uses -edit suffix)
const projectPickerValue = document.getElementById('project-picker-value-edit');
const projectPickerSearch = document.getElementById('project-picker-search-edit');
const projectPickerClear = document.getElementById('project-picker-clear-edit');
if (projectPickerValue) projectPickerValue.value = unit.project_id || '';
if (unit.project_id) {
// Fetch project display name
fetchProjectDisplayForEdit(unit.project_id).then(displayText => {
if (projectPickerSearch) projectPickerSearch.value = displayText;
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
});
} else {
if (projectPickerSearch) projectPickerSearch.value = '';
if (projectPickerClear) projectPickerClear.classList.add('hidden');
}
document.getElementById('editAddress').value = unit.address;
document.getElementById('editCoordinates').value = unit.coordinates;
document.getElementById('editNote').value = unit.note;
@@ -763,24 +938,129 @@
document.getElementById('editRetiredCheckbox').checked = unit.retired;
// Seismograph fields
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
document.getElementById('editDeployedWithModemId').value = unit.deployed_with_modem_id;
document.getElementById('editLastCalibrated').value = unit.last_calibrated || '';
// Calculate next calibration due from last calibrated
document.getElementById('editNextCalibrationDue').value = unit.last_calibrated
? calculateNextCalibrationDue(unit.last_calibrated)
: '';
// Populate modem picker for seismograph (uses -edit-seismo suffix)
const modemPickerValue = document.getElementById('modem-picker-value-edit-seismo');
const modemPickerSearch = document.getElementById('modem-picker-search-edit-seismo');
const modemPickerClear = document.getElementById('modem-picker-clear-edit-seismo');
if (modemPickerValue) modemPickerValue.value = unit.deployed_with_modem_id || '';
if (unit.deployed_with_modem_id) {
// Fetch modem display (ID + IP + note)
fetch(`/api/roster/${unit.deployed_with_modem_id}`)
.then(r => r.ok ? r.json() : null)
.then(modem => {
if (modem && modemPickerSearch) {
let display = modem.id;
if (modem.ip_address) display += ` - ${modem.ip_address}`;
if (modem.note) display += ` - ${modem.note}`;
modemPickerSearch.value = display;
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
}
})
.catch(() => {
if (modemPickerSearch) modemPickerSearch.value = unit.deployed_with_modem_id;
});
} else {
if (modemPickerSearch) modemPickerSearch.value = '';
if (modemPickerClear) modemPickerClear.classList.add('hidden');
}
// Modem fields
document.getElementById('editIpAddress').value = unit.ip_address;
document.getElementById('editPhoneNumber').value = unit.phone_number;
document.getElementById('editHardwareModel').value = unit.hardware_model;
document.getElementById('editHardwareModel').value = unit.hardware_model || '';
document.getElementById('editDeploymentType').value = unit.deployment_type || '';
// Populate unit picker for modem (uses -edit-modem suffix)
const unitPickerValue = document.getElementById('unit-picker-value-edit-modem');
const unitPickerSearch = document.getElementById('unit-picker-search-edit-modem');
const unitPickerClear = document.getElementById('unit-picker-clear-edit-modem');
if (unitPickerValue) unitPickerValue.value = unit.deployed_with_unit_id || '';
if (unit.deployed_with_unit_id) {
// Fetch unit display (ID + note)
fetch(`/api/roster/${unit.deployed_with_unit_id}`)
.then(r => r.ok ? r.json() : null)
.then(linkedUnit => {
if (linkedUnit && unitPickerSearch) {
const display = linkedUnit.note ? `${linkedUnit.id} - ${linkedUnit.note}` : linkedUnit.id;
unitPickerSearch.value = display;
if (unitPickerClear) unitPickerClear.classList.remove('hidden');
}
})
.catch(() => {
if (unitPickerSearch) unitPickerSearch.value = unit.deployed_with_unit_id;
});
} else {
if (unitPickerSearch) unitPickerSearch.value = '';
if (unitPickerClear) unitPickerClear.classList.add('hidden');
}
// SLM fields
document.getElementById('editSlmModel').value = unit.slm_model || '';
document.getElementById('editSlmHost').value = unit.slm_host || '';
document.getElementById('editSlmTcpPort').value = unit.slm_tcp_port || '';
document.getElementById('editSlmFtpPort').value = unit.slm_ftp_port || '';
document.getElementById('editSlmSerialNumber').value = unit.slm_serial_number || '';
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
// Populate SLM modem picker (uses -edit-slm suffix)
const slmModemPickerValue = document.getElementById('modem-picker-value-edit-slm');
const slmModemPickerSearch = document.getElementById('modem-picker-search-edit-slm');
const slmModemPickerClear = document.getElementById('modem-picker-clear-edit-slm');
if (slmModemPickerValue) slmModemPickerValue.value = unit.deployed_with_modem_id || '';
if (unit.deployed_with_modem_id && unit.device_type === 'slm') {
// 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 && slmModemPickerSearch) {
let display = modem.id;
if (modem.ip_address) display += ` - ${modem.ip_address}`;
if (modem.note) display += ` - ${modem.note}`;
slmModemPickerSearch.value = display;
if (slmModemPickerClear) slmModemPickerClear.classList.remove('hidden');
}
})
.catch(() => {
if (slmModemPickerSearch) slmModemPickerSearch.value = unit.deployed_with_modem_id;
});
} else {
if (slmModemPickerSearch) slmModemPickerSearch.value = '';
if (slmModemPickerClear) slmModemPickerClear.classList.add('hidden');
}
// Cascade section - show if there's a paired device
const cascadeSection = document.getElementById('editCascadeSection');
const cascadeToUnitId = document.getElementById('editCascadeToUnitId');
const pairedDeviceName = document.getElementById('editPairedDeviceName');
// Determine paired device based on device type
let pairedUnitId = null;
if (unit.device_type === 'modem' && unit.deployed_with_unit_id) {
pairedUnitId = unit.deployed_with_unit_id;
} else if ((unit.device_type === 'seismograph' || unit.device_type === 'sound_level_meter') && unit.deployed_with_modem_id) {
pairedUnitId = unit.deployed_with_modem_id;
}
if (pairedUnitId) {
cascadeToUnitId.value = pairedUnitId;
pairedDeviceName.textContent = pairedUnitId;
cascadeSection.classList.remove('hidden');
// Reset checkboxes
document.getElementById('editCascadeDeployed').checked = false;
document.getElementById('editCascadeRetired').checked = false;
document.getElementById('editCascadeProject').checked = false;
document.getElementById('editCascadeLocation').checked = false;
document.getElementById('editCascadeCoordinates').checked = false;
document.getElementById('editCascadeNote').checked = false;
} else {
cascadeToUnitId.value = '';
cascadeSection.classList.add('hidden');
}
// Store unit ID for form submission
document.getElementById('editUnitForm').dataset.unitId = unitId;
@@ -814,11 +1094,7 @@
if (response.ok) {
closeEditUnitModal();
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
alert('Unit updated successfully!');
} else {
const result = await response.json();
@@ -846,11 +1122,7 @@
});
if (response.ok) {
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
} else {
const result = await response.json();
@@ -878,11 +1150,7 @@
});
if (response.ok) {
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
alert(`Unit ${unitId} moved to ignore list`);
} else {
const result = await response.json();
@@ -905,11 +1173,7 @@
});
if (response.ok) {
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
alert(`Unit ${unitId} deleted successfully`);
} else {
const result = await response.json();
@@ -948,11 +1212,7 @@
`;
resultDiv.classList.remove('hidden');
// Trigger roster refresh for current active tab
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
refreshDeviceList();
// Close modal after 2 seconds
setTimeout(() => closeImportModal(), 2000);
@@ -968,35 +1228,35 @@
}
});
// Handle roster tab switching with auto-refresh
let currentRosterEndpoint = '/partials/roster-deployed'; // Default to deployed tab
// Refresh device list (applies current client-side filters after load)
function refreshDeviceList() {
htmx.ajax('GET', '/partials/devices-all', {
target: '#device-content',
swap: 'innerHTML'
}).then(() => {
// Re-apply filters after content loads
setTimeout(filterDevices, 100);
});
}
// Check if any modal is currently open
function isAnyModalOpen() {
const modalIds = ['addUnitModal', 'editUnitModal', 'renameUnitModal', 'importModal', 'quickCreateProjectModal'];
return modalIds.some(id => {
const modal = document.getElementById(id);
return modal && !modal.classList.contains('hidden');
});
}
document.addEventListener('DOMContentLoaded', function() {
const tabButtons = document.querySelectorAll('.roster-tab-button');
tabButtons.forEach(button => {
button.addEventListener('click', function() {
// Remove active-roster-tab class from all buttons
tabButtons.forEach(btn => btn.classList.remove('active-roster-tab'));
// Add active-roster-tab class to clicked button
this.classList.add('active-roster-tab');
// Update current endpoint for auto-refresh
currentRosterEndpoint = this.getAttribute('data-endpoint');
});
});
// Auto-refresh the current active tab every 10 seconds
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
setInterval(() => {
const rosterContent = document.getElementById('roster-content');
if (rosterContent) {
// Use HTMX to trigger a refresh of the current endpoint
htmx.ajax('GET', currentRosterEndpoint, {
target: '#roster-content',
swap: 'innerHTML'
});
const deviceContent = document.getElementById('device-content');
if (deviceContent && !isAnyModalOpen()) {
// Only auto-refresh if no modal is open
refreshDeviceList();
}
}, 10000); // 10 seconds
}, 30000);
});
// Un-ignore Unit (remove from ignored list)
@@ -1345,4 +1605,7 @@
}
</style>
<!-- Include Project Create Modal for inline project creation -->
{% include "partials/project_create_modal.html" %}
{% endblock %}

View File

@@ -27,25 +27,50 @@
<!-- Seismograph List -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
<div class="mb-4 flex flex-col gap-4">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
<!-- Search Box -->
<div class="relative">
<input
type="text"
id="seismo-search"
placeholder="Search seismographs..."
class="w-full sm:w-64 px-4 py-2 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-blue-500 focus:border-transparent"
hx-get="/api/seismo-dashboard/units"
hx-trigger="keyup changed delay:300ms"
hx-target="#seismo-units-list"
hx-include="[name='search']"
name="search"
/>
<svg class="absolute right-3 top-2.5 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>
<!-- Search Box -->
<div class="relative">
<input
type="text"
id="seismo-search"
placeholder="Search seismographs..."
class="w-full sm:w-64 px-4 py-2 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-blue-500 focus:border-transparent"
name="search"
/>
<svg class="absolute right-3 top-2.5 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>
</div>
<!-- Filters -->
<div class="flex flex-wrap items-center gap-3">
<span class="text-sm text-gray-600 dark:text-gray-400">Filter:</span>
<!-- Status Filter -->
<select id="seismo-status-filter" name="status"
class="px-3 py-1.5 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-blue-500 focus:border-transparent">
<option value="">All Status</option>
<option value="deployed">Deployed</option>
<option value="benched">Benched</option>
</select>
<!-- Modem Filter -->
<select id="seismo-modem-filter" name="modem"
class="px-3 py-1.5 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-blue-500 focus:border-transparent">
<option value="">All Modems</option>
<option value="with">With Modem</option>
<option value="without">Without Modem</option>
</select>
<!-- Clear Filters Button -->
<button id="seismo-clear-filters" type="button"
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
Clear Filters
</button>
</div>
</div>
@@ -59,17 +84,53 @@
</div>
<script>
// Clear search input on escape key
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('seismo-search');
if (searchInput) {
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
this.value = '';
htmx.trigger(this, 'keyup');
}
});
const statusFilter = document.getElementById('seismo-status-filter');
const modemFilter = document.getElementById('seismo-modem-filter');
const clearBtn = document.getElementById('seismo-clear-filters');
const unitsList = document.getElementById('seismo-units-list');
// Build URL with current filter values
function buildUrl() {
const params = new URLSearchParams();
if (searchInput.value) params.set('search', searchInput.value);
if (statusFilter.value) params.set('status', statusFilter.value);
if (modemFilter.value) params.set('modem', modemFilter.value);
return '/api/seismo-dashboard/units' + (params.toString() ? '?' + params.toString() : '');
}
// Trigger HTMX refresh
function refreshList() {
htmx.ajax('GET', buildUrl(), {target: '#seismo-units-list', swap: 'innerHTML'});
}
// Search input with debounce
let debounceTimer;
searchInput.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(refreshList, 300);
});
// Clear search on escape
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
this.value = '';
refreshList();
}
});
// Filter changes
statusFilter.addEventListener('change', refreshList);
modemFilter.addEventListener('change', refreshList);
// Clear all filters
clearBtn.addEventListener('click', function() {
searchInput.value = '';
statusFilter.value = '';
modemFilter.value = '';
refreshList();
});
});
</script>

File diff suppressed because it is too large Load Diff