From 1ce929258f4e80f677fc3fd03c023e2d1cfc8eab Mon Sep 17 00:00:00 2001 From: Kirubakaran Date: Thu, 4 Sep 2025 21:55:05 +0530 Subject: [PATCH] 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. --- .gitignore | 58 ++ SwapStation_WebApp.code-workspace | 8 + backend/Dockerfile | 28 + backend/__pycache__/models.cpython-313.pyc | Bin 2980 -> 3730 bytes .../__pycache__/mqtt_client.cpython-313.pyc | Bin 5152 -> 5309 bytes backend/core/mqtt_client.py | 22 +- backend/main.py | 494 +++++++------- backend/models.py | 19 +- backend/requirements.txt | 5 +- backend/test.py | 164 +++++ frontend/analytics.html | 317 +++++++++ frontend/assets/vec_logo.png | Bin 0 -> 88949 bytes frontend/css/style.css | 54 ++ frontend/css/tailwind.css | 137 ++++ frontend/dashboard.html | 356 ++++++++++ frontend/dashboard_copy.html | 434 ++++++++++++ frontend/index.html | 99 +++ frontend/js/auth.js | 36 + frontend/js/dashboard.js | 618 ++++++++++++++++++ frontend/js/logs.js | 123 ++++ frontend/js/main.js | 12 + frontend/js/station_selection.js | 114 ++++ frontend/logs.html | 206 ++++++ frontend/package.json | 16 + frontend/station_selection.html | 368 +++++++++++ 25 files changed, 3450 insertions(+), 238 deletions(-) create mode 100644 .gitignore create mode 100644 SwapStation_WebApp.code-workspace create mode 100644 backend/Dockerfile create mode 100644 backend/test.py create mode 100644 frontend/analytics.html create mode 100644 frontend/assets/vec_logo.png create mode 100644 frontend/css/style.css create mode 100644 frontend/css/tailwind.css create mode 100644 frontend/dashboard.html create mode 100644 frontend/dashboard_copy.html create mode 100644 frontend/index.html create mode 100644 frontend/js/auth.js create mode 100644 frontend/js/dashboard.js create mode 100644 frontend/js/logs.js create mode 100644 frontend/js/main.js create mode 100644 frontend/js/station_selection.js create mode 100644 frontend/logs.html create mode 100644 frontend/package.json create mode 100644 frontend/station_selection.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..566becf --- /dev/null +++ b/.gitignore @@ -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 diff --git a/SwapStation_WebApp.code-workspace b/SwapStation_WebApp.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/SwapStation_WebApp.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..0e38b5e --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/__pycache__/models.cpython-313.pyc b/backend/__pycache__/models.cpython-313.pyc index cc15cfc97de96fc228f3c9b3692d1fbdd438dec4..84f70fd3874cc6f38ddbb865dfe93580cc0ad82e 100644 GIT binary patch delta 1671 zcmah}O-vg{6rNeHy|(cW210-cvNa@R)!?)wv{;1*f|?&nXyT34Ly_FIJ=iOZZOtwL zBUMmEYT-obs%nx}3WpwwdO_+X#~gbr6{PXUonw=zt7i^9y!s6L6&OntRYqG&IbDYU2OD;{zW}eB8*4IF6 zY*G=p8?xEKI{ll1EgGyaZF`Ss16L%=_HFBDVQ?w+4oi=g%TsABGsBA6so7GN6^vN9 zVh0Rnz9^d=LAkNx6sk24mdUT37q_nObPlhGKX^M1fFgCWMIap6F)-?6lcmJ*`d}ms zs78(Lfel|A9K#(nWW6Stm+tCWmaS?B>GG^*!X_I|K@79R;TzT{1{Lu@I^KeC0-z?H z<8Ip_Y>bJ2JHu{vB-Xb}Ge%)3hzv(JF8$a&@O}5d*1}GBWZOH!Pr(<3IzO0HBG!nY zCmqL@u9QjzrWI|kZltvAtX|~pFqd}%*e6mc!_-VYlhRC+>*+bz-c)LnIO&|laW76r zz}GOXH(ZGe%Qtt!k+sTd<$lwnjt3oIB_AhOD%;_)7kC4-d!O}G^h+)k{Q&wA|55uv%Oe}aLt%X(wMbPz1b*C!52Lk&a~jnK z9?1oipe?DWrc~TN3BO(JPp~p)1}mD_vuTjXcttkmwX*D}E)`|H=Eo57{R#S?<_ zaF_Aa(P4iT=&R6icp_}KUd*xuei2hRQu@)Vs%SY<0nGo1u%7sOJm)aKRm?KcyDT1I240)l&6ixAyvrEsHsAMWT%S- zO;=UoW=@CfL&gm{Yw4rgmOy}rOgF*G1bvdhI{M0=qZ4*NeJ5P9uZ@#7(}Z?N!ImEw zq5CL_2v!i`6ih6vJO}k~UJIX)>0{CB;1o-YBi1_J1H7N_Wxj9}P7xF_W+C=Q8(+PR zl|_*nEm&hD_5$2Fj@=D8!VbaYc840{zV| zRA3NO#Kw@g7+fa~5StiO-UIvr6&fFpc!&p-QvhCoW`Gs|KR^H=2oPdmNILyu8#@~Y z0s_>=&<;}P5y0#EM+WPN4Qy685RunMU)!feb7q|{I;1xkhp6EgS%DncuDRGQ-PVYj z)@D=1T%neAgflnF5V8wtZHb(L_+|}JkZS2SpP0T63{I%QunngQ+EjL4Gx*u!!i+(8 zWVZ-~k#71*?ruA&uJdpW5Td{2<;(jX?}K1DxE0-wJ&f%n9t}M2eaqhse)3#f9o=`i zH?yVemhjotwl-4h4S2>QcDn2AUomqsgz#?n62GW46o=?$YKq4c;d8 z+*?o)RuH7%Q#lNLbL>H!hzu%hdyu^d&IehBorH_x9(=GM=u&-C@SL06v}_)@zw>?P z`@Y}(&i8%yUK{#am3L*?2gq7jy0u)}l;2N7s*$xjN#H8D1Q5&u#AG32vkc<0Yz%CE zb(kypRcZ6LFN-%B>Xi=}P7sB>Vz?Y2KV}enbLC$$*tYz-!L{Yf2Jg!Aa$5?x(p(64 zGs95CnLsqb*1$s0VDUaoT#k!6ua^FLj06KvcC#)To%5N2cU2n zfGk7qO$eLIsaJbK=i9SKD@WRr33TFKkIa+GIa(Jt&Q=6`M;<;d9BYLsU|*OgCyxWQ@?-?k`z%d0enkL9!gP-&af@6nK~*K ztb)xi8HJK*`$Zptb<4Xu8&hc_~Uh@f8*tC>0mwW z?8;A4N3C=E9(OChsI`6X))QYdtd?uRg+-cf3QdU0kY# zOAn=GG)QO{1$^84&D0PP5&c$F0kxAvhsl8*Dwa)mHk?$^5$Zfbz@%f`>^(~3FMNIQ zD8BDY!@;dx-v>~oq$>X;AG>0fY^a;GO-?Kc{X|NJCn(_s0_)&Uj`g00^{tvDGYsa_C@4*hFA}iBuf1(qQ$|^@Sfi~woZhMi zFTs9lZg#?FRLM}ARsmTzphITTp&&H)k5B?mW2vJb4&rA!ejIF9sTt{QMOIf)hD5Z( zM#3>TgU^P?UZE9MB}D(nP6(o3b#o??R0>j{e@Nt~K9jTpo0up#pNdDsXD{2XkASTCRY+sE<>8pIz(&c)FzC8D=;cy00IO0~FV U=%-K?{~j3#o`q0p0E&*&f67NpXaE2J delta 1481 zcmZuxOKclO7@paecWtj9YdbhjgEy`lx=rJTrV$BA(=@3luU3|gTenHGF|L({Zfs}P zy&zC6A)!WGnoc7mP6$q|XeAU5Ac44W2p2G@MbWB45w{`^A)%s2X4YOO2*%R4-^~C0 zzrT5YqkVi(-jQWDAnoqP-7Cv0@;iMD?UA;VCNK+D!3;n!3lNiqh|MyH%YkDs%f`T4 z;>u@-+&OR@A~^{(&x&>b`bQ*t9Ik?Cy99+d#2mQ912EJYQ9(XnIR63is=+$a{D8r= z`Gi4onk}C;gf^cuTn?WOwwcmmEXaiMAFvO4F;v3%FylGuX^Wx`%fuYE`%QppCyr0B z5hn92t}~H+9lHvL4YkbYa5tO8PneL;XdB6aOp@j`haFdd4EH+2ZX@JW!uMSrhYz-T zg(*OF_Fg!G=C`eQ9&a&GCZpMo~%~;Ckw@^rSj!| z3-)Ufi#M-TOUM#+eWAQi({((@tuva0ukuH_EJ@dkM!__7-IRzCwbkm@MSO=J#=r3i z9?<~y2{GkYLY~&aBT-&<{Uk;oh%uZJ;{0C%h(vK(_+;v#_v97}V$aOi|IGt}O7an*oZgvfS8U8l@RlG6$%DU3% zeEr6&8<=qq24@-7}qY z0;%|nCjnEq>e&wyYqve`L5)`UPqM`>n)4Pc{VR1sjw^$--gyFbu*GrGIk>sDB+Cr* zcnsJ5ebNyUIje~%iNEmoj2+BaxhN0^kL~#*CI@rfAWC*gKV;^*LY z2g8@(wp-xO!R>&+X9%)<&);sASGNEGd;je^nrH>8g)5cR;!+KdgomPcKr^V4+Lk!B YSh;-7D2<~uUJf7dpMp@`1{7`4KQzA%tpET3 diff --git a/backend/core/mqtt_client.py b/backend/core/mqtt_client.py index b459470..5234e4d 100644 --- a/backend/core/mqtt_client.py +++ b/backend/core/mqtt_client.py @@ -10,7 +10,6 @@ class MqttClient: This is a standard Python class, with no GUI dependencies. """ def __init__(self, broker, port, user, password, station_id, on_message_callback): - super().__init__() self.broker = broker self.port = port self.user = user @@ -18,7 +17,6 @@ class MqttClient: self.station_id = station_id self.on_message_callback = on_message_callback - # Generate a unique client ID to prevent connection conflicts unique_id = str(uuid.uuid4()) self.client_id = f"WebApp-Backend-{self.station_id}-{unique_id}" @@ -32,26 +30,32 @@ class MqttClient: if self.user and 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.""" - 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}") - # 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.client.subscribe(topic_base) print(f"Subscribed to: {topic_base}") else: - print(f"Failed to connect to MQTT for station {self.station_id}, return code {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.""" 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): """Callback for when a message is received from the broker.""" try: - # Pass the relevant data to the main application's handler self.on_message_callback(self.station_id, msg.topic, msg.payload) except Exception as e: print(f"Error processing message in callback for topic {msg.topic}: {e}") diff --git a/backend/main.py b/backend/main.py index e5be621..e5d87fa 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 sys import threading import json import csv import io +import time from datetime import datetime 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 # 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 login_required, current_user, LoginManager +from proto.vec_payload_chgSt_pb2 import ( + rpcRequest, + jobType_e +) # --- Load Environment Variables --- load_dotenv() @@ -216,22 +32,41 @@ if not DATABASE_URL: # --- Application Setup --- 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_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 = 10 -# --- MQTT Message Handling (UPDATED) --- +# --- MQTT Message Handling --- def on_message_handler(station_id, topic, payload): - """ - Handles incoming MQTT messages, decodes them, writes to PostgreSQL, - and emits to WebSockets. - """ + last_message_timestamps[station_id] = time.time() + print(f"Main handler received message for station {station_id} on topic {topic}") decoded_data = None @@ -245,13 +80,13 @@ def on_message_handler(station_id, topic, payload): decoded_data = decoder.decode_rpc_request(payload) if decoded_data: - # 1. Write the data to PostgreSQL for historical storage + # 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, # <-- Save the new topic_type + topic_type=message_type, payload=decoded_data ) db.session.add(log_entry) @@ -260,7 +95,6 @@ def on_message_handler(station_id, topic, payload): 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, @@ -272,16 +106,127 @@ def on_message_handler(station_id, topic, payload): def handle_connect(): 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']) def get_stations(): try: 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: return jsonify({"error": f"Database query failed: {e}"}), 500 + # --- CSV Export route (UPDATED) --- def _format_periodic_row(payload, num_slots=9): """ @@ -330,30 +275,47 @@ def _format_periodic_row(payload, num_slots=9): @app.route('/api/logs/export', methods=['GET']) def export_logs(): station_id = request.args.get('station_id') - start_date_str = request.args.get('start_date') - end_date_str = request.args.get('end_date') + start_datetime_str = request.args.get('start_datetime') + end_datetime_str = request.args.get('end_datetime') log_type = request.args.get('log_type', 'PERIODIC') - if not all([station_id, start_date_str, end_date_str]): - return jsonify({"error": "Missing required parameters: station_id, start_date, end_date"}), 400 + if not all([station_id, start_datetime_str, end_datetime_str]): + return jsonify({"error": "Missing required parameters"}), 400 try: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d') - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').replace(hour=23, minute=59, second=59) + start_datetime = datetime.strptime(start_datetime_str, '%Y-%m-%dT%H:%M') + end_datetime = datetime.strptime(end_datetime_str, '%Y-%m-%dT%H:%M') 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 - query = MqttLog.query.filter( - MqttLog.station_id == station_id, - MqttLog.timestamp.between(start_date, end_date), - MqttLog.topic_type == log_type - ) + # --- 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( + MqttLog.station_id == station_id, + 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 + ) 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() 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': base_header = ["Timestamp", "DeviceID", "StationDiagnosticCode"] @@ -378,9 +340,88 @@ def export_logs(): return Response( output, 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 --- def start_mqtt_clients(): """ @@ -413,15 +454,24 @@ if __name__ == '__main__': try: with app.app_context(): 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(): - print("No stations found. Adding a default station with default MQTT config.") + print("No stations found. Adding a default station.") default_station = Station( - station_id="V16000868210069259709", - name="Test Station 2", - mqtt_broker="mqtt-dev.upgrid.in", + station_id="V16000862287077265957", + name="Test Station 1", + mqtt_broker="mqtt.vecmocon.com", mqtt_port=1883, - mqtt_user="guest", - mqtt_password="password" + mqtt_user="your_username", + mqtt_password="your_password" ) db.session.add(default_station) db.session.commit() diff --git a/backend/models.py b/backend/models.py index 29be586..f9ab56d 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,5 +1,6 @@ from flask_sqlalchemy import SQLAlchemy 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. db = SQLAlchemy() @@ -8,28 +9,34 @@ class User(db.Model): """Represents a user in the database.""" id = db.Column(db.Integer, primary_key=True) 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): + """Represents a battery swap station in the database.""" id = db.Column(db.Integer, primary_key=True) station_id = db.Column(db.String(120), unique=True, nullable=False) name = db.Column(db.String(120), nullable=True) location = db.Column(db.String(200), nullable=True) - - # --- ADD THESE NEW FIELDS --- mqtt_broker = db.Column(db.String(255), nullable=False) mqtt_port = db.Column(db.Integer, nullable=False) mqtt_user = 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): """Represents a single MQTT message payload for historical logging.""" id = db.Column(db.Integer, primary_key=True) timestamp = db.Column(db.DateTime, server_default=db.func.now()) station_id = db.Column(db.String(120), nullable=False, index=True) 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) - # JSONB is a highly efficient way to store JSON data in PostgreSQL payload = db.Column(JSONB) + diff --git a/backend/requirements.txt b/backend/requirements.txt index 56eeba8..e533ff0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,7 +1,10 @@ Flask Flask-SocketIO Flask-SQLAlchemy +Flask-Cors +Flask-Login psycopg2-binary paho-mqtt protobuf -python-dotenv \ No newline at end of file +python-dotenv +Werkzeug \ No newline at end of file diff --git a/backend/test.py b/backend/test.py new file mode 100644 index 0000000..3c5a4f2 --- /dev/null +++ b/backend/test.py @@ -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 \ No newline at end of file diff --git a/frontend/analytics.html b/frontend/analytics.html new file mode 100644 index 0000000..0dffba5 --- /dev/null +++ b/frontend/analytics.html @@ -0,0 +1,317 @@ + + + + + + Swap Station – Analytics + + + + + + + + + + + + + +
+
+ +
+ + + + + +
+
+ Loading... +
+
+  
+
+
+ + +
+ VECMOCON +
+ + +
+ + + Device ID: + + + + + + + + Last Recv — + + + + Online + + + On Backup + + + + + + + + +
+
+ + + +
+ +
+
+ + +
+ +
+ + Device ID: + + + +
+ + to + + +
+
+ + +
+
+

