Compare commits
3 Commits
2ea8e6990c
...
fa41798552
| Author | SHA1 | Date |
|---|---|---|
|
|
fa41798552 | |
|
|
2c910cfcc5 | |
|
|
342d322e35 |
|
|
@ -6,7 +6,8 @@ SECRET_KEY="80473e17c5707e19252ef3736fba32805be21a9b3e914190"
|
||||||
# --- PostgreSQL Database Connection ---
|
# --- PostgreSQL Database Connection ---
|
||||||
# Replace with your actual database credentials.
|
# Replace with your actual database credentials.
|
||||||
# Format: postgresql://<user>:<password>@<host>:<port>/<dbname>
|
# Format: postgresql://<user>:<password>@<host>:<port>/<dbname>
|
||||||
DATABASE_URL="postgresql://swap_app_user:2004@localhost:5432/swap_station_db"
|
# DATABASE_URL="postgresql://swap_app_user:2004@localhost:5432/swap_station_db"
|
||||||
|
DATABASE_URL="postgresql://swap_app_user:Vec%40123@localhost:5432/swap_station_db"
|
||||||
|
|
||||||
# --- MQTT Broker Connection ---
|
# --- MQTT Broker Connection ---
|
||||||
MQTT_BROKER="mqtt-dev.upgrid.in"
|
MQTT_BROKER="mqtt-dev.upgrid.in"
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -5,8 +5,7 @@ import json
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta, timezone, time as dt_time
|
from datetime import datetime, timedelta
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
from flask import Flask, jsonify, request, Response
|
from flask import Flask, jsonify, request, Response
|
||||||
from flask_socketio import SocketIO, join_room
|
from flask_socketio import SocketIO, join_room
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
@ -40,7 +39,7 @@ app = Flask(__name__)
|
||||||
|
|
||||||
# CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True, expose_headers='Content-Disposition')
|
# CORS(app, resources={r"/api/*": {"origins": "http://127.0.0.1:5500"}}, supports_credentials=True, expose_headers='Content-Disposition')
|
||||||
|
|
||||||
CORS(app, resources={r"/api/*": {"origins": ["http://192.168.1.10:5500","http://127.0.0.1:5500"]}}, supports_credentials=True, expose_headers='Content-Disposition')
|
CORS(app, resources={r"/api/*": {"origins": ["http://10.10.2.47:5501","http://127.0.0.1:5501"]}}, supports_credentials=True, expose_headers='Content-Disposition')
|
||||||
|
|
||||||
# CORS(app, resources={r"/api/*": {"origins": "http://localhost:5173"}}) , "http://127.0.0.1:5500"
|
# CORS(app, resources={r"/api/*": {"origins": "http://localhost:5173"}}) , "http://127.0.0.1:5500"
|
||||||
# This tells Flask: "For any route starting with /api/, allow requests
|
# This tells Flask: "For any route starting with /api/, allow requests
|
||||||
|
|
@ -56,8 +55,6 @@ 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="*")
|
||||||
|
|
||||||
IST = ZoneInfo("Asia/Kolkata")
|
|
||||||
|
|
||||||
# --- User Loader for Flask-Login ---
|
# --- User Loader for Flask-Login ---
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
def load_user(user_id):
|
def load_user(user_id):
|
||||||
|
|
@ -286,29 +283,25 @@ def get_stations():
|
||||||
@app.route('/api/stations/daily-stats', methods=['GET'])
|
@app.route('/api/stations/daily-stats', methods=['GET'])
|
||||||
def get_all_station_stats():
|
def get_all_station_stats():
|
||||||
"""
|
"""
|
||||||
Calculates the swap statistics for the current calendar day in the IST timezone.
|
Calculates the swap statistics for today for all stations.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get the current time and date in your timezone (IST is defined globally)
|
# --- CHANGE THESE TWO LINES ---
|
||||||
now_ist = datetime.now(IST)
|
today_start = datetime.combine(datetime.utcnow().date(), time.min)
|
||||||
today_ist = now_ist.date()
|
today_end = datetime.combine(datetime.utcnow().date(), time.max)
|
||||||
|
|
||||||
# Calculate the precise start and end of that day
|
# This is an efficient query that groups by station_id and counts events in one go
|
||||||
start_of_day_ist = datetime.combine(today_ist, dt_time.min, tzinfo=IST)
|
|
||||||
end_of_day_ist = datetime.combine(today_ist, dt_time.max, tzinfo=IST)
|
|
||||||
|
|
||||||
# --- The rest of the query uses this new date range ---
|
|
||||||
stats = db.session.query(
|
stats = db.session.query(
|
||||||
MqttLog.station_id,
|
MqttLog.station_id,
|
||||||
func.count(case((MqttLog.payload['eventType'].astext == 'EVENT_SWAP_START', 1))).label('total_starts'),
|
func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_START', 1))).label('total_starts'),
|
||||||
func.count(case((MqttLog.payload['eventType'].astext == 'EVENT_SWAP_ENDED', 1))).label('completed'),
|
func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_ENDED', 1))).label('completed'),
|
||||||
func.count(case((MqttLog.payload['eventType'].astext == 'EVENT_SWAP_ABORTED', 1))).label('aborted')
|
func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_ABORTED', 1))).label('aborted')
|
||||||
).filter(
|
).filter(
|
||||||
MqttLog.topic_type == 'EVENTS',
|
MqttLog.topic_type == 'EVENTS',
|
||||||
# Use the new timezone-aware date range
|
MqttLog.timestamp.between(today_start, today_end)
|
||||||
MqttLog.timestamp.between(start_of_day_ist, end_of_day_ist)
|
|
||||||
).group_by(MqttLog.station_id).all()
|
).group_by(MqttLog.station_id).all()
|
||||||
|
|
||||||
|
# Convert the list of tuples into a dictionary for easy lookup
|
||||||
stats_dict = {
|
stats_dict = {
|
||||||
station_id: {
|
station_id: {
|
||||||
"total_starts": total_starts,
|
"total_starts": total_starts,
|
||||||
|
|
@ -580,7 +573,7 @@ def get_analytics_data():
|
||||||
hourly_swaps[log_hour] += 1
|
hourly_swaps[log_hour] += 1
|
||||||
if session_id:
|
if session_id:
|
||||||
swap_starts_map[session_id] = log.timestamp
|
swap_starts_map[session_id] = log.timestamp
|
||||||
elif event_type == 'EVENT_SWAP_ENDED':
|
elif event_type == 'EVENT_BATTERY_EXIT':
|
||||||
completed_swaps += 1
|
completed_swaps += 1
|
||||||
daily_completed[log_date] = daily_completed.get(log_date, 0) + 1
|
daily_completed[log_date] = daily_completed.get(log_date, 0) + 1
|
||||||
if session_id and session_id in swap_starts_map:
|
if session_id and session_id in swap_starts_map:
|
||||||
|
|
@ -960,5 +953,5 @@ if __name__ == '__main__':
|
||||||
mqtt_thread = threading.Thread(target=start_mqtt_clients, daemon=True)
|
mqtt_thread = threading.Thread(target=start_mqtt_clients, daemon=True)
|
||||||
mqtt_thread.start()
|
mqtt_thread.start()
|
||||||
|
|
||||||
print(f"Starting Flask-SocketIO server on http://192.168.1.10:5000")
|
print(f"Starting Flask-SocketIO server on http://10.10.2.47:5000")
|
||||||
socketio.run(app, host='192.168.1.10', port=5000)
|
socketio.run(app, host='10.10.2.47', port=5000)
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
|
|
@ -1,7 +1,7 @@
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// --- CONFIGURATION ---
|
// --- CONFIGURATION ---
|
||||||
const SOCKET_URL = "http://192.168.1.10:5000";
|
const SOCKET_URL = "http://10.10.2.47:5000";
|
||||||
const API_BASE = "http://192.168.1.10:5000/api";
|
const API_BASE = "http://10.10.2.47:5000/api";
|
||||||
|
|
||||||
// --- DOM ELEMENT REFERENCES ---
|
// --- DOM ELEMENT REFERENCES ---
|
||||||
const stationNameEl = document.getElementById('station-name');
|
const stationNameEl = document.getElementById('station-name');
|
||||||
|
|
@ -126,7 +126,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: data.labels,
|
labels: data.labels,
|
||||||
datasets: [{ label: 'Total Swaps', data: data.swap_data, backgroundColor: 'rgba(56, 189, 248, 0.6)' }]
|
datasets: [{ label: 'Total Swaps initiated', data: data.swap_data, backgroundColor: 'rgba(56, 189, 248, 0.6)' }]
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const password = document.getElementById('password').value;
|
const password = document.getElementById('password').value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('http://192.168.1.10:5000/api/login', {
|
const response = await fetch('http://10.10.2.47:5000/api/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
// frontend/js/common-header.js
|
// frontend/js/common-header.js
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// --- CONFIGURATION ---
|
// --- CONFIGURATION ---
|
||||||
const SOCKET_URL = "http://192.168.1.10:5000";
|
const SOCKET_URL = "http://10.10.2.47:5000";
|
||||||
const API_BASE = "http://192.168.1.10:5000/api";
|
const API_BASE = "http://10.10.2.47:5000/api";
|
||||||
|
|
||||||
// --- STATE & SELECTED STATION ---
|
// --- STATE & SELECTED STATION ---
|
||||||
let selectedStation = null;
|
let selectedStation = null;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// --- CONFIGURATION ---
|
// --- CONFIGURATION ---
|
||||||
const SOCKET_URL = "http://192.168.1.10:5000";
|
const SOCKET_URL = "http://10.10.2.47:5000";
|
||||||
const API_BASE = "http://192.168.1.10:5000/api"; // Added for API calls
|
const API_BASE = "http://10.10.2.47:5000/api"; // Added for API calls
|
||||||
|
|
||||||
// --- DOM ELEMENT REFERENCES ---
|
// --- DOM ELEMENT REFERENCES ---
|
||||||
const grid = document.getElementById('chambersGrid');
|
const grid = document.getElementById('chambersGrid');
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// --- CONFIGURATION ---
|
// --- CONFIGURATION ---
|
||||||
const SOCKET_URL = "http://192.168.1.10:5000";
|
const SOCKET_URL = "http://10.10.2.47:5000";
|
||||||
const API_BASE = "http://192.168.1.10:5000/api";
|
const API_BASE = "http://10.10.2.47:5000/api";
|
||||||
|
|
||||||
// --- DOM ELEMENT REFERENCES ---
|
// --- DOM ELEMENT REFERENCES ---
|
||||||
const stationNameEl = document.getElementById('station-name');
|
const stationNameEl = document.getElementById('station-name');
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
const stationCountEl = document.getElementById('station-count'); // Make sure you have an element with this ID in your HTML
|
const stationCountEl = document.getElementById('station-count'); // Make sure you have an element with this ID in your HTML
|
||||||
|
|
||||||
// --- CONFIG & STATE ---
|
// --- CONFIG & STATE ---
|
||||||
const API_BASE = 'http://192.168.1.10:5000/api';
|
const API_BASE = 'http://10.10.2.47:5000/api';
|
||||||
let allStations = []; // Master list of stations from the API
|
let allStations = []; // Master list of stations from the API
|
||||||
let pollingInterval = null;
|
let pollingInterval = null;
|
||||||
|
|
||||||
|
|
@ -117,10 +117,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
//-- NEW: Fetch and apply daily stats to each card ---
|
//-- NEW: Fetch and apply daily stats to each card ---
|
||||||
const fetchAndApplyStats = async () => {
|
const fetchAndApplyStats = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/stations/daily-stats`, {
|
const response = await fetch(`${API_BASE}/stations/daily-stats`);
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include' // <-- ADD THIS LINE
|
|
||||||
});
|
|
||||||
if (!response.ok) return; // Fail silently if stats aren't available
|
if (!response.ok) return; // Fail silently if stats aren't available
|
||||||
const stats = await response.json();
|
const stats = await response.json();
|
||||||
|
|
||||||
|
|
@ -161,10 +158,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/stations/${stationId}`, {
|
const response = await fetch(`${API_BASE}/stations/${stationId}`, { method: 'DELETE' });
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include' // <-- ADD THIS LINE
|
|
||||||
});
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
alert(`Station "${stationName}" removed successfully.`);
|
alert(`Station "${stationName}" removed successfully.`);
|
||||||
allStations = []; // Force a full refresh on next poll
|
allStations = []; // Force a full refresh on next poll
|
||||||
|
|
@ -181,61 +175,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- DATA FETCHING & POLLING ---
|
// --- DATA FETCHING & POLLING ---
|
||||||
// const loadAndPollStations = async () => {
|
|
||||||
// try {
|
|
||||||
// const response = await fetch(`${API_BASE}/stations`, {
|
|
||||||
// method: 'GET',
|
|
||||||
// credentials: 'include' // <-- ADD THIS LINE
|
|
||||||
// });
|
|
||||||
// if (!response.ok) throw new Error('Failed to fetch stations');
|
|
||||||
|
|
||||||
// const newStationList = await response.json();
|
|
||||||
|
|
||||||
// // If the number of stations has changed, we must do a full re-render.
|
|
||||||
// if (newStationList.length !== allStations.length) {
|
|
||||||
// allStations = newStationList;
|
|
||||||
// renderStations(allStations);
|
|
||||||
// } else {
|
|
||||||
// // Otherwise, we can do a more efficient status-only update.
|
|
||||||
// allStations = newStationList;
|
|
||||||
// updateStationStatuses(allStations);
|
|
||||||
// fetchAndApplyStats(); // Fetch and update daily stats
|
|
||||||
// }
|
|
||||||
// } catch (error) {
|
|
||||||
// console.error(error);
|
|
||||||
// stationCountEl.textContent = 'Could not load stations. Is the backend running?';
|
|
||||||
// if (pollingInterval) clearInterval(pollingInterval);
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
const loadAndPollStations = async () => {
|
const loadAndPollStations = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/stations`, { credentials: 'include' });
|
const response = await fetch(`${API_BASE}/stations`);
|
||||||
if (response.status === 401) {
|
|
||||||
localStorage.clear();
|
|
||||||
window.location.href = 'index.html';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch stations');
|
if (!response.ok) throw new Error('Failed to fetch stations');
|
||||||
|
|
||||||
const newStationList = await response.json();
|
const newStationList = await response.json();
|
||||||
|
|
||||||
|
// If the number of stations has changed, we must do a full re-render.
|
||||||
if (newStationList.length !== allStations.length) {
|
if (newStationList.length !== allStations.length) {
|
||||||
allStations = newStationList;
|
allStations = newStationList;
|
||||||
renderStations(allStations);
|
renderStations(allStations);
|
||||||
} else {
|
} else {
|
||||||
|
// Otherwise, we can do a more efficient status-only update.
|
||||||
allStations = newStationList;
|
allStations = newStationList;
|
||||||
// A more efficient status-only update could go here later
|
updateStationStatuses(allStations);
|
||||||
renderStations(allStations); // Re-render to update statuses
|
fetchAndApplyStats(); // Fetch and update daily stats
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- THIS IS THE FIX ---
|
|
||||||
// Call this AFTER the if/else, so it always runs on a successful fetch.
|
|
||||||
fetchAndApplyStats();
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
if (stationCountEl) stationCountEl.textContent = 'Could not load stations. Is the backend running?';
|
stationCountEl.textContent = 'Could not load stations. Is the backend running?';
|
||||||
if (pollingInterval) clearInterval(pollingInterval);
|
if (pollingInterval) clearInterval(pollingInterval);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -224,204 +224,221 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API_BASE = 'http://192.168.1.10:5000/api';
|
const API_BASE = 'http://10.10.2.47:5000/api';
|
||||||
|
|
||||||
// --- DOM Elements ---
|
|
||||||
const grid = document.getElementById('stations-grid');
|
const grid = document.getElementById('stations-grid');
|
||||||
const addStationCardTmpl = document.getElementById('add-station-card-template');
|
const addStationCardTmpl = document.getElementById('add-station-card-template');
|
||||||
const stationCardTmpl = document.getElementById('station-card-template');
|
const stationCardTmpl = document.getElementById('station-card-template');
|
||||||
|
|
||||||
const searchEl = document.getElementById('search');
|
const searchEl = document.getElementById('search');
|
||||||
const emptyState = document.getElementById('empty-state');
|
const emptyState = document.getElementById('empty-state');
|
||||||
const errorState = document.getElementById('error-state');
|
const errorState = document.getElementById('error-state');
|
||||||
const statusBtn = document.getElementById('statusBtn');
|
|
||||||
const statusMenu = document.getElementById('statusMenu');
|
// THEMED STATUS DROPDOWN LOGIC
|
||||||
|
const statusBtn = document.getElementById('statusBtn');
|
||||||
|
const statusMenu = document.getElementById('statusMenu');
|
||||||
const statusLabel = document.getElementById('statusLabel');
|
const statusLabel = document.getElementById('statusLabel');
|
||||||
|
let statusValue = 'all';
|
||||||
|
|
||||||
|
// Modals
|
||||||
const userModal = document.getElementById('userModal');
|
const userModal = document.getElementById('userModal');
|
||||||
const stationModal = document.getElementById('stationModal');
|
const stationModal = document.getElementById('stationModal');
|
||||||
|
|
||||||
// --- State Variables ---
|
|
||||||
let allStations = []; // Master list of stations
|
|
||||||
let statusValue = 'all';
|
|
||||||
let pollingInterval = null; // To hold our interval ID
|
|
||||||
|
|
||||||
// --- Modal & Form Logic (No changes needed here) ---
|
|
||||||
const openModal = (el) => { el.classList.remove('hidden'); el.classList.add('block'); };
|
const openModal = (el) => { el.classList.remove('hidden'); el.classList.add('block'); };
|
||||||
const closeModal = (el) => { el.classList.add('hidden'); el.classList.remove('block'); };
|
const closeModal = (el) => { el.classList.add('hidden'); el.classList.remove('block'); };
|
||||||
|
|
||||||
|
// Header buttons
|
||||||
document.getElementById('addUserBtn').onclick = () => openModal(userModal);
|
document.getElementById('addUserBtn').onclick = () => openModal(userModal);
|
||||||
document.getElementById('cancelUserBtn').onclick = () => closeModal(userModal);
|
document.getElementById('cancelUserBtn').onclick = () => closeModal(userModal);
|
||||||
document.getElementById('logoutBtn').onclick = () => { localStorage.clear(); window.location.href = './index.html'; };
|
document.getElementById('logoutBtn').onclick = () => { localStorage.clear(); window.location.href = './index.html'; };
|
||||||
document.getElementById('cancelStationBtn').onclick = () => closeModal(stationModal);
|
document.getElementById('cancelStationBtn').onclick = () => closeModal(stationModal);
|
||||||
// (Your form submission logic for users and stations can remain the same)
|
|
||||||
// ...
|
|
||||||
|
|
||||||
function statusStyles(status) {
|
// Forms
|
||||||
const online = { dot: 'bg-emerald-400 animate-pulseDot', badge: 'bg-emerald-500/15 text-emerald-300 border border-emerald-400/20', text: 'Online' };
|
document.getElementById('userForm').onsubmit = async (e)=>{
|
||||||
const offline = { dot: 'bg-rose-500', badge: 'bg-rose-500/15 text-rose-300 border border-rose-400/20', text: 'Offline' };
|
e.preventDefault();
|
||||||
return String(status).toLowerCase() === 'online' ? online : offline;
|
const payload = { username: newUsername.value.trim(), password: newPassword.value, is_admin: isAdmin.checked };
|
||||||
}
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/users`, {
|
||||||
// --- Main Rendering Function ---
|
method:'POST',
|
||||||
function render(stationsToDisplay) {
|
headers:{'Content-Type':'application/json'},
|
||||||
grid.innerHTML = '';
|
body: JSON.stringify(payload),
|
||||||
errorState.classList.add('hidden');
|
credentials: 'include'
|
||||||
|
|
||||||
if (!stationsToDisplay || stationsToDisplay.length === 0) {
|
|
||||||
emptyState.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
emptyState.classList.add('hidden');
|
|
||||||
stationsToDisplay.forEach(s => {
|
|
||||||
const node = stationCardTmpl.content.cloneNode(true);
|
|
||||||
const card = node.querySelector('div');
|
|
||||||
card.dataset.stationId = s.id || s.station_id; // IMPORTANT for stats update
|
|
||||||
|
|
||||||
// --- Populate Card Details (No changes here) ---
|
|
||||||
card.querySelector('.station-name').textContent = s.name ?? `Station ${s.id || s.station_id}`;
|
|
||||||
card.querySelector('.product-id').textContent = s.product_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;
|
|
||||||
|
|
||||||
card.querySelector('.metric-starts').textContent = 0;
|
|
||||||
card.querySelector('.metric-success').textContent = 0;
|
|
||||||
card.querySelector('.metric-aborted').textContent = 0;
|
|
||||||
|
|
||||||
// --- FIX: Added the full logic for the Open button ---
|
|
||||||
card.querySelector('.open-btn').addEventListener('click', () => {
|
|
||||||
const id = encodeURIComponent(s.id || s.station_id);
|
|
||||||
window.location.href = `./dashboard.html?stationId=${id}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- FIX: Added the full logic for the Remove button ---
|
|
||||||
card.querySelector('.remove-btn').addEventListener('click', async () => {
|
|
||||||
const stationId = s.id || s.station_id;
|
|
||||||
const stationName = s.name;
|
|
||||||
|
|
||||||
if (!confirm(`Are you sure you want to permanently remove "${stationName}"?`)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/stations/${stationId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
alert(`Station "${stationName}" removed successfully.`);
|
|
||||||
// Refresh the list immediately
|
|
||||||
loadAndPollStations();
|
|
||||||
} else {
|
|
||||||
const error = await response.json();
|
|
||||||
alert(`Failed to remove station: ${error.message}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error removing station:', error);
|
|
||||||
alert('An error occurred while trying to remove the station.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
grid.appendChild(node);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Append the "Add Station" card
|
|
||||||
const addNode = addStationCardTmpl.content.cloneNode(true);
|
|
||||||
addNode.querySelector('div').addEventListener('click', () => openModal(stationModal));
|
|
||||||
grid.appendChild(addNode);
|
|
||||||
|
|
||||||
if (window.lucide) {
|
|
||||||
lucide.createIcons();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
const q = (searchEl.value || '').trim().toLowerCase();
|
|
||||||
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 = statusValue === 'all' || String(s.status).toLowerCase() === statusValue;
|
|
||||||
return matchesQ && matchesStatus;
|
|
||||||
});
|
});
|
||||||
render(filtered);
|
if(!res.ok) throw new Error('Failed to add user');
|
||||||
|
closeModal(userModal); alert('User added');
|
||||||
|
} catch(err){ alert(err.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- NEW: Function to Fetch and Apply Daily Stats ---
|
document.getElementById('stationForm').onsubmit = async (e)=>{
|
||||||
const fetchAndApplyStats = async () => {
|
e.preventDefault();
|
||||||
try {
|
const payload = {
|
||||||
const response = await fetch(`${API_BASE}/stations/daily-stats`);
|
station_id: stationId.value.trim(),
|
||||||
if (!response.ok) return; // Fail silently if stats aren't available
|
product_id: stationProductId.value.trim(),
|
||||||
|
name: stationName.value.trim(),
|
||||||
|
location: stationLocation.value.trim(),
|
||||||
|
mqtt_broker: mqttBroker.value.trim(),
|
||||||
|
mqtt_port: Number(mqttPort.value),
|
||||||
|
mqtt_user: mqttUsername.value || null,
|
||||||
|
mqtt_password: mqttPassword.value || null,
|
||||||
|
};
|
||||||
|
|
||||||
const stats = await response.json();
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/stations`, {
|
||||||
for (const stationId in stats) {
|
method:'POST',
|
||||||
const stationCard = grid.querySelector(`[data-station-id="${stationId}"]`);
|
headers:{'Content-Type':'application/json'},
|
||||||
if (stationCard) {
|
body: JSON.stringify(payload),
|
||||||
const statData = stats[stationId];
|
credentials: 'include'
|
||||||
stationCard.querySelector('.metric-starts').textContent = statData.total_starts;
|
|
||||||
stationCard.querySelector('.metric-success').textContent = statData.completed;
|
|
||||||
stationCard.querySelector('.metric-aborted').textContent = statData.aborted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Could not fetch daily stats:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- MODIFIED: Main Function to Load and Poll for Data ---
|
|
||||||
const loadAndPollStations = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE}/stations`);
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch stations');
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
const newStationList = Array.isArray(data) ? data : (data.stations || []);
|
|
||||||
|
|
||||||
// Only re-render the whole grid if the number of stations changes
|
|
||||||
if (newStationList.length !== allStations.length) {
|
|
||||||
allStations = newStationList;
|
|
||||||
applyFilters(); // This will call render()
|
|
||||||
} else {
|
|
||||||
// If station count is the same, just update the master list
|
|
||||||
allStations = newStationList;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always fetch fresh stats after getting the station list
|
|
||||||
await fetchAndApplyStats();
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
errorState.textContent = 'Failed to load stations. Ensure API is running.';
|
|
||||||
errorState.classList.remove('hidden');
|
|
||||||
if (pollingInterval) clearInterval(pollingInterval); // Stop polling on error
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Event Listeners for Filters ---
|
|
||||||
searchEl.addEventListener('input', () => setTimeout(applyFilters, 150));
|
|
||||||
statusBtn.addEventListener('click', () => statusMenu.classList.toggle('hidden'));
|
|
||||||
statusMenu.querySelectorAll('button').forEach(b => {
|
|
||||||
b.addEventListener('click', () => {
|
|
||||||
statusValue = b.dataset.value;
|
|
||||||
statusLabel.textContent = b.textContent.trim();
|
|
||||||
statusMenu.classList.add('hidden');
|
|
||||||
applyFilters();
|
|
||||||
});
|
});
|
||||||
|
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}`;
|
||||||
|
// const productIdVal = s.product_id || '—';
|
||||||
|
// const productIdEl = card.querySelector('.product-id');
|
||||||
|
// if (productIdEl) {
|
||||||
|
// // Use .innerHTML and add a styled <span> for the title
|
||||||
|
// productIdEl.innerHTML = `<span class="font-semibold text-white-500">Product ID: </span>${productIdVal}`;
|
||||||
|
// }
|
||||||
|
const productIdVal = s.product_id || '—';
|
||||||
|
const productIdEl = card.querySelector('.product-id');
|
||||||
|
if (productIdEl) {
|
||||||
|
productIdEl.textContent = productIdVal;
|
||||||
|
}
|
||||||
|
card.querySelector('.station-location').textContent = s.location ?? '—';
|
||||||
|
const idVal = s.id || s.station_id || '—';
|
||||||
|
const idEl = card.querySelector('.station-id');
|
||||||
|
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}`;
|
||||||
|
});
|
||||||
|
// --- ADD THIS NEW BLOCK FOR THE REMOVE BUTTON ---
|
||||||
|
card.querySelector('.remove-btn').addEventListener('click', async () => {
|
||||||
|
const stationId = s.id || s.station_id;
|
||||||
|
const stationName = s.name;
|
||||||
|
|
||||||
|
// 1. Confirm with the user
|
||||||
|
if (!confirm(`Are you sure you want to permanently remove "${stationName}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 2. Call the DELETE API endpoint
|
||||||
|
const response = await fetch(`${API_BASE}/stations/${stationId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert(`Station "${stationName}" removed successfully.`);
|
||||||
|
// 3. Refresh the entire list from the server
|
||||||
|
loadStations();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Failed to remove station: ${error.message}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing station:', error);
|
||||||
|
alert('An error occurred while trying to remove the station.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.appendChild(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
if (window.lucide) {
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statusBtn.addEventListener('click', () => {
|
||||||
|
statusMenu.classList.toggle('hidden');
|
||||||
|
});
|
||||||
|
statusMenu.querySelectorAll('button').forEach(b=>{
|
||||||
|
b.addEventListener('click', () => setStatus(b.dataset.value, b.textContent.trim()));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- MODIFIED: Start Everything on Page Load ---
|
function applyFilters(){
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
const q = (searchEl.value||'').trim().toLowerCase();
|
||||||
loadAndPollStations(); // Load once immediately
|
const status = statusValue; // 'all' | 'online' | 'offline'
|
||||||
pollingInterval = setInterval(loadAndPollStations, 10000); // Then poll every 10 seconds
|
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');
|
||||||
});
|
});
|
||||||
|
|
||||||
</script>
|
document.addEventListener('DOMContentLoaded', loadStations);
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue