Compare commits

...

15 Commits
dev ... main

Author SHA1 Message Date
Kirubakaran 3abfe7976b feat: updated SDC decoding according to new proto 2025-11-03 05:18:55 +05:30
kiruba-vec fa41798552 chore: update the base version 2025-09-29 11:52:20 +05:30
kiruba-vec 2c910cfcc5 Merge branch 'main' of https://gitea.vecmocon.com/kirubakaran/SwapStation_WebApp 2025-09-29 11:49:31 +05:30
kiruba-vec 342d322e35 fix: fixed colour in the swap abort reason 2025-09-29 11:32:09 +05:30
Kirubakaran 2ea8e6990c fix: swap counts in the station card 2025-09-29 02:43:00 +05:30
Kirubakaran 3a2cda3263 fix: resolved the analytics and logs page bugs 2025-09-22 03:13:04 +05:30
Kirubakaran 45d2bbdab8 fix: added on_message_handler in the main.py 2025-09-20 22:03:00 +05:30
Kirubakaran eb88959660 fix: analytics page 2025-09-20 21:14:30 +05:30
Kirubakaran 219fbd92b4 fix: average swap time in analytics page 2025-09-16 02:03:40 +05:30
Kirubakaran 7f0a93a80d feat(app): implement comprehensive analytics and reporting features
Analytics Page:
- Implemented a new, dynamic analytics page with date filtering.
- Added KPI summary tiles for total, completed, and aborted swaps, average swap time, and station uptime.
- Included three interactive charts: Swap Activity Over Time, Hourly Swap Distribution, and Swap Abort Reasons.
- Added a Slot Utilization Heatmap for at-a-glance diagnostics.
- Styled charts for the dark theme and fixed axis scaling issues.

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

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

Fixes & Refactoring:
- Centralized all common header logic into common-header.js to resolve script conflicts.
- Improved dashboard WebSocket handler to correctly process partial data payloads.
2025-09-15 03:56:47 +05:30
Kirubakaran f61af015ad fix: enhance the UI of status bar 2025-09-14 19:15:18 +05:30
Kirubakaran 526bc8e8fa feat(app): Implement station removal and refactor to common header
This major update introduces the ability to remove stations and significantly refactors the frontend architecture for better code reuse and maintainability.

### Features
- **Remove Station:** Implemented full-stack station removal functionality.
  - Added `DELETE /api/stations/<id>` endpoint to the backend.
  - The MQTT client for the removed station is now gracefully disconnected.
  - Added a trash can icon button to the station selection card for removal.
- **Enhanced UI:** Improved the UI on several pages.
  - Added icons for Product ID and Location on station cards.
  - Updated card layout to display Product ID directly under the station name.
  - Implemented a fully functional, dynamic header for the dashboard, logs, and analytics pages.
2025-09-14 05:34:57 +05:30
Kirubakaran 494e6b4c7c chore: added the instant thread for new station 2025-09-13 02:42:20 +05:30
Kirubakaran 6d3899e8e1 feat: added the auth guard script 2025-09-13 02:08:41 +05:30
Kirubakaran 1054d3dc0b fix: changes the API for localhost 2025-09-11 01:55:34 +05:30
27 changed files with 2233 additions and 1103 deletions

View File

@ -6,7 +6,8 @@ SECRET_KEY="80473e17c5707e19252ef3736fba32805be21a9b3e914190"
# --- PostgreSQL Database Connection --- # --- PostgreSQL Database Connection ---
# Replace with your actual database credentials. # Replace with your actual database credentials.
# Format: postgresql://<user>:<password>@<host>:<port>/<dbname> # Format: postgresql://<user>:<password>@<host>:<port>/<dbname>
DATABASE_URL="postgresql://swap_app_user:2004@localhost:5432/swap_station_db" # DATABASE_URL="postgresql://swap_app_user:2004@localhost:5432/swap_station_db"
DATABASE_URL="postgresql://swap_app_user:Vec%40123@localhost:5432/swap_station_db"
# --- MQTT Broker Connection --- # --- MQTT Broker Connection ---
MQTT_BROKER="mqtt-dev.upgrid.in" MQTT_BROKER="mqtt-dev.upgrid.in"

View File

