update main to v0.10.0 #48
@@ -4,11 +4,29 @@ from sqlalchemy import desc
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
from backend.models import UnitHistory, Emitter, RosterUnit
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["activity"])
|
router = APIRouter(prefix="/api", tags=["activity"])
|
||||||
|
|
||||||
|
SFM_BASE_URL = os.getenv("SFM_BASE_URL", "http://localhost:8200")
|
||||||
|
|
||||||
|
|
||||||
|
def _humanize_age(seconds: float) -> str:
|
||||||
|
if seconds < 60:
|
||||||
|
return "just now"
|
||||||
|
if seconds < 3600:
|
||||||
|
return f"{int(seconds / 60)}m ago"
|
||||||
|
if seconds < 86400:
|
||||||
|
hrs = seconds / 3600
|
||||||
|
return f"{int(hrs)}h {int((hrs % 1) * 60)}m ago"
|
||||||
|
return f"{int(seconds / 86400)}d ago"
|
||||||
|
|
||||||
PHOTOS_BASE_DIR = Path("data/photos")
|
PHOTOS_BASE_DIR = Path("data/photos")
|
||||||
|
|
||||||
|
|
||||||
@@ -144,3 +162,86 @@ def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(
|
|||||||
"hours": hours,
|
"hours": hours,
|
||||||
"time_threshold": time_threshold.isoformat()
|
"time_threshold": time_threshold.isoformat()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-event-callins")
|
||||||
|
async def get_recent_event_callins(limit: int = 10, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Recent unit call-ins derived from SFM event forwards.
|
||||||
|
|
||||||
|
Architecture context: the live ACH replacement is on hold, so call-homes
|
||||||
|
arrive as Blastware ACH event files forwarded by series3-watcher and
|
||||||
|
landed in the SFM events store. One event ≈ one call-in. This is the
|
||||||
|
forward-looking source of "recent call-ins" that will eventually replace
|
||||||
|
the heartbeat-based /recent-callins endpoint entirely.
|
||||||
|
|
||||||
|
Each row represents one event; multiple consecutive events from the same
|
||||||
|
serial are intentionally NOT collapsed — each one is a distinct call-home.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{SFM_BASE_URL}/db/events",
|
||||||
|
params={"limit": limit},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
log.warning("SFM /db/events failed for recent-event-callins: %s", e)
|
||||||
|
return {"call_ins": [], "total": 0, "error": str(e)}
|
||||||
|
|
||||||
|
events = payload.get("events", []) or []
|
||||||
|
|
||||||
|
# Bulk-resolve serials → roster (single query, no N+1)
|
||||||
|
serials = list({ev.get("serial") for ev in events if ev.get("serial")})
|
||||||
|
roster_map: Dict[str, RosterUnit] = {}
|
||||||
|
if serials:
|
||||||
|
roster_map = {
|
||||||
|
r.id: r
|
||||||
|
for r in db.query(RosterUnit).filter(RosterUnit.id.in_(serials)).all()
|
||||||
|
}
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
call_ins: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
for ev in events:
|
||||||
|
serial = ev.get("serial")
|
||||||
|
if not serial:
|
||||||
|
continue
|
||||||
|
|
||||||
|
roster = roster_map.get(serial)
|
||||||
|
|
||||||
|
# created_at = when SFM received the forward. Falls back to the event
|
||||||
|
# timestamp if the SFM payload didn't carry created_at (older rows).
|
||||||
|
created_at_str = ev.get("created_at") or ev.get("timestamp")
|
||||||
|
time_ago = "—"
|
||||||
|
if created_at_str:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(created_at_str.replace("Z", "+00:00"))
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
time_ago = _humanize_age((now - ts).total_seconds())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
call_ins.append({
|
||||||
|
"unit_id": serial,
|
||||||
|
"serial": serial,
|
||||||
|
"event_id": ev.get("id"),
|
||||||
|
"event_timestamp": ev.get("timestamp"),
|
||||||
|
"created_at": ev.get("created_at"),
|
||||||
|
"time_ago": time_ago,
|
||||||
|
"peak_vector_sum": ev.get("peak_vector_sum"),
|
||||||
|
"false_trigger": bool(ev.get("false_trigger")),
|
||||||
|
"sensor_location": ev.get("sensor_location") or "",
|
||||||
|
"project": ev.get("project") or "",
|
||||||
|
"device_type": roster.device_type if roster else "seismograph",
|
||||||
|
"in_roster": roster is not None,
|
||||||
|
"note": (roster.note if roster else "") or "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"call_ins": call_ins,
|
||||||
|
"total": len(call_ins),
|
||||||
|
"source": "sfm-events",
|
||||||
|
}
|
||||||
|
|||||||
+202
-130
@@ -29,7 +29,55 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
|
||||||
<!-- Fleet Summary Card -->
|
<!-- Recent Alerts Card (col 1) -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Call-Ins Card (cols 2-3, double-wide) -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 md:col-span-2 lg:col-span-2" id="recent-callins-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 hidden sm:inline">from SFM event forwards</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-seismo-burgundy" 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">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" id="recent-callins-content">
|
||||||
|
<div id="recent-callins-list" class="space-y-2">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||||
|
</div>
|
||||||
|
<a href="/sfm" class="block mt-3 text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||||
|
View all events →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fleet Summary Card (col 4) -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||||
@@ -121,74 +169,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Alerts Card -->
|
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Recently Called In Units Card -->
|
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-callins-card">
|
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<svg class="w-6 h-6 text-seismo-burgundy" 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">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-content" id="recent-callins-content">
|
|
||||||
<div id="recent-callins-list" class="space-y-2">
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
|
||||||
</div>
|
|
||||||
<button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
|
||||||
Show all recent call-ins
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Today's Scheduled Actions Card -->
|
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="todays-actions-card">
|
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('todays-actions')">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="todays-actions-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-content" id="todays-actions-content"
|
|
||||||
hx-get="/dashboard/todays-actions"
|
|
||||||
hx-trigger="load, every 30s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dashboard Filters -->
|
<!-- Dashboard Filters -->
|
||||||
@@ -269,6 +249,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Today's Schedule — horizontal collapsible card.
|
||||||
|
Default collapsed; auto-expands when an upcoming action is detected
|
||||||
|
(pending + scheduled within the next 4h). JS reads
|
||||||
|
data-has-upcoming on the inner partial after htmx swap. -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-4 mb-8" id="todays-actions-card">
|
||||||
|
<div class="flex items-center justify-between cursor-pointer" onclick="toggleTodaysSchedule()">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="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>
|
||||||
|
<h2 class="text-base font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
||||||
|
<span id="todays-actions-badge"
|
||||||
|
class="hidden text-xs font-medium px-2 py-0.5 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200">
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform collapsed" id="todays-actions-chevron"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="card-content collapsed mt-4" id="todays-actions-content"
|
||||||
|
hx-get="/dashboard/todays-actions"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-on::after-swap="onTodaysActionsSwap(this)">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Recent Photos Section -->
|
<!-- Recent Photos Section -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||||
@@ -364,6 +374,17 @@
|
|||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Today's Schedule — horizontal collapsible at all breakpoints. */
|
||||||
|
#todays-actions-content.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#todays-actions-chevron.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
#todays-actions-chevron {
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@@ -654,7 +675,8 @@ function toggleCard(cardName) {
|
|||||||
// Restore card states from localStorage on page load
|
// Restore card states from localStorage on page load
|
||||||
function restoreCardStates() {
|
function restoreCardStates() {
|
||||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'todays-actions', 'fleet-map', 'fleet-status'];
|
// Note: todays-actions has its own collapse handling (see toggleTodaysSchedule / onTodaysActionsSwap)
|
||||||
|
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
||||||
|
|
||||||
cardNames.forEach(cardName => {
|
cardNames.forEach(cardName => {
|
||||||
const content = document.getElementById(`${cardName}-content`);
|
const content = document.getElementById(`${cardName}-content`);
|
||||||
@@ -839,89 +861,139 @@ async function loadRecentPhotos() {
|
|||||||
loadRecentPhotos();
|
loadRecentPhotos();
|
||||||
setInterval(loadRecentPhotos, 30000);
|
setInterval(loadRecentPhotos, 30000);
|
||||||
|
|
||||||
// Load and display recent call-ins
|
// Load and display recent call-ins.
|
||||||
let showingAllCallins = false;
|
// Source: SFM events (forwarded by series3-watcher from Blastware ACH).
|
||||||
const DEFAULT_CALLINS_DISPLAY = 5;
|
// Each event = one call-home. Heartbeat-derived endpoint /api/recent-callins
|
||||||
|
// is being phased out but kept as a backup.
|
||||||
async function loadRecentCallins() {
|
async function loadRecentCallins() {
|
||||||
|
const callinsList = document.getElementById('recent-callins-list');
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/recent-callins?hours=6');
|
const response = await fetch('/api/recent-event-callins?limit=10');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to load recent call-ins');
|
throw new Error('Failed to load recent call-ins');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const callinsList = document.getElementById('recent-callins-list');
|
|
||||||
const showAllButton = document.getElementById('show-all-callins');
|
|
||||||
|
|
||||||
if (data.call_ins && data.call_ins.length > 0) {
|
if (!data.call_ins || data.call_ins.length === 0) {
|
||||||
// Determine how many to show
|
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No recent event call-ins from SFM</p>';
|
||||||
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length);
|
return;
|
||||||
const callinsToDisplay = data.call_ins.slice(0, displayCount);
|
|
||||||
|
|
||||||
// Build HTML for call-ins list
|
|
||||||
let html = '';
|
|
||||||
callinsToDisplay.forEach(callin => {
|
|
||||||
// Status color
|
|
||||||
const statusColor = callin.status === 'OK' ? 'green' : callin.status === 'Pending' ? 'yellow' : 'red';
|
|
||||||
const statusClass = callin.status === 'OK' ? 'bg-green-500' : callin.status === 'Pending' ? 'bg-yellow-500' : 'bg-red-500';
|
|
||||||
|
|
||||||
// Build location/note line
|
|
||||||
let subtitle = '';
|
|
||||||
if (callin.location) {
|
|
||||||
subtitle = callin.location;
|
|
||||||
} else if (callin.note) {
|
|
||||||
subtitle = callin.note;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Two-column dense grid on lg+, single column below.
|
||||||
|
let html = '<div class="grid grid-cols-1 lg:grid-cols-2 gap-x-4 gap-y-1">';
|
||||||
|
data.call_ins.forEach(c => {
|
||||||
|
const isFalse = c.false_trigger;
|
||||||
|
const pvs = c.peak_vector_sum;
|
||||||
|
const pvsStr = (pvs !== null && pvs !== undefined)
|
||||||
|
? Number(pvs).toFixed(3) + ' in/s'
|
||||||
|
: '—';
|
||||||
|
|
||||||
|
// Subtitle: prefer sensor_location, fallback to project.
|
||||||
|
const subtitle = c.sensor_location || c.project || '';
|
||||||
|
|
||||||
|
// Status dot: amber for false trigger, green for real event,
|
||||||
|
// gray if unit not in roster.
|
||||||
|
const dotClass = !c.in_roster
|
||||||
|
? 'bg-gray-400'
|
||||||
|
: (isFalse ? 'bg-amber-400' : 'bg-green-500');
|
||||||
|
|
||||||
|
// Format event timestamp short (e.g. "05-13 05:00").
|
||||||
|
let tsShort = '';
|
||||||
|
if (c.event_timestamp) {
|
||||||
|
const ts = c.event_timestamp.replace('T', ' ');
|
||||||
|
// "2026-05-13 05:00:13" → "05-13 05:00"
|
||||||
|
tsShort = ts.length >= 16 ? ts.slice(5, 16) : ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitLink = c.in_roster
|
||||||
|
? `<a href="/unit/${c.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">${c.unit_id}</a>`
|
||||||
|
: `<span class="font-medium text-gray-500 dark:text-gray-400" title="Not in roster">${c.unit_id}</span>`;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
<div class="flex items-center justify-between py-1.5 border-b border-gray-100 dark:border-gray-700/50 last:border-0">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<span class="w-2 h-2 rounded-full ${statusClass}"></span>
|
<span class="w-2 h-2 rounded-full ${dotClass} flex-shrink-0" title="${isFalse ? 'False trigger' : 'Event'}"></span>
|
||||||
<div>
|
<div class="min-w-0 flex-1">
|
||||||
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
<div class="flex items-center gap-2">
|
||||||
${callin.unit_id}
|
${unitLink}
|
||||||
</a>
|
${isFalse ? '<span class="text-[10px] uppercase tracking-wide text-amber-600 dark:text-amber-400">false</span>' : ''}
|
||||||
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''}
|
<span class="text-xs text-gray-500 dark:text-gray-400">${pvsStr}</span>
|
||||||
|
</div>
|
||||||
|
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400 truncate" title="${subtitle.replace(/"/g, '"')}">${subtitle}</p>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span>
|
<div class="text-right ml-2 flex-shrink-0">
|
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400 block">${c.time_ago}</span>
|
||||||
|
${tsShort ? `<span class="text-[10px] text-gray-400 dark:text-gray-500 block font-mono">${tsShort}</span>` : ''}
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
html += '</div>';
|
||||||
callinsList.innerHTML = html;
|
callinsList.innerHTML = html;
|
||||||
|
|
||||||
// Show/hide the "Show all" button
|
|
||||||
if (data.call_ins.length > DEFAULT_CALLINS_DISPLAY) {
|
|
||||||
showAllButton.classList.remove('hidden');
|
|
||||||
showAllButton.textContent = showingAllCallins
|
|
||||||
? `Show fewer (${DEFAULT_CALLINS_DISPLAY})`
|
|
||||||
: `Show all (${data.call_ins.length})`;
|
|
||||||
} else {
|
|
||||||
showAllButton.classList.add('hidden');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No units have called in within the past 6 hours</p>';
|
|
||||||
showAllButton.classList.add('hidden');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading recent call-ins:', error);
|
console.error('Error loading recent call-ins:', error);
|
||||||
document.getElementById('recent-callins-list').innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
callinsList.innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle show all/show fewer
|
// Load recent call-ins on page load and refresh every 30 seconds.
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const showAllButton = document.getElementById('show-all-callins');
|
|
||||||
showAllButton.addEventListener('click', function() {
|
|
||||||
showingAllCallins = !showingAllCallins;
|
|
||||||
loadRecentCallins();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load recent call-ins on page load and refresh every 30 seconds
|
|
||||||
loadRecentCallins();
|
loadRecentCallins();
|
||||||
setInterval(loadRecentCallins, 30000);
|
setInterval(loadRecentCallins, 30000);
|
||||||
|
|
||||||
|
// ===== Today's Schedule horizontal card =====
|
||||||
|
function toggleTodaysSchedule() {
|
||||||
|
const content = document.getElementById('todays-actions-content');
|
||||||
|
const chevron = document.getElementById('todays-actions-chevron');
|
||||||
|
if (!content || !chevron) return;
|
||||||
|
const isCollapsed = content.classList.toggle('collapsed');
|
||||||
|
chevron.classList.toggle('collapsed', isCollapsed);
|
||||||
|
// Remember the user's explicit choice so we don't fight them on the next
|
||||||
|
// 30s htmx refresh.
|
||||||
|
localStorage.setItem('todaysScheduleUserToggled', '1');
|
||||||
|
localStorage.setItem('todaysScheduleCollapsed', isCollapsed ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTodaysActionsSwap(el) {
|
||||||
|
// Read pending/total counts from the rendered partial to drive
|
||||||
|
// auto-expand + the header badge.
|
||||||
|
const badge = document.getElementById('todays-actions-badge');
|
||||||
|
const content = document.getElementById('todays-actions-content');
|
||||||
|
const chevron = document.getElementById('todays-actions-chevron');
|
||||||
|
if (!content || !chevron) return;
|
||||||
|
|
||||||
|
// Count yellow status indicators in the rendered partial as a proxy for
|
||||||
|
// "pending action present today".
|
||||||
|
const pendingDots = el.querySelectorAll('.bg-yellow-400').length;
|
||||||
|
const pendingTimes = el.querySelectorAll('.text-yellow-600').length;
|
||||||
|
const hasPending = pendingDots > 0 || pendingTimes > 0;
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
if (hasPending) {
|
||||||
|
const n = Math.max(pendingDots, pendingTimes);
|
||||||
|
badge.textContent = `${n} pending today`;
|
||||||
|
badge.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
badge.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-expand only if the user hasn't manually toggled this session AND
|
||||||
|
// there's something pending. Once the user collapses/expands manually,
|
||||||
|
// their preference sticks.
|
||||||
|
const userToggled = localStorage.getItem('todaysScheduleUserToggled') === '1';
|
||||||
|
if (!userToggled && hasPending) {
|
||||||
|
content.classList.remove('collapsed');
|
||||||
|
chevron.classList.remove('collapsed');
|
||||||
|
} else if (!userToggled && !hasPending) {
|
||||||
|
content.classList.add('collapsed');
|
||||||
|
chevron.classList.add('collapsed');
|
||||||
|
} else if (userToggled) {
|
||||||
|
const stored = localStorage.getItem('todaysScheduleCollapsed') === '1';
|
||||||
|
content.classList.toggle('collapsed', stored);
|
||||||
|
chevron.classList.toggle('collapsed', stored);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user