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:
@@ -245,6 +245,8 @@ _deps
|
|||||||
bin/pPowerManger.log
|
bin/pPowerManger.log
|
||||||
bin/ccuState.txt
|
bin/ccuState.txt
|
||||||
*.db
|
*.db
|
||||||
|
# pPowerMangerHost 反馈落盘数据库
|
||||||
|
feedback_data.db
|
||||||
|
|
||||||
# PowerManager compiled binaries (built into bin/ by CMake)
|
# PowerManager compiled binaries (built into bin/ by CMake)
|
||||||
bin/pPowerManger
|
bin/pPowerManger
|
||||||
|
|||||||
Executable
+164
@@ -0,0 +1,164 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#=======================================================================
|
||||||
|
# FILE: scripts/build-board.sh
|
||||||
|
# DESC: 把源码同步到目标板卡 (RK3588 / Ubuntu 22.04),在板卡原生编译
|
||||||
|
# (aarch64 直编,比本地 QEMU 模拟快约 10 倍),并把产物部署到
|
||||||
|
# /root/work/h100/bin/,可选重启 systemd 服务。
|
||||||
|
#
|
||||||
|
# 用法:
|
||||||
|
# ./scripts/build-board.sh # 同步+编译+部署(不重启)
|
||||||
|
# ./scripts/build-board.sh --start # 编译后重启 3 个服务
|
||||||
|
# ./scripts/build-board.sh --clean # 板卡上重新 cmake 再编译
|
||||||
|
# ./scripts/build-board.sh --jobs 4 # 指定并行度(默认板卡 nproc)
|
||||||
|
#
|
||||||
|
# 参数:
|
||||||
|
# --host <ip> 目标主机(默认 192.168.0.223)
|
||||||
|
# --user <user> 登录用户(默认 root)
|
||||||
|
# --board-src <dir> 板卡源码目录(默认 /root/work/h100/src)
|
||||||
|
# --board-bin <dir> 板卡部署目录(默认 /root/work/h100/bin)
|
||||||
|
# --jobs <n> 编译并行度(默认板卡 nproc)
|
||||||
|
# --start 编译部署后重启服务
|
||||||
|
# --clean 先重新 cmake(配置变更时用)
|
||||||
|
# -h, --help 显示帮助
|
||||||
|
#
|
||||||
|
# 前置: 板卡已配免密 SSH,且有编译环境(cmake/g++/MOOS/jsoncpp),
|
||||||
|
# 源码已能本地编译(本脚本只负责同步+板卡编译)。
|
||||||
|
#=======================================================================
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")"
|
||||||
|
|
||||||
|
HOST="192.168.0.223"
|
||||||
|
USER="root"
|
||||||
|
BOARD_SRC="/root/work/h100/src"
|
||||||
|
BOARD_BIN="/root/work/h100/bin"
|
||||||
|
JOBS=""
|
||||||
|
DO_START=0
|
||||||
|
DO_CLEAN=0
|
||||||
|
|
||||||
|
SERVICES=(moosdb pPowerManger pPowerMangerHost)
|
||||||
|
|
||||||
|
info() { printf '\033[1;36m[build-board]\033[0m %s\n' "$*"; }
|
||||||
|
ok() { printf '\033[1;32m[build-board]\033[0m %s\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m[build-board]\033[0m %s\n' "$*"; }
|
||||||
|
err() { printf '\033[1;31m[build-board]\033[0m %s\n' "$*"; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,29p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
# 参数解析
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--host) [ $# -ge 2 ] || { err "--host 需要参数"; exit 1; }; HOST="$2"; shift 2 ;;
|
||||||
|
--user) [ $# -ge 2 ] || { err "--user 需要参数"; exit 1; }; USER="$2"; shift 2 ;;
|
||||||
|
--board-src) [ $# -ge 2 ] || { err "--board-src 需要参数"; exit 1; }; BOARD_SRC="$2"; shift 2 ;;
|
||||||
|
--board-bin) [ $# -ge 2 ] || { err "--board-bin 需要参数"; exit 1; }; BOARD_BIN="$2"; shift 2 ;;
|
||||||
|
--jobs) [ $# -ge 2 ] || { err "--jobs 需要参数"; exit 1; }; JOBS="$2"; shift 2 ;;
|
||||||
|
--start) DO_START=1; shift ;;
|
||||||
|
--clean) DO_CLEAN=1; shift ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) err "未知参数: $1(见 --help)"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
SSH_TARGET="${USER}@${HOST}"
|
||||||
|
SSH="ssh -o BatchMode=yes -o ConnectTimeout=5"
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
# 1. 前置检查
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
if ! ${SSH} "${SSH_TARGET}" 'true' 2>/dev/null; then
|
||||||
|
err "无法免密 SSH 到 ${SSH_TARGET}"
|
||||||
|
err "请先配置免密登录: ssh-copy-id ${SSH_TARGET}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "SSH 连接正常: ${SSH_TARGET}"
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
# 2. 同步源码到板卡(排除构建产物 / git / 数据)
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
info "同步源码到板卡 ${BOARD_SRC} ..."
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude='build/' \
|
||||||
|
--exclude='build-arm64/' \
|
||||||
|
--exclude='build-native/' \
|
||||||
|
--exclude='build-nosa-test/' \
|
||||||
|
--exclude='bin/' \
|
||||||
|
--exclude='.git/' \
|
||||||
|
--exclude='.cache/' \
|
||||||
|
--exclude='reports/' \
|
||||||
|
--exclude='*.log' \
|
||||||
|
--exclude='*.db' \
|
||||||
|
--exclude='CMakeCache.txt' \
|
||||||
|
"${PROJECT_ROOT}/" "${SSH_TARGET}:${BOARD_SRC}/" || { err "rsync 源码失败"; exit 1; }
|
||||||
|
ok "源码已同步"
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
# 3. 板卡原生编译
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
if [ "${DO_CLEAN}" = "1" ]; then
|
||||||
|
info "重新 cmake(--clean)..."
|
||||||
|
${SSH} "${SSH_TARGET}" "rm -rf ${BOARD_SRC}/build-native && mkdir -p ${BOARD_SRC}/build-native && \
|
||||||
|
cd ${BOARD_SRC}/build-native && cmake -DCMAKE_BUILD_TYPE=Release .." \
|
||||||
|
|| { err "cmake 失败"; exit 1; }
|
||||||
|
ok "cmake 完成"
|
||||||
|
else
|
||||||
|
${SSH} "${SSH_TARGET}" "mkdir -p ${BOARD_SRC}/build-native"
|
||||||
|
# 若首次未配置过则先 cmake
|
||||||
|
if ! ${SSH} "${SSH_TARGET}" "[ -f ${BOARD_SRC}/build-native/Makefile ]"; then
|
||||||
|
info "首次编译,先 cmake ..."
|
||||||
|
${SSH} "${SSH_TARGET}" "cd ${BOARD_SRC}/build-native && cmake -DCMAKE_BUILD_TYPE=Release .." \
|
||||||
|
|| { err "cmake 失败"; exit 1; }
|
||||||
|
ok "cmake 完成"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "${JOBS}" ]; then
|
||||||
|
JOBS="$(${SSH} "${SSH_TARGET}" nproc)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "板卡编译 (make -j${JOBS}) ..."
|
||||||
|
if ! ${SSH} "${SSH_TARGET}" "cd ${BOARD_SRC}/build-native && make -j${JOBS}"; then
|
||||||
|
err "板卡编译失败(见上方输出)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ok "板卡编译完成"
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
# 4. 部署产物到板卡运行目录
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
info "部署产物到 ${BOARD_BIN}/ ..."
|
||||||
|
${SSH} "${SSH_TARGET}" "mkdir -p ${BOARD_BIN} && \
|
||||||
|
cp -f ${BOARD_SRC}/bin/pPowerManger ${BOARD_BIN}/pPowerManger && \
|
||||||
|
cp -f ${BOARD_SRC}/bin/pPowerMangerHost ${BOARD_BIN}/pPowerMangerHost && \
|
||||||
|
chmod +x ${BOARD_BIN}/pPowerManger ${BOARD_BIN}/pPowerMangerHost" \
|
||||||
|
|| { err "部署产物失败"; exit 1; }
|
||||||
|
ok "产物已部署"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "产物: ${BOARD_BIN}/pPowerManger (aarch64)"
|
||||||
|
${SSH} "${SSH_TARGET}" "file ${BOARD_BIN}/pPowerManger | cut -c1-60"
|
||||||
|
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
# 5. 可选重启服务
|
||||||
|
#-------------------------------------------------------------------
|
||||||
|
if [ "${DO_START}" = "1" ]; then
|
||||||
|
info "重启服务 ..."
|
||||||
|
${SSH} "${SSH_TARGET}" "systemctl restart ${SERVICES[*]}" \
|
||||||
|
|| { err "重启服务失败"; exit 1; }
|
||||||
|
sleep 3
|
||||||
|
ok "服务状态: $(${SSH} "${SSH_TARGET}" "systemctl is-active ${SERVICES[*]}")"
|
||||||
|
else
|
||||||
|
echo ""
|
||||||
|
info "未重启服务。需要重启请加 --start 或手动:"
|
||||||
|
echo " systemctl restart ${SERVICES[*]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "完成。"
|
||||||
@@ -9,12 +9,17 @@
|
|||||||
SET(MONGOOSE_SRC
|
SET(MONGOOSE_SRC
|
||||||
${CMAKE_SOURCE_DIR}/src/pPowerManger/httpserver/mongoose.c)
|
${CMAKE_SOURCE_DIR}/src/pPowerManger/httpserver/mongoose.c)
|
||||||
|
|
||||||
|
SET(SQLITE_SRC
|
||||||
|
${CMAKE_SOURCE_DIR}/src/pPowerManger/sqlit3/sqlite3.c)
|
||||||
|
|
||||||
SET(SRC
|
SET(SRC
|
||||||
HostSim.cpp
|
HostSim.cpp
|
||||||
HostSim_Info.cpp
|
HostSim_Info.cpp
|
||||||
WebServer.cpp
|
WebServer.cpp
|
||||||
|
FeedbackStore.cpp
|
||||||
main.cpp
|
main.cpp
|
||||||
${MONGOOSE_SRC}
|
${MONGOOSE_SRC}
|
||||||
|
${SQLITE_SRC}
|
||||||
)
|
)
|
||||||
|
|
||||||
#--------------------------------------------------------
|
#--------------------------------------------------------
|
||||||
@@ -105,6 +110,7 @@ ADD_EXECUTABLE(pPowerMangerHost ${SRC})
|
|||||||
|
|
||||||
TARGET_INCLUDE_DIRECTORIES(pPowerMangerHost PRIVATE
|
TARGET_INCLUDE_DIRECTORIES(pPowerMangerHost PRIVATE
|
||||||
${CMAKE_SOURCE_DIR}/src/pPowerManger/httpserver
|
${CMAKE_SOURCE_DIR}/src/pPowerManger/httpserver
|
||||||
|
${CMAKE_SOURCE_DIR}/src/pPowerManger/sqlit3
|
||||||
${CMAKE_CURRENT_BINARY_DIR})
|
${CMAKE_CURRENT_BINARY_DIR})
|
||||||
|
|
||||||
TARGET_LINK_LIBRARIES(pPowerMangerHost
|
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 <iterator>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdlib>
|
||||||
#include "MBUtils.h"
|
#include "MBUtils.h"
|
||||||
#include "HostSim.h"
|
#include "HostSim.h"
|
||||||
#include "WebServer.h"
|
#include "WebServer.h"
|
||||||
|
#include "FeedbackStore.h"
|
||||||
#include "json/json.h"
|
#include "json/json.h"
|
||||||
|
|
||||||
using namespace std;
|
using namespace std;
|
||||||
|
|
||||||
|
static int64_t hostNowMs()
|
||||||
|
{
|
||||||
|
using namespace std::chrono;
|
||||||
|
return duration_cast<milliseconds>(system_clock::now().time_since_epoch()).count();
|
||||||
|
}
|
||||||
|
|
||||||
//---------------------------------------------------------
|
//---------------------------------------------------------
|
||||||
// Constructor
|
// Constructor
|
||||||
|
|
||||||
HostSim::HostSim()
|
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()
|
HostSim::~HostSim()
|
||||||
{
|
{
|
||||||
|
if (m_feedbackStore) {
|
||||||
|
m_feedbackStore->close();
|
||||||
|
delete m_feedbackStore;
|
||||||
|
m_feedbackStore = nullptr;
|
||||||
|
}
|
||||||
if (m_webServer) {
|
if (m_webServer) {
|
||||||
m_webServer->stop();
|
m_webServer->stop();
|
||||||
delete m_webServer;
|
delete m_webServer;
|
||||||
@@ -79,6 +96,7 @@ bool HostSim::Iterate()
|
|||||||
}
|
}
|
||||||
if (dirty && m_webServer) {
|
if (dirty && m_webServer) {
|
||||||
m_webServer->broadcast(buildSnapshot());
|
m_webServer->broadcast(buildSnapshot());
|
||||||
|
persistFeedback(hostNowMs());
|
||||||
lock_guard<mutex> lock(m_feedbackMutex);
|
lock_guard<mutex> lock(m_feedbackMutex);
|
||||||
m_feedbackDirty = false;
|
m_feedbackDirty = false;
|
||||||
}
|
}
|
||||||
@@ -98,14 +116,34 @@ bool HostSim::OnStartUp()
|
|||||||
for (p = sParams.begin(); p != sParams.end(); p++) {
|
for (p = sParams.begin(); p != sParams.end(); p++) {
|
||||||
string line = *p;
|
string line = *p;
|
||||||
string param = tolower(biteStringX(line, '='));
|
string param = tolower(biteStringX(line, '='));
|
||||||
string value = line;
|
string value = stripBlankEnds(line);
|
||||||
|
|
||||||
if (param == "web_port") {
|
if (param == "web_port") {
|
||||||
m_webPort = atoi(value.c_str());
|
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 = new WebServer();
|
||||||
m_webServer->setOnMessage([this](const string& msg) {
|
m_webServer->setOnMessage([this](const string& msg) {
|
||||||
@@ -114,6 +152,9 @@ bool HostSim::OnStartUp()
|
|||||||
m_webServer->setOnOpen([this]() {
|
m_webServer->setOnOpen([this]() {
|
||||||
return this->buildSnapshot();
|
return this->buildSnapshot();
|
||||||
});
|
});
|
||||||
|
m_webServer->setApiHandler([this](const string& uri, const string& query) {
|
||||||
|
return this->handleApi(uri, query);
|
||||||
|
});
|
||||||
m_webServer->start(m_webPort);
|
m_webServer->start(m_webPort);
|
||||||
|
|
||||||
RegisterVariables();
|
RegisterVariables();
|
||||||
@@ -205,6 +246,99 @@ string HostSim::buildSnapshot()
|
|||||||
return Json::writeString(wb, root);
|
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
|
// Procedure: RegisterVariables
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
|
||||||
class WebServer;
|
class WebServer;
|
||||||
|
class FeedbackStore;
|
||||||
|
|
||||||
// 模拟 pPowerManger 上位机:网页 <-> MOOSDB 桥
|
// 模拟 pPowerManger 上位机:网页 <-> MOOSDB 桥
|
||||||
class HostSim : public CMOOSApp
|
class HostSim : public CMOOSApp
|
||||||
@@ -37,11 +38,14 @@ class HostSim : public CMOOSApp
|
|||||||
void pushCommand(const std::string& key, const std::string& jsonValue);
|
void pushCommand(const std::string& key, const std::string& jsonValue);
|
||||||
// 取当前所有反馈变量的 JSON 快照
|
// 取当前所有反馈变量的 JSON 快照
|
||||||
std::string buildSnapshot();
|
std::string buildSnapshot();
|
||||||
|
// 处理 /api/* 请求,返回 JSON 响应体(空串表示 404)
|
||||||
|
std::string handleApi(const std::string& uri, const std::string& query);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void registerFeedbackVars();
|
void registerFeedbackVars();
|
||||||
bool drainCommands();
|
bool drainCommands();
|
||||||
void handleWebMessage(const std::string& msg);
|
void handleWebMessage(const std::string& msg);
|
||||||
|
void persistFeedback(int64_t nowMs);
|
||||||
|
|
||||||
private: // State variables
|
private: // State variables
|
||||||
// 反馈缓存:key -> 最新 JSON 字符串
|
// 反馈缓存:key -> 最新 JSON 字符串
|
||||||
@@ -56,6 +60,14 @@ class HostSim : public CMOOSApp
|
|||||||
// 网页服务器
|
// 网页服务器
|
||||||
WebServer* m_webServer;
|
WebServer* m_webServer;
|
||||||
int m_webPort;
|
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
|
#endif
|
||||||
|
|||||||
@@ -15,8 +15,24 @@ void WebServer::handleEvent(struct mg_connection* c, int ev, void* ev_data) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 静态资源路由:在嵌入式资源表 webassets 中按路径查找并返回
|
// /api/* 接口:交给宿主程序处理,返回 JSON
|
||||||
std::string path(hm->uri.buf, hm->uri.len);
|
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") {
|
if (path == "/" || path == "/index.html") {
|
||||||
path = "index.html";
|
path = "index.html";
|
||||||
} else if (!path.empty() && path[0] == '/') {
|
} else if (!path.empty() && path[0] == '/') {
|
||||||
@@ -76,6 +92,10 @@ void WebServer::setOnOpen(std::function<std::string()> handler) {
|
|||||||
m_onOpen = 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) {
|
bool WebServer::start(int port) {
|
||||||
if (m_running) return true;
|
if (m_running) return true;
|
||||||
m_port = port;
|
m_port = port;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ public:
|
|||||||
// 新 WebSocket 客户端连接时回调,返回需要立即推送给该客户端的初始文本(如快照)
|
// 新 WebSocket 客户端连接时回调,返回需要立即推送给该客户端的初始文本(如快照)
|
||||||
void setOnOpen(std::function<std::string()> handler);
|
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:
|
private:
|
||||||
static bool serverThreadCB(void* pParam);
|
static bool serverThreadCB(void* pParam);
|
||||||
bool serverThreadFunc();
|
bool serverThreadFunc();
|
||||||
@@ -41,6 +44,7 @@ private:
|
|||||||
std::vector<mg_connection*> m_wsConnections;
|
std::vector<mg_connection*> m_wsConnections;
|
||||||
std::function<void(const std::string&)> m_onMessage;
|
std::function<void(const std::string&)> m_onMessage;
|
||||||
std::function<std::string()> m_onOpen;
|
std::function<std::string()> m_onOpen;
|
||||||
|
std::function<std::string(const std::string&, const std::string&)> m_apiHandler;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // WEBSERVER_H
|
#endif // WEBSERVER_H
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ ProcessConfig = pPowerMangerHost
|
|||||||
AppTick = 4
|
AppTick = 4
|
||||||
CommsTick = 4
|
CommsTick = 4
|
||||||
WEB_PORT = 18080
|
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>
|
<div id="seriesBox" style="margin-bottom:8px"></div>
|
||||||
<canvas id="chart"></canvas>
|
<canvas id="chart"></canvas>
|
||||||
</section>
|
</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>
|
</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 切换
|
// 12. Tab 切换
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1109,7 +1174,6 @@ function switchTab(name) {
|
|||||||
});
|
});
|
||||||
if (name === "chart") { refreshFieldOptions(); drawChart(); }
|
if (name === "chart") { refreshFieldOptions(); drawChart(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 13. 初始化
|
// 13. 初始化
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1168,6 +1232,11 @@ function init() {
|
|||||||
el("btnAddSeries").onclick = () => addSeries(el("fieldSel").value);
|
el("btnAddSeries").onclick = () => addSeries(el("fieldSel").value);
|
||||||
el("btnClearSeries").onclick = () => { history = {}; nextColor = 0; renderSeriesBox(); drawChart(); };
|
el("btnClearSeries").onclick = () => { history = {}; nextColor = 0; renderSeriesBox(); drawChart(); };
|
||||||
|
|
||||||
|
// 历史数据
|
||||||
|
populateHistMsgs();
|
||||||
|
el("histMsg").onchange = populateHistFields;
|
||||||
|
el("btnLoadHistory").onclick = loadHistorySeries;
|
||||||
|
|
||||||
// 日志
|
// 日志
|
||||||
el("btnClearLog").onclick = () => { el("log").innerHTML = ""; };
|
el("btnClearLog").onclick = () => { el("log").innerHTML = ""; };
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user