feat(frontend): Finalize dashboard and logs pages with full interactivity

This commit completes the initial version of the frontend application by adding full functionality to the logs page and implementing several key UI/UX improvements across the dashboard.

Key Changes:

- **feat(logs):**
  - Implemented a real-time log viewer by connecting the page to the backend WebSocket server.
  - The script now intelligently sorts incoming `EVENTS` and `RPC` messages into their respective log textareas.
  - The header is now fully dynamic and shares the same live status polling logic as the main dashboard for UI consistency.

- **fix(dashboard):**
  - Corrected the online/offline status logic to use a reliable polling mechanism, ensuring the header status is always accurate.
  - Resolved a bug that caused the dashboard to show stale data after a station went offline by implementing a UI reset function.
  - Implemented a more robust, themed `flatpickr` date/time picker for the download modal.

- **refactor(ui):**
  - Added a universal button feedback system using CSS transitions for a smooth "press" effect on all buttons.
  - Redesigned the "empty chamber" state to be more intuitive, featuring a larger icon and conditionally displayed slot temperature.
  - Reorganized button layouts in the header and sidebar for better ergonomics.
dev
Kirubakaran 2025-09-04 21:55:05 +05:30
parent ea1e8a9266
commit 1ce929258f
25 changed files with 3450 additions and 238 deletions

58
.gitignore vendored Normal file
View File

@ -0,0 +1,58 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
.env
*.env
*.sqlite3
*.db
instance/
# Flask
*.log
*.pot
*.mo
# VS Code
.vscode/
# Node.js
node_modules/
package-lock.json
# Frontend build
/dist/
/build/
*.map
# OS
.DS_Store
Thumbs.db
# Jupyter
.ipynb_checkpoints/
# Misc
*.bak
*.swp
*.swo
# Protobuf
*.pb2.py
*.pb2.pyi
# Tailwind
css/tailwind.css
# Logs
*.log
logs/
# Others
*.coverage
.coverage
# Ignore test output
*.out
*.tmp

View File

@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

28
backend/Dockerfile Normal file
View File

@ -0,0 +1,28 @@
# Use official Python image
FROM python:3.11-slim
# Set work directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy backend code
COPY . .
# Expose Flask port
EXPOSE 5000
# Set environment variables
ENV FLASK_APP=main.py
ENV FLASK_ENV=production
# Start the Flask app (use gunicorn for production)
CMD ["gunicorn", "main:app", "--bind", "0.0.0.0:5000", "--worker-class", "eventlet", "--workers", "1"]

View File

@ -10,7 +10,6 @@ class MqttClient:
This is a standard Python class, with no GUI dependencies. This is a standard Python class, with no GUI dependencies.
""" """
def __init__(self, broker, port, user, password, station_id, on_message_callback): def __init__(self, broker, port, user, password, station_id, on_message_callback):
super().__init__()
self.broker = broker self.broker = broker
self.port = port self.port = port
self.user = user self.user = user
@ -18,7 +17,6 @@ class MqttClient:
self.station_id = station_id self.station_id = station_id
self.on_message_callback = on_message_callback self.on_message_callback = on_message_callback
# Generate a unique client ID to prevent connection conflicts
unique_id = str(uuid.uuid4()) unique_id = str(uuid.uuid4())
self.client_id = f"WebApp-Backend-{self.station_id}-{unique_id}" self.client_id = f"WebApp-Backend-{self.station_id}-{unique_id}"
@ -32,26 +30,32 @@ class MqttClient:
if self.user and self.password: if self.user and self.password:
self.client.username_pw_set(self.user, self.password) self.client.username_pw_set(self.user, self.password)
def on_connect(self, client, userdata, flags, rc, properties): 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.""" """Callback for when the client connects to the broker."""
if rc == 0: if reason_code == 0:
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}")
# Subscribe to all topics for this station using a wildcard
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:
print(f"Failed to connect to MQTT for station {self.station_id}, return code {rc}") print(f"Failed to connect to MQTT for station {self.station_id}, return code {reason_code}")
def on_disconnect(self, client, userdata, rc, 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...") print(f"Disconnected from MQTT for station {self.station_id}. Will attempt to reconnect...")
# Paho-MQTT's loop_start() handles automatic reconnection.
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."""
try: try:
# Pass the relevant data to the main application's handler
self.on_message_callback(self.station_id, msg.topic, msg.payload) self.on_message_callback(self.station_id, msg.topic, msg.payload)
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}")

View File

