Compare commits

..

1 Commits
main ... dev

Author SHA1 Message Date
Kirubakaran b97819ece9 chore: added the deployment configs 2025-09-05 02:59:48 +05:30
29 changed files with 1145 additions and 2223 deletions

View File

@ -6,8 +6,7 @@ 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:Vec%40123@localhost:5432/swap_station_db"
DATABASE_URL="postgresql://swap_app_user:2004@localhost:5432/swap_station_db"
# --- MQTT Broker Connection ---
MQTT_BROKER="mqtt-dev.upgrid.in"

View File

@ -1,87 +1,3 @@
# 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
@ -115,15 +31,19 @@ class MqttClient:
self.client.username_pw_set(self.user, self.password)
self.is_connected = False
self.stop_thread = False # <-- We will use this flag
self.reconnect_delay = 1
self.max_reconnect_delay = 60
self.stop_thread = False
# --- (Your on_connect, on_disconnect, and on_message methods stay the same) ---
# --- 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:
@ -131,13 +51,7 @@ 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."""
@ -146,33 +60,20 @@ class MqttClient:
except Exception as e:
print(f"Error processing message in callback for topic {msg.topic}: {e}")
def run(self):
"""A blocking loop that handles connection and reconnection."""
while not self.stop_thread:
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:
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"An unexpected error occurred for {self.station_id}: {e}. Retrying in 5 seconds...")
time.sleep(5)
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."""
# --- 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()
self.connect()
self.client.loop_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.stop_thread = True
self.client.disconnect() # This tells the client to disconnect gracefully
self.client.loop_stop()

View File

