/ firmware / data / index.html
index.html
  1  <!DOCTYPE html>
  2  <html lang="en">
  3  
  4  <head>
  5      <meta charset="utf-8" />
  6      <meta name="viewport" content="width=device-width, initial-scale=1" />
  7      <link rel="icon" type="image/svg+xml" href="/symbol.svg" />
  8      <title>Ceratina Captive Portal</title>
  9      <style>
 10          :root {
 11              --background: oklch(0.14 0.01 40);
 12              --foreground: oklch(0.84 0.12 82);
 13              --card: oklch(0.18 0.02 40);
 14              --primary: oklch(0.76 0.15 80);
 15              --primary-foreground: oklch(0.18 0.02 40);
 16              --secondary: oklch(0.16 0.012 36);
 17              --muted-foreground: oklch(0.66 0.07 78);
 18              --accent: oklch(0.3 0.06 72);
 19              --destructive: oklch(0.62 0.22 27);
 20              --border: oklch(0.36 0.05 70 / 0.6);
 21              --radius: 14px;
 22              --success: oklch(0.65 0.17 145);
 23              --shell-glow: 0 0 0 1px oklch(0.42 0.06 72 / 0.2), 0 18px 60px oklch(0.28 0.07 74 / 0.18);
 24              --row-highlight: oklch(0.22 0.04 145 / 0.35);
 25          }
 26  
 27          * { box-sizing: border-box; border-color: var(--border); }
 28          html { color-scheme: dark; }
 29  
 30          body {
 31              margin: 0;
 32              font: 15px/1.4 system-ui, sans-serif;
 33              background: var(--background);
 34              color: var(--foreground);
 35              min-height: 100vh;
 36              -webkit-font-smoothing: antialiased;
 37              background-image:
 38                  radial-gradient(circle at top, color-mix(in oklab, var(--primary) 14%, transparent) 0%, transparent 28%),
 39                  radial-gradient(circle at left 20%, color-mix(in oklab, var(--accent) 10%, transparent) 0%, transparent 22%);
 40          }
 41  
 42          ::selection {
 43              background: color-mix(in oklab, var(--primary) 32%, transparent);
 44              color: var(--foreground);
 45          }
 46  
 47          main {
 48              max-width: 800px;
 49              margin: 0 auto;
 50              padding: 16px;
 51          }
 52  
 53          .panel {
 54              background: oklch(0.18 0.02 40 / 0.92);
 55              border: 1px solid var(--border);
 56              border-radius: var(--radius);
 57              backdrop-filter: blur(4px);
 58              -webkit-backdrop-filter: blur(4px);
 59              box-shadow: var(--shell-glow);
 60              overflow: hidden;
 61          }
 62  
 63          /* ── Header ── */
 64  
 65          .header {
 66              display: flex;
 67              align-items: center;
 68              gap: 10px;
 69              padding: 14px 16px;
 70              border-bottom: 1px solid var(--border);
 71              flex-wrap: wrap;
 72          }
 73  
 74          .header h1 {
 75              margin: 0;
 76              font-size: 18px;
 77              font-weight: 700;
 78              color: var(--foreground);
 79          }
 80  
 81          .badge {
 82              display: inline-flex;
 83              align-items: center;
 84              gap: 6px;
 85              padding: 4px 12px;
 86              border-radius: 999px;
 87              border: 1px solid var(--border);
 88              background: var(--secondary);
 89              font-size: 13px;
 90              color: var(--muted-foreground);
 91              white-space: nowrap;
 92          }
 93  
 94          .badge .dot {
 95              width: 8px;
 96              height: 8px;
 97              border-radius: 50%;
 98              background: var(--success);
 99              flex-shrink: 0;
100          }
101  
102          .badge.ip { color: var(--success); }
103  
104          /* ── Status bar ── */
105  
106          .status-bar {
107              padding: 10px 16px;
108              border-bottom: 1px solid var(--border);
109              font-size: 14px;
110              color: var(--muted-foreground);
111              text-align: center;
112          }
113  
114          .status-bar.ok { color: oklch(0.78 0.12 150); }
115          .status-bar.err { color: oklch(0.75 0.14 25); }
116  
117          /* ── Connect form ── */
118  
119          .connect-row {
120              display: grid;
121              grid-template-columns: 1fr 1fr auto;
122              gap: 10px;
123              padding: 12px 16px;
124              border-bottom: 1px solid var(--border);
125              align-items: center;
126          }
127  
128          .connect-row input {
129              width: 100%;
130              padding: 9px 12px;
131              border-radius: 8px;
132              border: 1px solid var(--border);
133              background: oklch(0.14 0.01 40 / 0.7);
134              color: var(--foreground);
135              font: inherit;
136              outline: none;
137              transition: border-color 0.3s ease;
138          }
139  
140          .connect-row input::placeholder { color: var(--muted-foreground); }
141          .connect-row input:hover { border-color: oklch(0.76 0.15 80 / 0.45); }
142          .connect-row input:focus {
143              border-color: oklch(0.76 0.15 80 / 0.6);
144              box-shadow: 0 0 0 2px oklch(0.76 0.15 80 / 0.2);
145          }
146  
147          .btn-connect {
148              display: inline-flex;
149              align-items: center;
150              gap: 6px;
151              padding: 9px 18px;
152              border: 1px solid var(--border);
153              border-radius: 8px;
154              background: oklch(0.14 0.01 40 / 0.7);
155              color: var(--foreground);
156              font: inherit;
157              cursor: pointer;
158              white-space: nowrap;
159              transition: all 0.3s ease;
160          }
161  
162          .btn-connect:hover {
163              border-color: oklch(0.76 0.15 80 / 0.5);
164              background: oklch(0.3 0.06 72 / 0.45);
165          }
166  
167          .btn-connect:disabled { opacity: 0.6; cursor: default; }
168  
169          /* ── Network table ── */
170  
171          .net-table {
172              width: 100%;
173              border-collapse: collapse;
174              font-size: 14px;
175          }
176  
177          .net-table thead th {
178              text-align: left;
179              padding: 10px 16px;
180              font-size: 11px;
181              font-weight: 600;
182              text-transform: uppercase;
183              letter-spacing: 0.05em;
184              color: var(--muted-foreground);
185              border-bottom: 1px solid var(--border);
186          }
187  
188          .net-table tbody td {
189              padding: 12px 16px;
190              border-bottom: 1px solid oklch(0.36 0.05 70 / 0.25);
191              vertical-align: middle;
192          }
193  
194          .net-table tbody tr:last-child td { border-bottom: none; }
195  
196          .net-table tbody tr:hover {
197              background: oklch(0.3 0.06 72 / 0.15);
198          }
199  
200          .net-table tbody tr.connected {
201              background: var(--row-highlight);
202          }
203  
204          .ssid-link {
205              color: var(--primary);
206              text-decoration: underline;
207              text-underline-offset: 3px;
208              cursor: pointer;
209              font-weight: 500;
210          }
211  
212          .ssid-link:hover { color: oklch(0.82 0.13 82); }
213  
214          .ssid-cell {
215              display: flex;
216              align-items: center;
217              gap: 8px;
218              flex-wrap: wrap;
219          }
220  
221          .empty-row td {
222              text-align: center;
223              color: var(--muted-foreground);
224              padding: 20px 16px;
225          }
226  
227          /* ── Responsive ── */
228  
229          @media (max-width: 560px) {
230              .connect-row {
231                  grid-template-columns: 1fr;
232              }
233  
234              .net-table { font-size: 13px; }
235              .net-table thead th, .net-table tbody td { padding: 10px 12px; }
236              .header { gap: 8px; }
237          }
238      </style>
239  </head>
240  
241  <body>
242      <main>
243          <div class="panel">
244              <div class="header">
245                  <h1>Network</h1>
246                  <div style="flex:1"></div>
247                  <span class="badge" id="apBadge">--</span>
248                  <span class="badge" id="apIpBadge">--</span>
249                  <span class="badge" id="uptimeBadge">--</span>
250              </div>
251  
252              <form id="connectForm" class="connect-row">
253                  <input id="ssidInput" name="ssid" type="text" placeholder="SSID" autocomplete="username" />
254                  <input id="passwordInput" name="password" type="password"
255                      placeholder="Password (blank for open)" autocomplete="current-password" />
256                  <button id="connectButton" class="btn-connect" type="submit">&#x1F4F6; Connect</button>
257              </form>
258  
259              <div class="status-bar" id="statusBar"></div>
260  
261              <table class="net-table">
262                  <thead>
263                      <tr>
264                          <th id="ssidHeader">SSID</th>
265                          <th>RSSI</th>
266                          <th>Channel</th>
267                          <th>Security</th>
268                      </tr>
269                  </thead>
270                  <tbody id="networkBody">
271                      <tr class="empty-row"><td colspan="4">No scan results yet.</td></tr>
272                  </tbody>
273              </table>
274          </div>
275      </main>
276  
277      <script>
278          const ssidInput = document.getElementById("ssidInput");
279          const passwordInput = document.getElementById("passwordInput");
280          const connectButton = document.getElementById("connectButton");
281          const connectForm = document.getElementById("connectForm");
282          const statusBar = document.getElementById("statusBar");
283          const networkBody = document.getElementById("networkBody");
284          const apBadge = document.getElementById("apBadge");
285          const apIpBadge = document.getElementById("apIpBadge");
286          const uptimeBadge = document.getElementById("uptimeBadge");
287          const ssidHeader = document.getElementById("ssidHeader");
288  
289          let connectedSsid = "";
290          let connectedIp = "";
291  
292          const text = (v, fb = "--") => {
293              if (v === null || v === undefined) return fb;
294              const s = String(v).trim();
295              return s || fb;
296          };
297  
298          const uptime = (sec) => {
299              const t = Math.max(0, Number(sec) || 0);
300              const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = Math.floor(t % 60);
301              if (h > 0) return `${h}h ${m}m`;
302              if (m > 0) return `${m}m ${s}s`;
303              return `${s}s`;
304          };
305  
306          const setStatus = (msg, type = "") => {
307              statusBar.textContent = msg;
308              statusBar.className = "status-bar" + (type === "ok" ? " ok" : type === "err" ? " err" : "");
309              statusBar.style.display = msg ? "" : "none";
310          };
311  
312          const renderNetworks = (networks) => {
313              networkBody.innerHTML = "";
314  
315              if (!Array.isArray(networks) || networks.length === 0) {
316                  networkBody.innerHTML = '<tr class="empty-row"><td colspan="4">No networks found.</td></tr>';
317                  return;
318              }
319  
320              networks.forEach((net) => {
321                  const tr = document.createElement("tr");
322                  const name = text(net.ssid, "(hidden)");
323                  const isConnected = connectedSsid && net.ssid === connectedSsid;
324  
325                  if (isConnected) tr.classList.add("connected");
326  
327                  const security = net.open ? "open" : text(net.encryption, "secured");
328                  const ipBadge = isConnected && connectedIp
329                      ? `<span class="badge ip"><span class="dot"></span>${connectedIp}</span>`
330                      : "";
331  
332                  tr.innerHTML = `
333                      <td><div class="ssid-cell"><span class="ssid-link">${name}</span>${ipBadge}</div></td>
334                      <td>${typeof net.rssi === "number" ? net.rssi : "--"}</td>
335                      <td>${text(net.channel, "--")}</td>
336                      <td>${security}</td>
337                  `;
338  
339                  tr.querySelector(".ssid-link").addEventListener("click", () => {
340                      ssidInput.value = net.ssid || "";
341                      if (net.open) passwordInput.value = "";
342                      ssidInput.focus();
343                  });
344  
345                  networkBody.appendChild(tr);
346              });
347          };
348  
349          const refreshDeviceStatus = async () => {
350              try {
351                  const [wRes, dRes] = await Promise.all([
352                      fetch("/api/wireless/status", { cache: "no-store" }),
353                      fetch("/api/system/device/status", { cache: "no-store" })
354                  ]);
355  
356                  if (!wRes.ok || !dRes.ok) throw new Error();
357  
358                  const w = (await wRes.json()).data || {};
359                  const d = (await dRes.json()).data || {};
360                  const device = d.device || {};
361                  const runtime = d.runtime || {};
362  
363                  apBadge.textContent = w.ap_active ? text(w.ap_ssid, "AP") : "AP inactive";
364                  apIpBadge.textContent = text(w.ap_ipv4, "192.168.4.1");
365                  uptimeBadge.textContent = "\u23F1 " + uptime(runtime.uptime_seconds);
366  
367                  connectedSsid = w.connected ? (w.sta_ssid || "") : "";
368                  connectedIp = w.connected ? (w.sta_ipv4 || "") : "";
369              } catch {
370                  apBadge.textContent = "--";
371                  apIpBadge.textContent = "--";
372              }
373          };
374  
375          const scanNetworks = async () => {
376              connectButton.disabled = true;
377              setStatus("Scanning\u2026");
378  
379              try {
380                  const res = await fetch("/api/wireless/actions/scan", {
381                      method: "POST",
382                      headers: { "Content-Type": "application/json" },
383                      body: "{}"
384                  });
385  
386                  if (!res.ok) throw new Error(`HTTP ${res.status}`);
387  
388                  const networks = (await res.json()).data?.networks || [];
389                  renderNetworks(networks);
390                  ssidHeader.textContent = networks.length ? `SSID (${networks.length})` : "SSID";
391                  setStatus("");
392              } catch {
393                  renderNetworks([]);
394                  setStatus("Scan failed.", "err");
395              } finally {
396                  connectButton.disabled = false;
397              }
398          };
399  
400          connectForm.addEventListener("submit", async (e) => {
401              e.preventDefault();
402              const ssid = ssidInput.value.trim();
403              if (!ssid) { setStatus("Enter an SSID first.", "err"); ssidInput.focus(); return; }
404  
405              connectButton.disabled = true;
406              setStatus(`Connecting to ${ssid}\u2026`);
407  
408              try {
409                  const res = await fetch("/api/wireless/actions/connect", {
410                      method: "POST",
411                      headers: { "Content-Type": "application/json" },
412                      body: JSON.stringify({ ssid, password: passwordInput.value })
413                  });
414  
415                  const payload = await res.json();
416                  if (!res.ok) throw new Error(payload.error?.message || `HTTP ${res.status}`);
417  
418                  await refreshDeviceStatus();
419  
420                  if (payload.ok) {
421                      const ip = payload.data?.sta_ipv4 ? ` \u2014 ${payload.data.sta_ipv4}` : "";
422                      setStatus(`Connected to ${ssid}${ip}`, "ok");
423                      scanNetworks();
424                  } else {
425                      setStatus("Connection failed. Check password.", "err");
426                  }
427              } catch (err) {
428                  setStatus(`Failed: ${err.message}`, "err");
429              } finally {
430                  connectButton.disabled = false;
431              }
432          });
433  
434          refreshDeviceStatus();
435          scanNetworks();
436          setInterval(refreshDeviceStatus, 5000);
437      </script>
438  </body>
439  
440  </html>