pPowerMangerHost:反馈数据 SQLite 落库,新增历史查询 API 与曲线绘图
- 新增 FeedbackStore(sqlite3 封装),fb_log 表按消息落盘,环形时间保留 - HostSim 每次收到反馈即批量写库,支持 DB_FILE/FB_LOG_KEEP_SECONDS 等配置 - 新增 /api/history、/api/rawlog 接口;曲线页新增历史数据加载 - 数据库目录不存在时自动创建;新增板卡原生编译脚本 scripts/build-board.sh
This commit is contained in:
@@ -9,12 +9,17 @@
|
||||
SET(MONGOOSE_SRC
|
||||
${CMAKE_SOURCE_DIR}/src/pPowerManger/httpserver/mongoose.c)
|
||||
|
||||
SET(SQLITE_SRC
|
||||
${CMAKE_SOURCE_DIR}/src/pPowerManger/sqlit3/sqlite3.c)
|
||||
|
||||
SET(SRC
|
||||
HostSim.cpp
|
||||
HostSim_Info.cpp
|
||||
WebServer.cpp
|
||||
FeedbackStore.cpp
|
||||
main.cpp
|
||||
${MONGOOSE_SRC}
|
||||
${SQLITE_SRC}
|
||||
)
|
||||
|
||||
#--------------------------------------------------------
|
||||
@@ -105,6 +110,7 @@ ADD_EXECUTABLE(pPowerMangerHost ${SRC})
|
||||
|
||||
TARGET_INCLUDE_DIRECTORIES(pPowerMangerHost PRIVATE
|
||||
${CMAKE_SOURCE_DIR}/src/pPowerManger/httpserver
|
||||
${CMAKE_SOURCE_DIR}/src/pPowerManger/sqlit3
|
||||
${CMAKE_CURRENT_BINARY_DIR})
|
||||
|
||||
TARGET_LINK_LIBRARIES(pPowerMangerHost
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
#include "FeedbackStore.h"
|
||||
#include "sqlite3.h"
|
||||
#include "json/json.h"
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <sys/stat.h>
|
||||
|
||||
using namespace std;
|
||||
|
||||
// 创建路径的父目录(递归,类似 mkdir -p)
|
||||
static void ensureParentDir(const string& filePath)
|
||||
{
|
||||
size_t pos = filePath.find_last_of('/');
|
||||
if (pos == string::npos) return; // 无目录部分
|
||||
string dir = filePath.substr(0, pos);
|
||||
if (dir.empty()) return;
|
||||
|
||||
string cur;
|
||||
size_t start = 0;
|
||||
while (true) {
|
||||
size_t slash = dir.find('/', start);
|
||||
string part = (slash == string::npos) ? dir.substr(start)
|
||||
: dir.substr(start, slash - start);
|
||||
if (!part.empty()) {
|
||||
if (!cur.empty()) cur += '/';
|
||||
cur += part;
|
||||
mkdir(cur.c_str(), 0755);
|
||||
}
|
||||
if (slash == string::npos) break;
|
||||
start = slash + 1;
|
||||
}
|
||||
}
|
||||
|
||||
FeedbackStore::FeedbackStore()
|
||||
: m_db(nullptr), m_appendStmt(nullptr)
|
||||
{
|
||||
}
|
||||
|
||||
FeedbackStore::~FeedbackStore()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
static int64_t nowMs()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
bool FeedbackStore::open(const string& path)
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (m_db) return true;
|
||||
|
||||
ensureParentDir(path);
|
||||
|
||||
if (sqlite3_open(path.c_str(), &m_db) != SQLITE_OK) {
|
||||
m_db = nullptr;
|
||||
return false;
|
||||
}
|
||||
|
||||
const char* schema =
|
||||
"CREATE TABLE IF NOT EXISTS fb_log ("
|
||||
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
||||
" ts INTEGER NOT NULL,"
|
||||
" msg TEXT NOT NULL,"
|
||||
" payload TEXT NOT NULL"
|
||||
");"
|
||||
"CREATE INDEX IF NOT EXISTS idx_fb_log_msg_ts ON fb_log(msg, ts);";
|
||||
|
||||
char* err = nullptr;
|
||||
if (sqlite3_exec(m_db, schema, nullptr, nullptr, &err) != SQLITE_OK) {
|
||||
sqlite3_free(err);
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void FeedbackStore::close()
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (m_appendStmt) {
|
||||
sqlite3_finalize(m_appendStmt);
|
||||
m_appendStmt = nullptr;
|
||||
}
|
||||
if (m_db) {
|
||||
sqlite3_close(m_db);
|
||||
m_db = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool FeedbackStore::begin()
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (!m_db) return false;
|
||||
char* err = nullptr;
|
||||
int rc = sqlite3_exec(m_db, "BEGIN;", nullptr, nullptr, &err);
|
||||
if (err) sqlite3_free(err);
|
||||
return rc == SQLITE_OK;
|
||||
}
|
||||
|
||||
bool FeedbackStore::append(int64_t ts, const string& msg, const string& payload)
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (!m_db) return false;
|
||||
|
||||
if (!m_appendStmt) {
|
||||
const char* sql = "INSERT INTO fb_log (ts, msg, payload) VALUES (?, ?, ?);";
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &m_appendStmt, nullptr) != SQLITE_OK) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
sqlite3_bind_int64(m_appendStmt, 1, ts);
|
||||
sqlite3_bind_text(m_appendStmt, 2, msg.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_text(m_appendStmt, 3, payload.c_str(), -1, SQLITE_TRANSIENT);
|
||||
|
||||
int rc = sqlite3_step(m_appendStmt);
|
||||
sqlite3_reset(m_appendStmt);
|
||||
sqlite3_clear_bindings(m_appendStmt);
|
||||
return rc == SQLITE_DONE;
|
||||
}
|
||||
|
||||
bool FeedbackStore::commit()
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (!m_db) return false;
|
||||
char* err = nullptr;
|
||||
int rc = sqlite3_exec(m_db, "COMMIT;", nullptr, nullptr, &err);
|
||||
if (err) sqlite3_free(err);
|
||||
return rc == SQLITE_OK;
|
||||
}
|
||||
|
||||
void FeedbackStore::prune(int64_t keepSeconds)
|
||||
{
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (!m_db || keepSeconds <= 0) return;
|
||||
|
||||
int64_t cutoff = nowMs() - keepSeconds * 1000;
|
||||
const char* sql = "DELETE FROM fb_log WHERE ts < ?;";
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &stmt, nullptr) == SQLITE_OK) {
|
||||
sqlite3_bind_int64(stmt, 1, cutoff);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
string FeedbackStore::querySeries(const string& msg, const string& field,
|
||||
int64_t fromTs, int64_t toTs, int limit)
|
||||
{
|
||||
Json::Value result(Json::arrayValue);
|
||||
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (!m_db || msg.empty() || field.empty()) {
|
||||
return "[]";
|
||||
}
|
||||
if (limit <= 0) limit = 2000;
|
||||
if (toTs <= 0) toTs = nowMs();
|
||||
|
||||
const char* sql =
|
||||
"SELECT ts, payload FROM fb_log "
|
||||
"WHERE msg = ? AND ts >= ? AND ts <= ? ORDER BY ts ASC LIMIT ?;";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &stmt, nullptr) != SQLITE_OK) {
|
||||
return "[]";
|
||||
}
|
||||
sqlite3_bind_text(stmt, 1, msg.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 2, fromTs);
|
||||
sqlite3_bind_int64(stmt, 3, toTs);
|
||||
sqlite3_bind_int(stmt, 4, limit);
|
||||
|
||||
Json::CharReaderBuilder builder;
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
int64_t ts = sqlite3_column_int64(stmt, 0);
|
||||
const char* payload = (const char*)sqlite3_column_text(stmt, 1);
|
||||
if (!payload) continue;
|
||||
|
||||
Json::Value root;
|
||||
JSONCPP_STRING errs;
|
||||
istringstream stream(payload);
|
||||
if (!Json::parseFromStream(builder, stream, &root, &errs)) continue;
|
||||
if (!root.isMember(field) || !root[field].isNumeric()) continue;
|
||||
|
||||
Json::Value pt(Json::arrayValue);
|
||||
pt.append((Json::Int64)ts);
|
||||
pt.append(root[field].asDouble());
|
||||
result.append(pt);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
|
||||
Json::StreamWriterBuilder wb;
|
||||
wb.settings_["indentation"] = "";
|
||||
return Json::writeString(wb, result);
|
||||
}
|
||||
|
||||
string FeedbackStore::queryRaw(const string& msg, int64_t fromTs, int64_t toTs, int limit)
|
||||
{
|
||||
Json::Value result(Json::objectValue);
|
||||
Json::Value rows(Json::arrayValue);
|
||||
|
||||
lock_guard<mutex> lock(m_mutex);
|
||||
if (m_db && !msg.empty()) {
|
||||
if (limit <= 0) limit = 500;
|
||||
if (toTs <= 0) toTs = nowMs();
|
||||
|
||||
const char* sql =
|
||||
"SELECT ts, payload FROM fb_log "
|
||||
"WHERE msg = ? AND ts >= ? AND ts <= ? ORDER BY ts ASC LIMIT ?;";
|
||||
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
if (sqlite3_prepare_v2(m_db, sql, -1, &stmt, nullptr) == SQLITE_OK) {
|
||||
sqlite3_bind_text(stmt, 1, msg.c_str(), -1, SQLITE_TRANSIENT);
|
||||
sqlite3_bind_int64(stmt, 2, fromTs);
|
||||
sqlite3_bind_int64(stmt, 3, toTs);
|
||||
sqlite3_bind_int(stmt, 4, limit);
|
||||
|
||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||
Json::Value row(Json::objectValue);
|
||||
row["ts"] = (Json::Int64)sqlite3_column_int64(stmt, 0);
|
||||
const char* p = (const char*)sqlite3_column_text(stmt, 1);
|
||||
row["payload"] = p ? p : "";
|
||||
rows.append(row);
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
result["rows"] = rows;
|
||||
Json::StreamWriterBuilder wb;
|
||||
wb.settings_["indentation"] = "";
|
||||
return Json::writeString(wb, result);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#ifndef FEEDBACKSTORE_H
|
||||
#define FEEDBACKSTORE_H
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <mutex>
|
||||
#include <cstdint>
|
||||
|
||||
struct sqlite3;
|
||||
struct sqlite3_stmt;
|
||||
|
||||
// 轻量 SQLite 封装:按时间序列存储上位机反馈消息的原始 JSON。
|
||||
// 表结构:
|
||||
// fb_log(id INTEGER PK AUTOINCREMENT, ts INTEGER, msg TEXT, payload TEXT)
|
||||
// 索引: (msg, ts)
|
||||
class FeedbackStore
|
||||
{
|
||||
public:
|
||||
FeedbackStore();
|
||||
~FeedbackStore();
|
||||
|
||||
bool open(const std::string& path);
|
||||
void close();
|
||||
bool isOpen() const { return m_db != nullptr; }
|
||||
|
||||
// 批写入:begin -> append*N -> commit
|
||||
bool begin();
|
||||
bool append(int64_t ts, const std::string& msg, const std::string& payload);
|
||||
bool commit();
|
||||
|
||||
// 按保留时间清理:删除 ts 早于 (当前时间 - keepSeconds) 的记录
|
||||
void prune(int64_t keepSeconds);
|
||||
|
||||
// 查询某消息某数值字段的时间序列,返回 JSON 数组字符串 [[ts,value],...]
|
||||
std::string querySeries(const std::string& msg, const std::string& field,
|
||||
int64_t fromTs, int64_t toTs, int limit);
|
||||
|
||||
// 查询某消息的原始记录,返回 JSON {"rows":[{"ts":..,"payload":".."},...]}
|
||||
std::string queryRaw(const std::string& msg,
|
||||
int64_t fromTs, int64_t toTs, int limit);
|
||||
|
||||
private:
|
||||
sqlite3* m_db;
|
||||
sqlite3_stmt* m_appendStmt;
|
||||
std::mutex m_mutex;
|
||||
};
|
||||
|
||||
#endif // FEEDBACKSTORE_H
|
||||
@@ -7,18 +7,30 @@
|
||||
|
||||
#include <iterator>
|
||||
#include <sstream>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include "MBUtils.h"
|
||||
#include "HostSim.h"
|
||||
#include "WebServer.h"
|
||||
#include "FeedbackStore.h"
|
||||
#include "json/json.h"
|
||||
|
||||
using namespace std;
|
||||
|
||||
static int64_t hostNowMs()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Constructor
|
||||
|
||||
HostSim::HostSim()
|
||||
: m_feedbackDirty(false), m_webServer(nullptr), m_webPort(18080)
|
||||
: m_feedbackDirty(false), m_webServer(nullptr), m_webPort(18080),
|
||||
m_feedbackStore(nullptr), m_dbFile("feedback_data.db"),
|
||||
m_fbLogEnabled(true), m_fbLogKeepSeconds(86400),
|
||||
m_fbLogPruneIntervalSeconds(60), m_lastPruneTs(0)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -27,6 +39,11 @@ HostSim::HostSim()
|
||||
|
||||
HostSim::~HostSim()
|
||||
{
|
||||
if (m_feedbackStore) {
|
||||
m_feedbackStore->close();
|
||||
delete m_feedbackStore;
|
||||
m_feedbackStore = nullptr;
|
||||
}
|
||||
if (m_webServer) {
|
||||
m_webServer->stop();
|
||||
delete m_webServer;
|
||||
@@ -79,6 +96,7 @@ bool HostSim::Iterate()
|
||||
}
|
||||
if (dirty && m_webServer) {
|
||||
m_webServer->broadcast(buildSnapshot());
|
||||
persistFeedback(hostNowMs());
|
||||
lock_guard<mutex> lock(m_feedbackMutex);
|
||||
m_feedbackDirty = false;
|
||||
}
|
||||
@@ -98,14 +116,34 @@ bool HostSim::OnStartUp()
|
||||
for (p = sParams.begin(); p != sParams.end(); p++) {
|
||||
string line = *p;
|
||||
string param = tolower(biteStringX(line, '='));
|
||||
string value = line;
|
||||
string value = stripBlankEnds(line);
|
||||
|
||||
if (param == "web_port") {
|
||||
m_webPort = atoi(value.c_str());
|
||||
} else if (param == "db_file") {
|
||||
m_dbFile = value;
|
||||
} else if (param == "fb_log_enabled") {
|
||||
m_fbLogEnabled = (tolower(value) == "true" || value == "1");
|
||||
} else if (param == "fb_log_keep_seconds") {
|
||||
m_fbLogKeepSeconds = atoll(value.c_str());
|
||||
} else if (param == "fb_log_prune_interval_seconds") {
|
||||
m_fbLogPruneIntervalSeconds = atoll(value.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启动反馈落盘存储(SQLite)
|
||||
if (m_fbLogEnabled) {
|
||||
m_feedbackStore = new FeedbackStore();
|
||||
if (m_feedbackStore->open(m_dbFile)) {
|
||||
cout << "pPowerMangerHost feedback logging to " << m_dbFile << endl;
|
||||
} else {
|
||||
cerr << "pPowerMangerHost failed to open feedback DB: " << m_dbFile << endl;
|
||||
delete m_feedbackStore;
|
||||
m_feedbackStore = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// 启动网页服务器
|
||||
m_webServer = new WebServer();
|
||||
m_webServer->setOnMessage([this](const string& msg) {
|
||||
@@ -114,6 +152,9 @@ bool HostSim::OnStartUp()
|
||||
m_webServer->setOnOpen([this]() {
|
||||
return this->buildSnapshot();
|
||||
});
|
||||
m_webServer->setApiHandler([this](const string& uri, const string& query) {
|
||||
return this->handleApi(uri, query);
|
||||
});
|
||||
m_webServer->start(m_webPort);
|
||||
|
||||
RegisterVariables();
|
||||
@@ -205,6 +246,99 @@ string HostSim::buildSnapshot()
|
||||
return Json::writeString(wb, root);
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Procedure: persistFeedback
|
||||
// 把当前反馈缓存批量写入 SQLite(每次收到即存)
|
||||
|
||||
void HostSim::persistFeedback(int64_t nowMs)
|
||||
{
|
||||
if (!m_feedbackStore || !m_feedbackStore->isOpen()) return;
|
||||
|
||||
vector<pair<string, string> > entries;
|
||||
{
|
||||
lock_guard<mutex> lock(m_feedbackMutex);
|
||||
for (map<string, string>::iterator it = m_feedback.begin();
|
||||
it != m_feedback.end(); ++it) {
|
||||
entries.push_back(make_pair(it->first, it->second));
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.empty()) return;
|
||||
|
||||
m_feedbackStore->begin();
|
||||
for (size_t i = 0; i < entries.size(); i++) {
|
||||
m_feedbackStore->append(nowMs, entries[i].first, entries[i].second);
|
||||
}
|
||||
m_feedbackStore->commit();
|
||||
|
||||
if (nowMs - m_lastPruneTs >= m_fbLogPruneIntervalSeconds * 1000) {
|
||||
m_feedbackStore->prune(m_fbLogKeepSeconds);
|
||||
m_lastPruneTs = nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Procedure: handleApi
|
||||
// 处理 /api/history 与 /api/rawlog 查询
|
||||
|
||||
static string urlDecode(const string& s)
|
||||
{
|
||||
string out;
|
||||
for (size_t i = 0; i < s.size(); i++) {
|
||||
if (s[i] == '%' && i + 2 < s.size()) {
|
||||
int v = 0;
|
||||
for (int j = 1; j <= 2; j++) {
|
||||
char c = s[i + j];
|
||||
v <<= 4;
|
||||
if (c >= '0' && c <= '9') v |= (c - '0');
|
||||
else if (c >= 'a' && c <= 'f') v |= (c - 'a' + 10);
|
||||
else if (c >= 'A' && c <= 'F') v |= (c - 'A' + 10);
|
||||
}
|
||||
out += (char)v;
|
||||
i += 2;
|
||||
} else if (s[i] == '+') {
|
||||
out += ' ';
|
||||
} else {
|
||||
out += s[i];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static string queryGet(const string& query, const string& key)
|
||||
{
|
||||
size_t pos = 0;
|
||||
while (pos <= query.size()) {
|
||||
size_t amp = query.find('&', pos);
|
||||
string kv = query.substr(pos, amp == string::npos ? string::npos : amp - pos);
|
||||
size_t eq = kv.find('=');
|
||||
if (eq != string::npos && kv.substr(0, eq) == key) {
|
||||
return urlDecode(kv.substr(eq + 1));
|
||||
}
|
||||
if (amp == string::npos) break;
|
||||
pos = amp + 1;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
string HostSim::handleApi(const string& uri, const string& query)
|
||||
{
|
||||
if (!m_feedbackStore || !m_feedbackStore->isOpen()) return "";
|
||||
|
||||
string msg = queryGet(query, "msg");
|
||||
string field = queryGet(query, "field");
|
||||
int64_t fromTs = atoll(queryGet(query, "from").c_str());
|
||||
int64_t toTs = atoll(queryGet(query, "to").c_str());
|
||||
int limit = atoi(queryGet(query, "limit").c_str());
|
||||
|
||||
if (uri == "/api/history") {
|
||||
return m_feedbackStore->querySeries(msg, field, fromTs, toTs, limit);
|
||||
} else if (uri == "/api/rawlog") {
|
||||
return m_feedbackStore->queryRaw(msg, fromTs, toTs, limit);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
//---------------------------------------------------------
|
||||
// Procedure: RegisterVariables
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
#include <atomic>
|
||||
|
||||
class WebServer;
|
||||
class FeedbackStore;
|
||||
|
||||
// 模拟 pPowerManger 上位机:网页 <-> MOOSDB 桥
|
||||
class HostSim : public CMOOSApp
|
||||
@@ -37,11 +38,14 @@ class HostSim : public CMOOSApp
|
||||
void pushCommand(const std::string& key, const std::string& jsonValue);
|
||||
// 取当前所有反馈变量的 JSON 快照
|
||||
std::string buildSnapshot();
|
||||
// 处理 /api/* 请求,返回 JSON 响应体(空串表示 404)
|
||||
std::string handleApi(const std::string& uri, const std::string& query);
|
||||
|
||||
private:
|
||||
void registerFeedbackVars();
|
||||
bool drainCommands();
|
||||
void handleWebMessage(const std::string& msg);
|
||||
void persistFeedback(int64_t nowMs);
|
||||
|
||||
private: // State variables
|
||||
// 反馈缓存:key -> 最新 JSON 字符串
|
||||
@@ -56,6 +60,14 @@ class HostSim : public CMOOSApp
|
||||
// 网页服务器
|
||||
WebServer* m_webServer;
|
||||
int m_webPort;
|
||||
|
||||
// 反馈落盘存储(SQLite)
|
||||
FeedbackStore* m_feedbackStore;
|
||||
std::string m_dbFile;
|
||||
bool m_fbLogEnabled;
|
||||
int64_t m_fbLogKeepSeconds;
|
||||
int64_t m_fbLogPruneIntervalSeconds;
|
||||
int64_t m_lastPruneTs;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
||||
@@ -15,8 +15,24 @@ void WebServer::handleEvent(struct mg_connection* c, int ev, void* ev_data) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态资源路由:在嵌入式资源表 webassets 中按路径查找并返回
|
||||
// /api/* 接口:交给宿主程序处理,返回 JSON
|
||||
std::string path(hm->uri.buf, hm->uri.len);
|
||||
if (path.rfind("/api/", 0) == 0) {
|
||||
std::string body;
|
||||
if (m_apiHandler) {
|
||||
std::string query(hm->query.buf, hm->query.len);
|
||||
body = m_apiHandler(path, query);
|
||||
}
|
||||
if (!body.empty()) {
|
||||
mg_http_reply(c, 200, "Content-Type: application/json\r\n",
|
||||
"%.*s", (int)body.size(), body.c_str());
|
||||
} else {
|
||||
mg_http_reply(c, 404, "Content-Type: text/plain\r\n", "Not Found\n");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态资源路由:在嵌入式资源表 webassets 中按路径查找并返回
|
||||
if (path == "/" || path == "/index.html") {
|
||||
path = "index.html";
|
||||
} else if (!path.empty() && path[0] == '/') {
|
||||
@@ -76,6 +92,10 @@ void WebServer::setOnOpen(std::function<std::string()> handler) {
|
||||
m_onOpen = handler;
|
||||
}
|
||||
|
||||
void WebServer::setApiHandler(std::function<std::string(const std::string&, const std::string&)> handler) {
|
||||
m_apiHandler = handler;
|
||||
}
|
||||
|
||||
bool WebServer::start(int port) {
|
||||
if (m_running) return true;
|
||||
m_port = port;
|
||||
|
||||
@@ -27,6 +27,9 @@ public:
|
||||
// 新 WebSocket 客户端连接时回调,返回需要立即推送给该客户端的初始文本(如快照)
|
||||
void setOnOpen(std::function<std::string()> handler);
|
||||
|
||||
// 处理 /api/* HTTP 请求,返回 JSON 响应体(空串表示 404)
|
||||
void setApiHandler(std::function<std::string(const std::string& uri, const std::string& query)> handler);
|
||||
|
||||
private:
|
||||
static bool serverThreadCB(void* pParam);
|
||||
bool serverThreadFunc();
|
||||
@@ -41,6 +44,7 @@ private:
|
||||
std::vector<mg_connection*> m_wsConnections;
|
||||
std::function<void(const std::string&)> m_onMessage;
|
||||
std::function<std::string()> m_onOpen;
|
||||
std::function<std::string(const std::string&, const std::string&)> m_apiHandler;
|
||||
};
|
||||
|
||||
#endif // WEBSERVER_H
|
||||
|
||||
@@ -5,4 +5,10 @@ ProcessConfig = pPowerMangerHost
|
||||
AppTick = 4
|
||||
CommsTick = 4
|
||||
WEB_PORT = 18080
|
||||
|
||||
// 反馈落盘(SQLite)
|
||||
DB_FILE = feedback_data.db
|
||||
FB_LOG_ENABLED = true
|
||||
FB_LOG_KEEP_SECONDS = 86400 // 历史保留时长(秒),24h
|
||||
FB_LOG_PRUNE_INTERVAL_SECONDS = 60 // 清理检查周期(秒)
|
||||
}
|
||||
|
||||
@@ -367,6 +367,25 @@
|
||||
<div id="seriesBox" style="margin-bottom:8px"></div>
|
||||
<canvas id="chart"></canvas>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>历史数据(本地 SQLite)</h2>
|
||||
<div class="row">
|
||||
<label>消息</label>
|
||||
<select id="histMsg"></select>
|
||||
<label>字段</label>
|
||||
<select id="histField" style="min-width:220px"></select>
|
||||
<label>时间范围</label>
|
||||
<select id="histRange">
|
||||
<option value="300">最近 5 分钟</option>
|
||||
<option value="3600">最近 1 小时</option>
|
||||
<option value="21600">最近 6 小时</option>
|
||||
<option value="86400" selected>最近 24 小时</option>
|
||||
</select>
|
||||
<button id="btnLoadHistory">加载历史</button>
|
||||
</div>
|
||||
<div class="muted" id="histEcho"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 日志 -->
|
||||
@@ -1097,6 +1116,52 @@ function drawChart() {
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 11.5 历史数据(本地 SQLite)
|
||||
// ============================================================
|
||||
function populateHistMsgs() {
|
||||
el("histMsg").innerHTML = FEEDBACKS.map(([k, t]) =>
|
||||
`<option value="${k}">${esc(t)}</option>`).join("");
|
||||
populateHistFields();
|
||||
}
|
||||
|
||||
function populateHistFields() {
|
||||
const msg = el("histMsg").value;
|
||||
const d = feedbackData[msg];
|
||||
const sel = el("histField");
|
||||
if (!d || typeof d !== "object") { sel.innerHTML = '<option value="">暂无字段</option>'; return; }
|
||||
const fields = Object.keys(d).filter(k => typeof d[k] === "number");
|
||||
sel.innerHTML = fields.length
|
||||
? fields.map(f => `<option value="${esc(f)}">${esc(f)}</option>`).join("")
|
||||
: '<option value="">无数值字段</option>';
|
||||
}
|
||||
|
||||
function loadHistorySeries() {
|
||||
const msg = el("histMsg").value;
|
||||
const field = el("histField").value;
|
||||
if (!msg || !field) { log("请选择消息和字段", "err"); return; }
|
||||
const rangeSec = Number(el("histRange").value);
|
||||
const to = Date.now();
|
||||
const from = to - rangeSec * 1000;
|
||||
const url = "/api/history?msg=" + encodeURIComponent(msg) +
|
||||
"&field=" + encodeURIComponent(field) +
|
||||
"&from=" + from + "&to=" + to + "&limit=2000";
|
||||
fetch(url).then(r => r.json()).then(points => {
|
||||
if (!Array.isArray(points) || points.length === 0) {
|
||||
el("histEcho").textContent = "该字段在所选范围内无数据";
|
||||
return;
|
||||
}
|
||||
const key = "历史 " + msg + "." + field;
|
||||
history[key] = { color: COLORS[nextColor % COLORS.length], points: points };
|
||||
nextColor++;
|
||||
renderSeriesBox();
|
||||
drawChart();
|
||||
el("histEcho").textContent = "已加载 " + points.length + " 点 → " + key;
|
||||
}).catch(err => {
|
||||
el("histEcho").textContent = "加载失败: " + err;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 12. Tab 切换
|
||||
// ============================================================
|
||||
@@ -1109,7 +1174,6 @@ function switchTab(name) {
|
||||
});
|
||||
if (name === "chart") { refreshFieldOptions(); drawChart(); }
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 13. 初始化
|
||||
// ============================================================
|
||||
@@ -1168,6 +1232,11 @@ function init() {
|
||||
el("btnAddSeries").onclick = () => addSeries(el("fieldSel").value);
|
||||
el("btnClearSeries").onclick = () => { history = {}; nextColor = 0; renderSeriesBox(); drawChart(); };
|
||||
|
||||
// 历史数据
|
||||
populateHistMsgs();
|
||||
el("histMsg").onchange = populateHistFields;
|
||||
el("btnLoadHistory").onclick = loadHistorySeries;
|
||||
|
||||
// 日志
|
||||
el("btnClearLog").onclick = () => { el("log").innerHTML = ""; };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user