diff --git a/Makefile b/Makefile index 64548c7..a7d2254 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ PREFIX ?= /usr/local BINDIR = $(PREFIX)/bin # 源文件 -SRCS = main.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c middleware_ext.c load_balance.c grpc.c http3.c sse.c fastcgi.c fcgi_handler.c cache.c +SRCS = main.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c middleware_ext.c load_balance.c grpc.c http3.c sse.c fastcgi.c fcgi_handler.c cache.c dashboard.c OBJS = $(SRCS:.c=.o) TARGET = cocoon @@ -90,8 +90,8 @@ unit-test: $(UNIT_TEST_BINS) fi # 单元测试编译规则 -$(UNIT_TEST_DIR)/test_server: $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c sse.c fastcgi.c fcgi_handler.c cache.c $(UNITY_SRC) - $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c sse.c fastcgi.c fcgi_handler.c cache.c $(UNITY_SRC) $(LDFLAGS) +$(UNIT_TEST_DIR)/test_server: $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c sse.c fastcgi.c fcgi_handler.c cache.c dashboard.c $(UNITY_SRC) + $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_server.c server.c http.c static.c log.c config.c multipart.c tls.c http2.c access_log.c websocket.c platform.c middleware.c plugin.c proxy.c proxy_tls.c healthcheck.c sse.c fastcgi.c fcgi_handler.c cache.c dashboard.c $(UNITY_SRC) $(LDFLAGS) $(UNIT_TEST_DIR)/test_multipart: $(UNIT_TEST_DIR)/test_multipart.c multipart.c $(UNITY_SRC) $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_multipart.c multipart.c $(UNITY_SRC) -lm @@ -146,6 +146,10 @@ $(UNIT_TEST_DIR)/test_fastcgi: $(UNIT_TEST_DIR)/test_fastcgi.c fastcgi.c log.c $ $(UNIT_TEST_DIR)/test_cache: $(UNIT_TEST_DIR)/test_cache.c cache.c log.c $(UNITY_SRC) $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_cache.c cache.c log.c $(UNITY_SRC) $(LDFLAGS) +# Dashboard 测试 +$(UNIT_TEST_DIR)/test_dashboard: $(UNIT_TEST_DIR)/test_dashboard.c dashboard.c sse.c log.c $(UNITY_SRC) + $(CC) $(CFLAGS) -I. -I$(UNIT_TEST_DIR)/../unity -o $@ $(UNIT_TEST_DIR)/test_dashboard.c dashboard.c sse.c log.c $(UNITY_SRC) $(LDFLAGS) + # 安装 install: $(TARGET) install -d $(BINDIR) diff --git a/dashboard.c b/dashboard.c new file mode 100644 index 0000000..6811812 --- /dev/null +++ b/dashboard.c @@ -0,0 +1,216 @@ +/** + * dashboard.c - 内置管理 Dashboard 实现 + * + * 提供 `/_status` HTML 管理页面和 `/_status/events` SSE 实时指标流。 + * HTML 完全自包含(内联 CSS/JS),不依赖外部文件。 + * + * @author xfy + */ + +#include "dashboard.h" +#include "sse.h" +#include "log.h" +#include "platform.h" +#include "../coco/include/coco.h" +#include +#include +#include +#include + +/* 内联的 Dashboard HTML 页面 */ +static const char DASHBOARD_HTML[] = +"\n" +"\n" +"\n" +"\n" +"\n" +"Cocoon Dashboard\n" +"\n" +"\n" +"\n" +"
\n" +"

🪵 Cocoon Dashboard

\n" +"
Connecting...
\n" +"
\n" +"

Uptime

--
\n" +"

Active Connections

--
\n" +"

Total Requests

--
\n" +"

Response Distribution

