feat(app): implement comprehensive analytics and reporting features

Analytics Page:
- Implemented a new, dynamic analytics page with date filtering.
- Added KPI summary tiles for total, completed, and aborted swaps, average swap time, and station uptime.
- Included three interactive charts: Swap Activity Over Time, Hourly Swap Distribution, and Swap Abort Reasons.
- Added a Slot Utilization Heatmap for at-a-glance diagnostics.
- Styled charts for the dark theme and fixed axis scaling issues.

Station Selection Page:
- Enhanced the station selection page to display live daily swap statistics on each station card.

Backend API:
- Created a new '/api/analytics' endpoint to serve all KPI and chart data.
- Created a new '/api/stations/daily-stats' endpoint.

Fixes & Refactoring:
- Centralized all common header logic into common-header.js to resolve script conflicts.
- Improved dashboard WebSocket handler to correctly process partial data payloads.
main
Kirubakaran 2025-09-15 03:56:47 +05:30
parent f61af015ad
commit 7f0a93a80d
8 changed files with 1155 additions and 884 deletions

View File

@ -5,12 +5,12 @@ import json
import csv import csv
import io import io
import time import time
from datetime import datetime from datetime import datetime, timedelta
from flask import Flask, jsonify, request, Response from flask import Flask, jsonify, request, Response
from flask_socketio import SocketIO, join_room from flask_socketio import SocketIO, join_room
from flask_cors import CORS from flask_cors import CORS
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy import desc from sqlalchemy import desc, func, case
# Import your custom core modules and the new models # Import your custom core modules and the new models
from core.mqtt_client import MqttClient from core.mqtt_client import MqttClient
@ -268,6 +268,44 @@ def get_stations():
return jsonify({"error": f"Database query failed: {e}"}), 500 return jsonify({"error": f"Database query failed: {e}"}), 500
#--- Daily Stats Route ---
@app.route('/api/stations/daily-stats', methods=['GET'])
def get_all_station_stats():
"""
Calculates the swap statistics for today for all stations.
"""
try:
# --- CHANGE THESE TWO LINES ---
today_start = datetime.combine(datetime.now().date(), time.min) # Use local time
today_end = datetime.combine(datetime.now().date(), time.max) # Use local time
# This is an efficient query that groups by station_id and counts events in one go
stats = db.session.query(
MqttLog.station_id,
func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_START', 1))).label('total_starts'),
func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_ENDED', 1))).label('completed'),
func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_ABORTED', 1))).label('aborted')
).filter(
MqttLog.topic_type == 'EVENTS',
MqttLog.timestamp.between(today_start, today_end)
).group_by(MqttLog.station_id).all()
# Convert the list of tuples into a dictionary for easy lookup
stats_dict = {
station_id: {
"total_starts": total_starts,
"completed": completed,
"aborted": aborted
} for station_id, total_starts, completed, aborted in stats
}
return jsonify(stats_dict)
except Exception as e:
print(f"Error fetching daily stats: {e}")
return jsonify({"message": "Could not fetch daily station stats."}), 500
@app.route('/api/logs/recent/<string:station_id>', methods=['GET']) @app.route('/api/logs/recent/<string:station_id>', methods=['GET'])
def get_recent_logs(station_id): def get_recent_logs(station_id):
""" """
@ -292,6 +330,145 @@ def get_recent_logs(station_id):
return jsonify({"message": "Could not fetch recent logs."}), 500 return jsonify({"message": "Could not fetch recent logs."}), 500
# A helper dictionary to make abort reason labels more readable
ABORT_REASON_MAP = {
"ABORT_UNKNOWN": "Unknown",
"ABORT_BAT_EXIT_TIMEOUT": "Battery Exit Timeout",
"ABORT_BAT_ENTRY_TIMEOUT": "Battery Entry Timeout",
"ABORT_DOOR_CLOSE_TIMEOUT": "Door Close Timeout",
"ABORT_DOOR_OPEN_TIMEOUT": "Door Open Timeout",
"ABORT_INVALID_PARAM": "Invalid Parameter",
"ABORT_REMOTE_REQUESTED": "Remote Abort",
"ABORT_INVALID_BATTERY": "Invalid Battery"
}
#--- Analytics Route ---
@app.route('/api/analytics', methods=['GET'])
def get_analytics_data():
# 1. Get and validate request parameters (same as before)
station_id = request.args.get('station_id')
start_date_str = request.args.get('start_date')
end_date_str = request.args.get('end_date')
if not all([station_id, start_date_str, end_date_str]):
return jsonify({"message": "Missing required parameters."}), 400
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
start_datetime = datetime.combine(start_date, datetime.min.time())
end_datetime = datetime.combine(end_date, datetime.max.time())
except ValueError:
return jsonify({"message": "Invalid date format. Please use YYYY-MM-DD."}), 400
# 2. Query for EVENT logs (for swap calculations)
try:
event_logs = MqttLog.query.filter(
MqttLog.station_id == station_id,
MqttLog.topic_type == 'EVENTS',
MqttLog.timestamp.between(start_datetime, end_datetime)
).all()
except Exception as e:
return jsonify({"message": f"Could not query event logs: {e}"}), 500
# --- NEW: Query for PERIODIC logs (for uptime calculation) ---
try:
periodic_logs = MqttLog.query.filter(
MqttLog.station_id == station_id,
MqttLog.topic_type == 'PERIODIC',
MqttLog.timestamp.between(start_datetime, end_datetime)
).order_by(MqttLog.timestamp.asc()).all()
except Exception as e:
return jsonify({"message": f"Could not query periodic logs: {e}"}), 500
# 3. Process EVENT logs for swap KPIs and charts
total_swaps, completed_swaps, aborted_swaps = 0, 0, 0
completed_swap_times, daily_completed, daily_aborted, hourly_swaps, abort_reason_counts = [], {}, {}, [0] * 24, {}
slot_utilization_counts = {i: 0 for i in range(1, 10)} # For the heatmap
for log in event_logs:
# (This processing logic is unchanged)
event_type = log.payload.get('eventType')
log_date = log.timestamp.date()
log_hour = log.timestamp.hour
if event_type == 'EVENT_SWAP_START':
total_swaps += 1
hourly_swaps[log_hour] += 1
elif event_type == 'EVENT_SWAP_ENDED':
completed_swaps += 1
daily_completed[log_date] = daily_completed.get(log_date, 0) + 1
swap_time = log.payload.get('eventData', {}).get('swapTime')
if swap_time is not None:
completed_swap_times.append(swap_time)
elif event_type == 'EVENT_SWAP_ABORTED':
aborted_swaps += 1
daily_aborted[log_date] = daily_aborted.get(log_date, 0) + 1
reason = log.payload.get('eventData', {}).get('swapAbortReason', 'ABORT_UNKNOWN')
abort_reason_counts[reason] = abort_reason_counts.get(reason, 0) + 1
elif event_type == 'EVENT_BATTERY_EXIT':
slot_id = log.payload.get('eventData', {}).get('slotId')
if slot_id and slot_id in slot_utilization_counts:
slot_utilization_counts[slot_id] += 1
# --- NEW: 4. Calculate Station Uptime ---
total_period_seconds = (end_datetime - start_datetime).total_seconds()
total_downtime_seconds = 0
MAX_ONLINE_GAP_SECONDS = 30 # Assume offline if no message for over 30 seconds
if not periodic_logs:
total_downtime_seconds = total_period_seconds
else:
# Check gap from start time to first message
first_gap = (periodic_logs[0].timestamp - start_datetime).total_seconds()
if first_gap > MAX_ONLINE_GAP_SECONDS:
total_downtime_seconds += first_gap
# Check gaps between consecutive messages
for i in range(1, len(periodic_logs)):
gap = (periodic_logs[i].timestamp - periodic_logs[i-1].timestamp).total_seconds()
if gap > MAX_ONLINE_GAP_SECONDS:
total_downtime_seconds += gap
# Check gap from last message to end time
last_gap = (end_datetime - periodic_logs[-1].timestamp).total_seconds()
if last_gap > MAX_ONLINE_GAP_SECONDS:
total_downtime_seconds += last_gap
station_uptime = 100 * (1 - (total_downtime_seconds / total_period_seconds))
station_uptime = max(0, min(100, station_uptime)) # Ensure value is between 0 and 100
# 5. Prepare final data structures (KPI section is now updated)
avg_swap_time_seconds = sum(completed_swap_times) / len(completed_swap_times) if completed_swap_times else 0
kpi_data = {
"total_swaps": total_swaps, "completed_swaps": completed_swaps,
"aborted_swaps": aborted_swaps, "avg_swap_time_seconds": avg_swap_time_seconds,
"station_uptime": round(station_uptime, 2) # Add uptime to the KPI object
}
# (The rest of the chart data preparation is unchanged)
date_labels, completed_data, aborted_data = [], [], []
current_date = start_date
while current_date <= end_date:
date_labels.append(current_date.strftime('%b %d'))
completed_data.append(daily_completed.get(current_date, 0))
aborted_data.append(daily_aborted.get(current_date, 0))
current_date += timedelta(days=1)
swap_activity_data = {"labels": date_labels, "completed_data": completed_data, "aborted_data": aborted_data}
hourly_distribution_data = {"labels": [f"{h % 12 if h % 12 != 0 else 12} {'AM' if h < 12 else 'PM'}" for h in range(24)], "swap_data": hourly_swaps}
abort_reasons_data = {"labels": [ABORT_REASON_MAP.get(r, r) for r in abort_reason_counts.keys()], "reason_data": list(abort_reason_counts.values())}
slot_utilization_data = {"counts": [slot_utilization_counts[i] for i in range(1, 10)]} # Return counts as a simple list [_ , _, ...]
# 6. Combine all data and return
return jsonify({
"kpis": kpi_data,
"swap_activity": swap_activity_data,
"hourly_distribution": hourly_distribution_data,
"abort_reasons": abort_reasons_data,
"slot_utilization": slot_utilization_data # <-- ADD THIS NEW KEY
})
# --- CSV Export route (UPDATED) --- # --- CSV Export route (UPDATED) ---
def _format_periodic_row(payload, num_slots=9): def _format_periodic_row(payload, num_slots=9):
""" """

View File