@ -1,209 +1,25 @@
# import os
# import sys
# import threading
# import json
# import csv
# import io
# from datetime import datetime
# from flask import Flask, jsonify, request, Response
# from flask_socketio import SocketIO
# from dotenv import load_dotenv
# # Import your custom core modules and the new models
# from core.mqtt_client import MqttClient
# from core.protobuf_decoder import ProtobufDecoder
# from models import db, Station, User, MqttLog
# # --- Load Environment Variables ---
# load_dotenv()
# # --- Pre-startup Check for Essential Configuration ---
# DATABASE_URL = os.getenv("DATABASE_URL")
# if not DATABASE_URL:
# print("FATAL ERROR: DATABASE_URL is not set in .env file.")
# sys.exit(1)
# # --- Application Setup ---
# app = Flask(__name__)
# app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL
# app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# app.config['SECRET_KEY'] = os.getenv("SECRET_KEY", "a_very_secret_key")
# db.init_app(app)
# socketio = SocketIO(app, cors_allowed_origins="*")
# # --- Global instances ---
# decoder = ProtobufDecoder()
# mqtt_clients = {}
# # --- MQTT Message Handling ---
# def on_message_handler(station_id, topic, payload):
# """
# Handles incoming MQTT messages, decodes them, writes to PostgreSQL,
# and emits to WebSockets.
# """
# print(f"Main handler received message for station {station_id} on topic {topic}")
# decoded_data = None
# message_type = topic.split('/')[-1]
# if message_type == 'PERIODIC':
# decoded_data = decoder.decode_periodic(payload)
# elif message_type == 'EVENTS':
# decoded_data = decoder.decode_event(payload)
# elif message_type == 'REQUEST':
# decoded_data = decoder.decode_rpc_request(payload)
# if decoded_data:
# # 1. Write the data to PostgreSQL for historical storage
# try:
# with app.app_context():
# log_entry = MqttLog(
# station_id=station_id,
# topic=topic,
# payload=decoded_data
# )
# db.session.add(log_entry)
# db.session.commit()
# print(f"Successfully wrote data for {station_id} to PostgreSQL.")
# except Exception as e:
# print(f"Error writing to PostgreSQL: {e}")
# # 2. Emit the data to the frontend for real-time view
# socketio.emit('dashboard_update', {
# 'stationId': station_id,
# 'topic': topic,
# 'data': decoded_data
# }, room=station_id)
# # --- (WebSocket and API routes remain the same) ---
# @socketio.on('connect')
# def handle_connect():
# print('Client connected to WebSocket')
# @socketio.on('disconnect')
# def handle_disconnect():
# print('Client disconnected')
# @socketio.on('join_station_room')
# def handle_join_station_room(data):
# station_id = data.get('station_id')
# if station_id:
# from flask import request
# socketio.join_room(station_id, request.sid)
# @socketio.on('leave_station_room')
# def handle_leave_station_room(data):
# station_id = data.get('station_id')
# if station_id:
# from flask import request
# socketio.leave_room(station_id, request.sid)
# @app.route('/api/stations', methods=['GET'])
# def get_stations():
# try:
# stations = Station.query.all()
# return jsonify([{"id": s.station_id, "name": s.name} for s in stations])
# except Exception as e:
# return jsonify({"error": f"Database query failed: {e}"}), 500
# # --- (CSV Export route remains the same) ---
# @app.route('/api/logs/export', methods=['GET'])
# def export_logs():
# # ... (existing implementation)
# pass
# # --- Main Application Logic (UPDATED) ---
# 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():
# # Get the full station objects, not just the IDs
# 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})")
# # Use the specific details from each station object in the database
# 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__':
# try:
# with app.app_context():
# db.create_all()
# if not Station.query.first():
# print("No stations found. Adding a default station with default MQTT config.")
# # Add a default station with MQTT details for first-time setup
# default_station = Station(
# station_id="V16000868210069259709",
# name="Test Station 2",
# mqtt_broker="mqtt-dev.upgrid.in",
# mqtt_port=1883,
# mqtt_user="guest",
# mqtt_password="password"
# )
# db.session.add(default_station)
# db.session.commit()
# except Exception as e:
# print(f"FATAL ERROR: Could not connect to PostgreSQL: {e}")
# sys.exit(1)
# mqtt_thread = threading.Thread(target=start_mqtt_clients, daemon=True)
# mqtt_thread.start()
# print(f"Starting Flask-SocketIO server on http://localhost:5000")
# socketio.run(app, host='0.0.0.0', port=5000)
import os import os
import sys import sys
import threading import threading
import json import json
import csv import csv
import io import io
import time
from datetime import datetime from datetime import datetime
from flask import Flask, jsonify, request, Response from flask import Flask, jsonify, request, Response
from flask_socketio import SocketIO from flask_socketio import SocketIO, join_room
from flask_cors import CORS
from dotenv import load_dotenv from dotenv import load_dotenv
# 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
from core.protobuf_decoder import ProtobufDecoder from core.protobuf_decoder import ProtobufDecoder
from models import db, Station, User, MqttLog from models import db, Station, User, MqttLog
from flask_login import login_required, current_user, LoginManager
from proto.vec_payload_chgSt_pb2 import (
rpcRequest,
jobType_e
)
# --- Load Environment Variables --- # --- Load Environment Variables ---
load_dotenv() load_dotenv()
@ -216,22 +32,41 @@ if not DATABASE_URL:
# --- Application Setup --- # --- Application Setup ---
app = Flask(__name__) app = Flask(__name__)
# CORS(app)
# 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://localhost:5173"}})
# This tells Flask: "For any route starting with /api/, allow requests
# from the frontend running on http://localhost:5173".
# ADD THESE LINES FOR FLASK-LOGIN
login_manager = LoginManager()
login_manager.init_app(app)
app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = os.getenv("SECRET_KEY", "a_very_secret_key") app.config['SECRET_KEY'] = os.getenv("SECRET_KEY", "a_very_secret_key")
db.init_app(app) db.init_app(app)
socketio = SocketIO(app, cors_allowed_origins="*") socketio = SocketIO(app, cors_allowed_origins="*")
# --- User Loader for Flask-Login ---
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# --- Global instances --- # --- Global instances ---
decoder = ProtobufDecoder() decoder = ProtobufDecoder()
mqtt_clients = {} mqtt_clients = {}
last_message_timestamps = {}
STATION_TIMEOUT_SECONDS = 10
# --- MQTT Message Handling (UPDATED) --- # --- 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()
Handles incoming MQTT messages, decodes them, writes to PostgreSQL,
and emits to WebSockets.
"""
print(f"Main handler received message for station {station_id} on topic {topic}") print(f"Main handler received message for station {station_id} on topic {topic}")
decoded_data = None decoded_data = None
@ -245,13 +80,13 @@ 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:
# 1. Write the data to PostgreSQL for historical storage # print("DECODED DATA TO BE SENT:", decoded_data)
try: try:
with app.app_context(): with app.app_context():
log_entry = MqttLog( log_entry = MqttLog(
station_id=station_id, station_id=station_id,
topic=topic, topic=topic,
topic_type=message_type, # <-- Save the new topic_type topic_type=message_type,
payload=decoded_data payload=decoded_data
) )
db.session.add(log_entry) db.session.add(log_entry)
@ -260,7 +95,6 @@ def on_message_handler(station_id, topic, payload):
except Exception as e: except Exception as e:
print(f"Error writing to PostgreSQL: {e}") print(f"Error writing to PostgreSQL: {e}")
# 2. Emit the data to the frontend for real-time view
socketio.emit('dashboard_update', { socketio.emit('dashboard_update', {
'stationId': station_id, 'stationId': station_id,
'topic': topic, 'topic': topic,
@ -272,16 +106,127 @@ def on_message_handler(station_id, topic, payload):
def handle_connect(): def handle_connect():
print('Client connected to WebSocket') print('Client connected to WebSocket')
# ... (other socketio handlers) # --- NEW: Function to handle joining a room and sending initial data ---
@socketio.on('join_station_room')
def handle_join_station_room(data):
station_id = data['station_id']
join_room(station_id)
print(f"Client joined room for station: {station_id}")
try:
# Find the most recent log entry for this station
latest_log = MqttLog.query.filter_by(
station_id=station_id,
topic_type='PERIODIC'
).order_by(MqttLog.timestamp.desc()).first()
if latest_log:
# If we have a past log, send it immediately to the new client
print(f"Sending initial state for {station_id} to new client.")
socketio.emit('dashboard_update', {
'stationId': station_id,
'topic': latest_log.topic,
'data': latest_log.payload
}, room=station_id)
except Exception as e:
print(f"Error querying or sending initial state for {station_id}: {e}")
# --- API Routes ---
@app.route('/api/login', methods=['POST'])
def login():
"""Handles user login."""
data = request.get_json()
if not data or not data.get('username') or not data.get('password'):
return jsonify({"message": "Username and password are required."}), 400
user = User.query.filter_by(username=data['username']).first()
if user and user.check_password(data['password']):
# In a real app, you would create a session token here (e.g., with Flask-Login)
return jsonify({"message": "Login successful"}), 200
return jsonify({"message": "Invalid username or password"}), 401
# --- Admin-only: Add User ---
@app.route('/api/users', methods=['POST'])
# @login_required # Ensures the user is logged in
def add_user():
# Check if the logged-in user is an admin
# if not current_user.is_admin:
# return jsonify({"message": "Admin access required."}), 403 # Forbidden
data = request.get_json()
username = data.get('username')
password = data.get('password')
is_admin = data.get('is_admin', False)
if not username or not password:
return jsonify({"message": "Username and password are required."}), 400
if User.query.filter_by(username=username).first():
return jsonify({"message": "Username already exists."}), 409 # Conflict
new_user = User(username=username, is_admin=is_admin)
new_user.set_password(password)
db.session.add(new_user)
db.session.commit()
return jsonify({"message": "User added successfully."}), 201
# --- Admin-only: Add Station ---
@app.route('/api/stations', methods=['POST'])
# @login_required # Ensures the user is logged in
def add_station():
# if not current_user.is_admin:
# return jsonify({"message": "Admin access required."}), 403
data = request.get_json()
# All fields are now expected from the frontend form
required_fields = ['station_id', 'name', 'location', 'mqtt_broker', 'mqtt_port']
if not all(field in data for field in required_fields):
return jsonify({"message": "Missing required station details."}), 400
if Station.query.filter_by(station_id=data['station_id']).first():
return jsonify({"message": "Station ID already exists."}), 409
new_station = Station(
station_id=data['station_id'],
name=data['name'],
location=data['location'],
mqtt_broker=data['mqtt_broker'],
mqtt_port=data['mqtt_port'],
mqtt_user=data.get('mqtt_user'),
mqtt_password=data.get('mqtt_password')
)
db.session.add(new_station)
db.session.commit()
# You might want to start the new MQTT client here as well
# start_single_mqtt_client(new_station)
return jsonify({"message": "Station added successfully."}), 201
@app.route('/api/stations', methods=['GET']) @app.route('/api/stations', methods=['GET'])
def get_stations(): def get_stations():
try: try:
stations = Station.query.all() stations = Station.query.all()
return jsonify([{"id": s.station_id, "name": s.name} for s in stations]) station_list = []
for s in stations:
# --- NEW: More accurate heartbeat logic ---
last_msg_time = last_message_timestamps.get(s.station_id)
# A station is online only if we have received a message recently
is_online = last_msg_time is not None and (time.time() - last_msg_time) < STATION_TIMEOUT_SECONDS
station_list.append({
"id": s.station_id,
"name": s.name,
"location": s.location,
"status": "Online" if is_online else "Offline"
})
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
# --- CSV Export route (UPDATED) --- # --- CSV Export route (UPDATED) ---
def _format_periodic_row(payload, num_slots=9): def _format_periodic_row(payload, num_slots=9):
""" """
@ -330,31 +275,48 @@ def _format_periodic_row(payload, num_slots=9):
@app.route('/api/logs/export', methods=['GET']) @app.route('/api/logs/export', methods=['GET'])
def export_logs(): def export_logs():
station_id = request.args.get('station_id') station_id = request.args.get('station_id')
start_date_str = request.args.get('start_date') start_datetime_str = request.args.get('start_datetime')
end_date_str = request.args.get('end_date') end_datetime_str = request.args.get('end_datetime')
log_type = request.args.get('log_type', 'PERIODIC') log_type = request.args.get('log_type', 'PERIODIC')
if not all([station_id, start_date_str, end_date_str]): if not all([station_id, start_datetime_str, end_datetime_str]):
return jsonify({"error": "Missing required parameters: station_id, start_date, end_date"}), 400 return jsonify({"error": "Missing required parameters"}), 400
try: try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d') start_datetime = datetime.strptime(start_datetime_str, '%Y-%m-%dT%H:%M')
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) end_datetime = datetime.strptime(end_datetime_str, '%Y-%m-%dT%H:%M')
except ValueError: except ValueError:
return jsonify({"error": "Invalid date format. Use YYYY-MM-DD."}), 400 return jsonify({"error": "Invalid datetime format"}), 400
# UPDATED QUERY: Filter by the new 'topic_type' column for better performance # --- FIX 1: Correctly query for Events & RPC ---
if log_type == 'EVENT':
# If frontend asks for EVENT, search for both EVENTS and REQUEST in the DB
query = MqttLog.query.filter( query = MqttLog.query.filter(
MqttLog.station_id == station_id, MqttLog.station_id == station_id,
MqttLog.timestamp.between(start_date, end_date), MqttLog.timestamp.between(start_datetime, end_datetime),
MqttLog.topic_type.in_(['EVENTS', 'REQUEST'])
)
else: # Otherwise, query for PERIODIC
query = MqttLog.query.filter(
MqttLog.station_id == station_id,
MqttLog.timestamp.between(start_datetime, end_datetime),
MqttLog.topic_type == log_type MqttLog.topic_type == log_type
) )
logs = query.order_by(MqttLog.timestamp.asc()).all() logs = query.order_by(MqttLog.timestamp.asc()).all()
if not logs:
return jsonify({"message": "No logs found for the selected criteria."}), 404
output = io.StringIO() output = io.StringIO()
writer = csv.writer(output) writer = csv.writer(output)
# --- FIX 2: Create a cleaner filename ---
station = Station.query.filter_by(station_id=station_id).first()
station_name = station.name.replace(' ', '_') if station else station_id
date_str = start_datetime.strftime('%Y-%m-%d')
filename = f"{station_name}_{log_type}_{date_str}.csv"
if log_type == 'PERIODIC': if log_type == 'PERIODIC':
base_header = ["Timestamp", "DeviceID", "StationDiagnosticCode"] base_header = ["Timestamp", "DeviceID", "StationDiagnosticCode"]
slot_fields = [ slot_fields = [
@ -378,9 +340,88 @@ def export_logs():
return Response( return Response(
output, output,
mimetype="text/csv", mimetype="text/csv",
headers={"Content-Disposition": f"attachment;filename=logs_{station_id}_{log_type}_{start_date_str}_to_{end_date_str}.csv"} headers={"Content-Disposition": f"attachment;filename={filename}"}
) )
@socketio.on('rpc_request')
def handle_rpc_request(payload):
"""
Receives a command from the web dashboard, creates a Protobuf RPC request,
and publishes it to the station via MQTT.
"""
station_id = payload.get('station_id')
command = payload.get('command')
data = payload.get('data') # This will be the slot_id or swap_pairs array
print(f"Received RPC request for station {station_id}: {command} with data {data}")
# Find the correct MQTT client for this station
mqtt_client = mqtt_clients.get(station_id)
if not mqtt_client or not mqtt_client.is_connected:
print(f"Cannot send RPC for {station_id}: MQTT client not connected.")
return # Or emit an error back to the user
# --- Create the Protobuf message based on the command ---
# This is where the logic from your snippet is implemented.
request_payload = rpcRequest(
ts=int(time.time()),
jobId=f"job_{int(time.time())}"
)
# Determine the jobType and set data based on the command string
if command == 'OPEN':
request_payload.jobType = jobType_e.JOBTYPE_GATE_OPEN_CLOSE
request_payload.slotInfo.slotId = data
request_payload.slotInfo.state = 1
elif command == 'CHG_ON':
# Replace this with the correct name from your .proto file
request_payload.jobType = jobType_e.JOBTYPE_CHARGER_ENABLE_DISABLE
request_payload.slotInfo.slotId = data
request_payload.slotInfo.state = 1 # State 1 for ON
elif command == 'CHG_OFF':
# Replace this with the correct name from your .proto file
request_payload.jobType = jobType_e.JOBTYPE_CHARGER_ENABLE_DISABLE
request_payload.slotInfo.slotId = data
request_payload.slotInfo.state = 0 # State 0 for OFF
elif command == 'START_SWAP':
# --- THIS IS THE CORRECTED LINE ---
request_payload.jobType = jobType_e.JOBTYPE_SWAP_START
if data and isinstance(data, list):
# Your logic for adding the swap pairs to the payload
for pair in data:
swap_info = request_payload.swapInfo.add()
swap_info.fromSlot = pair[0]
swap_info.toSlot = pair[1]
# --- NEW: Added handlers for Abort and Reset ---
elif command == 'ABORT_SWAP':
request_payload.jobType = jobType_e.JOBTYPE_TRANSACTION_ABORT
elif command == 'STATION_RESET':
request_payload.jobType = jobType_e.JOBTYPE_REBOOT
elif command == 'LANGUAGE_UPDATE':
request_payload.jobType = jobType_e.JOBTYPE_LANGUAGE_UPDATE
# Logic to map language string to enum would go here
else:
print(f"Unknown command: {command}")
return
# --- Serialize and Publish the Message ---
serialized_payload = request_payload.SerializeToString()
# Construct the MQTT topic
# NOTE: You may need to fetch client_id and version from your database
topic = f"VEC/batterySmartStation/v100/{station_id}/RPC/REQUEST"
print(f"Publishing to {topic}")
mqtt_client.client.publish(topic, serialized_payload)
# --- Main Application Logic --- # --- Main Application Logic ---
def start_mqtt_clients(): def start_mqtt_clients():
""" """
@ -413,15 +454,24 @@ if __name__ == '__main__':
try: try:
with app.app_context(): with app.app_context():
db.create_all() db.create_all()
# Add a default user if none exist
if not User.query.first():
print("No users found. Creating a default admin user.")
default_user = User(username='admin')
default_user.set_password('password')
db.session.add(default_user)
db.session.commit()
# Add a default station if none exist
if not Station.query.first(): if not Station.query.first():
print("No stations found. Adding a default station with default MQTT config.") print("No stations found. Adding a default station.")
default_station = Station( default_station = Station(
station_id="V16000868210069259709", station_id="V16000862287077265957",
name="Test Station 2", name="Test Station 1",
mqtt_broker="mqtt-dev.upgrid.in", mqtt_broker="mqtt.vecmocon.com",
mqtt_port=1883, mqtt_port=1883,
mqtt_user="guest", mqtt_user="your_username",
mqtt_password="password" mqtt_password="your_password"
) )
db.session.add(default_station) db.session.add(default_station)
db.session.commit() db.session.commit()

View File

@ -1,5 +1,6 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from werkzeug.security import generate_password_hash, check_password_hash
# Create a SQLAlchemy instance. This will be linked to the Flask app in main.py. # Create a SQLAlchemy instance. This will be linked to the Flask app in main.py.
db = SQLAlchemy() db = SQLAlchemy()
@ -8,28 +9,34 @@ class User(db.Model):
"""Represents a user in the database.""" """Represents a user in the database."""
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False) username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(120), nullable=False) password_hash = db.Column(db.String(256), nullable=False)
is_admin = db.Column(db.Boolean, default=False, nullable=False)
def set_password(self, password):
"""Creates a secure hash of the password."""
self.password_hash = generate_password_hash(password)
def check_password(self, password):
"""Checks if the provided password matches the stored hash."""
return check_password_hash(self.password_hash, password)
class Station(db.Model): class Station(db.Model):
"""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)
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)
# --- ADD THESE NEW FIELDS ---
mqtt_broker = db.Column(db.String(255), nullable=False) mqtt_broker = db.Column(db.String(255), nullable=False)
mqtt_port = db.Column(db.Integer, nullable=False) mqtt_port = db.Column(db.Integer, nullable=False)
mqtt_user = db.Column(db.String(120), nullable=True) mqtt_user = db.Column(db.String(120), nullable=True)
mqtt_password = db.Column(db.String(120), nullable=True) mqtt_password = db.Column(db.String(120), nullable=True)
# --- Table for MQTT Logs (without raw payload) ---
class MqttLog(db.Model): class MqttLog(db.Model):
"""Represents a single MQTT message payload for historical logging.""" """Represents a single MQTT message payload for historical logging."""
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
timestamp = db.Column(db.DateTime, server_default=db.func.now()) timestamp = db.Column(db.DateTime, server_default=db.func.now())
station_id = db.Column(db.String(120), nullable=False, index=True) station_id = db.Column(db.String(120), nullable=False, index=True)
topic = db.Column(db.String(255), nullable=False) topic = db.Column(db.String(255), nullable=False)
# --- NEW: Added topic_type for efficient filtering ---
topic_type = db.Column(db.String(50), nullable=False, index=True) topic_type = db.Column(db.String(50), nullable=False, index=True)
# JSONB is a highly efficient way to store JSON data in PostgreSQL
payload = db.Column(JSONB) payload = db.Column(JSONB)

