Compare commits
2 Commits
65ea0920db
...
8431784708
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8431784708 | ||
|
|
c771a86675 |
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
39
backend/templates_config.py
Normal file
39
backend/templates_config.py
Normal 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
|
||||||
1
backend/utils/__init__.py
Normal file
1
backend/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Utils package
|
||||||
173
backend/utils/timezone.py
Normal file
173
backend/utils/timezone.py
Normal 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"),
|
||||||
|
]
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user