2 Commits

Author SHA1 Message Date
serversdwn
8431784708 feat: Refactor template handling, improve scheduler functions, and add timezone utilities
- Moved Jinja2 template setup to a shared configuration file (templates_config.py) for consistent usage across routers.
- Introduced timezone utilities in a new module (timezone.py) to handle UTC to local time conversions and formatting.
- Updated all relevant routers to use the new shared template configuration and timezone filters.
- Enhanced templates to utilize local time formatting for various datetime fields, improving user experience with timezone awareness.
2026-01-23 06:05:39 +00:00
serversdwn
c771a86675 Feat/Fix: Scheduler actions more strictly defined. Commands now working. 2026-01-22 20:25:19 +00:00
28 changed files with 771 additions and 172 deletions

View File

@@ -58,8 +58,8 @@ app.add_middleware(
# Mount static files # Mount static files
app.mount("/static", StaticFiles(directory="backend/static"), name="static") app.mount("/static", StaticFiles(directory="backend/static"), name="static")
# Setup Jinja2 templates # Use shared templates configuration with timezone filters
templates = Jinja2Templates(directory="templates") from backend.templates_config import templates
# Add custom context processor to inject environment variable into all templates # Add custom context processor to inject environment variable into all templates
@app.middleware("http") @app.middleware("http")

View File

@@ -363,13 +363,14 @@ class Alert(Base):
- device_offline: Device became unreachable - device_offline: Device became unreachable
- device_online: Device came back online - device_online: Device came back online
- schedule_failed: Scheduled action failed to execute - schedule_failed: Scheduled action failed to execute
- schedule_completed: Scheduled action completed successfully
""" """
__tablename__ = "alerts" __tablename__ = "alerts"
id = Column(String, primary_key=True, index=True) # UUID id = Column(String, primary_key=True, index=True) # UUID
# Alert classification # Alert classification
alert_type = Column(String, nullable=False) # "device_offline" | "device_online" | "schedule_failed" alert_type = Column(String, nullable=False) # "device_offline" | "device_online" | "schedule_failed" | "schedule_completed"
severity = Column(String, default="warning") # "info" | "warning" | "critical" severity = Column(String, default="warning") # "info" | "warning" | "critical"
# Related entities (nullable - may not all apply) # Related entities (nullable - may not all apply)

View File

@@ -5,7 +5,6 @@ API endpoints for managing in-app alerts.
""" """
from fastapi import APIRouter, Request, Depends, HTTPException, Query from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional from typing import Optional
@@ -14,9 +13,9 @@ from datetime import datetime, timedelta
from backend.database import get_db from backend.database import get_db
from backend.models import Alert, RosterUnit from backend.models import Alert, RosterUnit
from backend.services.alert_service import get_alert_service from backend.services.alert_service import get_alert_service
from backend.templates_config import templates
router = APIRouter(prefix="/api/alerts", tags=["alerts"]) router = APIRouter(prefix="/api/alerts", tags=["alerts"])
templates = Jinja2Templates(directory="templates")
# ============================================================================ # ============================================================================

View File

@@ -1,10 +1,9 @@
from fastapi import APIRouter, Request, Depends from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
from backend.templates_config import templates
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/dashboard/active") @router.get("/dashboard/active")

View File

@@ -6,7 +6,6 @@ and unit assignments within projects.
""" """
from fastapi import APIRouter, Request, Depends, HTTPException, Query from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
@@ -24,9 +23,9 @@ from backend.models import (
RosterUnit, RosterUnit,
RecordingSession, RecordingSession,
) )
from backend.templates_config import templates
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"]) router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
templates = Jinja2Templates(directory="templates")
# ============================================================================ # ============================================================================

View File