View File

@ -1,7 +1,10 @@
Flask Flask
Flask-SocketIO Flask-SocketIO
Flask-SQLAlchemy Flask-SQLAlchemy
Flask-Cors
Flask-Login
psycopg2-binary psycopg2-binary
paho-mqtt paho-mqtt
protobuf protobuf
python-dotenv python-dotenv
Werkzeug

164
backend/test.py Normal file
View File

@ -0,0 +1,164 @@
import os
import sys
import threading
import json
import csv
import io
import time # Import the time module
from datetime import datetime
from flask import Flask, jsonify, request, Response
from flask_socketio import SocketIO, join_room # <-- IMPORTANT: Add join_room
from flask_cors import CORS
from dotenv import load_dotenv
# Import your custom core modules and the new models
from core.mqtt_client import MqttClient
from core.protobuf_decoder import ProtobufDecoder
from models import db, Station, User, MqttLog
from flask_login import LoginManager, login_required, current_user
# --- Load Environment Variables ---
load_dotenv()
# --- Pre-startup Check for Essential Configuration ---
DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
print("FATAL ERROR: DATABASE_URL is not set in .env file.")
sys.exit(1)
# --- Application Setup ---
app = Flask(__name__)
CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True)
login_manager = LoginManager()
login_manager.init_app(app)
app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = os.getenv("SECRET_KEY", "a_very_secret_key")
db.init_app(app)
socketio = SocketIO(app, cors_allowed_origins="*")
# --- User Loader for Flask-Login ---
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# --- Global instances ---
decoder = ProtobufDecoder()
mqtt_clients = {}
last_message_timestamps = {}
STATION_TIMEOUT_SECONDS = 90
# --- MQTT Message Handling ---
def on_message_handler(station_id, topic, payload):
last_message_timestamps[station_id] = time.time()
print(f"Main handler received message for station {station_id} on topic {topic}")
decoded_data = None
message_type = topic.split('/')[-1]
if message_type == 'PERIODIC':
decoded_data = decoder.decode_periodic(payload)
elif message_type == 'EVENTS':
decoded_data = decoder.decode_event(payload)
elif message_type == 'REQUEST':
decoded_data = decoder.decode_rpc_request(payload)
if decoded_data:
print("DECODED DATA TO BE SENT:", decoded_data)
try:
with app.app_context():
log_entry = MqttLog(
station_id=station_id,
topic=topic,
topic_type=message_type,
payload=decoded_data
)
db.session.add(log_entry)
db.session.commit()
print(f"Successfully wrote data for {station_id} to PostgreSQL.")
except Exception as e:
print(f"Error writing to PostgreSQL: {e}")
socketio.emit('dashboard_update', {
'stationId': station_id,
'topic': topic,
'data': decoded_data
}, room=station_id)
# --- WebSocket Handlers ---
@socketio.on('connect')
def handle_connect():
print('Client connected to WebSocket')
# --- NEW: Function to handle joining a room and sending initial data ---
@socketio.on('join_station_room')
def handle_join_station_room(data):
station_id = data['station_id']
join_room(station_id)
print(f"Client joined room for station: {station_id}")
try:
# Find the most recent log entry for this station
latest_log = MqttLog.query.filter_by(
station_id=station_id,
topic_type='PERIODIC'
).order_by(MqttLog.timestamp.desc()).first()
if latest_log:
# If we have a past log, send it immediately to the new client
print(f"Sending initial state for {station_id} to new client.")
socketio.emit('dashboard_update', {
'stationId': station_id,
'topic': latest_log.topic,
'data': latest_log.payload
}, room=station_id)
except Exception as e:
print(f"Error querying or sending initial state for {station_id}: {e}")
# ... (rest of your API routes remain the same) ...
# --- API Routes ---
@app.route('/api/login', methods=['POST'])
def login():
# ... (code omitted for brevity)
pass
@app.route('/api/users', methods=['POST'])
# @login_required # Temporarily disabled for testing
def add_user():
# ... (code omitted for brevity)
pass
@app.route('/api/stations', methods=['POST'])
# @login_required # Temporarily disabled for testing
def add_station():
# ... (code omitted for brevity)
pass
@app.route('/api/stations', methods=['GET'])
def get_stations():
try:
stations = Station.query.all()
station_list = []
for s in stations:
last_msg_time = last_message_timestamps.get(s.station_id)
is_online = last_msg_time is not None and (time.time() - last_msg_time) < STATION_TIMEOUT_SECONDS
station_list.append({
"id": s.station_id,
"name": s.name,
"location": s.location,
"status": "Online" if is_online else "Offline"
})
return jsonify(station_list)
except Exception as e:
return jsonify({"error": f"Database query failed: {e}"}), 500
# ... (your CSV export and MQTT client start functions remain the same) ...
if __name__ == '__main__':
# ... (your main startup logic remains the same) ...
pass

317
frontend/analytics.html Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

54
frontend/css/style.css Normal file
View File

