409 lines
17 KiB
C
409 lines
17 KiB
C
#include "web_server.h"
|
||
#include "device_list.h"
|
||
#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);
|
||
static esp_err_t send_page_footer(httpd_req_t *req);
|
||
|
||
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 main_post_uri = {.uri = "/", .method = HTTP_POST, .handler = ping_handler, .user_ctx = NULL};
|
||
|
||
void web_server_init(void)
|
||
{
|
||
httpd_handle_t server_handle = start_webserver();
|
||
|
||
if (server_handle != NULL)
|
||
{
|
||
register_http_handlers(server_handle);
|
||
}
|
||
}
|
||
|
||
static httpd_handle_t start_webserver(void)
|
||
{
|
||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||
httpd_handle_t server = NULL;
|
||
|
||
if (httpd_start(&server, &config) == ESP_OK)
|
||
{
|
||
ESP_LOGI(TAG, "HTTP server started");
|
||
}
|
||
else
|
||
{
|
||
ESP_LOGE(TAG, "Failed to start http server");
|
||
}
|
||
|
||
return server;
|
||
}
|
||
|
||
static void register_http_handlers(httpd_handle_t server)
|
||
{
|
||
esp_err_t err = httpd_register_uri_handler(server, &main_uri);
|
||
if (err != ESP_OK)
|
||
{
|
||
ESP_LOGE(TAG, "Failed to register main URI");
|
||
}
|
||
|
||
err = httpd_register_uri_handler(server, &main_post_uri);
|
||
if (err != ESP_OK)
|
||
{
|
||
ESP_LOGE(TAG, "Failed to register main POST URI");
|
||
}
|
||
}
|
||
|
||
static esp_err_t main_page_handler(httpd_req_t *req)
|
||
{
|
||
esp_err_t err = httpd_resp_set_type(req, "text/html; charset=utf-8");
|
||
if (err != ESP_OK)
|
||
return 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)
|
||
{
|
||
ESP_LOGE(TAG, "Page header isn't created");
|
||
return err;
|
||
}
|
||
|
||
err = send_device_table(req);
|
||
if (err != ESP_OK)
|
||
{
|
||
ESP_LOGE(TAG, "Table isn't created");
|
||
return err;
|
||
}
|
||
|
||
err = send_page_footer(req);
|
||
if (err != ESP_OK)
|
||
{
|
||
ESP_LOGE(TAG, "Page footer isn't created");
|
||
return err;
|
||
}
|
||
|
||
return httpd_resp_sendstr_chunk(req, NULL);
|
||
}
|
||
|
||
static esp_err_t send_page_header(httpd_req_t *req)
|
||
{
|
||
return httpd_resp_sendstr_chunk(
|
||
req,
|
||
"<!DOCTYPE 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><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=\"/\" 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;
|
||
|
||
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);
|
||
|
||
if (current_device == NULL)
|
||
continue;
|
||
|
||
err = send_device_row(req, current_device);
|
||
|
||
if (err != ESP_OK)
|
||
return err;
|
||
}
|
||
|
||
return httpd_resp_sendstr_chunk(
|
||
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[640];
|
||
char last_seen[96];
|
||
|
||
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\" aria-label=\"Выбрать %u.%u.%u.%u\">"
|
||
"</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],
|
||
device->mac.addr[1],
|
||
device->mac.addr[2],
|
||
device->mac.addr[3],
|
||
device->mac.addr[4],
|
||
device->mac.addr[5],
|
||
|
||
ip4_addr1(&device->ip),
|
||
ip4_addr2(&device->ip),
|
||
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],
|
||
device->mac.addr[3],
|
||
device->mac.addr[4],
|
||
device->mac.addr[5],
|
||
|
||
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,
|
||
"</main></body>"
|
||
"</html>"
|
||
);
|
||
}
|
||
|
||
static esp_err_t ping_handler(httpd_req_t *req)
|
||
{
|
||
if (req->content_len == 0)
|
||
return redirect_to_main(req);
|
||
|
||
if (req->content_len > PING_FORM_MAX_LENGTH)
|
||
{
|
||
httpd_resp_set_status(req, "413 Payload Too Large");
|
||
httpd_resp_sendstr(req, "Too many selected devices");
|
||
return ESP_FAIL;
|
||
}
|
||
|
||
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, "");
|
||
} |