@ -6,19 +6,18 @@
<title>Swap Station Analytics</title> <title>Swap Station Analytics</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="js/common-header.js"></script> <script src="js/common-header.js"></script>
<script src="js/auth-guard.js"></script> <script src="js/auth-guard.js"></script>
<!-- Inter + Tailwind -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script> <script>
tailwind.config = { tailwind.config = {
theme: { theme: {
@ -35,14 +34,21 @@
:root { color-scheme: dark; } :root { color-scheme: dark; }
html, body { height: 100%; } html, body { height: 100%; }
body { background:#0a0a0a; } body { background:#0a0a0a; }
/* soft background glow */
.bg-glow::before, .bg-glow::before,
.bg-glow::after { .bg-glow::after {
content:""; position:fixed; pointer-events:none; z-index:-1; border-radius:9999px; filter: blur(64px); content:""; position:fixed; pointer-events:none; z-index:-1; border-radius:9999px; filter: blur(64px);
} }
.bg-glow::before { width:34rem; height:34rem; left:-8rem; top:-8rem; background: rgba(16,185,129,.12); } .bg-glow::before { width:34rem; height:34rem; left:-8rem; top:-8rem; background: rgba(16,185,129,.12); }
.bg-glow::after { width:36rem; height:36rem; right:-10rem; bottom:-10rem; background: rgba(56,189,248,.10); } .bg-glow::after { width:36rem; height:36rem; right:-10rem; bottom:-10rem; background: rgba(56,189,248,.10); }
.glass { background: rgba(30,41,59,.45); border: 1px solid rgba(255,255,255,.10); border-radius: .9rem; backdrop-filter: saturate(150%) blur(12px); }
.tile { background: rgba(2,6,23,.45); border:1px solid rgba(255,255,255,.12); border-radius:.9rem; padding:1rem; }
.btn { font-weight:800; font-size:12px; padding:.5rem .7rem; border-radius:.6rem; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06); transition:.18s; }
.btn:hover { border-color: rgba(16,185,129,.45); background: rgba(16,185,129,.10); }
.cham_chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.cham_chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.cham_chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.cham_chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; } .badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem; .chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
@ -62,17 +68,17 @@
.cham_chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)} .cham_chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.glass { background: rgba(30,41,59,.45); border: 1px solid rgba(255,255,255,.10); border-radius: .9rem; backdrop-filter: saturate(150%) blur(12px); } .glass { background: rgba(30,41,59,.45); border: 1px solid rgba(255,255,255,.10); border-radius: .9rem; backdrop-filter: saturate(150%) blur(12px); }
.tile { background: rgba(2,6,23,.45); border:1px solid rgba(255,255,255,.12); border-radius:.9rem; padding:1rem; }
.btn { font-weight:800; font-size:12px; padding:.5rem .7rem; border-radius:.6rem; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06); transition:.18s; } .btn { font-weight:800; font-size:12px; padding:.5rem .7rem; border-radius:.6rem; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06); transition:.18s; }
.btn:hover { border-color: rgba(16,185,129,.45); background: rgba(16,185,129,.10); } .btn:hover { border-color: rgba(16,185,129,.45); background: rgba(16,185,129,.10); }
.btn-ghost { border-color: rgba(255,255,255,.10); background: rgba(255,255,255,.05); } .btn-danger { border-color: rgba(244,63,94,.35); background: rgba(244,63,94,.14); color: #fecaca; }
.btn-danger { border-color: rgba(244,63,94,.35); background: rgba(244,63,94,.14); color:#fecaca; }
.btn-danger:hover { background: rgba(244,63,94,.22); } .btn-danger:hover { background: rgba(244,63,94,.22); }
.btn-ghost { border-color: rgba(255,255,255,.10); background: rgba(255,255,255,.05); }
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; } .badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } .textarea {
width:100%; height:100%; background: linear-gradient(180deg, rgba(2,6,23,.55), rgba(2,6,23,.35));
/* mini bar chart bars */ border:1px dashed rgba(255,255,255,.14); border-radius:.75rem; padding:1rem; color:#d1d5db;
.bar { width: 10px; border-radius: 6px 6px 0 0; background: linear-gradient(180deg,#22c55e,#0ea5e9); } font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; resize:none; outline:none;
}
</style> </style>
</head> </head>
<body class="min-h-screen text-gray-100 bg-glow"> <body class="min-h-screen text-gray-100 bg-glow">
@ -113,11 +119,6 @@
<span id="device-id" class="font-semibold"></span> <span id="device-id" class="font-semibold"></span>
</span> </span>
<!-- <span class="badge border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span>
<span id="device-id"></span>
</span> -->
<span class="badge border-white/10 bg-white/5 text-slate-200"> <span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> <svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path> <circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
@ -135,9 +136,6 @@
</svg> </svg>
</button> </button>
<!-- <span id="backup-power-chip" class="cham_chip cham_chip-slate" title="Station power source" style="display: none;">On Backup</span> -->
<!-- <span class="chip chip-amber" title="Running on backup supply">On Backup</span> -->
<div class="hidden sm:block w-px h-5 bg-white/10"></div> <div class="hidden sm:block w-px h-5 bg-white/10"></div>
<button id="refreshBtn" class="btn btn-ghost !p-2"> <button id="refreshBtn" class="btn btn-ghost !p-2">
@ -161,7 +159,6 @@
</div> </div>
</div> </div>
<!-- Tabs -->
<div class="border-t border-white/10 bg-black/10"> <div class="border-t border-white/10 bg-black/10">
<nav class="mx-auto max-w-7.5xl px-3 sm:px-4 flex"> <nav class="mx-auto max-w-7.5xl px-3 sm:px-4 flex">
<a href="./dashboard.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Main</a> <a href="./dashboard.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Main</a>
@ -172,168 +169,81 @@
</header> </header>
<!-- CONTENT -->
<main class="relative z-10 mx-auto max-w-7.5xl px-3 sm:px-4 py-4 flex flex-col gap-4"> <main class="relative z-10 mx-auto max-w-7.5xl px-3 sm:px-4 py-4 flex flex-col gap-4">
<!-- Device + Date Range --> <section class="flex flex-wrap items-center gap-4">
<section class="flex flex-wrap items-center gap-2"> <div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-white/10 bg-white/5 text-slate-200"> <button data-range="today" class="date-range-btn btn btn-ghost">Today</button>
<span>Device ID:</span> <button data-range="7" class="date-range-btn btn btn-ghost">Last 7 Days</button>
<span id="device-id" class="font-bold mono"></span> <button data-range="30" class="date-range-btn btn btn-ghost">Last 30 Days</button>
</span> </div>
<div class="ml-auto flex items-center gap-2"> <div class="ml-auto flex items-center gap-2">
<input id="from" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60"> <input id="from" type="text" placeholder="Start Date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<span class="text-gray-500">to</span> <span class="text-gray-500">to</span>
<input id="to" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60"> <input id="to" type="text" placeholder="End Date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<button id="applyRange" class="btn">Apply</button> <button id="applyRange" class="btn">Apply</button>
</div> </div>
</section> </section>
<!-- Stat Tiles --> <section class="grid grid-cols-1 md:grid-cols-5 gap-3">
<section class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div class="tile"> <div class="tile">
<p class="text-xs text-gray-400">Total Swaps (Today)</p> <p class="text-xs text-gray-400">Total Swaps Initiated</p>
<p class="text-3xl font-extrabold">142</p> <p id="total-swaps" class="text-3xl font-extrabold text-sky-400">...</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Completed Swaps</p>
<p class="text-3xl font-extrabold text-emerald-400">
<span id="completed-swaps">...</span>
<span id="success-rate" class="text-sm font-bold text-gray-400 ml-1">(...%)</span>
</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Aborted Swaps</p>
<p class="text-3xl font-extrabold text-rose-400">
<span id="aborted-swaps">...</span>
<span id="abort-rate" class="text-sm font-bold text-gray-400 ml-1">(...%)</span>
</p>
</div> </div>
<div class="tile"> <div class="tile">
<p class="text-xs text-gray-400">Avg. Swap Time</p> <p class="text-xs text-gray-400">Avg. Swap Time</p>
<p class="text-3xl font-extrabold">2.1 <span class="text-lg font-bold text-gray-300">min</span></p> <p id="avg-swap-time" class="text-3xl font-extrabold">... <span class="text-lg font-bold text-gray-300">min</span></p>
</div> </div>
<div class="tile"> <div class="tile">
<p class="text-xs text-gray-400">Station Uptime</p> <p class="text-xs text-gray-400">Station Uptime</p>
<p class="text-3xl font-extrabold text-emerald-400">99.8%</p> <p id="station-uptime" class="text-3xl font-extrabold text-teal-400">... %</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Peak Hours</p>
<p class="text-3xl font-extrabold">57 PM</p>
</div> </div>
</section> </section>
<!-- Charts --> <section class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-[20rem]">
<!-- Weekly Swaps - CSS bars --> <div class="glass p-4 h-96">
<div class="glass p-4"> <h3 class="font-extrabold">Swap Activity Over Time</h3>
<div class="flex items-center justify-between"> <canvas id="swapActivityChart"></canvas>
<h3 class="font-extrabold">Swaps This Week</h3>
<span class="text-xs text-gray-400">Mon → Sun</span>
</div> </div>
<div class="mt-4 h-64 rounded-lg border border-white/10 bg-white/5 p-4 flex items-end gap-4 min-h-[20.8rem]"> <div class="glass p-4 h-96">
<!-- Each group: bar + label --> <h3 class="font-extrabold">Hourly Swap Distribution</h3>
<div class="flex flex-col items-center gap-2"> <canvas id="hourlyDistributionChart"></canvas>
<div class="bar" style="height:50%"></div><span class="text-xs text-gray-400">Mon</span>
</div> </div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:74%"></div><span class="text-xs text-gray-400">Tue</span> <div class="glass p-4 h-96">
</div> <h3 class="font-extrabold">Swap Abort Reasons</h3>
<div class="flex flex-col items-center gap-2"> <canvas id="abortReasonsChart"></canvas>
<div class="bar" style="height:60%"></div><span class="text-xs text-gray-400">Wed</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:85%"></div><span class="text-xs text-gray-400">Thu</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:92%"></div><span class="text-xs text-gray-400">Fri</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:42%"></div><span class="text-xs text-gray-400">Sat</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:30%"></div><span class="text-xs text-gray-400">Sun</span>
</div> </div>
<div class="glass p-4 h-96">
<h3 class="font-extrabold mb-4">Slot Utilization Heatmap</h3>
<div id="heatmap-grid" class="grid grid-cols-3 gap-4 h-[calc(100%-2rem)]">
</div> </div>
</div> </div>
<!-- Battery Health - donut style --> <!-- <div class="glass p-4 h-96 flex items-center justify-center">
<div class="glass p-4"> <p class="text-slate-500">Future Chart Area</p>
<h3 class="font-extrabold">Battery Health</h3> </div> -->
<div class="h-64 flex items-center justify-center">
<div class="relative w-52 h-52">
<svg class="w-full h-full" viewBox="0 0 36 36">
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none" stroke="#1f2937" stroke-width="3"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#ef4444" stroke-width="3" stroke-dasharray="20, 100"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#f59e0b" stroke-width="3" stroke-dasharray="30, 100"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#22c55e" stroke-width="3" stroke-dasharray="50, 100"/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-2xl font-extrabold">250</span>
<span class="text-xs text-gray-400">Total Batteries</span>
</div>
</div>
</div>
<div class="mt-3 grid grid-cols-3 gap-2 text-xs">
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-emerald-400"></span><span>Good</span></div>
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-amber-400"></span><span>Warning</span></div>
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-rose-400"></span><span>Poor</span></div>
</div>
</div>
</section> </section>
</main> </main>
<script>
// Fill device id from selected station (if stored by station_selection)
document.addEventListener('DOMContentLoaded', () => {
// Replace 'VEC-STN-0128' with the actual ID of the station you want to load
const stationId = 'VEC-STN-0128';
loadStationInfo(stationId);
});
// This function fetches data from your backend and updates the page
async function loadStationInfo(stationId) {
// Find the HTML elements by their IDs
const nameElement = document.getElementById('station-name');
const locationElement = document.getElementById('station-location');
try {
// 1. Fetch data from your backend API endpoint
// You must replace this URL with your actual API endpoint
const response = await fetch(`/api/stations/${stationId}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
// 2. Convert the response into JSON format
// Example JSON: { "name": "VEC-STN-0128", "location": "Sector 62, Noida" }
const stationData = await response.json();
// 3. Update the HTML content with the data from the database
nameElement.textContent = stationData.name;
locationElement.textContent = stationData.location;
} catch (error) {
// If something goes wrong, show an error message
nameElement.textContent = 'Error Loading Station';
locationElement.textContent = 'Could not fetch data.';
console.error('Error fetching station data:', error);
}
}
// Demo "last recv" timestamp
document.querySelector('#last-update-status').textContent =
'Last Recv ' + new Date().toLocaleString();
// Actions
document.querySelector('#logout-btn')?.addEventListener('click', () => {
window.location.href = './index.html';
});
document.querySelector('#refreshBtn')?.addEventListener('click', () => location.reload());
// Date range apply (wire to backend later)
document.querySelector('#applyRange')?.addEventListener('click', () => {
const f = document.querySelector('#from').value;
const t = document.querySelector('#to').value;
if (!f || !t) return alert('Choose a date range first.');
alert(`Apply analytics range:\n${f} → ${t}`);
});
</script>
<script src="./js/analytics.js"></script> <script src="./js/analytics.js"></script>
</body> </body>

View File

@ -0,0 +1,340 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Analytics</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="js/common-header.js"></script>
<script src="js/auth-guard.js"></script>
<!-- Inter + Tailwind -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ["Inter","ui-sans-serif","system-ui"] },
keyframes: { pulseDot: { "0%,100%": { transform:"scale(1)", opacity:1 }, "50%": { transform:"scale(1.2)", opacity:.7 } } },
animation: { pulseDot: "pulseDot 1.2s ease-in-out infinite" }
}
}
}
</script>
<style>
:root { color-scheme: dark; }
html, body { height: 100%; }
body { background:#0a0a0a; }
/* soft background glow */
.bg-glow::before,
.bg-glow::after {
content:""; position:fixed; pointer-events:none; z-index:-1; border-radius:9999px; filter: blur(64px);
}
.bg-glow::before { width:34rem; height:34rem; left:-8rem; top:-8rem; background: rgba(16,185,129,.12); }
.bg-glow::after { width:36rem; height:36rem; right:-10rem; bottom:-10rem; background: rgba(56,189,248,.10); }
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)}
.chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.cham_chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.cham_chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.cham_chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.cham_chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.cham_chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)}
.cham_chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.glass { background: rgba(30,41,59,.45); border: 1px solid rgba(255,255,255,.10); border-radius: .9rem; backdrop-filter: saturate(150%) blur(12px); }
.tile { background: rgba(2,6,23,.45); border:1px solid rgba(255,255,255,.12); border-radius:.9rem; padding:1rem; }
.btn { font-weight:800; font-size:12px; padding:.5rem .7rem; border-radius:.6rem; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06); transition:.18s; }
.btn:hover { border-color: rgba(16,185,129,.45); background: rgba(16,185,129,.10); }
.btn-ghost { border-color: rgba(255,255,255,.10); background: rgba(255,255,255,.05); }
.btn-danger { border-color: rgba(244,63,94,.35); background: rgba(244,63,94,.14); color:#fecaca; }
.btn-danger:hover { background: rgba(244,63,94,.22); }
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
/* mini bar chart bars */
.bar { width: 10px; border-radius: 6px 6px 0 0; background: linear-gradient(180deg,#22c55e,#0ea5e9); }
</style>
</head>
<body class="min-h-screen text-gray-100 bg-glow">
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur">
<div class="mx-auto max-w-7.5xl px-3 sm:px-4 py-2 grid grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex items-center gap-2 sm:gap-3">
<a href="./station_selection.html"
class="h-9 w-9 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 transition"
title="Back">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<div class="flex flex-col leading-tight">
<div id="station-name" class="text-base sm:text-lg font-extrabold tracking-tight">
Loading...
</div>
<div id="station-location" class="text-xs sm:text-sm text-slate-100">
&nbsp; </div>
</div>
</div>
<div class="flex items-center justify-center scale-100">
<img src="./assets/vec_logo.png" alt="VECMOCON"
class="h-7 w-auto opacity-90" onerror="this.style.display='none'"/>
</div>
<div class="flex items-center flex-wrap justify-end gap-1.5 sm:gap-2">
<span class="badge border-white/10 bg-white/5 text-slate-200">
<span>Product ID:</span>
<span id="product-id" class="font-semibold"></span>
</span>
<span class="badge border-white/10 bg-white/5 text-slate-200">
<span>Station ID:</span>
<span id="device-id" class="font-semibold"></span>
</span>
<!-- <span class="badge border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span>
<span id="device-id"></span>
</span> -->
<span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg>
<span id="last-update-status">Waiting...</span>
</span>
<span id="connection-status-chip" class="cham_chip cham_chip-amber" title="Connection to backend">
<span class="h-2 w-2 rounded-full bg-amber-400"></span> Connecting...
</span>
<button id="station-reset-btn" class="btn btn-danger !p-2" title="Station Reset">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg>
</button>
<!-- <span id="backup-power-chip" class="cham_chip cham_chip-slate" title="Station power source" style="display: none;">On Backup</span> -->
<!-- <span class="chip chip-amber" title="Running on backup supply">On Backup</span> -->
<div class="hidden sm:block w-px h-5 bg-white/10"></div>
<button id="refreshBtn" class="btn btn-ghost !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M21 12a9 9 0 10-3.5 7.1M21 12h-4m4 0l-2.5-2.5"></path>
</svg>
</button>
<button id="downloadBtn" class="btn btn-ghost !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M12 3v12m0 0l4-4m-4 4l-4-4"></path><path d="M5 21h14"></path>
</svg>
</button>
<button id="logout-btn" class="btn btn-danger !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<path d="M16 17l5-5-5-5"></path><path d="M21 12H9"></path>
</svg>
</button>
</div>
</div>
<!-- Tabs -->
<div class="border-t border-white/10 bg-black/10">
<nav class="mx-auto max-w-7.5xl px-3 sm:px-4 flex">
<a href="./dashboard.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Main</a>
<a href="./logs.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Logs</a>
<a href="./analytics.html" class="px-4 py-2 text-sm font-semibold border-b-2 border-emerald-400/70 text-white">Analytics</a>
</nav>
</div>
</header>
<!-- CONTENT -->
<main class="relative z-10 mx-auto max-w-7.5xl px-3 sm:px-4 py-4 flex flex-col gap-4">
<!-- Device + Date Range -->
<section class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span>
<span id="device-id" class="font-bold mono"></span>
</span>
<div class="ml-auto flex items-center gap-2">
<input id="from" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<span class="text-gray-500">to</span>
<input id="to" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<button id="applyRange" class="btn">Apply</button>
</div>
</section>
<!-- Stat Tiles -->
<section class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div class="tile">
<p class="text-xs text-gray-400">Total Swaps (Today)</p>
<p class="text-3xl font-extrabold">142</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Avg. Swap Time</p>
<p class="text-3xl font-extrabold">2.1 <span class="text-lg font-bold text-gray-300">min</span></p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Station Uptime</p>
<p class="text-3xl font-extrabold text-emerald-400">99.8%</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Peak Hours</p>
<p class="text-3xl font-extrabold">57 PM</p>
</div>
</section>
<!-- Charts -->
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-[20rem]">
<!-- Weekly Swaps - CSS bars -->
<div class="glass p-4">
<div class="flex items-center justify-between">
<h3 class="font-extrabold">Swaps This Week</h3>
<span class="text-xs text-gray-400">Mon → Sun</span>
</div>
<div class="mt-4 h-64 rounded-lg border border-white/10 bg-white/5 p-4 flex items-end gap-4 min-h-[20.8rem]">
<!-- Each group: bar + label -->
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:50%"></div><span class="text-xs text-gray-400">Mon</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:74%"></div><span class="text-xs text-gray-400">Tue</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:60%"></div><span class="text-xs text-gray-400">Wed</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:85%"></div><span class="text-xs text-gray-400">Thu</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:92%"></div><span class="text-xs text-gray-400">Fri</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:42%"></div><span class="text-xs text-gray-400">Sat</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:30%"></div><span class="text-xs text-gray-400">Sun</span>
</div>
</div>
</div>
<!-- Battery Health - donut style -->
<div class="glass p-4">
<h3 class="font-extrabold">Battery Health</h3>
<div class="h-64 flex items-center justify-center">
<div class="relative w-52 h-52">
<svg class="w-full h-full" viewBox="0 0 36 36">
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none" stroke="#1f2937" stroke-width="3"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#ef4444" stroke-width="3" stroke-dasharray="20, 100"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#f59e0b" stroke-width="3" stroke-dasharray="30, 100"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#22c55e" stroke-width="3" stroke-dasharray="50, 100"/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-2xl font-extrabold">250</span>
<span class="text-xs text-gray-400">Total Batteries</span>
</div>
</div>
</div>
<div class="mt-3 grid grid-cols-3 gap-2 text-xs">
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-emerald-400"></span><span>Good</span></div>
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-amber-400"></span><span>Warning</span></div>
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-rose-400"></span><span>Poor</span></div>
</div>
</div>
</section>
</main>
<script>
// Fill device id from selected station (if stored by station_selection)
document.addEventListener('DOMContentLoaded', () => {
// Replace 'VEC-STN-0128' with the actual ID of the station you want to load
const stationId = 'VEC-STN-0128';
loadStationInfo(stationId);
});
// This function fetches data from your backend and updates the page
async function loadStationInfo(stationId) {
// Find the HTML elements by their IDs
const nameElement = document.getElementById('station-name');
const locationElement = document.getElementById('station-location');
try {
// 1. Fetch data from your backend API endpoint
// You must replace this URL with your actual API endpoint
const response = await fetch(`/api/stations/${stationId}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
// 2. Convert the response into JSON format
// Example JSON: { "name": "VEC-STN-0128", "location": "Sector 62, Noida" }
const stationData = await response.json();
// 3. Update the HTML content with the data from the database
nameElement.textContent = stationData.name;
locationElement.textContent = stationData.location;
} catch (error) {
// If something goes wrong, show an error message
nameElement.textContent = 'Loading...';
// locationElement.textContent = 'Could not fetch data.';
console.error('Error fetching station data:', error);
}
}
// Demo "last recv" timestamp
document.querySelector('#last-update-status').textContent =
'Last Recv ' + new Date().toLocaleString();
// Actions
document.querySelector('#logout-btn')?.addEventListener('click', () => {
window.location.href = './index.html';
});
document.querySelector('#refreshBtn')?.addEventListener('click', () => location.reload());
// Date range apply (wire to backend later)
document.querySelector('#applyRange')?.addEventListener('click', () => {
const f = document.querySelector('#from').value;
const t = document.querySelector('#to').value;
if (!f || !t) return alert('Choose a date range first.');
alert(`Apply analytics range:\n${f} → ${t}`);
});
</script>
<script src="./js/analytics.js"></script>
</body>
</html>

View File

@ -200,7 +200,7 @@
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> <svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path> <circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg> </svg>
<span id="last-update-status">Waiting...</span> <span id="last-update-status">Waiting for data...</span>
</span> </span>
<span id="connection-status-chip" class="cham_chip cham_chip-amber" title="Connection to backend"> <span id="connection-status-chip" class="cham_chip cham_chip-amber" title="Connection to backend">

View File

@ -1,434 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ["Inter","ui-sans-serif","system-ui"] },
keyframes: {
pulseDot: { "0%,100%": { transform:"scale(1)", opacity: 1 }, "50%": { transform:"scale(1.2)", opacity: .7 } }
},
animation: { pulseDot: "pulseDot 1.2s ease-in-out infinite" }
}
}
}
</script>
<style>
:root { color-scheme: dark; }
html, body { height: 100%; margin: 0; }
body { background:#0a0a0a; }
.page { max-width:1400px; }
.glass {
background: rgba(30,41,59,.45);
border: 1px solid rgba(255,255,255,.10);
border-radius: .9rem;
backdrop-filter: saturate(150%) blur(12px);
}
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.1rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)}
.chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.cham_chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.cham_chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.cham_chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.cham_chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.cham_chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)}
.cham_chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.btn { font-weight:700; font-size:10px; padding: 0.15rem 0.5rem; border-radius:.5rem; border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06); transition:.18s; }
.btn:hover { border-color: rgba(16,185,129,.45); background: rgba(16,185,129,.10); }
.btn-primary{background-image:linear-gradient(to right,#10b981,#14b8a6,#06b6d4);color:#fff;border-color:transparent}
.btn-primary:hover{filter:brightness(1.05);transform:translateY(-1px)}
.btn-danger { border-color: rgba(244,63,94,.35); background: rgba(244,63,94,.14); color: #fecaca; }
.btn-danger:hover { background: rgba(244,63,94,.22); }
.btn-ghost { border-color: rgba(255,255,255,.10); background: rgba(255,255,255,.05); }
.field{font-size:10px;color:#9ca3af}
.value{font-size:12px;font-weight:600;color:#e5e7eb}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
.chamber-card.paired{border-color:#34d399!important;box-shadow:0 0 0 2px rgba(52,211,153,.45)}
.chamber-card.pending{border-color:#60a5fa!important;box-shadow:0 0 0 2px rgba(96,165,250,.45)}
.door-pill{color:#fff;font-size:10px;font-weight:700;padding:4px;border-radius:6px; width: 100%; text-align: center;}
.door-open{background:#22c55e}.door-close{background:#ef4444}
.bat-id-big{font-size:14px;font-weight:800;border-radius:.5rem;padding:.2rem .4rem;
background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.10)}
</style>
</head>
<body class="min-h-screen text-gray-100 flex flex-col">
<div class="pointer-events-none fixed inset-0">
<div class="absolute -top-24 -left-24 w-[32rem] h-[32rem] rounded-full bg-emerald-500/10 blur-3xl"></div>
<div class="absolute -bottom-24 -right-24 w-[36rem] h-[36rem] rounded-full bg-sky-500/10 blur-3xl"></div>
</div>
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur">
<div class="mx-auto max-w-7.5xl px-3 sm:px-4 py-2 grid grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex items-center gap-2 sm:gap-3">
<a href="./station_selection.html"
class="h-9 w-9 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 transition"
title="Back">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<div class="flex flex-col leading-tight">
<div id="station-name" class="text-base sm:text-lg font-extrabold tracking-tight">
Loading...
</div>
<div id="station-location" class="text-xs sm:text-sm text-slate-100">
&nbsp; </div>
</div>
</div>
<div class="flex items-center justify-center">
<img src="./assets/vec_logo.png" alt="VECMOCON"
class="h-7 w-auto opacity-90" onerror="this.style.display='none'"/>
</div>
<div class="flex items-center flex-wrap justify-end gap-1.5 sm:gap-2">
<span class="badge border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span>
<span id="device-id"></span>
</span>
<span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg>
<span id="last-update-status">Last Recv —</span>
</span>
<span class="cham_chip cham_chip-emerald">
<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online
</span>
<span class="cham_chip cham_chip-amber" title="Running on backup supply">On Backup</span>
<div class="hidden sm:block w-px h-5 bg-white/10"></div>
<button id="refreshBtn" class="btn btn-ghost !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M21 12a9 9 0 10-3.5 7.1M21 12h-4m4 0l-2.5-2.5"></path>
</svg>
</button>
<button id="downloadBtn" class="btn btn-ghost !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M12 3v12m0 0l4-4m-4 4l-4-4"></path><path d="M5 21h14"></path>
</svg>
</button>
<button id="logout-btn" class="btn btn-danger !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<path d="M16 17l5-5-5-5"></path><path d="M21 12H9"></path>
</svg>
</button>
</div>
</div>
<div class="border-t border-white/10 bg-black/10">
<nav class="mx-auto max-w-7.5xl px-3 sm:px-4 flex">
<a href="./dashboard.html" class="px-4 py-2 text-sm font-semibold border-b-2 border-emerald-400/70 text-white">Main</a>
<a href="./logs.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Logs</a>
<a href="./analytics.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Analytics</a>
</nav>
</div>
</header>
<main class="relative z-10 flex-1 w-full px-3 py-3 overflow-y-auto lg:overflow-hidden">
<div class="page mx-auto flex flex-col lg:h-full lg:flex-row gap-3">
<section id="chambersGrid" class="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 lg:grid-rows-3 gap-3"></section>
<aside class="w-full lg:w-96 lg:shrink-0 flex flex-col gap-3 overflow-y-auto">
<section class="glass p-4">
<div class="flex items-center justify-between mb-3">
<span class="text-xm font-bold mb-2">System Diagnostics Code</span>
<span class="text-sm font-bold text-emerald-300">148</span>
</div>
<div class="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
<div class="text-rose-300 text-center">Lock Power Cut</div><div class="text-rose-300 text-center">Main Power Cut</div>
<div class="text-rose-300 text-center">Relayboard CAN</div><div class="text-rose-300 text-center">DB CAN Recv</div>
<div class="text-rose-300 text-center">Smoke Alarm</div><div class="text-rose-300 text-center">Water Alarm</div>
<div class="text-rose-300 text-center">Phase Failure</div><div class="text-rose-300 text-center">Earth Leakage</div>
</div>
<button id="station-reset-btn" class="btn btn-danger w-full mt-3 !py-2">Station Reset</button>
</section>
<section id="swap-panel" class="glass p-4 flex flex-col min-h-[220px]">
<h3 class="text-sm font-bold mb-2">Swap Process</h3>
<div id="swap-pairs-list" class="flex-1 flex flex-wrap gap-2 content-center justify-center">
<p id="swap-idle-text" class="w-full text-sm text-center text-gray-400">
Click a <span class="text-sky-300 font-semibold">empty</span> slot, then an <span class="text-emerald-300 font-semibold">full</span> slot.
</p>
</div>
<div class="mt-3">
<div class="grid grid-cols-2 gap-2">
<button id="start-swap-btn" class="btn btn-primary !py-2" disabled>Start Swaps</button>
<button id="abort-swap-btn" class="btn btn-danger !py-2">Abort Swap</button>
</div>
<button id="clear-swap-btn" class="btn w-full mt-2 !py-2">Clear Selection</button>
</div>
</section>
<section class="glass p-4">
<h3 class="text-sm font-bold mb-2">Audio Command</h3>
<select class="w-full rounded-md bg-white/5 border border-white/10 px-2 py-2 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<option>English</option><option>Hindi</option><option>Tamil</option>
</select>
</section>
<section class="glass p-4 flex-1 flex flex-col min-h-[610px]">
<h3 class="text-sm font-bold mb-2">Instance Log</h3>
<textarea class="flex-1 bg-black/20 border border-white/10 rounded-md p-2 text-xs font-mono resize-none" readonly>[--:--:--] Waiting for data…</textarea>
</section>
</aside>
</div>
</main>
<template id="chamberTemplate">
<div class="chamber-card relative glass rounded-xl p-2 flex flex-col transition border border-white/20">
<div class="text-center absolute left-0 right-0 top-0 -translate-y-1/2">
<span class="bg-slate-800 px-2 text-xs font-extrabold text-gray-200 tracking-wide rounded">
CHAMBER <span class="slotNo">#</span>
</span>
</div>
<div class="mt-2 mb-1.5">
<div class="flex items-center gap-2">
<h4 class="field font-bold text-gray-300 shrink-0">BAT_ID</h4>
<div class="bat-id-big mono truncate flex-1 text-left" title="—"></div>
</div>
</div>
<div class="flex-1 flex gap-2">
<div class="flex-1 space-y-0.5">
<div class="flex items-center justify-between border-b border-white/10 pb-0.5 mb-1">
<h4 class="field font-bold text-gray-300">BATTERY</h4>
<span class="battery-status-pill chip chip-slate"></span>
</div>
<div class="flex justify-between items-baseline"><span class="field">SOC</span><span class="value soc"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Voltage</span><span class="value voltage"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Bat Temp</span><span class="value bat-temp"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Bat Fault</span><span class="value bat-fault text-rose-300"></span></div>
</div>
<div class="flex-1 space-y-0.5 border-l border-white/10 pl-2">
<div class="flex items-center justify-between border-b border-white/10 pb-0.5 mb-1">
<h4 class="field font-bold text-gray-300">CHARGER</h4>
<span class="charger-status-pill chip chip-slate"></span>
</div>
<div class="flex justify-between items-baseline"><span class="field">Current</span><span class="value current"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Slot Temp</span><span class="value slot-temp"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Chg Temp</span><span class="value chg-temp"></span></div>
<div class="flex justify-between items-baseline"><span class="field">Chg Fault</span><span class="value chg-fault text-rose-300"></span></div>
</div>
</div>
<div class="grid grid-cols-4 gap-1.5 mt-2 shrink-0">
<div class="door-pill door-close">CLOSED</div>
<button class="btn">OPEN</button>
<button class="btn">CHG ON</button>
<button class="btn">CHG OFF</button>
</div>
</div>
</template>
<script>
// SCRIPT IS UNCHANGED
const qs = (s) => document.querySelector(s);
(function setDevice() {
const el = document.querySelector('#device-id');
if (!el) return;
try {
const sel = JSON.parse(localStorage.getItem('selected_station') || '{}');
el.textContent = sel?.id || sel?.station_id || '—';
} catch {
el.textContent = '—';
}
})();
document.addEventListener('DOMContentLoaded', () => {
const stationId = 'VEC-STN-0128';
loadStationInfo(stationId);
});
async function loadStationInfo(stationId) {
const nameElement = document.getElementById('station-name');
const locationElement = document.getElementById('station-location');
try {
const mockStationData = { name: "VEC-STN-0128", location: "Sector 62, Noida" };
nameElement.textContent = mockStationData.name;
locationElement.textContent = mockStationData.location;
} catch (error) {
nameElement.textContent = 'Error Loading Station';
locationElement.textContent = 'Could not fetch data.';
console.error('Error fetching station data:', error);
}
}
(function setLastRecv() {
const el = qs('#last-update-status');
if (el) {
// Handle both badge and text styles
const textContent = 'Last Recv ' + new Date().toLocaleTimeString();
if (el.tagName === 'SPAN') {
el.textContent = textContent;
} else {
// Fallback for different structures if needed
el.innerHTML = textContent;
}
}
})();
qs('#refreshBtn')?.addEventListener('click', () => location.reload());
qs('#downloadBtn')?.addEventListener('click', () => {
alert('Hook this to your /api/logs/export endpoint.');
});
qs('#logout-btn')?.addEventListener('click', () => {
window.location.href = './index.html';
});
const grid = document.getElementById('chambersGrid');
const tmpl = document.getElementById('chamberTemplate');
grid.innerHTML = '';
for (let i = 1; i <= 9; i++) {
const node = tmpl.content.cloneNode(true);
const card = node.querySelector('.chamber-card');
card.dataset.chamberId = i;
card.querySelector('.slotNo').textContent = i;
const batIdBig = node.querySelector('.bat-id-big');
const batteryStatus = node.querySelector('.battery-status-pill');
const chargerStatus = node.querySelector('.charger-status-pill');
const socEl = node.querySelector('.soc');
const voltageEl = node.querySelector('.voltage');
const batFaultEl = node.querySelector('.bat-fault');
const chgFaultEl = node.querySelector('.chg-fault');
const batTempEl = node.querySelector('.bat-temp');
const currentEl = node.querySelector('.current');
const slotTempEl = node.querySelector('.slot-temp');
const chgTempEl = node.querySelector('.chg-temp');
const doorPill = node.querySelector('.door-pill');
const present = i % 3 !== 0;
batIdBig.textContent = present ? `TK${510000 + i}X00${200 + i}` : '—';
if (present) {
batteryStatus.textContent = 'Present';
batteryStatus.className = 'battery-status-pill chip chip-emerald';
chargerStatus.textContent = 'Charging';
chargerStatus.className = 'charger-status-pill chip chip-sky';
socEl.textContent = `${Math.max(20, 96 - i*3)}%`;
voltageEl.textContent = `5${i}.2 V`;
batTempEl.textContent = `${25 + i}.0 °C`;
currentEl.textContent = '10.5 A';
slotTempEl.textContent = `${28 + i}.0 °C`;
chgTempEl.textContent = `${35 + i}.0 °C`;
if (i === 5) batFaultEl.textContent = 'OVERHEAT';
} else {
batteryStatus.textContent = 'Absent';
batteryStatus.className = 'battery-status-pill chip chip-rose';
chargerStatus.textContent = 'Idle';
chargerStatus.className = 'charger-status-pill chip chip-slate';
chgFaultEl.textContent = (i === 6) ? 'COMM_FAIL' : '—';
}
card.querySelectorAll('.btn').forEach(b => b.addEventListener('click', e => e.stopPropagation()));
grid.appendChild(node);
}
const swapIdleText = document.getElementById('swap-idle-text');
const swapPairsList = document.getElementById('swap-pairs-list');
const startSwapBtn = document.getElementById('start-swap-btn');
const abortSwapBtn = document.getElementById('abort-swap-btn');
const clearSwapBtn = document.getElementById('clear-swap-btn');
const resetBtn = document.getElementById('station-reset-btn');
let currentPair = [], swapPairs = [];
function updateSwapUI(){
const isBuilding = currentPair.length || swapPairs.length;
if (!swapIdleText) return;
swapIdleText.classList.toggle('hidden', isBuilding);
startSwapBtn.disabled = swapPairs.length === 0;
const pairedOut = swapPairs.map(p=>p[0]), pairedIn = swapPairs.map(p=>p[1]);
document.querySelectorAll('.chamber-card').forEach(card=>{
const n = +card.dataset.chamberId;
card.classList.remove('paired','pending');
if (pairedOut.includes(n) || pairedIn.includes(n)) card.classList.add('paired');
else if (currentPair.includes(n)) card.classList.add('pending');
});
swapPairsList.innerHTML = isBuilding ? '' : swapIdleText.outerHTML;
if (isBuilding){
swapPairs.forEach(p=>{
const e = document.createElement('div');
e.className = 'text-sm font-semibold flex items-center justify-center gap-2 bg-black/20 rounded p-1.5';
e.innerHTML = `<span class="text-emerald-300">${p[0]}</span><span></span><span class="text-sky-300">${p[1]}</span>`;
swapPairsList.appendChild(e);
});
if (currentPair.length){
const e = document.createElement('div');
e.className = 'text-sm font-semibold flex items-center justify-center gap-2 bg-black/20 rounded p-1.5 ring-2 ring-sky-500';
e.innerHTML = `<span class="text-emerald-300 font-bold">${currentPair[0]}</span><span></span><span class="text-gray-400">?</span>`;
swapPairsList.appendChild(e);
}
}
}
function handleChamberClick(num){
if (swapPairs.length >= 4 && currentPair.length === 0) return alert('Maximum of 4 swap pairs reached.');
const usedOut = swapPairs.map(p=>p[0]), usedIn = swapPairs.map(p=>p[1]);
if ((currentPair.length === 0 && usedOut.includes(num)) ||
(currentPair.length === 1 && usedIn.includes(num)) ||
(currentPair.length === 1 && currentPair[0] === num)) return;
currentPair.push(num);
if (currentPair.length === 2){ swapPairs.push([...currentPair]); currentPair = []; }
updateSwapUI();
}
function clearSelection(){ currentPair=[]; swapPairs=[]; updateSwapUI(); }
document.querySelectorAll('.chamber-card').forEach(c =>
c.addEventListener('click', () => handleChamberClick(+c.dataset.chamberId))
);
clearSwapBtn?.addEventListener('click', clearSelection);
startSwapBtn?.addEventListener('click', () => {
if (swapPairs.length){ alert('Executing swaps:\n'+swapPairs.map(p=>`${p[0]} → ${p[1]}`).join('\n')); clearSelection(); }
});
abortSwapBtn?.addEventListener('click', () => alert('Sending Swap Abort command!'));
resetBtn?.addEventListener('click', () => { if (confirm('Reset station?')) alert('Sending Station Reset…'); });
updateSwapUI();
</script>
</body>
</html>

View File

@ -1,38 +1,175 @@
// document.addEventListener('DOMContentLoaded', () => {
// // --- CONFIGURATION ---
// const SOCKET_URL = "http://192.168.1.12:5000";
// const API_BASE = "http://192.168.1.12:5000/api";
// // --- DOM ELEMENT REFERENCES ---
// const stationNameEl = document.getElementById('station-name');
// const stationLocationEl = document.getElementById('station-location');
// const deviceIdEl = document.getElementById('device-id');
// const productIdEl = document.getElementById('product-id');
// const lastUpdateEl = document.getElementById('last-update-status');
// const connChip = document.getElementById('connection-status-chip');
// const requestLogArea = document.getElementById('request-log-area');
// const eventLogArea = document.getElementById('event-log-area');
// const clearReqBtn = document.getElementById('clear-req');
// const clearEvtBtn = document.getElementById('clear-evt');
// const clearAllBtn = document.getElementById('clear-all');
// const refreshBtn = document.getElementById('refreshBtn');
// const downloadBtn = document.getElementById('downloadBtn');
// const logoutBtn = document.getElementById('logout-btn');
// const resetBtn = document.getElementById('station-reset-btn');
// // --- STATE ---
// let selectedStation = null;
// let socket;
// let statusPollingInterval;
// // --- HELPER FUNCTIONS ---
// const prependLog = (textarea, data) => {
// if (!textarea) return;
// const timestamp = new Date().toLocaleTimeString();
// const formattedJson = JSON.stringify(data, null, 2);
// const newLog = `[${timestamp}]\n${formattedJson}\n\n---------------------------------\n\n`;
// textarea.value = newLog + textarea.value;
// };
// const sendCommand = (command, data = null) => {
// if (!selectedStation || !socket || !socket.connected) {
// console.error(`Cannot send command '${command}', not connected.`);
// return;
// }
// const payload = { station_id: selectedStation.id, command: command, data: data };
// socket.emit('rpc_request', payload);
// };
// const checkStationStatus = async () => {
// if (!selectedStation) return;
// try {
// const response = await fetch(`${API_BASE}/stations`);
// if (!response.ok) return;
// const stations = await response.json();
// const thisStation = stations.find(s => s.id === selectedStation.id);
// if (thisStation) {
// stationNameEl.textContent = thisStation.name;
// stationLocationEl.textContent = thisStation.location;
// if (thisStation.status === 'Online') {
// connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
// connChip.className = 'cham_chip cham_chip-emerald';
// } else {
// connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
// connChip.className = 'cham_chip cham_chip-rose';
// }
// }
// } catch (error) { console.error("Loading...", error); }
// };
// // --- INITIALIZATION ---
// try {
// selectedStation = JSON.parse(localStorage.getItem('selected_station'));
// if (!selectedStation || !selectedStation.id) {
// throw new Error('No station selected. Please go back to the selection page.');
// }
// deviceIdEl.textContent = selectedStation.id;
// productIdEl.textContent = selectedStation.product_id;
// } catch (e) {
// document.body.innerHTML = `<div class="text-center p-8 text-rose-400">${e.message}<a href="./station_selection.html" class="underline ml-2">Go Back</a></div>`;
// return;
// }
// // --- SOCKET.IO CONNECTION ---
// socket = io(SOCKET_URL);
// socket.on('connect', () => {
// console.log("Connected to WebSocket for logs.");
// socket.emit('join_station_room', { station_id: selectedStation.id });
// });
// socket.on('dashboard_update', (message) => {
// const { stationId, topic, data } = message;
// if (stationId !== selectedStation.id) return;
// lastUpdateEl.textContent = 'Last Recv ' + new Date().toLocaleTimeString();
// if (topic.endsWith('EVENTS')) {
// prependLog(eventLogArea, data);
// } else if (topic.endsWith('REQUEST')) {
// prependLog(requestLogArea, data);
// }
// });
// if(logoutBtn) logoutBtn.addEventListener('click', () => {
// localStorage.clear();
// window.location.href = 'index.html';
// });
// if(refreshBtn) refreshBtn.addEventListener('click', () => location.reload());
// // if(downloadBtn) downloadBtn.addEventListener('click', () => alert("Download functionality can be added here.")); // Placeholder for download modal
// if(resetBtn) resetBtn.addEventListener('click', () => {
// if (confirm('Are you sure you want to reset the station?')) {
// sendCommand('STATION_RESET');
// }
// });
// // --- STARTUP ---
// checkStationStatus();
// statusPollingInterval = setInterval(checkStationStatus, 10000);
// if (typeof lucide !== 'undefined') {
// lucide.createIcons();
// }
// });
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// --- CONFIGURATION --- // --- CONFIGURATION ---
const SOCKET_URL = "http://192.168.1.12:5000";
const API_BASE = "http://192.168.1.12:5000/api"; const API_BASE = "http://192.168.1.12:5000/api";
// --- DOM ELEMENT REFERENCES --- // --- DOM ELEMENT REFERENCES ---
// KPI Tiles
const totalSwapsEl = document.getElementById('total-swaps');
const completedSwapsEl = document.getElementById('completed-swaps');
const successRateEl = document.getElementById('success-rate');
const abortedSwapsEl = document.getElementById('aborted-swaps');
const abortRateEl = document.getElementById('abort-rate');
const avgSwapTimeEl = document.getElementById('avg-swap-time');
// Date Range Elements
const fromDateInput = document.getElementById('from');
const toDateInput = document.getElementById('to');
const applyRangeBtn = document.getElementById('applyRange');
const quickRangeBtns = document.querySelectorAll('.date-range-btn');
// Chart Canvases
const swapActivityCanvas = document.getElementById('swapActivityChart');
const hourlyDistributionCanvas = document.getElementById('hourlyDistributionChart');
const abortReasonsCanvas = document.getElementById('abortReasonsChart');
const stationUptimeEl = document.getElementById('station-uptime');
const heatmapGridEl = document.getElementById('heatmap-grid');
//status elements
const stationNameEl = document.getElementById('station-name'); const stationNameEl = document.getElementById('station-name');
const stationLocationEl = document.getElementById('station-location'); const stationLocationEl = document.getElementById('station-location');
const deviceIdEl = document.getElementById('device-id'); const deviceIdEl = document.getElementById('device-id');
const productIdEl = document.getElementById('product-id'); const productIdEl = document.getElementById('product-id');
const lastUpdateEl = document.getElementById('last-update-status');
const connChip = document.getElementById('connection-status-chip');
const requestLogArea = document.getElementById('request-log-area');
const eventLogArea = document.getElementById('event-log-area');
const clearReqBtn = document.getElementById('clear-req');
const clearEvtBtn = document.getElementById('clear-evt');
const clearAllBtn = document.getElementById('clear-all');
const refreshBtn = document.getElementById('refreshBtn'); const refreshBtn = document.getElementById('refreshBtn');
const downloadBtn = document.getElementById('downloadBtn');
const logoutBtn = document.getElementById('logout-btn');
const resetBtn = document.getElementById('station-reset-btn'); const resetBtn = document.getElementById('station-reset-btn');
const logoutBtn = document.getElementById('logout-btn');
// --- CONSTANTS ---
const chartDefaults = {
color: 'rgba(203, 213, 225, 0.7)', // Light gray for text
borderColor: 'rgba(255, 255, 255, 0.1)', // Subtle grid lines
};
// --- STATE --- // --- STATE ---
let selectedStation = null; let selectedStation = null;
let socket; let fromDatePicker, toDatePicker;
let statusPollingInterval; let swapChartInstance, hourlyChartInstance, abortChartInstance; // To hold chart instances
// --- HELPER FUNCTIONS --- // --- HELPER FUNCTIONS ---
const prependLog = (textarea, data) => {
if (!textarea) return;
const timestamp = new Date().toLocaleTimeString();
const formattedJson = JSON.stringify(data, null, 2);
const newLog = `[${timestamp}]\n${formattedJson}\n\n---------------------------------\n\n`;
textarea.value = newLog + textarea.value;
};
const sendCommand = (command, data = null) => { const sendCommand = (command, data = null) => {
if (!selectedStation || !socket || !socket.connected) { if (!selectedStation || !socket || !socket.connected) {
@ -43,32 +180,275 @@ document.addEventListener('DOMContentLoaded', () => {
socket.emit('rpc_request', payload); socket.emit('rpc_request', payload);
}; };
const checkStationStatus = async () => { /**
* Updates the main KPI tiles with data from the API.
* @param {object} data - The kpis data from the backend.
*/
const updateStatTiles = (data) => {
if (!data) { // Used for loading state or on error
totalSwapsEl.textContent = '...';
completedSwapsEl.textContent = '...';
successRateEl.textContent = '(...%)';
abortedSwapsEl.textContent = '...';
abortRateEl.textContent = '(...%)';
avgSwapTimeEl.innerHTML = '... <span class="text-lg font-bold text-gray-300">min</span>';
stationUptimeEl.textContent = '... %';
return;
}
const total = data.total_swaps ?? 0;
const completed = data.completed_swaps ?? 0;
const aborted = data.aborted_swaps ?? 0;
totalSwapsEl.textContent = total;
completedSwapsEl.textContent = completed;
abortedSwapsEl.textContent = aborted;
const successRate = total > 0 ? ((completed / total) * 100).toFixed(1) : 0;
successRateEl.textContent = `(${successRate}%)`;
const abortRate = total > 0 ? ((aborted / total) * 100).toFixed(1) : 0;
abortRateEl.textContent = `(${abortRate}%)`;
const avgTimeInMinutes = data.avg_swap_time_seconds ? (data.avg_swap_time_seconds / 60).toFixed(1) : '—';
avgSwapTimeEl.innerHTML = `${avgTimeInMinutes} <span class="text-lg font-bold text-gray-300">min</span>`;
stationUptimeEl.textContent = `${data.station_uptime ?? '...'} %`;
};
// --- CHART.JS VISUALIZATION CODE ---
/**
* Renders the "Swap Activity Over Time" bar chart.
* @param {object} data - The chart data from the backend.
*/
const renderSwapActivityChart = (data) => {
if (swapChartInstance) {
swapChartInstance.destroy();
}
// Dynamically calculate the max value for the Y-axis
const maxValue = Math.max(...data.completed_data, ...data.aborted_data);
const yAxisMax = Math.ceil(maxValue * 1.2) + 1; // Add 20% padding + 1
swapChartInstance = new Chart(swapActivityCanvas, {
type: 'bar',
data: {
labels: data.labels,
datasets: [
{
label: 'Completed Swaps',
data: data.completed_data,
backgroundColor: 'rgba(16, 185, 129, 0.6)',
borderColor: 'rgba(16, 185, 129, 1)',
borderWidth: 1
},
{
label: 'Aborted Swaps',
data: data.aborted_data,
backgroundColor: 'rgba(244, 63, 94, 0.6)',
borderColor: 'rgba(244, 63, 94, 1)',
borderWidth: 1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: yAxisMax, // <-- SET DYNAMIC MAX VALUE
grid: { color: chartDefaults.borderColor },
ticks: {
color: chartDefaults.color,
stepSize: 1
}
},
x: {
grid: { display: false },
ticks: { color: chartDefaults.color }
}
},
plugins: {
legend: { labels: { color: chartDefaults.color } }
}
}
});
};
/**
* Renders the "Hourly Swap Distribution" bar chart.
* @param {object} data - The chart data from the backend.
*/
const renderHourlyDistributionChart = (data) => {
if (hourlyChartInstance) {
hourlyChartInstance.destroy();
}
// Dynamically calculate the max value for the Y-axis
const maxValue = Math.max(...data.swap_data);
const yAxisMax = Math.ceil(maxValue * 1.2) + 1; // Add 20% padding + 1
hourlyChartInstance = new Chart(hourlyDistributionCanvas, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{
label: 'Total Swaps',
data: data.swap_data,
backgroundColor: 'rgba(56, 189, 248, 0.6)',
borderColor: 'rgba(56, 189, 248, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: yAxisMax, // <-- SET DYNAMIC MAX VALUE
grid: { color: chartDefaults.borderColor },
ticks: {
color: chartDefaults.color,
stepSize: 1
}
},
x: {
grid: { display: false },
ticks: {
color: chartDefaults.color,
maxTicksLimit: 12
}
}
},
plugins: {
legend: { labels: { color: chartDefaults.color } }
}
}
});
};
/**
* Renders the "Swap Abort Reasons" donut chart.
* @param {object} data - The chart data from the backend.
*/
const renderAbortReasonsChart = (data) => {
if (abortChartInstance) {
abortChartInstance.destroy();
}
abortChartInstance = new Chart(abortReasonsCanvas, {
type: 'doughnut',
data: {
labels: data.labels, // e.g., ['Timeout', 'User Cancelled', ...]
datasets: [{
label: 'Count',
data: data.reason_data, // e.g., [10, 5, ...]
backgroundColor: [
'rgba(244, 63, 94, 0.7)',
'rgba(245, 158, 11, 0.7)',
'rgba(139, 92, 246, 0.7)',
'rgba(56, 189, 248, 0.7)',
],
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top', // Better placement for donuts
labels: { color: chartDefaults.color }
}
}
}
});
};
/**
* Fetches all analytics data from the backend.
* @param {string} startDate - The start date in 'YYYY-MM-DD' format.
* @param {string} endDate - The end date in 'YYYY-MM-DD' format.
*/
const fetchAnalyticsData = async (startDate, endDate) => {
if (!selectedStation) return; if (!selectedStation) return;
updateStatTiles(null); // Set UI to loading state
try { try {
const response = await fetch(`${API_BASE}/stations`); const params = new URLSearchParams({
if (!response.ok) return; station_id: selectedStation.id,
const stations = await response.json(); start_date: startDate,
const thisStation = stations.find(s => s.id === selectedStation.id); end_date: endDate
if (thisStation) { });
stationNameEl.textContent = thisStation.name;
stationLocationEl.textContent = thisStation.location; const response = await fetch(`${API_BASE}/analytics?${params}`);
if (thisStation.status === 'Online') {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`; if (!response.ok) {
connChip.className = 'cham_chip cham_chip-emerald'; const errorData = await response.json();
} else { throw new Error(errorData.message || 'Failed to fetch analytics data');
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose';
} }
const analyticsData = await response.json();
// Update all sections of the page with the new data
updateStatTiles(analyticsData.kpis);
renderSwapActivityChart(analyticsData.swap_activity);
renderHourlyDistributionChart(analyticsData.hourly_distribution);
renderAbortReasonsChart(analyticsData.abort_reasons);
renderSlotHeatmap(analyticsData.slot_utilization);
} catch (error) {
console.error("Error fetching analytics data:", error);
updateStatTiles(null); // Reset UI on error
// Destroy charts on error to show a blank state
if(swapChartInstance) swapChartInstance.destroy();
if(hourlyChartInstance) hourlyChartInstance.destroy();
if(abortChartInstance) abortChartInstance.destroy();
} }
} catch (error) { console.error("Failed to fetch station status:", error); } };
/**
* Renders the Slot Utilization Heatmap.
* @param {object} data - The slot_utilization data from the backend.
*/
const renderSlotHeatmap = (data) => {
heatmapGridEl.innerHTML = ''; // Clear previous heatmap
if (!data || !data.counts || data.counts.length === 0) return;
const counts = data.counts;
const maxCount = Math.max(...counts);
counts.forEach((count, index) => {
const slotNumber = index + 1;
// Calculate color intensity: 0 = no usage, 1 = max usage
const intensity = maxCount > 0 ? count / maxCount : 0;
// Create HSL color: Hue is fixed (e.g., blue), Saturation is fixed, Lightness varies
// A low intensity (0) will be dark, a high intensity (1) will be bright.
const lightness = 20 + (50 * intensity); // Varies from 20% to 70%
const backgroundColor = `hsl(200, 80%, ${lightness}%)`;
const cell = document.createElement('div');
cell.className = 'rounded-md flex flex-col items-center justify-center text-white font-bold';
cell.style.backgroundColor = backgroundColor;
cell.innerHTML = `
<span class="text-xs opacity-70">Slot ${slotNumber}</span>
<span class="text-2xl">${count}</span>
<span class="text-xs opacity-70">swaps</span>
`;
heatmapGridEl.appendChild(cell);
});
}; };
// --- INITIALIZATION --- // --- INITIALIZATION ---
try { try {
selectedStation = JSON.parse(localStorage.getItem('selected_station')); selectedStation = JSON.parse(localStorage.getItem('selected_station'));
if (!selectedStation || !selectedStation.id) { if (!selectedStation || !selectedStation.id) {
throw new Error('No station selected. Please go back to the selection page.'); throw new Error('No station selected.');
} }
deviceIdEl.textContent = selectedStation.id; deviceIdEl.textContent = selectedStation.id;
productIdEl.textContent = selectedStation.product_id; productIdEl.textContent = selectedStation.product_id;
@ -77,43 +457,56 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
// --- SOCKET.IO CONNECTION --- // Initialize Flatpickr
socket = io(SOCKET_URL); fromDatePicker = flatpickr(fromDateInput, { dateFormat: "Y-m-d", altInput: true, altFormat: "M j, Y" });
socket.on('connect', () => { toDatePicker = flatpickr(toDateInput, { dateFormat: "Y-m-d", altInput: true, altFormat: "M j, Y" });
console.log("Connected to WebSocket for logs.");
socket.emit('join_station_room', { station_id: selectedStation.id }); // Default to today and fetch initial data
const todayStr = new Date().toISOString().split('T')[0];
fromDatePicker.setDate(todayStr, false);
toDatePicker.setDate(todayStr, false);
fetchAnalyticsData(todayStr, todayStr);
// --- EVENT LISTENERS ---
applyRangeBtn.addEventListener('click', () => {
const startDate = fromDateInput.value;
const endDate = toDateInput.value;
if (!startDate || !endDate) return alert('Please select both a start and end date.');
if (new Date(startDate) > new Date(endDate)) return alert('Start date cannot be after the end date.');
fetchAnalyticsData(startDate, endDate);
}); });
socket.on('dashboard_update', (message) => { // (The rest of your button listeners are unchanged)
const { stationId, topic, data } = message;
if (stationId !== selectedStation.id) return;
lastUpdateEl.textContent = 'Last Recv ' + new Date().toLocaleTimeString();
if (topic.endsWith('EVENTS')) {
prependLog(eventLogArea, data);
} else if (topic.endsWith('REQUEST')) {
prependLog(requestLogArea, data);
}
});
if(logoutBtn) logoutBtn.addEventListener('click', () => { if(logoutBtn) logoutBtn.addEventListener('click', () => {
localStorage.clear(); localStorage.clear();
window.location.href = 'index.html'; window.location.href = 'index.html';
}); });
if(refreshBtn) refreshBtn.addEventListener('click', () => location.reload()); if(refreshBtn) refreshBtn.addEventListener('click', () => location.reload());
// if(downloadBtn) downloadBtn.addEventListener('click', () => alert("Download functionality can be added here.")); // Placeholder for download modal
if(resetBtn) resetBtn.addEventListener('click', () => { if(resetBtn) resetBtn.addEventListener('click', () => {
if (confirm('Are you sure you want to reset the station?')) { if (confirm('Are you sure you want to reset the station?')) {
sendCommand('STATION_RESET'); sendCommand('STATION_RESET');
} }
}); });
// --- STARTUP --- quickRangeBtns.forEach(btn => {
checkStationStatus(); btn.addEventListener('click', () => {
statusPollingInterval = setInterval(checkStationStatus, 10000); const range = btn.dataset.range;
if (typeof lucide !== 'undefined') { const today = new Date();
lucide.createIcons(); let startDate = new Date();
if (range === 'today') {
startDate = today;
} else {
startDate.setDate(today.getDate() - (parseInt(range, 10) - 1));
} }
const startDateStr = startDate.toISOString().split('T')[0];
const endDateStr = today.toISOString().split('T')[0];
fromDatePicker.setDate(startDateStr, false);
toDatePicker.setDate(endDateStr, false);
fetchAnalyticsData(startDateStr, endDateStr);
});
});
}); });

View File

@ -260,196 +260,33 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
// --- NEW: This function polls the API for the true station status --- // --- NEW: This function polls the API for the true station status ---
const checkStationStatus = async () => { // const checkStationStatus = async () => {
if (!selectedStation) return; // if (!selectedStation) return;
try { // try {
const response = await fetch(`${API_BASE}/stations`); // const response = await fetch(`${API_BASE}/stations`);
if (!response.ok) return; // if (!response.ok) return;
const stations = await response.json(); // const stations = await response.json();
const thisStation = stations.find(s => s.id === selectedStation.id); // const thisStation = stations.find(s => s.id === selectedStation.id);
if (thisStation && connChip) { // if (thisStation && connChip) {
stationNameEl.textContent = thisStation.name; // stationNameEl.textContent = thisStation.name;
stationLocationEl.textContent = thisStation.location; // stationLocationEl.textContent = thisStation.location;
if (thisStation.status === 'Online') { // if (thisStation.status === 'Online') {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`; // connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
connChip.className = 'cham_chip cham_chip-emerald'; // connChip.className = 'cham_chip cham_chip-emerald';
} else { // } else {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`; // connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose'; // connChip.className = 'cham_chip cham_chip-rose';
lastUpdateEl.textContent = "Waiting for data..."; // lastUpdateEl.textContent = "Waiting for data...";
resetDashboardUI(); // resetDashboardUI();
} // }
} // }
} catch (error) { // } catch (error) {
console.error("Failed to fetch station status:", error); // console.error("Failed to fetch station status:", error);
} // }
}; // };
// --- DOWNLOAD MODAL LOGIC ---
const showDownloadModal = () => {
const modalOverlay = document.createElement('div');
modalOverlay.className = "fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50";
modalOverlay.innerHTML = `
<div class="bg-slate-800 border border-slate-700 rounded-lg shadow-xl p-6 w-full max-w-md">
<h3 class="text-lg font-bold text-white mb-4">Export Logs</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Quick Time Ranges</label>
<div class="grid grid-cols-3 gap-2">
<button data-range="1" class="time-range-btn btn btn-ghost !py-1.5">Last Hour</button>
<button data-range="6" class="time-range-btn btn btn-ghost !py-1.5">Last 6 Hours</button>
<button data-range="24" class="time-range-btn btn btn-ghost !py-1.5">Last 24 Hours</button>
<button data-range="today" class="time-range-btn btn btn-ghost !py-1.5">Today</button>
<button data-range="yesterday" class="time-range-btn btn btn-ghost !py-1.5">Yesterday</button>
</div>
</div>
<div>
<label for="log-type" class="block text-sm font-medium text-gray-300">Log Type</label>
<select id="log-type" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
<option value="PERIODIC">Periodic Data</option>
<option value="EVENT">Events & RPC</option>
</select>
</div>
<div>
<label for="start-datetime" class="block text-sm font-medium text-gray-300">Start Date & Time</label>
<input type="text" id="start-datetime" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
</div>
<div>
<label for="end-datetime" class="block text-sm font-medium text-gray-300">End Date & Time</label>
<input type="text" id="end-datetime" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button id="cancel-download" class="btn btn-ghost px-4 py-2">Cancel</button>
<button id="confirm-download" class="btn btn-primary px-4 py-2">Download CSV</button>
</div>
</div>
`;
document.body.appendChild(modalOverlay);
const startInput = document.getElementById('start-datetime');
const endInput = document.getElementById('end-datetime');
// --- NEW: Initialize flatpickr on the inputs ---
const fpConfig = {
enableTime: true,
dateFormat: "Y-m-d\\TH:i", // Format needed by the backend
time_24hr: true
};
const fpStart = flatpickr(startInput, fpConfig);
const fpEnd = flatpickr(endInput, fpConfig);
// --- (The rest of the function is the same) ---
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 3600 * 1000);
fpStart.setDate(oneHourAgo, true);
fpEnd.setDate(now, true);
modalOverlay.querySelectorAll('.time-range-btn').forEach(button => {
button.addEventListener('click', () => {
const range = button.dataset.range;
const now = new Date();
let start = new Date();
if (range === 'today') {
start.setHours(0, 0, 0, 0);
} else if (range === 'yesterday') {
start.setDate(start.getDate() - 1);
start.setHours(0, 0, 0, 0);
now.setDate(now.getDate() - 1);
now.setHours(23, 59, 59, 999);
} else {
start.setHours(now.getHours() - parseInt(range, 10));
}
fpStart.setDate(start, true);
fpEnd.setDate(now, true);
});
});
document.getElementById('cancel-download').onclick = () => document.body.removeChild(modalOverlay);
document.getElementById('confirm-download').onclick = async () => {
const logType = document.getElementById('log-type').value;
const startDateStr = document.getElementById('start-datetime').value;
const endDateStr = document.getElementById('end-datetime').value;
const confirmBtn = document.getElementById('confirm-download');
if (!startDateStr || !endDateStr) {
alert('Please select both a start and end date/time.');
return;
}
// --- Validation Logic ---
const selectedStartDate = new Date(startDateStr);
const selectedEndDate = new Date(endDateStr);
const currentDate = new Date();
if (selectedStartDate > currentDate) {
alert('Error: The start date cannot be in the future.');
return;
}
if (selectedEndDate > currentDate) {
alert('Error: The end date cannot be in the future.');
return;
}
if (selectedStartDate >= selectedEndDate) {
alert('Error: The start date must be earlier than the end date.');
return;
}
// --- Fetch and Download Logic ---
confirmBtn.textContent = 'Fetching...';
confirmBtn.disabled = true;
const downloadUrl = `${API_BASE}/logs/export?station_id=${selectedStation.id}&start_datetime=${startDateStr}&end_datetime=${endDateStr}&log_type=${logType}`;
try {
const response = await fetch(downloadUrl);
if (response.ok) { // Status 200, CSV file received
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
const logType = document.getElementById('log-type').value;
const dateStr = startDateStr.split('T')[0]; // Get just the date part
let filename = `${selectedStation.name || selectedStation.id}_${logType}_${dateStr}.csv`;
const disposition = response.headers.get('Content-Disposition');
if (disposition && disposition.indexOf('attachment') !== -1) {
const filenameMatch = disposition.match(/filename="(.+?)"/);
if (filenameMatch && filenameMatch.length === 2) {
filename = filenameMatch[1];
}
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
document.body.removeChild(modalOverlay);
} else { // Status 404, no data found
const errorData = await response.json();
alert(`Could not download: ${errorData.message}`);
}
} catch (error) {
alert('An unexpected error occurred. Please check the console.');
console.error('Download error:', error);
} finally {
confirmBtn.textContent = 'Download CSV';
confirmBtn.disabled = false;
}
};
};
// --- MAIN LOGIC (Your original code is unchanged) --- // --- MAIN LOGIC (Your original code is unchanged) ---
const initializeDashboard = () => { const initializeDashboard = () => {
@ -546,7 +383,6 @@ document.addEventListener('DOMContentLoaded', () => {
window.location.href = './index.html'; window.location.href = './index.html';
}); });
} }
if (downloadBtn) downloadBtn.addEventListener('click', showDownloadModal);
// Audio Command Button (assuming it exists in your HTML) // Audio Command Button (assuming it exists in your HTML)
const sendAudioBtn = document.getElementById('send-audio-btn'); const sendAudioBtn = document.getElementById('send-audio-btn');
@ -580,18 +416,27 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
socket.on('dashboard_update', (message) => { socket.on('dashboard_update', (message) => {
// console.log("DEBUG: Received 'dashboard_update' message:", message); console.log("DEBUG: Received 'dashboard_update' message:", message);
const { stationId, data } = message; const { stationId, data } = message;
console.log("Received data payload:", data);
if (stationId !== selectedStation.id) { if (stationId !== selectedStation.id) {
console.warn(`Ignoring message for wrong station. Expected ${selectedStation.id}, got ${stationId}`); console.warn(`Ignoring message for wrong station. Expected ${selectedStation.id}, got ${stationId}`);
return; return;
} }
lastUpdateEl.textContent = `Last Recv ${new Date().toLocaleTimeString()}`; lastUpdateEl.textContent = `Last Recv ${new Date().toLocaleTimeString()}`;
stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—';
// Show/hide the backup power chip based on the payload data // 1. Check if the 'stationDiagnosticCode' key exists in the data.
if (data.hasOwnProperty('stationDiagnosticCode')) {
const sdc = data.stationDiagnosticCode;
stationDiagCodeEl.textContent = sdc;
updateDiagnosticsUI(sdc);
}
// 2. Check if the 'backupSupplyStatus' key exists.
if (data.hasOwnProperty('backupSupplyStatus')) {
if (data.backupSupplyStatus === 1) { if (data.backupSupplyStatus === 1) {
backupPowerChip.textContent = 'On Backup'; backupPowerChip.textContent = 'On Backup';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-amber'; backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-amber';
@ -599,17 +444,12 @@ document.addEventListener('DOMContentLoaded', () => {
backupPowerChip.textContent = 'On Mains Power'; backupPowerChip.textContent = 'On Mains Power';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-emerald'; backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-emerald';
} }
}
lastUpdateEl.textContent = new Date().toLocaleTimeString(); // 3. Only process chamber-level data if it exists.
stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—';
// --- NEW: Call the function to update the diagnostics grid ---
updateDiagnosticsUI(data.stationDiagnosticCode || 0);
if (data.slotLevelPayload && Array.isArray(data.slotLevelPayload)) { if (data.slotLevelPayload && Array.isArray(data.slotLevelPayload)) {
data.slotLevelPayload.forEach((slotData, index) => { data.slotLevelPayload.forEach((slotData, index) => {
const slotId = index + 1; const slotId = index + 1;
chamberData[slotId - 1] = slotData; // Keep live data in sync
const card = grid.querySelector(`.chamber-card[data-chamber-id="${slotId}"]`); const card = grid.querySelector(`.chamber-card[data-chamber-id="${slotId}"]`);
if (card) { if (card) {
updateChamberUI(card, slotData); updateChamberUI(card, slotData);

View File

@ -256,21 +256,42 @@ document.addEventListener('DOMContentLoaded', () => {
card.className = "group bg-gray-900/60 backdrop-blur-xl rounded-2xl shadow-lg border border-gray-700 transition-transform duration-300 ease-out flex flex-col justify-between hover:-translate-y-1.5 hover:border-emerald-400/60 hover:shadow-[0_0_0_1px_rgba(16,185,129,0.25),0_20px_40px_rgba(0,0,0,0.45)]"; card.className = "group bg-gray-900/60 backdrop-blur-xl rounded-2xl shadow-lg border border-gray-700 transition-transform duration-300 ease-out flex flex-col justify-between hover:-translate-y-1.5 hover:border-emerald-400/60 hover:shadow-[0_0_0_1px_rgba(16,185,129,0.25),0_20px_40px_rgba(0,0,0,0.45)]";
card.innerHTML = ` card.innerHTML = `
<div class="main-content p-5 flex-grow cursor-pointer"> <div class="main-content p-5 flex-grow cursor-pointer" data-station-json='${JSON.stringify(station)}'>
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<div>
<h3 class="text-lg font-bold text-white pr-2">${station.name}</h3> <h3 class="text-lg font-bold text-white pr-2">${station.name}</h3>
<p class="text-xs text-slate-400 font-mono"># ${station.product_id || 'N/A'}</p>
</div>
<div class="status-badge flex items-center text-xs font-semibold px-3 py-1 rounded-full ${status.bgColor} ${status.color}"> <div class="status-badge flex items-center text-xs font-semibold px-3 py-1 rounded-full ${status.bgColor} ${status.color}">
<i data-lucide="${status.icon}" class="w-4 h-4 mr-1.5"></i>
<span class="status-text">${station.status}</span> <span class="status-text">${station.status}</span>
</div> </div>
</div> </div>
<p class="text-sm text-gray-400 mt-1">${station.id}</p> <p class="text-sm text-gray-400 mt-2 font-mono">${station.id}</p>
</div> </div>
<div class="border-t border-gray-700/50 px-5 py-2 flex justify-end">
<button <div class="border-t border-gray-700/50 px-5 pt-3 pb-4">
class="remove-btn text-xs font-semibold text-red-500 hover:text-red-400 transition" <div class="grid grid-cols-3 gap-2 text-center">
> <div>
Remove Station <p class="text-xs text-slate-400">Total Starts</p>
<p class="font-bold text-lg text-white stat-total">0</p>
</div>
<div>
<p class="text-xs text-slate-400">Completed</p>
<p class="font-bold text-lg text-emerald-400 stat-completed">0</p>
</div>
<div>
<p class="text-xs text-slate-400">Aborted</p>
<p class="font-bold text-lg text-rose-400 stat-aborted">0</p>
</div>
</div>
</div>
<div class="border-t border-gray-700/50 px-5 py-2 flex justify-between items-center bg-black/20 rounded-b-2xl">
<button class="open-btn text-sm font-bold bg-emerald-500/80 hover:bg-emerald-500 text-white py-1 px-4 rounded-md transition">
Open
</button>
<button class="remove-btn text-gray-400 hover:text-red-500 transition" title="Remove Station">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</button> </button>
</div> </div>
`; `;
@ -302,6 +323,29 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}; };
//-- NEW: Fetch and apply daily stats to each card ---
const fetchAndApplyStats = async () => {
try {
const response = await fetch(`${API_BASE}/stations/daily-stats`);
if (!response.ok) return; // Fail silently if stats aren't available
const stats = await response.json();
// Loop through the stats object and update each card
for (const stationId in stats) {
const stationCard = stationsGrid.querySelector(`.station-card[data-station-id="${stationId}"]`);
if (stationCard) {
const statData = stats[stationId];
stationCard.querySelector('.stat-total').textContent = statData.total_starts;
stationCard.querySelector('.stat-completed').textContent = statData.completed;
stationCard.querySelector('.stat-aborted').textContent = statData.aborted;
}
}
} catch (error) {
console.error("Could not fetch daily stats:", error);
}
};
// --- MAIN EVENT LISTENER --- // --- MAIN EVENT LISTENER ---
// This single listener handles all clicks on the grid for efficiency. // This single listener handles all clicks on the grid for efficiency.
stationsGrid.addEventListener('click', async (event) => { stationsGrid.addEventListener('click', async (event) => {
@ -356,6 +400,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Otherwise, we can do a more efficient status-only update. // Otherwise, we can do a more efficient status-only update.
allStations = newStationList; allStations = newStationList;
updateStationStatuses(allStations); updateStationStatuses(allStations);
fetchAndApplyStats(); // Fetch and update daily stats
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);