From abb2c9838e5ac5166a8f39462e09f0f50d9204f7 Mon Sep 17 00:00:00 2001 From: Kristjan Komlosi Date: Sat, 7 Feb 2026 23:20:38 +0100 Subject: [PATCH] frontend test --- app.js | 348 +++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 120 ++++++++++++++++++ styles.css | 359 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 827 insertions(+) create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css diff --git a/app.js b/app.js new file mode 100644 index 0000000..b16075f --- /dev/null +++ b/app.js @@ -0,0 +1,348 @@ +const BASE_URL = "http://localhost:8080"; +const ACTION_WRITE = "write"; + +const state = { + token: localStorage.getItem("lambdaiot.token") || "", + devices: [], + sensors: [], + actors: [], + selectedDeviceId: null, + readingsLimit: 50, +}; + +const elements = { + authStatus: document.getElementById("authStatus"), + authMessage: document.getElementById("authMessage"), + loginForm: document.getElementById("loginForm"), + logoutButton: document.getElementById("logoutButton"), + deviceList: document.getElementById("deviceList"), + deviceMeta: document.getElementById("deviceMeta"), + sensorList: document.getElementById("sensorList"), + actorList: document.getElementById("actorList"), + sensorNote: document.getElementById("sensorNote"), + actorNote: document.getElementById("actorNote"), + refreshDevices: document.getElementById("refreshDevices"), +}; + +const templates = { + sensorCard: document.getElementById("sensorCardTemplate"), + actorCard: document.getElementById("actorCardTemplate"), + readingRow: document.getElementById("readingRowTemplate"), +}; + +function setAuthStatus(message) { + elements.authStatus.textContent = message; +} + +function setAuthMessage(message, isError = false) { + elements.authMessage.textContent = message; + elements.authMessage.style.color = isError ? "#b0412e" : "var(--muted)"; +} + +function updateAuthUI() { + if (state.token) { + setAuthStatus("Signed in"); + setAuthMessage("Token stored locally."); + } else { + setAuthStatus("Not signed in"); + setAuthMessage("Login required for sensors, actors, and readings."); + } +} + +async function apiFetch(path, options = {}, authRequired = true) { + const headers = new Headers(options.headers || {}); + if (authRequired) { + if (!state.token) { + throw new Error("Missing JWT token."); + } + headers.set("Authorization", `Bearer ${state.token}`); + } + if (options.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const response = await fetch(`${BASE_URL}${path}`, { + ...options, + headers, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `Request failed with status ${response.status}`); + } + + if (response.status === 204) { + return null; + } + + return response.json(); +} + +async function loadDevices() { + elements.deviceList.innerHTML = ""; + state.devices = []; + try { + const data = await apiFetch("/devices", {}, false); + state.devices = Array.isArray(data) ? data : []; + renderDeviceList(); + } catch (error) { + elements.deviceList.innerHTML = `
  • Failed to load devices: ${error.message}
  • `; + } +} + +function renderDeviceList() { + elements.deviceList.innerHTML = ""; + if (state.devices.length === 0) { + elements.deviceList.innerHTML = "
  • No devices found.
  • "; + return; + } + + state.devices.forEach((device) => { + const item = document.createElement("li"); + item.classList.toggle("active", device.id === state.selectedDeviceId); + item.innerHTML = ` +
    ${device.name}
    +
    ${device.location || "No location"}
    + `; + item.addEventListener("click", () => selectDevice(device)); + elements.deviceList.appendChild(item); + }); +} + +function selectDevice(device) { + state.selectedDeviceId = device.id; + renderDeviceList(); + elements.deviceMeta.textContent = `${device.name} • ${device.description || "No description"}`; + loadDeviceDetails(device.id); +} + +async function loadDeviceDetails(deviceId) { + elements.sensorList.innerHTML = ""; + elements.actorList.innerHTML = ""; + elements.sensorNote.textContent = "Loading sensors..."; + elements.actorNote.textContent = "Loading actors..."; + + if (!state.token) { + elements.sensorNote.textContent = "Login required to view sensors."; + elements.actorNote.textContent = "Login required to view actors."; + return; + } + + try { + const [sensors, actors] = await Promise.all([ + apiFetch("/sensors"), + apiFetch("/actors"), + ]); + + state.sensors = (sensors || []).filter((sensor) => sensor.device_id === deviceId); + state.actors = (actors || []).filter((actor) => actor.device_id === deviceId); + + await renderSensors(); + renderActors(); + } catch (error) { + elements.sensorNote.textContent = `Failed to load sensors: ${error.message}`; + elements.actorNote.textContent = `Failed to load actors: ${error.message}`; + } +} + +async function renderSensors() { + elements.sensorList.innerHTML = ""; + if (state.sensors.length === 0) { + elements.sensorNote.textContent = "No sensors for this device."; + return; + } + elements.sensorNote.textContent = `${state.sensors.length} sensor(s)`; + + const readingsPromises = state.sensors.map(async (sensor) => { + try { + const readings = await apiFetch( + `/sensor-readings?sensor_id=${sensor.id}&limit=${state.readingsLimit}&page=0` + ); + return { sensorId: sensor.id, readings: readings || [] }; + } catch (error) { + return { sensorId: sensor.id, readings: [], error: error.message }; + } + }); + + const readingsResults = await Promise.all(readingsPromises); + const readingsMap = new Map(readingsResults.map((entry) => [entry.sensorId, entry])); + + state.sensors.forEach((sensor) => { + const card = templates.sensorCard.content.cloneNode(true); + const title = card.querySelector(".card-title"); + const subtitle = card.querySelector(".card-subtitle"); + const pill = card.querySelector(".pill"); + const list = card.querySelector(".readings-list"); + + title.textContent = sensor.name; + subtitle.textContent = `${sensor.type} • ${sensor.id}`; + pill.textContent = sensor.data_type_id === 1 ? "bool" : "float"; + + const readingsEntry = readingsMap.get(sensor.id); + if (!readingsEntry || readingsEntry.readings.length === 0) { + list.textContent = readingsEntry?.error + ? `Error: ${readingsEntry.error}` + : "No readings yet."; + } else { + readingsEntry.readings.forEach((reading) => { + const row = templates.readingRow.content.cloneNode(true); + row.querySelector(".reading-value").textContent = reading.value; + row.querySelector(".reading-time").textContent = new Date( + reading.value_at + ).toLocaleString(); + list.appendChild(row); + }); + } + + elements.sensorList.appendChild(card); + }); +} + +function renderActors() { + elements.actorList.innerHTML = ""; + if (state.actors.length === 0) { + elements.actorNote.textContent = "No actors for this device."; + return; + } + elements.actorNote.textContent = `${state.actors.length} actor(s)`; + + state.actors.forEach((actor) => { + const card = templates.actorCard.content.cloneNode(true); + const cardRoot = card.querySelector(".card"); + const title = card.querySelector(".card-title"); + const subtitle = card.querySelector(".card-subtitle"); + const pill = card.querySelector(".pill"); + const body = card.querySelector(".card-body"); + + if (cardRoot) { + cardRoot.dataset.actorId = actor.id; + } + + title.textContent = actor.name; + subtitle.textContent = `${actor.type} • ${actor.id}`; + pill.textContent = actor.data_type_id === 1 ? "bool" : "float"; + + const controls = document.createElement("div"); + controls.className = "actor-controls"; + + if (actor.data_type_id === 1) { + const onButton = document.createElement("button"); + onButton.textContent = "On"; + onButton.addEventListener("click", () => writeActor(actor, 1)); + + const offButton = document.createElement("button"); + offButton.className = "secondary"; + offButton.textContent = "Off"; + offButton.addEventListener("click", () => writeActor(actor, 0)); + + controls.appendChild(onButton); + controls.appendChild(offButton); + } else { + const input = document.createElement("input"); + input.type = "number"; + input.step = "any"; + input.placeholder = "Value"; + + const setButton = document.createElement("button"); + setButton.textContent = "Set"; + setButton.addEventListener("click", () => { + const value = Number(input.value); + if (Number.isNaN(value)) { + showActorResponse(body, "Enter a numeric value.", true); + return; + } + writeActor(actor, value); + }); + + controls.appendChild(input); + controls.appendChild(setButton); + } + + body.appendChild(controls); + + const response = document.createElement("div"); + response.className = "actor-response"; + body.appendChild(response); + + elements.actorList.appendChild(card); + }); +} + +function showActorResponse(container, message, isError = false) { + if (!container) return; + const response = container.querySelector(".actor-response"); + if (!response) return; + response.textContent = message; + response.style.color = isError ? "#b0412e" : "var(--muted)"; +} + +async function writeActor(actor, value) { + try { + const payload = { action: ACTION_WRITE, value }; + await apiFetch(`/actors/${actor.id}/write`, { + method: "POST", + body: JSON.stringify(payload), + }); + const card = findActorCard(actor.id); + showActorResponse(card, "Command published."); + } catch (error) { + const card = findActorCard(actor.id); + showActorResponse(card, `Error: ${error.message}`, true); + } +} + +function findActorCard(actorId) { + const cards = Array.from(elements.actorList.querySelectorAll(".card")); + return cards.find((card) => card.dataset.actorId === actorId); +} + +async function handleLogin(event) { + event.preventDefault(); + const username = document.getElementById("username").value.trim(); + const password = document.getElementById("password").value.trim(); + + if (!username || !password) { + setAuthMessage("Enter both username and password.", true); + return; + } + + try { + const result = await apiFetch( + "/login", + { + method: "POST", + body: JSON.stringify({ username, password }), + }, + false + ); + + state.token = result.token || ""; + localStorage.setItem("lambdaiot.token", state.token); + updateAuthUI(); + setAuthMessage("Login successful."); + + if (state.selectedDeviceId) { + loadDeviceDetails(state.selectedDeviceId); + } + } catch (error) { + setAuthMessage(`Login failed: ${error.message}`, true); + } +} + +function handleLogout() { + state.token = ""; + localStorage.removeItem("lambdaiot.token"); + updateAuthUI(); + setAuthMessage("Logged out."); + elements.sensorList.innerHTML = ""; + elements.actorList.innerHTML = ""; + elements.sensorNote.textContent = "Login required to view sensors."; + elements.actorNote.textContent = "Login required to view actors."; +} + +elements.loginForm.addEventListener("submit", handleLogin); +elements.logoutButton.addEventListener("click", handleLogout); +elements.refreshDevices.addEventListener("click", loadDevices); + +updateAuthUI(); +loadDevices(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..29c1115 --- /dev/null +++ b/index.html @@ -0,0 +1,120 @@ + + + + + + Lambda IoT Web Client + + + +
    +
    +
    +
    λ
    +
    +
    Lambda IoT
    +
    Device Control Console
    +
    +
    +
    Not signed in
    +
    + +
    + + +
    +
    +

    Device Details

    +
    Select a device
    +
    +
    +
    +
    Sign in for protected endpoints
    +
    + + + + +
    +
    +
    + +
    +
    +

    Sensors

    +
    No device selected.
    +
    +
    +
    + +
    +
    +

    Actors

    +
    No device selected.
    +
    +
    +
    +
    +
    +
    +
    + + + + + + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..9f31ca7 --- /dev/null +++ b/styles.css @@ -0,0 +1,359 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600;700&family=IBM+Plex+Mono:wght@400;600&display=swap"); + +:root { + color-scheme: light; + --bg: #f6f4f0; + --panel: #ffffff; + --ink: #1f1b16; + --muted: #5d564f; + --accent: #e4572e; + --accent-2: #1f7a8c; + --accent-3: #f4a261; + --border: rgba(31, 27, 22, 0.12); + --shadow: 0 24px 60px rgba(31, 27, 22, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Space Grotesk", "Helvetica Neue", sans-serif; + color: var(--ink); + background: radial-gradient(circle at top, #fff5e6 0%, var(--bg) 45%, #e6f0ee 100%); + min-height: 100vh; +} + +.page { + max-width: 1200px; + margin: 0 auto; + padding: 32px 24px 60px; +} + +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; + gap: 16px; +} + +.brand { + display: flex; + align-items: center; + gap: 16px; +} + +.brand-mark { + width: 54px; + height: 54px; + border-radius: 18px; + background: linear-gradient(145deg, var(--accent), var(--accent-3)); + display: grid; + place-items: center; + color: #fffaf3; + font-weight: 700; + font-size: 28px; + box-shadow: 0 12px 25px rgba(228, 87, 46, 0.25); +} + +.brand-title { + font-size: 24px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.brand-subtitle { + color: var(--muted); + font-size: 14px; +} + +.status { + padding: 10px 16px; + background: var(--panel); + border-radius: 999px; + border: 1px solid var(--border); + font-size: 13px; + color: var(--muted); + box-shadow: var(--shadow); +} + +.layout { + display: grid; + grid-template-columns: 280px 1fr; + gap: 24px; +} + +.panel { + background: var(--panel); + border-radius: 24px; + border: 1px solid var(--border); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + overflow: hidden; +} + +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 22px; + border-bottom: 1px solid var(--border); + background: linear-gradient(160deg, rgba(244, 162, 97, 0.18), rgba(31, 122, 140, 0.05)); +} + +.panel-header h2, +.panel-header h3 { + margin: 0; + font-size: 18px; +} + +.panel-body { + padding: 20px 22px; + display: flex; + flex-direction: column; + gap: 24px; +} + +.devices-panel .panel-body { + padding: 0; +} + +.device-list { + list-style: none; + margin: 0; + padding: 12px 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.device-list li { + padding: 12px 18px; + margin: 0 12px; + border-radius: 16px; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.2s ease; +} + +.device-list li:hover { + border-color: var(--accent-3); + background: rgba(244, 162, 97, 0.15); +} + +.device-list li.active { + border-color: var(--accent); + background: rgba(228, 87, 46, 0.12); +} + +.device-name { + font-weight: 600; + margin-bottom: 4px; +} + +.device-location { + font-size: 12px; + color: var(--muted); +} + +.device-meta { + font-size: 13px; + color: var(--muted); +} + +.auth-block { + background: rgba(31, 122, 140, 0.08); + border-radius: 18px; + padding: 16px; + border: 1px solid rgba(31, 122, 140, 0.2); +} + +.auth-title { + font-weight: 600; + margin-bottom: 12px; +} + +.auth-form { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 10px; +} + +.auth-form input { + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--border); + font-family: "IBM Plex Mono", "Courier New", monospace; + font-size: 13px; +} + +.auth-form button { + padding: 10px 14px; + border-radius: 12px; + border: none; + background: var(--accent); + color: #fffaf3; + font-weight: 600; + cursor: pointer; +} + +.auth-form button.ghost { + background: transparent; + border: 1px solid var(--accent); + color: var(--accent); +} + +.auth-message { + margin-top: 10px; + font-size: 12px; + color: var(--muted); +} + +.detail-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.section-header h3 { + margin: 0; + font-size: 18px; +} + +.section-note { + font-size: 12px; + color: var(--muted); +} + +.stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.card { + border-radius: 18px; + border: 1px solid var(--border); + padding: 16px; + background: #fffaf7; +} + +.card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.card-title { + font-weight: 600; + font-size: 16px; +} + +.card-subtitle { + font-size: 12px; + color: var(--muted); +} + +.pill { + padding: 6px 10px; + border-radius: 999px; + background: rgba(31, 122, 140, 0.12); + font-size: 11px; + font-family: "IBM Plex Mono", "Courier New", monospace; + color: var(--accent-2); +} + +.readings-header { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); + margin-bottom: 10px; +} + +.readings-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.reading-row { + display: flex; + justify-content: space-between; + gap: 16px; + font-family: "IBM Plex Mono", "Courier New", monospace; + font-size: 12px; +} + +.reading-time { + color: var(--muted); +} + +.actor-controls { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.actor-controls input { + padding: 8px 12px; + border-radius: 12px; + border: 1px solid var(--border); + font-family: "IBM Plex Mono", "Courier New", monospace; + font-size: 13px; + width: 120px; +} + +.actor-controls button { + padding: 8px 12px; + border-radius: 12px; + border: 1px solid transparent; + background: var(--accent-2); + color: #f7fbfb; + cursor: pointer; + font-weight: 600; +} + +.actor-controls button.secondary { + background: rgba(31, 122, 140, 0.12); + color: var(--accent-2); + border-color: rgba(31, 122, 140, 0.4); +} + +.actor-response { + margin-top: 10px; + font-size: 12px; + color: var(--muted); +} + +button.ghost { + padding: 8px 12px; + border-radius: 10px; + border: 1px solid var(--border); + background: transparent; + font-size: 12px; + cursor: pointer; +} + +@media (max-width: 960px) { + .layout { + grid-template-columns: 1fr; + } + + .devices-panel { + order: 2; + } +}