Total Swaps (Today)

+

142

+
+
+

Avg. Swap Time

+

2.1 min

+
+
+

Station Uptime

+

99.8%

+
+
+

Peak Hours

+

5–7 PM

+
+
+ + +
+ +
+
+

Swaps This Week

+ Mon → Sun +
+ +
+ +
+
Mon +
+
+
Tue +
+
+
Wed +
+
+
Thu +
+
+
Fri +
+
+
Sat +
+
+
Sun +
+
+
+ + +
+

Battery Health

+
+
+ + + + + + +
+ 250 + Total Batteries +
+
+
+ +
+
Good
+
Warning
+
Poor
+
+
+
+
+ + + + diff --git a/frontend/assets/vec_logo.png b/frontend/assets/vec_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..55ec9787da2376c12de5d8392806bcd342242699 GIT binary patch literal 88949 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 H?#BNC>U7?7 literal 0 HcmV?d00001 diff --git a/frontend/css/style.css b/frontend/css/style.css new file mode 100644 index 0000000..8d25093 --- /dev/null +++ b/frontend/css/style.css @@ -0,0 +1,54 @@ + \ No newline at end of file diff --git a/frontend/css/tailwind.css b/frontend/css/tailwind.css new file mode 100644 index 0000000..fa1becb --- /dev/null +++ b/frontend/css/tailwind.css @@ -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 } +} diff --git a/frontend/dashboard.html b/frontend/dashboard.html new file mode 100644 index 0000000..9d7b595 --- /dev/null +++ b/frontend/dashboard.html @@ -0,0 +1,356 @@ + + + + + + Swap Station – Dashboard + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+
+
+ + + + + +
+
+ Loading... +
+
+  
+
+
+ +
+ VECMOCON +
+ +
+ + + Device ID: + + + + + + + + Waiting... + + + + Connecting... + + + + + + + + + + + + + +
+
+ +
+ +
+
+ +
+
+
+ + +
+
+ + + + + \ No newline at end of file diff --git a/frontend/dashboard_copy.html b/frontend/dashboard_copy.html new file mode 100644 index 0000000..a9a1b34 --- /dev/null +++ b/frontend/dashboard_copy.html @@ -0,0 +1,434 @@ + + + + + + Swap Station – Dashboard + + + + + + + + + + + +
+
+
+
+ +
+
+
+ + + + + +
+
+ Loading... +
+
+  
+
+
+ +
+ VECMOCON +
+ +
+ + + Device ID: + + + + + + + + Last Recv — + + + + Online + + + On Backup + + + + + + + + +
+ +
+ +
+ +
+
+ +
+
+
+ + +
+
+ + + + + + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a1f392e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,99 @@ + + + + + + Swap Station – Login + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + +
+
+ +
+ VECMOCON +

