Compare commits
10 Commits
63d9c59873
...
v0.6.1
| Author | SHA1 | Date | |
|---|---|---|---|
| b15d434fce | |||
|
|
70ef43de11 | ||
| 7b4e12c127 | |||
|
|
24473c9ca3 | ||
|
|
caabfd0c42 | ||
|
|
ebe60d2b7d | ||
|
|
842e9d6f61 | ||
| 742a98a8ed | |||
| 3b29c4d645 | |||
| b47e69e609 |
12
CHANGELOG.md
12
CHANGELOG.md
@@ -5,6 +5,18 @@ All notable changes to Terra-View will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.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
|
## [0.6.0] - 2026-02-06
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Terra-View v0.6.0
|
# 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.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -496,6 +496,11 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## 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
|
### v0.6.0 — 2026-02-06
|
||||||
- **Calendar & Reservation Mode**: Fleet calendar view with device deployment scheduling and reservation system
|
- **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
|
- **Device Pairing Interface**: New `/pair-devices` page with two-column layout for linking recorders with modems, fuzzy-search, and visual pairing workflow
|
||||||
@@ -579,7 +584,9 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.6.0** — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)
|
**Current: 0.6.1** — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
|
||||||
|
|
||||||
|
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)
|
Previous: 0.5.1 — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.6.0"
|
VERSION = "0.6.1"
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Seismo Fleet Manager",
|
title="Seismo Fleet Manager",
|
||||||
description="Backend API for managing seismograph fleet status",
|
description="Backend API for managing seismograph fleet status",
|
||||||
|
|||||||
73
backend/migrate_add_oneoff_schedule_fields.py
Normal file
73
backend/migrate_add_oneoff_schedule_fields.py
Normal 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)
|
||||||
@@ -321,9 +321,10 @@ class RecurringSchedule(Base):
|
|||||||
"""
|
"""
|
||||||
Recurring schedule definitions for automated sound monitoring.
|
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)
|
- "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
|
- "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"
|
__tablename__ = "recurring_schedules"
|
||||||
|
|
||||||
@@ -333,7 +334,7 @@ class RecurringSchedule(Base):
|
|||||||
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (optional, can use assignment)
|
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"
|
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"
|
device_type = Column(String, nullable=False) # "slm" | "seismograph"
|
||||||
|
|
||||||
# Weekly Calendar fields (schedule_type = "weekly_calendar")
|
# Weekly Calendar fields (schedule_type = "weekly_calendar")
|
||||||
@@ -349,7 +350,11 @@ class RecurringSchedule(Base):
|
|||||||
cycle_time = Column(String, nullable=True) # "00:00" - time to run stop/download/restart
|
cycle_time = Column(String, nullable=True) # "00:00" - time to run stop/download/restart
|
||||||
include_download = Column(Boolean, default=True) # Download data before 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
|
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
|
# When True: prevents "overwrite data?" prompts by using a new index each time
|
||||||
|
|
||||||
|
|||||||
@@ -186,6 +186,41 @@ async def create_recurring_schedule(
|
|||||||
created_schedules = []
|
created_schedules = []
|
||||||
base_name = data.get("name", "Unnamed Schedule")
|
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
|
# Create a schedule for each location
|
||||||
for location in locations:
|
for location in locations:
|
||||||
# Determine device type from location
|
# Determine device type from location
|
||||||
@@ -207,6 +242,8 @@ async def create_recurring_schedule(
|
|||||||
include_download=data.get("include_download", True),
|
include_download=data.get("include_download", True),
|
||||||
auto_increment_index=data.get("auto_increment_index", True),
|
auto_increment_index=data.get("auto_increment_index", True),
|
||||||
timezone=data.get("timezone", "America/New_York"),
|
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
|
# Generate actions immediately so they appear right away
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ async def add_roster_unit(
|
|||||||
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Auto-fill location data from modem if pairing and fields are empty
|
# Auto-fill data from modem if pairing and fields are empty
|
||||||
if deployed_with_modem_id:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
@@ -252,6 +252,24 @@ async def add_roster_unit(
|
|||||||
unit.coordinates = modem.coordinates
|
unit.coordinates = modem.coordinates
|
||||||
if not unit.project_id and modem.project_id:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = 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.add(unit)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -564,7 +582,7 @@ async def edit_roster_unit(
|
|||||||
unit.next_calibration_due = next_cal_date
|
unit.next_calibration_due = next_cal_date
|
||||||
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||||
|
|
||||||
# Auto-fill location data from modem if pairing and fields are empty
|
# Auto-fill data from modem if pairing and fields are empty
|
||||||
if deployed_with_modem_id:
|
if deployed_with_modem_id:
|
||||||
modem = db.query(RosterUnit).filter(
|
modem = db.query(RosterUnit).filter(
|
||||||
RosterUnit.id == deployed_with_modem_id,
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
@@ -580,6 +598,8 @@ async def edit_roster_unit(
|
|||||||
unit.coordinates = modem.coordinates
|
unit.coordinates = modem.coordinates
|
||||||
if not unit.project_id and modem.project_id:
|
if not unit.project_id and modem.project_id:
|
||||||
unit.project_id = modem.project_id
|
unit.project_id = modem.project_id
|
||||||
|
if not unit.note and modem.note:
|
||||||
|
unit.note = modem.note
|
||||||
|
|
||||||
# Modem-specific fields
|
# Modem-specific fields
|
||||||
unit.ip_address = ip_address if ip_address else None
|
unit.ip_address = ip_address if ip_address else None
|
||||||
@@ -598,6 +618,51 @@ async def edit_roster_unit(
|
|||||||
unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None
|
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
|
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
|
# Record history entries for changed fields
|
||||||
if old_note != note:
|
if old_note != note:
|
||||||
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
|
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ class RecurringScheduleService:
|
|||||||
include_download: bool = True,
|
include_download: bool = True,
|
||||||
auto_increment_index: bool = True,
|
auto_increment_index: bool = True,
|
||||||
timezone: str = "America/New_York",
|
timezone: str = "America/New_York",
|
||||||
|
start_datetime: datetime = None,
|
||||||
|
end_datetime: datetime = None,
|
||||||
) -> RecurringSchedule:
|
) -> RecurringSchedule:
|
||||||
"""
|
"""
|
||||||
Create a new recurring schedule.
|
Create a new recurring schedule.
|
||||||
@@ -57,7 +59,7 @@ class RecurringScheduleService:
|
|||||||
project_id: Project ID
|
project_id: Project ID
|
||||||
location_id: Monitoring location ID
|
location_id: Monitoring location ID
|
||||||
name: Schedule name
|
name: Schedule name
|
||||||
schedule_type: "weekly_calendar" or "simple_interval"
|
schedule_type: "weekly_calendar", "simple_interval", or "one_off"
|
||||||
device_type: "slm" or "seismograph"
|
device_type: "slm" or "seismograph"
|
||||||
unit_id: Specific unit (optional, can use assignment)
|
unit_id: Specific unit (optional, can use assignment)
|
||||||
weekly_pattern: Dict of day patterns for weekly_calendar
|
weekly_pattern: Dict of day patterns for weekly_calendar
|
||||||
@@ -66,6 +68,8 @@ class RecurringScheduleService:
|
|||||||
include_download: Whether to download data on cycle
|
include_download: Whether to download data on cycle
|
||||||
auto_increment_index: Whether to auto-increment store index before start
|
auto_increment_index: Whether to auto-increment store index before start
|
||||||
timezone: Timezone for schedule times
|
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:
|
Returns:
|
||||||
Created RecurringSchedule
|
Created RecurringSchedule
|
||||||
@@ -85,6 +89,8 @@ class RecurringScheduleService:
|
|||||||
auto_increment_index=auto_increment_index,
|
auto_increment_index=auto_increment_index,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
timezone=timezone,
|
timezone=timezone,
|
||||||
|
start_datetime=start_datetime,
|
||||||
|
end_datetime=end_datetime,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Calculate next occurrence
|
# Calculate next occurrence
|
||||||
@@ -213,6 +219,8 @@ class RecurringScheduleService:
|
|||||||
actions = self._generate_weekly_calendar_actions(schedule, horizon_days)
|
actions = self._generate_weekly_calendar_actions(schedule, horizon_days)
|
||||||
elif schedule.schedule_type == "simple_interval":
|
elif schedule.schedule_type == "simple_interval":
|
||||||
actions = self._generate_interval_actions(schedule, horizon_days)
|
actions = self._generate_interval_actions(schedule, horizon_days)
|
||||||
|
elif schedule.schedule_type == "one_off":
|
||||||
|
actions = self._generate_one_off_actions(schedule)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Unknown schedule type: {schedule.schedule_type}")
|
logger.warning(f"Unknown schedule type: {schedule.schedule_type}")
|
||||||
return []
|
return []
|
||||||
@@ -431,6 +439,77 @@ class RecurringScheduleService:
|
|||||||
|
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
|
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,
|
||||||
|
"schedule_type": "one_off",
|
||||||
|
"auto_increment_index": schedule.auto_increment_index,
|
||||||
|
})
|
||||||
|
|
||||||
|
start_action = ScheduledAction(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=schedule.project_id,
|
||||||
|
location_id=schedule.location_id,
|
||||||
|
unit_id=unit_id,
|
||||||
|
action_type="start",
|
||||||
|
device_type=schedule.device_type,
|
||||||
|
scheduled_time=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]:
|
def _calculate_next_occurrence(self, schedule: RecurringSchedule) -> Optional[datetime]:
|
||||||
"""Calculate when the next action should occur."""
|
"""Calculate when the next action should occur."""
|
||||||
if not schedule.enabled:
|
if not schedule.enabled:
|
||||||
@@ -471,6 +550,13 @@ class RecurringScheduleService:
|
|||||||
if cycle_utc > now_utc:
|
if cycle_utc > now_utc:
|
||||||
return cycle_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
|
return None
|
||||||
|
|
||||||
def _resolve_unit_id(self, schedule: RecurringSchedule) -> Optional[str]:
|
def _resolve_unit_id(self, schedule: RecurringSchedule) -> Optional[str]:
|
||||||
|
|||||||
@@ -628,6 +628,15 @@ class SchedulerService:
|
|||||||
|
|
||||||
for schedule in schedules:
|
for schedule in schedules:
|
||||||
try:
|
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)
|
actions = service.generate_actions_for_schedule(schedule, horizon_days=7)
|
||||||
total_generated += len(actions)
|
total_generated += len(actions)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -112,6 +112,69 @@ class SLMMClient:
|
|||||||
error_msg = str(e) if str(e) else type(e).__name__
|
error_msg = str(e) if str(e) else type(e).__name__
|
||||||
raise SLMMClientError(f"Unexpected error: {error_msg}")
|
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
|
# Unit Management
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -537,10 +600,13 @@ class SLMMClient:
|
|||||||
remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav")
|
remote_path: Path on device to download (e.g., "/NL43_DATA/measurement.wav")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Binary file content (as response)
|
Dict with local_path, filename, size_bytes
|
||||||
"""
|
"""
|
||||||
data = {"remote_path": remote_path}
|
return await self._download_request(
|
||||||
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
|
f"/{unit_id}/ftp/download",
|
||||||
|
{"remote_path": remote_path},
|
||||||
|
unit_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def download_folder(
|
async def download_folder(
|
||||||
self,
|
self,
|
||||||
@@ -557,10 +623,13 @@ class SLMMClient:
|
|||||||
remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000")
|
remote_path: Folder path on device to download (e.g., "/NL43_DATA/Auto_0000")
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with local_path, folder_name, file_count, zip_size_bytes
|
Dict with local_path, folder_name, size_bytes
|
||||||
"""
|
"""
|
||||||
data = {"remote_path": remote_path}
|
return await self._download_request(
|
||||||
return await self._request("POST", f"/{unit_id}/ftp/download-folder", data=data)
|
f"/{unit_id}/ftp/download-folder",
|
||||||
|
{"remote_path": remote_path},
|
||||||
|
unit_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def download_current_measurement(
|
async def download_current_measurement(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -398,10 +398,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Offline Database -->
|
<!-- Offline Database -->
|
||||||
<script src="/static/offline-db.js?v=0.5.1"></script>
|
<script src="/static/offline-db.js?v=0.6.1"></script>
|
||||||
|
|
||||||
<!-- Mobile JavaScript -->
|
<!-- Mobile JavaScript -->
|
||||||
<script src="/static/mobile.js?v=0.5.1"></script>
|
<script src="/static/mobile.js?v=0.6.1"></script>
|
||||||
|
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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">
|
<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
|
Weekly
|
||||||
</span>
|
</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 %}
|
{% 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">
|
<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
|
24/7 Cycle
|
||||||
@@ -69,6 +73,20 @@
|
|||||||
(with download)
|
(with download)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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() }}
|
||||||
|
→
|
||||||
|
{{ item.schedule.end_datetime|local_datetime }} {{ timezone_abbr() }}
|
||||||
|
{% endif %}
|
||||||
|
{% if item.schedule.include_download %}
|
||||||
|
(with download)
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if item.schedule.next_occurrence %}
|
{% if item.schedule.next_occurrence %}
|
||||||
|
|||||||
204
templates/partials/projects/schedule_oneoff.html
Normal file
204
templates/partials/projects/schedule_oneoff.html
Normal 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>
|
||||||
@@ -456,7 +456,7 @@
|
|||||||
<!-- Schedule Type Selection -->
|
<!-- Schedule Type Selection -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Schedule Type</label>
|
<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">
|
<label class="relative cursor-pointer">
|
||||||
<input type="radio" name="schedule_type" value="weekly_calendar" class="peer sr-only" checked onchange="toggleScheduleType('weekly_calendar')">
|
<input type="radio" name="schedule_type" value="weekly_calendar" class="peer sr-only" checked onchange="toggleScheduleType('weekly_calendar')">
|
||||||
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
|
<div class="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">
|
||||||
@@ -485,6 +485,20 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -498,6 +512,11 @@
|
|||||||
{% include "partials/projects/schedule_interval.html" %}
|
{% include "partials/projects/schedule_interval.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- One-Off Editor -->
|
||||||
|
<div id="schedule-oneoff-wrapper" class="hidden">
|
||||||
|
{% include "partials/projects/schedule_oneoff.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Timezone -->
|
<!-- Timezone -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Timezone</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Timezone</label>
|
||||||
@@ -1108,13 +1127,18 @@ function getSelectedLocationIds() {
|
|||||||
function toggleScheduleType(type) {
|
function toggleScheduleType(type) {
|
||||||
const weeklyEditor = document.getElementById('schedule-weekly-wrapper');
|
const weeklyEditor = document.getElementById('schedule-weekly-wrapper');
|
||||||
const intervalEditor = document.getElementById('schedule-interval-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') {
|
if (type === 'weekly_calendar') {
|
||||||
weeklyEditor.classList.remove('hidden');
|
weeklyEditor.classList.remove('hidden');
|
||||||
intervalEditor.classList.add('hidden');
|
} else if (type === 'simple_interval') {
|
||||||
} else {
|
|
||||||
weeklyEditor.classList.add('hidden');
|
|
||||||
intervalEditor.classList.remove('hidden');
|
intervalEditor.classList.remove('hidden');
|
||||||
|
} else if (type === 'one_off') {
|
||||||
|
oneoffEditor.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,7 +1202,7 @@ document.getElementById('schedule-form').addEventListener('submit', async functi
|
|||||||
} else {
|
} else {
|
||||||
payload.include_download = true;
|
payload.include_download = true;
|
||||||
}
|
}
|
||||||
} else {
|
} else if (scheduleType === 'simple_interval') {
|
||||||
// Get interval data
|
// Get interval data
|
||||||
if (typeof getIntervalData === 'function') {
|
if (typeof getIntervalData === 'function') {
|
||||||
const intervalData = getIntervalData();
|
const intervalData = getIntervalData();
|
||||||
@@ -1190,6 +1214,45 @@ document.getElementById('schedule-form').addEventListener('submit', async functi
|
|||||||
showScheduleError('Interval editor not loaded properly.');
|
showScheduleError('Interval editor not loaded properly.');
|
||||||
return;
|
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 {
|
try {
|
||||||
|
|||||||
@@ -772,6 +772,8 @@
|
|||||||
|
|
||||||
// Handle Add Unit form submission
|
// Handle Add Unit form submission
|
||||||
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
|
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) {
|
if (event.detail.successful) {
|
||||||
closeAddUnitModal();
|
closeAddUnitModal();
|
||||||
refreshDeviceList();
|
refreshDeviceList();
|
||||||
|
|||||||
Reference in New Issue
Block a user