SwapStation_WebApp/frontend/station_selection.html

428 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Swap Station Select a Station</title>
<script src="js/auth-guard.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest" defer></script>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ["Inter", "ui-sans-serif", "system-ui"] },
keyframes: {
pulseDot: { '0%,100%': { transform:'scale(1)', opacity: 1 }, '50%': { transform:'scale(1.25)', opacity: .65 } },
fadeUp: { '0%': { opacity:0, transform:'translateY(8px)' }, '100%': { opacity:1, transform:'translateY(0)' } },
},
animation: { pulseDot: 'pulseDot 1.2s ease-in-out infinite', fadeUp: 'fadeUp .25s ease-out both' }
}
}
}
</script>
<style>
:root { color-scheme: dark; }
html, body { height: 100%; }
</style>
</head>
<body class="min-h-screen bg-[#0a0a0a] text-gray-100">
<div class="pointer-events-none fixed inset-0">
<div class="absolute -top-24 -left-24 w-[32rem] h-[32rem] rounded-full bg-emerald-500/10 blur-3xl"></div>
<div class="absolute -bottom-24 -right-24 w-[36rem] h-[36rem] rounded-full bg-sky-500/10 blur-3xl"></div>
</div>
<!-- Header with Logout and Add User -->
<header class="relative z-10 border-b border-white/10 bg-black/20 backdrop-blur">
<div class="mx-auto max-w-7xl px-4 py-4 grid grid-cols-3 items-center gap-3">
<div>
<h1 class="text-xl md:text-2xl font-extrabold tracking-tight">Select a Station</h1>
</div>
<div class="flex justify-center">
<img src="./assets/vec_logo.png" alt="VECMOCON"
class="h-7 w-auto opacity-90" onerror="this.style.display='none'"/>
</div>
<div class="flex items-center justify-end gap-4">
<div class="flex items-center gap-4 text-xs text-gray-400">
<span class="inline-flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-emerald-400"></span> Online</span>
<span class="inline-flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-rose-500"></span> Offline</span>
</div>
<div class="flex items-center gap-2">
<button id="addUserBtn" class="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-semibold hover:border-emerald-400/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.5 20.25a7.5 7.5 0 0115 0"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v6m3-3H9"/></svg>
Add User
</button>
<button id="logoutBtn" class="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-xs font-semibold hover:border-rose-400/30">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6A2.25 2.25 0 005.25 5.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9l3 3m0 0l-3 3m3-3H3"/></svg>
Logout
</button>
</div>
</div>
</div>
</header>
<main class="relative z-10 mx-auto max-w-7xl px-4 py-6">
<div class="mb-6 grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="md:col-span-2">
<input id="search" type="text" placeholder="Search by name, ID or location"
class="w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm placeholder-gray-500 outline-none focus:ring-2 focus:ring-emerald-500/60" />
</div>
<!-- THEMED STATUS DROPDOWN -->
<div class="relative" id="statusFilterWrap">
<button id="statusBtn"
class="w-full flex items-center justify-between rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm outline-none
focus:ring-2 focus:ring-emerald-500/60 hover:border-emerald-400/30 transition">
<span id="statusLabel">All statuses</span>
<svg class="h-4 w-4 opacity-80" viewBox="0 0 20 20" fill="currentColor"><path d="M5.23 7.21a.75.75 0 011.06.02L10 11l3.71-3.77a.75.75 0 011.08 1.04l-4.25 4.33a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"/></svg>
</button>
<div id="statusMenu"
class="hidden absolute z-20 mt-2 w-full rounded-xl border border-white/10 bg-black/85 backdrop-blur-xl shadow-2xl overflow-hidden">
<button data-value="all" class="w-full text-left px-4 py-2 text-sm hover:bg-white/10">All statuses</button>
<button data-value="online" class="w-full text-left px-4 py-2 text-sm hover:bg-white/10">Online</button>
<button data-value="offline" class="w-full text-left px-4 py-2 text-sm hover:bg-white/10">Offline</button>
</div>
</div>
</div>
<!-- Grid -->
<div id="stations-grid" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 items-start"></div>
<div id="empty-state" class="hidden mt-12 text-center text-gray-400">No stations match your filters.</div>
<div id="error-state" class="hidden mt-12 text-center text-rose-300"></div>
</main>
<!-- Station Card Template (with metrics) -->
<template id="station-card-template">
<div class="group rounded-2xl border border-white/10 bg-white/5 p-4 transition animate-fadeUp hover:-translate-y-1.5 hover:border-emerald-400/60 hover:shadow-[0_0_0_1px_rgba(16,185,129,0.25),0_20px_40px_rgba(0,0,0,0.45)]">
<div class="flex items-start justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="status-dot h-2.5 w-2.5 rounded-full"></span>
<p class="truncate text-sm text-gray-400"><span class="font-semibold text-gray-200 station-name">Station</span></p>
</div>
<!-- <p class="product-id mt-1 text-xs text-gray-400" title="Product ID">-</p> -->
<div class="product-id-wrapper mt-1 flex items-center gap-1.5 text-xs text-gray-400">
<i data-lucide="hash" class="h-3 w-3 text-gray-500"></i>
<span class="product-id"></span>
</div>
<!-- <p class="mt-1 text-xs text-gray-400 station-location">Location</p> -->
<div class="location-wrapper mt-1 flex items-center gap-1.5 text-xs text-gray-400">
<i data-lucide="map-pin" class="h-3 w-3 text-gray-500"></i>
<span class="station-location">Location</span>
</div>
</div>
<span class="status-badge rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide"></span>
</div>
<!-- ID + status row -->
<div class="mt-4 grid grid-cols-1 gap-2 text-xs">
<div class="rounded-lg border border-white/10 bg-black/20 p-2 min-w-0">
<p class="text-[10px] text-gray-400">Station ID</p>
<p class="station-id font-semibold text-gray-200 whitespace-nowrap overflow-x-auto leading-snug text-[11px]" title="Station ID"></p>
</div>
<!-- <div class="rounded-lg border border-white/10 bg-black/20 p-2 min-w-0">
<p class="text-[10px] text-gray-400">Product ID</p>
<p class="product-id font-semibold text-gray-200 whitespace-nowrap overflow-x-auto leading-snug text-[11px]" title="Product ID">—</p>
</div> -->
</div>
<!-- Metrics Row -->
<div class="mt-3 grid grid-cols-3 gap-2">
<div class="rounded-lg border border-white/10 bg-black/20 p-2 text-center">
<p class="text-[10px] text-gray-400">Total Starts</p>
<p class="metric-starts text-sm font-semibold">0</p>
</div>
<div class="rounded-lg border border-white/10 bg-black/20 p-2 text-center">
<p class="text-[10px] text-gray-400">Completed</p>
<p class="metric-success text-sm font-semibold">0</p>
</div>
<div class="rounded-lg border border-white/10 bg-black/20 p-2 text-center">
<p class="text-[10px] text-gray-400">Aborted</p>
<p class="metric-aborted text-sm font-semibold">0</p>
</div>
</div>
<div class="mt-4 flex items-center gap-2">
<button class="open-btn w-full flex-grow rounded-lg bg-gradient-to-r from-emerald-500 to-teal-500 px-3 py-2 text-xs font-semibold text-white transition hover:brightness-110">
Open
</button>
<button class="remove-btn flex-shrink-0 rounded-lg border border-white/10 bg-white/5 p-2 text-rose-400 transition hover:border-rose-400/60 hover:bg-rose-500/10">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
<line x1="10" x2="10" y1="11" y2="17"/>
<line x1="14" x2="14" y1="11" y2="17"/>
</svg>
</button>
</div>
</div>
</template>
<!-- Add Station Card Template (goes LAST) -->
<template id="add-station-card-template">
<div class="flex h-full min-h-[160px] w-full items-center justify-center rounded-2xl border-2 border-dashed border-emerald-400/40 bg-emerald-500/5 p-6 text-emerald-300 hover:border-emerald-300 hover:text-emerald-200 cursor-pointer transition animate-fadeUp">
<div class="flex flex-col items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
<span class="font-semibold">Add Station</span>
</div>
</div>
</template>
<!-- User Modal -->
<div id="userModal" class="fixed inset-0 z-50 hidden bg-black/95">
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="w-full max-w-md rounded-2xl bg-white/10 p-6">
<h2 class="text-lg font-bold mb-4">Add User</h2>
<form id="userForm" class="space-y-4">
<input type="text" id="newUsername" placeholder="Username" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="password" id="newPassword" placeholder="Password" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" id="isAdmin" class="h-4 w-4"> Is Admin</label>
<div class="flex justify-end gap-2">
<button type="button" id="cancelUserBtn" class="px-4 py-2 rounded bg-gray-600/40">Cancel</button>
<button type="submit" class="px-4 py-2 rounded bg-emerald-600">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Station Modal -->
<div id="stationModal" class="fixed inset-0 z-50 hidden bg-black/95">
<div class="absolute inset-0 flex items-center justify-center p-4">
<div class="w-full max-w-lg rounded-2xl bg-white/10 p-6">
<h2 class="text-lg font-bold mb-4">Add Station</h2>
<form id="stationForm" class="space-y-3">
<input type="text" placeholder="Station ID" id="stationId" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="text" placeholder="Product ID" id="stationProductId" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="text" placeholder="Name" id="stationName" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="text" placeholder="Location" id="stationLocation" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="text" placeholder="MQTT Broker" id="mqttBroker" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="number" placeholder="MQTT Port" id="mqttPort" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2" required>
<input type="text" placeholder="MQTT Username" id="mqttUsername" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2">
<input type="password" placeholder="MQTT Password" id="mqttPassword" class="w-full rounded-lg border border-white/20 bg-black/40 px-3 py-2">
<div class="flex justify-end gap-2">
<button type="button" id="cancelStationBtn" class="px-4 py-2 rounded bg-gray-600/40">Cancel</button>
<button type="submit" class="px-4 py-2 rounded bg-emerald-600">Save</button>
</div>
</form>
</div>
</div>
</div>
<script>
const API_BASE = 'http://192.168.1.10:5000/api';
// --- DOM Elements ---
const grid = document.getElementById('stations-grid');
const addStationCardTmpl = document.getElementById('add-station-card-template');
const stationCardTmpl = document.getElementById('station-card-template');
const searchEl = document.getElementById('search');
const emptyState = document.getElementById('empty-state');
const errorState = document.getElementById('error-state');
const statusBtn = document.getElementById('statusBtn');
const statusMenu = document.getElementById('statusMenu');
const statusLabel = document.getElementById('statusLabel');
const userModal = document.getElementById('userModal');
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 closeModal = (el) => { el.classList.add('hidden'); el.classList.remove('block'); };
document.getElementById('addUserBtn').onclick = () => openModal(userModal);
document.getElementById('cancelUserBtn').onclick = () => closeModal(userModal);
document.getElementById('logoutBtn').onclick = () => { localStorage.clear(); window.location.href = './index.html'; };
document.getElementById('cancelStationBtn').onclick = () => closeModal(stationModal);
// (Your form submission logic for users and stations can remain the same)
// ...
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;
}
// --- Main Rendering Function ---
function render(stationsToDisplay) {
grid.innerHTML = '';
errorState.classList.add('hidden');
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);
}
// --- NEW: Function to Fetch and Apply Daily Stats ---
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();
for (const stationId in stats) {
const stationCard = grid.querySelector(`[data-station-id="${stationId}"]`);
if (stationCard) {
const statData = stats[stationId];
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();
});
});
// --- MODIFIED: Start Everything on Page Load ---
document.addEventListener('DOMContentLoaded', () => {
loadAndPollStations(); // Load once immediately
pollingInterval = setInterval(loadAndPollStations, 10000); // Then poll every 10 seconds
});
</script>
</body>
</html>