@@ -9,17 +9,19 @@ Provides API endpoints for the Projects system:
""" """
from fastapi import APIRouter, Request, Depends, HTTPException, Query from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func, and_ from sqlalchemy import func, and_
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
from collections import OrderedDict
import uuid import uuid
import json import json
import logging import logging
import io import io
from backend.utils.timezone import utc_to_local, format_local_datetime
from backend.database import get_db from backend.database import get_db
from backend.models import ( from backend.models import (
Project, Project,
@@ -31,9 +33,9 @@ from backend.models import (
RecurringSchedule, RecurringSchedule,
RosterUnit, RosterUnit,
) )
from backend.templates_config import templates
router = APIRouter(prefix="/api/projects", tags=["projects"]) router = APIRouter(prefix="/api/projects", tags=["projects"])
templates = Jinja2Templates(directory="templates")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -461,16 +463,37 @@ async def get_project_schedules(
if status: if status:
query = query.filter(ScheduledAction.execution_status == status) query = query.filter(ScheduledAction.execution_status == status)
# For pending actions, show soonest first (ascending)
# For completed/failed, show most recent first (descending)
if status == "pending":
schedules = query.order_by(ScheduledAction.scheduled_time.asc()).all()
else:
schedules = query.order_by(ScheduledAction.scheduled_time.desc()).all() schedules = query.order_by(ScheduledAction.scheduled_time.desc()).all()
# Enrich with location details # Enrich with location details and group by date
schedules_data = [] schedules_by_date = OrderedDict()
for schedule in schedules: for schedule in schedules:
location = None location = None
if schedule.location_id: if schedule.location_id:
location = db.query(MonitoringLocation).filter_by(id=schedule.location_id).first() location = db.query(MonitoringLocation).filter_by(id=schedule.location_id).first()
schedules_data.append({ # Get local date for grouping
if schedule.scheduled_time:
local_dt = utc_to_local(schedule.scheduled_time)
date_key = local_dt.strftime("%Y-%m-%d")
date_display = local_dt.strftime("%A, %B %d, %Y") # "Wednesday, January 22, 2026"
else:
date_key = "unknown"
date_display = "Unknown Date"
if date_key not in schedules_by_date:
schedules_by_date[date_key] = {
"date_display": date_display,
"date_key": date_key,
"actions": [],
}
schedules_by_date[date_key]["actions"].append({
"schedule": schedule, "schedule": schedule,
"location": location, "location": location,
}) })
@@ -478,7 +501,84 @@ async def get_project_schedules(
return templates.TemplateResponse("partials/projects/schedule_list.html", { return templates.TemplateResponse("partials/projects/schedule_list.html", {
"request": request, "request": request,
"project_id": project_id, "project_id": project_id,
"schedules": schedules_data, "schedules_by_date": schedules_by_date,
})
@router.post("/{project_id}/schedules/{schedule_id}/execute")
async def execute_scheduled_action(
project_id: str,
schedule_id: str,
db: Session = Depends(get_db),
):
"""
Manually execute a scheduled action now.
"""
from backend.services.scheduler import get_scheduler
action = db.query(ScheduledAction).filter_by(
id=schedule_id,
project_id=project_id,
).first()
if not action:
raise HTTPException(status_code=404, detail="Action not found")
if action.execution_status != "pending":
raise HTTPException(
status_code=400,
detail=f"Action is not pending (status: {action.execution_status})",
)
# Execute via scheduler service
scheduler = get_scheduler()
result = await scheduler.execute_action_by_id(schedule_id)
# Refresh from DB to get updated status
db.refresh(action)
return JSONResponse({
"success": result.get("success", False),
"message": f"Action executed: {action.action_type}",
"result": result,
"action": {
"id": action.id,
"execution_status": action.execution_status,
"executed_at": action.executed_at.isoformat() if action.executed_at else None,
"error_message": action.error_message,
},
})
@router.post("/{project_id}/schedules/{schedule_id}/cancel")
async def cancel_scheduled_action(
project_id: str,
schedule_id: str,
db: Session = Depends(get_db),
):
"""
Cancel a pending scheduled action.
"""
action = db.query(ScheduledAction).filter_by(
id=schedule_id,
project_id=project_id,
).first()
if not action:
raise HTTPException(status_code=404, detail="Action not found")
if action.execution_status != "pending":
raise HTTPException(
status_code=400,
detail=f"Can only cancel pending actions (status: {action.execution_status})",
)
action.execution_status = "cancelled"
db.commit()
return JSONResponse({
"success": True,
"message": "Action cancelled successfully",
}) })

View File

@@ -5,7 +5,6 @@ API endpoints for managing recurring monitoring schedules.
""" """
from fastapi import APIRouter, Request, Depends, HTTPException, Query from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional from typing import Optional
@@ -15,9 +14,9 @@ import json
from backend.database import get_db from backend.database import get_db
from backend.models import RecurringSchedule, MonitoringLocation, Project, RosterUnit from backend.models import RecurringSchedule, MonitoringLocation, Project, RosterUnit
from backend.services.recurring_schedule_service import get_recurring_schedule_service from backend.services.recurring_schedule_service import get_recurring_schedule_service
from backend.templates_config import templates
router = APIRouter(prefix="/api/projects/{project_id}/recurring-schedules", tags=["recurring-schedules"]) router = APIRouter(prefix="/api/projects/{project_id}/recurring-schedules", tags=["recurring-schedules"])
templates = Jinja2Templates(directory="templates")
# ============================================================================ # ============================================================================
@@ -209,17 +208,25 @@ async def create_recurring_schedule(
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"),
) )
# Generate actions immediately so they appear right away
generated_actions = service.generate_actions_for_schedule(schedule, horizon_days=7)
created_schedules.append({ created_schedules.append({
"schedule_id": schedule.id, "schedule_id": schedule.id,
"location_id": location.id, "location_id": location.id,
"location_name": location.name, "location_name": location.name,
"actions_generated": len(generated_actions),
}) })
total_actions = sum(s.get("actions_generated", 0) for s in created_schedules)
return JSONResponse({ return JSONResponse({
"success": True, "success": True,
"schedules": created_schedules, "schedules": created_schedules,
"count": len(created_schedules), "count": len(created_schedules),
"message": f"Created {len(created_schedules)} recurring schedule(s)", "actions_generated": total_actions,
"message": f"Created {len(created_schedules)} recurring schedule(s) with {total_actions} upcoming actions",
}) })

View File

@@ -5,7 +5,6 @@ Handles scheduled actions for automated recording control.
""" """
from fastapi import APIRouter, Request, Depends, HTTPException, Query from fastapi import APIRouter, Request, Depends, HTTPException, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_
@@ -23,9 +22,9 @@ from backend.models import (
RosterUnit, RosterUnit,
) )
from backend.services.scheduler import get_scheduler from backend.services.scheduler import get_scheduler
from backend.templates_config import templates
router = APIRouter(prefix="/api/projects/{project_id}/scheduler", tags=["scheduler"]) router = APIRouter(prefix="/api/projects/{project_id}/scheduler", tags=["scheduler"])
templates = Jinja2Templates(directory="templates")
# ============================================================================ # ============================================================================

View File

@@ -5,13 +5,12 @@ Provides endpoints for the seismograph-specific dashboard
from fastapi import APIRouter, Request, Depends, Query from fastapi import APIRouter, Request, Depends, Query
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit from backend.models import RosterUnit
from backend.templates_config import templates
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"]) router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
templates = Jinja2Templates(directory="templates")
@router.get("/stats", response_class=HTMLResponse) @router.get("/stats", response_class=HTMLResponse)

View File

@@ -5,7 +5,6 @@ Provides API endpoints for the Sound Level Meters dashboard page.
""" """
from fastapi import APIRouter, Request, Depends, Query from fastapi import APIRouter, Request, Depends, Query
from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import func from sqlalchemy import func
@@ -18,11 +17,11 @@ import os
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit from backend.models import RosterUnit
from backend.routers.roster_edit import sync_slm_to_slmm_cache from backend.routers.roster_edit import sync_slm_to_slmm_cache
from backend.templates_config import templates
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"]) router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
templates = Jinja2Templates(directory="templates")
# SLMM backend URL - configurable via environment variable # SLMM backend URL - configurable via environment variable
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100") SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")