@ -1,3 +1,87 @@
# import paho.mqtt.client as mqtt
# import uuid
# import time
# import threading
# import socket
# class MqttClient:
# """
# Handles the connection and message processing for a single MQTT station.
# This is a standard Python class, with no GUI dependencies.
# """
# def __init__(self, broker, port, user, password, station_id, on_message_callback):
# self.broker = broker
# self.port = port
# self.user = user
# self.password = password
# self.station_id = station_id
# self.on_message_callback = on_message_callback
# unique_id = str(uuid.uuid4())
# self.client_id = f"WebApp-Backend-{self.station_id}-{unique_id}"
# self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, self.client_id)
# # Assign callback functions
# self.client.on_connect = self.on_connect
# self.client.on_message = self.on_message
# self.client.on_disconnect = self.on_disconnect
# if self.user and self.password:
# self.client.username_pw_set(self.user, self.password)
# self.is_connected = False
# self.reconnect_delay = 1
# self.max_reconnect_delay = 60
# self.stop_thread = False
# # --- CORRECTED CALLBACK SIGNATURES ---
# def on_connect(self, client, userdata, flags, reason_code, properties):
# """Callback for when the client connects to the broker."""
# if reason_code == 0:
# self.is_connected = True
# self.reconnect_delay = 1
# print(f"Successfully connected to MQTT broker for station: {self.station_id}")
# topic_base = f"VEC/batterySmartStation/v100/{self.station_id}/#"
# # topic_base = f"VEC/batterySmartStation/v100/+/+"
# self.client.subscribe(topic_base)
# print(f"Subscribed to: {topic_base}")
# else:
# print(f"Failed to connect to MQTT for station {self.station_id}, return code {reason_code}")
# def on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties):
# """Callback for when the client disconnects."""
# print(f"Disconnected from MQTT for station {self.station_id}. Will attempt to reconnect...")
# def on_message(self, client, userdata, msg):
# """Callback for when a message is received from the broker."""
# try:
# self.on_message_callback(self.station_id, msg.topic, msg.payload)
# except Exception as e:
# print(f"Error processing message in callback for topic {msg.topic}: {e}")
# def connect(self):
# """Connects the client to the MQTT broker."""
# print(f"Attempting to connect to {self.broker}:{self.port} with client ID: {self.client_id}")
# try:
# self.client.connect(self.broker, self.port, 60)
# except Exception as e:
# print(f"Error connecting to MQTT for station {self.station_id}: {e}")
# def start(self):
# """Starts the MQTT client's network loop in a separate thread."""
# self.connect()
# self.client.loop_start()
# def stop(self):
# """Stops the MQTT client's network loop."""
# print(f"Stopping MQTT client for station: {self.station_id}")
# self.client.loop_stop()
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
import uuid import uuid
import time import time
@ -31,19 +115,15 @@ class MqttClient:
self.client.username_pw_set(self.user, self.password) self.client.username_pw_set(self.user, self.password)
self.is_connected = False self.is_connected = False
self.reconnect_delay = 1 self.stop_thread = False # <-- We will use this flag
self.max_reconnect_delay = 60
self.stop_thread = False
# --- CORRECTED CALLBACK SIGNATURES --- # --- (Your on_connect, on_disconnect, and on_message methods stay the same) ---
def on_connect(self, client, userdata, flags, reason_code, properties): def on_connect(self, client, userdata, flags, reason_code, properties):
"""Callback for when the client connects to the broker.""" """Callback for when the client connects to the broker."""
if reason_code == 0: if reason_code == 0:
self.is_connected = True self.is_connected = True
self.reconnect_delay = 1
print(f"Successfully connected to MQTT broker for station: {self.station_id}") 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.station_id}/#"
# topic_base = f"VEC/batterySmartStation/v100/+/+"
self.client.subscribe(topic_base) self.client.subscribe(topic_base)
print(f"Subscribed to: {topic_base}") print(f"Subscribed to: {topic_base}")
else: else:
@ -51,7 +131,13 @@ class MqttClient:
def on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties): def on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties):
"""Callback for when the client disconnects.""" """Callback for when the client disconnects."""
print(f"Disconnected from MQTT for station {self.station_id}. Will attempt to reconnect...") 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): def on_message(self, client, userdata, msg):
"""Callback for when a message is received from the broker.""" """Callback for when a message is received from the broker."""
@ -60,20 +146,33 @@ class MqttClient:
except Exception as e: except Exception as e:
print(f"Error processing message in callback for topic {msg.topic}: {e}") print(f"Error processing message in callback for topic {msg.topic}: {e}")
def connect(self): def run(self):
"""Connects the client to the MQTT broker.""" """A blocking loop that handles connection and reconnection."""
print(f"Attempting to connect to {self.broker}:{self.port} with client ID: {self.client_id}") while not self.stop_thread:
try: try:
self.client.connect(self.broker, self.port, 60) print(f"Attempting to connect to {self.broker}:{self.port} for station {self.station_id}")
except Exception as e: self.client.connect(self.broker, self.port, 60)
print(f"Error connecting to MQTT for station {self.station_id}: {e}") 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)
def start(self): def start(self):
"""Starts the MQTT client's network loop in a separate thread.""" """Starts the MQTT client's network loop in a separate thread."""
self.connect() # --- CHANGED ---
self.client.loop_start() # We now run our custom `run` method in a thread
main_thread = threading.Thread(target=self.run)
main_thread.daemon = True
main_thread.start()
def stop(self): def stop(self):
"""Stops the MQTT client's network loop.""" """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}") print(f"Stopping MQTT client for station: {self.station_id}")
self.client.loop_stop() self.stop_thread = True
self.client.disconnect() # This tells the client to disconnect gracefully

View File

@ -5,11 +5,12 @@ import json
import csv import csv
import io import io
import time import time
from datetime import datetime from datetime import datetime, timedelta
from flask import Flask, jsonify, request, Response from flask import Flask, jsonify, request, Response
from flask_socketio import SocketIO, join_room from flask_socketio import SocketIO, join_room
from flask_cors import CORS from flask_cors import CORS
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy import desc, func, case
# Import your custom core modules and the new models # Import your custom core modules and the new models
from core.mqtt_client import MqttClient from core.mqtt_client import MqttClient
@ -36,9 +37,11 @@ app = Flask(__name__)
# CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True) # CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True)
CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True, expose_headers='Content-Disposition') # CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True, expose_headers='Content-Disposition')
# CORS(app, resources={r"/api/*": {"origins": "http://localhost:5173"}}) CORS(app, resources={r"/api/*": {"origins": ["http://172.20.10.4:5500","http://127.0.0.1:5500"]}}, supports_credentials=True, expose_headers='Content-Disposition')
# CORS(app, resources={r"/api/*": {"origins": "http://localhost:5173"}}) , "http://127.0.0.1:5500"
# This tells Flask: "For any route starting with /api/, allow requests # This tells Flask: "For any route starting with /api/, allow requests
# from the frontend running on http://localhost:5173". # from the frontend running on http://localhost:5173".
@ -63,15 +66,16 @@ mqtt_clients = {}
last_message_timestamps = {} last_message_timestamps = {}
STATION_TIMEOUT_SECONDS = 10 STATION_TIMEOUT_SECONDS = 10
# --- MQTT Message Handling --- # --- MQTT Message Handling ---
def on_message_handler(station_id, topic, payload): def on_message_handler(station_id, topic, payload):
last_message_timestamps[station_id] = time.time()
print(f"Main handler received message for station {station_id} on topic {topic}")
decoded_data = None
message_type = topic.split('/')[-1] message_type = topic.split('/')[-1]
if message_type in ['PERIODIC']:
last_message_timestamps[station_id] = time.time()
# Decode the message payload based on its type
decoded_data = None
if message_type == 'PERIODIC': if message_type == 'PERIODIC':
decoded_data = decoder.decode_periodic(payload) decoded_data = decoder.decode_periodic(payload)
elif message_type == 'EVENTS': elif message_type == 'EVENTS':
@ -80,7 +84,6 @@ def on_message_handler(station_id, topic, payload):
decoded_data = decoder.decode_rpc_request(payload) decoded_data = decoder.decode_rpc_request(payload)
if decoded_data: if decoded_data:
# print("DECODED DATA TO BE SENT:", decoded_data)
try: try:
with app.app_context(): with app.app_context():
log_entry = MqttLog( log_entry = MqttLog(
@ -91,15 +94,30 @@ def on_message_handler(station_id, topic, payload):
) )
db.session.add(log_entry) db.session.add(log_entry)
db.session.commit() db.session.commit()
print(f"Successfully wrote data for {station_id} to PostgreSQL.")
except Exception as e: except Exception as e:
print(f"Error writing to PostgreSQL: {e}") print(f"Error writing to PostgreSQL: {e}")
# Emit update to the main dashboard
socketio.emit('dashboard_update', { socketio.emit('dashboard_update', {
'stationId': station_id, 'stationId': station_id,
'topic': topic, 'topic': topic,
'data': decoded_data 'data': decoded_data
}, room=station_id) }, 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) --- # --- (WebSocket and API routes remain the same) ---
@socketio.on('connect') @socketio.on('connect')
@ -189,6 +207,7 @@ def add_station():
new_station = Station( new_station = Station(
station_id=data['station_id'], station_id=data['station_id'],
product_id=data['product_id'],
name=data['name'], name=data['name'],
location=data['location'], location=data['location'],
mqtt_broker=data['mqtt_broker'], mqtt_broker=data['mqtt_broker'],
@ -198,13 +217,45 @@ def add_station():
) )
db.session.add(new_station) db.session.add(new_station)
db.session.commit() db.session.commit()
# You might want to start the new MQTT client here as well # Immediately start the MQTT client for the station just created.
# start_single_mqtt_client(new_station) start_single_mqtt_client(new_station)
return jsonify({"message": "Station added successfully."}), 201 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']) @app.route('/api/stations', methods=['GET'])
def get_stations(): def get_stations():
try: try:
@ -220,12 +271,386 @@ def get_stations():
"id": s.station_id, "id": s.station_id,
"name": s.name, "name": s.name,
"location": s.location, "location": s.location,
"product_id": s.product_id,
"status": "Online" if is_online else "Offline" "status": "Online" if is_online else "Offline"
}) })
return jsonify(station_list) return jsonify(station_list)
except Exception as e: except Exception as e:
return jsonify({"error": f"Database query failed: {e}"}), 500 return jsonify({"error": f"Database query failed: {e}"}), 500
#--- Daily Stats Route ---
@app.route('/api/stations/daily-stats', methods=['GET'])
def get_all_station_stats():
"""
Calculates the swap statistics for today for all stations.
"""
try:
# --- CHANGE THESE TWO LINES ---
today_start = datetime.combine(datetime.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) --- # --- CSV Export route (UPDATED) ---
def _format_periodic_row(payload, num_slots=9): def _format_periodic_row(payload, num_slots=9):
@ -250,6 +675,12 @@ def _format_periodic_row(payload, num_slots=9):
for i in range(1, num_slots + 1): for i in range(1, num_slots + 1):
slot = slot_map.get(i) slot = slot_map.get(i)
if slot: 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([ row.extend([
slot.get('batteryIdentification', ''), slot.get('batteryIdentification', ''),
slot.get("batteryPresent", 0), slot.get("batteryPresent", 0),
@ -422,33 +853,72 @@ def handle_rpc_request(payload):
print(f"Publishing to {topic}") print(f"Publishing to {topic}")
mqtt_client.client.publish(topic, serialized_payload) mqtt_client.client.publish(topic, serialized_payload)
# ADD THIS NEW FUNCTION
def start_single_mqtt_client(station):
"""
Creates and starts a new MQTT client thread for a SINGLE station.
This is our new reusable function.
"""
if station.station_id in mqtt_clients and mqtt_clients[station.station_id].is_connected:
print(f"MQTT client for {station.station_id} is already running.")
return
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() # The start method should handle threading
mqtt_clients[station.station_id] = client
# --- Main Application Logic --- # --- 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(): def start_mqtt_clients():
""" """
Initializes and starts an MQTT client for each station found in the database, Initializes and starts an MQTT client for each station found in the database
using the specific MQTT credentials stored for each station. by calling our new reusable function.
""" """
try: try:
with app.app_context(): with app.app_context():
stations = Station.query.all() stations = Station.query.all()
print(f"Found {len(stations)} existing stations to monitor.")
except Exception as e: except Exception as e:
print(f"CRITICAL: Could not query stations from the database in MQTT thread: {e}") print(f"CRITICAL: Could not query stations from the database: {e}")
return return
for station in stations: for station in stations:
if station.station_id not in mqtt_clients: start_single_mqtt_client(station)
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
if __name__ == '__main__': if __name__ == '__main__':
try: try:
@ -467,6 +937,7 @@ if __name__ == '__main__':
print("No stations found. Adding a default station.") print("No stations found. Adding a default station.")
default_station = Station( default_station = Station(
station_id="V16000862287077265957", station_id="V16000862287077265957",
product_id="VEC_PROD_001",
name="Test Station 1", name="Test Station 1",
mqtt_broker="mqtt.vecmocon.com", mqtt_broker="mqtt.vecmocon.com",
mqtt_port=1883, mqtt_port=1883,
@ -482,5 +953,5 @@ if __name__ == '__main__':
mqtt_thread = threading.Thread(target=start_mqtt_clients, daemon=True) mqtt_thread = threading.Thread(target=start_mqtt_clients, daemon=True)
mqtt_thread.start() mqtt_thread.start()
print(f"Starting Flask-SocketIO server on http://localhost:5000") print(f"Starting Flask-SocketIO server on http://172.20.10.4:5000")
socketio.run(app, host='0.0.0.0', port=5000) socketio.run(app, host='172.20.10.4', port=5000)

View File

@ -24,6 +24,7 @@ class Station(db.Model):
"""Represents a battery swap station in the database.""" """Represents a battery swap station in the database."""
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
station_id = db.Column(db.String(120), unique=True, nullable=False) 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) name = db.Column(db.String(120), nullable=True)
location = db.Column(db.String(200), nullable=True) location = db.Column(db.String(200), nullable=True)
mqtt_broker = db.Column(db.String(255), nullable=False) mqtt_broker = db.Column(db.String(255), nullable=False)

View File

@ -25,6 +25,7 @@ enum jobType_e {
JOBTYPE_REBOOT = 0x104; JOBTYPE_REBOOT = 0x104;
JOBTYPE_SWAP_DENY = 0x105; JOBTYPE_SWAP_DENY = 0x105;
JOBTYPE_LANGUAGE_UPDATE = 0x106; JOBTYPE_LANGUAGE_UPDATE = 0x106;
JOBTYPE_SWAP_AUTH_SUCCESS = 0x107;
} }
enum jobResult_e { enum jobResult_e {

File diff suppressed because one or more lines are too long

View File

@ -33,6 +33,7 @@ class jobType_e(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
JOBTYPE_REBOOT: _ClassVar[jobType_e] JOBTYPE_REBOOT: _ClassVar[jobType_e]
JOBTYPE_SWAP_DENY: _ClassVar[jobType_e] JOBTYPE_SWAP_DENY: _ClassVar[jobType_e]
JOBTYPE_LANGUAGE_UPDATE: _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): class jobResult_e(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = () __slots__ = ()
@ -92,6 +93,7 @@ JOBTYPE_TRANSACTION_ABORT: jobType_e
JOBTYPE_REBOOT: jobType_e JOBTYPE_REBOOT: jobType_e
JOBTYPE_SWAP_DENY: jobType_e JOBTYPE_SWAP_DENY: jobType_e
JOBTYPE_LANGUAGE_UPDATE: jobType_e JOBTYPE_LANGUAGE_UPDATE: jobType_e
JOBTYPE_SWAP_AUTH_SUCCESS: jobType_e
JOB_RESULT_UNKNOWN: jobResult_e JOB_RESULT_UNKNOWN: jobResult_e
JOB_RESULT_SUCCESS: jobResult_e JOB_RESULT_SUCCESS: jobResult_e
JOB_RESULT_REJECTED: jobResult_e JOB_RESULT_REJECTED: jobResult_e

55
frontend/_header.html Normal file
View File

@ -0,0 +1,55 @@
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur">
<div class="mx-auto max-w-7.5xl px-3 sm:px-4 py-2 grid grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex items-center gap-2 sm:gap-3">
<a href="./station_selection.html"
class="h-9 w-9 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 transition"
title="Back">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<div class="flex flex-col leading-tight">
<div id="station-name" class="text-base sm:text-lg font-extrabold tracking-tight">Loading...</div>
<div id="station-location" class="text-xs sm:text-sm text-slate-100">&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,11 +5,19 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Analytics</title> <title>Swap Station Analytics</title>
<!-- Inter + Tailwind --> <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="js/common-header.js"></script>
<script src="js/auth-guard.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = {
theme: { theme: {
@ -26,16 +34,23 @@
:root { color-scheme: dark; } :root { color-scheme: dark; }
html, body { height: 100%; } html, body { height: 100%; }
body { background:#0a0a0a; } body { background:#0a0a0a; }
/* soft background glow */
.bg-glow::before, .bg-glow::before,
.bg-glow::after { .bg-glow::after {
content:""; position:fixed; pointer-events:none; z-index:-1; border-radius:9999px; filter: blur(64px); content:""; position:fixed; pointer-events:none; z-index:-1; border-radius:9999px; filter: blur(64px);
} }
.bg-glow::before { width:34rem; height:34rem; left:-8rem; top:-8rem; background: rgba(16,185,129,.12); } .bg-glow::before { width:34rem; height:34rem; left:-8rem; top:-8rem; background: rgba(16,185,129,.12); }
.bg-glow::after { width:36rem; height:36rem; right:-10rem; bottom:-10rem; background: rgba(56,189,248,.10); } .bg-glow::after { width:36rem; height:36rem; right:-10rem; bottom:-10rem; background: rgba(56,189,248,.10); }
.glass { background: rgba(30,41,59,.45); border: 1px solid rgba(255,255,255,.10); border-radius: .9rem; backdrop-filter: saturate(150%) blur(12px); }
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; } .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; .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} 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-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
@ -44,26 +59,32 @@
.chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)} .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)} .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); } .cham_chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
.tile { background: rgba(2,6,23,.45); border:1px solid rgba(255,255,255,.12); border-radius:.9rem; padding:1rem; } font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.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; } .cham_chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.btn:hover { border-color: rgba(16,185,129,.45); background: rgba(16,185,129,.10); } .cham_chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.btn-ghost { border-color: rgba(255,255,255,.10); background: rgba(255,255,255,.05); } .cham_chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.btn-danger { border-color: rgba(244,63,94,.35); background: rgba(244,63,94,.14); color:#fecaca; } .cham_chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)}
.btn-danger:hover { background: rgba(244,63,94,.22); } .cham_chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; }
/* mini bar chart bars */ .glass { background: rgba(30,41,59,.45); border: 1px solid rgba(255,255,255,.10); border-radius: .9rem; backdrop-filter: saturate(150%) blur(12px); }
.bar { width: 10px; border-radius: 6px 6px 0 0; background: linear-gradient(180deg,#22c55e,#0ea5e9); } .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); }
.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;
}
</style> </style>
</head> </head>
<body class="min-h-screen text-gray-100 bg-glow"> <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">
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur">
<div class="mx-auto max-w-7.5xl px-3 sm:px-4 py-2 grid grid-cols-[auto_1fr_auto] items-center gap-2"> <div class="mx-auto max-w-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"> <div class="flex items-center gap-2 sm:gap-3">
<a href="./station_selection.html" <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" class="h-9 w-9 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 transition"
@ -81,58 +102,63 @@
</div> </div>
</div> </div>
<!-- Center --> <div class="flex items-center justify-center scale-100">
<div class="flex items-center justify-center">
<img src="./assets/vec_logo.png" alt="VECMOCON" <img src="./assets/vec_logo.png" alt="VECMOCON"
class="h-7 w-auto opacity-90" onerror="this.style.display='none'"/> class="h-7 w-auto opacity-90" onerror="this.style.display='none'"/>
</div> </div>
<!-- Right badges/actions -->
<div class="flex items-center flex-wrap justify-end gap-1.5 sm:gap-2"> <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 class="badge border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span> <span>Product ID:</span>
<span id="device-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>
<span class="badge border-white/10 bg-white/5 text-slate-200"> <span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> <svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path> <circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg> </svg>
<span id="last-update-status">Last Recv —</span> <span id="last-update-status">Waiting...</span>
</span> </span>
<span class="chip chip-emerald"> <span id="connection-status-chip" class="cham_chip cham_chip-amber" title="Connection to backend">
<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online <span class="h-2 w-2 rounded-full bg-amber-400"></span> Connecting...
</span> </span>
<span class="chip chip-amber" title="Running on backup supply">On Backup</span> <button id="station-reset-btn" class="btn btn-danger !p-2" title="Station Reset">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg>
</button>
<div class="hidden sm:block w-px h-5 bg-white/10"></div> <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"> <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="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"> <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="M21 12a9 9 0 10-3.5 7.1M21 12h-4m4 0l-2.5-2.5"></path>
<path d="M16 17l5-5-5-5"></path><path d="M21 12H9"></path>
</svg> </svg>
</button> </button>
</div>
<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> </div>
<!-- Tabs -->
<div class="border-t border-white/10 bg-black/10"> <div class="border-t border-white/10 bg-black/10">
<nav class="mx-auto max-w-7.5xl px-3 sm:px-4 flex"> <nav class="mx-auto max-w-7.5xl px-3 sm:px-4 flex">
<a href="./dashboard.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Main</a> <a href="./dashboard.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Main</a>
@ -142,176 +168,122 @@
</div> </div>
</header> </header>
<!-- CONTENT -->
<main class="relative z-10 mx-auto max-w-7.5xl px-3 sm:px-4 py-4 flex flex-col gap-4"> <main class="relative z-10 mx-auto max-w-7.5xl px-3 sm:px-4 py-4 flex flex-col gap-4">
<!-- Device + Date Range --> <section class="flex flex-wrap items-center gap-4">
<section class="flex flex-wrap items-center gap-2"> <div class="flex items-center gap-2">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-white/10 bg-white/5 text-slate-200"> <button data-range="today" class="date-range-btn btn btn-ghost">Today</button>
<span>Device ID:</span> <button data-range="7" class="date-range-btn btn btn-ghost">Last 7 Days</button>
<span id="device-id" class="font-bold mono"></span> <button data-range="30" class="date-range-btn btn btn-ghost">Last 30 Days</button>
</span> </div>
<div class="ml-auto flex items-center gap-2"> <div class="ml-auto flex items-center gap-2">
<input id="from" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60"> <input id="from" type="text" placeholder="Start Date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<span class="text-gray-500">to</span> <span class="text-gray-500">to</span>
<input id="to" type="date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60"> <input id="to" type="text" placeholder="End Date" class="rounded-md bg-white/5 border border-white/10 px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-emerald-500/60">
<button id="applyRange" class="btn">Apply</button> <button id="applyRange" class="btn">Apply</button>
</div> </div>
</section> </section>
<!-- Stat Tiles --> <section class="grid grid-cols-2 lg:grid-cols-6 gap-3">
<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>
</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>
</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 md:grid-cols-5 gap-3">
<div class="tile"> <div class="tile">
<p class="text-xs text-gray-400">Total Swaps (Today)</p> <p class="text-xs text-gray-400">Total Swaps Initiated</p>
<p class="text-3xl font-extrabold">142</p> <p id="total-swaps" class="text-3xl font-extrabold text-sky-400">...</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Completed Swaps</p>
<p class="text-3xl font-extrabold text-emerald-400">
<span id="completed-swaps">...</span>
<span id="success-rate" class="text-sm font-bold text-gray-400 ml-1">(...%)</span>
</p>
</div>
<div class="tile">
<p class="text-xs text-gray-400">Aborted Swaps</p>
<p class="text-3xl font-extrabold text-rose-400">
<span id="aborted-swaps">...</span>
<span id="abort-rate" class="text-sm font-bold text-gray-400 ml-1">(...%)</span>
</p>
</div> </div>
<div class="tile"> <div class="tile">
<p class="text-xs text-gray-400">Avg. Swap Time</p> <p class="text-xs text-gray-400">Avg. Swap Time</p>
<p class="text-3xl font-extrabold">2.1 <span class="text-lg font-bold text-gray-300">min</span></p> <p id="avg-swap-time" class="text-3xl font-extrabold">
<span id="avg-swap-time-value">...</span>
<span class="text-lg font-bold text-gray-300">min</span>
</p>
</div> </div>
<div class="tile"> <div class="tile">
<p class="text-xs text-gray-400">Station Uptime</p> <p class="text-xs text-gray-400">Station Uptime</p>
<p class="text-3xl font-extrabold text-emerald-400">99.8%</p> <p id="station-uptime" class="text-3xl font-extrabold text-teal-400">... %</p>
</div>
</section> -->
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="glass p-4 h-96">
<h3 class="font-extrabold">Swap Activity Over Time</h3>
<canvas id="swapActivityChart"></canvas>
</div> </div>
<div class="tile">
<p class="text-xs text-gray-400">Peak Hours</p> <div class="glass p-4 h-96">
<p class="text-3xl font-extrabold">57 PM</p> <h3 class="font-extrabold">Hourly Swap Distribution</h3>
<canvas id="hourlyDistributionChart"></canvas>
</div> </div>
<div class="glass p-4 h-96">
<h3 class="font-extrabold mb-4">Slot Utilization Heatmap</h3>
<div id="heatmap-grid" class="grid grid-cols-3 gap-4 h-[calc(100%-2rem)]">
</div>
</div>
<div class="glass p-4 h-96">
<h3 class="font-extrabold">Swap Abort Reasons</h3>
<canvas id="abortReasonsChart"></canvas>
</div>
<!-- <div class="glass p-4 h-96 flex items-center justify-center">
<p class="text-slate-500">Future Chart Area</p>
</div> -->
</section> </section>
<!-- Charts -->
<section class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-[20rem]">
<!-- Weekly Swaps - CSS bars -->
<div class="glass p-4">
<div class="flex items-center justify-between">
<h3 class="font-extrabold">Swaps This Week</h3>
<span class="text-xs text-gray-400">Mon → Sun</span>
</div>
<div class="mt-4 h-64 rounded-lg border border-white/10 bg-white/5 p-4 flex items-end gap-4 min-h-[20.8rem]">
<!-- Each group: bar + label -->
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:50%"></div><span class="text-xs text-gray-400">Mon</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:74%"></div><span class="text-xs text-gray-400">Tue</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:60%"></div><span class="text-xs text-gray-400">Wed</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:85%"></div><span class="text-xs text-gray-400">Thu</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:92%"></div><span class="text-xs text-gray-400">Fri</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:42%"></div><span class="text-xs text-gray-400">Sat</span>
</div>
<div class="flex flex-col items-center gap-2">
<div class="bar" style="height:30%"></div><span class="text-xs text-gray-400">Sun</span>
</div>
</div>
</div>
<!-- Battery Health - donut style -->
<div class="glass p-4">
<h3 class="font-extrabold">Battery Health</h3>
<div class="h-64 flex items-center justify-center">
<div class="relative w-52 h-52">
<svg class="w-full h-full" viewBox="0 0 36 36">
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none" stroke="#1f2937" stroke-width="3"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#ef4444" stroke-width="3" stroke-dasharray="20, 100"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#f59e0b" stroke-width="3" stroke-dasharray="30, 100"/>
<path d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831"
fill="none" stroke="#22c55e" stroke-width="3" stroke-dasharray="50, 100"/>
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center">
<span class="text-2xl font-extrabold">250</span>
<span class="text-xs text-gray-400">Total Batteries</span>
</div>
</div>
</div>
<div class="mt-3 grid grid-cols-3 gap-2 text-xs">
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-emerald-400"></span><span>Good</span></div>
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-amber-400"></span><span>Warning</span></div>
<div class="flex items-center gap-2"><span class="h-2.5 w-2.5 rounded-full bg-rose-400"></span><span>Poor</span></div>
</div>
</div>
</section>
</main> </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> </body>
</html> </html>

View File

@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Dashboard</title> <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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
@ -12,7 +14,7 @@
<script src="https://unpkg.com/lucide@latest" defer></script> <script src="https://unpkg.com/lucide@latest" defer></script>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script> <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="./js/dashboard.js"></script>
<script> <script>
tailwind.config = { tailwind.config = {
theme: { theme: {
@ -148,6 +150,13 @@
.bat-id-big{font-size:14px;font-weight:800;border-radius:.5rem;padding:.2rem .4rem; .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)} 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> </style>
</head> </head>
<body class="min-h-screen text-gray-100 flex flex-col"> <body class="min-h-screen text-gray-100 flex flex-col">
@ -157,8 +166,9 @@
<div class="absolute -bottom-24 -right-24 w-[36rem] h-[36rem] rounded-full bg-sky-500/10 blur-3xl"></div> <div class="absolute -bottom-24 -right-24 w-[36rem] h-[36rem] rounded-full bg-sky-500/10 blur-3xl"></div>
</div> </div>
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur"> <!-- <header> -->
<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"> <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="flex items-center gap-2 sm:gap-3"> <div class="flex items-center gap-2 sm:gap-3">
<a href="./station_selection.html" <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" class="h-9 w-9 flex items-center justify-center rounded-full bg-white/5 hover:bg-white/10 transition"
@ -182,31 +192,34 @@
</div> </div>
<div class="flex items-center flex-wrap justify-end gap-1.5 sm:gap-2"> <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 class="badge border-white/10 bg-white/5 text-slate-200">
<span>Device ID:</span> <span>Station ID:</span>
<span id="device-id"></span> <span id="device-id" class="font-semibold"></span>
</span> </span>
<span class="badge border-white/10 bg-white/5 text-slate-200"> <span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> <svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path> <circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg> </svg>
<span id="last-update-status">Waiting...</span> <span id="last-update-status">Waiting for data...</span>
</span> </span>
<span id="connection-status-chip" class="cham_chip cham_chip-amber" title="Connection to backend"> <span id="connection-status-chip" class="cham_chip cham_chip-amber" title="Connection to backend">
<span class="h-2 w-2 rounded-full bg-amber-400"></span> Connecting... <span class="h-2 w-2 rounded-full bg-amber-400"></span> Connecting...
</span> </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"> <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"/> <path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg> </svg>
</button> </button>
<!-- <span id="backup-power-chip" class="cham_chip cham_chip-slate" title="Station power source" style="display: none;">On Backup</span> -->
<div class="hidden sm:block w-px h-5 bg-white/10"></div> <div class="hidden sm:block w-px h-5 bg-white/10"></div>
<button id="refreshBtn" class="btn btn-ghost !p-2"> <button id="refreshBtn" class="btn btn-ghost !p-2">
@ -239,24 +252,25 @@
</div> </div>
</header> </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"> <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"> <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> <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"> <aside class="w-full lg:w-96 lg:shrink-0 flex flex-col gap-3 overflow-y-auto">
<section class="glass p-4"> <section class="glass p-4">
<div class="flex items-center justify-between mb-3"> <span class="text-sm font-semibold text-gray-400">STATION DIAGNOSTIC CODE (SDC)</span>
<span class="text-xm font-bold mb-2">System Diagnostics Code</span> <div class="flex items-baseline gap-2 mt-1 mb-3">
<span id="station-diag-code" class="text-sm font-bold text-emerald-300"></span> <i data-lucide="shield-alert" class="w-5 h-5 text-rose-500 flex-shrink-0"></i>
<span id="station-diag-code-raw" class="text-xl font-extrabold text-emerald-300 flex-1 text-right"></span>
</div> </div>
<div id="diag-flags-grid" class="grid grid-cols-2 gap-x-8 gap-y-1 text-sm"> <!-- <div id="sdc-visual-flags" class="flex flex-wrap gap-1.5 pt-3 border-t border-white/10 flex-wrap">
<div class="text-rose-300 text-center">Lock Power Cut</div><div class="text-rose-300 text-center">Main Power Cut</div> </div> -->
<div class="text-rose-300 text-center">Relayboard CAN</div><div class="text-rose-300 text-center">DB CAN Recv</div> <div id="diag-flags-grid" class="grid mt-3 pt-3 border-t border-white/10 content-center">
<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> </div>
<span id="backup-power-chip" class="cham_chip cham_chip-slate w-full justify-center mt-3"> <span id="backup-power-chip" class="cham_chip cham_chip-slate w-full justify-center mt-3"></span>
</section> </section>
<section id="swap-panel" class="glass p-4 flex flex-col min-h-[220px]"> <section id="swap-panel" class="glass p-4 flex flex-col min-h-[220px]">
@ -352,5 +366,8 @@
</div> </div>
</template> </template>
<script src="./js/common-header.js"></script>
<script src="./js/dashboard.js"></script>
</body> </body>
</html> </html>

View File

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

317
frontend/js/analytics.js Normal file
View File

@ -0,0 +1,317 @@
document.addEventListener('DOMContentLoaded', () => {
// --- CONFIGURATION ---
const SOCKET_URL = "http://172.20.10.4:5000";
const API_BASE = "http://172.20.10.4:5000/api";
// --- DOM ELEMENT REFERENCES ---
const stationNameEl = document.getElementById('station-name');
const stationLocationEl = document.getElementById('station-location');
const deviceIdEl = document.getElementById('device-id');
const productIdEl = document.getElementById('product-id');
const connChip = document.getElementById('connection-status-chip');
const totalInitiatedEl = document.getElementById('total-swaps-initiated');
const totalStartedEl = document.getElementById('total-swaps-started');
const completedSwapsEl = document.getElementById('completed-swaps');
const successRateEl = document.getElementById('success-rate');
const abortedSwapsEl = document.getElementById('aborted-swaps');
const abortRateEl = document.getElementById('abort-rate');
const avgSwapTimeValueEl = document.getElementById('avg-swap-time-value');
const stationUptimeEl = document.getElementById('station-uptime');
const fromDateInput = document.getElementById('from');
const toDateInput = document.getElementById('to');
const applyRangeBtn = document.getElementById('applyRange');
const quickRangeBtns = document.querySelectorAll('.date-range-btn');
const swapActivityCanvas = document.getElementById('swapActivityChart');
const hourlyDistributionCanvas = document.getElementById('hourlyDistributionChart');
const abortReasonsCanvas = document.getElementById('abortReasonsChart');
const heatmapGridEl = document.getElementById('heatmap-grid');
const logoutBtn = document.getElementById('logout-btn');
const refreshBtn = document.getElementById('refreshBtn');
// --- STATE & CONSTANTS ---
let selectedStation = null;
let socket;
let fromDatePicker, toDatePicker;
let swapChartInstance, hourlyChartInstance, abortChartInstance;
let lastKpis = {};
const chartDefaults = { color: 'rgba(203, 213, 225, 0.7)', borderColor: 'rgba(255, 255, 255, 0.1)' };
const ABORT_REASON_COLORS = { "Unknown": "#94a3b8", "Battery Exit Timeout": "#f43f5e", "Battery Entry Timeout": "#ec4899", "Door Close Timeout": "#f59e0b", "Door Open Timeout": "#eab308", "Invalid Parameter": "#a855f7", "Remote Abort": "#8b5cf6", "Invalid Battery": "#3b82f6" };
// --- HELPER FUNCTIONS ---
const updateStatTiles = (data) => {
if (!data) {
totalInitiatedEl.textContent = '...'; totalStartedEl.textContent = '...'; completedSwapsEl.textContent = '...';
successRateEl.textContent = '(...%)'; abortedSwapsEl.textContent = '...'; abortRateEl.textContent = '(...%)';
avgSwapTimeValueEl.textContent = '...'; stationUptimeEl.textContent = '... %'; lastKpis = {}; return;
}
const updateIfNeeded = (element, newValue, formatter = val => val) => {
const key = element.id; const formattedValue = formatter(newValue);
if (lastKpis[key] !== formattedValue) { element.textContent = formattedValue; lastKpis[key] = formattedValue; }
};
updateIfNeeded(totalInitiatedEl, data.total_swaps_initiated ?? 0);
updateIfNeeded(totalStartedEl, data.total_swaps_started ?? 0);
const completed = data.completed_swaps ?? 0; const aborted = data.aborted_swaps ?? 0; const totalStarts = data.total_swaps_started ?? 0;
updateIfNeeded(completedSwapsEl, completed); updateIfNeeded(abortedSwapsEl, aborted);
const successRate = totalStarts > 0 ? ((completed / totalStarts) * 100).toFixed(1) : 0;
updateIfNeeded(successRateEl, successRate, val => `(${val}%)`);
const abortRate = totalStarts > 0 ? ((aborted / totalStarts) * 100).toFixed(1) : 0;
updateIfNeeded(abortRateEl, abortRate, val => `(${val}%)`);
const avgTimeInSeconds = data.avg_swap_time_seconds != null ? Math.round(data.avg_swap_time_seconds) : '0';
updateIfNeeded(avgSwapTimeValueEl, avgTimeInSeconds);
const uptime = data.station_uptime ?? '...';
updateIfNeeded(stationUptimeEl, uptime, val => `${val} %`);
};
const renderSwapActivityChart = (data) => {
// Dynamically calculate the max value for the Y-axis
const maxValue = Math.max(...data.completed_data, ...data.aborted_data);
const yAxisMax = Math.ceil(maxValue * 1.2) + 1; // Add 20% padding + 1
// If the chart doesn't exist yet, create it.
if (!swapChartInstance) {
swapChartInstance = new Chart(swapActivityCanvas, {
type: 'bar',
data: {
labels: data.labels,
datasets: [
{ label: 'Completed Swaps', data: data.completed_data, backgroundColor: 'rgba(16, 185, 129, 0.6)' },
{ label: 'Aborted Swaps', data: data.aborted_data, backgroundColor: 'rgba(244, 63, 94, 0.6)' }
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: yAxisMax, // <-- SET DYNAMIC MAX VALUE
grid: { color: chartDefaults.borderColor },
ticks: {
color: chartDefaults.color,
stepSize: 1
}
},
x: {
grid: { display: false },
ticks: { color: chartDefaults.color }
}
},
plugins: {
legend: { labels: { color: chartDefaults.color } }
}
}
});
} else {
// If the chart already exists, just update its data.
swapChartInstance.data.labels = data.labels;
swapChartInstance.data.datasets[0].data = data.completed_data;
swapChartInstance.data.datasets[1].data = data.aborted_data;
swapChartInstance.options.scales.y.max = yAxisMax;
swapChartInstance.update(); // This smoothly animates the changes.
}
};
const renderHourlyDistributionChart = (data) => {
// Dynamically calculate the max value for the Y-axis
const maxValue = Math.max(...data.swap_data);
const yAxisMax = Math.ceil(maxValue * 1.2) + 1; // Add 20% padding + 1
if (!hourlyChartInstance) {
hourlyChartInstance = new Chart(hourlyDistributionCanvas, {
type: 'bar',
data: {
labels: data.labels,
datasets: [{ label: 'Total Swaps initiated', data: data.swap_data, backgroundColor: 'rgba(56, 189, 248, 0.6)' }]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: yAxisMax, // <-- SET DYNAMIC MAX VALUE
grid: { color: chartDefaults.borderColor },
ticks: {
color: chartDefaults.color,
stepSize: 1
}
},
x: {
grid: { display: false },
ticks: {
color: chartDefaults.color,
maxTicksLimit: 12
}
}
},
plugins: {
legend: { labels: { color: chartDefaults.color } }
}
}
});
} else {
hourlyChartInstance.data.labels = data.labels;
hourlyChartInstance.data.datasets[0].data = data.swap_data;
hourlyChartInstance.options.scales.y.max = yAxisMax;
hourlyChartInstance.update();
}
};
const renderAbortReasonsChart = (data) => {
if (!abortChartInstance) {
abortChartInstance = new Chart(abortReasonsCanvas, {
type: 'doughnut',
data: {
labels: data.labels,
datasets: [{ label: 'Count', data: data.reason_data, backgroundColor: data.labels.map(label => ABORT_REASON_COLORS[label] || '#cccccc') }]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top', // Better placement for donuts
labels: { color: chartDefaults.color }
}
}
}
});
} else {
abortChartInstance.data.labels = data.labels;
abortChartInstance.data.datasets[0].data = data.reason_data;
abortChartInstance.data.datasets[0].backgroundColor = data.labels.map(label => ABORT_REASON_COLORS[label] || '#cccccc');
abortChartInstance.update();
}
};
const renderSlotHeatmap = (data) => {
heatmapGridEl.innerHTML = ''; // Clear previous heatmap
if (!data || !data.counts || data.counts.length === 0) return;
const counts = data.counts;
const maxCount = Math.max(...counts);
counts.forEach((count, index) => {
const slotNumber = index + 1;
// Calculate color intensity: 0 = no usage, 1 = max usage
const intensity = maxCount > 0 ? count / maxCount : 0;
// Create HSL color: Hue is fixed (e.g., blue), Saturation is fixed, Lightness varies
// A low intensity (0) will be dark, a high intensity (1) will be bright.
const lightness = 20 + (50 * intensity); // Varies from 20% to 70%
const backgroundColor = `hsl(200, 80%, ${lightness}%)`;
const cell = document.createElement('div');
cell.className = 'rounded-md flex flex-col items-center justify-center text-white font-bold';
cell.style.backgroundColor = backgroundColor;
cell.innerHTML = `
<span class="text-xs opacity-70">Slot ${slotNumber}</span>
<span class="text-2xl">${count}</span>
<span class="text-xs opacity-70">swaps</span>
`;
heatmapGridEl.appendChild(cell);
});
};
const fetchAnalyticsData = async (startDate, endDate) => {
if (!selectedStation) return;
updateStatTiles(null);
try {
const params = new URLSearchParams({ station_id: selectedStation.id, start_date: startDate, end_date: endDate });
const response = await fetch(`${API_BASE}/analytics?${params}`);
if (!response.ok) throw new Error((await response.json()).message || 'Failed to fetch analytics data');
const analyticsData = await response.json();
updateStatTiles(analyticsData.kpis);
renderSwapActivityChart(analyticsData.swap_activity);
renderHourlyDistributionChart(analyticsData.hourly_distribution);
renderAbortReasonsChart(analyticsData.abort_reasons);
renderSlotHeatmap(analyticsData.slot_utilization);
} catch (error) { console.error("Error fetching analytics data:", error); updateStatTiles(null); }
};
const checkStationStatus = async () => { /* ... Your correct status check logic ... */ };
const connectSocket = () => {
socket = io(SOCKET_URL);
socket.on('connect', () => {
console.log("Analytics: Connected to WebSocket.");
if (selectedStation) {
socket.emit('join_station_room', { station_id: selectedStation.id });
}
});
// This listener handles the full refresh for important events
socket.on('analytics_updated', () => {
console.log("Analytics update received (Event/Request). Refetching data.");
applyRangeBtn.click();
});
// --- ADD THIS NEW LISTENER for lightweight status updates ---
socket.on('status_update', (data) => {
// data will look like: { status: 'Online' }
console.log("Live status update received:", data.status);
if (connChip) {
if (data.status === 'Online') {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
connChip.className = 'cham_chip cham_chip-emerald';
} else {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose';
}
}
});
};
function init() {
try {
selectedStation = JSON.parse(localStorage.getItem('selected_station'));
if (!selectedStation || !selectedStation.id) throw new Error('No station selected.');
} catch (e) { window.location.href = './station_selection.html'; return; }
stationNameEl.textContent = selectedStation.name;
stationLocationEl.textContent = selectedStation.location;
deviceIdEl.textContent = selectedStation.id;
productIdEl.textContent = selectedStation.product_id;
if (logoutBtn) logoutBtn.addEventListener('click', () => { localStorage.clear(); window.location.href = 'index.html'; });
if (refreshBtn) refreshBtn.addEventListener('click', () => location.reload());
applyRangeBtn.addEventListener('click', () => {
const startDate = fromDatePicker.selectedDates[0];
const endDate = toDatePicker.selectedDates[0];
if (startDate && endDate) {
fetchAnalyticsData(flatpickr.formatDate(startDate, "Y-m-d"), flatpickr.formatDate(endDate, "Y-m-d"));
}
});
quickRangeBtns.forEach(btn => {
btn.addEventListener('click', () => {
const range = btn.dataset.range;
const today = new Date(); let startDate = new Date();
if (range === 'today') startDate = today;
else startDate.setDate(today.getDate() - (parseInt(range, 10) - 1));
fromDatePicker.setDate(startDate, true); toDatePicker.setDate(today, true);
applyRangeBtn.click();
});
});
fromDatePicker = flatpickr(fromDateInput, { dateFormat: "Y-m-d", altInput: true, altFormat: "M j, Y" });
toDatePicker = flatpickr(toDateInput, { dateFormat: "Y-m-d", altInput: true, altFormat: "M j, Y" });
const todayStr = new Date().toISOString().split('T')[0];
fromDatePicker.setDate(todayStr, false); toDatePicker.setDate(todayStr, false);
fetchAnalyticsData(todayStr, todayStr);
checkStationStatus();
// setInterval(checkStationStatus, 10000);
connectSocket();
}
init();
});

11
frontend/js/auth-guard.js Normal file
View File

@ -0,0 +1,11 @@
(function() {
// Get the user from localStorage
const user = localStorage.getItem('user');
// Check if the user object exists. If not, redirect to the login page.
if (!user) {
// Use window.location.replace() to prevent the user from clicking the "back" button
// and re-accessing the protected page.
window.location.replace('/frontend/index.html');
}
})();

View File

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

View File

@ -0,0 +1,239 @@
// frontend/js/common-header.js
document.addEventListener('DOMContentLoaded', () => {
// --- CONFIGURATION ---
const SOCKET_URL = "http://172.20.10.4:5000";
const API_BASE = "http://172.20.10.4:5000/api";
// --- STATE & SELECTED STATION ---
let selectedStation = null;
let socket;
let statusPollingInterval;
try {
selectedStation = JSON.parse(localStorage.getItem('selected_station'));
if (!selectedStation || !selectedStation.id) {
window.location.href = './station_selection.html';
return;
}
} catch (e) {
window.location.href = './station_selection.html';
return;
}
// --- HEADER DOM ELEMENTS ---
const stationNameEl = document.getElementById('station-name');
const stationLocationEl = document.getElementById('station-location');
const stationIdEl = document.getElementById('station-id-display');
const productIdEl = document.getElementById('product-id-display');
const connChip = document.getElementById('connection-status-chip');
const lastUpdateEl = document.getElementById('last-update-status');
const logoutBtn = document.getElementById('logout-btn');
const navLinks = document.getElementById('main-nav');
const downloadBtn = document.getElementById('downloadBtn');
// --- INITIALIZATION ---
function initializeHeader() {
// Populate header with initial data
if (stationNameEl) stationNameEl.textContent = selectedStation.name || 'Unknown';
if (stationLocationEl) stationLocationEl.textContent = selectedStation.location || 'No location';
if (stationIdEl) stationIdEl.textContent = selectedStation.id;
if (productIdEl) productIdEl.textContent = selectedStation.product_id;
// Highlight the active tab
if (navLinks) {
const currentPage = window.location.pathname.split('/').pop();
const activeLink = navLinks.querySelector(`a[href="./${currentPage}"]`);
if (activeLink) {
activeLink.classList.remove('text-gray-400', 'hover:text-gray-200');
activeLink.classList.add('border-b-2', 'border-emerald-400/70', 'text-white');
}
}
// Start polling and connect WebSocket
checkStationStatus();
statusPollingInterval = setInterval(checkStationStatus, 10000);
connectSocket();
// Add event listener for the logout button
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
localStorage.clear();
window.location.href = './index.html';
});
}
if (downloadBtn) {
downloadBtn.addEventListener('click', showDownloadModal);
}
}
// --- POLLING FOR ONLINE/OFFLINE STATUS ---
const checkStationStatus = async () => {
try {
const response = await fetch(`${API_BASE}/stations`);
if (!response.ok) return;
const stations = await response.json();
const thisStation = stations.find(s => s.id === selectedStation.id);
if (thisStation && connChip) {
if (thisStation.status === 'Online') {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
connChip.className = 'cham_chip cham_chip-emerald';
} else {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose';
if (lastUpdateEl) lastUpdateEl.textContent = "Waiting for data...";
}
}
} catch (error) {
console.error("Failed to fetch station status:", error);
}
};
const showDownloadModal = () => {
const modalOverlay = document.createElement('div');
modalOverlay.className = "fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50";
modalOverlay.innerHTML = `
<div class="bg-slate-800 border border-slate-700 rounded-lg shadow-xl p-6 w-full max-w-md">
<h3 class="text-lg font-bold text-white mb-4">Export Logs</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Quick Time Ranges</label>
<div class="grid grid-cols-3 gap-2">
<button data-range="1" class="time-range-btn btn btn-ghost !py-1.5">Last Hour</button>
<button data-range="6" class="time-range-btn btn btn-ghost !py-1.5">Last 6 Hours</button>
<button data-range="24" class="time-range-btn btn btn-ghost !py-1.5">Last 24 Hours</button>
<button data-range="today" class="time-range-btn btn btn-ghost !py-1.5">Today</button>
<button data-range="yesterday" class="time-range-btn btn btn-ghost !py-1.5">Yesterday</button>
</div>
</div>
<div>
<label for="log-type" class="block text-sm font-medium text-gray-300">Log Type</label>
<select id="log-type" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
<option value="PERIODIC">Periodic Data</option>
<option value="EVENT">Events & RPC</option>
</select>
</div>
<div>
<label for="start-datetime" class="block text-sm font-medium text-gray-300">Start Date & Time</label>
<input type="text" id="start-datetime" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
</div>
<div>
<label for="end-datetime" class="block text-sm font-medium text-gray-300">End Date & Time</label>
<input type="text" id="end-datetime" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button id="cancel-download" class="btn btn-ghost px-4 py-2">Cancel</button>
<button id="confirm-download" class="btn btn-primary px-4 py-2">Download CSV</button>
</div>
</div>
`;
document.body.appendChild(modalOverlay);
const startInput = document.getElementById('start-datetime');
const endInput = document.getElementById('end-datetime');
const fpConfig = {
enableTime: true,
dateFormat: "Y-m-d\\TH:i",
time_24hr: true
};
const fpStart = flatpickr(startInput, fpConfig);
const fpEnd = flatpickr(endInput, fpConfig);
const now = new Date();
fpStart.setDate(new Date(now.getTime() - 3600 * 1000), true);
fpEnd.setDate(now, true);
modalOverlay.querySelectorAll('.time-range-btn').forEach(button => {
button.addEventListener('click', () => {
const range = button.dataset.range;
const now = new Date();
let start = new Date();
if (range === 'today') {
start.setHours(0, 0, 0, 0);
} else if (range === 'yesterday') {
start.setDate(start.getDate() - 1);
start.setHours(0, 0, 0, 0);
now.setDate(now.getDate() - 1);
now.setHours(23, 59, 59, 999);
} else {
start.setHours(now.getHours() - parseInt(range, 10));
}
fpStart.setDate(start, true);
fpEnd.setDate(now, true);
});
});
document.getElementById('cancel-download').onclick = () => document.body.removeChild(modalOverlay);
document.getElementById('confirm-download').onclick = async () => {
const logType = document.getElementById('log-type').value;
const startDateStr = document.getElementById('start-datetime').value;
const endDateStr = document.getElementById('end-datetime').value;
const confirmBtn = document.getElementById('confirm-download');
if (!startDateStr || !endDateStr) {
return alert('Please select both a start and end date/time.');
}
confirmBtn.textContent = 'Fetching...';
confirmBtn.disabled = true;
const downloadUrl = `${API_BASE}/logs/export?station_id=${selectedStation.id}&start_datetime=${startDateStr}&end_datetime=${endDateStr}&log_type=${logType}`;
try {
const response = await fetch(downloadUrl);
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
let filename = `${selectedStation.name || selectedStation.id}_${logType}_${startDateStr.split('T')[0]}.csv`;
const disposition = response.headers.get('Content-Disposition');
if (disposition && disposition.includes('attachment')) {
const filenameMatch = disposition.match(/filename="(.+?)"/);
if (filenameMatch && filenameMatch.length === 2) filename = filenameMatch[1];
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
document.body.removeChild(modalOverlay);
} else {
const errorData = await response.json();
alert(`Could not download: ${errorData.message}`);
}
} catch (error) {
alert('An unexpected error occurred. Please check the console.');
console.error('Download error:', error);
} finally {
confirmBtn.textContent = 'Download CSV';
confirmBtn.disabled = false;
}
};
};
// --- WEBSOCKET CONNECTION ---
const connectSocket = () => {
socket = io(SOCKET_URL);
socket.on('connect', () => {
console.log("Header: Connected to WebSocket.");
socket.emit('join_station_room', { station_id: selectedStation.id });
});
socket.on('disconnect', () => console.log("Header: Disconnected from WebSocket."));
socket.on('dashboard_update', (message) => {
if (message.stationId === selectedStation.id && lastUpdateEl) {
lastUpdateEl.textContent = `Last Recv ${new Date().toLocaleTimeString()}`;
}
});
};
// --- START THE SCRIPT ---
initializeHeader();
});

View File

@ -1,7 +1,7 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// --- CONFIGURATION --- // --- CONFIGURATION ---
const SOCKET_URL = "http://localhost:5000"; const SOCKET_URL = "http://172.20.10.4:5000";
const API_BASE = "http://localhost:5000/api"; // Added for API calls const API_BASE = "http://172.20.10.4:5000/api"; // Added for API calls
// --- DOM ELEMENT REFERENCES --- // --- DOM ELEMENT REFERENCES ---
const grid = document.getElementById('chambersGrid'); const grid = document.getElementById('chambersGrid');
@ -11,8 +11,12 @@ document.addEventListener('DOMContentLoaded', () => {
const stationNameEl = document.getElementById('station-name'); const stationNameEl = document.getElementById('station-name');
const stationLocationEl = document.getElementById('station-location'); const stationLocationEl = document.getElementById('station-location');
const deviceIdEl = document.getElementById('device-id'); const deviceIdEl = document.getElementById('device-id');
const productIdEl = document.getElementById('product-id');
const lastUpdateEl = document.getElementById('last-update-status'); const lastUpdateEl = document.getElementById('last-update-status');
const stationDiagCodeEl = document.getElementById('station-diag-code');
const stationDiagCodeEl = document.getElementById('station-diag-code-raw'); // CHANGED ID: Use new raw code display ID
const sdcVisualFlagsEl = document.getElementById('sdc-visual-flags');
const backupPowerChip = document.getElementById('backup-power-chip'); const backupPowerChip = document.getElementById('backup-power-chip');
const diagFlagsGrid = document.getElementById('diag-flags-grid'); const diagFlagsGrid = document.getElementById('diag-flags-grid');
const audioSelect = document.getElementById('audio-command-select'); const audioSelect = document.getElementById('audio-command-select');
@ -29,14 +33,40 @@ document.addEventListener('DOMContentLoaded', () => {
let chamberData = Array(9).fill({ batteryPresent: false }); let chamberData = Array(9).fill({ batteryPresent: false });
// The list of errors from your Python code // The list of errors from your Python code
const DIAGNOSTIC_ERRORS = [ // const DIAGNOSTIC_ERRORS = [
"Lock Power Cut", "Main Power Cut", // "Lock Power Cut", "Main Power Cut",
"Relayboard CAN", "DB CAN Recv", // "Relayboard CAN", "DB CAN Recv",
"MB Can Recv", "Smoke Alarm", // "MB Can Recv", "Smoke Alarm",
"Water Alarm", "Phase Failure", // "Water Alarm", "Phase Failure",
"Earth Leakage" // "Earth Leakage"
// ];
const ALARM_GROUPS = [
{ name: "EarthLk", start_bit: 20, count: 11 },
{ name: "DBCF", start_bit: 5, count: 9 },
{ name: "RBCF", start_bit: 14, count: 3 },
{ name: "PF", start_bit: 17, count: 3 },
{ name: "SMPS", start_bit: 0, count: 2 },
{ name: "MBCF", start_bit: 4, count: 1 },
{ name: "Smoke Alarm", start_bit: 2, count: 1 },
{ name: "Water Level", start_bit: 3, count: 1 },
]; ];
const BATTERY_FAULT_MAP = {
8: "UT", // Under Temperature
4: "OV", // Over Voltage
2: "OT", // Over Temperature
1: "OC" // Over Current
};
const CHARGER_FAULT_MAP = {
1: "OV", // Over Voltage
2: "UV", // Under Voltage
4: "OT", // Over Temperature
8: "CAN Failure"
// Add other charger fault codes here
};
// --- NEW: SWAP PROCESS ELEMENTS & LOGIC --- // --- NEW: SWAP PROCESS ELEMENTS & LOGIC ---
const swapIdleText = document.getElementById('swap-idle-text'); const swapIdleText = document.getElementById('swap-idle-text');
const swapPairsList = document.getElementById('swap-pairs-list'); const swapPairsList = document.getElementById('swap-pairs-list');
@ -113,6 +143,58 @@ document.addEventListener('DOMContentLoaded', () => {
updateSwapUI(); 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) --- // --- HELPER FUNCTIONS (Your original code is unchanged) ---
@ -149,6 +231,63 @@ document.addEventListener('DOMContentLoaded', () => {
logTextArea.value = newLog + logTextArea.value; 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) => { const updateChamberUI = (card, slot) => {
if (!card || !slot) return; if (!card || !slot) return;
@ -176,18 +315,24 @@ document.addEventListener('DOMContentLoaded', () => {
card.querySelector('.soc').textContent = `${slot.soc || 0}%`; card.querySelector('.soc').textContent = `${slot.soc || 0}%`;
card.querySelector('.voltage').textContent = `${((slot.voltage || 0) / 1000).toFixed(1)} V`; 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-temp').textContent = `${((slot.batteryMaxTemp || 0) / 10).toFixed(1)} °C`;
card.querySelector('.bat-fault').textContent = slot.batteryFaultCode || '—'; // card.querySelector('.bat-fault').textContent = slot.batteryFaultCode || '—';
card.querySelector('.bat-fault').textContent = decodeFaults(slot.batteryFaultCode, BATTERY_FAULT_MAP);
// card.querySelector('.bat-fault').textContent = decodeBatteryFaults(slot.batteryFaultCode);
card.querySelector('.current').textContent = `${((slot.current || 0) / 1000).toFixed(1)} A`; card.querySelector('.current').textContent = `${((slot.current || 0) / 1000).toFixed(1)} A`;
card.querySelector('.slot-temp').textContent = `${((slot.slotTemperature || 0) / 10).toFixed(1)} °C`; card.querySelector('.slot-temp').textContent = `${((slot.slotTemperature || 0) / 10).toFixed(1)} °C`;
card.querySelector('.chg-fault').textContent = slot.chargerFaultCode || '—'; card.querySelector('.chg-temp').textContent = `${((slot.chargerMaxTemp || 0) / 10).toFixed(1)} °C`;
// card.querySelector('.chg-fault').textContent = slot.chargerFaultCode || '—';
card.querySelector('.chg-fault').textContent = decodeFaults(slot.chargerFaultCode, CHARGER_FAULT_MAP);
const batPill = card.querySelector('.battery-status-pill'); 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.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Present`;
batPill.className = 'battery-status-pill chip chip-emerald'; batPill.className = 'battery-status-pill chip chip-emerald';
const chgPill = card.querySelector('.charger-status-pill'); const chgPill = card.querySelector('.charger-status-pill');
if (slot.chargerMode === 1) { if (slot.chargerPresent && slot.current > 0) {
chgPill.innerHTML = `<span class="h-2 w-2 rounded-full bg-sky-400 animate-pulseDot"></span> Charging`; chgPill.innerHTML = `<span class="h-2 w-2 rounded-full bg-sky-400 animate-pulseDot"></span> Charging`;
chgPill.className = 'charger-status-pill chip chip-sky'; chgPill.className = 'charger-status-pill chip chip-sky';
} else { } else {
@ -219,25 +364,62 @@ document.addEventListener('DOMContentLoaded', () => {
}; };
// --- NEW: Function to decode the SDC and update the UI --- // --- NEW: Function to decode the SDC and update the UI ---
const updateDiagnosticsUI = (sdcCode) => { // const updateDiagnosticsUI = (sdcCode) => {
if (!diagFlagsGrid) return; // if (!diagFlagsGrid) return;
diagFlagsGrid.innerHTML = ''; // Clear previous statuses // diagFlagsGrid.innerHTML = ''; // Clear previous statuses
DIAGNOSTIC_ERRORS.forEach((errorText, index) => { // DIAGNOSTIC_ERRORS.forEach((errorText, index) => {
// Use bitwise AND to check if the bit at this index is set // // Use bitwise AND to check if the bit at this index is set
const isActive = (sdcCode & (1 << index)) !== 0; // const isActive = (sdcCode & (1 << index)) !== 0;
const div = document.createElement('div'); // const div = document.createElement('div');
div.textContent = errorText; // div.textContent = errorText;
// Apply different styles based on whether the alarm is active // // Apply different styles based on whether the alarm is active
if (isActive) { // if (isActive) {
div.className = 'text-rose-300 text-center font-semibold'; // div.className = 'text-rose-300 text-center font-semibold';
} else { // } else {
div.className = 'text-slate-500 text-center'; // div.className = 'text-slate-500 text-center';
// }
// diagFlagsGrid.appendChild(div);
// });
// };
function updateDiagnosticAlarms(sdcValue){
const sdc = Number(sdcValue);
const hexEl = document.getElementById("station-diag-code-raw"); // hex display
const chipsEl = document.getElementById("sdc-visual-flags"); // quick flags row
const gridEl = document.getElementById("diag-flags-grid"); // dots grid
if (hexEl) hexEl.textContent = toHex8(sdc);
renderSdcChips(chipsEl, sdc);
renderSdcGrid(gridEl, sdc);
}
const checkStationStatus = async () => {
if (!selectedStation) return;
try {
const response = await fetch(`${API_BASE}/stations`);
if (!response.ok) return;
const stations = await response.json();
const thisStation = stations.find(s => s.id === selectedStation.id);
if (thisStation && connChip) {
if (thisStation.status === 'Online') {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
connChip.className = 'cham_chip cham_chip-emerald';
} else {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose';
if (lastUpdateEl) lastUpdateEl.textContent = "Waiting for data...";
// Optionally reset the dashboard if the station goes offline
// resetDashboardUI();
}
} }
diagFlagsGrid.appendChild(div); } catch (error) {
}); console.error("Failed to fetch station status:", error);
}
}; };
const resetDashboardUI = () => { const resetDashboardUI = () => {
@ -255,200 +437,38 @@ document.addEventListener('DOMContentLoaded', () => {
card.querySelector('.filled-state').style.display = 'none'; card.querySelector('.filled-state').style.display = 'none';
card.querySelector('.empty-state').style.display = 'flex'; card.querySelector('.empty-state').style.display = 'flex';
}); });
updateDiagnosticAlarms(0);
logToInstance("Station is offline. Clearing stale data.", "error"); logToInstance("Station is offline. Clearing stale data.", "error");
}; };
// --- NEW: This function polls the API for the true station status --- // --- NEW: This function polls the API for the true station status ---
const checkStationStatus = async () => { // const checkStationStatus = async () => {
if (!selectedStation) return; // if (!selectedStation) return;
try { // try {
const response = await fetch(`${API_BASE}/stations`); // const response = await fetch(`${API_BASE}/stations`);
if (!response.ok) return; // if (!response.ok) return;
const stations = await response.json(); // const stations = await response.json();
const thisStation = stations.find(s => s.id === selectedStation.id); // const thisStation = stations.find(s => s.id === selectedStation.id);
if (thisStation && connChip) { // if (thisStation && connChip) {
stationNameEl.textContent = thisStation.name; // stationNameEl.textContent = thisStation.name;
stationLocationEl.textContent = thisStation.location; // stationLocationEl.textContent = thisStation.location;
if (thisStation.status === 'Online') { // if (thisStation.status === 'Online') {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`; // connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
connChip.className = 'cham_chip cham_chip-emerald'; // connChip.className = 'cham_chip cham_chip-emerald';
} else { // } else {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`; // connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose'; // connChip.className = 'cham_chip cham_chip-rose';
lastUpdateEl.textContent = "Waiting for data..."; // lastUpdateEl.textContent = "Waiting for data...";
resetDashboardUI(); // resetDashboardUI();
} // }
} // }
} catch (error) { // } catch (error) {
console.error("Failed to fetch station status:", error); // console.error("Failed to fetch station status:", error);
} // }
}; // };
// --- DOWNLOAD MODAL LOGIC ---
const showDownloadModal = () => {
const modalOverlay = document.createElement('div');
modalOverlay.className = "fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50";
modalOverlay.innerHTML = `
<div class="bg-slate-800 border border-slate-700 rounded-lg shadow-xl p-6 w-full max-w-md">
<h3 class="text-lg font-bold text-white mb-4">Export Logs</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Quick Time Ranges</label>
<div class="grid grid-cols-3 gap-2">
<button data-range="1" class="time-range-btn btn btn-ghost !py-1.5">Last Hour</button>
<button data-range="6" class="time-range-btn btn btn-ghost !py-1.5">Last 6 Hours</button>
<button data-range="24" class="time-range-btn btn btn-ghost !py-1.5">Last 24 Hours</button>
<button data-range="today" class="time-range-btn btn btn-ghost !py-1.5">Today</button>
<button data-range="yesterday" class="time-range-btn btn btn-ghost !py-1.5">Yesterday</button>
</div>
</div>
<div>
<label for="log-type" class="block text-sm font-medium text-gray-300">Log Type</label>
<select id="log-type" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
<option value="PERIODIC">Periodic Data</option>
<option value="EVENT">Events & RPC</option>
</select>
</div>
<div>
<label for="start-datetime" class="block text-sm font-medium text-gray-300">Start Date & Time</label>
<input type="text" id="start-datetime" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
</div>
<div>
<label for="end-datetime" class="block text-sm font-medium text-gray-300">End Date & Time</label>
<input type="text" id="end-datetime" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button id="cancel-download" class="btn btn-ghost px-4 py-2">Cancel</button>
<button id="confirm-download" class="btn btn-primary px-4 py-2">Download CSV</button>
</div>
</div>
`;
document.body.appendChild(modalOverlay);
const startInput = document.getElementById('start-datetime');
const endInput = document.getElementById('end-datetime');
// --- NEW: Initialize flatpickr on the inputs ---
const fpConfig = {
enableTime: true,
dateFormat: "Y-m-d\\TH:i", // Format needed by the backend
time_24hr: true
};
const fpStart = flatpickr(startInput, fpConfig);
const fpEnd = flatpickr(endInput, fpConfig);
// --- (The rest of the function is the same) ---
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 3600 * 1000);
fpStart.setDate(oneHourAgo, true);
fpEnd.setDate(now, true);
modalOverlay.querySelectorAll('.time-range-btn').forEach(button => {
button.addEventListener('click', () => {
const range = button.dataset.range;
const now = new Date();
let start = new Date();
if (range === 'today') {
start.setHours(0, 0, 0, 0);
} else if (range === 'yesterday') {
start.setDate(start.getDate() - 1);
start.setHours(0, 0, 0, 0);
now.setDate(now.getDate() - 1);
now.setHours(23, 59, 59, 999);
} else {
start.setHours(now.getHours() - parseInt(range, 10));
}
fpStart.setDate(start, true);
fpEnd.setDate(now, true);
});
});
document.getElementById('cancel-download').onclick = () => document.body.removeChild(modalOverlay);
document.getElementById('confirm-download').onclick = async () => {
const logType = document.getElementById('log-type').value;
const startDateStr = document.getElementById('start-datetime').value;
const endDateStr = document.getElementById('end-datetime').value;
const confirmBtn = document.getElementById('confirm-download');
if (!startDateStr || !endDateStr) {
alert('Please select both a start and end date/time.');
return;
}
// --- Validation Logic ---
const selectedStartDate = new Date(startDateStr);
const selectedEndDate = new Date(endDateStr);
const currentDate = new Date();
if (selectedStartDate > currentDate) {
alert('Error: The start date cannot be in the future.');
return;
}
if (selectedEndDate > currentDate) {
alert('Error: The end date cannot be in the future.');
return;
}
if (selectedStartDate >= selectedEndDate) {
alert('Error: The start date must be earlier than the end date.');
return;
}
// --- Fetch and Download Logic ---
confirmBtn.textContent = 'Fetching...';
confirmBtn.disabled = true;
const downloadUrl = `${API_BASE}/logs/export?station_id=${selectedStation.id}&start_datetime=${startDateStr}&end_datetime=${endDateStr}&log_type=${logType}`;
try {
const response = await fetch(downloadUrl);
if (response.ok) { // Status 200, CSV file received
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
const logType = document.getElementById('log-type').value;
const dateStr = startDateStr.split('T')[0]; // Get just the date part
let filename = `${selectedStation.name || selectedStation.id}_${logType}_${dateStr}.csv`;
const disposition = response.headers.get('Content-Disposition');
if (disposition && disposition.indexOf('attachment') !== -1) {
const filenameMatch = disposition.match(/filename="(.+?)"/);
if (filenameMatch && filenameMatch.length === 2) {
filename = filenameMatch[1];
}
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
document.body.removeChild(modalOverlay);
} else { // Status 404, no data found
const errorData = await response.json();
alert(`Could not download: ${errorData.message}`);
}
} catch (error) {
alert('An unexpected error occurred. Please check the console.');
console.error('Download error:', error);
} finally {
confirmBtn.textContent = 'Download CSV';
confirmBtn.disabled = false;
}
};
};
// --- MAIN LOGIC (Your original code is unchanged) --- // --- MAIN LOGIC (Your original code is unchanged) ---
const initializeDashboard = () => { const initializeDashboard = () => {
@ -459,7 +479,12 @@ document.addEventListener('DOMContentLoaded', () => {
} }
stationNameEl.textContent = selectedStation.name || 'Unknown Station'; stationNameEl.textContent = selectedStation.name || 'Unknown Station';
stationLocationEl.textContent = selectedStation.location || 'No location'; stationLocationEl.textContent = selectedStation.location || 'No location';
deviceIdEl.textContent = selectedStation.id;
// 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) { } 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>`; 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; return;
@ -540,7 +565,6 @@ document.addEventListener('DOMContentLoaded', () => {
window.location.href = './index.html'; window.location.href = './index.html';
}); });
} }
if (downloadBtn) downloadBtn.addEventListener('click', showDownloadModal);
// Audio Command Button (assuming it exists in your HTML) // Audio Command Button (assuming it exists in your HTML)
const sendAudioBtn = document.getElementById('send-audio-btn'); const sendAudioBtn = document.getElementById('send-audio-btn');
@ -574,36 +598,40 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
socket.on('dashboard_update', (message) => { socket.on('dashboard_update', (message) => {
// console.log("DEBUG: Received 'dashboard_update' message:", message); console.log("DEBUG: Received 'dashboard_update' message:", message);
const { stationId, data } = message; const { stationId, data } = message;
console.log("Received data payload:", data);
if (stationId !== selectedStation.id) { if (stationId !== selectedStation.id) {
console.warn(`Ignoring message for wrong station. Expected ${selectedStation.id}, got ${stationId}`); console.warn(`Ignoring message for wrong station. Expected ${selectedStation.id}, got ${stationId}`);
return; return;
} }
lastUpdateEl.textContent = `Last Recv ${new Date().toLocaleTimeString()}`; lastUpdateEl.textContent = `Last Recv ${new Date().toLocaleTimeString()}`;
stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—';
// 1. Check if the 'stationDiagnosticCode' key exists in the data.
// Show/hide the backup power chip based on the payload data if (data.hasOwnProperty('stationDiagnosticCode')) {
if (data.backupSupplyStatus === 1) { const sdcRaw = data.stationDiagnosticCode;
backupPowerChip.textContent = 'On Backup'; const sdc = Number.isFinite(sdcRaw) ? Number(sdcRaw) : 0;
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-amber'; updateDiagnosticAlarms(sdc);
} else {
backupPowerChip.textContent = 'On Mains Power';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-emerald';
} }
lastUpdateEl.textContent = new Date().toLocaleTimeString(); // 2. Check if the 'backupSupplyStatus' key exists.
stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—'; if (data.hasOwnProperty('backupSupplyStatus')) {
if (data.backupSupplyStatus === 1) {
backupPowerChip.textContent = 'On Backup';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-amber';
} else {
backupPowerChip.textContent = 'On Mains Power';
backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-emerald';
}
}
// --- NEW: Call the function to update the diagnostics grid --- // 3. Only process chamber-level data if it exists.
updateDiagnosticsUI(data.stationDiagnosticCode || 0);
if (data.slotLevelPayload && Array.isArray(data.slotLevelPayload)) { if (data.slotLevelPayload && Array.isArray(data.slotLevelPayload)) {
data.slotLevelPayload.forEach((slotData, index) => { data.slotLevelPayload.forEach((slotData, index) => {
const slotId = index + 1; const slotId = index + 1;
chamberData[slotId - 1] = slotData; // Keep live data in sync
const card = grid.querySelector(`.chamber-card[data-chamber-id="${slotId}"]`); const card = grid.querySelector(`.chamber-card[data-chamber-id="${slotId}"]`);
if (card) { if (card) {
updateChamberUI(card, slotData); updateChamberUI(card, slotData);

View File

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

View File

@ -0,0 +1,79 @@
// // frontend/js/page-header.js
// document.addEventListener('DOMContentLoaded', () => {
// // 1. Get the station data from Local Storage
// const selectedStation = JSON.parse(localStorage.getItem('selected_station'));
// // 2. Safety check: If no station is selected, go back to the selection page
// if (!selectedStation) {
// alert('No station selected. Redirecting to the selection page.');
// window.location.href = 'station_selection.html';
// return;
// }
// // 3. Find all the display elements in the header
// const stationNameEl = document.getElementById('station-name');
// const stationLocationEl = document.getElementById('station-location');
// const stationIdEl = document.getElementById('station-id-display');
// const productIdEl = document.getElementById('product-id-display');
// // 4. Update the elements with the station's data
// if (stationNameEl) stationNameEl.textContent = selectedStation.name;
// if (stationLocationEl) stationLocationEl.textContent = selectedStation.location;
// if (stationIdEl) stationIdEl.textContent = selectedStation.id;
// if (productIdEl) productIdEl.textContent = selectedStation.product_id;
// });
// frontend/js/page-header.js
// This function fetches the common header and injects it into the page
async function loadHeader() {
try {
const response = await fetch('_header.html');
if (!response.ok) {
throw new Error('Could not load the header file.');
}
const headerHTML = await response.text();
// Adds the header right after the opening <body> tag
document.body.insertAdjacentHTML('afterbegin', headerHTML);
} catch (error) {
console.error('Failed to load header:', error);
// Optionally, display an error to the user
}
}
// This function populates the header with data from localStorage
function populateHeaderData() {
const selectedStation = JSON.parse(localStorage.getItem('selected_station'));
if (!selectedStation) {
alert('No station selected. Redirecting...');
window.location.href = 'station_selection.html';
return;
}
const stationNameEl = document.getElementById('station-name');
const stationLocationEl = document.getElementById('station-location');
const stationIdEl = document.getElementById('station-id-display');
const productIdEl = document.getElementById('product-id-display');
if (stationNameEl) stationNameEl.textContent = selectedStation.name;
if (stationLocationEl) stationLocationEl.textContent = selectedStation.location;
if (stationIdEl) stationIdEl.textContent = selectedStation.id;
if (productIdEl) productIdEl.textContent = selectedStation.product_id;
}
// Main execution block
// We use an async function to make sure the header is loaded BEFORE we try to fill it
async function initializePageHeader() {
await loadHeader();
populateHeaderData();
}
// Run the initialization when the page content is loaded
document.addEventListener('DOMContentLoaded', initializePageHeader);

View File

@ -1,25 +1,22 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// --- DOM ELEMENTS --- // --- DOM ELEMENTS ---
const stationsGrid = document.getElementById('stations-grid'); const stationsGrid = document.getElementById('stations-grid');
const stationTemplate = document.getElementById('stationCardTemplate'); const stationCountEl = document.getElementById('station-count'); // Make sure you have an element with this ID in your HTML
const errorMessage = document.getElementById('error-message');
const stationCountEl = document.getElementById('station-count');
// Note: SocketIO is not needed on this page anymore
let allStations = []; // To store the master list of stations // --- CONFIG & STATE ---
const API_BASE = 'http://172.20.10.4:5000/api';
let allStations = []; // Master list of stations from the API
let pollingInterval = null;
// --- AUTHENTICATION & USER INFO (Your existing code is perfect) --- // --- AUTHENTICATION ---
const user = JSON.parse(localStorage.getItem('user')); const user = JSON.parse(localStorage.getItem('user'));
if (!user) { if (!user) {
window.location.href = 'index.html'; // Redirect if not logged in window.location.href = 'index.html'; // Redirect if not logged in
return; return;
} }
// User info and logout button logic... (omitted for brevity, no changes needed) // (Your other button listeners for logout, add user, etc., can go here)
// document.getElementById('logoutBtn').onclick = () => { ... };
// --- ADMIN FEATURES (Your existing code is perfect) ---
// Admin button and add station card logic... (omitted for brevity, no changes needed)
// --- HELPER FUNCTIONS --- // --- HELPER FUNCTIONS ---
const getStatusAttributes = (status) => { const getStatusAttributes = (status) => {
@ -34,45 +31,76 @@ document.addEventListener('DOMContentLoaded', () => {
window.location.href = `dashboard.html?station_id=${stationId}`; window.location.href = `dashboard.html?station_id=${stationId}`;
}; };
// This function now only renders the initial grid // --- UI RENDERING ---
// This function's only job is to build the HTML. It does not add event listeners.
const renderStations = (stations) => { const renderStations = (stations) => {
stationsGrid.innerHTML = ''; stationsGrid.innerHTML = ''; // Clear the grid
stationCountEl.textContent = `${stations.length} stations found. Select one to monitor.`; stationCountEl.textContent = `${stations.length} stations found.`;
stations.forEach(station => { stations.forEach(station => {
const status = getStatusAttributes(station.status); const status = getStatusAttributes(station.status);
const card = document.createElement('div'); const card = document.createElement('div');
card.className = "group bg-gray-900/60 backdrop-blur-xl rounded-2xl shadow-lg border border-gray-700 transition-transform duration-300 ease-out cursor-pointer flex flex-col justify-between hover:-translate-y-1.5 hover:border-emerald-400/60 hover:shadow-[0_0_0_1px_rgba(16,185,129,0.25),0_20px_40px_rgba(0,0,0,0.45)]"; // Add station ID to the card's dataset for easy access
card.id = `station-${station.id}`; card.dataset.stationId = station.id;
card.onclick = () => handleStationSelect(station.id); card.dataset.stationName = station.name;
card.className = "station-card group bg-gray-900/60 backdrop-blur-xl rounded-2xl shadow-lg border border-gray-700 transition-transform duration-300 ease-out flex flex-col justify-between hover:-translate-y-1.5 hover:border-emerald-400/60 hover:shadow-[0_0_0_1px_rgba(16,185,129,0.25),0_20px_40px_rgba(0,0,0,0.45)]";
card.innerHTML = ` card.innerHTML = `
<div class="p-5"> <div class="main-content p-5 flex-grow cursor-pointer" data-station-json='${JSON.stringify(station)}'>
<div class="flex justify-between items-start"> <div class="flex justify-between items-start">
<h3 class="text-lg font-bold text-white pr-2">${station.name}</h3> <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}"> <div class="status-badge flex items-center text-xs font-semibold px-3 py-1 rounded-full ${status.bgColor} ${status.color}">
<i data-lucide="${status.icon}" class="w-4 h-4 mr-1.5"></i>
<span class="status-text">${station.status}</span> <span class="status-text">${station.status}</span>
</div> </div>
</div> </div>
<p class="text-sm text-gray-400 mt-1">${station.id}</p> <p class="text-sm text-gray-400 mt-2 font-mono">${station.id}</p>
</div>
<div class="border-t border-gray-700/50 px-5 pt-3 pb-4">
<div class="grid grid-cols-3 gap-2 text-center">
<div>
<p class="text-xs text-slate-400">Total Starts</p>
<p class="font-bold text-lg text-white stat-total">0</p>
</div>
<div>
<p class="text-xs text-slate-400">Completed</p>
<p class="font-bold text-lg text-emerald-400 stat-completed">0</p>
</div>
<div>
<p class="text-xs text-slate-400">Aborted</p>
<p class="font-bold text-lg text-rose-400 stat-aborted">0</p>
</div>
</div>
</div>
<div class="border-t border-gray-700/50 px-5 py-2 flex justify-between items-center bg-black/20 rounded-b-2xl">
<button class="open-btn text-sm font-bold bg-emerald-500/80 hover:bg-emerald-500 text-white py-1 px-4 rounded-md transition">
Open
</button>
<button class="remove-btn text-gray-400 hover:text-red-500 transition" title="Remove Station">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</button>
</div> </div>
`; `;
stationsGrid.appendChild(card); stationsGrid.appendChild(card);
}); });
lucide.createIcons();
if (window.lucide) {
lucide.createIcons();
}
}; };
// --- NEW: Function to update statuses without redrawing everything ---
const updateStationStatuses = (stations) => { const updateStationStatuses = (stations) => {
stations.forEach(station => { stations.forEach(station => {
const card = document.getElementById(`station-${station.id}`); const card = stationsGrid.querySelector(`[data-station-id="${station.id}"]`);
if (card) { if (card) {
const status = getStatusAttributes(station.status); const status = getStatusAttributes(station.status);
const statusBadge = card.querySelector('.status-badge'); const statusBadge = card.querySelector('.status-badge');
const statusText = card.querySelector('.status-text'); const statusText = card.querySelector('.status-text');
const statusIcon = card.querySelector('i[data-lucide]'); const statusIcon = card.querySelector('i[data-lucide]');
if (statusBadge && statusText && statusIcon) { 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}`; 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; statusText.textContent = station.status;
@ -80,35 +108,98 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
}); });
lucide.createIcons(); // Re-render icons if any changed if (window.lucide) {
lucide.createIcons();
}
}; };
// --- DATA FETCHING & STATUS POLLING ---
const loadAndPollStations = async () => { //-- NEW: Fetch and apply daily stats to each card ---
const fetchAndApplyStats = async () => {
try { try {
const response = await fetch('http://localhost:5000/api/stations'); const response = await fetch(`${API_BASE}/stations/daily-stats`);
if (!response.ok) throw new Error('Failed to fetch stations'); if (!response.ok) return; // Fail silently if stats aren't available
const stations = await response.json(); const stats = await response.json();
// Check if this is the first time loading data // Loop through the stats object and update each card
if (allStations.length === 0) { for (const stationId in stats) {
allStations = stations; const stationCard = stationsGrid.querySelector(`.station-card[data-station-id="${stationId}"]`);
renderStations(allStations); // Initial full render if (stationCard) {
} else { const statData = stats[stationId];
allStations = stations; stationCard.querySelector('.stat-total').textContent = statData.total_starts;
updateStationStatuses(allStations); // Subsequent, efficient updates stationCard.querySelector('.stat-completed').textContent = statData.completed;
stationCard.querySelector('.stat-aborted').textContent = statData.aborted;
}
}
} catch (error) {
console.error("Could not fetch daily stats:", error);
}
};
// --- MAIN EVENT LISTENER ---
// This single listener handles all clicks on the grid for efficiency.
stationsGrid.addEventListener('click', async (event) => {
const mainContent = event.target.closest('.main-content');
const removeButton = event.target.closest('.remove-btn');
if (mainContent) {
const card = mainContent.closest('[data-station-id]');
if (card) {
handleStationSelect(card.dataset.stationId);
}
} else if (removeButton) {
event.stopPropagation(); // Prevent main content click
const card = removeButton.closest('[data-station-id]');
const stationId = card.dataset.stationId;
const stationName = card.dataset.stationName;
if (!confirm(`Are you sure you want to permanently remove "${stationName}"?`)) {
return;
} }
try {
const response = await fetch(`${API_BASE}/stations/${stationId}`, { method: 'DELETE' });
if (response.ok) {
alert(`Station "${stationName}" removed successfully.`);
allStations = []; // Force a full refresh on next poll
loadAndPollStations();
} else {
const error = await response.json();
alert(`Failed to remove station: ${error.message}`);
}
} catch (error) {
console.error('Error removing station:', error);
alert('An error occurred while trying to remove the station.');
}
}
});
// --- DATA FETCHING & POLLING ---
const loadAndPollStations = async () => {
try {
const response = await fetch(`${API_BASE}/stations`);
if (!response.ok) throw new Error('Failed to fetch stations');
const newStationList = await response.json();
// If the number of stations has changed, we must do a full re-render.
if (newStationList.length !== allStations.length) {
allStations = newStationList;
renderStations(allStations);
} else {
// Otherwise, we can do a more efficient status-only update.
allStations = newStationList;
updateStationStatuses(allStations);
fetchAndApplyStats(); // Fetch and update daily stats
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
stationCountEl.textContent = 'Could not load stations. Is the backend running?'; stationCountEl.textContent = 'Could not load stations. Is the backend running?';
// Stop polling on error
if (pollingInterval) clearInterval(pollingInterval); if (pollingInterval) clearInterval(pollingInterval);
} }
}; };
// --- INITIALIZATION --- // --- INITIALIZATION ---
loadAndPollStations(); // Load immediately on page start loadAndPollStations(); // Load immediately on page start
// Then, set an interval to refresh the statuses every 10 seconds pollingInterval = setInterval(loadAndPollStations, 10000);
const pollingInterval = setInterval(loadAndPollStations, 10000);
}); });

View File

@ -5,10 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Logs</title> <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 --> <!-- Font + Tailwind -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.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 src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = {
@ -77,7 +85,7 @@
title="Back"> title="Back">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
</svg> </svg>
</a> </a>
<div class="flex flex-col leading-tight"> <div class="flex flex-col leading-tight">
<div id="station-name" class="text-base sm:text-lg font-extrabold tracking-tight"> <div id="station-name" class="text-base sm:text-lg font-extrabold tracking-tight">
@ -94,11 +102,21 @@
</div> </div>
<div class="flex items-center flex-wrap justify-end gap-1.5 sm:gap-2"> <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 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>Device ID:</span>
<span id="device-id"></span> <span id="device-id"></span>
</span> </span> -->
<span class="badge border-white/10 bg-white/5 text-slate-200"> <span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> <svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
@ -156,6 +174,25 @@
<main class="relative z-10 mx-auto max-w-7.5xl px-3 sm:px-4 py-4 flex flex-col gap-4"> <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 --> <!-- 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 --> <!-- Logs panels -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-[18rem]"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-[18rem]">
<!-- Request --> <!-- Request -->
@ -201,6 +238,7 @@
</div> </div>
</main> </main>
<script src="./js/logs.js"></script> <script src="./js/common-header.js"></script>
<script src="./js/logs.js"></script>
</body> </body>
</html> </html>

View File

@ -5,10 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Select a Station</title> <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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"> <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 src="https://cdn.tailwindcss.com"></script>
<script> <script>
tailwind.config = { tailwind.config = {
@ -109,7 +113,16 @@
<span class="status-dot h-2.5 w-2.5 rounded-full"></span> <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> <p class="truncate text-sm text-gray-400"><span class="font-semibold text-gray-200 station-name">Station</span></p>
</div> </div>
<p class="mt-1 text-xs text-gray-400 station-location">Location</p> <!-- <p class="product-id mt-1 text-xs text-gray-400" title="Product ID">-</p> -->
<div class="product-id-wrapper mt-1 flex items-center gap-1.5 text-xs text-gray-400">
<i data-lucide="hash" class="h-3 w-3 text-gray-500"></i>
<span class="product-id"></span>
</div>
<!-- <p class="mt-1 text-xs text-gray-400 station-location">Location</p> -->
<div class="location-wrapper mt-1 flex items-center gap-1.5 text-xs text-gray-400">
<i data-lucide="map-pin" class="h-3 w-3 text-gray-500"></i>
<span class="station-location">Location</span>
</div>
</div> </div>
<span class="status-badge rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"></span> <span class="status-badge rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"></span>
</div> </div>
@ -120,6 +133,10 @@
<p class="text-[10px] text-gray-400">Station ID</p> <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> <p class="station-id font-semibold text-gray-200 whitespace-nowrap overflow-x-auto leading-snug text-[11px]" title="Station ID"></p>
</div> </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> </div>
<!-- Metrics Row --> <!-- Metrics Row -->
@ -138,9 +155,21 @@
</div> </div>
</div> </div>
<button <div class="mt-4 flex items-center gap-2">
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 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">
</button> 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> </div>
</template> </template>
@ -179,6 +208,7 @@
<h2 class="text-lg font-bold mb-4">Add Station</h2> <h2 class="text-lg font-bold mb-4">Add Station</h2>
<form id="stationForm" class="space-y-3"> <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="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="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="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> <input type="text" placeholder="MQTT Broker" id="mqttBroker" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
@ -195,7 +225,7 @@
</div> </div>
<script> <script>
const API_BASE = 'http://localhost:5000/api'; const API_BASE = 'http://172.20.10.4:5000/api';
const grid = document.getElementById('stations-grid'); const grid = document.getElementById('stations-grid');
const addStationCardTmpl = document.getElementById('add-station-card-template'); const addStationCardTmpl = document.getElementById('add-station-card-template');
@ -244,13 +274,15 @@
e.preventDefault(); e.preventDefault();
const payload = { const payload = {
station_id: stationId.value.trim(), station_id: stationId.value.trim(),
product_id: stationProductId.value.trim(),
name: stationName.value.trim(), name: stationName.value.trim(),
location: stationLocation.value.trim(), location: stationLocation.value.trim(),
mqtt_broker: mqttBroker.value.trim(), mqtt_broker: mqttBroker.value.trim(),
mqtt_port: Number(mqttPort.value), mqtt_port: Number(mqttPort.value),
mqtt_username: mqttUsername.value || null, mqtt_user: mqttUsername.value || null,
mqtt_password: mqttPassword.value || null, mqtt_password: mqttPassword.value || null,
}; };
try { try {
const res = await fetch(`${API_BASE}/stations`, { const res = await fetch(`${API_BASE}/stations`, {
method:'POST', method:'POST',
@ -289,6 +321,17 @@
const node = stationCardTmpl.content.cloneNode(true); const node = stationCardTmpl.content.cloneNode(true);
const card = node.querySelector('div'); const card = node.querySelector('div');
card.querySelector('.station-name').textContent = s.name ?? `Station ${s.id || s.station_id}`; 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 ?? '—'; card.querySelector('.station-location').textContent = s.location ?? '—';
const idVal = s.id || s.station_id || '—'; const idVal = s.id || s.station_id || '—';
const idEl = card.querySelector('.station-id'); const idEl = card.querySelector('.station-id');
@ -315,6 +358,35 @@
const id = encodeURIComponent(s.id || s.station_id); const id = encodeURIComponent(s.id || s.station_id);
window.location.href = `./dashboard.html?stationId=${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); grid.appendChild(node);
} }
@ -325,6 +397,10 @@
const addCard = addNode.querySelector('div'); const addCard = addNode.querySelector('div');
addCard.addEventListener('click', () => openModal(stationModal)); addCard.addEventListener('click', () => openModal(stationModal));
grid.appendChild(addNode); grid.appendChild(addNode);
if (window.lucide) {
lucide.createIcons();
}
} }
statusBtn.addEventListener('click', () => { statusBtn.addEventListener('click', () => {