Подключены ESP32|Linux, обновлен интерфейс

This commit is contained in:
2026-09-11 20:53:38 +03:00
parent 9fce687690
commit e7a01aaeda
23 changed files with 477 additions and 647 deletions
+228 -36
View File
@@ -3,12 +3,21 @@
#include "ping.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define PING_FORM_MAX_LENGTH (DEVICE_LIST_MAX_SIZE * (4 + ETH_HWADDR_LEN * 2 + 1) - 1)
static const char *TAG = "web_server";
static esp_err_t main_page_handler(httpd_req_t *req);
static esp_err_t ping_handler(httpd_req_t *req);
static esp_err_t redirect_to_main(httpd_req_t *req);
static void format_last_seen(TickType_t last_seen, char *text, size_t size);
static esp_err_t send_page_header(httpd_req_t *req);
static esp_err_t send_device_table(httpd_req_t *req);
static esp_err_t send_device_row(httpd_req_t *req, const device_info_t *device);
@@ -18,7 +27,7 @@ static httpd_handle_t start_webserver(void);
static void register_http_handlers(httpd_handle_t server);
static const httpd_uri_t main_uri = {.uri = "/", .method = HTTP_GET, .handler = main_page_handler, .user_ctx = NULL};
static const httpd_uri_t ping_uri = {.uri = "/ping", .method = HTTP_GET, .handler = ping_handler, .user_ctx = NULL};
static const httpd_uri_t main_post_uri = {.uri = "/", .method = HTTP_POST, .handler = ping_handler, .user_ctx = NULL};
void web_server_init(void)
{
@@ -55,18 +64,22 @@ static void register_http_handlers(httpd_handle_t server)
ESP_LOGE(TAG, "Failed to register main URI");
}
err = httpd_register_uri_handler(server, &ping_uri);
err = httpd_register_uri_handler(server, &main_post_uri);
if (err != ESP_OK)
{
ESP_LOGE(TAG, "Failed to register ping URI");
ESP_LOGE(TAG, "Failed to register main POST URI");
}
}
static esp_err_t main_page_handler(httpd_req_t *req)
{
httpd_resp_set_type(req, "text/html");
esp_err_t err = httpd_resp_set_type(req, "text/html; charset=utf-8");
if (err != ESP_OK)
return err;
esp_err_t err;
err = httpd_resp_set_hdr(req, "Cache-Control", "no-store");
if (err != ESP_OK)
return err;
err = send_page_header(req);
if (err != ESP_OK)
@@ -74,7 +87,7 @@ static esp_err_t main_page_handler(httpd_req_t *req)
ESP_LOGE(TAG, "Page header isn't created");
return err;
}
err = send_device_table(req);
if (err != ESP_OK)
{
@@ -88,7 +101,7 @@ static esp_err_t main_page_handler(httpd_req_t *req)
ESP_LOGE(TAG, "Page footer isn't created");
return err;
}
return httpd_resp_sendstr_chunk(req, NULL);
}
@@ -97,37 +110,131 @@ static esp_err_t send_page_header(httpd_req_t *req)
return httpd_resp_sendstr_chunk(
req,
"<!DOCTYPE html>"
"<html>"
"<html lang=\"ru\">"
"<head>"
"<meta charset=\"UTF-8\">"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
"<title>ARP Monitoring</title>"
"<style>"
":root{color-scheme:light;--ink:#142139;--muted:#63738c;--line:#e6ecf3;--blue:#1463ef}"
"*{box-sizing:border-box}"
"body{margin:0;background:#f4f7fb;color:var(--ink);font-family:system-ui,-apple-system,'Segoe UI',sans-serif;font-size:14px;line-height:1.5}"
"main{max-width:1280px;margin:0 auto;padding:44px 28px 28px}"
"h1{margin:0;font-size:36px;line-height:1.2;letter-spacing:-1.2px;color:#0e1729}"
".intro{margin:12px 0 28px;color:var(--muted);font-size:15px}"
".summary{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px;margin-bottom:26px}"
".card{display:flex;align-items:center;gap:20px;padding:24px;background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 3px 14px #23395d04}"
".icon{display:flex;align-items:center;justify-content:center;flex:none;width:54px;height:54px;border-radius:13px;background:#eaf1ff;color:var(--blue)}"
".icon svg{width:27px;height:27px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}"
".icon.green{background:#e8f8ee;color:#16803d}.icon.red{background:#fff0f0;color:#ca3540}"
".card-label{margin:0;color:#485a73;font-weight:500}.card-value{display:block;font-size:32px;font-weight:700;line-height:1.2;letter-spacing:-.7px;margin:3px 0}"
".card-note{font-size:12px;color:var(--muted)}"
".panel{background:#fff;border:1px solid var(--line);border-radius:14px;box-shadow:0 4px 20px #23395d05;overflow:hidden}"
".panel-head{padding:22px 24px;display:flex;align-items:center;justify-content:space-between;gap:12px}"
"h2{font-size:19px;letter-spacing:-.4px;margin:0}.count{margin-left:8px;color:var(--muted);font-weight:500}"
".tag{color:#576b87;background:#f4f7fb;border:1px solid var(--line);padding:4px 10px;border-radius:6px;font-size:12px;white-space:nowrap}"
".table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse;text-align:left;white-space:nowrap}"
"th{background:#f8fafd;color:#5c6c83;font-size:12px;font-weight:600;padding:13px 24px;border-top:1px solid var(--line)}"
"td{padding:19px 24px;border-top:1px solid var(--line);color:#43536c}"
"th:first-child,td:first-child{width:76px;padding-right:12px}"
"tbody tr:hover{background:#f8fbff}tbody tr:has(input:checked){background:#f0f6ff}"
"input[type=checkbox]{width:18px;height:18px;margin:0;vertical-align:middle;accent-color:var(--blue);cursor:pointer}"
".address{font-weight:600;color:#1b2c47;font-variant-numeric:tabular-nums}"
".mac{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:13px}"
".status{display:inline-flex;align-items:center;gap:7px;border-radius:20px;padding:4px 11px;font-size:12px;font-weight:600}"
".status:before{content:'';width:7px;height:7px;border-radius:50%;background:currentColor}"
".online{background:#e7f8ec;color:#18763a}.offline{background:#ffeded;color:#ba303b}"
".panel-foot{border-top:1px solid var(--line);padding:18px 24px;display:flex;align-items:center;justify-content:space-between;gap:18px}"
".hint{margin:0;color:var(--muted);font-size:13px}"
"button{display:inline-flex;align-items:center;justify-content:center;gap:10px;border:1px solid #1059db;border-radius:8px;background:var(--blue);color:white;padding:11px 19px;line-height:1.5;font-family:inherit;font-size:14px;font-weight:600;cursor:pointer;box-shadow:0 3px 7px #1463ef20;white-space:nowrap}"
"button:hover{background:#0c53d3}button:active{transform:translateY(1px)}"
"button svg{width:16px;height:16px;fill:currentColor}"
"input:focus-visible,button:focus-visible,.table-scroll:focus-visible{outline:3px solid #87b4ff;outline-offset:4px}"
".notes{margin:18px 4px 0;color:var(--muted);font-size:12px;line-height:1.8}.notes p{margin:0}"
".empty{text-align:center;padding:44px 24px;white-space:normal;color:var(--muted)}"
"@media(max-width:760px){main{padding:28px 16px}h1{font-size:29px}.intro{font-size:14px}.summary{gap:10px}.card{padding:16px;gap:10px}.icon{display:none}.card-value{font-size:27px}.card-note{font-size:11px}.panel-head{padding:18px}.panel-foot{padding:18px;flex-wrap:wrap}th,td{padding-left:18px;padding-right:18px}}"
"@media(max-width:420px){.summary{gap:8px}.card{padding:12px 9px}.card-label{font-size:12px}.card-note{display:none}.panel-foot button{width:100%}.tag{display:none}}"
"</style>"
"</head>"
"<body>"
"<body><main>"
"<h1>ARP Monitoring</h1>"
"<p class=\"intro\">Отслеживание устройств в Вашей локальной сети с помощью ARP. Отслеживайте и диагностируйте подключенные устройства в реальном времени.</p>"
);
}
static esp_err_t send_device_table(httpd_req_t *req)
{
esp_err_t err;
const size_t count = device_list_count();
size_t online = 0, total = 0;
for (size_t index = 0; index < count; index++)
{
const device_info_t *device = device_list_get_by_index(index);
if (device != NULL)
{
total++;
if (device->active)
online++;
}
}
static const char *const summary_parts[] = {
"<section class=\"summary\" aria-label=\"Сводка устройств\">"
"<div class=\"card\"><span class=\"icon\" aria-hidden=\"true\"><svg viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"13\" rx=\"2\"/><path d=\"M12 17v4m-4 0h8\"/></svg></span>"
"<div><p class=\"card-label\">Всего устройств</p><strong class=\"card-value\">",
"</strong><span class=\"card-note\">Обнаружено по ARP</span></div></div>"
"<div class=\"card\"><span class=\"icon green\" aria-hidden=\"true\"><svg viewBox=\"0 0 24 24\"><path d=\"M3 9a14 14 0 0 1 18 0M6 12a9 9 0 0 1 12 0M9 15a5 5 0 0 1 6 0\"/><circle cx=\"12\" cy=\"19\" r=\"1\"/></svg></span>"
"<div><p class=\"card-label\">Online</p><strong class=\"card-value\">",
"</strong><span class=\"card-note\">Сейчас онлайн</span></div></div>"
"<div class=\"card\"><span class=\"icon red\" aria-hidden=\"true\"><svg viewBox=\"0 0 24 24\"><path d=\"m3 3 18 18M9 6a14 14 0 0 1 12 3M3 9l2-1m9 3a9 9 0 0 1 4 1M6 12l2-1m1 4a5 5 0 0 1 6 0\"/><circle cx=\"12\" cy=\"19\" r=\"1\"/></svg></span>"
"<div><p class=\"card-label\">Offline</p><strong class=\"card-value\">",
"</strong><span class=\"card-note\">Не ответили</span></div></div></section>"
"<section class=\"panel\" aria-labelledby=\"devices-title\"><div class=\"panel-head\"><h2 id=\"devices-title\">Устройства <span class=\"count\">(",
")</span></h2><span class=\"tag\">ARP / IPv4</span></div>"
};
const size_t values[] = {total, online, total - online, total};
for (size_t index = 0; index < sizeof(values) / sizeof(values[0]); index++)
{
err = httpd_resp_sendstr_chunk(req, summary_parts[index]);
if (err != ESP_OK)
return err;
char number[32];
int length = snprintf(number, sizeof(number), "%zu", values[index]);
if (length < 0 || (size_t)length >= sizeof(number))
return ESP_FAIL;
err = httpd_resp_sendstr_chunk(req, number);
if (err != ESP_OK)
return err;
}
err = httpd_resp_sendstr_chunk(req, summary_parts[4]);
if (err != ESP_OK)
return err;
err = httpd_resp_sendstr_chunk(
req,
"<form action=\"/ping\" method=\"get\">"
"<table border=\"1\">"
"<tr>"
"<th>Select</th>"
"<th>IP address</th>"
"<th>MAC address</th>"
"<th>Last seen</th>"
"<th>Status</th>"
"</tr>"
"<form action=\"/\" method=\"post\">"
"<div class=\"table-scroll\" tabindex=\"0\" role=\"region\" aria-label=\"Список устройств\"><table>"
"<thead><tr>"
"<th scope=\"col\">Выбор</th>"
"<th scope=\"col\">IP-адрес</th>"
"<th scope=\"col\">MAC-адрес</th>"
"<th scope=\"col\">Последний ARP</th>"
"<th scope=\"col\">Статус</th>"
"</tr></thead><tbody>"
);
if (err != ESP_OK)
return err;
for (size_t index = 0; index < device_list_count(); index++)
if (total == 0)
{
err = httpd_resp_sendstr_chunk(req,
"<tr><td colspan=\"5\" class=\"empty\">Устройства пока не обнаружены.<br>Ожидаем входящие ARP-пакеты.</td></tr>");
if (err != ESP_OK)
return err;
}
for (size_t index = 0; index < count; index++)
{
const device_info_t *current_device;
current_device = device_list_get_by_index(index);
@@ -142,30 +249,35 @@ static esp_err_t send_device_table(httpd_req_t *req)
}
return httpd_resp_sendstr_chunk(
req,
"</table>"
"<br>"
"<input type=\"submit\" value=\"Ping selected devices\">"
"</form>"
req,
"</tbody></table></div>"
"<div class=\"panel-foot\"><p class=\"hint\">Выберите устройства для проверки доступности.</p>"
"<button type=\"submit\"><svg viewBox=\"0 0 16 16\" aria-hidden=\"true\"><path d=\"M4 2v12l10-6z\"/></svg>Проверить выбранные (Ping)</button></div>"
"</form></section>"
"<div class=\"notes\"><p>Последний ARP — время с последнего ARP-пакета устройства. Значения пересчитываются при загрузке страницы.</p>"
"<p>Статус обновляется при получении ARP и проверке Ping; постоянная проверка доступности не выполняется.</p></div>"
);
}
static esp_err_t send_device_row(httpd_req_t *req, const device_info_t *device)
{
char html[256];
char html[640];
char last_seen[96];
snprintf(
format_last_seen(device->last_seen, last_seen, sizeof(last_seen));
int length = snprintf(
html,
sizeof(html),
"<tr>"
"<td>"
"<input type=\"checkbox\" name=\"mac\" value=\"%02X%02X%02X%02X%02X%02X\">"
"<input type=\"checkbox\" name=\"mac\" value=\"%02X%02X%02X%02X%02X%02X\" aria-label=\"Выбрать %u.%u.%u.%u\">"
"</td>"
"<td>%u.%u.%u.%u</td>"
"<td>%02X:%02X:%02X:%02X:%02X:%02X</td>"
"<td>%lu</td>"
"<td class=\"address\">%u.%u.%u.%u</td>"
"<td class=\"mac\">%02X:%02X:%02X:%02X:%02X:%02X</td>"
"<td>%s</td>"
"<td><span class=\"status %s\">%s</span></td>"
"</tr>",
device->mac.addr[0],
@@ -180,6 +292,11 @@ static esp_err_t send_device_row(httpd_req_t *req, const device_info_t *device)
ip4_addr3(&device->ip),
ip4_addr4(&device->ip),
ip4_addr1(&device->ip),
ip4_addr2(&device->ip),
ip4_addr3(&device->ip),
ip4_addr4(&device->ip),
device->mac.addr[0],
device->mac.addr[1],
device->mac.addr[2],
@@ -187,31 +304,106 @@ static esp_err_t send_device_row(httpd_req_t *req, const device_info_t *device)
device->mac.addr[4],
device->mac.addr[5],
(unsigned long)device->last_seen,
last_seen,
device->active ? "online" : "offline",
device->active ? "Online" : "Offline"
);
if (length < 0 || (size_t)length >= sizeof(html))
return ESP_FAIL;
return httpd_resp_sendstr_chunk(req, html);
}
static void format_last_seen(TickType_t last_seen, char *text, size_t size)
{
TickType_t elapsed = (TickType_t)(xTaskGetTickCount() - last_seen);
uint64_t seconds = (uint64_t)elapsed / configTICK_RATE_HZ;
if (seconds == 0)
snprintf(text, size, "только что");
else if (seconds < 60)
snprintf(text, size, "%" PRIu64 " с назад", seconds);
else if (seconds < 3600)
snprintf(text, size, "%" PRIu64 " мин %" PRIu64 " с назад",
seconds / 60, seconds % 60);
else if (seconds < 86400)
snprintf(text, size, "%" PRIu64 " ч %" PRIu64 " мин назад",
seconds / 3600, (seconds % 3600) / 60);
else
snprintf(text, size, "%" PRIu64 " д %" PRIu64 " ч назад",
seconds / 86400, (seconds % 86400) / 3600);
}
static esp_err_t send_page_footer(httpd_req_t *req)
{
return httpd_resp_sendstr_chunk(
req,
"</body>"
"</main></body>"
"</html>"
);
}
static esp_err_t ping_handler(httpd_req_t *req)
{
char query[256];
if (req->content_len == 0)
return redirect_to_main(req);
if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK)
if (req->content_len > PING_FORM_MAX_LENGTH)
{
ping_selected_devices(query);
httpd_resp_set_status(req, "413 Payload Too Large");
httpd_resp_sendstr(req, "Too many selected devices");
return ESP_FAIL;
}
return main_page_handler(req);
char *body = malloc(req->content_len + 1);
if (body == NULL)
{
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Not enough memory");
return ESP_FAIL;
}
size_t received = 0;
while (received < req->content_len)
{
int result = httpd_req_recv(req, body + received, req->content_len - received);
if (result <= 0)
{
free(body);
if (result == HTTPD_SOCK_ERR_TIMEOUT)
httpd_resp_send_err(req, HTTPD_408_REQ_TIMEOUT, "Request timed out");
return ESP_FAIL;
}
received += (size_t)result;
}
if (memchr(body, '\0', received) != NULL)
{
free(body);
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Invalid form data");
}
body[received] = '\0';
ping_selected_devices(body);
free(body);
return redirect_to_main(req);
}
static esp_err_t redirect_to_main(httpd_req_t *req)
{
esp_err_t err = httpd_resp_set_status(req, "303 See Other");
if (err != ESP_OK)
return err;
err = httpd_resp_set_hdr(req, "Location", "/");
if (err != ESP_OK)
return err;
err = httpd_resp_set_hdr(req, "Cache-Control", "no-store");
if (err != ESP_OK)
return err;
return httpd_resp_sendstr(req, "");
}