Swap Station Dashboard

+

Sign in to access your stations

+
+ + + + + +
+
+
+ + +
+ +
+ + +
+
+ + +
+
+ +

© VECMOCON • All rights reserved.

+
+ + + + + diff --git a/frontend/js/auth.js b/frontend/js/auth.js new file mode 100644 index 0000000..e3f1071 --- /dev/null +++ b/frontend/js/auth.js @@ -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); + } + }); +}); diff --git a/frontend/js/dashboard.js b/frontend/js/dashboard.js new file mode 100644 index 0000000..2c184a5 --- /dev/null +++ b/frontend/js/dashboard.js @@ -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 = `${p[0]}${p[1]}`; + 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 = `${currentPair[0]}?`; + 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 = ` Present`; + batPill.className = 'battery-status-pill chip chip-emerald'; + + const chgPill = card.querySelector('.charger-status-pill'); + if (slot.chargerMode === 1) { + chgPill.innerHTML = ` Charging`; + chgPill.className = 'charger-status-pill chip chip-sky'; + } else { + chgPill.innerHTML = ` 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 = ` Online`; + connChip.className = 'cham_chip cham_chip-emerald'; + } else { + connChip.innerHTML = ` 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 = ` +
+

Export Logs

+
+
+ +
+ + + + + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ `; + 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 = `
${e.message} Go Back
`; + 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 = ` 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(); +}); \ No newline at end of file diff --git a/frontend/js/logs.js b/frontend/js/logs.js new file mode 100644 index 0000000..ec64195 --- /dev/null +++ b/frontend/js/logs.js @@ -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 = ` Online`; + connChip.className = 'cham_chip cham_chip-emerald'; + } else { + connChip.innerHTML = ` 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 = `
${e.message}Go Back
`; + 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(); + } +}); \ No newline at end of file diff --git a/frontend/js/main.js b/frontend/js/main.js new file mode 100644 index 0000000..a3b23fb --- /dev/null +++ b/frontend/js/main.js @@ -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'; + }); + } +}); diff --git a/frontend/js/station_selection.js b/frontend/js/station_selection.js new file mode 100644 index 0000000..cf8c7f8 --- /dev/null +++ b/frontend/js/station_selection.js @@ -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 = ` +
+
+