@ -0,0 +1,54 @@
<style>
:root { color-scheme: dark; }
html, body { height: 100%; margin: 0; }
body { background:#0a0a0a; }
.page { max-width:1400px; }
/* Glass */
.glass {
background: rgba(255,255,255,.05);
border: 1px solid rgba(255,255,255,.10);
border-radius: .9rem;
backdrop-filter: saturate(140%) blur(12px);
}
/* Chips */
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.35rem .6rem;border-radius:.6rem;
font-size:10.5px;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)}
/* Buttons */
.btn{font-size:11.5px;font-weight:700;border-radius:.7rem;padding:.46rem .65rem;
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);box-shadow:0 0 0 1px rgba(16,185,129,.25) inset}
.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{background:rgba(244,63,94,.15);color:#fecaca;border-color:rgba(244,63,94,.4)}
.btn-danger:hover{background:rgba(244,63,94,.25)}
.btn-icon{display:inline-flex;align-items:center;gap:.45rem}
.field{font-size:11px;color:#9ca3af}
.value{font-size:13px;font-weight:700;color:#e5e7eb}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace}
/* Chamber selection highlight */
.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:11px;font-weight:800;padding:2px 10px;border-radius:6px}
.door-open{background:#22c55e}.door-close{background:#ef4444}
/* Chamber header rail look */
.chamber-rail{position:relative;padding-top:.25rem;margin-top:-.5rem}
.chamber-rail:before,.chamber-rail:after{content:"";position:absolute;top:.7rem;height:2px;width:30%;background:rgba(16,185,129,.35)}
.chamber-rail:before{left:0}.chamber-rail:after{right:0}
.chamber-title{display:inline-block;padding:.15rem .6rem;border-radius:.5rem;border:1px solid rgba(255,255,255,.15);
background:rgba(255,255,255,.05);font-weight:800;font-size:.8rem;letter-spacing:.03em}
.bat-id-big{font-size:15px;font-weight:800;border-radius:.5rem;padding:.35rem .55rem;
background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.10)}
</style>

137
frontend/css/tailwind.css Normal file
View File

@ -0,0 +1,137 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ====== POLISH PACK (drop-in) ========================================== */
/* Subtle app background movement */
body::before,
body::after{
content:"";
position:fixed; inset:auto;
width:34rem; height:34rem; filter:blur(70px);
pointer-events:none; z-index:-1; opacity:.18;
border-radius:9999px;
}
body::before{ left:-12rem; top:-10rem; background:radial-gradient(ellipse at center,#10b98155 0%,#10b98100 60%);}
body::after{ right:-14rem; bottom:-12rem; background:radial-gradient(ellipse at center,#06b6d455 0%,#06b6d400 60%);}
/* ---------- Header chips & status row ---------- */
.header-chip{
--bg: rgba(255,255,255,.06);
--bd: rgba(255,255,255,.14);
--fg: #e5e7eb;
display:inline-flex; align-items:center; gap:.4rem;
padding:.35rem .6rem; border-radius:.65rem;
border:1px solid var(--bd); background:var(--bg); color:var(--fg);
font-weight:700; font-size:.72rem; letter-spacing:.02em;
box-shadow:0 0 0 1px rgba(255,255,255,.02) inset;
transition:filter .18s ease, transform .18s ease, border-color .18s ease;
}
.header-chip svg{ width:.9rem; height:.9rem; opacity:.9 }
.header-chip:hover{ filter:brightness(1.06); border-color:#34d39955 }
/* Variants */
.header-chip--online{ --bg:rgba(16,185,129,.15); --bd:rgba(16,185,129,.35); --fg:#a7f3d0 }
.header-chip--backup{ --bg:rgba(245,158,11,.12); --bd:rgba(245,158,11,.35); --fg:#fde68a }
.header-chip--warn{ --bg:rgba(244,63,94,.12); --bd:rgba(244,63,94,.4); --fg:#fecaca }
/* ---------- Chamber cards ---------- */
.chamber-card{
border:1px solid rgba(255,255,255,.10);
background:linear-gradient(180deg,rgba(2,6,23,.45),rgba(2,6,23,.35));
border-radius:14px; transition:border-color .18s ease, box-shadow .18s ease, transform .18s ease;
display:flex; flex-direction:column; min-height: 225px;
}
.chamber-card:hover{
border-color:#34d39966;
box-shadow:0 6px 30px rgba(0,0,0,.35), 0 0 0 1px rgba(16,185,129,.15) inset;
transform:translateY(-1px);
}
/* keep all 9 chambers equal height inside the grid */
#chambersGrid{ align-content:start }
#chambersGrid > * { min-height: 0 } /* fix Firefox grid stretching */
/* Chamber title bar */
.chamber-card .title-bar{
display:flex; align-items:center; justify-content:center;
gap:.5rem; padding:.35rem .5rem; margin:-.25rem -.25rem .4rem;
border-bottom:1px solid rgba(255,255,255,.08);
}
.chamber-card .title-chip{
font-weight:800; font-size:.78rem; letter-spacing:.02em;
background:rgba(20,83,45,.35); color:#bbf7d0;
padding:.25rem .55rem; border-radius:.5rem; border:1px solid rgba(16,185,129,.35);
box-shadow:0 0 0 1px rgba(16,185,129,.12) inset;
}
/* BAT ID emphasis row */
.bat-id-row{
display:flex; align-items:center; justify-content:space-between;
gap:.75rem; padding:.4rem .55rem; border-radius:.55rem;
border:1px dashed rgba(255,255,255,.14);
background:rgba(2,6,23,.35);
}
.bat-id-row .label{ color:#93a3af; font-size:.7rem; font-weight:700; letter-spacing:.03em }
.bat-id-row .value{
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight:800; font-size:.9rem; letter-spacing:.06em;
color:#e5fff4; text-shadow:0 0 12px rgba(16,185,129,.25);
}
/* Two-column “key: value” rows inside a chamber */
.kv{ display:flex; align-items:baseline; justify-content:space-between; gap:.75rem }
.kv .k{ color:#9ca3af; font-size:.72rem; }
.kv .v{ color:#e5e7eb; font-weight:700; font-size:.85rem }
/* Pills (battery / charger status) */
.pill{ padding:.28rem .5rem; border-radius:.5rem; border:1px solid; font-weight:800; font-size:.7rem; letter-spacing:.02em }
.pill--ok{ background:rgba(16,185,129,.15); color:#a7f3d0; border-color:rgba(16,185,129,.35) }
.pill--idle{ background:rgba(148,163,184,.12); color:#cbd5e1; border-color:rgba(148,163,184,.35) }
.pill--bad{ background:rgba(244,63,94,.16); color:#fecaca; border-color:rgba(244,63,94,.38) }
.pill--sky{ background:rgba(56,189,248,.15); color:#bae6fd; border-color:rgba(56,189,248,.38) }
/* Door chip */
.door-chip{ color:#fff; font-weight:800; font-size:.72rem; padding:.22rem .55rem; border-radius:.45rem }
.door-chip--open{ background:#22c55e }
.door-chip--closed{ background:#ef4444 }
/* Action buttons inside card */
.card-actions .btn{
font-size:.72rem; font-weight:800; padding:.45rem .55rem; border-radius:.6rem;
border:1px solid rgba(255,255,255,.12); background:rgba(255,255,255,.06);
}
.card-actions .btn:hover{ border-color:#34d39966; box-shadow:0 0 0 1px rgba(52,211,153,.2) inset }
/* ---------- Right column (panels) ---------- */
.panel{
border:1px solid rgba(255,255,255,.10);
background:linear-gradient(180deg,rgba(2,6,23,.45),rgba(2,6,23,.35));
border-radius:14px;
}
.panel h3{ font-weight:800; letter-spacing:.02em }
/* Station reset emphasis */
#station-reset-btn{
border:1px solid rgba(244,63,94,.45)!important; color:#fecaca!important; background:rgba(244,63,94,.08)!important;
}
#station-reset-btn:hover{ background:rgba(244,63,94,.18)!important; box-shadow:0 0 0 2px rgba(244,63,94,.25) inset }
/* Instance log terminal feel */
.instance-log{
background:#020617; color:#d1fae5; border:1px solid rgba(16,185,129,.15);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size:.78rem; line-height:1.35; border-radius:.6rem; padding:.75rem;
box-shadow:inset 0 1px 0 rgba(16,185,129,.06);
}
/* Nice thin scrollbars (Chrome/Edge) */
*::-webkit-scrollbar{ height:10px; width:10px }
*::-webkit-scrollbar-thumb{ background:linear-gradient(#1f2937,#111827); border:2px solid #0b1220; border-radius:12px }
*::-webkit-scrollbar-thumb:hover{ background:linear-gradient(#243041,#131a2a) }
/* Motion-reduction respect */
@media (prefers-reduced-motion: reduce){
.header-chip, .chamber-card{ transition:none }
}

356
frontend/dashboard.html Normal file
View File

@ -0,0 +1,356 @@
<!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.jsdelivr.net/npm/flatpickr"></script>
<script src="https://unpkg.com/lucide@latest" defer></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
<script src="./js/dashboard.js"></script>
<script>
tailwind.config = {
theme: {
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>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<style>
/* --- FINAL: Polished Flatpickr Dark/Glass Theme --- */
.flatpickr-calendar {
background: rgba(30, 41, 59, 0.7); /* Darker, glassier background */
backdrop-filter: saturate(150%) blur(16px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.75rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
width: 320px;
}
/* FIX: Make month/year text visible */
.flatpickr-current-month .flatpickr-monthDropdown-months,
.flatpickr-current-month .numInput.cur-year {
color: #e2e8f0; /* Light text color */
font-weight: 600;
background: transparent;
}
/* Month navigation arrows */
.flatpickr-months .flatpickr-prev-month svg,
.flatpickr-months .flatpickr-next-month svg {
fill: #10b981; /* Emerald green */
}
/* Day of the week header (Sun, Mon, etc.) */
span.flatpickr-weekday {
color: #94a3b8; /* Lighter gray for contrast */
font-weight: 600;
}
/* Individual day numbers */
.flatpickr-day {
color: #cbd5e1;
border: 1px solid transparent;
transition: background 0.1s ease-in-out;
}
.flatpickr-day:hover {
background: rgba(56, 189, 248, 0.15); /* Subtle sky blue hover */
}
.flatpickr-day.today {
border-color: #34d399;
}
.flatpickr-day.selected {
background: linear-gradient(to right, #10b981, #14b8a6);
border-color: #10b981;
box-shadow: 0 0 15px rgba(16, 185, 129, 0.4);
}
/* FIX: Prevent white flash on time input focus */
.flatpickr-time input, .flatpickr-time .flatpickr-am-pm {
color: #e2e8f0;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.flatpickr-time input:focus {
background: rgba(0,0,0,0.4); /* Keep background dark on focus */
}
/* Time selection up/down arrows */
.flatpickr-time .arrowUp path, .flatpickr-time .arrowDown path {
fill: #94a3b8;
}
</style>
<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; transition: background-color 0.1s ease-in-out, transform 0.1s ease-in-out;}
.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); }
.btn-feedback {
background-color: rgba(34, 197, 94, 0.4); /* A semi-transparent green */
transform: scale(0.97); /* Makes the button slightly smaller */
}
.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 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>Device ID:</span>
<span id="device-id"></span>
</span>
<span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg>
<span id="last-update-status">Waiting...</span>
</span>
<span id="connection-status-chip" class="cham_chip cham_chip-amber" title="Connection to backend">
<span class="h-2 w-2 rounded-full bg-amber-400"></span> Connecting...
</span>
<button id="station-reset-btn" class="btn btn-danger !p-2" title="Station Reset">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg>
</button>
<!-- <span id="backup-power-chip" class="cham_chip cham_chip-slate" title="Station power source" style="display: none;">On Backup</span> -->
<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 id="station-diag-code" class="text-sm font-bold text-emerald-300"></span>
</div>
<div id="diag-flags-grid" class="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
<div class="text-rose-300 text-center">Lock Power Cut</div><div class="text-rose-300 text-center">Main Power Cut</div>
<div class="text-rose-300 text-center">Relayboard CAN</div><div class="text-rose-300 text-center">DB CAN Recv</div>
<div class="text-rose-300 text-center">MB Can Recv</div><div class="text-rose-300 text-center">Smoke Alarm</div>
<div class="text-rose-300 text-center">Water Alarm</div><div class="text-rose-300 text-center">Phase Failure</div>
<div class="text-rose-300 text-center">Earth Leakage</div>
</div>
<span id="backup-power-chip" class="cham_chip cham_chip-slate w-full justify-center mt-3">
</section>
<section id="swap-panel" class="glass p-4 flex flex-col min-h-[220px]">
<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>
<div class="flex items-center gap-2">
<select id="audio-command-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>
<button id="send-audio-btn" class="btn btn-primary !py-2 px-4">Send</button>
</div>
</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 id="instance-log" 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="filled-state flex-1 flex flex-col">
<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="—">Waiting...</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>
<div class="empty-state relative flex-1 flex-col items-center justify-center text-slate-500 hidden pt-4">
<div class="absolute top-1 right-2 flex items-center gap-1 text-xs">
<i data-lucide="thermometer" class="w-3 h-3 text-slate-400"></i>
<span class="value slot-temp-empty text-slate-400"></span>
</div>
<i data-lucide="battery" class="w-16 h-16 opacity-50 text-slate-500"></i>
</div>
<div class="grid grid-cols-4 gap-1.5 mt-2 shrink-0">
<div class="door-pill door-close">--</div>
<button data-command="OPEN" class="btn">OPEN</button>
<button data-command="CHG_ON" class="btn">CHG ON</button>
<button data-command="CHG_OFF" class="btn">CHG OFF</button>
</div>
</div>
</template>
</body>
</html>

View File

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

99
frontend/index.html Normal file
View File

@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Login</title>
<!-- Inter font -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<!-- Tailwind CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ["Inter", "ui-sans-serif", "system-ui"] },
keyframes: {
float: { '0%,100%': { transform:'translateY(0)' }, '50%': { transform:'translateY(-8px)' } },
spinSlow: { '0%': { transform:'rotate(0)' }, '100%': { transform:'rotate(360deg)' } },
fadeUp: { '0%': { opacity:0, transform:'translateY(8px) scale(.98)' }, '100%': { opacity:1, transform:'translateY(0) scale(1)' } },
},
animation: {
float: 'float 6s ease-in-out infinite',
spinSlow: 'spinSlow 18s linear infinite',
fadeUp: 'fadeUp .45s ease-out forwards',
},
backgroundImage: {
grid: "radial-gradient(circle at 25px 25px, rgba(120,120,120,.15) 2px, transparent 2px)",
}
}
}
}
</script>
<style>
:root { color-scheme: dark; }
html, body { height: 100%; }
</style>
</head>
<body class="min-h-screen bg-[#0a0a0a] text-gray-100 relative overflow-hidden flex items-center justify-center">
<!-- Ambient glows -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute -top-32 -left-32 w-[38rem] h-[38rem] rounded-full bg-gradient-to-br from-emerald-500/20 via-teal-400/10 to-cyan-400/10 blur-3xl animate-float"></div>
<div class="absolute -bottom-40 -right-40 w-[42rem] h-[42rem] rounded-full bg-gradient-to-tr from-sky-500/20 via-purple-500/10 to-fuchsia-400/10 blur-3xl animate-float" style="animation-delay:-2s"></div>
</div>
<div class="absolute inset-0 bg-grid bg-[length:100px_100px] opacity-[.08] [mask-image:radial-gradient(ellipse_at_center,black,transparent_70%)]"></div>
<div class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[46rem] h-[46rem] rounded-full border border-emerald-400/10 animate-spinSlow"></div>
<!-- Main -->
<main class="relative z-10 w-full max-w-md">
<div class="rounded-2xl border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl ring-1 ring-white/10 p-8 animate-fadeUp">
<!-- Brand -->
<div class="flex flex-col items-center gap-3">
<img src="./assets/vec_logo.png" alt="VECMOCON" class="h-12 w-auto drop-shadow" onerror="this.style.display='none'"/>
<h1 class="text-3xl font-extrabold tracking-tight">Swap Station Dashboard</h1>
<p class="text-sm text-gray-400">Sign in to access your stations</p>
</div>
<!-- Error -->
<div id="error-message" class="mt-6 hidden rounded-lg border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-200"></div>
<!-- Form -->
<form id="login-form" class="mt-6 space-y-5" autocomplete="on">
<div class="space-y-4">
<div class="relative">
<label for="username" class="sr-only">Username</label>
<input id="username" name="username" type="text" required
class="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-3 text-white placeholder-gray-500 outline-none focus:ring-2 focus:ring-emerald-500/70 focus:border-emerald-500/40"
placeholder="Username" />
</div>
<div class="relative">
<label for="password" class="sr-only">Password</label>
<input id="password" name="password" type="password" required
class="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-3 text-white placeholder-gray-500 outline-none focus:ring-2 focus:ring-emerald-500/70 focus:border-emerald-500/40"
placeholder="Password" />
</div>
</div>
<button type="submit"
class="relative w-full overflow-hidden rounded-xl bg-gradient-to-r from-emerald-500 via-teal-500 to-cyan-500 px-4 py-3 text-sm font-semibold text-white transition-transform duration-300 hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-emerald-400/60">
Sign In
</button>
</form>
</div>
<p class="mt-6 text-center text-[11px] text-gray-500">© <span id="year"></span> VECMOCON • All rights reserved.</p>
</main>
<script>
document.getElementById('year').textContent = new Date().getFullYear();
</script>
<script src="./js/auth.js"></script>
</body>
</html>

36
frontend/js/auth.js Normal file
View File

@ -0,0 +1,36 @@
// frontend/js/auth.js
document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('login-form');
const errorMessageDiv = document.getElementById('error-message');
loginForm.addEventListener('submit', async (event) => {
event.preventDefault();
errorMessageDiv.classList.add('hidden');
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
try {
const response = await fetch('http://localhost:5000/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok) {
// Save user to localStorage and go to station selection
localStorage.setItem('user', JSON.stringify(data.user));
window.location.href = 'station_selection.html';
} else {
errorMessageDiv.textContent = data.message || 'Login failed.';
errorMessageDiv.classList.remove('hidden');
}
} catch (error) {
errorMessageDiv.textContent = 'Failed to connect to the server.';
errorMessageDiv.classList.remove('hidden');
console.error('Login error:', error);
}
});
});

618
frontend/js/dashboard.js Normal file
View File

@ -0,0 +1,618 @@
document.addEventListener('DOMContentLoaded', () => {
// --- CONFIGURATION ---
const SOCKET_URL = "http://localhost:5000";
const API_BASE = "http://localhost:5000/api"; // Added for API calls
// --- DOM ELEMENT REFERENCES ---
const grid = document.getElementById('chambersGrid');
const chamberTmpl = document.getElementById('chamberTemplate');
const logTextArea = document.getElementById('instance-log');
const connChip = document.getElementById('connection-status-chip');
const stationNameEl = document.getElementById('station-name');
const stationLocationEl = document.getElementById('station-location');
const deviceIdEl = document.getElementById('device-id');
const lastUpdateEl = document.getElementById('last-update-status');
const stationDiagCodeEl = document.getElementById('station-diag-code');
const backupPowerChip = document.getElementById('backup-power-chip');
const diagFlagsGrid = document.getElementById('diag-flags-grid');
const audioSelect = document.getElementById('audio-command-select');
// Header Buttons
const refreshBtn = document.getElementById('refreshBtn');
const downloadBtn = document.getElementById('downloadBtn');
const logoutBtn = document.getElementById('logout-btn');
// --- STATE ---
let selectedStation = null;
let socket;
let statusPollingInterval; // To hold our interval timer
let chamberData = Array(9).fill({ batteryPresent: false });
// The list of errors from your Python code
const DIAGNOSTIC_ERRORS = [
"Lock Power Cut", "Main Power Cut",
"Relayboard CAN", "DB CAN Recv",
"MB Can Recv", "Smoke Alarm",
"Water Alarm", "Phase Failure",
"Earth Leakage"
];
// --- NEW: SWAP PROCESS ELEMENTS & LOGIC ---
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 = [];
// --- SWAP UI LOGIC ---
function updateSwapUI() {
const isBuilding = currentPair.length > 0 || swapPairs.length > 0;
if (swapIdleText) swapIdleText.style.display = isBuilding ? 'none' : 'block';
if (startSwapBtn) 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 = parseInt(card.dataset.chamberId, 10);
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');
});
if (swapPairsList) {
swapPairsList.innerHTML = '';
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 > 0) {
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) {
// Deselection logic
if (currentPair.length === 1 && currentPair[0] === num) {
currentPair = [];
updateSwapUI();
return;
}
const isAlreadyPaired = swapPairs.flat().includes(num);
if (isAlreadyPaired) {
swapPairs = swapPairs.filter(pair => !pair.includes(num));
updateSwapUI();
return;
}
// Selection logic
if (swapPairs.length >= 4) return alert('Maximum of 4 swap pairs reached.');
// Note: Live validation would go here. Removed for testing.
currentPair.push(num);
if (currentPair.length === 2) {
swapPairs.push([...currentPair]);
currentPair = [];
}
updateSwapUI();
}
function clearSelection() {
currentPair = [];
swapPairs = [];
updateSwapUI();
}
// --- HELPER FUNCTIONS (Your original code is unchanged) ---
const applyButtonFeedback = (button) => {
if (!button) return;
button.classList.add('btn-feedback');
setTimeout(() => {
button.classList.remove('btn-feedback');
}, 150);
};
// And ensure your sendCommand function does NOT have the feedback logic
const sendCommand = (command, data = null) => {
if (!selectedStation || !socket || !socket.connected) {
logToInstance(`Cannot send command '${command}', not connected.`, "error");
return;
}
const payload = { station_id: selectedStation.id, command: command, data: data };
socket.emit('rpc_request', payload);
logToInstance(`Sent command: ${command}`, 'cmd');
};
const setChipStyle = (element, text, style) => {
if (!element) return;
const baseClass = element.className.split(' ')[0];
element.textContent = text;
element.className = `${baseClass} chip-${style}`;
};
const logToInstance = (message, type = 'info') => {
if (!logTextArea) return;
const timestamp = new Date().toLocaleTimeString();
const typeIndicator = type === 'error' ? '[ERROR]' : type === 'cmd' ? '[CMD]' : '[INFO]';
const newLog = `[${timestamp}] ${typeIndicator} ${message}\n`;
logTextArea.value = newLog + logTextArea.value;
};
const updateChamberUI = (card, slot) => {
if (!card || !slot) return;
const filledState = card.querySelector('.filled-state');
const emptyState = card.querySelector('.empty-state');
const doorPill = card.querySelector('.door-pill');
// Always update door status
doorPill.textContent = slot.doorStatus ? 'OPEN' : 'CLOSED';
doorPill.className = slot.doorStatus ? 'door-pill door-open' : 'door-pill door-close';
const slotTempText = `${((slot.slotTemperature || 0) / 10).toFixed(1)} °C`;
if (slot.batteryPresent) {
filledState.style.display = 'flex';
emptyState.style.display = 'none';
// Update the detailed view
card.querySelector('.slot-temp').textContent = slotTempText;
card.querySelector('.bat-id-big').textContent = slot.batteryIdentification || '—';
card.querySelector('.soc').textContent = `${slot.soc || 0}%`;
// --- Populate the detailed view ---
card.querySelector('.bat-id-big').textContent = slot.batteryIdentification || '—';
card.querySelector('.soc').textContent = `${slot.soc || 0}%`;
card.querySelector('.voltage').textContent = `${((slot.voltage || 0) / 1000).toFixed(1)} V`;
card.querySelector('.bat-temp').textContent = `${((slot.batteryMaxTemp || 0) / 10).toFixed(1)} °C`;
card.querySelector('.bat-fault').textContent = slot.batteryFaultCode || '—';
card.querySelector('.current').textContent = `${((slot.current || 0) / 1000).toFixed(1)} A`;
card.querySelector('.slot-temp').textContent = `${((slot.slotTemperature || 0) / 10).toFixed(1)} °C`;
card.querySelector('.chg-fault').textContent = slot.chargerFaultCode || '—';
const batPill = card.querySelector('.battery-status-pill');
batPill.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Present`;
batPill.className = 'battery-status-pill chip chip-emerald';
const chgPill = card.querySelector('.charger-status-pill');
if (slot.chargerMode === 1) {
chgPill.innerHTML = `<span class="h-2 w-2 rounded-full bg-sky-400 animate-pulseDot"></span> Charging`;
chgPill.className = 'charger-status-pill chip chip-sky';
} else {
chgPill.innerHTML = `<span class="h-2 w-2 rounded-full bg-slate-500"></span> Idle`;
chgPill.className = 'charger-status-pill chip chip-slate';
}
} else {
// Show the empty view
filledState.style.display = 'none';
emptyState.style.display = 'flex';
// --- DEBUGGING LOGIC ---
const tempElement = card.querySelector('.slot-temp-empty');
if (tempElement) {
tempElement.textContent = slotTempText;
// console.log(`Chamber ${slot.chamberNo}: Found .slot-temp-empty, setting text to: ${slotTempText}`);
} else {
// console.error(`Chamber ${slot.chamberNo}: Element .slot-temp-empty NOT FOUND! Check your HTML template.`);
}
}
// Check if the icon library is loaded and then render the icons
if (typeof lucide !== 'undefined') {
lucide.createIcons();
} else {
console.error('Lucide icon script is not loaded. Please check dashboard.html');
}
};
// --- NEW: Function to decode the SDC and update the UI ---
const updateDiagnosticsUI = (sdcCode) => {
if (!diagFlagsGrid) return;
diagFlagsGrid.innerHTML = ''; // Clear previous statuses
DIAGNOSTIC_ERRORS.forEach((errorText, index) => {
// Use bitwise AND to check if the bit at this index is set
const isActive = (sdcCode & (1 << index)) !== 0;
const div = document.createElement('div');
div.textContent = errorText;
// Apply different styles based on whether the alarm is active
if (isActive) {
div.className = 'text-rose-300 text-center font-semibold';
} else {
div.className = 'text-slate-500 text-center';
}
diagFlagsGrid.appendChild(div);
});
};
const resetDashboardUI = () => {
grid.querySelectorAll('.chamber-card').forEach(card => {
card.querySelector('.bat-id-big').textContent = 'Waiting...';
card.querySelector('.soc').textContent = '—';
card.querySelector('.voltage').textContent = '—';
card.querySelector('.bat-temp').textContent = '—';
card.querySelector('.bat-fault').textContent = '—';
card.querySelector('.current').textContent = '—';
card.querySelector('.slot-temp').textContent = '—';
card.querySelector('.chg-fault').textContent = '—';
// Show the "empty" view by default when resetting
card.querySelector('.filled-state').style.display = 'none';
card.querySelector('.empty-state').style.display = 'flex';
});
logToInstance("Station is offline. Clearing stale data.", "error");
};
// --- NEW: This function polls the API for the true station status ---
const checkStationStatus = async () => {
if (!selectedStation) return;
try {
const response = await fetch(`${API_BASE}/stations`);
if (!response.ok) return;
const stations = await response.json();
const thisStation = stations.find(s => s.id === selectedStation.id);
if (thisStation && connChip) {
stationNameEl.textContent = thisStation.name;
stationLocationEl.textContent = thisStation.location;
if (thisStation.status === 'Online') {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
connChip.className = 'cham_chip cham_chip-emerald';
} else {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose';
lastUpdateEl.textContent = "Waiting for data...";
resetDashboardUI();
}
}
} catch (error) {
console.error("Failed to fetch station status:", error);
}
};
// --- DOWNLOAD MODAL LOGIC ---
const showDownloadModal = () => {
const modalOverlay = document.createElement('div');
modalOverlay.className = "fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50";
modalOverlay.innerHTML = `
<div class="bg-slate-800 border border-slate-700 rounded-lg shadow-xl p-6 w-full max-w-md">
<h3 class="text-lg font-bold text-white mb-4">Export Logs</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-300 mb-2">Quick Time Ranges</label>
<div class="grid grid-cols-3 gap-2">
<button data-range="1" class="time-range-btn btn btn-ghost !py-1.5">Last Hour</button>
<button data-range="6" class="time-range-btn btn btn-ghost !py-1.5">Last 6 Hours</button>
<button data-range="24" class="time-range-btn btn btn-ghost !py-1.5">Last 24 Hours</button>
<button data-range="today" class="time-range-btn btn btn-ghost !py-1.5">Today</button>
<button data-range="yesterday" class="time-range-btn btn btn-ghost !py-1.5">Yesterday</button>
</div>
</div>
<div>
<label for="log-type" class="block text-sm font-medium text-gray-300">Log Type</label>
<select id="log-type" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
<option value="PERIODIC">Periodic Data</option>
<option value="EVENT">Events & RPC</option>
</select>
</div>
<div>
<label for="start-datetime" class="block text-sm font-medium text-gray-300">Start Date & Time</label>
<input type="text" id="start-datetime" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
</div>
<div>
<label for="end-datetime" class="block text-sm font-medium text-gray-300">End Date & Time</label>
<input type="text" id="end-datetime" class="mt-1 block w-full bg-slate-700 border-slate-600 rounded-md p-2 text-white">
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<button id="cancel-download" class="btn btn-ghost px-4 py-2">Cancel</button>
<button id="confirm-download" class="btn btn-primary px-4 py-2">Download CSV</button>
</div>
</div>
`;
document.body.appendChild(modalOverlay);
const startInput = document.getElementById('start-datetime');
const endInput = document.getElementById('end-datetime');
// --- NEW: Initialize flatpickr on the inputs ---
const fpConfig = {
enableTime: true,
dateFormat: "Y-m-d\\TH:i", // Format needed by the backend
time_24hr: true
};
const fpStart = flatpickr(startInput, fpConfig);
const fpEnd = flatpickr(endInput, fpConfig);
// --- (The rest of the function is the same) ---
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 3600 * 1000);
fpStart.setDate(oneHourAgo, true);
fpEnd.setDate(now, true);
modalOverlay.querySelectorAll('.time-range-btn').forEach(button => {
button.addEventListener('click', () => {
const range = button.dataset.range;
const now = new Date();
let start = new Date();
if (range === 'today') {
start.setHours(0, 0, 0, 0);
} else if (range === 'yesterday') {
start.setDate(start.getDate() - 1);
start.setHours(0, 0, 0, 0);
now.setDate(now.getDate() - 1);
now.setHours(23, 59, 59, 999);
} else {
start.setHours(now.getHours() - parseInt(range, 10));
}
fpStart.setDate(start, true);
fpEnd.setDate(now, true);
});
});
document.getElementById('cancel-download').onclick = () => document.body.removeChild(modalOverlay);
document.getElementById('confirm-download').onclick = async () => {
const logType = document.getElementById('log-type').value;
const startDateStr = document.getElementById('start-datetime').value;
const endDateStr = document.getElementById('end-datetime').value;
const confirmBtn = document.getElementById('confirm-download');
if (!startDateStr || !endDateStr) {
alert('Please select both a start and end date/time.');
return;
}
// --- Validation Logic ---
const selectedStartDate = new Date(startDateStr);
const selectedEndDate = new Date(endDateStr);
const currentDate = new Date();
if (selectedStartDate > currentDate) {
alert('Error: The start date cannot be in the future.');
return;
}
if (selectedEndDate > currentDate) {
alert('Error: The end date cannot be in the future.');
return;
}
if (selectedStartDate >= selectedEndDate) {
alert('Error: The start date must be earlier than the end date.');
return;
}
// --- Fetch and Download Logic ---
confirmBtn.textContent = 'Fetching...';
confirmBtn.disabled = true;
const downloadUrl = `${API_BASE}/logs/export?station_id=${selectedStation.id}&start_datetime=${startDateStr}&end_datetime=${endDateStr}&log_type=${logType}`;
try {
const response = await fetch(downloadUrl);
if (response.ok) { // Status 200, CSV file received
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
const logType = document.getElementById('log-type').value;
const dateStr = startDateStr.split('T')[0]; // Get just the date part
let filename = `${selectedStation.name || selectedStation.id}_${logType}_${dateStr}.csv`;
const disposition = response.headers.get('Content-Disposition');
if (disposition && disposition.indexOf('attachment') !== -1) {
const filenameMatch = disposition.match(/filename="(.+?)"/);
if (filenameMatch && filenameMatch.length === 2) {
filename = filenameMatch[1];
}
}
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
document.body.removeChild(modalOverlay);
} else { // Status 404, no data found
const errorData = await response.json();
alert(`Could not download: ${errorData.message}`);
}
} catch (error) {
alert('An unexpected error occurred. Please check the console.');
console.error('Download error:', error);
} finally {
confirmBtn.textContent = 'Download CSV';
confirmBtn.disabled = false;
}
};
};
// --- MAIN LOGIC (Your original code is unchanged) ---
const initializeDashboard = () => {
try {
selectedStation = JSON.parse(localStorage.getItem('selected_station'));
if (!selectedStation || !selectedStation.id) {
window.location.href = './index.html';
}
stationNameEl.textContent = selectedStation.name || 'Unknown Station';
stationLocationEl.textContent = selectedStation.location || 'No location';
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">Go Back</a></div>`;
return;
}
grid.innerHTML = '';
for (let i = 1; i <= 9; i++) {
const node = chamberTmpl.content.cloneNode(true);
const card = node.querySelector('.chamber-card');
card.dataset.chamberId = i;
card.querySelector('.slotNo').textContent = i;
grid.appendChild(node);
}
logToInstance(`Dashboard initialized for station: ${selectedStation.name} (${selectedStation.id})`);
// --- EVENT LISTENERS ---
// NEW: A single, global click listener for all button feedback
document.addEventListener('click', (event) => {
const button = event.target.closest('.btn');
if (button) {
applyButtonFeedback(button);
}
});
// Chamber Card and Swap Selection
document.querySelectorAll('.chamber-card').forEach(card => {
// Listener for swap selection
card.addEventListener('click', () => handleChamberClick(parseInt(card.dataset.chamberId, 10)));
// Listeners for command buttons inside the card
card.querySelectorAll('.btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const commandText = e.currentTarget.textContent.trim();
const command = commandText.replace(' ', '_');
const chamberNum = parseInt(e.currentTarget.closest('.chamber-card').dataset.chamberId, 10);
if (command === 'OPEN') {
if (confirm(`Are you sure you want to open door for Chamber ${chamberNum}?`)) {
sendCommand(command, chamberNum);
} else {
logToInstance(`Open Door command for Chamber ${chamberNum} cancelled.`);
}
} else {
sendCommand(command, chamberNum);
}
});
});
});
// Swap Panel Buttons
if (clearSwapBtn) clearSwapBtn.addEventListener('click', clearSelection);
if (startSwapBtn) {
startSwapBtn.addEventListener('click', () => {
if (swapPairs.length > 0) {
const formattedPairs = swapPairs.map(p => `[${p[0]},${p[1]}]`).join(',');
sendCommand('START_SWAP', formattedPairs);
clearSelection();
}
});
}
if (abortSwapBtn) abortSwapBtn.addEventListener('click', () => sendCommand('ABORT_SWAP'));
if (resetBtn) {
resetBtn.addEventListener('click', () => {
if (confirm('Are you sure you want to reset the station?')) {
sendCommand('STATION_RESET');
}
});
}
// Header Buttons
if (refreshBtn) refreshBtn.addEventListener('click', () => window.location.reload());
if (logoutBtn) {
logoutBtn.addEventListener('click', () => {
localStorage.clear();
window.location.href = './index.html';
});
}
if (downloadBtn) downloadBtn.addEventListener('click', showDownloadModal);
// Audio Command Button (assuming it exists in your HTML)
const sendAudioBtn = document.getElementById('send-audio-btn');
if (sendAudioBtn) {
sendAudioBtn.addEventListener('click', () => {
const language = document.getElementById('audio-command-select').value;
sendCommand('LANGUAGE_UPDATE', language);
});
}
updateSwapUI();
connectSocket();
checkStationStatus();
statusPollingInterval = setInterval(checkStationStatus, 10000);
};
const connectSocket = () => {
socket = io(SOCKET_URL);
// --- CHANGED: No longer sets status to "Online" on its own ---
socket.on('connect', () => {
logToInstance("Successfully connected to the backend WebSocket.");
socket.emit('join_station_room', { station_id: selectedStation.id });
});
// --- CHANGED: Sets status to "Offline" correctly on disconnect ---
socket.on('disconnect', () => {
logToInstance("Disconnected from the backend WebSocket.", "error");
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose';
});
socket.on('dashboard_update', (message) => {
// console.log("DEBUG: Received 'dashboard_update' message:", message);
const { stationId, data } = message;
if (stationId !== selectedStation.id) {
console.warn(`Ignoring message for wrong station. Expected ${selectedStation.id}, got ${stationId}`);
return;
}
lastUpdateEl.textContent = `Last Recv ${new Date().toLocaleTimeString()}`;
stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—';
// Show/hide the backup power chip based on the payload data
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';
}
lastUpdateEl.textContent = new Date().toLocaleTimeString();
stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—';
// --- NEW: Call the function to update the diagnostics grid ---
updateDiagnosticsUI(data.stationDiagnosticCode || 0);
if (data.slotLevelPayload && Array.isArray(data.slotLevelPayload)) {
data.slotLevelPayload.forEach((slotData, index) => {
const slotId = index + 1;
chamberData[slotId - 1] = slotData; // Keep live data in sync
const card = grid.querySelector(`.chamber-card[data-chamber-id="${slotId}"]`);
if (card) {
updateChamberUI(card, slotData);
}
});
}
});
};
// --- SCRIPT EXECUTION ---
initializeDashboard();
});

123
frontend/js/logs.js Normal file
View File

@ -0,0 +1,123 @@
document.addEventListener('DOMContentLoaded', () => {
// --- CONFIGURATION ---
const SOCKET_URL = "http://localhost:5000";
const API_BASE = "http://localhost:5000/api";
// --- DOM ELEMENT REFERENCES ---
const stationNameEl = document.getElementById('station-name');
const stationLocationEl = document.getElementById('station-location');
const deviceIdEl = document.getElementById('device-id');
const lastUpdateEl = document.getElementById('last-update-status');
const connChip = document.getElementById('connection-status-chip');
const requestLogArea = document.getElementById('request-log-area');
const eventLogArea = document.getElementById('event-log-area');
const clearReqBtn = document.getElementById('clear-req');
const clearEvtBtn = document.getElementById('clear-evt');
const clearAllBtn = document.getElementById('clear-all');
const refreshBtn = document.getElementById('refreshBtn');
const downloadBtn = document.getElementById('downloadBtn');
const logoutBtn = document.getElementById('logout-btn');
const resetBtn = document.getElementById('station-reset-btn');
// --- STATE ---
let selectedStation = null;
let socket;
let statusPollingInterval;
// --- HELPER FUNCTIONS ---
const prependLog = (textarea, data) => {
if (!textarea) return;
const timestamp = new Date().toLocaleTimeString();
const formattedJson = JSON.stringify(data, null, 2);
const newLog = `[${timestamp}]\n${formattedJson}\n\n---------------------------------\n\n`;
textarea.value = newLog + textarea.value;
};
const sendCommand = (command, data = null) => {
if (!selectedStation || !socket || !socket.connected) {
console.error(`Cannot send command '${command}', not connected.`);
return;
}
const payload = { station_id: selectedStation.id, command: command, data: data };
socket.emit('rpc_request', payload);
};
const checkStationStatus = async () => {
if (!selectedStation) return;
try {
const response = await fetch(`${API_BASE}/stations`);
if (!response.ok) return;
const stations = await response.json();
const thisStation = stations.find(s => s.id === selectedStation.id);
if (thisStation) {
stationNameEl.textContent = thisStation.name;
stationLocationEl.textContent = thisStation.location;
if (thisStation.status === 'Online') {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-emerald-400 animate-pulseDot"></span> Online`;
connChip.className = 'cham_chip cham_chip-emerald';
} else {
connChip.innerHTML = `<span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline`;
connChip.className = 'cham_chip cham_chip-rose';
}
}
} catch (error) { console.error("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')) {
prependLog(eventLogArea, data);
} else if (topic.endsWith('REQUEST')) {
prependLog(requestLogArea, data);
}
});
// --- BUTTON EVENT LISTENERS ---
if(clearReqBtn) clearReqBtn.addEventListener('click', () => requestLogArea.value = '');
if(clearEvtBtn) clearEvtBtn.addEventListener('click', () => eventLogArea.value = '');
if(clearAllBtn) clearAllBtn.addEventListener('click', () => {
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 ---
checkStationStatus();
statusPollingInterval = setInterval(checkStationStatus, 10000);
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});

12
frontend/js/main.js Normal file
View File

@ -0,0 +1,12 @@
document.addEventListener('DOMContentLoaded', () => {
const logoutButton = document.getElementById('logout-button');
if (logoutButton) {
logoutButton.addEventListener('click', () => {
// Clear the user's session from local storage
localStorage.removeItem('user');
// Redirect to the login page
window.location.href = 'index.html';
});
}
});

View File

@ -0,0 +1,114 @@
document.addEventListener('DOMContentLoaded', () => {
// --- DOM ELEMENTS ---
const stationsGrid = document.getElementById('stations-grid');
const stationTemplate = document.getElementById('stationCardTemplate');
const errorMessage = document.getElementById('error-message');
const stationCountEl = document.getElementById('station-count');
// Note: SocketIO is not needed on this page anymore
let allStations = []; // To store the master list of stations
// --- AUTHENTICATION & USER INFO (Your existing code is perfect) ---
const user = JSON.parse(localStorage.getItem('user'));
if (!user) {
window.location.href = 'index.html'; // Redirect if not logged in
return;
}
// User info and logout button logic... (omitted for brevity, no changes needed)
// --- ADMIN FEATURES (Your existing code is perfect) ---
// Admin button and add station card logic... (omitted for brevity, no changes needed)
// --- HELPER FUNCTIONS ---
const getStatusAttributes = (status) => {
switch (status) {
case 'Online': return { color: 'text-green-500', bgColor: 'bg-green-100/60 dark:bg-green-500/10', icon: 'power' };
case 'Offline': return { color: 'text-red-500', bgColor: 'bg-red-100/60 dark:bg-red-500/10', icon: 'power-off' };
default: return { color: 'text-gray-500', bgColor: 'bg-gray-100/60 dark:bg-gray-500/10', icon: 'help-circle' };
}
};
const handleStationSelect = (stationId) => {
window.location.href = `dashboard.html?station_id=${stationId}`;
};
// This function now only renders the initial grid
const renderStations = (stations) => {
stationsGrid.innerHTML = '';
stationCountEl.textContent = `${stations.length} stations found. Select one to monitor.`;
stations.forEach(station => {
const status = getStatusAttributes(station.status);
const card = document.createElement('div');
card.className = "group bg-gray-900/60 backdrop-blur-xl rounded-2xl shadow-lg border border-gray-700 transition-transform duration-300 ease-out cursor-pointer flex flex-col justify-between hover:-translate-y-1.5 hover:border-emerald-400/60 hover:shadow-[0_0_0_1px_rgba(16,185,129,0.25),0_20px_40px_rgba(0,0,0,0.45)]";
card.id = `station-${station.id}`;
card.onclick = () => handleStationSelect(station.id);
card.innerHTML = `
<div class="p-5">
<div class="flex justify-between items-start">
<h3 class="text-lg font-bold text-white pr-2">${station.name}</h3>
<div class="status-badge flex items-center text-xs font-semibold px-3 py-1 rounded-full ${status.bgColor} ${status.color}">
<i data-lucide="${status.icon}" class="w-4 h-4 mr-1.5"></i>
<span class="status-text">${station.status}</span>
</div>
</div>
<p class="text-sm text-gray-400 mt-1">${station.id}</p>
</div>
`;
stationsGrid.appendChild(card);
});
lucide.createIcons();
};
// --- NEW: Function to update statuses without redrawing everything ---
const updateStationStatuses = (stations) => {
stations.forEach(station => {
const card = document.getElementById(`station-${station.id}`);
if (card) {
const status = getStatusAttributes(station.status);
const statusBadge = card.querySelector('.status-badge');
const statusText = card.querySelector('.status-text');
const statusIcon = card.querySelector('i[data-lucide]');
if (statusBadge && statusText && statusIcon) {
statusBadge.className = `status-badge flex items-center text-xs font-semibold px-3 py-1 rounded-full ${status.bgColor} ${status.color}`;
statusText.textContent = station.status;
statusIcon.setAttribute('data-lucide', status.icon);
}
}
});
lucide.createIcons(); // Re-render icons if any changed
};
// --- DATA FETCHING & STATUS POLLING ---
const loadAndPollStations = async () => {
try {
const response = await fetch('http://localhost:5000/api/stations');
if (!response.ok) throw new Error('Failed to fetch stations');
const stations = await response.json();
// Check if this is the first time loading data
if (allStations.length === 0) {
allStations = stations;
renderStations(allStations); // Initial full render
} else {
allStations = stations;
updateStationStatuses(allStations); // Subsequent, efficient updates
}
} catch (error) {
console.error(error);
stationCountEl.textContent = 'Could not load stations. Is the backend running?';
// Stop polling on error
if (pollingInterval) clearInterval(pollingInterval);
}
};
// --- INITIALIZATION ---
loadAndPollStations(); // Load immediately on page start
// Then, set an interval to refresh the statuses every 10 seconds
const pollingInterval = setInterval(loadAndPollStations, 10000);
});

206
frontend/logs.html Normal file
View File

@ -0,0 +1,206 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Logs</title>
<!-- Font + Tailwind -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.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%; }
body { background:#0a0a0a; }
/* subtle background glow */
.bg-glow::before,
.bg-glow::after {
content:""; position:fixed; pointer-events:none; z-index:-1; border-radius:9999px; filter: blur(64px);
}
.bg-glow::before { width:34rem; height:34rem; left:-8rem; top:-8rem; background: rgba(16,185,129,.12); }
.bg-glow::after { width:36rem; height:36rem; right:-10rem; bottom:-10rem; background: rgba(56,189,248,.10); }
.badge { font-size:12px; font-weight:800; padding:.35rem .6rem; border-radius:.6rem; border:1px solid; display:inline-flex; align-items:center; gap:.4rem; }
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)}
.chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.cham_chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .5rem;border-radius:.4rem;
font-size:10px;font-weight:800;letter-spacing:.02em;text-transform:uppercase;border:1px solid}
.cham_chip-emerald{background:rgba(16,185,129,.15);color:#a7f3d0;border-color:rgba(16,185,129,.35)}
.cham_chip-rose{background:rgba(244,63,94,.16);color:#fecaca;border-color:rgba(244,63,94,.35)}
.cham_chip-amber{background:rgba(245,158,11,.18);color:#fde68a;border-color:rgba(245,158,11,.40)}
.cham_chip-sky{background:rgba(56,189,248,.15);color:#bae6fd;border-color:rgba(56,189,248,.35)}
.cham_chip-slate{background:rgba(148,163,184,.12);color:#cbd5e1;border-color:rgba(148,163,184,.30)}
.glass { background: rgba(30,41,59,.45); border: 1px solid rgba(255,255,255,.10); border-radius: .9rem; backdrop-filter: saturate(150%) blur(12px); }
.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>
</head>
<body class="min-h-screen text-gray-100 bg-glow">
<!-- STATUS BAR + TABS -->
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur">
<div class="mx-auto max-w-7.5xl px-3 sm:px-4 py-2 grid grid-cols-[auto_1fr_auto] items-center gap-2">
<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>Device ID:</span>
<span id="device-id"></span>
</span>
<span class="badge border-white/10 bg-white/5 text-slate-200">
<svg class="w-2.5 h-2.5 opacity-90" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="9"></circle><path d="M12 7v5l3 3"></path>
</svg>
<span id="last-update-status">Waiting...</span>
</span>
<span id="connection-status-chip" class="cham_chip cham_chip-amber" title="Connection to backend">
<span class="h-2 w-2 rounded-full bg-amber-400"></span> Connecting...
</span>
<button id="station-reset-btn" class="btn btn-danger !p-2" title="Station Reset">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/>
</svg>
</button>
<!-- <span id="backup-power-chip" class="cham_chip cham_chip-slate" title="Station power source" style="display: none;">On Backup</span> -->
<div class="hidden sm:block w-px h-5 bg-white/10"></div>
<button id="refreshBtn" class="btn btn-ghost !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M21 12a9 9 0 10-3.5 7.1M21 12h-4m4 0l-2.5-2.5"></path>
</svg>
</button>
<button id="downloadBtn" class="btn btn-ghost !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M12 3v12m0 0l4-4m-4 4l-4-4"></path><path d="M5 21h14"></path>
</svg>
</button>
<button id="logout-btn" class="btn btn-danger !p-2">
<svg class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
<path d="M16 17l5-5-5-5"></path><path d="M21 12H9"></path>
</svg>
</button>
</div>
</div>
<!-- Tabs -->
<div class="border-t border-white/10 bg-black/10">
<nav class="mx-auto max-w-7.5xl px-3 sm:px-4 flex">
<a href="./dashboard.html" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 hover:border-b-2 hover:border-white/30 font-semibold">Main</a>
<a href="./logs.html" class="px-4 py-2 text-sm font-semibold border-b-2 border-emerald-400/70 text-white">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>
<!-- BODY -->
<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 -->
<!-- Logs panels -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-[18rem]">
<!-- Request -->
<section class="glass p-4 flex flex-col min-h-[34rem]">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base sm:text-lg font-extrabold">RPC Request</h2>
</div>
<div class="flex items-center gap-2">
<button id="clear-req" class="btn btn-danger">Clear</button>
</div>
</div>
<div class="mt-3 flex-1">
<textarea id="request-log-area" class="textarea" readonly></textarea>
</div>
</section>
<!-- Events -->
<section class="glass p-4 flex flex-col min-h-[34rem]">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base sm:text-lg font-extrabold">Events</h2>
</div>
<div class="flex items-center gap-2">
<button id="clear-evt" class="btn btn-danger">Clear</button>
</div>
</div>
<div class="mt-3 flex-1">
<textarea id="event-log-area" class="textarea" readonly></textarea>
</div>
</section>
</div>
<!-- Footer actions -->
<div class="flex justify-end">
<button id="clear-all" class="btn btn-danger inline-flex items-center gap-1">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M3 6h18"></path><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6"></path><path d="M14 11v6"></path>
</svg>
Clear All Logs
</button>
</div>
</main>
<script src="./js/logs.js"></script>
</body>
</html>

16
frontend/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"devDependencies": {
"tailwindcss": "^4.1.12"
}
}

View File

@ -0,0 +1,368 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Select a Station</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;500;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.25)', opacity: .65 } },
fadeUp: { '0%': { opacity:0, transform:'translateY(8px)' }, '100%': { opacity:1, transform:'translateY(0)' } },
},
animation: { pulseDot: 'pulseDot 1.2s ease-in-out infinite', fadeUp: 'fadeUp .25s ease-out both' }
}
}
}
</script>
<style>
:root { color-scheme: dark; }
html, body { height: 100%; }
</style>
</head>
<body class="min-h-screen bg-[#0a0a0a] text-gray-100">
<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 with Logout and Add User -->
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur">
<div class="mx-auto max-w-7xl px-4 py-4 grid grid-cols-3 items-center gap-3">
<div>
<h1 class="text-xl md:text-2xl font-extrabold tracking-tight">Select a Station</h1>
</div>
<div class="flex 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 justify-end gap-4">
<div class="flex items-center gap-4 text-xs text-gray-400">
<span class="inline-flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-emerald-400"></span> Online</span>
<span class="inline-flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline</span>
</div>
<div class="flex items-center gap-2">
<button id="addUserBtn" class="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-semibold hover:border-emerald-400/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.5 20.25a7.5 7.5 0 0115 0"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v6m3-3H9"/></svg>
Add User
</button>
<button id="logoutBtn" class="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-semibold hover:border-rose-400/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6A2.25 2.25 0 005.25 5.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9l3 3m0 0l-3 3m3-3H3"/></svg>
Logout
</button>
</div>
</div>
</div>
</header>
<main class="relative z-10 mx-auto max-w-7xl px-4 py-6">
<div class="mb-6 grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="md:col-span-2">
<input id="search" type="text" placeholder="Search by name, ID or location"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-gray-500 outline-none focus:ring-2 focus:ring-emerald-500/60" />
</div>
<!-- THEMED STATUS DROPDOWN -->
<div class="relative" id="statusFilterWrap">
<button id="statusBtn"
class="w-full flex items-center justify-between rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm outline-none
focus:ring-2 focus:ring-emerald-500/60 hover:border-emerald-400/30 transition">
<span id="statusLabel">All statuses</span>
<svg class="h-4 w-4 opacity-80" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11l3.71-3.77a.75.75 0 011.08 1.04l-4.25 4.33a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"/></svg>
</button>
<div id="statusMenu"
class="hidden absolute z-20 mt-2 w-full rounded-xl border border-white/10 bg-black/85 backdrop-blur-xl shadow-2xl overflow-hidden">
<button data-value="all" class="w-full text-left px-4 py-2 text-sm hover:bg-white/10">All statuses</button>
<button data-value="online" class="w-full text-left px-4 py-2 text-sm hover:bg-white/10">Online</button>
<button data-value="offline" class="w-full text-left px-4 py-2 text-sm hover:bg-white/10">Offline</button>
</div>
</div>
</div>
<!-- Grid -->
<div id="stations-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 items-start"></div>
<div id="empty-state" class="hidden mt-12 text-center text-gray-400">No stations match your filters.</div>
<div id="error-state" class="hidden mt-12 text-center text-rose-300"></div>
</main>
<!-- Station Card Template (with metrics) -->
<template id="station-card-template">
<div class="group rounded-2xl border border-white/10 bg-white/5 p-4 transition animate-fadeUp 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)]">
<div class="flex items-start justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="status-dot h-2.5 w-2.5 rounded-full"></span>
<p class="truncate text-sm text-gray-400"><span class="font-semibold text-gray-200 station-name">Station</span></p>
</div>
<p class="mt-1 text-xs text-gray-400 station-location">Location</p>
</div>
<span class="status-badge rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"></span>
</div>
<!-- ID + status row -->
<div class="mt-4 grid grid-cols-1 gap-2 text-xs">
<div class="rounded-lg border border-white/10 bg-black/20 p-2 min-w-0">
<p class="text-[10px] text-gray-400">Station ID</p>
<p class="station-id font-semibold text-gray-200 whitespace-nowrap overflow-x-auto leading-snug text-[11px]" title="Station ID"></p>
</div>
</div>
<!-- Metrics Row -->
<div class="mt-3 grid grid-cols-3 gap-2">
<div class="rounded-lg border border-white/10 bg-black/20 p-2 text-center">
<p class="text-[10px] text-gray-400">Total Starts</p>
<p class="metric-starts text-sm font-semibold">0</p>
</div>
<div class="rounded-lg border border-white/10 bg-black/20 p-2 text-center">
<p class="text-[10px] text-gray-400">Completed</p>
<p class="metric-success text-sm font-semibold">0</p>
</div>
<div class="rounded-lg border border-white/10 bg-black/20 p-2 text-center">
<p class="text-[10px] text-gray-400">Aborted</p>
<p class="metric-aborted text-sm font-semibold">0</p>
</div>
</div>
<button
class="open-btn mt-4 w-full rounded-xl bg-gradient-to-r from-emerald-500 via-teal-500 to-cyan-500 px-3 py-2 text-xs font-semibold text-white transition group-hover:brightness-110 group-hover:-translate-y-px"> Open
</button>
</div>
</template>
<!-- Add Station Card Template (goes LAST) -->
<template id="add-station-card-template">
<div class="flex h-full min-h-[160px] w-full items-center justify-center rounded-2xl border-2 border-dashed border-emerald-400/40 bg-emerald-500/5 p-6 text-emerald-300 hover:border-emerald-300 hover:text-emerald-200 cursor-pointer transition animate-fadeUp">
<div class="flex flex-col items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
<span class="font-semibold">Add Station</span>
</div>
</div>
</template>
<!-- User Modal -->
<div id="userModal" class="fixed inset-0 z-50 hidden bg-black/95">
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="w-full max-w-md rounded-2xl bg-white/10 p-6">
<h2 class="text-lg font-bold mb-4">Add User</h2>
<form id="userForm" class="space-y-4">
<input type="text" id="newUsername" placeholder="Username" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="password" id="newPassword" placeholder="Password" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" id="isAdmin" class="h-4 w-4"> Is Admin</label>
<div class="flex justify-end gap-2">
<button type="button" id="cancelUserBtn" class="px-4 py-2 rounded bg-gray-600/40">Cancel</button>
<button type="submit" class="px-4 py-2 rounded bg-emerald-600">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Station Modal -->
<div id="stationModal" class="fixed inset-0 z-50 hidden bg-black/95">
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="w-full max-w-lg rounded-2xl bg-white/10 p-6">
<h2 class="text-lg font-bold mb-4">Add Station</h2>
<form id="stationForm" class="space-y-3">
<input type="text" placeholder="Station ID" id="stationId" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="text" placeholder="Name" id="stationName" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="text" placeholder="Location" id="stationLocation" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="text" placeholder="MQTT Broker" id="mqttBroker" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="number" placeholder="MQTT Port" id="mqttPort" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="text" placeholder="MQTT Username" id="mqttUsername" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2">
<input type="password" placeholder="MQTT Password" id="mqttPassword" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2">
<div class="flex justify-end gap-2">
<button type="button" id="cancelStationBtn" class="px-4 py-2 rounded bg-gray-600/40">Cancel</button>
<button type="submit" class="px-4 py-2 rounded bg-emerald-600">Save</button>
</div>
</form>
</div>
</div>
</div>
<script>
const API_BASE = 'http://localhost:5000/api';
const grid = document.getElementById('stations-grid');
const addStationCardTmpl = document.getElementById('add-station-card-template');
const stationCardTmpl = document.getElementById('station-card-template');
const searchEl = document.getElementById('search');
const emptyState = document.getElementById('empty-state');
const errorState = document.getElementById('error-state');
// THEMED STATUS DROPDOWN LOGIC
const statusBtn = document.getElementById('statusBtn');
const statusMenu = document.getElementById('statusMenu');
const statusLabel = document.getElementById('statusLabel');
let statusValue = 'all';
// Modals
const userModal = document.getElementById('userModal');
const stationModal = document.getElementById('stationModal');
const openModal = (el) => { el.classList.remove('hidden'); el.classList.add('block'); };
const closeModal = (el) => { el.classList.add('hidden'); el.classList.remove('block'); };
// Header buttons
document.getElementById('addUserBtn').onclick = () => openModal(userModal);
document.getElementById('cancelUserBtn').onclick = () => closeModal(userModal);
document.getElementById('logoutBtn').onclick = () => { localStorage.clear(); window.location.href = './index.html'; };
document.getElementById('cancelStationBtn').onclick = () => closeModal(stationModal);
// Forms
document.getElementById('userForm').onsubmit = async (e)=>{
e.preventDefault();
const payload = { username: newUsername.value.trim(), password: newPassword.value, is_admin: isAdmin.checked };
try {
const res = await fetch(`${API_BASE}/users`, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload),
credentials: 'include'
});
if(!res.ok) throw new Error('Failed to add user');
closeModal(userModal); alert('User added');
} catch(err){ alert(err.message); }
}
document.getElementById('stationForm').onsubmit = async (e)=>{
e.preventDefault();
const payload = {
station_id: stationId.value.trim(),
name: stationName.value.trim(),
location: stationLocation.value.trim(),
mqtt_broker: mqttBroker.value.trim(),
mqtt_port: Number(mqttPort.value),
mqtt_username: mqttUsername.value || null,
mqtt_password: mqttPassword.value || null,
};
try {
const res = await fetch(`${API_BASE}/stations`, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload),
credentials: 'include'
});
if(!res.ok) throw new Error('Failed to add station');
closeModal(stationModal); await loadStations();
} catch(err){ alert(err.message); }
}
function statusStyles(status){
const online = { dot:'bg-emerald-400 animate-pulseDot', badge:'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20', text:'Online' };
const offline = { dot:'bg-rose-500', badge:'bg-rose-500/15 text-rose-300 border border-rose-400/20', text:'Offline' };
return String(status).toLowerCase()==='online'?online:offline;
}
function setStatus(val, label) {
statusValue = val;
statusLabel.textContent = label;
statusMenu.classList.add('hidden');
applyFilters(); // reuse your existing function
}
let allStations = [];
function render(stations){
grid.innerHTML = '';
if(!stations || stations.length===0){
emptyState.classList.remove('hidden');
} else {
emptyState.classList.add('hidden');
for(const s of stations){
const node = stationCardTmpl.content.cloneNode(true);
const card = node.querySelector('div');
card.querySelector('.station-name').textContent = s.name ?? `Station ${s.id || s.station_id}`;
card.querySelector('.station-location').textContent = s.location ?? '—';
const idVal = s.id || s.station_id || '—';
const idEl = card.querySelector('.station-id');
idEl.textContent = idVal; idEl.setAttribute('title', idVal);
const styles = statusStyles(s.status);
const dot = card.querySelector('.status-dot');
dot.className = `status-dot h-2.5 w-2.5 rounded-full ${styles.dot}`;
const badge = card.querySelector('.status-badge');
badge.className = `status-badge rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${styles.badge}`;
badge.textContent = styles.text;
// Metrics
const starts = s.total_swaps_started ?? s.metrics?.total_starts ?? 0;
const success = s.total_swaps_success ?? s.metrics?.total_completed ?? 0;
const aborted = s.total_swaps_aborted ?? s.metrics?.total_aborted ?? 0;
card.querySelector('.metric-starts').textContent = starts;
card.querySelector('.metric-success').textContent = success;
card.querySelector('.metric-aborted').textContent = aborted;
// Open
card.querySelector('.open-btn').addEventListener('click', () => {
localStorage.setItem('selected_station', JSON.stringify(s));
const id = encodeURIComponent(s.id || s.station_id);
window.location.href = `./dashboard.html?stationId=${id}`;
});
grid.appendChild(node);
}
}
// Finally, append the Add Station card LAST
const addNode = addStationCardTmpl.content.cloneNode(true);
const addCard = addNode.querySelector('div');
addCard.addEventListener('click', () => openModal(stationModal));
grid.appendChild(addNode);
}
statusBtn.addEventListener('click', () => {
statusMenu.classList.toggle('hidden');
});
statusMenu.querySelectorAll('button').forEach(b=>{
b.addEventListener('click', () => setStatus(b.dataset.value, b.textContent.trim()));
});
function applyFilters(){
const q = (searchEl.value||'').trim().toLowerCase();
const status = statusValue; // 'all' | 'online' | 'offline'
const filtered = allStations.filter(s=>{
const matchesQ = !q || [s.name, s.id, s.station_id, s.location].filter(Boolean).some(v=>String(v).toLowerCase().includes(q));
const matchesStatus = status==='all' || String(s.status).toLowerCase()===status;
return matchesQ && matchesStatus;
});
render(filtered);
}
searchEl.addEventListener('input', ()=> setTimeout(applyFilters,150));
async function loadStations(){
try{
const res = await fetch(`${API_BASE}/stations`);
const data = await res.json();
allStations = Array.isArray(data) ? data : (data.stations||[]);
applyFilters();
}catch(err){
errorState.textContent = 'Failed to load stations. Ensure API is running.';
errorState.classList.remove('hidden');
}
}
document.addEventListener('click', (e)=>{
if (!document.getElementById('statusFilterWrap').contains(e.target)) statusMenu.classList.add('hidden');
});
document.addEventListener('DOMContentLoaded', loadStations);
</script>
</body>
</html>