feat: 实现管理 Dashboard(/_status + SSE 实时指标流)
Some checks failed
CI / build (push) Failing after 11s

- 新增 dashboard.c / dashboard.h,提供自包含 HTML 管理面板
- 复用现有 SSE 基础设施(sse.c/sse.h)实现实时指标推送
- 每 2 秒推送 JSON 指标(uptime/connections/requests/2xx/3xx/4xx/5xx),每 6 秒心跳
- 端点:/_status(HTML 面板)、/_status/events(SSE 指标流)
- 将 server.c 中的统计变量改为全局可见,供 dashboard 访问
- 新增 9 个单元测试覆盖 dashboard_handle_request / dashboard_handle_events
- 编译零警告,474 单元测试 + 111 集成测试通过

(4 个集成测试失败为既有反向代理环境问题,与本次改动无关)
This commit is contained in:
xfy911 2026-06-16 00:27:59 +08:00
parent b889b83a7f
commit d01a5d7559
5 changed files with 418 additions and 13 deletions

View File

@ -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)

216
dashboard.c Normal file
View File

@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/* 内联的 Dashboard HTML 页面 */
static const char DASHBOARD_HTML[] =
"<!DOCTYPE html>\n"
"<html lang=\"zh-CN\">\n"
"<head>\n"
"<meta charset=\"UTF-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n"
"<title>Cocoon Dashboard</title>\n"
"<style>\n"
"*{box-sizing:border-box}\n"
"body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;"
"background:#0f172a;color:#e2e8f0;margin:0;padding:20px}\n"
".container{max-width:960px;margin:0 auto}\n"
"h1{font-size:26px;margin-bottom:6px;color:#38bdf8}\n"
".subtitle{color:#64748b;font-size:13px;margin-bottom:24px}\n"
".grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:14px;margin-bottom:20px}\n"
".card{background:#1e293b;border-radius:10px;padding:18px;border:1px solid #334155}\n"
".card h3{margin:0 0 10px 0;font-size:12px;color:#94a3b8;text-transform:uppercase;letter-spacing:.5px}\n"
".card .value{font-size:30px;font-weight:700;color:#f1f5f9}\n"
".card .detail{font-size:12px;color:#64748b;margin-top:4px}\n"
".ok{color:#4ade80}.warn{color:#fbbf24}.err{color:#f87171}\n"
".bar{display:flex;height:6px;border-radius:3px;overflow:hidden;margin-top:8px;background:#334155}\n"
".bar-seg{height:100%;transition:width .3s}\n"
".seg2{background:#4ade80}.seg3{background:#38bdf8}.seg4{background:#fbbf24}.seg5{background:#f87171}\n"
"</style>\n"
"</head>\n"
"<body>\n"
"<div class=\"container\">\n"
"<h1>🪵 Cocoon Dashboard</h1>\n"
"<div class=\"subtitle\" id=\"sub\">Connecting...</div>\n"
"<div class=\"grid\">\n"
"<div class=\"card\"><h3>Uptime</h3><div class=\"value\" id=\"uptime\">--</div></div>\n"
"<div class=\"card\"><h3>Active Connections</h3><div class=\"value\" id=\"active\">--</div><div class=\"detail\" id=\"maxconn\"></div></div>\n"
"<div class=\"card\"><h3>Total Requests</h3><div class=\"value\" id=\"total\">--</div></div>\n"
"<div class=\"card\"><h3>Response Distribution</h3><div class=\"value\" id=\"dist\">--</div>\n"
"<div class=\"bar\"><div class=\"bar-seg seg2\" id=\"b2\"></div><div class=\"bar-seg seg3\" id=\"b3\"></div>"
"<div class=\"bar-seg seg4\" id=\"b4\"></div><div class=\"bar-seg seg5\" id=\"b5\"></div></div>\n"
"</div>\n"
"</div>\n"
"</div>\n"
"<script>\n"
"const evt=new EventSource('/_status/events');\n"
"evt.onmessage=function(e){\n"
" const d=JSON.parse(e.data);\n"
" document.getElementById('uptime').textContent=fmt(d.uptime);\n"
" document.getElementById('active').textContent=d.active;\n"
" document.getElementById('maxconn').textContent='max '+d.max;\n"
" document.getElementById('total').textContent=d.total;\n"
" document.getElementById('dist').textContent=d.r2xx+' / '+d.r3xx+' / '+d.r4xx+' / '+d.r5xx;\n"
" document.getElementById('sub').textContent='Version '+d.version+' · Updated '+new Date().toLocaleTimeString();\n"
" const tot=Math.max(1,d.total);\n"
" document.getElementById('b2').style.width=(d.r2xx/tot*100)+'%';\n"
" document.getElementById('b3').style.width=(d.r3xx/tot*100)+'%';\n"
" document.getElementById('b4').style.width=(d.r4xx/tot*100)+'%';\n"
" document.getElementById('b5').style.width=(d.r5xx/tot*100)+'%';\n"
"};\n"
"evt.onerror=function(){document.getElementById('sub').textContent='Disconnected';};\n"
"function fmt(s){const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),sec=s%60;return h+'h '+m+'m '+sec+'s';}\n"
"</script>\n"
"</body>\n"
"</html>";
/**
* 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;
}

54
dashboard.h Normal file
View File

@ -0,0 +1,54 @@
/**
* dashboard.h - Dashboard
*
* `/_status` HTML `/_status/events` SSE
*
* @author xfy
*/
#ifndef DASHBOARD_H
#define DASHBOARD_H
#include <stdint.h>
#include <stdbool.h>
#include <time.h>
#include <stdatomic.h>
#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 */

View File

@ -34,6 +34,7 @@
#include "config.h"
#include "fcgi_handler.h"
#include "cache.h"
#include "dashboard.h"
#include <time.h>
#include <stdio.h>
#include <stdlib.h>
@ -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;

110
tests/unit/test_dashboard.c Normal file
View File

@ -0,0 +1,110 @@
#include "unity.h"
#include "dashboard.h"
#include "http.h"
#include <string.h>
/* 为测试提供全局指标变量的定义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();
}