View File

@@ -6,7 +6,6 @@ Provides endpoints for SLM dashboard cards, detail pages, and real-time data.
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
import httpx import httpx
@@ -15,11 +14,11 @@ import os
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit from backend.models import RosterUnit
from backend.templates_config import templates
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/slm", tags=["slm-ui"]) router = APIRouter(prefix="/slm", tags=["slm-ui"])
templates = Jinja2Templates(directory="templates")
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://172.19.0.1:8100") SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://172.19.0.1:8100")

View File

@@ -221,6 +221,61 @@ class AlertService:
expires_hours=24, expires_hours=24,
) )
def create_schedule_completed_alert(
self,
schedule_id: str,
action_type: str,
unit_id: str = None,
project_id: str = None,
location_id: str = None,
metadata: dict = None,
) -> Alert:
"""
Create alert when a scheduled action completes successfully.
Args:
schedule_id: The ScheduledAction ID
action_type: start, stop, download
unit_id: Related unit
project_id: Related project
location_id: Related location
metadata: Additional info (e.g., downloaded folder, index numbers)
Returns:
Created Alert
"""
# Build descriptive message based on action type and metadata
if action_type == "stop" and metadata:
download_folder = metadata.get("downloaded_folder")
download_success = metadata.get("download_success", False)
if download_success and download_folder:
message = f"Measurement stopped and data downloaded ({download_folder})"
elif download_success is False and metadata.get("download_attempted"):
message = "Measurement stopped but download failed"
else:
message = "Measurement stopped successfully"
elif action_type == "start" and metadata:
new_index = metadata.get("new_index")
if new_index is not None:
message = f"Measurement started (index {new_index:04d})"
else:
message = "Measurement started successfully"
else:
message = f"Scheduled {action_type} completed successfully"
return self.create_alert(
alert_type="schedule_completed",
title=f"Scheduled {action_type} completed",
message=message,
severity="info",
unit_id=unit_id,
project_id=project_id,
location_id=location_id,
schedule_id=schedule_id,
metadata={"action_type": action_type, **(metadata or {})},
expires_hours=12, # Info alerts expire quickly
)
def get_active_alerts( def get_active_alerts(
self, self,
project_id: str = None, project_id: str = None,

View File

@@ -403,6 +403,87 @@ class DeviceController:
else: else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}") raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ========================================================================
# Cycle Commands (for scheduled automation)
# ========================================================================
async def start_cycle(
self,
unit_id: str,
device_type: str,
sync_clock: bool = True,
) -> Dict[str, Any]:
"""
Execute complete start cycle for scheduled automation.
This handles the full pre-recording workflow:
1. Sync device clock to server time
2. Find next safe index (with overwrite protection)
3. Start measurement
Args:
unit_id: Unit identifier
device_type: "slm" | "seismograph"
sync_clock: Whether to sync device clock to server time
Returns:
Response dict from device module
"""
if device_type == "slm":
try:
return await self.slmm_client.start_cycle(unit_id, sync_clock)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph start cycle not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
async def stop_cycle(
self,
unit_id: str,
device_type: str,
download: bool = True,
) -> Dict[str, Any]:
"""
Execute complete stop cycle for scheduled automation.
This handles the full post-recording workflow:
1. Stop measurement
2. Enable FTP
3. Download measurement folder
4. Verify download
Args:
unit_id: Unit identifier
device_type: "slm" | "seismograph"
download: Whether to download measurement data
Returns:
Response dict from device module
"""
if device_type == "slm":
try:
return await self.slmm_client.stop_cycle(unit_id, download)
except SLMMClientError as e:
raise DeviceControllerError(f"SLMM error: {str(e)}")
elif device_type == "seismograph":
return {
"status": "not_implemented",
"message": "Seismograph stop cycle not yet implemented",
"unit_id": unit_id,
}
else:
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
# ======================================================================== # ========================================================================
# Health Check # Health Check
# ======================================================================== # ========================================================================

View File

@@ -146,13 +146,22 @@ class RecurringScheduleService:
return False return False
# Delete pending generated actions for this schedule # Delete pending generated actions for this schedule
# Note: We don't have recurring_schedule_id field yet, so we can't clean up # The schedule_id is stored in the notes field as JSON
# generated actions. This is fine for now. pending_actions = self.db.query(ScheduledAction).filter(
and_(
ScheduledAction.execution_status == "pending",
ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'),
)
).all()
deleted_count = len(pending_actions)
for action in pending_actions:
self.db.delete(action)
self.db.delete(schedule) self.db.delete(schedule)
self.db.commit() self.db.commit()
logger.info(f"Deleted recurring schedule: {schedule.name}") logger.info(f"Deleted recurring schedule: {schedule.name} (and {deleted_count} pending actions)")
return True return True
def enable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]: def enable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]:

