Files
arp_monitoring/components/web_server/web_server.c
T

409 lines
17 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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, "");
}