@ -5,12 +5,11 @@ import json
import csv
import io
import time
from datetime import datetime, timedelta
from datetime import datetime
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
@ -37,11 +36,9 @@ 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://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"
# CORS(app, resources={r"/api/*": {"origins": "http://localhost:5173"}})
# This tells Flask: "For any route starting with /api/, allow requests
# from the frontend running on http://localhost:5173".
@ -66,16 +63,15 @@ mqtt_clients = {}
last_message_timestamps = {}
STATION_TIMEOUT_SECONDS = 10
# --- MQTT Message Handling ---
def on_message_handler(station_id, topic, payload):
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
print(f"Main handler received message for station {station_id} on topic {topic}")
decoded_data = None
message_type = topic.split('/')[-1]
if message_type == 'PERIODIC':
decoded_data = decoder.decode_periodic(payload)
elif message_type == 'EVENTS':
@ -84,6 +80,7 @@ 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(
@ -94,31 +91,16 @@ 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():
@ -207,7 +189,6 @@ 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'],
@ -218,44 +199,12 @@ def add_station():
db.session.add(new_station)
db.session.commit()
# Immediately start the MQTT client for the station just created.
start_single_mqtt_client(new_station)
# You might want to start the new MQTT client here as well
# 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:
@ -271,7 +220,6 @@ 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)
@ -279,379 +227,6 @@ 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):
"""
@ -675,12 +250,6 @@ 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),
@ -853,16 +422,21 @@ def handle_rpc_request(payload):
print(f"Publishing to {topic}")
mqtt_client.client.publish(topic, serialized_payload)
# ADD THIS NEW FUNCTION
def start_single_mqtt_client(station):
# --- Main Application Logic ---
def start_mqtt_clients():
"""
Creates and starts a new MQTT client thread for a SINGLE station.
This is our new reusable function.
Initializes and starts an MQTT client for each station found in the database,
using the specific MQTT credentials stored for each station.
"""
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.")
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(
@ -873,53 +447,9 @@ def start_single_mqtt_client(station):
station_id=station.station_id,
on_message_callback=on_message_handler
)
client.start() # The start method should handle threading
client.start()
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():
@ -937,7 +467,6 @@ 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,
@ -953,5 +482,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://172.20.10.4:5000")
socketio.run(app, host='172.20.10.4', port=5000)
print(f"Starting Flask-SocketIO server on http://localhost:5000")
socketio.run(app, host='0.0.0.0', port=5000)

View File

@ -24,7 +24,6 @@ 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)

View File

@ -25,7 +25,6 @@ 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

View File

@ -33,7 +33,6 @@ 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__ = ()
@ -93,7 +92,6 @@ 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

22
frontend/Dockerfile Normal file
View File

@ -0,0 +1,22 @@
# Use an official Nginx runtime as a parent image
FROM nginx:alpine
# Set the working directory to the Nginx web root
WORKDIR /usr/share/nginx/html
# Remove the default Nginx welcome page
RUN rm -f /usr/share/nginx/html/index.html
# Copy the static assets from the frontend directory into the container
# This includes your HTML, CSS, JS, and assets folders.
COPY . .
# Copy the custom Nginx configuration
# This replaces the default config with our version that includes the API proxy.
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port 80 to allow traffic to the Nginx server
EXPOSE 80
# The command to start Nginx when the container launches
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,55 +0,0 @@
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur">
<div class="mx-auto max-w-7.5xl px-3 sm:px-4 py-2 grid grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex items-center gap-2 sm:gap-3">
<a href="./station_selection.html"
class="h-9 w-9 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 transition"
title="Back">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<div class="flex flex-col leading-tight">
<div id="station-name" class="text-base sm:text-lg font-extrabold tracking-tight">Loading...</div>
<div id="station-location" class="text-xs sm:text-sm text-slate-100">&nbsp;</div>
</div>
</div>
<div class="flex items-center justify-center scale-100">
<img src="./assets/vec_logo.png" alt="VECMOCON" class="h-7 w-auto opacity-90" onerror="this.style.display='none'"/>
</div>
<div class="flex items-center flex-wrap justify-end gap-1.5 sm:gap-2">
<span class="badge border-white/10 bg-white/5 text-slate-200">
<span>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>

View File

@ -5,19 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Analytics</title>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="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>
<!-- Inter + Tailwind -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
@ -34,21 +26,14 @@
: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;
@ -59,32 +44,26 @@
.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-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); }
.btn-danger { border-color: rgba(244,63,94,.35); background: rgba(244,63,94,.14); color:#fecaca; }
.btn-danger:hover { background: rgba(244,63,94,.22); }
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.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;
}
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
/* mini bar chart bars */
.bar { width: 10px; border-radius: 6px 6px 0 0; background: linear-gradient(180deg,#22c55e,#0ea5e9); }
</style>
</head>
<body class="min-h-screen text-gray-100 bg-glow">
<!-- 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"
@ -102,39 +81,32 @@
</div>
</div>
<div class="flex items-center justify-center scale-100">
<!-- Center -->
<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>
<!-- 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>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>Device ID:</span>
<span id="device-id"></span>
</span>
<span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg>
<span id="last-update-status">Waiting...</span>
<span id="last-update-status">Last Recv —</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 class="chip chip-emerald">
<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online
</span>
<button id="station-reset-btn" class="btn btn-danger !p-2" title="Station Reset">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg>
</button>
<span class="chip chip-amber" title="Running on backup supply">On Backup</span>
<div class="hidden sm:block w-px h-5 bg-white/10"></div>
@ -158,7 +130,9 @@
</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>
@ -168,122 +142,176 @@
</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">
<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>
<!-- Device + Date Range -->
<section class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span>
<span id="device-id" class="font-bold mono"></span>
</span>
<div class="ml-auto flex items-center gap-2">
<input id="from" type="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">
<input id="from" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<span class="text-gray-500">to</span>
<input id="to" type="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">
<input id="to" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<button id="applyRange" class="btn">Apply</button>
</div>
</section>
<section class="grid grid-cols-2 lg:grid-cols-6 gap-3">
<!-- Stat Tiles -->
<section class="grid grid-cols-1 md:grid-cols-4 gap-3">
<div class="tile">
<p class="text-xs text-gray-400">Total Swaps 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>
<p class="text-xs text-gray-400">Total Swaps (Today)</p>
<p class="text-3xl font-extrabold">142</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Avg. Swap Time</p>
<p 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>
<p class="text-3xl font-extrabold">2.1 <span class="text-lg font-bold text-gray-300">min</span></p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Station Uptime</p>
<p id="station-uptime" class="text-3xl font-extrabold text-teal-400">... %</p>
<p class="text-3xl font-extrabold text-emerald-400">99.8%</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Peak Hours</p>
<p class="text-3xl font-extrabold">57 PM</p>
</div>
</section>
<!-- <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">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>
<!-- 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="glass p-4 h-96">
<h3 class="font-extrabold">Hourly Swap Distribution</h3>
<canvas id="hourlyDistributionChart"></canvas>
<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 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>
<!-- 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="glass p-4 h-96 flex items-center justify-center">
<p class="text-slate-500">Future Chart Area</p>
</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 src="./js/analytics.js"></script>
<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 = '—'; }
})();
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>

View File

@ -5,8 +5,6 @@
<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">
@ -14,7 +12,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: {
@ -150,13 +148,6 @@
.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">
@ -166,9 +157,8 @@
<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-7xl px-3 sm:px-4 py-2 grid grid-cols-[auto_1fr_auto] items-center gap-2">
<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"
@ -194,31 +184,28 @@
<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-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>Device ID:</span>
<span id="device-id"></span>
</span>
<span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg>
<span id="last-update-status">Waiting for data...</span>
<span id="last-update-status">Waiting...</span>
</span>
<span id="connection-status-chip" class="cham_chip cham_chip-amber" title="Connection to backend">
<span class="h-2 w-2 rounded-full bg-amber-400"></span> Connecting...
</span>
<button id="station-reset-btn" class="btn btn-danger !p-2" title="Station Reset">
<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>
</button>
<!-- <span id="backup-power-chip" class="cham_chip cham_chip-slate" title="Station power source" style="display: none;">On Backup</span> -->
<div class="hidden sm:block w-px h-5 bg-white/10"></div>
@ -252,25 +239,24 @@
</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">
<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 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>
</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 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>
<span id="backup-power-chip" class="cham_chip cham_chip-slate w-full justify-center mt-3"></span>
<span id="backup-power-chip" class="cham_chip cham_chip-slate w-full justify-center mt-3">
</section>
<section id="swap-panel" class="glass p-4 flex flex-col min-h-[220px]">
@ -366,8 +352,5 @@
</div>
</template>
<script src="./js/common-header.js"></script>
<script src="./js/dashboard.js"></script>
</body>
</html>

View File

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

View File

@ -1,317 +0,0 @@
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();
});

View File

@ -1,11 +0,0 @@
(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');
}
})();

View File

@ -11,7 +11,7 @@ document.addEventListener('DOMContentLoaded', () => {
const password = document.getElementById('password').value;
try {
const response = await fetch('http://172.20.10.4:5000/api/login', {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),

View File

@ -1,239 +0,0 @@
// 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();
});

View File

@ -1,7 +1,6 @@
document.addEventListener('DOMContentLoaded', () => {
// --- CONFIGURATION ---
const SOCKET_URL = "http://172.20.10.4:5000";
const API_BASE = "http://172.20.10.4:5000/api"; // Added for API calls
const API_BASE = "/api"; // Added for API calls
// --- DOM ELEMENT REFERENCES ---
const grid = document.getElementById('chambersGrid');
@ -11,12 +10,8 @@ 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-raw'); // CHANGED ID: Use new raw code display ID
const sdcVisualFlagsEl = document.getElementById('sdc-visual-flags');
const stationDiagCodeEl = document.getElementById('station-diag-code');
const backupPowerChip = document.getElementById('backup-power-chip');
const diagFlagsGrid = document.getElementById('diag-flags-grid');
const audioSelect = document.getElementById('audio-command-select');
@ -33,40 +28,14 @@ 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 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 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 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');
@ -143,58 +112,6 @@ 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) ---
@ -231,63 +148,6 @@ 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;
@ -315,24 +175,18 @@ 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 = decodeFaults(slot.batteryFaultCode, BATTERY_FAULT_MAP);
// card.querySelector('.bat-fault').textContent = decodeBatteryFaults(slot.batteryFaultCode);
card.querySelector('.bat-fault').textContent = 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-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);
card.querySelector('.chg-fault').textContent = slot.chargerFaultCode || '—';
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.chargerPresent && slot.current > 0) {
if (slot.chargerMode === 1) {
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 {
@ -364,62 +218,25 @@ 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';
// }
// 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';
// Apply different styles based on whether the alarm is active
if (isActive) {
div.className = 'text-rose-300 text-center font-semibold';
} 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);
div.className = 'text-slate-500 text-center';
}
diagFlagsGrid.appendChild(div);
});
};
const resetDashboardUI = () => {
@ -437,38 +254,200 @@ 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);
// }
// };
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;
}
};
};
// --- MAIN LOGIC (Your original code is unchanged) ---
const initializeDashboard = () => {
@ -479,12 +458,7 @@ 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;
@ -565,6 +539,7 @@ 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');
@ -582,7 +557,7 @@ document.addEventListener('DOMContentLoaded', () => {
};
const connectSocket = () => {
socket = io(SOCKET_URL);
socket = io();
// --- CHANGED: No longer sets status to "Online" on its own ---
socket.on('connect', () => {
@ -598,27 +573,18 @@ 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 || '—';
// 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')) {
// Show/hide the backup power chip based on the payload data
if (data.backupSupplyStatus === 1) {
backupPowerChip.textContent = 'On Backup';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-amber';
@ -626,12 +592,17 @@ document.addEventListener('DOMContentLoaded', () => {
backupPowerChip.textContent = 'On Mains Power';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-emerald';
}
}
// 3. Only process chamber-level data if it exists.
lastUpdateEl.textContent = new Date().toLocaleTimeString();
stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—';
// --- NEW: Call the function to update the diagnostics grid ---
updateDiagnosticsUI(data.stationDiagnosticCode || 0);
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);

View File

@ -1,152 +1,100 @@
document.addEventListener('DOMContentLoaded', () => {
// --- CONFIGURATION ---
const SOCKET_URL = "http://172.20.10.4:5000";
const API_BASE = "http://172.20.10.4:5000/api";
const API_BASE = "/api";
// --- DOM ELEMENT REFERENCES ---
const stationNameEl = document.getElementById('station-name');
const stationLocationEl = document.getElementById('station-location');
const deviceIdEl = document.getElementById('device-id');
const productIdEl = document.getElementById('product-id');
const lastUpdateEl = document.getElementById('last-update-status');
const connChip = document.getElementById('connection-status-chip');
const requestLogArea = document.getElementById('request-log-area');
const eventLogArea = document.getElementById('event-log-area');
const 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 logCountInput = document.getElementById('log-count');
const fromDateInput = document.getElementById('from-date');
const toDateInput = document.getElementById('to-date');
const applyFiltersBtn = document.getElementById('apply-filters-btn');
const resetBtn = document.getElementById('station-reset-btn');
// --- STATE ---
let selectedStation = null;
let socket;
let statusPollingInterval;
let fromDatePicker, toDatePicker;
// --- HELPER FUNCTIONS --
const appendLog = (textarea, data, topic, timestampStr) => {
// --- HELPER FUNCTIONS ---
const prependLog = (textarea, data) => {
if (!textarea) return;
const timestamp = new Date(timestampStr).toLocaleString();
const timestamp = new Date().toLocaleTimeString();
const formattedJson = JSON.stringify(data, null, 2);
// 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 newLog = `[${timestamp}]\n${formattedJson}\n\n---------------------------------\n\n`;
textarea.value = newLog + textarea.value;
};
// 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");
try {
// 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);
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);
};
// --- INITIALIZATION AND EVENT HANDLERS ---
function init() {
console.log("1. Starting initialization...");
// Step 1: Load the station from localStorage.
const checkStationStatus = async () => {
if (!selectedStation) return;
try {
const stationData = localStorage.getItem('selected_station');
console.log("2. Fetched from localStorage:", stationData);
if (!stationData) {
throw new Error('No station data found in localStorage.');
const response = await fetch(`${API_BASE}/stations`);
if (!response.ok) return;
const stations = await response.json();
const thisStation = stations.find(s => s.id === selectedStation.id);
if (thisStation) {
stationNameEl.textContent = thisStation.name;
stationLocationEl.textContent = thisStation.location;
if (thisStation.status === 'Online') {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
connChip.className = 'cham_chip cham_chip-emerald';
} else {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose';
}
}
} catch (error) { console.error("Failed to fetch station status:", error); }
};
selectedStation = JSON.parse(stationData);
console.log("3. Parsed station data:", selectedStation);
// --- INITIALIZATION ---
try {
selectedStation = JSON.parse(localStorage.getItem('selected_station'));
if (!selectedStation || !selectedStation.id) {
throw new Error('Parsed station data is invalid or missing an ID.');
throw new Error('No station selected. Please go back to the selection page.');
}
deviceIdEl.textContent = selectedStation.id;
} catch (e) {
console.error("ERROR during station loading:", e);
// window.location.href = './station_selection.html'; // Temporarily disable redirect for debugging
document.body.innerHTML = `<div class="text-center p-8 text-rose-400">${e.message}<a href="./station_selection.html" class="underline ml-2">Go Back</a></div>`;
return;
}
// 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.IO CONNECTION ---
socket = io();
socket.on('connect', () => {
console.log("Connected to WebSocket for logs.");
socket.emit('join_station_room', { station_id: selectedStation.id });
});
fromDatePicker = flatpickr(fromDateInput, { dateFormat: "Y-m-d", defaultDate: "today" });
toDatePicker = flatpickr(toDateInput, { dateFormat: "Y-m-d", defaultDate: "today" });
socket.on('dashboard_update', (message) => {
const { stationId, topic, data } = message;
if (stationId !== selectedStation.id) return;
applyFiltersBtn.addEventListener('click', fetchRecentLogs);
lastUpdateEl.textContent = 'Last Recv ' + new Date().toLocaleTimeString();
// Step 3: Set up button event listeners.
if (topic.endsWith('EVENTS')) {
prependLog(eventLogArea, data);
} else if (topic.endsWith('REQUEST')) {
prependLog(requestLogArea, data);
}
});
// --- BUTTON EVENT LISTENERS ---
if(clearReqBtn) clearReqBtn.addEventListener('click', () => requestLogArea.value = '');
if(clearEvtBtn) clearEvtBtn.addEventListener('click', () => eventLogArea.value = '');
if(clearAllBtn) clearAllBtn.addEventListener('click', () => {
@ -158,32 +106,17 @@ document.addEventListener('DOMContentLoaded', () => {
window.location.href = 'index.html';
});
if(refreshBtn) refreshBtn.addEventListener('click', () => location.reload());
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);
if(downloadBtn) downloadBtn.addEventListener('click', () => alert("Download functionality can be added here.")); // Placeholder for download modal
if(resetBtn) resetBtn.addEventListener('click', () => {
if (confirm('Are you sure you want to reset the station?')) {
sendCommand('STATION_RESET');
}
});
// --- STARTUP ---
checkStationStatus();
statusPollingInterval = setInterval(checkStationStatus, 10000);
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// --- START THE APPLICATION ---
init();
});

View File

@ -1,79 +0,0 @@
// // 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);

View File

@ -1,22 +1,25 @@
document.addEventListener('DOMContentLoaded', () => {
// --- DOM ELEMENTS ---
const stationsGrid = document.getElementById('stations-grid');
const stationCountEl = document.getElementById('station-count'); // Make sure you have an element with this ID in your HTML
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
// --- CONFIG & STATE ---
const API_BASE = 'http://172.20.10.4:5000/api';
let allStations = []; // Master list of stations from the API
let pollingInterval = null;
let allStations = []; // To store the master list of stations
// --- AUTHENTICATION ---
// --- AUTHENTICATION & USER INFO (Your existing code is perfect) ---
const user = JSON.parse(localStorage.getItem('user'));
if (!user) {
window.location.href = 'index.html'; // Redirect if not logged in
return;
}
// (Your other button listeners for logout, add user, etc., can go here)
// document.getElementById('logoutBtn').onclick = () => { ... };
// 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)
// --- HELPER FUNCTIONS ---
const getStatusAttributes = (status) => {
@ -31,76 +34,45 @@ document.addEventListener('DOMContentLoaded', () => {
window.location.href = `dashboard.html?station_id=${stationId}`;
};
// --- UI RENDERING ---
// This function's only job is to build the HTML. It does not add event listeners.
// This function now only renders the initial grid
const renderStations = (stations) => {
stationsGrid.innerHTML = ''; // Clear the grid
stationCountEl.textContent = `${stations.length} stations found.`;
stationsGrid.innerHTML = '';
stationCountEl.textContent = `${stations.length} stations found. Select one to monitor.`;
stations.forEach(station => {
const status = getStatusAttributes(station.status);
const card = document.createElement('div');
// 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.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);
card.innerHTML = `
<div class="main-content p-5 flex-grow cursor-pointer" data-station-json='${JSON.stringify(station)}'>
<div class="p-5">
<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-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>
<p class="text-sm text-gray-400 mt-1">${station.id}</p>
</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 = stationsGrid.querySelector(`[data-station-id="${station.id}"]`);
const card = document.getElementById(`station-${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;
@ -108,98 +80,35 @@ document.addEventListener('DOMContentLoaded', () => {
}
}
});
if (window.lucide) {
lucide.createIcons();
}
lucide.createIcons(); // Re-render icons if any changed
};
//-- 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 ---
// --- DATA FETCHING & STATUS POLLING ---
const loadAndPollStations = async () => {
try {
const response = await fetch(`${API_BASE}/stations`);
const response = await fetch('/api/stations');
if (!response.ok) throw new Error('Failed to fetch stations');
const stations = await response.json();
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);
// Check if this is the first time loading data
if (allStations.length === 0) {
allStations = stations;
renderStations(allStations); // Initial full render
} else {
// Otherwise, we can do a more efficient status-only update.
allStations = newStationList;
updateStationStatuses(allStations);
fetchAndApplyStats(); // Fetch and update daily stats
allStations = stations;
updateStationStatuses(allStations); // Subsequent, efficient updates
}
} 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
pollingInterval = setInterval(loadAndPollStations, 10000);
// Then, set an interval to refresh the statuses every 10 seconds
const pollingInterval = setInterval(loadAndPollStations, 10000);
});

View File

@ -5,18 +5,10 @@
<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 = {
@ -104,19 +96,9 @@
<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">
@ -174,25 +156,6 @@
<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 -->
@ -238,7 +201,6 @@
</div>
</main>
<script src="./js/common-header.js"></script>
<script src="./js/logs.js"></script>
<script src="./js/logs.js"></script>
</body>
</html>

32
frontend/nginx.conf Normal file
View File

@ -0,0 +1,32 @@
server {
listen 80;
server_name localhost;
# Root directory for static files
root /usr/share/nginx/html;
index index.html;
# Serve static assets directly
location / {
try_files $uri $uri/ =404;
}
# Proxy API requests to the backend service
# In Dokploy, you would set 'backend' to the name of your backend service.
location /api/ {
proxy_pass http://backend:5000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Proxy WebSocket connections to the backend service
location /socket.io/ {
proxy_pass http://backend:5000/socket.io/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
}

View File

@ -5,14 +5,10 @@
<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 = {
@ -113,16 +109,7 @@
<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="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>
<p class="mt-1 text-xs text-gray-400 station-location">Location</p>
</div>
<span class="status-badge rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"></span>
</div>
@ -133,10 +120,6 @@
<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 -->
@ -155,21 +138,9 @@
</div>
</div>
<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
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
</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>
@ -208,7 +179,6 @@
<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>
@ -225,7 +195,7 @@
</div>
<script>
const API_BASE = 'http://172.20.10.4:5000/api';
const API_BASE = 'http://localhost:5000/api';
const grid = document.getElementById('stations-grid');
const addStationCardTmpl = document.getElementById('add-station-card-template');
@ -274,15 +244,13 @@
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_user: mqttUsername.value || null,
mqtt_username: mqttUsername.value || null,
mqtt_password: mqttPassword.value || null,
};
try {
const res = await fetch(`${API_BASE}/stations`, {
method:'POST',
@ -321,17 +289,6 @@
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');
@ -358,35 +315,6 @@
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);
}
@ -397,10 +325,6 @@
const addCard = addNode.querySelector('div');
addCard.addEventListener('click', () => openModal(stationModal));
grid.appendChild(addNode);
if (window.lucide) {
lucide.createIcons();
}
}
statusBtn.addEventListener('click', () => {