View File

@@ -23,6 +23,7 @@ from sqlalchemy import and_
from backend.database import SessionLocal from backend.database import SessionLocal
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule
from backend.services.device_controller import get_device_controller, DeviceControllerError from backend.services.device_controller import get_device_controller, DeviceControllerError
from backend.services.alert_service import get_alert_service
import uuid import uuid
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -197,6 +198,21 @@ class SchedulerService:
print(f"✓ Action {action.id} completed successfully") print(f"✓ Action {action.id} completed successfully")
# Create success alert
try:
alert_service = get_alert_service(db)
alert_metadata = response.get("cycle_response", {}) if isinstance(response, dict) else {}
alert_service.create_schedule_completed_alert(
schedule_id=action.id,
action_type=action.action_type,
unit_id=unit_id,
project_id=action.project_id,
location_id=action.location_id,
metadata=alert_metadata,
)
except Exception as alert_err:
logger.warning(f"Failed to create success alert: {alert_err}")
except Exception as e: except Exception as e:
# Mark action as failed # Mark action as failed
action.execution_status = "failed" action.execution_status = "failed"
@@ -207,6 +223,20 @@ class SchedulerService:
print(f"✗ Action {action.id} failed: {e}") print(f"✗ Action {action.id} failed: {e}")
# Create failure alert
try:
alert_service = get_alert_service(db)
alert_service.create_schedule_failed_alert(
schedule_id=action.id,
action_type=action.action_type,
unit_id=unit_id if 'unit_id' in dir() else action.unit_id,
error_message=str(e),
project_id=action.project_id,
location_id=action.location_id,
)
except Exception as alert_err:
logger.warning(f"Failed to create failure alert: {alert_err}")
return result return result
async def _execute_start( async def _execute_start(
@@ -215,35 +245,19 @@ class SchedulerService:
unit_id: str, unit_id: str,
db: Session, db: Session,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Execute a 'start' action.""" """Execute a 'start' action using the start_cycle command.
# Parse action notes for automation settings
auto_increment_index = False
try:
if action.notes:
notes_data = json.loads(action.notes)
auto_increment_index = notes_data.get("auto_increment_index", False)
except json.JSONDecodeError:
pass # Notes is plain text, not JSON
# If auto_increment_index is enabled, increment the store index before starting start_cycle handles:
increment_response = None 1. Sync device clock to server time
if auto_increment_index and action.device_type == "slm": 2. Find next safe index (with overwrite protection)
try: 3. Start measurement
logger.info(f"Auto-incrementing store index for unit {unit_id}") """
increment_response = await self.device_controller.increment_index( # Execute the full start cycle via device controller
# SLMM handles clock sync, index increment, and start
cycle_response = await self.device_controller.start_cycle(
unit_id, unit_id,
action.device_type, action.device_type,
) sync_clock=True,
logger.info(f"Index incremented: {increment_response}")
except Exception as e:
logger.warning(f"Failed to increment index for {unit_id}: {e}")
# Continue with start anyway - don't fail the whole action
# Start recording via device controller
response = await self.device_controller.start_recording(
unit_id,
action.device_type,
config={},
) )
# Create recording session # Create recording session
@@ -257,8 +271,7 @@ class SchedulerService:
status="recording", status="recording",
session_metadata=json.dumps({ session_metadata=json.dumps({
"scheduled_action_id": action.id, "scheduled_action_id": action.id,
"auto_increment_index": auto_increment_index, "cycle_response": cycle_response,
"increment_response": increment_response,
}), }),
) )
db.add(session) db.add(session)
@@ -266,9 +279,7 @@ class SchedulerService:
return { return {
"status": "started", "status": "started",
"session_id": session.id, "session_id": session.id,
"device_response": response, "cycle_response": cycle_response,
"index_incremented": auto_increment_index,
"increment_response": increment_response,
} }
async def _execute_stop( async def _execute_stop(
@@ -277,11 +288,29 @@ class SchedulerService:
unit_id: str, unit_id: str,
db: Session, db: Session,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Execute a 'stop' action.""" """Execute a 'stop' action using the stop_cycle command.
# Stop recording via device controller
response = await self.device_controller.stop_recording( stop_cycle handles:
1. Stop measurement
2. Enable FTP
3. Download measurement folder
4. Verify download
"""
# Parse notes for download preference
include_download = True
try:
if action.notes:
notes_data = json.loads(action.notes)
include_download = notes_data.get("include_download", True)
except json.JSONDecodeError:
pass # Notes is plain text, not JSON
# Execute the full stop cycle via device controller
# SLMM handles stop, FTP enable, and download
cycle_response = await self.device_controller.stop_cycle(
unit_id, unit_id,
action.device_type, action.device_type,
download=include_download,
) )
# Find and update the active recording session # Find and update the active recording session
@@ -299,11 +328,20 @@ class SchedulerService:
active_session.duration_seconds = int( active_session.duration_seconds = int(
(active_session.stopped_at - active_session.started_at).total_seconds() (active_session.stopped_at - active_session.started_at).total_seconds()
) )
# Store download info in session metadata
if cycle_response.get("download_success"):
try:
metadata = json.loads(active_session.session_metadata or "{}")
metadata["downloaded_folder"] = cycle_response.get("downloaded_folder")
metadata["local_path"] = cycle_response.get("local_path")
active_session.session_metadata = json.dumps(metadata)
except json.JSONDecodeError:
pass
return { return {
"status": "stopped", "status": "stopped",
"session_id": active_session.id if active_session else None, "session_id": active_session.id if active_session else None,
"device_response": response, "cycle_response": cycle_response,
} }
async def _execute_download( async def _execute_download(

View File

@@ -9,13 +9,14 @@ that handles TCP/FTP communication with Rion NL-43/NL-53 devices.
""" """
import httpx import httpx
import os
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from datetime import datetime from datetime import datetime
import json import json
# SLMM backend base URLs # SLMM backend base URLs - use environment variable if set (for Docker)
SLMM_BASE_URL = "http://localhost:8100" SLMM_BASE_URL = os.environ.get("SLMM_BASE_URL", "http://localhost:8100")
SLMM_API_BASE = f"{SLMM_BASE_URL}/api/nl43" SLMM_API_BASE = f"{SLMM_BASE_URL}/api/nl43"
@@ -505,6 +506,68 @@ class SLMMClient:
} }
return await self._request("POST", f"/{unit_id}/ftp/download", data=data) return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
# ========================================================================
# Cycle Commands (for scheduled automation)
# ========================================================================
async def start_cycle(
self,
unit_id: str,
sync_clock: bool = True,
) -> Dict[str, Any]:
"""
Execute complete start cycle on device via SLMM.
This handles the full pre-recording workflow:
1. Sync device clock to server time
2. Find next safe index (with overwrite protection)
3. Start measurement
Args:
unit_id: Unit identifier
sync_clock: Whether to sync device clock to server time
Returns:
Dict with clock_synced, old_index, new_index, started, etc.
"""
return await self._request(
"POST",
f"/{unit_id}/start-cycle",
data={"sync_clock": sync_clock},
)
async def stop_cycle(
self,
unit_id: str,
download: bool = True,
download_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
Execute complete stop cycle on device via SLMM.
This handles the full post-recording workflow:
1. Stop measurement
2. Enable FTP
3. Download measurement folder (if download=True)
4. Verify download
Args:
unit_id: Unit identifier
download: Whether to download measurement data
download_path: Custom path for downloaded ZIP (optional)
Returns:
Dict with stopped, ftp_enabled, download_success, local_path, etc.
"""
data = {"download": download}
if download_path:
data["download_path"] = download_path
return await self._request(
"POST",
f"/{unit_id}/stop-cycle",
data=data,
)
# ======================================================================== # ========================================================================
# Polling Status (for device monitoring/alerts) # Polling Status (for device monitoring/alerts)
# ======================================================================== # ========================================================================

View File

@@ -0,0 +1,39 @@
"""
Shared Jinja2 templates configuration.
All routers should import `templates` from this module to get consistent
filter and global function registration.
"""
from fastapi.templating import Jinja2Templates
# Import timezone utilities
from backend.utils.timezone import (
format_local_datetime, format_local_time,
get_user_timezone, get_timezone_abbreviation
)
def jinja_local_datetime(dt, fmt="%Y-%m-%d %H:%M"):
"""Jinja filter to convert UTC datetime to local timezone."""
return format_local_datetime(dt, fmt)
def jinja_local_time(dt):
"""Jinja filter to format time in local timezone."""
return format_local_time(dt)
def jinja_timezone_abbr():
"""Jinja global to get current timezone abbreviation."""
return get_timezone_abbreviation()
# Create templates instance
templates = Jinja2Templates(directory="templates")
# Register Jinja filters and globals
templates.env.filters["local_datetime"] = jinja_local_datetime
templates.env.filters["local_time"] = jinja_local_time
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
templates.env.globals["get_user_timezone"] = get_user_timezone

View File

@@ -0,0 +1 @@
# Utils package

173
backend/utils/timezone.py Normal file
View File

@@ -0,0 +1,173 @@
"""
Timezone utilities for Terra-View.
Provides consistent timezone handling throughout the application.
All database times are stored in UTC; this module converts for display.
"""
from datetime import datetime
from zoneinfo import ZoneInfo
from typing import Optional
from backend.database import SessionLocal
from backend.models import UserPreferences
# Default timezone if none set
DEFAULT_TIMEZONE = "America/New_York"
def get_user_timezone() -> str:
"""
Get the user's configured timezone from preferences.
Returns:
Timezone string (e.g., "America/New_York")
"""
db = SessionLocal()
try:
prefs = db.query(UserPreferences).filter_by(id=1).first()
if prefs and prefs.timezone:
return prefs.timezone
return DEFAULT_TIMEZONE
finally:
db.close()
def get_timezone_info(tz_name: str = None) -> ZoneInfo:
"""
Get ZoneInfo object for the specified or user's timezone.
Args:
tz_name: Timezone name, or None to use user preference
Returns:
ZoneInfo object
"""
if tz_name is None:
tz_name = get_user_timezone()
try:
return ZoneInfo(tz_name)
except Exception:
return ZoneInfo(DEFAULT_TIMEZONE)
def utc_to_local(dt: datetime, tz_name: str = None) -> datetime:
"""
Convert a UTC datetime to local timezone.
Args:
dt: Datetime in UTC (naive or aware)
tz_name: Target timezone, or None to use user preference
Returns:
Datetime in local timezone
"""
if dt is None:
return None
tz = get_timezone_info(tz_name)
# Assume naive datetime is UTC
if dt.tzinfo is None:
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
return dt.astimezone(tz)
def local_to_utc(dt: datetime, tz_name: str = None) -> datetime:
"""
Convert a local datetime to UTC.
Args:
dt: Datetime in local timezone (naive or aware)
tz_name: Source timezone, or None to use user preference
Returns:
Datetime in UTC (naive, for database storage)
"""
if dt is None:
return None
tz = get_timezone_info(tz_name)
# Assume naive datetime is in local timezone
if dt.tzinfo is None:
dt = dt.replace(tzinfo=tz)
# Convert to UTC and strip tzinfo for database storage
return dt.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
def format_local_datetime(dt: datetime, fmt: str = "%Y-%m-%d %H:%M", tz_name: str = None) -> str:
"""
Format a UTC datetime as local time string.
Args:
dt: Datetime in UTC
fmt: strftime format string
tz_name: Target timezone, or None to use user preference
Returns:
Formatted datetime string in local time
"""
if dt is None:
return "N/A"
local_dt = utc_to_local(dt, tz_name)
return local_dt.strftime(fmt)
def format_local_time(dt: datetime, tz_name: str = None) -> str:
"""
Format a UTC datetime as local time (HH:MM format).
Args:
dt: Datetime in UTC
tz_name: Target timezone
Returns:
Time string in HH:MM format
"""
return format_local_datetime(dt, "%H:%M", tz_name)
def format_local_date(dt: datetime, tz_name: str = None) -> str:
"""
Format a UTC datetime as local date (YYYY-MM-DD format).
Args:
dt: Datetime in UTC
tz_name: Target timezone
Returns:
Date string
"""
return format_local_datetime(dt, "%Y-%m-%d", tz_name)
def get_timezone_abbreviation(tz_name: str = None) -> str:
"""
Get the abbreviation for a timezone (e.g., EST, EDT, PST).
Args:
tz_name: Timezone name, or None to use user preference
Returns:
Timezone abbreviation
"""
tz = get_timezone_info(tz_name)
now = datetime.now(tz)
return now.strftime("%Z")
# Common US timezone choices for settings dropdown
TIMEZONE_CHOICES = [
("America/New_York", "Eastern Time (ET)"),
("America/Chicago", "Central Time (CT)"),
("America/Denver", "Mountain Time (MT)"),
("America/Los_Angeles", "Pacific Time (PT)"),
("America/Anchorage", "Alaska Time (AKT)"),
("Pacific/Honolulu", "Hawaii Time (HT)"),
("UTC", "UTC"),
]

View File

@@ -123,7 +123,7 @@
{% endif %} {% endif %}
<div> <div>
<div class="text-sm text-gray-600 dark:text-gray-400">Created</div> <div class="text-sm text-gray-600 dark:text-gray-400">Created</div>
<div class="text-gray-900 dark:text-white">{{ location.created_at.strftime('%Y-%m-%d %H:%M') if location.created_at else 'N/A' }}</div> <div class="text-gray-900 dark:text-white">{{ location.created_at|local_datetime if location.created_at else 'N/A' }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -150,7 +150,7 @@
{% if assignment %} {% if assignment %}
<div> <div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div> <div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at.strftime('%Y-%m-%d %H:%M') if assignment.assigned_at else 'N/A' }}</div> <div class="text-gray-900 dark:text-white">{{ assignment.assigned_at|local_datetime if assignment.assigned_at else 'N/A' }}</div>
</div> </div>
{% if assignment.notes %} {% if assignment.notes %}
<div> <div>

View File

@@ -10,7 +10,7 @@
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Location: {{ item.location.name }}</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Location: {{ item.location.name }}</p>
{% endif %} {% endif %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Assigned: {% if item.assignment.assigned_at %}{{ item.assignment.assigned_at.strftime('%Y-%m-%d %H:%M') }}{% else %}Unknown{% endif %} Assigned: {% if item.assignment.assigned_at %}{{ item.assignment.assigned_at|local_datetime }}{% else %}Unknown{% endif %}
</p> </p>
</div> </div>
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"> <button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">

View File

@@ -81,7 +81,7 @@
{% for action in upcoming_actions %} {% for action in upcoming_actions %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3"> <div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p> <p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time.strftime('%Y-%m-%d %H:%M') }}</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time|local_datetime }} {{ timezone_abbr() }}</p>
{% if action.description %} {% if action.description %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
{% endif %} {% endif %}

View File

@@ -74,7 +74,7 @@
{% if item.schedule.next_occurrence %} {% if item.schedule.next_occurrence %}
<div class="text-xs"> <div class="text-xs">
<span class="text-gray-400">Next:</span> <span class="text-gray-400">Next:</span>
{{ item.schedule.next_occurrence.strftime('%Y-%m-%d %H:%M') }} {{ item.schedule.timezone }} {{ item.schedule.next_occurrence|local_datetime }} {{ timezone_abbr() }}
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -1,98 +1,142 @@
<!-- Scheduled Actions List --> <!-- Scheduled Actions List - Grouped by Date -->
{% if schedules %} {% if schedules_by_date %}
<div class="space-y-4"> <div class="space-y-6">
{% for item in schedules %} {% for date_key, date_group in schedules_by_date.items() %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"> <div>
<!-- Date Header -->
<div class="flex items-center gap-3 mb-3">
<div class="flex-shrink-0 w-10 h-10 bg-seismo-orange/10 dark:bg-seismo-orange/20 rounded-lg flex items-center justify-center">
<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 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>
</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ date_group.date_display }}</h3>
<p class="text-xs text-gray-500">{{ date_group.actions|length }} action{{ 's' if date_group.actions|length != 1 else '' }}</p>
</div>
</div>
<!-- Actions for this date -->
<div class="space-y-3 ml-13 pl-3 border-l-2 border-gray-200 dark:border-gray-700">
{% for item in date_group.actions %}
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center gap-3 mb-2"> <div class="flex items-center gap-3 mb-2">
<h4 class="font-semibold text-gray-900 dark:text-white"> <!-- Action type with icon -->
{{ item.schedule.action_type }} {% if item.schedule.action_type == 'start' %}
</h4> <span class="flex items-center gap-1.5 text-green-600 dark:text-green-400 font-semibold">
<svg class="w-4 h-4" 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>
Start
</span>
{% elif item.schedule.action_type == 'stop' %}
<span class="flex items-center gap-1.5 text-red-600 dark:text-red-400 font-semibold">
<svg class="w-4 h-4" 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>
Stop
</span>
{% elif item.schedule.action_type == 'download' %}
<span class="flex items-center gap-1.5 text-blue-600 dark:text-blue-400 font-semibold">
<svg class="w-4 h-4" 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>
Download
</span>
{% else %}
<span class="font-semibold text-gray-900 dark:text-white">{{ item.schedule.action_type }}</span>
{% endif %}
<!-- Status badge -->
{% if item.schedule.execution_status == 'pending' %} {% if item.schedule.execution_status == 'pending' %}
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full"> <span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
Pending Pending
</span> </span>
{% elif item.schedule.execution_status == 'completed' %} {% elif item.schedule.execution_status == 'completed' %}
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full"> <span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
Completed Completed
</span> </span>
{% elif item.schedule.execution_status == 'failed' %} {% elif item.schedule.execution_status == 'failed' %}
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full"> <span class="px-2 py-0.5 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full">
Failed Failed
</span> </span>
{% elif item.schedule.execution_status == 'cancelled' %} {% elif item.schedule.execution_status == 'cancelled' %}
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full"> <span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
Cancelled Cancelled
</span> </span>
{% endif %} {% endif %}
</div> </div>
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400"> <div class="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
<!-- Time -->
<div class="flex items-center gap-1">
<svg class="w-4 h-4 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>
<span>{{ item.schedule.scheduled_time|local_datetime('%H:%M') if item.schedule.scheduled_time else 'N/A' }}</span>
</div>
<!-- Location -->
{% if item.location %} {% if item.location %}
<div> <div class="flex items-center gap-1">
<span class="text-xs text-gray-500">Location:</span> <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}" <a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
class="text-seismo-orange hover:text-seismo-navy font-medium ml-1"> class="text-seismo-orange hover:text-seismo-navy font-medium">
{{ item.location.name }} {{ item.location.name }}
</a> </a>
</div> </div>
{% endif %} {% endif %}
<div>
<span class="text-xs text-gray-500">Scheduled:</span>
<span class="ml-1">{{ item.schedule.scheduled_time.strftime('%Y-%m-%d %H:%M') if item.schedule.scheduled_time else 'N/A' }}</span>
</div>
{% if item.schedule.executed_at %} {% if item.schedule.executed_at %}
<div> <div class="flex items-center gap-1">
<span class="text-xs text-gray-500">Executed:</span> <svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<span class="ml-1">{{ item.schedule.executed_at.strftime('%Y-%m-%d %H:%M') }}</span> <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"/>
</div> </svg>
{% endif %} <span>Executed {{ item.schedule.executed_at|local_datetime('%H:%M') }}</span>
{% if item.schedule.created_at %}
<div>
<span class="text-xs text-gray-500">Created:</span>
<span class="ml-1">{{ item.schedule.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if item.schedule.description %} {% if item.schedule.error_message %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2"> <div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs">
{{ item.schedule.description }} <span class="text-red-600 dark:text-red-400 font-medium">Error:</span>
</p> <span class="ml-1 text-red-700 dark:text-red-300">{{ item.schedule.error_message }}</span>
{% endif %}
{% if item.schedule.result_message %}
<div class="mt-2 text-xs">
<span class="text-gray-500">Result:</span>
<span class="ml-1 text-gray-700 dark:text-gray-300">{{ item.schedule.result_message }}</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="flex items-center gap-2"> <!-- Actions -->
<div class="flex items-center gap-2 flex-shrink-0">
{% if item.schedule.execution_status == 'pending' %} {% if item.schedule.execution_status == 'pending' %}
<button onclick="executeSchedule('{{ item.schedule.id }}')" <button onclick="executeSchedule('{{ item.schedule.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"> class="p-2 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded-lg transition-colors"
Execute Now title="Execute Now">
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</button> </button>
<button onclick="cancelSchedule('{{ item.schedule.id }}')" <button onclick="cancelSchedule('{{ item.schedule.id }}')"
class="px-3 py-1 text-xs bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 rounded-lg hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors"> class="p-2 text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
Cancel title="Cancel">
<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"/>
</svg>
</button> </button>
{% endif %} {% endif %}
<button onclick="viewScheduleDetails('{{ item.schedule.id }}')"
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
Details
</button>
</div> </div>
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
</div>
{% endfor %}
</div>
{% else %} {% else %}
<div class="text-center py-12"> <div class="text-center py-12">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -141,9 +185,4 @@ function cancelSchedule(scheduleId) {
alert('Error cancelling schedule: ' + error); alert('Error cancelling schedule: ' + error);
}); });
} }
function viewScheduleDetails(scheduleId) {
// TODO: Implement schedule details modal
alert('Schedule details coming soon: ' + scheduleId);
}
</script> </script>

View File

@@ -41,13 +41,13 @@
<div> <div>
<span class="text-xs text-gray-500">Started:</span> <span class="text-xs text-gray-500">Started:</span>
<span class="ml-1">{{ item.session.started_at.strftime('%Y-%m-%d %H:%M') if item.session.started_at else 'N/A' }}</span> <span class="ml-1">{{ item.session.started_at|local_datetime if item.session.started_at else 'N/A' }}</span>
</div> </div>
{% if item.session.stopped_at %} {% if item.session.stopped_at %}
<div> <div>
<span class="text-xs text-gray-500">Ended:</span> <span class="text-xs text-gray-500">Ended:</span>
<span class="ml-1">{{ item.session.stopped_at.strftime('%Y-%m-%d %H:%M') }}</span> <span class="ml-1">{{ item.session.stopped_at|local_datetime }}</span>
</div> </div>
{% endif %} {% endif %}

View File

@@ -24,7 +24,7 @@
</svg> </svg>
<div> <div>
<div class="font-semibold text-gray-900 dark:text-white"> <div class="font-semibold text-gray-900 dark:text-white">
{{ session.started_at.strftime('%Y-%m-%d %H:%M') if session.started_at else 'Unknown Date' }} {{ session.started_at|local_datetime if session.started_at else 'Unknown Date' }}
</div> </div>
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %} {% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
@@ -155,7 +155,7 @@
<!-- Download Time --> <!-- Download Time -->
{% if file.downloaded_at %} {% if file.downloaded_at %}
<span class="mx-1"></span> <span class="mx-1"></span>
{{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }} {{ file.downloaded_at|local_datetime }}
{% endif %} {% endif %}
<!-- Source Info from Metadata --> <!-- Source Info from Metadata -->

View File

@@ -60,7 +60,7 @@
{% if item.assignment.assigned_at %} {% if item.assignment.assigned_at %}
<div class="col-span-2"> <div class="col-span-2">
<span class="text-xs text-gray-500">Assigned:</span> <span class="text-xs text-gray-500">Assigned:</span>
<span class="ml-1">{{ item.assignment.assigned_at.strftime('%Y-%m-%d %H:%M') }}</span> <span class="ml-1">{{ item.assignment.assigned_at|local_datetime }}</span>
</div> </div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -51,7 +51,7 @@
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400"> <div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{% if unit.slm_last_check %} {% if unit.slm_last_check %}
Last check: {{ unit.slm_last_check.strftime('%Y-%m-%d %H:%M') }} Last check: {{ unit.slm_last_check|local_datetime }}
{% else %} {% else %}
No recent check-in No recent check-in
{% endif %} {% endif %}