${station.name}

+
+ + ${station.status} +
+
+

${station.id}

+
+ `; + 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); +}); \ No newline at end of file diff --git a/frontend/logs.html b/frontend/logs.html new file mode 100644 index 0000000..f6a3aac --- /dev/null +++ b/frontend/logs.html @@ -0,0 +1,206 @@ + + + + + + Swap Station – Logs + + + + + + + + + + + + + +
+
+
+ + + + + +
+
+ Loading... +
+
+  
+
+
+ +
+ VECMOCON +
+ +
+ + + Device ID: + + + + + + + + Waiting... + + + + Connecting... + + + + + + + + + + + + + +
+
+ + +
+ +
+
+ + +
+ + + +
+ +
+
+
+

RPC Request

+
+
+ +
+
+
+ +
+
+ + +
+
+
+

Events

+
+
+ +
+
+
+ +
+
+
+ + +
+ +
+
+ + + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..e4773f6 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/station_selection.html b/frontend/station_selection.html new file mode 100644 index 0000000..cf3ced9 --- /dev/null +++ b/frontend/station_selection.html @@ -0,0 +1,368 @@ + + + + + + Swap Station – Select a Station + + + + + + + + + + + +
+
+
+
+ + +
+
+ +
+

Select a Station

+
+ +
+ VECMOCON +
+ +
+
+ Online + Offline +
+ +
+ + +
+
+ +
+
+ +
+
+
+ +
+ +
+ + + +
+
+ + +
+ + +
+ + + + + + + + + + + + + + + +