diff --git a/backend/main.py b/backend/main.py index c7c1a98..cbea79c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,12 +5,12 @@ import json import csv import io import time -from datetime import datetime +from datetime import datetime, timedelta from flask import Flask, jsonify, request, Response from flask_socketio import SocketIO, join_room from flask_cors import CORS from dotenv import load_dotenv -from sqlalchemy import desc +from sqlalchemy import desc, func, case # Import your custom core modules and the new models from core.mqtt_client import MqttClient @@ -268,6 +268,44 @@ def get_stations(): return jsonify({"error": f"Database query failed: {e}"}), 500 +#--- Daily Stats Route --- +@app.route('/api/stations/daily-stats', methods=['GET']) +def get_all_station_stats(): + """ + Calculates the swap statistics for today for all stations. + """ + try: + # --- CHANGE THESE TWO LINES --- + today_start = datetime.combine(datetime.now().date(), time.min) # Use local time + today_end = datetime.combine(datetime.now().date(), time.max) # Use local time + + # This is an efficient query that groups by station_id and counts events in one go + stats = db.session.query( + MqttLog.station_id, + func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_START', 1))).label('total_starts'), + func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_ENDED', 1))).label('completed'), + func.count(case((MqttLog.payload['eventType'] == 'EVENT_SWAP_ABORTED', 1))).label('aborted') + ).filter( + MqttLog.topic_type == 'EVENTS', + MqttLog.timestamp.between(today_start, today_end) + ).group_by(MqttLog.station_id).all() + + # Convert the list of tuples into a dictionary for easy lookup + stats_dict = { + station_id: { + "total_starts": total_starts, + "completed": completed, + "aborted": aborted + } for station_id, total_starts, completed, aborted in stats + } + + return jsonify(stats_dict) + + except Exception as e: + print(f"Error fetching daily stats: {e}") + return jsonify({"message": "Could not fetch daily station stats."}), 500 + + @app.route('/api/logs/recent/', methods=['GET']) def get_recent_logs(station_id): """ @@ -290,7 +328,146 @@ def get_recent_logs(station_id): except Exception as e: print(f"Error fetching recent logs: {e}") return jsonify({"message": "Could not fetch recent logs."}), 500 + +# A helper dictionary to make abort reason labels more readable +ABORT_REASON_MAP = { + "ABORT_UNKNOWN": "Unknown", + "ABORT_BAT_EXIT_TIMEOUT": "Battery Exit Timeout", + "ABORT_BAT_ENTRY_TIMEOUT": "Battery Entry Timeout", + "ABORT_DOOR_CLOSE_TIMEOUT": "Door Close Timeout", + "ABORT_DOOR_OPEN_TIMEOUT": "Door Open Timeout", + "ABORT_INVALID_PARAM": "Invalid Parameter", + "ABORT_REMOTE_REQUESTED": "Remote Abort", + "ABORT_INVALID_BATTERY": "Invalid Battery" +} + +#--- Analytics Route --- +@app.route('/api/analytics', methods=['GET']) +def get_analytics_data(): + # 1. Get and validate request parameters (same as before) + station_id = request.args.get('station_id') + start_date_str = request.args.get('start_date') + end_date_str = request.args.get('end_date') + + if not all([station_id, start_date_str, end_date_str]): + return jsonify({"message": "Missing required parameters."}), 400 + + try: + start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + start_datetime = datetime.combine(start_date, datetime.min.time()) + end_datetime = datetime.combine(end_date, datetime.max.time()) + except ValueError: + return jsonify({"message": "Invalid date format. Please use YYYY-MM-DD."}), 400 + + # 2. Query for EVENT logs (for swap calculations) + try: + event_logs = MqttLog.query.filter( + MqttLog.station_id == station_id, + MqttLog.topic_type == 'EVENTS', + MqttLog.timestamp.between(start_datetime, end_datetime) + ).all() + except Exception as e: + return jsonify({"message": f"Could not query event logs: {e}"}), 500 + + # --- NEW: Query for PERIODIC logs (for uptime calculation) --- + try: + periodic_logs = MqttLog.query.filter( + MqttLog.station_id == station_id, + MqttLog.topic_type == 'PERIODIC', + MqttLog.timestamp.between(start_datetime, end_datetime) + ).order_by(MqttLog.timestamp.asc()).all() + except Exception as e: + return jsonify({"message": f"Could not query periodic logs: {e}"}), 500 + + # 3. Process EVENT logs for swap KPIs and charts + total_swaps, completed_swaps, aborted_swaps = 0, 0, 0 + completed_swap_times, daily_completed, daily_aborted, hourly_swaps, abort_reason_counts = [], {}, {}, [0] * 24, {} + slot_utilization_counts = {i: 0 for i in range(1, 10)} # For the heatmap + + for log in event_logs: + # (This processing logic is unchanged) + event_type = log.payload.get('eventType') + log_date = log.timestamp.date() + log_hour = log.timestamp.hour + if event_type == 'EVENT_SWAP_START': + total_swaps += 1 + hourly_swaps[log_hour] += 1 + elif event_type == 'EVENT_SWAP_ENDED': + completed_swaps += 1 + daily_completed[log_date] = daily_completed.get(log_date, 0) + 1 + swap_time = log.payload.get('eventData', {}).get('swapTime') + if swap_time is not None: + completed_swap_times.append(swap_time) + elif event_type == 'EVENT_SWAP_ABORTED': + aborted_swaps += 1 + daily_aborted[log_date] = daily_aborted.get(log_date, 0) + 1 + reason = log.payload.get('eventData', {}).get('swapAbortReason', 'ABORT_UNKNOWN') + abort_reason_counts[reason] = abort_reason_counts.get(reason, 0) + 1 + elif event_type == 'EVENT_BATTERY_EXIT': + slot_id = log.payload.get('eventData', {}).get('slotId') + if slot_id and slot_id in slot_utilization_counts: + slot_utilization_counts[slot_id] += 1 + + # --- NEW: 4. Calculate Station Uptime --- + total_period_seconds = (end_datetime - start_datetime).total_seconds() + total_downtime_seconds = 0 + MAX_ONLINE_GAP_SECONDS = 30 # Assume offline if no message for over 30 seconds + + if not periodic_logs: + total_downtime_seconds = total_period_seconds + else: + # Check gap from start time to first message + first_gap = (periodic_logs[0].timestamp - start_datetime).total_seconds() + if first_gap > MAX_ONLINE_GAP_SECONDS: + total_downtime_seconds += first_gap + + # Check gaps between consecutive messages + for i in range(1, len(periodic_logs)): + gap = (periodic_logs[i].timestamp - periodic_logs[i-1].timestamp).total_seconds() + if gap > MAX_ONLINE_GAP_SECONDS: + total_downtime_seconds += gap + + # Check gap from last message to end time + last_gap = (end_datetime - periodic_logs[-1].timestamp).total_seconds() + if last_gap > MAX_ONLINE_GAP_SECONDS: + total_downtime_seconds += last_gap + + station_uptime = 100 * (1 - (total_downtime_seconds / total_period_seconds)) + station_uptime = max(0, min(100, station_uptime)) # Ensure value is between 0 and 100 + + + # 5. Prepare final data structures (KPI section is now updated) + avg_swap_time_seconds = sum(completed_swap_times) / len(completed_swap_times) if completed_swap_times else 0 + kpi_data = { + "total_swaps": total_swaps, "completed_swaps": completed_swaps, + "aborted_swaps": aborted_swaps, "avg_swap_time_seconds": avg_swap_time_seconds, + "station_uptime": round(station_uptime, 2) # Add uptime to the KPI object + } + + # (The rest of the chart data preparation is unchanged) + date_labels, completed_data, aborted_data = [], [], [] + current_date = start_date + while current_date <= end_date: + date_labels.append(current_date.strftime('%b %d')) + completed_data.append(daily_completed.get(current_date, 0)) + aborted_data.append(daily_aborted.get(current_date, 0)) + current_date += timedelta(days=1) + + swap_activity_data = {"labels": date_labels, "completed_data": completed_data, "aborted_data": aborted_data} + hourly_distribution_data = {"labels": [f"{h % 12 if h % 12 != 0 else 12} {'AM' if h < 12 else 'PM'}" for h in range(24)], "swap_data": hourly_swaps} + abort_reasons_data = {"labels": [ABORT_REASON_MAP.get(r, r) for r in abort_reason_counts.keys()], "reason_data": list(abort_reason_counts.values())} + slot_utilization_data = {"counts": [slot_utilization_counts[i] for i in range(1, 10)]} # Return counts as a simple list [_ , _, ...] + + # 6. Combine all data and return + return jsonify({ + "kpis": kpi_data, + "swap_activity": swap_activity_data, + "hourly_distribution": hourly_distribution_data, + "abort_reasons": abort_reasons_data, + "slot_utilization": slot_utilization_data # <-- ADD THIS NEW KEY + }) # --- CSV Export route (UPDATED) --- def _format_periodic_row(payload, num_slots=9): diff --git a/frontend/analytics.html b/frontend/analytics.html index ba710ab..9f4768d 100644 --- a/frontend/analytics.html +++ b/frontend/analytics.html @@ -6,19 +6,18 @@ Swap Station – Analytics + + + - - - - - + - + \ No newline at end of file diff --git a/frontend/analytics_copy.html b/frontend/analytics_copy.html new file mode 100644 index 0000000..2f7c592 --- /dev/null +++ b/frontend/analytics_copy.html @@ -0,0 +1,340 @@ + + + + + + Swap Station – Analytics + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + +
+
+ Loading... +
+
+  
+
+
+ +
+ VECMOCON +
+ +
+ + + Product ID: + + + + + Station ID: + + + + + + + + + + Waiting... + + + + Connecting... + + + + + + + + + + + + + + +
+
+ + +
+ +
+
+ + + +
+ +
+ + 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/dashboard.html b/frontend/dashboard.html index 30a7bcc..10877e1 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -200,7 +200,7 @@ - Waiting... + Waiting for data... diff --git a/frontend/dashboard_copy.html b/frontend/dashboard_copy.html deleted file mode 100644 index a9a1b34..0000000 --- a/frontend/dashboard_copy.html +++ /dev/null @@ -1,434 +0,0 @@ - - - - - - Swap Station – Dashboard - - - - - - - - - - - -
-
-
-
- -
-
-
- - - - - -
-
- Loading... -
-
-  
-
-
- -
- VECMOCON -
- -
- - - Device ID: - - - - - - - - Last Recv — - - - - Online - - - On Backup - - - - - - - - -
- -
- -
- -
-
- -
-
-
- - -
-
- - - - - - \ No newline at end of file diff --git a/frontend/js/analytics.js b/frontend/js/analytics.js index fe4c26a..5e4570a 100644 --- a/frontend/js/analytics.js +++ b/frontend/js/analytics.js @@ -1,39 +1,176 @@ +// document.addEventListener('DOMContentLoaded', () => { +// // --- CONFIGURATION --- +// const SOCKET_URL = "http://192.168.1.12:5000"; +// const API_BASE = "http://192.168.1.12:5000/api"; + +// // --- DOM ELEMENT REFERENCES --- +// const stationNameEl = document.getElementById('station-name'); +// const stationLocationEl = document.getElementById('station-location'); +// const deviceIdEl = document.getElementById('device-id'); +// const productIdEl = document.getElementById('product-id'); +// const 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("Loading...", 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; +// productIdEl.textContent = selectedStation.product_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); +// } +// }); + + +// 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(); +// } +// }); + + + document.addEventListener('DOMContentLoaded', () => { // --- CONFIGURATION --- - const SOCKET_URL = "http://192.168.1.12:5000"; const API_BASE = "http://192.168.1.12:5000/api"; // --- DOM ELEMENT REFERENCES --- + // KPI Tiles + const totalSwapsEl = document.getElementById('total-swaps'); + const completedSwapsEl = document.getElementById('completed-swaps'); + const successRateEl = document.getElementById('success-rate'); + const abortedSwapsEl = document.getElementById('aborted-swaps'); + const abortRateEl = document.getElementById('abort-rate'); + const avgSwapTimeEl = document.getElementById('avg-swap-time'); + + // Date Range Elements + const fromDateInput = document.getElementById('from'); + const toDateInput = document.getElementById('to'); + const applyRangeBtn = document.getElementById('applyRange'); + const quickRangeBtns = document.querySelectorAll('.date-range-btn'); + + // Chart Canvases + const swapActivityCanvas = document.getElementById('swapActivityChart'); + const hourlyDistributionCanvas = document.getElementById('hourlyDistributionChart'); + const abortReasonsCanvas = document.getElementById('abortReasonsChart'); + + const stationUptimeEl = document.getElementById('station-uptime'); + + const heatmapGridEl = document.getElementById('heatmap-grid'); + + //status elements const stationNameEl = document.getElementById('station-name'); const stationLocationEl = document.getElementById('station-location'); const deviceIdEl = document.getElementById('device-id'); const productIdEl = document.getElementById('product-id'); - const lastUpdateEl = document.getElementById('last-update-status'); - const connChip = document.getElementById('connection-status-chip'); - const requestLogArea = document.getElementById('request-log-area'); - const eventLogArea = document.getElementById('event-log-area'); - const 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'); + const logoutBtn = document.getElementById('logout-btn'); - // --- STATE --- - let selectedStation = null; - let socket; - let statusPollingInterval; + // --- CONSTANTS --- - // --- 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 chartDefaults = { + color: 'rgba(203, 213, 225, 0.7)', // Light gray for text + borderColor: 'rgba(255, 255, 255, 0.1)', // Subtle grid lines }; + // --- STATE --- + let selectedStation = null; + let fromDatePicker, toDatePicker; + let swapChartInstance, hourlyChartInstance, abortChartInstance; // To hold chart instances + + // --- HELPER FUNCTIONS --- + const sendCommand = (command, data = null) => { if (!selectedStation || !socket || !socket.connected) { console.error(`Cannot send command '${command}', not connected.`); @@ -43,77 +180,333 @@ document.addEventListener('DOMContentLoaded', () => { 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'; + /** + * Updates the main KPI tiles with data from the API. + * @param {object} data - The kpis data from the backend. + */ + const updateStatTiles = (data) => { + if (!data) { // Used for loading state or on error + totalSwapsEl.textContent = '...'; + completedSwapsEl.textContent = '...'; + successRateEl.textContent = '(...%)'; + abortedSwapsEl.textContent = '...'; + abortRateEl.textContent = '(...%)'; + avgSwapTimeEl.innerHTML = '... min'; + stationUptimeEl.textContent = '... %'; + return; + } + + const total = data.total_swaps ?? 0; + const completed = data.completed_swaps ?? 0; + const aborted = data.aborted_swaps ?? 0; + + totalSwapsEl.textContent = total; + completedSwapsEl.textContent = completed; + abortedSwapsEl.textContent = aborted; + + const successRate = total > 0 ? ((completed / total) * 100).toFixed(1) : 0; + successRateEl.textContent = `(${successRate}%)`; + + const abortRate = total > 0 ? ((aborted / total) * 100).toFixed(1) : 0; + abortRateEl.textContent = `(${abortRate}%)`; + + const avgTimeInMinutes = data.avg_swap_time_seconds ? (data.avg_swap_time_seconds / 60).toFixed(1) : '—'; + avgSwapTimeEl.innerHTML = `${avgTimeInMinutes} min`; + + stationUptimeEl.textContent = `${data.station_uptime ?? '...'} %`; + + }; + + // --- CHART.JS VISUALIZATION CODE --- + + /** + * Renders the "Swap Activity Over Time" bar chart. + * @param {object} data - The chart data from the backend. + */ + const renderSwapActivityChart = (data) => { + if (swapChartInstance) { + swapChartInstance.destroy(); + } + + // Dynamically calculate the max value for the Y-axis + const maxValue = Math.max(...data.completed_data, ...data.aborted_data); + const yAxisMax = Math.ceil(maxValue * 1.2) + 1; // Add 20% padding + 1 + + swapChartInstance = new Chart(swapActivityCanvas, { + type: 'bar', + data: { + labels: data.labels, + datasets: [ + { + label: 'Completed Swaps', + data: data.completed_data, + backgroundColor: 'rgba(16, 185, 129, 0.6)', + borderColor: 'rgba(16, 185, 129, 1)', + borderWidth: 1 + }, + { + label: 'Aborted Swaps', + data: data.aborted_data, + backgroundColor: 'rgba(244, 63, 94, 0.6)', + borderColor: 'rgba(244, 63, 94, 1)', + borderWidth: 1 + } + ] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + max: yAxisMax, // <-- SET DYNAMIC MAX VALUE + grid: { color: chartDefaults.borderColor }, + ticks: { + color: chartDefaults.color, + stepSize: 1 + } + }, + x: { + grid: { display: false }, + ticks: { color: chartDefaults.color } + } + }, + plugins: { + legend: { labels: { color: chartDefaults.color } } } } - } catch (error) { console.error("Failed to fetch station status:", error); } + }); }; - + + /** + * Renders the "Hourly Swap Distribution" bar chart. + * @param {object} data - The chart data from the backend. + */ + const renderHourlyDistributionChart = (data) => { + if (hourlyChartInstance) { + hourlyChartInstance.destroy(); + } + + // Dynamically calculate the max value for the Y-axis + const maxValue = Math.max(...data.swap_data); + const yAxisMax = Math.ceil(maxValue * 1.2) + 1; // Add 20% padding + 1 + + hourlyChartInstance = new Chart(hourlyDistributionCanvas, { + type: 'bar', + data: { + labels: data.labels, + datasets: [{ + label: 'Total Swaps', + data: data.swap_data, + backgroundColor: 'rgba(56, 189, 248, 0.6)', + borderColor: 'rgba(56, 189, 248, 1)', + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + max: yAxisMax, // <-- SET DYNAMIC MAX VALUE + grid: { color: chartDefaults.borderColor }, + ticks: { + color: chartDefaults.color, + stepSize: 1 + } + }, + x: { + grid: { display: false }, + ticks: { + color: chartDefaults.color, + maxTicksLimit: 12 + } + } + }, + plugins: { + legend: { labels: { color: chartDefaults.color } } + } + } + }); + }; + + /** + * Renders the "Swap Abort Reasons" donut chart. + * @param {object} data - The chart data from the backend. + */ + const renderAbortReasonsChart = (data) => { + if (abortChartInstance) { + abortChartInstance.destroy(); + } + abortChartInstance = new Chart(abortReasonsCanvas, { + type: 'doughnut', + data: { + labels: data.labels, // e.g., ['Timeout', 'User Cancelled', ...] + datasets: [{ + label: 'Count', + data: data.reason_data, // e.g., [10, 5, ...] + backgroundColor: [ + 'rgba(244, 63, 94, 0.7)', + 'rgba(245, 158, 11, 0.7)', + 'rgba(139, 92, 246, 0.7)', + 'rgba(56, 189, 248, 0.7)', + ], + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: 'top', // Better placement for donuts + labels: { color: chartDefaults.color } + } + } + } + }); + }; + + /** + * Fetches all analytics data from the backend. + * @param {string} startDate - The start date in 'YYYY-MM-DD' format. + * @param {string} endDate - The end date in 'YYYY-MM-DD' format. + */ + const fetchAnalyticsData = async (startDate, endDate) => { + if (!selectedStation) return; + + updateStatTiles(null); // Set UI to loading state + + try { + const params = new URLSearchParams({ + station_id: selectedStation.id, + start_date: startDate, + end_date: endDate + }); + + const response = await fetch(`${API_BASE}/analytics?${params}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to fetch analytics data'); + } + + const analyticsData = await response.json(); + + // Update all sections of the page with the new data + updateStatTiles(analyticsData.kpis); + renderSwapActivityChart(analyticsData.swap_activity); + renderHourlyDistributionChart(analyticsData.hourly_distribution); + renderAbortReasonsChart(analyticsData.abort_reasons); + renderSlotHeatmap(analyticsData.slot_utilization); + + } catch (error) { + console.error("Error fetching analytics data:", error); + updateStatTiles(null); // Reset UI on error + // Destroy charts on error to show a blank state + if(swapChartInstance) swapChartInstance.destroy(); + if(hourlyChartInstance) hourlyChartInstance.destroy(); + if(abortChartInstance) abortChartInstance.destroy(); + } + }; + + /** + * Renders the Slot Utilization Heatmap. + * @param {object} data - The slot_utilization data from the backend. + */ + const renderSlotHeatmap = (data) => { + heatmapGridEl.innerHTML = ''; // Clear previous heatmap + if (!data || !data.counts || data.counts.length === 0) return; + + const counts = data.counts; + const maxCount = Math.max(...counts); + + counts.forEach((count, index) => { + const slotNumber = index + 1; + + // Calculate color intensity: 0 = no usage, 1 = max usage + const intensity = maxCount > 0 ? count / maxCount : 0; + + // Create HSL color: Hue is fixed (e.g., blue), Saturation is fixed, Lightness varies + // A low intensity (0) will be dark, a high intensity (1) will be bright. + const lightness = 20 + (50 * intensity); // Varies from 20% to 70% + const backgroundColor = `hsl(200, 80%, ${lightness}%)`; + + const cell = document.createElement('div'); + cell.className = 'rounded-md flex flex-col items-center justify-center text-white font-bold'; + cell.style.backgroundColor = backgroundColor; + cell.innerHTML = ` + Slot ${slotNumber} + ${count} + swaps + `; + heatmapGridEl.appendChild(cell); + }); + }; + // --- 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.'); + throw new Error('No station selected.'); } deviceIdEl.textContent = selectedStation.id; - productIdEl.textContent = selectedStation.product_id; + productIdEl.textContent = selectedStation.product_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); - } - }); + // Initialize Flatpickr + fromDatePicker = flatpickr(fromDateInput, { dateFormat: "Y-m-d", altInput: true, altFormat: "M j, Y" }); + toDatePicker = flatpickr(toDateInput, { dateFormat: "Y-m-d", altInput: true, altFormat: "M j, Y" }); + // Default to today and fetch initial data + const todayStr = new Date().toISOString().split('T')[0]; + fromDatePicker.setDate(todayStr, false); + toDatePicker.setDate(todayStr, false); + fetchAnalyticsData(todayStr, todayStr); + // --- EVENT LISTENERS --- + applyRangeBtn.addEventListener('click', () => { + const startDate = fromDateInput.value; + const endDate = toDateInput.value; + if (!startDate || !endDate) return alert('Please select both a start and end date.'); + if (new Date(startDate) > new Date(endDate)) return alert('Start date cannot be after the end date.'); + + fetchAnalyticsData(startDate, endDate); + }); + + // (The rest of your button listeners are unchanged) 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(); - } + quickRangeBtns.forEach(btn => { + btn.addEventListener('click', () => { + const range = btn.dataset.range; + const today = new Date(); + let startDate = new Date(); + + if (range === 'today') { + startDate = today; + } else { + startDate.setDate(today.getDate() - (parseInt(range, 10) - 1)); + } + + const startDateStr = startDate.toISOString().split('T')[0]; + const endDateStr = today.toISOString().split('T')[0]; + + fromDatePicker.setDate(startDateStr, false); + toDatePicker.setDate(endDateStr, false); + fetchAnalyticsData(startDateStr, endDateStr); + }); + }); }); \ No newline at end of file diff --git a/frontend/js/dashboard.js b/frontend/js/dashboard.js index f0d2654..0609010 100644 --- a/frontend/js/dashboard.js +++ b/frontend/js/dashboard.js @@ -260,196 +260,33 @@ document.addEventListener('DOMContentLoaded', () => { }; // --- NEW: This function polls the API for the true station status --- - const checkStationStatus = async () => { - if (!selectedStation) return; - try { - const response = await fetch(`${API_BASE}/stations`); - if (!response.ok) return; - const stations = await response.json(); - const thisStation = stations.find(s => s.id === selectedStation.id); + // const checkStationStatus = async () => { + // if (!selectedStation) return; + // try { + // const response = await fetch(`${API_BASE}/stations`); + // if (!response.ok) return; + // const stations = await response.json(); + // const thisStation = stations.find(s => s.id === selectedStation.id); - if (thisStation && connChip) { + // if (thisStation && connChip) { - stationNameEl.textContent = thisStation.name; - stationLocationEl.textContent = thisStation.location; + // stationNameEl.textContent = thisStation.name; + // stationLocationEl.textContent = thisStation.location; - if (thisStation.status === 'Online') { - connChip.innerHTML = ` 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; - } - }; - }; + // 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); + // } + // }; // --- MAIN LOGIC (Your original code is unchanged) --- const initializeDashboard = () => { @@ -546,7 +383,6 @@ document.addEventListener('DOMContentLoaded', () => { window.location.href = './index.html'; }); } - if (downloadBtn) downloadBtn.addEventListener('click', showDownloadModal); // Audio Command Button (assuming it exists in your HTML) const sendAudioBtn = document.getElementById('send-audio-btn'); @@ -580,36 +416,40 @@ document.addEventListener('DOMContentLoaded', () => { }); socket.on('dashboard_update', (message) => { - // console.log("DEBUG: Received 'dashboard_update' message:", message); + console.log("DEBUG: Received 'dashboard_update' message:", message); const { stationId, data } = message; + console.log("Received data payload:", data); + if (stationId !== selectedStation.id) { console.warn(`Ignoring message for wrong station. Expected ${selectedStation.id}, got ${stationId}`); return; } lastUpdateEl.textContent = `Last Recv ${new Date().toLocaleTimeString()}`; - stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—'; - - // 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'; + + // 1. Check if the 'stationDiagnosticCode' key exists in the data. + if (data.hasOwnProperty('stationDiagnosticCode')) { + const sdc = data.stationDiagnosticCode; + stationDiagCodeEl.textContent = sdc; + updateDiagnosticsUI(sdc); } - lastUpdateEl.textContent = new Date().toLocaleTimeString(); - stationDiagCodeEl.textContent = data.stationDiagnosticCode || '—'; + // 2. Check if the 'backupSupplyStatus' key exists. + if (data.hasOwnProperty('backupSupplyStatus')) { + if (data.backupSupplyStatus === 1) { + backupPowerChip.textContent = 'On Backup'; + backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-amber'; + } else { + backupPowerChip.textContent = 'On Mains Power'; + backupPowerChip.className = 'cham_chip w-full justify-center mt-3 cham_chip-emerald'; + } + } - // --- NEW: Call the function to update the diagnostics grid --- - updateDiagnosticsUI(data.stationDiagnosticCode || 0); - + // 3. Only process chamber-level data if it exists. 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); diff --git a/frontend/js/station_selection.js b/frontend/js/station_selection.js index 9ef7a8a..2b5cd75 100644 --- a/frontend/js/station_selection.js +++ b/frontend/js/station_selection.js @@ -256,21 +256,42 @@ document.addEventListener('DOMContentLoaded', () => { card.className = "group bg-gray-900/60 backdrop-blur-xl rounded-2xl shadow-lg border border-gray-700 transition-transform duration-300 ease-out flex flex-col justify-between hover:-translate-y-1.5 hover:border-emerald-400/60 hover:shadow-[0_0_0_1px_rgba(16,185,129,0.25),0_20px_40px_rgba(0,0,0,0.45)]"; card.innerHTML = ` -
+
-