--
\n" +"
" +"
\n" +"
\n" +"
\n" +"
\n" +"\n" +"\n" +""; + +/** + * dashboard_send_html - 发送 Dashboard HTML 页面 + */ +static int dashboard_send_html(int fd, bool keep_alive) { + size_t html_len = strlen(DASHBOARD_HTML); + + char header[512]; + int header_len = snprintf(header, sizeof(header), + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/html; charset=utf-8\r\n" + "Content-Length: %zu\r\n" + "Connection: %s\r\n" + "Server: Cocoon/1.0\r\n" + "\r\n", + html_len, + keep_alive ? "keep-alive" : "close"); + + if (send(fd, header, (size_t)header_len, 0) < 0) return -1; + if (send(fd, DASHBOARD_HTML, html_len, 0) < 0) return -1; + return 0; +} + +/** + * dashboard_build_metrics_json - 构建当前指标 JSON + */ +static int dashboard_build_metrics_json(char *buf, size_t buflen) { + time_t now = time(NULL); + time_t uptime = (g_server_start_time > 0) ? (now - g_server_start_time) : 0; + int active = atomic_load(&g_active_connections); + unsigned int total = atomic_load(&g_total_requests); + unsigned int r2xx = atomic_load(&g_response_2xx); + unsigned int r3xx = atomic_load(&g_response_3xx); + unsigned int r4xx = atomic_load(&g_response_4xx); + unsigned int r5xx = atomic_load(&g_response_5xx); + + return snprintf(buf, buflen, + "{" + "\"uptime\":%ld," + "\"active\":%d," + "\"max\":%u," + "\"total\":%u," + "\"r2xx\":%u," + "\"r3xx\":%u," + "\"r4xx\":%u," + "\"r5xx\":%u," + "\"version\":\"Cocoon/1.0\"" + "}", + uptime, active, g_max_connections, + total, r2xx, r3xx, r4xx, r5xx); +} + +/** + * dashboard_handle_request - 处理 /_status 页面请求 + */ +bool dashboard_handle_request(int fd, const http_request_t *req) { + if (!req || strcmp(req->path, "/_status") != 0) { + return false; + } + + if (req->method != HTTP_GET) { + const char *resp = + "HTTP/1.1 405 Method Not Allowed\r\n" + "Allow: GET\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n" + "Server: Cocoon/1.0\r\n" + "\r\n"; + send(fd, resp, strlen(resp), 0); + return true; + } + + log_info("Dashboard 页面请求 fd=%d", fd); + dashboard_send_html(fd, req->keep_alive); + return true; +} + +/** + * dashboard_sse_handle_request - 处理 /_status/events SSE 流 + * + * 每 2 秒推送一次 JSON 指标,包含心跳注释防止代理超时。 + */ +bool dashboard_sse_handle_request(int fd, const http_request_t *req) { + if (!req || strcmp(req->path, "/_status/events") != 0) { + return false; + } + + if (req->method != HTTP_GET) { + const char *resp = + "HTTP/1.1 405 Method Not Allowed\r\n" + "Allow: GET\r\n" + "Content-Length: 0\r\n" + "Connection: close\r\n" + "Server: Cocoon/1.0\r\n" + "\r\n"; + send(fd, resp, strlen(resp), 0); + return true; + } + + log_info("Dashboard SSE 连接建立 fd=%d", fd); + + if (sse_send_headers(fd) != 0) { + log_warn("Dashboard SSE 发送响应头失败 fd=%d", fd); + return true; + } + + /* 发送连接确认事件 */ + sse_send_event(fd, "connected", "{\"status\":\"ok\"}", 0); + + uint32_t tick = 0; + + while (1) { + char json[1024]; + int json_len = dashboard_build_metrics_json(json, sizeof(json)); + if (json_len > 0) { + if (sse_send_event(fd, "metrics", json, tick + 1) != 0) { + log_info("Dashboard SSE 客户端断开 fd=%d", fd); + break; + } + } + tick++; + + /* 每 3 个事件(6 秒)发送一次心跳注释 */ + if (tick % 3 == 0) { + if (sse_send_comment(fd, "heartbeat") != 0) { + break; + } + } + + /* 2 秒间隔(协程安全) */ + coco_sleep(2000); + } + + log_info("Dashboard SSE 连接结束 fd=%d", fd); + return true; +} diff --git a/dashboard.h b/dashboard.h new file mode 100644 index 0000000..4864eb8 --- /dev/null +++ b/dashboard.h @@ -0,0 +1,54 @@ +/** + * dashboard.h - 内置管理 Dashboard + * + * 提供 `/_status` HTML 管理页面和 `/_status/events` SSE 实时指标流。 + * + * @author xfy + */ + +#ifndef DASHBOARD_H +#define DASHBOARD_H + +#include +#include +#include +#include +#include "http.h" + +/* 从 server.c 暴露的指标变量 */ +extern atomic_int g_active_connections; +extern time_t g_server_start_time; +extern uint32_t g_max_connections; +extern atomic_uint g_total_requests; +extern atomic_uint g_response_2xx; +extern atomic_uint g_response_3xx; +extern atomic_uint g_response_4xx; +extern atomic_uint g_response_5xx; +extern atomic_uint g_response_200; +extern atomic_uint g_response_404; + +/** + * dashboard_handle_request - 处理内置 Dashboard 页面请求 + * + * 端点路径:/_status + * 返回自包含的 HTML 页面,通过 EventSource 连接 /_status/events 实时刷新指标。 + * + * @param fd 客户端 socket + * @param req HTTP 请求 + * @return true 请求已处理,false 非 Dashboard 请求 + */ +bool dashboard_handle_request(int fd, const http_request_t *req); + +/** + * dashboard_sse_handle_request - 处理 Dashboard SSE 实时指标流 + * + * 端点路径:/_status/events + * 以 SSE 格式每 2 秒推送一次当前服务器指标 JSON。 + * + * @param fd 客户端 socket + * @param req HTTP 请求 + * @return true 连接保持(已转入 SSE 模式),false 非 SSE 请求或错误 + */ +bool dashboard_sse_handle_request(int fd, const http_request_t *req); + +#endif /* DASHBOARD_H */ diff --git a/server.c b/server.c index 5d44294..52afc6a 100644 --- a/server.c +++ b/server.c @@ -34,6 +34,7 @@ #include "config.h" #include "fcgi_handler.h" #include "cache.h" +#include "dashboard.h" #include #include #include @@ -85,19 +86,19 @@ struct server_context { /* 内存缓存 */ cocoon_cache_t *cache; /**< 内存响应缓存(NULL 表示未启用) */ }; -static atomic_int g_active_connections = 0; /* 服务器启动时间 */ -static time_t g_server_start_time = 0; +time_t g_server_start_time = 0; /* 最大连接数(供健康检查端点使用) */ -static uint32_t g_max_connections = 0; +uint32_t g_max_connections = 0; /* Prometheus 指标计数器 */ -static atomic_uint g_total_requests = 0; -static atomic_uint g_response_2xx = 0; -static atomic_uint g_response_3xx = 0; -static atomic_uint g_response_4xx = 0; -static atomic_uint g_response_5xx = 0; -static atomic_uint g_response_200 = 0; -static atomic_uint g_response_404 = 0; +atomic_int g_active_connections = 0; +atomic_uint g_total_requests = 0; +atomic_uint g_response_2xx = 0; +atomic_uint g_response_3xx = 0; +atomic_uint g_response_4xx = 0; +atomic_uint g_response_5xx = 0; +atomic_uint g_response_200 = 0; +atomic_uint g_response_404 = 0; /** * update_metrics - 更新 Prometheus 指标计数器 @@ -473,6 +474,26 @@ static bool handle_request(connection_t *conn, const char *root_dir) { } } + /* Dashboard SSE 端点(必须在 /_status 之前检查) */ + if (dashboard_sse_handle_request(conn->fd, &req)) { + conn->response_status = 200; + update_metrics(conn->response_status); + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); + http_request_free(&req); + return true; /* SSE 保持连接 */ + } + + /* Dashboard 页面端点 */ + if (dashboard_handle_request(conn->fd, &req)) { + conn->response_status = 200; + update_metrics(conn->response_status); + access_log_write((struct sockaddr *)&conn->client_addr, conn->addr_len, + &req, conn->response_status, -1); + http_request_free(&req); + return req.keep_alive; + } + /* SSE 端点 */ if (sse_handle_request(conn->fd, &req)) { conn->response_status = 200; diff --git a/tests/unit/test_dashboard.c b/tests/unit/test_dashboard.c new file mode 100644 index 0000000..7eb0eb3 --- /dev/null +++ b/tests/unit/test_dashboard.c @@ -0,0 +1,110 @@ +#include "unity.h" +#include "dashboard.h" +#include "http.h" +#include + +/* 为测试提供全局指标变量的定义(server.c 中定义的副本) */ +atomic_int g_active_connections = 0; +time_t g_server_start_time = 0; +uint32_t g_max_connections = 0; +atomic_uint g_total_requests = 0; +atomic_uint g_response_2xx = 0; +atomic_uint g_response_3xx = 0; +atomic_uint g_response_4xx = 0; +atomic_uint g_response_5xx = 0; +atomic_uint g_response_200 = 0; +atomic_uint g_response_404 = 0; + +/* ===== dashboard_handle_request 路径匹配测试 ===== */ + +void test_dashboard_not_match(void) { + http_request_t req = {0}; + req.method = HTTP_GET; + strncpy(req.path, "/index.html", sizeof(req.path) - 1); + /* fd = -1 不会实际发送,但匹配逻辑不受影响 */ + TEST_ASSERT_FALSE(dashboard_handle_request(-1, &req)); +} + +void test_dashboard_match_status(void) { + http_request_t req = {0}; + req.method = HTTP_GET; + strncpy(req.path, "/_status", sizeof(req.path) - 1); + req.keep_alive = false; + /* fd = -1 会导致 send 失败,但函数应返回 true(请求已识别) */ + TEST_ASSERT_TRUE(dashboard_handle_request(-1, &req)); +} + +void test_dashboard_post_not_allowed(void) { + http_request_t req = {0}; + req.method = HTTP_POST; + strncpy(req.path, "/_status", sizeof(req.path) - 1); + /* 非 GET 应返回 405,但函数仍返回 true(请求已处理) */ + TEST_ASSERT_TRUE(dashboard_handle_request(-1, &req)); +} + +void test_dashboard_null_req(void) { + TEST_ASSERT_FALSE(dashboard_handle_request(-1, NULL)); +} + +/* ===== dashboard_sse_handle_request 路径匹配测试 ===== */ + +void test_dashboard_sse_not_match(void) { + http_request_t req = {0}; + req.method = HTTP_GET; + strncpy(req.path, "/_sse", sizeof(req.path) - 1); + TEST_ASSERT_FALSE(dashboard_sse_handle_request(-1, &req)); +} + +void test_dashboard_sse_match_events(void) { + http_request_t req = {0}; + req.method = HTTP_GET; + strncpy(req.path, "/_status/events", sizeof(req.path) - 1); + /* fd = -1 会导致 send 失败,快速退出循环,返回 true */ + TEST_ASSERT_TRUE(dashboard_sse_handle_request(-1, &req)); +} + +void test_dashboard_sse_post_not_allowed(void) { + http_request_t req = {0}; + req.method = HTTP_POST; + strncpy(req.path, "/_status/events", sizeof(req.path) - 1); + TEST_ASSERT_TRUE(dashboard_sse_handle_request(-1, &req)); +} + +void test_dashboard_sse_null_req(void) { + TEST_ASSERT_FALSE(dashboard_sse_handle_request(-1, NULL)); +} + +/* ===== 指标变量存在性验证 ===== */ + +void test_metrics_variables_exist(void) { + /* 确认全局指标变量可被访问(链接期检查) */ + TEST_ASSERT_EQUAL_INT(0, atomic_load(&g_active_connections)); + TEST_ASSERT_EQUAL_INT(0, atomic_load(&g_total_requests)); + TEST_ASSERT_EQUAL_INT(0, atomic_load(&g_response_2xx)); + TEST_ASSERT_EQUAL_INT(0, atomic_load(&g_response_3xx)); + TEST_ASSERT_EQUAL_INT(0, atomic_load(&g_response_4xx)); + TEST_ASSERT_EQUAL_INT(0, atomic_load(&g_response_5xx)); + TEST_ASSERT_EQUAL_INT(0, atomic_load(&g_response_200)); + TEST_ASSERT_EQUAL_INT(0, atomic_load(&g_response_404)); + TEST_ASSERT_EQUAL_UINT(0, g_max_connections); + TEST_ASSERT_EQUAL_INT64(0, g_server_start_time); +} + +void setUp(void) {} +void tearDown(void) {} + +int main(void) { + UNITY_BEGIN(); + + RUN_TEST(test_dashboard_not_match); + RUN_TEST(test_dashboard_match_status); + RUN_TEST(test_dashboard_post_not_allowed); + RUN_TEST(test_dashboard_null_req); + RUN_TEST(test_dashboard_sse_not_match); + RUN_TEST(test_dashboard_sse_match_events); + RUN_TEST(test_dashboard_sse_post_not_allowed); + RUN_TEST(test_dashboard_sse_null_req); + RUN_TEST(test_metrics_variables_exist); + + return UNITY_END(); +}