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.
This commit is contained in:
@@ -58,8 +58,8 @@ app.add_middleware(
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
||||
|
||||
# Setup Jinja2 templates
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
# Use shared templates configuration with timezone filters
|
||||
from backend.templates_config import templates
|
||||
|
||||
# Add custom context processor to inject environment variable into all templates
|
||||
@app.middleware("http")
|
||||
|
||||
@@ -5,7 +5,6 @@ API endpoints for managing in-app alerts.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
@@ -14,9 +13,9 @@ from datetime import datetime, timedelta
|
||||
from backend.database import get_db
|
||||
from backend.models import Alert, RosterUnit
|
||||
from backend.services.alert_service import get_alert_service
|
||||
from backend.templates_config import templates
|
||||
|
||||
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
from backend.templates_config import templates
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/dashboard/active")
|
||||
|
||||
@@ -6,7 +6,6 @@ and unit assignments within projects.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
@@ -24,9 +23,9 @@ from backend.models import (
|
||||
RosterUnit,
|
||||
RecordingSession,
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
|
||||
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.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from collections import OrderedDict
|
||||
import uuid
|
||||
import json
|
||||
import logging
|
||||
import io
|
||||
|
||||
from backend.utils.timezone import utc_to_local, format_local_datetime
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import (
|
||||
Project,
|
||||
@@ -31,9 +33,9 @@ from backend.models import (
|
||||
RecurringSchedule,
|
||||
RosterUnit,
|
||||
)
|
||||
from backend.templates_config import templates
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["projects"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -461,16 +463,37 @@ async def get_project_schedules(
|
||||
if status:
|
||||
query = query.filter(ScheduledAction.execution_status == status)
|
||||
|
||||
schedules = query.order_by(ScheduledAction.scheduled_time.desc()).all()
|
||||
# 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()
|
||||
|
||||
# Enrich with location details
|
||||
schedules_data = []
|
||||
# Enrich with location details and group by date
|
||||
schedules_by_date = OrderedDict()
|
||||
for schedule in schedules:
|
||||
location = None
|
||||
if schedule.location_id:
|
||||
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,
|
||||
"location": location,
|
||||
})
|
||||
@@ -478,7 +501,7 @@ async def get_project_schedules(
|
||||
return templates.TemplateResponse("partials/projects/schedule_list.html", {
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"schedules": schedules_data,
|
||||
"schedules_by_date": schedules_by_date,
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ API endpoints for managing recurring monitoring schedules.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
@@ -15,9 +14,9 @@ import json
|
||||
from backend.database import get_db
|
||||
from backend.models import RecurringSchedule, MonitoringLocation, Project, RosterUnit
|
||||
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"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -209,17 +208,25 @@ async def create_recurring_schedule(
|
||||
auto_increment_index=data.get("auto_increment_index", True),
|
||||
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({
|
||||
"schedule_id": schedule.id,
|
||||
"location_id": location.id,
|
||||
"location_name": location.name,
|
||||
"actions_generated": len(generated_actions),
|
||||
})
|
||||
|
||||
total_actions = sum(s.get("actions_generated", 0) for s in created_schedules)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"schedules": 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.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
@@ -23,9 +22,9 @@ from backend.models import (
|
||||
RosterUnit,
|
||||
)
|
||||
from backend.services.scheduler import get_scheduler
|
||||
from backend.templates_config import templates
|
||||
|
||||
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.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
from backend.templates_config import templates
|
||||
|
||||
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@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.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
@@ -18,11 +17,11 @@ import os
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
from backend.routers.roster_edit import sync_slm_to_slmm_cache
|
||||
from backend.templates_config import templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# SLMM backend URL - configurable via environment variable
|
||||
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.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
@@ -15,11 +14,11 @@ import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
from backend.templates_config import templates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@@ -146,13 +146,22 @@ class RecurringScheduleService:
|
||||
return False
|
||||
|
||||
# Delete pending generated actions for this schedule
|
||||
# Note: We don't have recurring_schedule_id field yet, so we can't clean up
|
||||
# generated actions. This is fine for now.
|
||||
# The schedule_id is stored in the notes field as JSON
|
||||
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.commit()
|
||||
|
||||
logger.info(f"Deleted recurring schedule: {schedule.name}")
|
||||
logger.info(f"Deleted recurring schedule: {schedule.name} (and {deleted_count} pending actions)")
|
||||
return True
|
||||
|
||||
def enable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]:
|
||||
|
||||
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 %}
|
||||
<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>
|
||||
@@ -150,7 +150,7 @@
|
||||
{% if assignment %}
|
||||
<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>
|
||||
{% if assignment.notes %}
|
||||
<div>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Location: {{ item.location.name }}</p>
|
||||
{% endif %}
|
||||
<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>
|
||||
</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">
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
{% for action in upcoming_actions %}
|
||||
<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="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 %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
{% if item.schedule.next_occurrence %}
|
||||
<div class="text-xs">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,102 +1,139 @@
|
||||
<!-- Scheduled Actions List -->
|
||||
{% if schedules %}
|
||||
<div class="space-y-4">
|
||||
{% for item in schedules %}
|
||||
<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 class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ item.schedule.action_type }}
|
||||
</h4>
|
||||
{% 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">
|
||||
Pending
|
||||
</span>
|
||||
{% 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">
|
||||
Completed
|
||||
</span>
|
||||
{% 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">
|
||||
Failed
|
||||
</span>
|
||||
{% 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">
|
||||
Cancelled
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{% if item.location %}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Location:</span>
|
||||
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
|
||||
class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
||||
{{ item.location.name }}
|
||||
</a>
|
||||
</div>
|
||||
{% 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 %}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Executed:</span>
|
||||
<span class="ml-1">{{ item.schedule.executed_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.schedule.description %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{{ item.schedule.description }}
|
||||
</p>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
{% if item.schedule.error_message %}
|
||||
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs">
|
||||
<span class="text-red-600 dark:text-red-400 font-medium">Error:</span>
|
||||
<span class="ml-1 text-red-700 dark:text-red-300">{{ item.schedule.error_message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Scheduled Actions List - Grouped by Date -->
|
||||
{% if schedules_by_date %}
|
||||
<div class="space-y-6">
|
||||
{% for date_key, date_group in schedules_by_date.items() %}
|
||||
<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 class="flex items-center gap-2">
|
||||
{% if item.schedule.execution_status == 'pending' %}
|
||||
<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">
|
||||
Execute Now
|
||||
</button>
|
||||
<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">
|
||||
Cancel
|
||||
</button>
|
||||
{% 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>
|
||||
<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="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<!-- Action type with icon -->
|
||||
{% if item.schedule.action_type == 'start' %}
|
||||
<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' %}
|
||||
<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
|
||||
</span>
|
||||
{% elif item.schedule.execution_status == 'completed' %}
|
||||
<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
|
||||
</span>
|
||||
{% elif item.schedule.execution_status == 'failed' %}
|
||||
<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
|
||||
</span>
|
||||
{% elif item.schedule.execution_status == 'cancelled' %}
|
||||
<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
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<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 %}
|
||||
<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="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 }}"
|
||||
class="text-seismo-orange hover:text-seismo-navy font-medium">
|
||||
{{ item.location.name }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.schedule.executed_at %}
|
||||
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>Executed {{ item.schedule.executed_at|local_datetime('%H:%M') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.schedule.error_message %}
|
||||
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs">
|
||||
<span class="text-red-600 dark:text-red-400 font-medium">Error:</span>
|
||||
<span class="ml-1 text-red-700 dark:text-red-300">{{ item.schedule.error_message }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
{% if item.schedule.execution_status == 'pending' %}
|
||||
<button onclick="executeSchedule('{{ item.schedule.id }}')"
|
||||
class="p-2 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded-lg transition-colors"
|
||||
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 onclick="cancelSchedule('{{ item.schedule.id }}')"
|
||||
class="p-2 text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||
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>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -148,9 +185,4 @@ function cancelSchedule(scheduleId) {
|
||||
alert('Error cancelling schedule: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
function viewScheduleDetails(scheduleId) {
|
||||
// TODO: Implement schedule details modal
|
||||
alert('Schedule details coming soon: ' + scheduleId);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -41,13 +41,13 @@
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{% if item.session.stopped_at %}
|
||||
<div>
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
</svg>
|
||||
<div>
|
||||
<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 class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
|
||||
@@ -155,7 +155,7 @@
|
||||
<!-- Download Time -->
|
||||
{% if file.downloaded_at %}
|
||||
<span class="mx-1">•</span>
|
||||
{{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{{ file.downloaded_at|local_datetime }}
|
||||
{% endif %}
|
||||
|
||||
<!-- Source Info from Metadata -->
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
{% if item.assignment.assigned_at %}
|
||||
<div class="col-span-2">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{% 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 %}
|
||||
No recent check-in
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user