${station.name}

+
+

${station.name}

+

# ${station.product_id || 'N/A'}

+
- ${station.status}
-

${station.id}

+

${station.id}

-
- +
`; @@ -302,6 +323,29 @@ document.addEventListener('DOMContentLoaded', () => { } }; + + //-- NEW: Fetch and apply daily stats to each card --- + const fetchAndApplyStats = async () => { + try { + const response = await fetch(`${API_BASE}/stations/daily-stats`); + if (!response.ok) return; // Fail silently if stats aren't available + const stats = await response.json(); + + // Loop through the stats object and update each card + for (const stationId in stats) { + const stationCard = stationsGrid.querySelector(`.station-card[data-station-id="${stationId}"]`); + if (stationCard) { + const statData = stats[stationId]; + stationCard.querySelector('.stat-total').textContent = statData.total_starts; + stationCard.querySelector('.stat-completed').textContent = statData.completed; + stationCard.querySelector('.stat-aborted').textContent = statData.aborted; + } + } + } catch (error) { + console.error("Could not fetch daily stats:", error); + } + }; + // --- MAIN EVENT LISTENER --- // This single listener handles all clicks on the grid for efficiency. stationsGrid.addEventListener('click', async (event) => { @@ -356,6 +400,7 @@ document.addEventListener('DOMContentLoaded', () => { // 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);