Compare commits
15 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
3abfe7976b | |
|
|
fa41798552 | |
|
|
2c910cfcc5 | |
|
|
342d322e35 | |
|
|
2ea8e6990c | |
|
|
3a2cda3263 | |
|
|
45d2bbdab8 | |
|
|
eb88959660 | |
|
|
219fbd92b4 | |
|
|
7f0a93a80d | |
|
|
f61af015ad | |
|
|
526bc8e8fa | |
|
|
494e6b4c7c | |
|
|
6d3899e8e1 | |
|
|
1054d3dc0b |
|
|
@ -6,7 +6,8 @@ SECRET_KEY="80473e17c5707e19252ef3736fba32805be21a9b3e914190"
|
|||
# --- PostgreSQL Database Connection ---
|
||||
# Replace with your actual database credentials.
|
||||
# Format: postgresql://<user>:<password>@<host>:<port>/<dbname>
|
||||
DATABASE_URL="postgresql://swap_app_user:2004@localhost:5432/swap_station_db"
|
||||
# DATABASE_URL="postgresql://swap_app_user:2004@localhost:5432/swap_station_db"
|
||||
DATABASE_URL="postgresql://swap_app_user:Vec%40123@localhost:5432/swap_station_db"
|
||||
|
||||
# --- MQTT Broker Connection ---
|
||||
MQTT_BROKER="mqtt-dev.upgrid.in"
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,3 +1,87 @@
|
|||
# import paho.mqtt.client as mqtt
|
||||
# import uuid
|
||||
# import time
|
||||
# import threading
|
||||
# import socket
|
||||
|
||||
# class MqttClient:
|
||||
# """
|
||||
# Handles the connection and message processing for a single MQTT station.
|
||||
# This is a standard Python class, with no GUI dependencies.
|
||||
# """
|
||||
# def __init__(self, broker, port, user, password, station_id, on_message_callback):
|
||||
# self.broker = broker
|
||||
# self.port = port
|
||||
# self.user = user
|
||||
# self.password = password
|
||||
# self.station_id = station_id
|
||||
# self.on_message_callback = on_message_callback
|
||||
|
||||
# unique_id = str(uuid.uuid4())
|
||||
# self.client_id = f"WebApp-Backend-{self.station_id}-{unique_id}"
|
||||
|
||||
# self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, self.client_id)
|
||||
|
||||
# # Assign callback functions
|
||||
# self.client.on_connect = self.on_connect
|
||||
# self.client.on_message = self.on_message
|
||||
# self.client.on_disconnect = self.on_disconnect
|
||||
|
||||
# if self.user and self.password:
|
||||
# self.client.username_pw_set(self.user, self.password)
|
||||
|
||||
# self.is_connected = False
|
||||
# self.reconnect_delay = 1
|
||||
# self.max_reconnect_delay = 60
|
||||
# self.stop_thread = False
|
||||
|
||||
# # --- CORRECTED CALLBACK SIGNATURES ---
|
||||
# def on_connect(self, client, userdata, flags, reason_code, properties):
|
||||
# """Callback for when the client connects to the broker."""
|
||||
# if reason_code == 0:
|
||||
# self.is_connected = True
|
||||
# self.reconnect_delay = 1
|
||||
# print(f"Successfully connected to MQTT broker for station: {self.station_id}")
|
||||
# topic_base = f"VEC/batterySmartStation/v100/{self.station_id}/#"
|
||||
# # topic_base = f"VEC/batterySmartStation/v100/+/+"
|
||||
# self.client.subscribe(topic_base)
|
||||
# print(f"Subscribed to: {topic_base}")
|
||||
# else:
|
||||
# print(f"Failed to connect to MQTT for station {self.station_id}, return code {reason_code}")
|
||||
|
||||
# def on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties):
|
||||
# """Callback for when the client disconnects."""
|
||||
# print(f"Disconnected from MQTT for station {self.station_id}. Will attempt to reconnect...")
|
||||
|
||||
# def on_message(self, client, userdata, msg):
|
||||
# """Callback for when a message is received from the broker."""
|
||||
# try:
|
||||
# self.on_message_callback(self.station_id, msg.topic, msg.payload)
|
||||
# except Exception as e:
|
||||
# print(f"Error processing message in callback for topic {msg.topic}: {e}")
|
||||
|
||||
# def connect(self):
|
||||
# """Connects the client to the MQTT broker."""
|
||||
# print(f"Attempting to connect to {self.broker}:{self.port} with client ID: {self.client_id}")
|
||||
# try:
|
||||
# self.client.connect(self.broker, self.port, 60)
|
||||
# except Exception as e:
|
||||
# print(f"Error connecting to MQTT for station {self.station_id}: {e}")
|
||||
|
||||
# def start(self):
|
||||
# """Starts the MQTT client's network loop in a separate thread."""
|
||||
# self.connect()
|
||||
# self.client.loop_start()
|
||||
|
||||
# def stop(self):
|
||||
# """Stops the MQTT client's network loop."""
|
||||
# print(f"Stopping MQTT client for station: {self.station_id}")
|
||||
# self.client.loop_stop()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
import uuid
|
||||
import time
|
||||
|
|
@ -31,19 +115,15 @@ class MqttClient:
|
|||
self.client.username_pw_set(self.user, self.password)
|
||||
|
||||
self.is_connected = False
|
||||
self.reconnect_delay = 1
|
||||
self.max_reconnect_delay = 60
|
||||
self.stop_thread = False
|
||||
self.stop_thread = False # <-- We will use this flag
|
||||
|
||||
# --- CORRECTED CALLBACK SIGNATURES ---
|
||||
# --- (Your on_connect, on_disconnect, and on_message methods stay the same) ---
|
||||
def on_connect(self, client, userdata, flags, reason_code, properties):
|
||||
"""Callback for when the client connects to the broker."""
|
||||
if reason_code == 0:
|
||||
self.is_connected = True
|
||||
self.reconnect_delay = 1
|
||||
print(f"Successfully connected to MQTT broker for station: {self.station_id}")
|
||||
topic_base = f"VEC/batterySmartStation/v100/{self.station_id}/#"
|
||||
# topic_base = f"VEC/batterySmartStation/v100/+/+"
|
||||
self.client.subscribe(topic_base)
|
||||
print(f"Subscribed to: {topic_base}")
|
||||
else:
|
||||
|
|
@ -51,7 +131,13 @@ class MqttClient:
|
|||
|
||||
def on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties):
|
||||
"""Callback for when the client disconnects."""
|
||||
self.is_connected = False
|
||||
# Only print reconnect message if it wasn't a deliberate stop
|
||||
if not self.stop_thread:
|
||||
print(f"Disconnected from MQTT for station {self.station_id}. Will attempt to reconnect...")
|
||||
else:
|
||||
print(f"Intentionally disconnected from MQTT for station {self.station_id}.")
|
||||
|
||||
|
||||
def on_message(self, client, userdata, msg):
|
||||
"""Callback for when a message is received from the broker."""
|
||||
|
|
@ -60,20 +146,33 @@ class MqttClient:
|
|||
except Exception as e:
|
||||
print(f"Error processing message in callback for topic {msg.topic}: {e}")
|
||||
|
||||
def connect(self):
|
||||
"""Connects the client to the MQTT broker."""
|
||||
print(f"Attempting to connect to {self.broker}:{self.port} with client ID: {self.client_id}")
|
||||
def run(self):
|
||||
"""A blocking loop that handles connection and reconnection."""
|
||||
while not self.stop_thread:
|
||||
try:
|
||||
print(f"Attempting to connect to {self.broker}:{self.port} for station {self.station_id}")
|
||||
self.client.connect(self.broker, self.port, 60)
|
||||
self.client.loop_forever() # This is a blocking call
|
||||
break # Exit loop if loop_forever finishes cleanly
|
||||
except socket.error as e:
|
||||
print(f"Connection error for {self.station_id}: {e}. Retrying in 5 seconds...")
|
||||
time.sleep(5)
|
||||
except Exception as e:
|
||||
print(f"Error connecting to MQTT for station {self.station_id}: {e}")
|
||||
print(f"An unexpected error occurred for {self.station_id}: {e}. Retrying in 5 seconds...")
|
||||
time.sleep(5)
|
||||
|
||||
def start(self):
|
||||
"""Starts the MQTT client's network loop in a separate thread."""
|
||||
self.connect()
|
||||
self.client.loop_start()
|
||||
# --- CHANGED ---
|
||||
# We now run our custom `run` method in a thread
|
||||
main_thread = threading.Thread(target=self.run)
|
||||
main_thread.daemon = True
|
||||
main_thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""Stops the MQTT client's network loop."""
|
||||
# --- CHANGED ---
|
||||
# This is the complete, correct way to stop the client
|
||||
print(f"Stopping MQTT client for station: {self.station_id}")
|
||||
self.client.loop_stop()
|
||||
self.stop_thread = True
|
||||
self.client.disconnect() # This tells the client to disconnect gracefully
|
||||
523
backend/main.py
523
backend/main.py
|
|
@ -5,11 +5,12 @@ import json
|
|||
import csv
|
||||
import io
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Flask, jsonify, request, Response
|
||||
from flask_socketio import SocketIO, join_room
|
||||
from flask_cors import CORS
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import desc, func, case
|
||||
|
||||
# Import your custom core modules and the new models
|
||||
from core.mqtt_client import MqttClient
|
||||
|
|
@ -36,9 +37,11 @@ app = Flask(__name__)
|
|||
|
||||
# CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True)
|
||||
|
||||
CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True, expose_headers='Content-Disposition')
|
||||
# CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True, expose_headers='Content-Disposition')
|
||||
|
||||
# CORS(app, resources={r"/api/*": {"origins": "http://localhost:5173"}})
|
||||
CORS(app, resources={r"/api/*": {"origins": ["http://172.20.10.4:5500","http://127.0.0.1:5500"]}}, supports_credentials=True, expose_headers='Content-Disposition')
|
||||
|
||||
# CORS(app, resources={r"/api/*": {"origins": "http://localhost:5173"}}) , "http://127.0.0.1:5500"
|
||||
# This tells Flask: "For any route starting with /api/, allow requests
|
||||
# from the frontend running on http://localhost:5173".
|
||||
|
||||
|
|
@ -63,15 +66,16 @@ mqtt_clients = {}
|
|||
last_message_timestamps = {}
|
||||
STATION_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
# --- MQTT Message Handling ---
|
||||
def on_message_handler(station_id, topic, payload):
|
||||
last_message_timestamps[station_id] = time.time()
|
||||
|
||||
print(f"Main handler received message for station {station_id} on topic {topic}")
|
||||
|
||||
decoded_data = None
|
||||
message_type = topic.split('/')[-1]
|
||||
|
||||
if message_type in ['PERIODIC']:
|
||||
last_message_timestamps[station_id] = time.time()
|
||||
|
||||
# Decode the message payload based on its type
|
||||
decoded_data = None
|
||||
if message_type == 'PERIODIC':
|
||||
decoded_data = decoder.decode_periodic(payload)
|
||||
elif message_type == 'EVENTS':
|
||||
|
|
@ -80,7 +84,6 @@ def on_message_handler(station_id, topic, payload):
|
|||
decoded_data = decoder.decode_rpc_request(payload)
|
||||
|
||||
if decoded_data:
|
||||
# print("DECODED DATA TO BE SENT:", decoded_data)
|
||||
try:
|
||||
with app.app_context():
|
||||
log_entry = MqttLog(
|
||||
|
|
@ -91,16 +94,31 @@ def on_message_handler(station_id, topic, payload):
|
|||
)
|
||||
db.session.add(log_entry)
|
||||
db.session.commit()
|
||||
print(f"Successfully wrote data for {station_id} to PostgreSQL.")
|
||||
except Exception as e:
|
||||
print(f"Error writing to PostgreSQL: {e}")
|
||||
|
||||
# Emit update to the main dashboard
|
||||
socketio.emit('dashboard_update', {
|
||||
'stationId': station_id,
|
||||
'topic': topic,
|
||||
'data': decoded_data
|
||||
}, room=station_id)
|
||||
|
||||
if message_type == 'PERIODIC':
|
||||
# For periodic messages, only calculate and send the live status
|
||||
# This logic is from your /api/stations route
|
||||
last_msg_time = last_message_timestamps.get(station_id)
|
||||
is_online = last_msg_time is not None and (time.time() - last_msg_time) < STATION_TIMEOUT_SECONDS
|
||||
status_text = "Online" if is_online else "Offline"
|
||||
|
||||
print(f"Sending live status update: {status_text}")
|
||||
socketio.emit('status_update', {'status': status_text}, room=station_id)
|
||||
|
||||
# Emit update notification to the analytics page
|
||||
if message_type in ['EVENTS', 'REQUEST']:
|
||||
socketio.emit('analytics_updated', room=station_id)
|
||||
|
||||
|
||||
# --- (WebSocket and API routes remain the same) ---
|
||||
@socketio.on('connect')
|
||||
def handle_connect():
|
||||
|
|
@ -189,6 +207,7 @@ def add_station():
|
|||
|
||||
new_station = Station(
|
||||
station_id=data['station_id'],
|
||||
product_id=data['product_id'],
|
||||
name=data['name'],
|
||||
location=data['location'],
|
||||
mqtt_broker=data['mqtt_broker'],
|
||||
|
|
@ -199,12 +218,44 @@ def add_station():
|
|||
db.session.add(new_station)
|
||||
db.session.commit()
|
||||
|
||||
# You might want to start the new MQTT client here as well
|
||||
# start_single_mqtt_client(new_station)
|
||||
# Immediately start the MQTT client for the station just created.
|
||||
start_single_mqtt_client(new_station)
|
||||
|
||||
return jsonify({"message": "Station added successfully."}), 201
|
||||
|
||||
|
||||
# The new function with logging
|
||||
@app.route('/api/stations/<string:station_id>', methods=['DELETE'])
|
||||
def remove_station(station_id):
|
||||
"""
|
||||
Removes a station from the database and stops its MQTT client.
|
||||
"""
|
||||
print(f"\n--- REMOVE REQUEST RECEIVED for station: {station_id} ---")
|
||||
|
||||
# 1. Find the station in the database
|
||||
station = Station.query.filter_by(station_id=station_id).first_or_404()
|
||||
print(f"[LOG] Found station '{station.name}' in the database.")
|
||||
|
||||
# 2. Stop the running MQTT client for this station
|
||||
client_to_stop = mqtt_clients.get(station_id)
|
||||
if client_to_stop:
|
||||
print(f"[LOG] Found active MQTT client. Attempting to stop it now...")
|
||||
client_to_stop.stop()
|
||||
mqtt_clients.pop(station_id, None)
|
||||
print(f"[LOG] Successfully stopped and removed client object for {station_id}.")
|
||||
else:
|
||||
print(f"[LOG] No active MQTT client was found for {station_id}. No action needed.")
|
||||
|
||||
# 3. Delete the station from the database
|
||||
print(f"[LOG] Attempting to delete {station_id} from the database...")
|
||||
db.session.delete(station)
|
||||
db.session.commit()
|
||||
print(f"[LOG] Successfully deleted station from the database.")
|
||||
|
||||
print(f"--- REMOVE REQUEST COMPLETED for station: {station_id} ---\n")
|
||||
return jsonify({"message": f"Station {station_id} removed successfully."}), 200
|
||||
|
||||
|
||||
@app.route('/api/stations', methods=['GET'])
|
||||
def get_stations():
|
||||
try:
|
||||
|
|
@ -220,6 +271,7 @@ def get_stations():
|
|||
"id": s.station_id,
|
||||
"name": s.name,
|
||||
"location": s.location,
|
||||
"product_id": s.product_id,
|
||||
"status": "Online" if is_online else "Offline"
|
||||
})
|
||||
return jsonify(station_list)
|
||||
|
|
@ -227,6 +279,379 @@ def get_stations():
|
|||
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.utcnow().date(), time.min)
|
||||
today_end = datetime.combine(datetime.utcnow().date(), time.max)
|
||||
|
||||
# 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'])
|
||||
def get_recent_logs(station_id):
|
||||
# Get parameters from the request, with defaults
|
||||
start_date_str = request.args.get('start_date', datetime.now().strftime('%Y-%m-%d'))
|
||||
end_date_str = request.args.get('end_date', datetime.now().strftime('%Y-%m-%d'))
|
||||
limit_count = request.args.get('count', 50, type=int)
|
||||
|
||||
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()) # <-- FIX
|
||||
end_datetime = datetime.combine(end_date, datetime.max.time())
|
||||
except ValueError:
|
||||
return jsonify({"message": "Invalid date format."}), 400
|
||||
|
||||
try:
|
||||
# The query now uses all three filters
|
||||
logs = MqttLog.query.filter(
|
||||
MqttLog.station_id == station_id,
|
||||
MqttLog.topic_type.in_(['EVENTS', 'REQUEST']),
|
||||
MqttLog.timestamp.between(start_datetime, end_datetime)
|
||||
).order_by(desc(MqttLog.timestamp)).limit(limit_count).all()
|
||||
|
||||
logs.reverse()
|
||||
|
||||
log_list = [{
|
||||
"topic": log.topic, "payload": log.payload, "timestamp": log.timestamp.isoformat()
|
||||
} for log in logs]
|
||||
|
||||
return jsonify(log_list)
|
||||
except Exception as e:
|
||||
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)
|
||||
# ).order_by(MqttLog.timestamp.asc()).all() # <-- ADD THIS SORTING
|
||||
# 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. REVISED: Process logs to calculate KPIs and chart data ---
|
||||
# swap_starts = {} # Dictionary to store start times by sessionId
|
||||
# completed_swap_times = []
|
||||
|
||||
# total_swaps, completed_swaps, aborted_swaps = 0, 0, 0
|
||||
# daily_completed, daily_aborted, hourly_swaps, abort_reason_counts = {}, {}, [0] * 24, {}
|
||||
# slot_utilization_counts = {i: 0 for i in range(1, 10)}
|
||||
|
||||
# print("\n--- STARTING SWAP ANALYSIS ---") # Add this line
|
||||
# for log in event_logs:
|
||||
# event_type = log.payload.get('eventType')
|
||||
# session_id = log.payload.get('sessionId')
|
||||
# log_date = log.timestamp.date()
|
||||
# log_hour = log.timestamp.hour
|
||||
|
||||
# if event_type == 'EVENT_SWAP_START':
|
||||
# total_swaps += 1
|
||||
# hourly_swaps[log_hour] += 1
|
||||
# if session_id:
|
||||
# swap_starts[session_id] = log.timestamp # Store start time
|
||||
# print(f"Found START for session '{session_id}' at {log.timestamp}") # Add this line
|
||||
|
||||
# elif event_type == 'EVENT_SWAP_ENDED':
|
||||
# completed_swaps += 1
|
||||
# daily_completed[log_date] = daily_completed.get(log_date, 0) + 1
|
||||
# if session_id and session_id in swap_starts:
|
||||
# # Calculate duration if we have a matching start event
|
||||
# duration = (log.timestamp - swap_starts[session_id]).total_seconds()
|
||||
# completed_swap_times.append(duration)
|
||||
# print(f"Found MATCHING END for session '{session_id}'. Duration: {duration}s") # Add this line
|
||||
# del swap_starts[session_id] # Remove to prevent reuse
|
||||
# else:
|
||||
# print(f"Found END event but could not find matching START for session '{session_id}'") # Add this line
|
||||
|
||||
# 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
|
||||
|
||||
# print(f"--- ANALYSIS COMPLETE ---") # Add this line
|
||||
# print(f"Calculated Durations: {completed_swap_times}") # Add this line
|
||||
|
||||
# # --- 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
|
||||
|
||||
# # avg_swap_time_seconds = sum(completed_swap_times) / len(completed_swap_times) if completed_swap_times else None
|
||||
|
||||
# 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
|
||||
# })
|
||||
|
||||
|
||||
@app.route('/api/analytics', methods=['GET'])
|
||||
def get_analytics_data():
|
||||
# 1. Get and validate request parameters
|
||||
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 ALL relevant logs (EVENTS and REQUESTS) in one go
|
||||
try:
|
||||
logs = MqttLog.query.filter(
|
||||
MqttLog.station_id == station_id,
|
||||
MqttLog.topic_type.in_(['EVENTS', 'REQUEST']),
|
||||
MqttLog.timestamp.between(start_datetime, end_datetime)
|
||||
).order_by(MqttLog.timestamp.asc()).all()
|
||||
|
||||
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 logs: {e}"}), 500
|
||||
|
||||
# 3. Initialize data structures for processing
|
||||
swap_starts_map = {}
|
||||
completed_swap_times = []
|
||||
|
||||
total_initiations = 0
|
||||
total_starts = 0
|
||||
completed_swaps = 0
|
||||
aborted_swaps = 0
|
||||
|
||||
daily_completed, daily_aborted, hourly_swaps, abort_reason_counts = {}, {}, [0] * 24, {}
|
||||
slot_utilization_counts = {i: 0 for i in range(1, 10)}
|
||||
|
||||
# 4. Process the logs to calculate all KPIs and chart data
|
||||
for log in logs:
|
||||
event_type = log.payload.get('eventType')
|
||||
job_type = log.payload.get('jobType')
|
||||
session_id = log.payload.get('sessionId')
|
||||
log_date = log.timestamp.date()
|
||||
log_hour = log.timestamp.hour
|
||||
|
||||
if job_type == 'JOBTYPE_SWAP_AUTH_SUCCESS':
|
||||
total_initiations += 1
|
||||
elif event_type == 'EVENT_SWAP_START':
|
||||
total_starts += 1
|
||||
hourly_swaps[log_hour] += 1
|
||||
if session_id:
|
||||
swap_starts_map[session_id] = log.timestamp
|
||||
elif event_type == 'EVENT_BATTERY_EXIT':
|
||||
completed_swaps += 1
|
||||
daily_completed[log_date] = daily_completed.get(log_date, 0) + 1
|
||||
if session_id and session_id in swap_starts_map:
|
||||
duration = (log.timestamp - swap_starts_map[session_id]).total_seconds()
|
||||
completed_swap_times.append(duration)
|
||||
del swap_starts_map[session_id]
|
||||
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_SLOT_LOCK_DISENEGAGED':
|
||||
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
|
||||
|
||||
# 6. Prepare final data structures
|
||||
avg_swap_time_seconds = sum(completed_swap_times) / len(completed_swap_times) if completed_swap_times else None
|
||||
|
||||
kpi_data = {
|
||||
"total_swaps_initiated": total_initiations,
|
||||
"total_swaps_started": total_starts,
|
||||
"completed_swaps": completed_swaps,
|
||||
"aborted_swaps": aborted_swaps,
|
||||
"avg_swap_time_seconds": avg_swap_time_seconds,
|
||||
"station_uptime": round(station_uptime, 2)
|
||||
}
|
||||
|
||||
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 [_ , _, ...]
|
||||
|
||||
|
||||
# 7. 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
|
||||
})
|
||||
|
||||
# --- CSV Export route (UPDATED) ---
|
||||
def _format_periodic_row(payload, num_slots=9):
|
||||
"""
|
||||
|
|
@ -250,6 +675,12 @@ def _format_periodic_row(payload, num_slots=9):
|
|||
for i in range(1, num_slots + 1):
|
||||
slot = slot_map.get(i)
|
||||
if slot:
|
||||
# Convert boolean values to readable text
|
||||
# door_status_text = "OPEN" if slot.get("doorStatus", 0) == 1 else "CLOSED"
|
||||
# door_lock_status_text = "UNLOCKED" if slot.get("doorLockStatus", 0) == 1 else "LOCKED"
|
||||
# battery_present_text = "YES" if slot.get("batteryPresent", 0) == 1 else "NO"
|
||||
# charger_present_text = "YES" if slot.get("chargerPresent", 0) == 1 else "NO"
|
||||
|
||||
row.extend([
|
||||
slot.get('batteryIdentification', ''),
|
||||
slot.get("batteryPresent", 0),
|
||||
|
|
@ -422,21 +853,16 @@ def handle_rpc_request(payload):
|
|||
print(f"Publishing to {topic}")
|
||||
mqtt_client.client.publish(topic, serialized_payload)
|
||||
|
||||
# --- Main Application Logic ---
|
||||
def start_mqtt_clients():
|
||||
# ADD THIS NEW FUNCTION
|
||||
def start_single_mqtt_client(station):
|
||||
"""
|
||||
Initializes and starts an MQTT client for each station found in the database,
|
||||
using the specific MQTT credentials stored for each station.
|
||||
Creates and starts a new MQTT client thread for a SINGLE station.
|
||||
This is our new reusable function.
|
||||
"""
|
||||
try:
|
||||
with app.app_context():
|
||||
stations = Station.query.all()
|
||||
except Exception as e:
|
||||
print(f"CRITICAL: Could not query stations from the database in MQTT thread: {e}")
|
||||
if station.station_id in mqtt_clients and mqtt_clients[station.station_id].is_connected:
|
||||
print(f"MQTT client for {station.station_id} is already running.")
|
||||
return
|
||||
|
||||
for station in stations:
|
||||
if station.station_id not in mqtt_clients:
|
||||
print(f"Creating and starting MQTT client for station: {station.name} ({station.station_id})")
|
||||
|
||||
client = MqttClient(
|
||||
|
|
@ -447,9 +873,53 @@ def start_mqtt_clients():
|
|||
station_id=station.station_id,
|
||||
on_message_callback=on_message_handler
|
||||
)
|
||||
client.start()
|
||||
client.start() # The start method should handle threading
|
||||
mqtt_clients[station.station_id] = client
|
||||
|
||||
# --- Main Application Logic ---
|
||||
# def start_mqtt_clients():
|
||||
# """
|
||||
# Initializes and starts an MQTT client for each station found in the database,
|
||||
# using the specific MQTT credentials stored for each station.
|
||||
# """
|
||||
# try:
|
||||
# with app.app_context():
|
||||
# stations = Station.query.all()
|
||||
# except Exception as e:
|
||||
# print(f"CRITICAL: Could not query stations from the database in MQTT thread: {e}")
|
||||
# return
|
||||
|
||||
# for station in stations:
|
||||
# if station.station_id not in mqtt_clients:
|
||||
# print(f"Creating and starting MQTT client for station: {station.name} ({station.station_id})")
|
||||
|
||||
# client = MqttClient(
|
||||
# broker=station.mqtt_broker,
|
||||
# port=station.mqtt_port,
|
||||
# user=station.mqtt_user,
|
||||
# password=station.mqtt_password,
|
||||
# station_id=station.station_id,
|
||||
# on_message_callback=on_message_handler
|
||||
# )
|
||||
# client.start()
|
||||
# mqtt_clients[station.station_id] = client
|
||||
|
||||
def start_mqtt_clients():
|
||||
"""
|
||||
Initializes and starts an MQTT client for each station found in the database
|
||||
by calling our new reusable function.
|
||||
"""
|
||||
try:
|
||||
with app.app_context():
|
||||
stations = Station.query.all()
|
||||
print(f"Found {len(stations)} existing stations to monitor.")
|
||||
except Exception as e:
|
||||
print(f"CRITICAL: Could not query stations from the database: {e}")
|
||||
return
|
||||
|
||||
for station in stations:
|
||||
start_single_mqtt_client(station)
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
with app.app_context():
|
||||
|
|
@ -467,6 +937,7 @@ if __name__ == '__main__':
|
|||
print("No stations found. Adding a default station.")
|
||||
default_station = Station(
|
||||
station_id="V16000862287077265957",
|
||||
product_id="VEC_PROD_001",
|
||||
name="Test Station 1",
|
||||
mqtt_broker="mqtt.vecmocon.com",
|
||||
mqtt_port=1883,
|
||||
|
|
@ -482,5 +953,5 @@ if __name__ == '__main__':
|
|||
mqtt_thread = threading.Thread(target=start_mqtt_clients, daemon=True)
|
||||
mqtt_thread.start()
|
||||
|
||||
print(f"Starting Flask-SocketIO server on http://localhost:5000")
|
||||
socketio.run(app, host='0.0.0.0', port=5000)
|
||||
print(f"Starting Flask-SocketIO server on http://172.20.10.4:5000")
|
||||
socketio.run(app, host='172.20.10.4', port=5000)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ class Station(db.Model):
|
|||
"""Represents a battery swap station in the database."""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
station_id = db.Column(db.String(120), unique=True, nullable=False)
|
||||
product_id = db.Column(db.String(80), unique=True, nullable=False)
|
||||
name = db.Column(db.String(120), nullable=True)
|
||||
location = db.Column(db.String(200), nullable=True)
|
||||
mqtt_broker = db.Column(db.String(255), nullable=False)
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -25,6 +25,7 @@ enum jobType_e {
|
|||
JOBTYPE_REBOOT = 0x104;
|
||||
JOBTYPE_SWAP_DENY = 0x105;
|
||||
JOBTYPE_LANGUAGE_UPDATE = 0x106;
|
||||
JOBTYPE_SWAP_AUTH_SUCCESS = 0x107;
|
||||
}
|
||||
|
||||
enum jobResult_e {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -33,6 +33,7 @@ class jobType_e(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
|||
JOBTYPE_REBOOT: _ClassVar[jobType_e]
|
||||
JOBTYPE_SWAP_DENY: _ClassVar[jobType_e]
|
||||
JOBTYPE_LANGUAGE_UPDATE: _ClassVar[jobType_e]
|
||||
JOBTYPE_SWAP_AUTH_SUCCESS: _ClassVar[jobType_e]
|
||||
|
||||
class jobResult_e(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
|
||||
__slots__ = ()
|
||||
|
|
@ -92,6 +93,7 @@ JOBTYPE_TRANSACTION_ABORT: jobType_e
|
|||
JOBTYPE_REBOOT: jobType_e
|
||||
JOBTYPE_SWAP_DENY: jobType_e
|
||||
JOBTYPE_LANGUAGE_UPDATE: jobType_e
|
||||
JOBTYPE_SWAP_AUTH_SUCCESS: jobType_e
|
||||
JOB_RESULT_UNKNOWN: jobResult_e
|
||||
JOB_RESULT_SUCCESS: jobResult_e
|
||||
JOB_RESULT_REJECTED: jobResult_e
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
<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"> </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>Station ID:</span>
|
||||
<span id="station-id-display" class="font-semibold">—</span>
|
||||
</span>
|
||||
<span class="badge border-white/10 bg-white/5 text-slate-200">
|
||||
<span>Product ID:</span>
|
||||
<span id="product-id-display" class="font-semibold">—</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="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 id="main-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 font-semibold">Main</a>
|
||||
<a href="./logs.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 font-semibold">Logs</a>
|
||||
<a href="./analytics.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 font-semibold">Analytics</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -5,11 +5,19 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Swap Station – Analytics</title>
|
||||
|
||||
<!-- Inter + Tailwind -->
|
||||
<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/auth-guard.js"></script>
|
||||
|
||||
<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: {
|
||||
|
|
@ -26,14 +34,21 @@
|
|||
: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); }
|
||||
.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; }
|
||||
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
|
||||
|
|
@ -44,26 +59,32 @@
|
|||
.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 { 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); }
|
||||
.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); }
|
||||
.textarea {
|
||||
width:100%; height:100%; background: linear-gradient(180deg, rgba(2,6,23,.55), rgba(2,6,23,.35));
|
||||
border:1px dashed rgba(255,255,255,.14); border-radius:.75rem; padding:1rem; color:#d1d5db;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; resize:none; outline:none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen text-gray-100 bg-glow">
|
||||
|
||||
<!-- STATUS BAR + TABS -->
|
||||
<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">
|
||||
<!-- Left -->
|
||||
<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"
|
||||
|
|
@ -81,32 +102,39 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Center -->
|
||||
<div class="flex items-center justify-center">
|
||||
<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>
|
||||
|
||||
<!-- Right badges/actions -->
|
||||
<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>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">
|
||||
<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 id="last-update-status">Waiting...</span>
|
||||
</span>
|
||||
|
||||
<span class="chip chip-emerald">
|
||||
<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online
|
||||
<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>
|
||||
|
||||
<span class="chip chip-amber" title="Running on backup supply">On Backup</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>
|
||||
|
||||
<div class="hidden sm:block w-px h-5 bg-white/10"></div>
|
||||
|
||||
|
|
@ -130,9 +158,7 @@
|
|||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
|
@ -142,176 +168,122 @@
|
|||
</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>
|
||||
|
||||
<main class="relative z-10 mx-auto max-w-7.5xl px-3 sm:px-4 py-4 flex flex-col gap-4">
|
||||
<section class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button data-range="today" class="date-range-btn btn btn-ghost">Today</button>
|
||||
<button data-range="7" class="date-range-btn btn btn-ghost">Last 7 Days</button>
|
||||
<button data-range="30" class="date-range-btn btn btn-ghost">Last 30 Days</button>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Stat Tiles -->
|
||||
<section class="grid grid-cols-1 md:grid-cols-4 gap-3">
|
||||
<section class="grid grid-cols-2 lg:grid-cols-6 gap-3">
|
||||
<div class="tile">
|
||||
<p class="text-xs text-gray-400">Total Swaps (Today)</p>
|
||||
<p class="text-3xl font-extrabold">142</p>
|
||||
<p class="text-xs text-gray-400">Total Swaps Initiated</p>
|
||||
<p id="total-swaps-initiated" class="text-3xl font-extrabold text-sky-400">...</p>
|
||||
</div>
|
||||
<div class="tile">
|
||||
<p class="text-xs text-gray-400">Total Swaps Started</p>
|
||||
<p id="total-swaps-started" class="text-3xl font-extrabold text-blue-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 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>
|
||||
<p id="avg-swap-time" class="text-3xl font-extrabold">
|
||||
<span id="avg-swap-time-value">...</span>
|
||||
<span class="text-lg font-bold text-gray-300">sec</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>
|
||||
<p id="station-uptime" class="text-3xl font-extrabold text-teal-400">... %</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- <section class="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div class="tile">
|
||||
<p class="text-xs text-gray-400">Total Swaps Initiated</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">Peak Hours</p>
|
||||
<p class="text-3xl font-extrabold">5–7 PM</p>
|
||||
<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 class="tile">
|
||||
<p class="text-xs text-gray-400">Avg. Swap Time</p>
|
||||
<p id="avg-swap-time" class="text-3xl font-extrabold">
|
||||
<span id="avg-swap-time-value">...</span>
|
||||
<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 id="station-uptime" class="text-3xl font-extrabold text-teal-400">... %</p>
|
||||
</div>
|
||||
</section> -->
|
||||
|
||||
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
|
||||
<div class="glass p-4 h-96">
|
||||
<h3 class="font-extrabold">Swap Activity Over Time</h3>
|
||||
<canvas id="swapActivityChart"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="glass p-4 h-96">
|
||||
<h3 class="font-extrabold">Hourly Swap Distribution</h3>
|
||||
<canvas id="hourlyDistributionChart"></canvas>
|
||||
</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 class="glass p-4 h-96">
|
||||
<h3 class="font-extrabold">Swap Abort Reasons</h3>
|
||||
<canvas id="abortReasonsChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- <div class="glass p-4 h-96 flex items-center justify-center">
|
||||
<p class="text-slate-500">Future Chart Area</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)
|
||||
(function setDevice() {
|
||||
const el = document.querySelector('#device-id');
|
||||
try {
|
||||
const sel = JSON.parse(localStorage.getItem('selected_station') || '{}');
|
||||
el.textContent = sel?.id || sel?.station_id || '—';
|
||||
} catch { el.textContent = '—'; }
|
||||
})();
|
||||
<script src="./js/analytics.js"></script>
|
||||
|
||||
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());
|
||||
document.querySelector('#downloadBtn')?.addEventListener('click', () => {
|
||||
alert('Hook this to your /api/logs/export (or analytics export) endpoint.');
|
||||
});
|
||||
|
||||
// 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>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -5,6 +5,8 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Swap Station – Dashboard</title>
|
||||
|
||||
<script src="js/auth-guard.js"></script>
|
||||
|
||||
<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">
|
||||
|
|
@ -12,7 +14,7 @@
|
|||
<script src="https://unpkg.com/lucide@latest" defer></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
<script src="./js/dashboard.js"></script>
|
||||
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
|
|
@ -148,6 +150,13 @@
|
|||
|
||||
.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)}
|
||||
|
||||
#diag-flags-grid{
|
||||
display:grid;
|
||||
grid-template-columns: 80px 1fr; /* label col = 88px; adjust to taste */
|
||||
column-gap: 0.5rem; /* ~8px gap between columns */
|
||||
row-gap: 0.1; /* vertical spacing */
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen text-gray-100 flex flex-col">
|
||||
|
|
@ -157,8 +166,9 @@
|
|||
<div class="absolute -bottom-24 -right-24 w-[36rem] h-[36rem] rounded-full bg-sky-500/10 blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<!-- <header> -->
|
||||
<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="mx-auto max-w-7xl 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"
|
||||
|
|
@ -184,28 +194,31 @@
|
|||
<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>Product ID:</span>
|
||||
<span id="product-id-display" 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">
|
||||
<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 id="last-update-status">Waiting for data...</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">
|
||||
<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> -->
|
||||
</button>
|
||||
|
||||
<div class="hidden sm:block w-px h-5 bg-white/10"></div>
|
||||
|
||||
|
|
@ -239,24 +252,25 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<!-- <span id="backup-power-chip" class="cham_chip cham_chip-slate" title="Station power source" style="display: none;">On Backup</span> -->
|
||||
|
||||
|
||||
<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 id="station-diag-code" class="text-sm font-bold text-emerald-300">—</span>
|
||||
<span class="text-sm font-semibold text-gray-400">STATION DIAGNOSTIC CODE (SDC)</span>
|
||||
<div class="flex items-baseline gap-2 mt-1 mb-3">
|
||||
<i data-lucide="shield-alert" class="w-5 h-5 text-rose-500 flex-shrink-0"></i>
|
||||
<span id="station-diag-code-raw" class="text-xl font-extrabold text-emerald-300 flex-1 text-right">—</span>
|
||||
</div>
|
||||
<div id="diag-flags-grid" 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">MB 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 id="sdc-visual-flags" class="flex flex-wrap gap-1.5 pt-3 border-t border-white/10 flex-wrap">
|
||||
</div> -->
|
||||
<div id="diag-flags-grid" class="grid mt-3 pt-3 border-t border-white/10 content-center">
|
||||
</div>
|
||||
<span id="backup-power-chip" class="cham_chip cham_chip-slate w-full justify-center mt-3">
|
||||
<span id="backup-power-chip" class="cham_chip cham_chip-slate w-full justify-center mt-3">—</span>
|
||||
</section>
|
||||
|
||||
<section id="swap-panel" class="glass p-4 flex flex-col min-h-[220px]">
|
||||
|
|
@ -352,5 +366,8 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script src="./js/common-header.js"></script>
|
||||
<script src="./js/dashboard.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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">
|
||||
</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>
|
||||
|
|
@ -0,0 +1,317 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- CONFIGURATION ---
|
||||
const SOCKET_URL = "http://172.20.10.4:5000";
|
||||
const API_BASE = "http://172.20.10.4: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 connChip = document.getElementById('connection-status-chip');
|
||||
const totalInitiatedEl = document.getElementById('total-swaps-initiated');
|
||||
const totalStartedEl = document.getElementById('total-swaps-started');
|
||||
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 avgSwapTimeValueEl = document.getElementById('avg-swap-time-value');
|
||||
const stationUptimeEl = document.getElementById('station-uptime');
|
||||
const fromDateInput = document.getElementById('from');
|
||||
const toDateInput = document.getElementById('to');
|
||||
const applyRangeBtn = document.getElementById('applyRange');
|
||||
const quickRangeBtns = document.querySelectorAll('.date-range-btn');
|
||||
const swapActivityCanvas = document.getElementById('swapActivityChart');
|
||||
const hourlyDistributionCanvas = document.getElementById('hourlyDistributionChart');
|
||||
const abortReasonsCanvas = document.getElementById('abortReasonsChart');
|
||||
const heatmapGridEl = document.getElementById('heatmap-grid');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
|
||||
// --- STATE & CONSTANTS ---
|
||||
let selectedStation = null;
|
||||
let socket;
|
||||
let fromDatePicker, toDatePicker;
|
||||
let swapChartInstance, hourlyChartInstance, abortChartInstance;
|
||||
let lastKpis = {};
|
||||
const chartDefaults = { color: 'rgba(203, 213, 225, 0.7)', borderColor: 'rgba(255, 255, 255, 0.1)' };
|
||||
const ABORT_REASON_COLORS = { "Unknown": "#94a3b8", "Battery Exit Timeout": "#f43f5e", "Battery Entry Timeout": "#ec4899", "Door Close Timeout": "#f59e0b", "Door Open Timeout": "#eab308", "Invalid Parameter": "#a855f7", "Remote Abort": "#8b5cf6", "Invalid Battery": "#3b82f6" };
|
||||
|
||||
// --- HELPER FUNCTIONS ---
|
||||
|
||||
const updateStatTiles = (data) => {
|
||||
if (!data) {
|
||||
totalInitiatedEl.textContent = '...'; totalStartedEl.textContent = '...'; completedSwapsEl.textContent = '...';
|
||||
successRateEl.textContent = '(...%)'; abortedSwapsEl.textContent = '...'; abortRateEl.textContent = '(...%)';
|
||||
avgSwapTimeValueEl.textContent = '...'; stationUptimeEl.textContent = '... %'; lastKpis = {}; return;
|
||||
}
|
||||
const updateIfNeeded = (element, newValue, formatter = val => val) => {
|
||||
const key = element.id; const formattedValue = formatter(newValue);
|
||||
if (lastKpis[key] !== formattedValue) { element.textContent = formattedValue; lastKpis[key] = formattedValue; }
|
||||
};
|
||||
updateIfNeeded(totalInitiatedEl, data.total_swaps_initiated ?? 0);
|
||||
updateIfNeeded(totalStartedEl, data.total_swaps_started ?? 0);
|
||||
const completed = data.completed_swaps ?? 0; const aborted = data.aborted_swaps ?? 0; const totalStarts = data.total_swaps_started ?? 0;
|
||||
updateIfNeeded(completedSwapsEl, completed); updateIfNeeded(abortedSwapsEl, aborted);
|
||||
const successRate = totalStarts > 0 ? ((completed / totalStarts) * 100).toFixed(1) : 0;
|
||||
updateIfNeeded(successRateEl, successRate, val => `(${val}%)`);
|
||||
const abortRate = totalStarts > 0 ? ((aborted / totalStarts) * 100).toFixed(1) : 0;
|
||||
updateIfNeeded(abortRateEl, abortRate, val => `(${val}%)`);
|
||||
const avgTimeInSeconds = data.avg_swap_time_seconds != null ? Math.round(data.avg_swap_time_seconds) : '0';
|
||||
updateIfNeeded(avgSwapTimeValueEl, avgTimeInSeconds);
|
||||
const uptime = data.station_uptime ?? '...';
|
||||
updateIfNeeded(stationUptimeEl, uptime, val => `${val} %`);
|
||||
};
|
||||
|
||||
const renderSwapActivityChart = (data) => {
|
||||
|
||||
// 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
|
||||
|
||||
// If the chart doesn't exist yet, create it.
|
||||
if (!swapChartInstance) {
|
||||
|
||||
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)' },
|
||||
{ label: 'Aborted Swaps', data: data.aborted_data, backgroundColor: 'rgba(244, 63, 94, 0.6)' }
|
||||
]
|
||||
},
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If the chart already exists, just update its data.
|
||||
swapChartInstance.data.labels = data.labels;
|
||||
swapChartInstance.data.datasets[0].data = data.completed_data;
|
||||
swapChartInstance.data.datasets[1].data = data.aborted_data;
|
||||
swapChartInstance.options.scales.y.max = yAxisMax;
|
||||
swapChartInstance.update(); // This smoothly animates the changes.
|
||||
}
|
||||
};
|
||||
|
||||
const renderHourlyDistributionChart = (data) => {
|
||||
|
||||
// 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
|
||||
|
||||
if (!hourlyChartInstance) {
|
||||
|
||||
hourlyChartInstance = new Chart(hourlyDistributionCanvas, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{ label: 'Total Swaps initiated', data: data.swap_data, backgroundColor: 'rgba(56, 189, 248, 0.6)' }]
|
||||
},
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
hourlyChartInstance.data.labels = data.labels;
|
||||
hourlyChartInstance.data.datasets[0].data = data.swap_data;
|
||||
hourlyChartInstance.options.scales.y.max = yAxisMax;
|
||||
hourlyChartInstance.update();
|
||||
}
|
||||
};
|
||||
|
||||
const renderAbortReasonsChart = (data) => {
|
||||
if (!abortChartInstance) {
|
||||
abortChartInstance = new Chart(abortReasonsCanvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: data.labels,
|
||||
datasets: [{ label: 'Count', data: data.reason_data, backgroundColor: data.labels.map(label => ABORT_REASON_COLORS[label] || '#cccccc') }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top', // Better placement for donuts
|
||||
labels: { color: chartDefaults.color }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
abortChartInstance.data.labels = data.labels;
|
||||
abortChartInstance.data.datasets[0].data = data.reason_data;
|
||||
abortChartInstance.data.datasets[0].backgroundColor = data.labels.map(label => ABORT_REASON_COLORS[label] || '#cccccc');
|
||||
abortChartInstance.update();
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchAnalyticsData = async (startDate, endDate) => {
|
||||
if (!selectedStation) return;
|
||||
updateStatTiles(null);
|
||||
try {
|
||||
const params = new URLSearchParams({ station_id: selectedStation.id, start_date: startDate, end_date: endDate });
|
||||
const response = await fetch(`${API_BASE}/analytics?${params}`);
|
||||
if (!response.ok) throw new Error((await response.json()).message || 'Failed to fetch analytics data');
|
||||
const analyticsData = await response.json();
|
||||
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); }
|
||||
};
|
||||
|
||||
const checkStationStatus = async () => { /* ... Your correct status check logic ... */ };
|
||||
|
||||
const connectSocket = () => {
|
||||
socket = io(SOCKET_URL);
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log("Analytics: Connected to WebSocket.");
|
||||
if (selectedStation) {
|
||||
socket.emit('join_station_room', { station_id: selectedStation.id });
|
||||
}
|
||||
});
|
||||
|
||||
// This listener handles the full refresh for important events
|
||||
socket.on('analytics_updated', () => {
|
||||
console.log("Analytics update received (Event/Request). Refetching data.");
|
||||
applyRangeBtn.click();
|
||||
});
|
||||
|
||||
// --- ADD THIS NEW LISTENER for lightweight status updates ---
|
||||
socket.on('status_update', (data) => {
|
||||
// data will look like: { status: 'Online' }
|
||||
console.log("Live status update received:", data.status);
|
||||
if (connChip) {
|
||||
if (data.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';
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
function init() {
|
||||
try {
|
||||
selectedStation = JSON.parse(localStorage.getItem('selected_station'));
|
||||
if (!selectedStation || !selectedStation.id) throw new Error('No station selected.');
|
||||
} catch (e) { window.location.href = './station_selection.html'; return; }
|
||||
|
||||
stationNameEl.textContent = selectedStation.name;
|
||||
stationLocationEl.textContent = selectedStation.location;
|
||||
deviceIdEl.textContent = selectedStation.id;
|
||||
productIdEl.textContent = selectedStation.product_id;
|
||||
|
||||
if (logoutBtn) logoutBtn.addEventListener('click', () => { localStorage.clear(); window.location.href = 'index.html'; });
|
||||
if (refreshBtn) refreshBtn.addEventListener('click', () => location.reload());
|
||||
|
||||
applyRangeBtn.addEventListener('click', () => {
|
||||
const startDate = fromDatePicker.selectedDates[0];
|
||||
const endDate = toDatePicker.selectedDates[0];
|
||||
if (startDate && endDate) {
|
||||
fetchAnalyticsData(flatpickr.formatDate(startDate, "Y-m-d"), flatpickr.formatDate(endDate, "Y-m-d"));
|
||||
}
|
||||
});
|
||||
|
||||
quickRangeBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const range = btn.dataset.range;
|
||||
const today = new Date(); let startDate = new Date();
|
||||
if (range === 'today') startDate = today;
|
||||
else startDate.setDate(today.getDate() - (parseInt(range, 10) - 1));
|
||||
fromDatePicker.setDate(startDate, true); toDatePicker.setDate(today, true);
|
||||
applyRangeBtn.click();
|
||||
});
|
||||
});
|
||||
|
||||
fromDatePicker = flatpickr(fromDateInput, { dateFormat: "Y-m-d", altInput: true, altFormat: "M j, Y" });
|
||||
toDatePicker = flatpickr(toDateInput, { dateFormat: "Y-m-d", altInput: true, altFormat: "M j, Y" });
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
fromDatePicker.setDate(todayStr, false); toDatePicker.setDate(todayStr, false);
|
||||
|
||||
fetchAnalyticsData(todayStr, todayStr);
|
||||
checkStationStatus();
|
||||
// setInterval(checkStationStatus, 10000);
|
||||
connectSocket();
|
||||
}
|
||||
|
||||
init();
|
||||
});
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
(function() {
|
||||
// Get the user from localStorage
|
||||
const user = localStorage.getItem('user');
|
||||
|
||||
// Check if the user object exists. If not, redirect to the login page.
|
||||
if (!user) {
|
||||
// Use window.location.replace() to prevent the user from clicking the "back" button
|
||||
// and re-accessing the protected page.
|
||||
window.location.replace('/frontend/index.html');
|
||||
}
|
||||
})();
|
||||
|
|
@ -11,7 +11,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
const password = document.getElementById('password').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:5000/api/login', {
|
||||
const response = await fetch('http://172.20.10.4:5000/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
// frontend/js/common-header.js
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- CONFIGURATION ---
|
||||
const SOCKET_URL = "http://172.20.10.4:5000";
|
||||
const API_BASE = "http://172.20.10.4:5000/api";
|
||||
|
||||
// --- STATE & SELECTED STATION ---
|
||||
let selectedStation = null;
|
||||
let socket;
|
||||
let statusPollingInterval;
|
||||
|
||||
try {
|
||||
selectedStation = JSON.parse(localStorage.getItem('selected_station'));
|
||||
if (!selectedStation || !selectedStation.id) {
|
||||
window.location.href = './station_selection.html';
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
window.location.href = './station_selection.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// --- HEADER DOM ELEMENTS ---
|
||||
const stationNameEl = document.getElementById('station-name');
|
||||
const stationLocationEl = document.getElementById('station-location');
|
||||
const stationIdEl = document.getElementById('station-id-display');
|
||||
const productIdEl = document.getElementById('product-id-display');
|
||||
const connChip = document.getElementById('connection-status-chip');
|
||||
const lastUpdateEl = document.getElementById('last-update-status');
|
||||
const logoutBtn = document.getElementById('logout-btn');
|
||||
const navLinks = document.getElementById('main-nav');
|
||||
const downloadBtn = document.getElementById('downloadBtn');
|
||||
|
||||
// --- INITIALIZATION ---
|
||||
function initializeHeader() {
|
||||
// Populate header with initial data
|
||||
if (stationNameEl) stationNameEl.textContent = selectedStation.name || 'Unknown';
|
||||
if (stationLocationEl) stationLocationEl.textContent = selectedStation.location || 'No location';
|
||||
if (stationIdEl) stationIdEl.textContent = selectedStation.id;
|
||||
if (productIdEl) productIdEl.textContent = selectedStation.product_id;
|
||||
|
||||
// Highlight the active tab
|
||||
if (navLinks) {
|
||||
const currentPage = window.location.pathname.split('/').pop();
|
||||
const activeLink = navLinks.querySelector(`a[href="./${currentPage}"]`);
|
||||
if (activeLink) {
|
||||
activeLink.classList.remove('text-gray-400', 'hover:text-gray-200');
|
||||
activeLink.classList.add('border-b-2', 'border-emerald-400/70', 'text-white');
|
||||
}
|
||||
}
|
||||
|
||||
// Start polling and connect WebSocket
|
||||
checkStationStatus();
|
||||
statusPollingInterval = setInterval(checkStationStatus, 10000);
|
||||
connectSocket();
|
||||
|
||||
// Add event listener for the logout button
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', () => {
|
||||
localStorage.clear();
|
||||
window.location.href = './index.html';
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', showDownloadModal);
|
||||
}
|
||||
}
|
||||
|
||||
// --- POLLING FOR ONLINE/OFFLINE STATUS ---
|
||||
const checkStationStatus = async () => {
|
||||
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 && connChip) {
|
||||
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';
|
||||
if (lastUpdateEl) lastUpdateEl.textContent = "Waiting for data...";
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch station status:", error);
|
||||
}
|
||||
};
|
||||
|
||||
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');
|
||||
|
||||
const fpConfig = {
|
||||
enableTime: true,
|
||||
dateFormat: "Y-m-d\\TH:i",
|
||||
time_24hr: true
|
||||
};
|
||||
const fpStart = flatpickr(startInput, fpConfig);
|
||||
const fpEnd = flatpickr(endInput, fpConfig);
|
||||
|
||||
const now = new Date();
|
||||
fpStart.setDate(new Date(now.getTime() - 3600 * 1000), 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) {
|
||||
return alert('Please select both a start and end date/time.');
|
||||
}
|
||||
|
||||
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) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.style.display = 'none';
|
||||
a.href = url;
|
||||
|
||||
let filename = `${selectedStation.name || selectedStation.id}_${logType}_${startDateStr.split('T')[0]}.csv`;
|
||||
const disposition = response.headers.get('Content-Disposition');
|
||||
if (disposition && disposition.includes('attachment')) {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// --- WEBSOCKET CONNECTION ---
|
||||
const connectSocket = () => {
|
||||
socket = io(SOCKET_URL);
|
||||
socket.on('connect', () => {
|
||||
console.log("Header: Connected to WebSocket.");
|
||||
socket.emit('join_station_room', { station_id: selectedStation.id });
|
||||
});
|
||||
socket.on('disconnect', () => console.log("Header: Disconnected from WebSocket."));
|
||||
socket.on('dashboard_update', (message) => {
|
||||
if (message.stationId === selectedStation.id && lastUpdateEl) {
|
||||
lastUpdateEl.textContent = `Last Recv ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// --- START THE SCRIPT ---
|
||||
initializeHeader();
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- CONFIGURATION ---
|
||||
const SOCKET_URL = "http://localhost:5000";
|
||||
const API_BASE = "http://localhost:5000/api"; // Added for API calls
|
||||
const SOCKET_URL = "http://172.20.10.4:5000";
|
||||
const API_BASE = "http://172.20.10.4:5000/api"; // Added for API calls
|
||||
|
||||
// --- DOM ELEMENT REFERENCES ---
|
||||
const grid = document.getElementById('chambersGrid');
|
||||
|
|
@ -11,8 +11,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
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 stationDiagCodeEl = document.getElementById('station-diag-code');
|
||||
|
||||
const stationDiagCodeEl = document.getElementById('station-diag-code-raw'); // CHANGED ID: Use new raw code display ID
|
||||
const sdcVisualFlagsEl = document.getElementById('sdc-visual-flags');
|
||||
|
||||
const backupPowerChip = document.getElementById('backup-power-chip');
|
||||
const diagFlagsGrid = document.getElementById('diag-flags-grid');
|
||||
const audioSelect = document.getElementById('audio-command-select');
|
||||
|
|
@ -29,14 +33,40 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
let chamberData = Array(9).fill({ batteryPresent: false });
|
||||
|
||||
// The list of errors from your Python code
|
||||
const DIAGNOSTIC_ERRORS = [
|
||||
"Lock Power Cut", "Main Power Cut",
|
||||
"Relayboard CAN", "DB CAN Recv",
|
||||
"MB Can Recv", "Smoke Alarm",
|
||||
"Water Alarm", "Phase Failure",
|
||||
"Earth Leakage"
|
||||
// const DIAGNOSTIC_ERRORS = [
|
||||
// "Lock Power Cut", "Main Power Cut",
|
||||
// "Relayboard CAN", "DB CAN Recv",
|
||||
// "MB Can Recv", "Smoke Alarm",
|
||||
// "Water Alarm", "Phase Failure",
|
||||
// "Earth Leakage"
|
||||
// ];
|
||||
|
||||
const ALARM_GROUPS = [
|
||||
{ name: "EarthLk", start_bit: 20, count: 11 },
|
||||
{ name: "DBCF", start_bit: 5, count: 9 },
|
||||
{ name: "RBCF", start_bit: 14, count: 3 },
|
||||
{ name: "PF", start_bit: 17, count: 3 },
|
||||
{ name: "SMPS", start_bit: 0, count: 2 },
|
||||
{ name: "MBCF", start_bit: 4, count: 1 },
|
||||
{ name: "Smoke Alarm", start_bit: 2, count: 1 },
|
||||
{ name: "Water Level", start_bit: 3, count: 1 },
|
||||
];
|
||||
|
||||
const BATTERY_FAULT_MAP = {
|
||||
8: "UT", // Under Temperature
|
||||
4: "OV", // Over Voltage
|
||||
2: "OT", // Over Temperature
|
||||
1: "OC" // Over Current
|
||||
};
|
||||
|
||||
const CHARGER_FAULT_MAP = {
|
||||
1: "OV", // Over Voltage
|
||||
2: "UV", // Under Voltage
|
||||
4: "OT", // Over Temperature
|
||||
8: "CAN Failure"
|
||||
// Add other charger fault codes here
|
||||
};
|
||||
|
||||
// --- NEW: SWAP PROCESS ELEMENTS & LOGIC ---
|
||||
const swapIdleText = document.getElementById('swap-idle-text');
|
||||
const swapPairsList = document.getElementById('swap-pairs-list');
|
||||
|
|
@ -113,6 +143,58 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
updateSwapUI();
|
||||
}
|
||||
|
||||
// asdfghjkjhgfdsdfghjkjhgfdfghghjkjhgfdsdfghjkjhgfdsdfghjkjhgfdsdfghjkjhgfdsdfghjkjhgfdsdfghjkjhgfdsdfghjkjhgf
|
||||
function toHex8(n){ return "0x" + (Number(n>>>0).toString(16).toUpperCase().padStart(8,"0")); }
|
||||
function sliceGroupBits(sdc, start, count){ return (sdc >>> start) & ((1<<count)-1); }
|
||||
function bitOnReversed(groupMask, indexFromLeft, count){
|
||||
const bitToCheck = count - 1 - indexFromLeft; // same as: num_indicators - 1 - i
|
||||
return ((groupMask >> bitToCheck) & 1) === 1;
|
||||
}
|
||||
|
||||
/* Renders quick chips row: PF/DBCF/etc lit if any bit in that group is 1 */
|
||||
function renderSdcChips(container, sdc){
|
||||
if(!container) return;
|
||||
container.innerHTML = "";
|
||||
ALARM_GROUPS.forEach(g=>{
|
||||
const anyOn = sliceGroupBits(sdc, g.start_bit, g.count) !== 0;
|
||||
const el = document.createElement("span");
|
||||
el.className = "chip " + (anyOn ? "chip-emerald" : "chip-slate");
|
||||
el.textContent = g.name;
|
||||
el.title = anyOn ? "Some bits set" : "All clear";
|
||||
container.appendChild(el);
|
||||
});
|
||||
}
|
||||
|
||||
/* Renders the detailed dots grid; leftmost dot = highest bit */
|
||||
function renderSdcGrid(container, sdc){
|
||||
if(!container) return;
|
||||
container.innerHTML = "";
|
||||
ALARM_GROUPS.forEach(g=>{
|
||||
const label = document.createElement("div");
|
||||
label.className = "text-xs text-slate-300 font-medium py-1";
|
||||
label.textContent = g.name;
|
||||
container.appendChild(label);
|
||||
|
||||
const row = document.createElement("div");
|
||||
row.className = "flex flex-wrap gap-1 py-1";
|
||||
const mask = sliceGroupBits(sdc, g.start_bit, g.count);
|
||||
|
||||
for(let i=0;i<g.count;i++){
|
||||
const on = bitOnReversed(mask, i, g.count);
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "w-5 h-5 rounded-full grid place-items-center border text-[10px] " +
|
||||
(on ? "bg-emerald-500/90 border-emerald-600 text-white"
|
||||
: "bg-slate-800 border-slate-600 text-slate-400");
|
||||
dot.textContent = (i+1).toString();
|
||||
dot.title = `Bit ${g.start_bit + (g.count - 1 - i)} = ${on?1:0}`;
|
||||
dot.setAttribute("role","switch");
|
||||
dot.setAttribute("aria-pressed", on ? "true":"false");
|
||||
row.appendChild(dot);
|
||||
}
|
||||
container.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// --- HELPER FUNCTIONS (Your original code is unchanged) ---
|
||||
|
||||
|
|
@ -149,6 +231,63 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
logTextArea.value = newLog + logTextArea.value;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Decodes a fault bitmask into a human-readable string using a given map.
|
||||
* @param {number} faultCode The fault code number.
|
||||
* @param {object} faultMap The map to use for decoding (e.g., BATTERY_FAULT_MAP).
|
||||
* @returns {string} A comma-separated string of active faults, or "—" if none.
|
||||
*/
|
||||
const decodeFaults = (faultCode, faultMap) => {
|
||||
if (!faultCode || faultCode === 0) {
|
||||
return "—"; // No fault
|
||||
}
|
||||
|
||||
const activeFaults = [];
|
||||
for (const bit in faultMap) {
|
||||
if ((faultCode & bit) !== 0) {
|
||||
activeFaults.push(faultMap[bit]);
|
||||
}
|
||||
}
|
||||
|
||||
return activeFaults.length > 0 ? activeFaults.join(', ') : "—";
|
||||
};
|
||||
|
||||
|
||||
// --- NEW: Function to decode the SDC and create visual pill elements ---
|
||||
/**
|
||||
* Decodes the SDC and creates visual pill elements for active faults.
|
||||
* @param {number} sdcCode The station diagnostic code as an integer.
|
||||
*/
|
||||
// const updateSdcVisuals = (sdcCode) => {
|
||||
// if (!sdcVisualFlagsEl) return;
|
||||
// sdcVisualFlagsEl.innerHTML = ''; // Clear previous flags
|
||||
|
||||
// let hasActiveFault = false;
|
||||
|
||||
// for (const bitIndex in SDC_FLAGS) {
|
||||
// const bit = parseInt(bitIndex, 10);
|
||||
// if ((sdcCode & (1 << bit)) !== 0) {
|
||||
// hasActiveFault = true;
|
||||
// const flag = SDC_FLAGS[bitIndex];
|
||||
// const pill = document.createElement('div');
|
||||
// // Tailwind class for the pill look (using your defined style)
|
||||
// pill.className = `flex items-center gap-1.5 text-xs font-medium rounded-full px-3 py-1 ${flag.style}`;
|
||||
// // Use Lucide icon from the map
|
||||
// pill.innerHTML = `<i data-lucide="${flag.icon}" class="w-3.5 h-3.5 flex-shrink-0"></i> <span>${flag.name}</span>`;
|
||||
// sdcVisualFlagsEl.appendChild(pill);
|
||||
// }
|
||||
// }
|
||||
|
||||
// // If no active faults, display a healthy message
|
||||
// if (!hasActiveFault) {
|
||||
// sdcVisualFlagsEl.innerHTML = '<span class="text-xs text-emerald-400 font-semibold flex items-center gap-1"><i data-lucide="check-circle" class="w-4 h-4"></i> System OK</span>';
|
||||
// }
|
||||
// // Re-run Lucide to render the newly added icons (like check-circle)
|
||||
// lucide.createIcons();
|
||||
// };
|
||||
|
||||
|
||||
const updateChamberUI = (card, slot) => {
|
||||
if (!card || !slot) return;
|
||||
|
||||
|
|
@ -176,18 +315,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
card.querySelector('.soc').textContent = `${slot.soc || 0}%`;
|
||||
card.querySelector('.voltage').textContent = `${((slot.voltage || 0) / 1000).toFixed(1)} V`;
|
||||
card.querySelector('.bat-temp').textContent = `${((slot.batteryMaxTemp || 0) / 10).toFixed(1)} °C`;
|
||||
card.querySelector('.bat-fault').textContent = slot.batteryFaultCode || '—';
|
||||
// card.querySelector('.bat-fault').textContent = slot.batteryFaultCode || '—';
|
||||
|
||||
card.querySelector('.bat-fault').textContent = decodeFaults(slot.batteryFaultCode, BATTERY_FAULT_MAP);
|
||||
|
||||
// card.querySelector('.bat-fault').textContent = decodeBatteryFaults(slot.batteryFaultCode);
|
||||
|
||||
card.querySelector('.current').textContent = `${((slot.current || 0) / 1000).toFixed(1)} A`;
|
||||
card.querySelector('.slot-temp').textContent = `${((slot.slotTemperature || 0) / 10).toFixed(1)} °C`;
|
||||
card.querySelector('.chg-fault').textContent = slot.chargerFaultCode || '—';
|
||||
card.querySelector('.chg-temp').textContent = `${((slot.chargerMaxTemp || 0) / 10).toFixed(1)} °C`;
|
||||
// card.querySelector('.chg-fault').textContent = slot.chargerFaultCode || '—';
|
||||
card.querySelector('.chg-fault').textContent = decodeFaults(slot.chargerFaultCode, CHARGER_FAULT_MAP);
|
||||
|
||||
const batPill = card.querySelector('.battery-status-pill');
|
||||
batPill.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Present`;
|
||||
batPill.className = 'battery-status-pill chip chip-emerald';
|
||||
|
||||
const chgPill = card.querySelector('.charger-status-pill');
|
||||
if (slot.chargerMode === 1) {
|
||||
if (slot.chargerPresent && slot.current > 0) {
|
||||
chgPill.innerHTML = `<span class="h-2 w-2 rounded-full bg-sky-400 animate-pulseDot"></span> Charging`;
|
||||
chgPill.className = 'charger-status-pill chip chip-sky';
|
||||
} else {
|
||||
|
|
@ -219,25 +364,62 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
};
|
||||
|
||||
// --- NEW: Function to decode the SDC and update the UI ---
|
||||
const updateDiagnosticsUI = (sdcCode) => {
|
||||
if (!diagFlagsGrid) return;
|
||||
diagFlagsGrid.innerHTML = ''; // Clear previous statuses
|
||||
// const updateDiagnosticsUI = (sdcCode) => {
|
||||
// if (!diagFlagsGrid) return;
|
||||
// diagFlagsGrid.innerHTML = ''; // Clear previous statuses
|
||||
|
||||
DIAGNOSTIC_ERRORS.forEach((errorText, index) => {
|
||||
// Use bitwise AND to check if the bit at this index is set
|
||||
const isActive = (sdcCode & (1 << index)) !== 0;
|
||||
// DIAGNOSTIC_ERRORS.forEach((errorText, index) => {
|
||||
// // Use bitwise AND to check if the bit at this index is set
|
||||
// const isActive = (sdcCode & (1 << index)) !== 0;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.textContent = errorText;
|
||||
// const div = document.createElement('div');
|
||||
// div.textContent = errorText;
|
||||
|
||||
// Apply different styles based on whether the alarm is active
|
||||
if (isActive) {
|
||||
div.className = 'text-rose-300 text-center font-semibold';
|
||||
} else {
|
||||
div.className = 'text-slate-500 text-center';
|
||||
// // Apply different styles based on whether the alarm is active
|
||||
// if (isActive) {
|
||||
// div.className = 'text-rose-300 text-center font-semibold';
|
||||
// } else {
|
||||
// div.className = 'text-slate-500 text-center';
|
||||
// }
|
||||
// diagFlagsGrid.appendChild(div);
|
||||
// });
|
||||
// };
|
||||
function updateDiagnosticAlarms(sdcValue){
|
||||
const sdc = Number(sdcValue);
|
||||
const hexEl = document.getElementById("station-diag-code-raw"); // hex display
|
||||
const chipsEl = document.getElementById("sdc-visual-flags"); // quick flags row
|
||||
const gridEl = document.getElementById("diag-flags-grid"); // dots grid
|
||||
|
||||
if (hexEl) hexEl.textContent = toHex8(sdc);
|
||||
renderSdcChips(chipsEl, sdc);
|
||||
renderSdcGrid(gridEl, sdc);
|
||||
}
|
||||
|
||||
|
||||
|
||||
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 && connChip) {
|
||||
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';
|
||||
if (lastUpdateEl) lastUpdateEl.textContent = "Waiting for data...";
|
||||
// Optionally reset the dashboard if the station goes offline
|
||||
// resetDashboardUI();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch station status:", error);
|
||||
}
|
||||
diagFlagsGrid.appendChild(div);
|
||||
});
|
||||
};
|
||||
|
||||
const resetDashboardUI = () => {
|
||||
|
|
@ -255,200 +437,38 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
card.querySelector('.filled-state').style.display = 'none';
|
||||
card.querySelector('.empty-state').style.display = 'flex';
|
||||
});
|
||||
updateDiagnosticAlarms(0);
|
||||
logToInstance("Station is offline. Clearing stale data.", "error");
|
||||
};
|
||||
|
||||
// --- NEW: This function polls the API for the true station status ---
|
||||
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);
|
||||
// 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 && connChip) {
|
||||
// if (thisStation && connChip) {
|
||||
|
||||
stationNameEl.textContent = thisStation.name;
|
||||
stationLocationEl.textContent = thisStation.location;
|
||||
// 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';
|
||||
lastUpdateEl.textContent = "Waiting for data...";
|
||||
resetDashboardUI();
|
||||
}
|
||||
}
|
||||
} catch (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;
|
||||
}
|
||||
};
|
||||
};
|
||||
// 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';
|
||||
// lastUpdateEl.textContent = "Waiting for data...";
|
||||
// resetDashboardUI();
|
||||
// }
|
||||
// }
|
||||
// } catch (error) {
|
||||
// console.error("Failed to fetch station status:", error);
|
||||
// }
|
||||
// };
|
||||
|
||||
// --- MAIN LOGIC (Your original code is unchanged) ---
|
||||
const initializeDashboard = () => {
|
||||
|
|
@ -459,7 +479,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
stationNameEl.textContent = selectedStation.name || 'Unknown Station';
|
||||
stationLocationEl.textContent = selectedStation.location || 'No location';
|
||||
|
||||
// This populates the span with id="device-id" with the Station's ID
|
||||
deviceIdEl.textContent = selectedStation.id;
|
||||
|
||||
// This populates the span with id="product-id" with the Product 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">Go Back</a></div>`;
|
||||
return;
|
||||
|
|
@ -540,7 +565,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
window.location.href = './index.html';
|
||||
});
|
||||
}
|
||||
if (downloadBtn) downloadBtn.addEventListener('click', showDownloadModal);
|
||||
|
||||
// Audio Command Button (assuming it exists in your HTML)
|
||||
const sendAudioBtn = document.getElementById('send-audio-btn');
|
||||
|
|
@ -574,18 +598,27 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
});
|
||||
|
||||
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;
|
||||
|
||||
console.log("Received data payload:", data);
|
||||
|
||||
if (stationId !== selectedStation.id) {
|
||||
console.warn(`Ignoring message for wrong station. Expected ${selectedStation.id}, got ${stationId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
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 sdcRaw = data.stationDiagnosticCode;
|
||||
const sdc = Number.isFinite(sdcRaw) ? Number(sdcRaw) : 0;
|
||||
updateDiagnosticAlarms(sdc);
|
||||
}
|
||||
|
||||
// 2. Check if the 'backupSupplyStatus' key exists.
|
||||
if (data.hasOwnProperty('backupSupplyStatus')) {
|
||||
if (data.backupSupplyStatus === 1) {
|
||||
backupPowerChip.textContent = 'On Backup';
|
||||
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-amber';
|
||||
|
|
@ -593,17 +626,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
backupPowerChip.textContent = 'On Mains Power';
|
||||
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-emerald';
|
||||
}
|
||||
}
|
||||
|
||||
lastUpdateEl.textContent = new Date().toLocaleTimeString();
|
||||
stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—';
|
||||
|
||||
// --- NEW: Call the function to update the diagnostics grid ---
|
||||
updateDiagnosticsUI(data.stationDiagnosticCode || 0);
|
||||
|
||||
// 3. Only process chamber-level data if it exists.
|
||||
if (data.slotLevelPayload && Array.isArray(data.slotLevelPayload)) {
|
||||
data.slotLevelPayload.forEach((slotData, index) => {
|
||||
const slotId = index + 1;
|
||||
chamberData[slotId - 1] = slotData; // Keep live data in sync
|
||||
const card = grid.querySelector(`.chamber-card[data-chamber-id="${slotId}"]`);
|
||||
if (card) {
|
||||
updateChamberUI(card, slotData);
|
||||
|
|
|
|||
|
|
@ -1,101 +1,152 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- CONFIGURATION ---
|
||||
const SOCKET_URL = "http://localhost:5000";
|
||||
const API_BASE = "http://localhost:5000/api";
|
||||
const SOCKET_URL = "http://172.20.10.4:5000";
|
||||
const API_BASE = "http://172.20.10.4:5000/api";
|
||||
|
||||
// --- DOM ELEMENT REFERENCES ---
|
||||
const stationNameEl = document.getElementById('station-name');
|
||||
const stationLocationEl = document.getElementById('station-location');
|
||||
const deviceIdEl = document.getElementById('device-id');
|
||||
const lastUpdateEl = document.getElementById('last-update-status');
|
||||
const connChip = document.getElementById('connection-status-chip');
|
||||
const productIdEl = document.getElementById('product-id');
|
||||
const requestLogArea = document.getElementById('request-log-area');
|
||||
const eventLogArea = document.getElementById('event-log-area');
|
||||
const connChip = document.getElementById('connection-status-chip');
|
||||
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');
|
||||
const logCountInput = document.getElementById('log-count');
|
||||
const fromDateInput = document.getElementById('from-date');
|
||||
const toDateInput = document.getElementById('to-date');
|
||||
const applyFiltersBtn = document.getElementById('apply-filters-btn');
|
||||
|
||||
// --- STATE ---
|
||||
let selectedStation = null;
|
||||
let socket;
|
||||
let statusPollingInterval;
|
||||
let fromDatePicker, toDatePicker;
|
||||
|
||||
// --- HELPER FUNCTIONS ---
|
||||
const prependLog = (textarea, data) => {
|
||||
// --- HELPER FUNCTIONS --
|
||||
|
||||
const appendLog = (textarea, data, topic, timestampStr) => {
|
||||
if (!textarea) return;
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
|
||||
const timestamp = new Date(timestampStr).toLocaleString();
|
||||
const formattedJson = JSON.stringify(data, null, 2);
|
||||
const newLog = `[${timestamp}]\n${formattedJson}\n\n---------------------------------\n\n`;
|
||||
textarea.value = newLog + textarea.value;
|
||||
|
||||
// Clean up the topic for better display
|
||||
const topicParts = topic.split('/');
|
||||
const shortTopic = topicParts.slice(-2).join('/'); // Gets the last two parts, e.g., "RPC/REQUEST" or ".../EVENTS"
|
||||
|
||||
const newLog = `[${timestamp}] - Topic: ${shortTopic}\n${formattedJson}\n\n---------------------------------\n\n`;
|
||||
|
||||
textarea.value += newLog;
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
};
|
||||
|
||||
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 fetchRecentLogs = async () => {
|
||||
// try {
|
||||
// const response = await fetch(`${API_BASE}/logs/recent/${selectedStation.id}`);
|
||||
// if (!response.ok) throw new Error('Failed to fetch recent logs');
|
||||
// const logs = await response.json();
|
||||
|
||||
// requestLogArea.value = '';
|
||||
// eventLogArea.value = '';
|
||||
|
||||
// logs.forEach(log => {
|
||||
// if (log.topic.endsWith('EVENTS')) {
|
||||
// appendLog(eventLogArea, log.payload, log.topic, log.timestamp);
|
||||
// } else if (log.topic.endsWith('REQUEST')) {
|
||||
// appendLog(requestLogArea, log.payload, log.topic, log.timestamp);
|
||||
// }
|
||||
// });
|
||||
// console.log(`Successfully fetched and rendered ${logs.length} recent logs.`);
|
||||
// } catch (error) {
|
||||
// console.error(error);
|
||||
// }
|
||||
// };
|
||||
|
||||
|
||||
|
||||
const fetchRecentLogs = async () => {
|
||||
// Get values from all filters
|
||||
const count = logCountInput.value;
|
||||
const startDate = fromDatePicker.selectedDates[0];
|
||||
const endDate = toDatePicker.selectedDates[0];
|
||||
|
||||
if (!startDate || !endDate) return;
|
||||
|
||||
const startDateStr = flatpickr.formatDate(startDate, "Y-m-d");
|
||||
const endDateStr = flatpickr.formatDate(endDate, "Y-m-d");
|
||||
|
||||
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';
|
||||
// Build URL with all parameters
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDateStr,
|
||||
end_date: endDateStr,
|
||||
count: count
|
||||
});
|
||||
const response = await fetch(`${API_BASE}/logs/recent/${selectedStation.id}?${params}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch recent logs');
|
||||
const logs = await response.json();
|
||||
|
||||
requestLogArea.value = '';
|
||||
eventLogArea.value = '';
|
||||
|
||||
logs.forEach(log => {
|
||||
if (log.topic.endsWith('EVENTS')) {
|
||||
appendLog(eventLogArea, log.payload, log.topic, log.timestamp);
|
||||
} else if (log.topic.endsWith('REQUEST')) {
|
||||
appendLog(requestLogArea, log.payload, log.topic, log.timestamp);
|
||||
}
|
||||
});
|
||||
console.log(`Successfully fetched and rendered ${logs.length} recent logs.`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} catch (error) { console.error("Failed to fetch station status:", error); }
|
||||
};
|
||||
|
||||
// --- INITIALIZATION ---
|
||||
// --- INITIALIZATION AND EVENT HANDLERS ---
|
||||
function init() {
|
||||
console.log("1. Starting initialization...");
|
||||
|
||||
// Step 1: Load the station from localStorage.
|
||||
try {
|
||||
selectedStation = JSON.parse(localStorage.getItem('selected_station'));
|
||||
const stationData = localStorage.getItem('selected_station');
|
||||
console.log("2. Fetched from localStorage:", stationData);
|
||||
|
||||
if (!stationData) {
|
||||
throw new Error('No station data found in localStorage.');
|
||||
}
|
||||
|
||||
selectedStation = JSON.parse(stationData);
|
||||
console.log("3. Parsed station data:", selectedStation);
|
||||
|
||||
if (!selectedStation || !selectedStation.id) {
|
||||
throw new Error('No station selected. Please go back to the selection page.');
|
||||
throw new Error('Parsed station data is invalid or missing an ID.');
|
||||
}
|
||||
deviceIdEl.textContent = selectedStation.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>`;
|
||||
console.error("ERROR during station loading:", e);
|
||||
// window.location.href = './station_selection.html'; // Temporarily disable redirect for debugging
|
||||
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 });
|
||||
});
|
||||
// Step 2: Populate the header.
|
||||
console.log("4. Populating header...");
|
||||
stationNameEl.textContent = selectedStation.name;
|
||||
stationLocationEl.textContent = selectedStation.location;
|
||||
deviceIdEl.textContent = selectedStation.id;
|
||||
productIdEl.textContent = selectedStation.product_id;
|
||||
console.log("5. Header populated.");
|
||||
|
||||
socket.on('dashboard_update', (message) => {
|
||||
const { stationId, topic, data } = message;
|
||||
if (stationId !== selectedStation.id) return;
|
||||
fromDatePicker = flatpickr(fromDateInput, { dateFormat: "Y-m-d", defaultDate: "today" });
|
||||
toDatePicker = flatpickr(toDateInput, { dateFormat: "Y-m-d", defaultDate: "today" });
|
||||
|
||||
lastUpdateEl.textContent = 'Last Recv ' + new Date().toLocaleTimeString();
|
||||
applyFiltersBtn.addEventListener('click', fetchRecentLogs);
|
||||
|
||||
if (topic.endsWith('EVENTS')) {
|
||||
prependLog(eventLogArea, data);
|
||||
} else if (topic.endsWith('REQUEST')) {
|
||||
prependLog(requestLogArea, data);
|
||||
}
|
||||
});
|
||||
|
||||
// --- BUTTON EVENT LISTENERS ---
|
||||
// Step 3: Set up button event listeners.
|
||||
if(clearReqBtn) clearReqBtn.addEventListener('click', () => requestLogArea.value = '');
|
||||
if(clearEvtBtn) clearEvtBtn.addEventListener('click', () => eventLogArea.value = '');
|
||||
if(clearAllBtn) clearAllBtn.addEventListener('click', () => {
|
||||
|
|
@ -107,17 +158,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
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');
|
||||
console.log("6. Event listeners attached.");
|
||||
|
||||
// Step 4: Fetch initial data now that everything is set up.
|
||||
console.log("7. Calling fetchRecentLogs()...");
|
||||
fetchRecentLogs();
|
||||
|
||||
// Step 5: Start WebSocket connection for live updates.
|
||||
socket = io(SOCKET_URL);
|
||||
socket.on('connect', () => {
|
||||
console.log("Logs Page: Connected to WebSocket.");
|
||||
socket.emit('join_station_room', { station_id: selectedStation.id });
|
||||
});
|
||||
socket.on('dashboard_update', (message) => {
|
||||
const { stationId, topic, data } = message;
|
||||
if (stationId !== selectedStation.id) return;
|
||||
const now = new Date().toISOString();
|
||||
if (topic.endsWith('EVENTS')) {
|
||||
appendLog(eventLogArea, data, topic, now);
|
||||
} else if (topic.endsWith('REQUEST')) {
|
||||
appendLog(requestLogArea, data, topic, now);
|
||||
}
|
||||
});
|
||||
|
||||
// --- STARTUP ---
|
||||
checkStationStatus();
|
||||
statusPollingInterval = setInterval(checkStationStatus, 10000);
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
|
||||
|
||||
// --- START THE APPLICATION ---
|
||||
init();
|
||||
});
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
// // frontend/js/page-header.js
|
||||
// document.addEventListener('DOMContentLoaded', () => {
|
||||
// // 1. Get the station data from Local Storage
|
||||
// const selectedStation = JSON.parse(localStorage.getItem('selected_station'));
|
||||
|
||||
// // 2. Safety check: If no station is selected, go back to the selection page
|
||||
// if (!selectedStation) {
|
||||
// alert('No station selected. Redirecting to the selection page.');
|
||||
// window.location.href = 'station_selection.html';
|
||||
// return;
|
||||
// }
|
||||
|
||||
// // 3. Find all the display elements in the header
|
||||
// const stationNameEl = document.getElementById('station-name');
|
||||
// const stationLocationEl = document.getElementById('station-location');
|
||||
// const stationIdEl = document.getElementById('station-id-display');
|
||||
// const productIdEl = document.getElementById('product-id-display');
|
||||
|
||||
// // 4. Update the elements with the station's data
|
||||
// if (stationNameEl) stationNameEl.textContent = selectedStation.name;
|
||||
// if (stationLocationEl) stationLocationEl.textContent = selectedStation.location;
|
||||
// if (stationIdEl) stationIdEl.textContent = selectedStation.id;
|
||||
// if (productIdEl) productIdEl.textContent = selectedStation.product_id;
|
||||
// });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// frontend/js/page-header.js
|
||||
|
||||
// This function fetches the common header and injects it into the page
|
||||
async function loadHeader() {
|
||||
try {
|
||||
const response = await fetch('_header.html');
|
||||
if (!response.ok) {
|
||||
throw new Error('Could not load the header file.');
|
||||
}
|
||||
const headerHTML = await response.text();
|
||||
// Adds the header right after the opening <body> tag
|
||||
document.body.insertAdjacentHTML('afterbegin', headerHTML);
|
||||
} catch (error) {
|
||||
console.error('Failed to load header:', error);
|
||||
// Optionally, display an error to the user
|
||||
}
|
||||
}
|
||||
|
||||
// This function populates the header with data from localStorage
|
||||
function populateHeaderData() {
|
||||
const selectedStation = JSON.parse(localStorage.getItem('selected_station'));
|
||||
|
||||
if (!selectedStation) {
|
||||
alert('No station selected. Redirecting...');
|
||||
window.location.href = 'station_selection.html';
|
||||
return;
|
||||
}
|
||||
|
||||
const stationNameEl = document.getElementById('station-name');
|
||||
const stationLocationEl = document.getElementById('station-location');
|
||||
const stationIdEl = document.getElementById('station-id-display');
|
||||
const productIdEl = document.getElementById('product-id-display');
|
||||
|
||||
if (stationNameEl) stationNameEl.textContent = selectedStation.name;
|
||||
if (stationLocationEl) stationLocationEl.textContent = selectedStation.location;
|
||||
if (stationIdEl) stationIdEl.textContent = selectedStation.id;
|
||||
if (productIdEl) productIdEl.textContent = selectedStation.product_id;
|
||||
}
|
||||
|
||||
// Main execution block
|
||||
// We use an async function to make sure the header is loaded BEFORE we try to fill it
|
||||
async function initializePageHeader() {
|
||||
await loadHeader();
|
||||
populateHeaderData();
|
||||
}
|
||||
|
||||
// Run the initialization when the page content is loaded
|
||||
document.addEventListener('DOMContentLoaded', initializePageHeader);
|
||||
|
|
@ -1,25 +1,22 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// --- DOM ELEMENTS ---
|
||||
const stationsGrid = document.getElementById('stations-grid');
|
||||
const stationTemplate = document.getElementById('stationCardTemplate');
|
||||
const errorMessage = document.getElementById('error-message');
|
||||
const stationCountEl = document.getElementById('station-count');
|
||||
// Note: SocketIO is not needed on this page anymore
|
||||
const stationCountEl = document.getElementById('station-count'); // Make sure you have an element with this ID in your HTML
|
||||
|
||||
let allStations = []; // To store the master list of stations
|
||||
// --- CONFIG & STATE ---
|
||||
const API_BASE = 'http://172.20.10.4:5000/api';
|
||||
let allStations = []; // Master list of stations from the API
|
||||
let pollingInterval = null;
|
||||
|
||||
// --- AUTHENTICATION & USER INFO (Your existing code is perfect) ---
|
||||
// --- AUTHENTICATION ---
|
||||
const user = JSON.parse(localStorage.getItem('user'));
|
||||
if (!user) {
|
||||
window.location.href = 'index.html'; // Redirect if not logged in
|
||||
return;
|
||||
}
|
||||
|
||||
// User info and logout button logic... (omitted for brevity, no changes needed)
|
||||
|
||||
// --- ADMIN FEATURES (Your existing code is perfect) ---
|
||||
// Admin button and add station card logic... (omitted for brevity, no changes needed)
|
||||
|
||||
// (Your other button listeners for logout, add user, etc., can go here)
|
||||
// document.getElementById('logoutBtn').onclick = () => { ... };
|
||||
|
||||
// --- HELPER FUNCTIONS ---
|
||||
const getStatusAttributes = (status) => {
|
||||
|
|
@ -34,45 +31,76 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
window.location.href = `dashboard.html?station_id=${stationId}`;
|
||||
};
|
||||
|
||||
// This function now only renders the initial grid
|
||||
// --- UI RENDERING ---
|
||||
// This function's only job is to build the HTML. It does not add event listeners.
|
||||
const renderStations = (stations) => {
|
||||
stationsGrid.innerHTML = '';
|
||||
stationCountEl.textContent = `${stations.length} stations found. Select one to monitor.`;
|
||||
stationsGrid.innerHTML = ''; // Clear the grid
|
||||
stationCountEl.textContent = `${stations.length} stations found.`;
|
||||
|
||||
stations.forEach(station => {
|
||||
const status = getStatusAttributes(station.status);
|
||||
const card = document.createElement('div');
|
||||
card.className = "group bg-gray-900/60 backdrop-blur-xl rounded-2xl shadow-lg border border-gray-700 transition-transform duration-300 ease-out cursor-pointer 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.id = `station-${station.id}`;
|
||||
card.onclick = () => handleStationSelect(station.id);
|
||||
// Add station ID to the card's dataset for easy access
|
||||
card.dataset.stationId = station.id;
|
||||
card.dataset.stationName = station.name;
|
||||
card.className = "station-card 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 = `
|
||||
<div class="p-5">
|
||||
<div class="main-content p-5 flex-grow cursor-pointer" data-station-json='${JSON.stringify(station)}'>
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<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}">
|
||||
<i data-lucide="${status.icon}" class="w-4 h-4 mr-1.5"></i>
|
||||
<span class="status-text">${station.status}</span>
|
||||
</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 class="border-t border-gray-700/50 px-5 pt-3 pb-4">
|
||||
<div class="grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
`;
|
||||
stationsGrid.appendChild(card);
|
||||
});
|
||||
|
||||
if (window.lucide) {
|
||||
lucide.createIcons();
|
||||
}
|
||||
};
|
||||
|
||||
// --- NEW: Function to update statuses without redrawing everything ---
|
||||
const updateStationStatuses = (stations) => {
|
||||
stations.forEach(station => {
|
||||
const card = document.getElementById(`station-${station.id}`);
|
||||
const card = stationsGrid.querySelector(`[data-station-id="${station.id}"]`);
|
||||
if (card) {
|
||||
const status = getStatusAttributes(station.status);
|
||||
const statusBadge = card.querySelector('.status-badge');
|
||||
const statusText = card.querySelector('.status-text');
|
||||
const statusIcon = card.querySelector('i[data-lucide]');
|
||||
|
||||
if (statusBadge && statusText && statusIcon) {
|
||||
statusBadge.className = `status-badge flex items-center text-xs font-semibold px-3 py-1 rounded-full ${status.bgColor} ${status.color}`;
|
||||
statusText.textContent = station.status;
|
||||
|
|
@ -80,35 +108,98 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
}
|
||||
}
|
||||
});
|
||||
lucide.createIcons(); // Re-render icons if any changed
|
||||
if (window.lucide) {
|
||||
lucide.createIcons();
|
||||
}
|
||||
};
|
||||
|
||||
// --- DATA FETCHING & STATUS POLLING ---
|
||||
const loadAndPollStations = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:5000/api/stations');
|
||||
if (!response.ok) throw new Error('Failed to fetch stations');
|
||||
const stations = await response.json();
|
||||
|
||||
// Check if this is the first time loading data
|
||||
if (allStations.length === 0) {
|
||||
allStations = stations;
|
||||
renderStations(allStations); // Initial full render
|
||||
} else {
|
||||
allStations = stations;
|
||||
updateStationStatuses(allStations); // Subsequent, efficient updates
|
||||
//-- 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 ---
|
||||
// This single listener handles all clicks on the grid for efficiency.
|
||||
stationsGrid.addEventListener('click', async (event) => {
|
||||
const mainContent = event.target.closest('.main-content');
|
||||
const removeButton = event.target.closest('.remove-btn');
|
||||
|
||||
if (mainContent) {
|
||||
const card = mainContent.closest('[data-station-id]');
|
||||
if (card) {
|
||||
handleStationSelect(card.dataset.stationId);
|
||||
}
|
||||
} else if (removeButton) {
|
||||
event.stopPropagation(); // Prevent main content click
|
||||
const card = removeButton.closest('[data-station-id]');
|
||||
const stationId = card.dataset.stationId;
|
||||
const stationName = card.dataset.stationName;
|
||||
|
||||
if (!confirm(`Are you sure you want to permanently remove "${stationName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/stations/${stationId}`, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
alert(`Station "${stationName}" removed successfully.`);
|
||||
allStations = []; // Force a full refresh on next poll
|
||||
loadAndPollStations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to remove station: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing station:', error);
|
||||
alert('An error occurred while trying to remove the station.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- DATA FETCHING & POLLING ---
|
||||
const loadAndPollStations = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/stations`);
|
||||
if (!response.ok) throw new Error('Failed to fetch stations');
|
||||
|
||||
const newStationList = await response.json();
|
||||
|
||||
// If the number of stations has changed, we must do a full re-render.
|
||||
if (newStationList.length !== allStations.length) {
|
||||
allStations = newStationList;
|
||||
renderStations(allStations);
|
||||
} else {
|
||||
// Otherwise, we can do a more efficient status-only update.
|
||||
allStations = newStationList;
|
||||
updateStationStatuses(allStations);
|
||||
fetchAndApplyStats(); // Fetch and update daily stats
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
stationCountEl.textContent = 'Could not load stations. Is the backend running?';
|
||||
// Stop polling on error
|
||||
if (pollingInterval) clearInterval(pollingInterval);
|
||||
}
|
||||
};
|
||||
|
||||
// --- INITIALIZATION ---
|
||||
loadAndPollStations(); // Load immediately on page start
|
||||
// Then, set an interval to refresh the statuses every 10 seconds
|
||||
const pollingInterval = setInterval(loadAndPollStations, 10000);
|
||||
pollingInterval = setInterval(loadAndPollStations, 10000);
|
||||
});
|
||||
|
|
@ -5,10 +5,18 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Swap Station – Logs</title>
|
||||
|
||||
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
|
||||
|
||||
<script src="js/auth-guard.js"></script>
|
||||
|
||||
<!-- Font + 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.jsdelivr.net/npm/flatpickr"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
|
|
@ -96,9 +104,19 @@
|
|||
<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> -->
|
||||
|
||||
<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">
|
||||
|
|
@ -156,6 +174,25 @@
|
|||
<main class="relative z-10 mx-auto max-w-7.5xl px-3 sm:px-4 py-4 flex flex-col gap-4">
|
||||
<!-- Top row: Device badge + hint -->
|
||||
|
||||
<!-- <section class="flex items-center gap-2 mb-4">
|
||||
<label for="log-date-picker" class="text-sm font-bold text-gray-300">Select Date:</label>
|
||||
<input id="log-date-picker" type="text" placeholder="Select a 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">
|
||||
</section> -->
|
||||
|
||||
<section class="flex flex-wrap items-center gap-4 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<label for="log-count" class="text-sm font-bold text-gray-300">Log Count:</label>
|
||||
<input id="log-count" type="number" value="50" min="10" max="500" class="w-20 rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none">
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<input id="from-date" type="text" placeholder="Start Date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none">
|
||||
<span class="text-gray-500">to</span>
|
||||
<input id="to-date" type="text" placeholder="End Date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none">
|
||||
<button id="apply-filters-btn" class="btn">Apply</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Logs panels -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-[18rem]">
|
||||
<!-- Request -->
|
||||
|
|
@ -201,6 +238,7 @@
|
|||
</div>
|
||||
</main>
|
||||
|
||||
<script src="./js/logs.js"></script>
|
||||
<script src="./js/common-header.js"></script>
|
||||
<script src="./js/logs.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,14 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Swap Station – Select a Station</title>
|
||||
|
||||
<script src="js/auth-guard.js"></script>
|
||||
|
||||
<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;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
<script src="https://unpkg.com/lucide@latest" defer></script>
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
|
|
@ -109,7 +113,16 @@
|
|||
<span class="status-dot h-2.5 w-2.5 rounded-full"></span>
|
||||
<p class="truncate text-sm text-gray-400"><span class="font-semibold text-gray-200 station-name">Station</span></p>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400 station-location">Location</p>
|
||||
<!-- <p class="product-id mt-1 text-xs text-gray-400" title="Product ID">-</p> -->
|
||||
<div class="product-id-wrapper mt-1 flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<i data-lucide="hash" class="h-3 w-3 text-gray-500"></i>
|
||||
<span class="product-id">—</span>
|
||||
</div>
|
||||
<!-- <p class="mt-1 text-xs text-gray-400 station-location">Location</p> -->
|
||||
<div class="location-wrapper mt-1 flex items-center gap-1.5 text-xs text-gray-400">
|
||||
<i data-lucide="map-pin" class="h-3 w-3 text-gray-500"></i>
|
||||
<span class="station-location">Location</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-badge rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"></span>
|
||||
</div>
|
||||
|
|
@ -120,6 +133,10 @@
|
|||
<p class="text-[10px] text-gray-400">Station ID</p>
|
||||
<p class="station-id font-semibold text-gray-200 whitespace-nowrap overflow-x-auto leading-snug text-[11px]" title="Station ID">—</p>
|
||||
</div>
|
||||
<!-- <div class="rounded-lg border border-white/10 bg-black/20 p-2 min-w-0">
|
||||
<p class="text-[10px] text-gray-400">Product ID</p>
|
||||
<p class="product-id font-semibold text-gray-200 whitespace-nowrap overflow-x-auto leading-snug text-[11px]" title="Product ID">—</p>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<!-- Metrics Row -->
|
||||
|
|
@ -138,9 +155,21 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="open-btn mt-4 w-full rounded-xl bg-gradient-to-r from-emerald-500 via-teal-500 to-cyan-500 px-3 py-2 text-xs font-semibold text-white transition group-hover:brightness-110 group-hover:-translate-y-px"> Open
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<button class="open-btn w-full flex-grow rounded-lg bg-gradient-to-r from-emerald-500 to-teal-500 px-3 py-2 text-xs font-semibold text-white transition hover:brightness-110">
|
||||
Open
|
||||
</button>
|
||||
|
||||
<button class="remove-btn flex-shrink-0 rounded-lg border border-white/10 bg-white/5 p-2 text-rose-400 transition hover:border-rose-400/60 hover:bg-rose-500/10">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2">
|
||||
<path d="M3 6h18"/>
|
||||
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
||||
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
|
||||
<line x1="10" x2="10" y1="11" y2="17"/>
|
||||
<line x1="14" x2="14" y1="11" y2="17"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -179,6 +208,7 @@
|
|||
<h2 class="text-lg font-bold mb-4">Add Station</h2>
|
||||
<form id="stationForm" class="space-y-3">
|
||||
<input type="text" placeholder="Station ID" id="stationId" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||||
<input type="text" placeholder="Product ID" id="stationProductId" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||||
<input type="text" placeholder="Name" id="stationName" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||||
<input type="text" placeholder="Location" id="stationLocation" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||||
<input type="text" placeholder="MQTT Broker" id="mqttBroker" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
|
||||
|
|
@ -195,7 +225,7 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = 'http://localhost:5000/api';
|
||||
const API_BASE = 'http://172.20.10.4:5000/api';
|
||||
|
||||
const grid = document.getElementById('stations-grid');
|
||||
const addStationCardTmpl = document.getElementById('add-station-card-template');
|
||||
|
|
@ -244,13 +274,15 @@
|
|||
e.preventDefault();
|
||||
const payload = {
|
||||
station_id: stationId.value.trim(),
|
||||
product_id: stationProductId.value.trim(),
|
||||
name: stationName.value.trim(),
|
||||
location: stationLocation.value.trim(),
|
||||
mqtt_broker: mqttBroker.value.trim(),
|
||||
mqtt_port: Number(mqttPort.value),
|
||||
mqtt_username: mqttUsername.value || null,
|
||||
mqtt_user: mqttUsername.value || null,
|
||||
mqtt_password: mqttPassword.value || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/stations`, {
|
||||
method:'POST',
|
||||
|
|
@ -289,6 +321,17 @@
|
|||
const node = stationCardTmpl.content.cloneNode(true);
|
||||
const card = node.querySelector('div');
|
||||
card.querySelector('.station-name').textContent = s.name ?? `Station ${s.id || s.station_id}`;
|
||||
// const productIdVal = s.product_id || '—';
|
||||
// const productIdEl = card.querySelector('.product-id');
|
||||
// if (productIdEl) {
|
||||
// // Use .innerHTML and add a styled <span> for the title
|
||||
// productIdEl.innerHTML = `<span class="font-semibold text-white-500">Product ID: </span>${productIdVal}`;
|
||||
// }
|
||||
const productIdVal = s.product_id || '—';
|
||||
const productIdEl = card.querySelector('.product-id');
|
||||
if (productIdEl) {
|
||||
productIdEl.textContent = productIdVal;
|
||||
}
|
||||
card.querySelector('.station-location').textContent = s.location ?? '—';
|
||||
const idVal = s.id || s.station_id || '—';
|
||||
const idEl = card.querySelector('.station-id');
|
||||
|
|
@ -315,6 +358,35 @@
|
|||
const id = encodeURIComponent(s.id || s.station_id);
|
||||
window.location.href = `./dashboard.html?stationId=${id}`;
|
||||
});
|
||||
// --- ADD THIS NEW BLOCK FOR THE REMOVE BUTTON ---
|
||||
card.querySelector('.remove-btn').addEventListener('click', async () => {
|
||||
const stationId = s.id || s.station_id;
|
||||
const stationName = s.name;
|
||||
|
||||
// 1. Confirm with the user
|
||||
if (!confirm(`Are you sure you want to permanently remove "${stationName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. Call the DELETE API endpoint
|
||||
const response = await fetch(`${API_BASE}/stations/${stationId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert(`Station "${stationName}" removed successfully.`);
|
||||
// 3. Refresh the entire list from the server
|
||||
loadStations();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Failed to remove station: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error removing station:', error);
|
||||
alert('An error occurred while trying to remove the station.');
|
||||
}
|
||||
});
|
||||
|
||||
grid.appendChild(node);
|
||||
}
|
||||
|
|
@ -325,6 +397,10 @@
|
|||
const addCard = addNode.querySelector('div');
|
||||
addCard.addEventListener('click', () => openModal(stationModal));
|
||||
grid.appendChild(addNode);
|
||||
|
||||
if (window.lucide) {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
statusBtn.addEventListener('click', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue