添加Web配置管理页面
This commit is contained in:
parent
929728f032
commit
a6ee43d6af
16
README.md
16
README.md
@ -76,3 +76,19 @@ The application reads `config.json` next to the executable at:
|
||||
```text
|
||||
/opt/vehicle-video-service/bin/config.json
|
||||
```
|
||||
|
||||
## Web Configuration
|
||||
|
||||
The service includes a small built-in configuration page:
|
||||
|
||||
```text
|
||||
http://<device-ip>:18080
|
||||
```
|
||||
|
||||
Default login:
|
||||
|
||||
```text
|
||||
admin / admin123
|
||||
```
|
||||
|
||||
The page can edit camera channels, live/record switches, SRS ports, record path, MQTT settings, and the management page port. Saving writes `config.json` and regenerates SRS `live.conf` / `record.conf` under the configured SRS root. Restart the related services after saving for runtime changes to take effect.
|
||||
|
||||
16
config.json
16
config.json
@ -13,16 +13,32 @@
|
||||
"root": "/opt/vehicle-video-service/srs",
|
||||
"record_config": "/opt/vehicle-video-service/srs/conf/record.conf",
|
||||
"stream_app": "camera",
|
||||
"live_enabled": true,
|
||||
"record_enabled": true,
|
||||
"live_host": "127.0.0.1",
|
||||
"live_rtmp_port": 1935,
|
||||
"live_http_api_port": 1985,
|
||||
"live_http_server_port": 8080,
|
||||
"live_rtc_port": 8000,
|
||||
"live_vhost": "live",
|
||||
"record_host": "127.0.0.1",
|
||||
"record_rtmp_port": 2935,
|
||||
"record_http_api_port": 2985,
|
||||
"record_http_server_port": 2980,
|
||||
"record_vhost": "record",
|
||||
"record_path": "/media/record",
|
||||
"dvr_duration_sec": 60,
|
||||
"retention_days": 14,
|
||||
"usage_threshold": 0.9,
|
||||
"public_interface": "enP2p33s0"
|
||||
},
|
||||
"web": {
|
||||
"enabled": true,
|
||||
"bind": "0.0.0.0",
|
||||
"port": 18080,
|
||||
"username": "admin",
|
||||
"password": "admin123"
|
||||
},
|
||||
"cameras": [
|
||||
{
|
||||
"device": "/dev/video0",
|
||||
|
||||
@ -70,22 +70,41 @@ struct SRSConfig
|
||||
std::string root = "/opt/vehicle-video-service/srs";
|
||||
std::string record_config = "/opt/vehicle-video-service/srs/conf/record.conf";
|
||||
std::string stream_app = "camera";
|
||||
bool live_enabled = true;
|
||||
bool record_enabled = true;
|
||||
std::string live_host = "127.0.0.1";
|
||||
int live_rtmp_port = 1935;
|
||||
int live_http_api_port = 1985;
|
||||
int live_http_server_port = 8080;
|
||||
int live_rtc_port = 8000;
|
||||
std::string live_vhost = "live";
|
||||
std::string record_host = "127.0.0.1";
|
||||
int record_rtmp_port = 2935;
|
||||
int record_http_api_port = 2985;
|
||||
int record_http_server_port = 2980;
|
||||
std::string record_vhost = "record";
|
||||
std::string record_path = "/media/record";
|
||||
int dvr_duration_sec = 60;
|
||||
int retention_days = 14;
|
||||
double usage_threshold = 0.90;
|
||||
std::string public_interface = "enP2p33s0";
|
||||
};
|
||||
|
||||
struct WebConfig
|
||||
{
|
||||
bool enabled = true;
|
||||
std::string bind = "0.0.0.0";
|
||||
int port = 18080;
|
||||
std::string username = "admin";
|
||||
std::string password = "admin123";
|
||||
};
|
||||
|
||||
struct AppConfig
|
||||
{
|
||||
std::vector<Camera> cameras;
|
||||
MQTTConfig mqtt;
|
||||
SRSConfig srs;
|
||||
WebConfig web;
|
||||
|
||||
static AppConfig load_from_file(const std::string& filepath)
|
||||
{
|
||||
@ -142,17 +161,36 @@ struct AppConfig
|
||||
cfg.srs.root = s.value("root", cfg.srs.root);
|
||||
cfg.srs.record_config = s.value("record_config", cfg.srs.record_config);
|
||||
cfg.srs.stream_app = s.value("stream_app", cfg.srs.stream_app);
|
||||
cfg.srs.live_enabled = s.value("live_enabled", cfg.srs.live_enabled);
|
||||
cfg.srs.record_enabled = s.value("record_enabled", cfg.srs.record_enabled);
|
||||
cfg.srs.live_host = s.value("live_host", cfg.srs.live_host);
|
||||
cfg.srs.live_rtmp_port = s.value("live_rtmp_port", cfg.srs.live_rtmp_port);
|
||||
cfg.srs.live_http_api_port = s.value("live_http_api_port", cfg.srs.live_http_api_port);
|
||||
cfg.srs.live_http_server_port = s.value("live_http_server_port", cfg.srs.live_http_server_port);
|
||||
cfg.srs.live_rtc_port = s.value("live_rtc_port", cfg.srs.live_rtc_port);
|
||||
cfg.srs.live_vhost = s.value("live_vhost", cfg.srs.live_vhost);
|
||||
cfg.srs.record_host = s.value("record_host", cfg.srs.record_host);
|
||||
cfg.srs.record_rtmp_port = s.value("record_rtmp_port", cfg.srs.record_rtmp_port);
|
||||
cfg.srs.record_http_api_port = s.value("record_http_api_port", cfg.srs.record_http_api_port);
|
||||
cfg.srs.record_http_server_port = s.value("record_http_server_port", cfg.srs.record_http_server_port);
|
||||
cfg.srs.record_vhost = s.value("record_vhost", cfg.srs.record_vhost);
|
||||
cfg.srs.record_path = s.value("record_path", cfg.srs.record_path);
|
||||
cfg.srs.dvr_duration_sec = s.value("dvr_duration_sec", cfg.srs.dvr_duration_sec);
|
||||
cfg.srs.retention_days = s.value("retention_days", cfg.srs.retention_days);
|
||||
cfg.srs.usage_threshold = s.value("usage_threshold", cfg.srs.usage_threshold);
|
||||
cfg.srs.public_interface = s.value("public_interface", cfg.srs.public_interface);
|
||||
}
|
||||
|
||||
if (j.contains("web"))
|
||||
{
|
||||
auto& w = j["web"];
|
||||
cfg.web.enabled = w.value("enabled", cfg.web.enabled);
|
||||
cfg.web.bind = w.value("bind", cfg.web.bind);
|
||||
cfg.web.port = w.value("port", cfg.web.port);
|
||||
cfg.web.username = w.value("username", cfg.web.username);
|
||||
cfg.web.password = w.value("password", cfg.web.password);
|
||||
}
|
||||
|
||||
LOG_INFO("[Config] Loaded MQTT server: " + cfg.mqtt.server_ip + ":" + std::to_string(cfg.mqtt.server_port));
|
||||
LOG_INFO("[Config] MQTT client ID: " + cfg.mqtt.client_id);
|
||||
LOG_INFO("[Config] MQTT Credentials - username: " + cfg.mqtt.username + ", password: " + cfg.mqtt.password);
|
||||
|
||||
26
include/config_server.hpp
Normal file
26
include/config_server.hpp
Normal file
@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
#include "app_config.hpp"
|
||||
|
||||
class ConfigServer
|
||||
{
|
||||
public:
|
||||
ConfigServer() = default;
|
||||
~ConfigServer();
|
||||
|
||||
bool start(const std::string& config_path);
|
||||
void stop();
|
||||
|
||||
private:
|
||||
void run();
|
||||
void handle_client(int client_fd);
|
||||
|
||||
std::string config_path_;
|
||||
std::thread thread_;
|
||||
std::atomic<bool> running_{false};
|
||||
int server_fd_ = -1;
|
||||
};
|
||||
@ -5,7 +5,6 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_BIN="$ROOT_DIR/bin/vehicle_video_service"
|
||||
DIST_DIR="${DIST_DIR:-$ROOT_DIR/dist}"
|
||||
PACKAGE_NAME="${PACKAGE_NAME:-vehicle-video-service-aarch64}"
|
||||
STAGE_DIR="$DIST_DIR/$PACKAGE_NAME"
|
||||
|
||||
if [[ "${SKIP_VIDEO_BUILD:-0}" != "1" ]]; then
|
||||
"$ROOT_DIR/scripts/build_video.sh"
|
||||
@ -63,7 +62,11 @@ if [[ ! -x "$APP_BIN" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rm -rf "$STAGE_DIR"
|
||||
mkdir -p "$DIST_DIR"
|
||||
STAGE_PARENT="$(mktemp -d "$DIST_DIR/.package.XXXXXX")"
|
||||
STAGE_DIR="$STAGE_PARENT/$PACKAGE_NAME"
|
||||
trap 'rm -rf "$STAGE_PARENT"' EXIT
|
||||
|
||||
mkdir -p "$STAGE_DIR/bin" "$STAGE_DIR/config" "$STAGE_DIR/srs/bin" "$STAGE_DIR/srs/conf" \
|
||||
"$STAGE_DIR/srs/html" "$STAGE_DIR/srs/log" "$STAGE_DIR/srs/run" "$STAGE_DIR/systemd"
|
||||
|
||||
@ -78,6 +81,6 @@ install -m 0755 "$ROOT_DIR/deploy/install.sh" "$STAGE_DIR/install.sh"
|
||||
install -m 0755 "$ROOT_DIR/deploy/uninstall.sh" "$STAGE_DIR/uninstall.sh"
|
||||
install -m 0644 "$ROOT_DIR/README.md" "$STAGE_DIR/README.md"
|
||||
|
||||
tar -C "$DIST_DIR" -czf "$DIST_DIR/$PACKAGE_NAME.tar.gz" "$PACKAGE_NAME"
|
||||
tar -C "$STAGE_PARENT" -czf "$DIST_DIR/$PACKAGE_NAME.tar.gz" "$PACKAGE_NAME"
|
||||
|
||||
echo "Packaged $DIST_DIR/$PACKAGE_NAME.tar.gz"
|
||||
|
||||
675
src/config_server.cpp
Normal file
675
src/config_server.cpp
Normal file
@ -0,0 +1,675 @@
|
||||
#include "config_server.hpp"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <fcntl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/statvfs.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cerrno>
|
||||
#include <chrono>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "logger.hpp"
|
||||
#include "rtmp_manager.hpp"
|
||||
|
||||
extern AppConfig g_app_config;
|
||||
|
||||
namespace
|
||||
{
|
||||
using json = nlohmann::json;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
std::string read_file(const std::string& path)
|
||||
{
|
||||
std::ifstream ifs(path);
|
||||
if (!ifs.is_open()) return "";
|
||||
std::ostringstream ss;
|
||||
ss << ifs.rdbuf();
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
bool write_file_atomic(const std::string& path, const std::string& content)
|
||||
{
|
||||
std::string tmp = path + ".tmp";
|
||||
{
|
||||
std::ofstream ofs(tmp, std::ios::trunc);
|
||||
if (!ofs.is_open()) return false;
|
||||
ofs << content;
|
||||
if (!ofs.good()) return false;
|
||||
}
|
||||
return ::rename(tmp.c_str(), path.c_str()) == 0;
|
||||
}
|
||||
|
||||
std::string http_reason(int status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case 200:
|
||||
return "OK";
|
||||
case 400:
|
||||
return "Bad Request";
|
||||
case 401:
|
||||
return "Unauthorized";
|
||||
case 404:
|
||||
return "Not Found";
|
||||
case 405:
|
||||
return "Method Not Allowed";
|
||||
case 500:
|
||||
return "Internal Server Error";
|
||||
default:
|
||||
return "OK";
|
||||
}
|
||||
}
|
||||
|
||||
void send_response(int fd, int status, const std::string& content_type, const std::string& body,
|
||||
const std::vector<std::pair<std::string, std::string>>& extra_headers = {})
|
||||
{
|
||||
std::ostringstream resp;
|
||||
resp << "HTTP/1.1 " << status << " " << http_reason(status) << "\r\n";
|
||||
resp << "Content-Type: " << content_type << "\r\n";
|
||||
resp << "Content-Length: " << body.size() << "\r\n";
|
||||
resp << "Connection: close\r\n";
|
||||
for (const auto& h : extra_headers) resp << h.first << ": " << h.second << "\r\n";
|
||||
resp << "\r\n";
|
||||
resp << body;
|
||||
std::string raw = resp.str();
|
||||
::send(fd, raw.data(), raw.size(), MSG_NOSIGNAL);
|
||||
}
|
||||
|
||||
std::string url_decode(const std::string& s)
|
||||
{
|
||||
std::string out;
|
||||
for (size_t i = 0; i < s.size(); ++i)
|
||||
{
|
||||
if (s[i] == '%' && i + 2 < s.size())
|
||||
{
|
||||
char hex[3] = {s[i + 1], s[i + 2], 0};
|
||||
out.push_back(static_cast<char>(strtol(hex, nullptr, 16)));
|
||||
i += 2;
|
||||
}
|
||||
else if (s[i] == '+')
|
||||
{
|
||||
out.push_back(' ');
|
||||
}
|
||||
else
|
||||
{
|
||||
out.push_back(s[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string base64_decode(const std::string& in)
|
||||
{
|
||||
static const std::string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
std::string out;
|
||||
int val = 0;
|
||||
int valb = -8;
|
||||
for (unsigned char c : in)
|
||||
{
|
||||
if (std::isspace(c)) continue;
|
||||
if (c == '=') break;
|
||||
int idx = chars.find(c);
|
||||
if (idx == (int)std::string::npos) return "";
|
||||
val = (val << 6) + idx;
|
||||
valb += 6;
|
||||
if (valb >= 0)
|
||||
{
|
||||
out.push_back(char((val >> valb) & 0xFF));
|
||||
valb -= 8;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool authorized(const std::string& headers)
|
||||
{
|
||||
if (g_app_config.web.username.empty()) return true;
|
||||
std::string needle = "Authorization: Basic ";
|
||||
auto pos = headers.find(needle);
|
||||
if (pos == std::string::npos) return false;
|
||||
pos += needle.size();
|
||||
auto end = headers.find("\r\n", pos);
|
||||
std::string token = headers.substr(pos, end - pos);
|
||||
return base64_decode(token) == g_app_config.web.username + ":" + g_app_config.web.password;
|
||||
}
|
||||
|
||||
std::string make_live_conf(const json& cfg)
|
||||
{
|
||||
const auto& s = cfg.at("srs");
|
||||
std::ostringstream os;
|
||||
os << "listen " << s.value("live_rtmp_port", 1935) << ";\n";
|
||||
os << "max_connections 2000;\n";
|
||||
os << "daemon off;\n";
|
||||
os << "pid ./run/srs_live.pid;\n";
|
||||
os << "srs_log_tank file;\n";
|
||||
os << "srs_log_file ./log/srs_live.log;\n\n";
|
||||
os << "http_server {\n enabled on;\n listen " << s.value("live_http_server_port", 8080)
|
||||
<< ";\n dir ./html;\n}\n\n";
|
||||
os << "http_api {\n enabled on;\n listen " << s.value("live_http_api_port", 1985)
|
||||
<< ";\n}\n\n";
|
||||
os << "stats {\n network 0;\n}\n\n";
|
||||
os << "rtc_server {\n enabled on;\n listen " << s.value("live_rtc_port", 8000)
|
||||
<< ";\n candidate $CANDIDATE;\n}\n\n";
|
||||
os << "vhost " << s.value("live_vhost", "live") << " {\n";
|
||||
os << " rtc {\n enabled on;\n rtmp_to_rtc on;\n rtc_to_rtmp off;\n }\n\n";
|
||||
os << " play {\n mw_latency 100;\n queue_length 10;\n }\n\n";
|
||||
os << " http_remux {\n enabled on;\n mount [vhost]/[app]/[stream].flv;\n }\n\n";
|
||||
os << " dvr {\n enabled off;\n }\n hls {\n enabled off;\n }\n forward {\n enabled off;\n }\n}\n";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
std::string make_record_conf(const json& cfg)
|
||||
{
|
||||
const auto& s = cfg.at("srs");
|
||||
std::string record_path = s.value("record_path", "/media/record");
|
||||
if (!record_path.empty() && record_path.back() == '/') record_path.pop_back();
|
||||
std::ostringstream os;
|
||||
os << "listen " << s.value("record_rtmp_port", 2935) << ";\n";
|
||||
os << "pid ./run/srs_record.pid;\n";
|
||||
os << "srs_log_tank file;\n";
|
||||
os << "srs_log_file ./log/srs_record.log;\n";
|
||||
os << "max_connections 2000;\n";
|
||||
os << "daemon off;\n\n";
|
||||
os << "http_server {\n enabled on;\n listen " << s.value("record_http_server_port", 2980)
|
||||
<< ";\n dir ./html;\n}\n\n";
|
||||
os << "http_api {\n enabled on;\n listen " << s.value("record_http_api_port", 2985)
|
||||
<< ";\n}\n\n";
|
||||
os << "stats {\n network 0;\n}\n\n";
|
||||
os << "vhost " << s.value("record_vhost", "record") << " {\n";
|
||||
os << " dvr {\n enabled " << (s.value("record_enabled", true) ? "on" : "off")
|
||||
<< ";\n dvr_plan segment;\n dvr_duration " << s.value("dvr_duration_sec", 60)
|
||||
<< ";\n dvr_apply all;\n\n";
|
||||
os << " dvr_path " << record_path
|
||||
<< "/[stream]/[2006]-[01]-[02]/[15]/[15]-[04]-[05].mp4;\n\n time_jitter full;\n }\n\n";
|
||||
os << " http_remux {\n enabled on;\n mount [vhost]/[app]/[stream].flv;\n }\n\n";
|
||||
os << " rtc {\n enabled off;\n }\n}\n";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
json disk_status(const std::string& path)
|
||||
{
|
||||
json d;
|
||||
d["path"] = path;
|
||||
struct statvfs vfs{};
|
||||
if (statvfs(path.c_str(), &vfs) != 0)
|
||||
{
|
||||
d["ok"] = false;
|
||||
d["error"] = strerror(errno);
|
||||
return d;
|
||||
}
|
||||
uint64_t total = static_cast<uint64_t>(vfs.f_blocks) * vfs.f_frsize;
|
||||
uint64_t avail = static_cast<uint64_t>(vfs.f_bavail) * vfs.f_frsize;
|
||||
d["ok"] = true;
|
||||
d["total_bytes"] = total;
|
||||
d["available_bytes"] = avail;
|
||||
d["used_percent"] = total == 0 ? 0 : (100.0 * (double)(total - avail) / (double)total);
|
||||
return d;
|
||||
}
|
||||
|
||||
json service_active(const std::string& name)
|
||||
{
|
||||
json s;
|
||||
s["name"] = name;
|
||||
std::string cmd = "systemctl is-active " + name + " 2>/dev/null";
|
||||
FILE* fp = popen(cmd.c_str(), "r");
|
||||
if (!fp)
|
||||
{
|
||||
s["active"] = "unknown";
|
||||
return s;
|
||||
}
|
||||
char buf[64] = {0};
|
||||
std::string out;
|
||||
if (fgets(buf, sizeof(buf), fp)) out = buf;
|
||||
pclose(fp);
|
||||
while (!out.empty() && (out.back() == '\n' || out.back() == '\r')) out.pop_back();
|
||||
s["active"] = out.empty() ? "unknown" : out;
|
||||
return s;
|
||||
}
|
||||
|
||||
json status_json()
|
||||
{
|
||||
json st;
|
||||
st["time_ms"] = std::chrono::duration_cast<std::chrono::milliseconds>(
|
||||
std::chrono::system_clock::now().time_since_epoch())
|
||||
.count();
|
||||
st["public_ip"] = get_ip_address(g_app_config.srs.public_interface);
|
||||
st["disk"] = disk_status(g_app_config.srs.record_path);
|
||||
st["services"] = json::array({service_active("vehicle-video-srs-live.service"),
|
||||
service_active("vehicle-video-srs-record.service"),
|
||||
service_active("vehicle-video-service.service")});
|
||||
st["channels"] = json::array();
|
||||
auto channels = RTMPManager::get_all_channels_status();
|
||||
for (size_t i = 0; i < g_app_config.cameras.size(); ++i)
|
||||
{
|
||||
const auto& cam = g_app_config.cameras[i];
|
||||
json c;
|
||||
c["loc"] = static_cast<int>(i);
|
||||
c["name"] = cam.name;
|
||||
c["device"] = cam.device;
|
||||
c["enabled"] = cam.enabled;
|
||||
c["device_exists"] = access(cam.device.c_str(), F_OK) == 0;
|
||||
if (i < channels.size())
|
||||
{
|
||||
c["running"] = channels[i].running;
|
||||
c["url"] = channels[i].url;
|
||||
c["reason"] = channels[i].reason;
|
||||
}
|
||||
st["channels"].push_back(c);
|
||||
}
|
||||
return st;
|
||||
}
|
||||
|
||||
bool write_srs_configs(const json& cfg, std::string& message)
|
||||
{
|
||||
if (!cfg.contains("srs")) return true;
|
||||
std::string root = cfg["srs"].value("root", "/opt/vehicle-video-service/srs");
|
||||
fs::path conf_dir = fs::path(root) / "conf";
|
||||
std::error_code ec;
|
||||
fs::create_directories(conf_dir, ec);
|
||||
if (ec)
|
||||
{
|
||||
message = "config saved, but failed to create SRS conf dir: " + ec.message();
|
||||
return false;
|
||||
}
|
||||
bool ok1 = write_file_atomic((conf_dir / "live.conf").string(), make_live_conf(cfg));
|
||||
bool ok2 = write_file_atomic((conf_dir / "record.conf").string(), make_record_conf(cfg));
|
||||
if (!ok1 || !ok2)
|
||||
{
|
||||
message = "config saved, but failed to write SRS conf files";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* index_html()
|
||||
{
|
||||
return R"HTML(
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Vehicle Video Service</title>
|
||||
<style>
|
||||
:root { color-scheme: light; font-family: Arial, "Microsoft YaHei", sans-serif; background:#f4f6f8; color:#17202a; }
|
||||
body { margin:0; }
|
||||
header { background:#12343b; color:white; padding:18px 24px; }
|
||||
h1 { margin:0; font-size:22px; font-weight:700; }
|
||||
main { max-width:1180px; margin:0 auto; padding:20px; }
|
||||
section { background:white; border:1px solid #d9e0e6; border-radius:8px; margin-bottom:16px; padding:16px; }
|
||||
h2 { margin:0 0 14px; font-size:17px; }
|
||||
.grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
|
||||
.grid.two { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
label { display:flex; flex-direction:column; gap:6px; font-size:13px; color:#52606d; }
|
||||
input, select { box-sizing:border-box; width:100%; padding:9px 10px; border:1px solid #c8d1da; border-radius:6px; font-size:14px; }
|
||||
input[type=checkbox] { width:auto; }
|
||||
table { width:100%; border-collapse:collapse; font-size:14px; }
|
||||
th, td { border-bottom:1px solid #e6ebef; padding:8px; text-align:left; vertical-align:middle; }
|
||||
th { color:#52606d; font-weight:600; }
|
||||
button { border:0; border-radius:6px; padding:10px 14px; background:#0f766e; color:white; font-weight:700; cursor:pointer; }
|
||||
button.secondary { background:#405261; }
|
||||
.actions { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.pill { display:inline-block; padding:3px 7px; border-radius:999px; background:#e8f2ff; color:#174ea6; font-size:12px; }
|
||||
.warn { color:#9a3412; }
|
||||
.ok { color:#166534; }
|
||||
.muted { color:#667785; }
|
||||
#toast { min-height:24px; margin-left:6px; }
|
||||
@media (max-width: 860px) { .grid, .grid.two { grid-template-columns:1fr; } table { display:block; overflow-x:auto; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>Vehicle Video Service</h1></header>
|
||||
<main>
|
||||
<section>
|
||||
<div class="actions">
|
||||
<button onclick="saveConfig()">保存配置</button>
|
||||
<button class="secondary" onclick="loadAll()">刷新</button>
|
||||
<span id="toast" class="muted"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>运行状态</h2>
|
||||
<div id="status" class="muted">加载中...</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>管理页面</h2>
|
||||
<div class="grid">
|
||||
<label>启用<input id="web_enabled" type="checkbox"></label>
|
||||
<label>监听地址<input id="web_bind"></label>
|
||||
<label>端口<input id="web_port" type="number"></label>
|
||||
<label>用户名<input id="web_username"></label>
|
||||
<label>密码<input id="web_password" type="password"></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>MQTT</h2>
|
||||
<div class="grid">
|
||||
<label>车辆ID<input id="veh_id" type="number"></label>
|
||||
<label>服务器地址<input id="mqtt_addr"></label>
|
||||
<label>端口<input id="mqtt_port" type="number"></label>
|
||||
<label>心跳间隔(ms)<input id="mqtt_keepalive" type="number"></label>
|
||||
<label>Client ID<input id="mqtt_client"></label>
|
||||
<label>用户名<input id="mqtt_user"></label>
|
||||
<label>密码<input id="mqtt_pass" type="password"></label>
|
||||
<label>使用账号密码<input id="mqtt_need_pwd" type="checkbox"></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>SRS 与录像</h2>
|
||||
<div class="grid">
|
||||
<label>SRS根目录<input id="srs_root"></label>
|
||||
<label>录像配置文件<input id="record_config"></label>
|
||||
<label>对外网卡<input id="public_interface"></label>
|
||||
<label>应用名<input id="stream_app"></label>
|
||||
<label>实时流启用<input id="live_enabled" type="checkbox"></label>
|
||||
<label>录像启用<input id="record_enabled" type="checkbox"></label>
|
||||
<label>录像目录<input id="record_path"></label>
|
||||
<label>切片秒数<input id="dvr_duration" type="number"></label>
|
||||
<label>保留天数<input id="retention_days" type="number"></label>
|
||||
<label>磁盘阈值(0-1)<input id="usage_threshold" type="number" step="0.01"></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>端口</h2>
|
||||
<div class="grid">
|
||||
<label>Live RTMP<input id="live_rtmp_port" type="number"></label>
|
||||
<label>Live HTTP API<input id="live_http_api_port" type="number"></label>
|
||||
<label>Live HTTP Server<input id="live_http_server_port" type="number"></label>
|
||||
<label>Live RTC UDP<input id="live_rtc_port" type="number"></label>
|
||||
<label>Record RTMP<input id="record_rtmp_port" type="number"></label>
|
||||
<label>Record HTTP API<input id="record_http_api_port" type="number"></label>
|
||||
<label>Record HTTP Server<input id="record_http_server_port" type="number"></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>视频通道</h2>
|
||||
<table>
|
||||
<thead><tr><th>启用</th><th>名称</th><th>设备</th><th>码率</th><th>宽</th><th>高</th><th>FPS</th></tr></thead>
|
||||
<tbody id="camera_rows"></tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
let config = {};
|
||||
const $ = id => document.getElementById(id);
|
||||
const num = id => Number($(id).value);
|
||||
|
||||
async function api(path, options) {
|
||||
const res = await fetch(path, options);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
function setToast(text, cls='muted') { const t=$('toast'); t.className=cls; t.textContent=text; }
|
||||
|
||||
function fillConfig() {
|
||||
const m = config.mqtt_server || {};
|
||||
const s = config.srs || {};
|
||||
const w = config.web || {};
|
||||
$('veh_id').value = m.veh_id ?? 0;
|
||||
$('mqtt_addr').value = m.address ?? '';
|
||||
$('mqtt_port').value = m.port ?? 1883;
|
||||
$('mqtt_keepalive').value = m.mqtt_heart_threshold ?? 1000;
|
||||
$('mqtt_client').value = m.client_id ?? '';
|
||||
$('mqtt_user').value = m.username ?? '';
|
||||
$('mqtt_pass').value = m.password ?? '';
|
||||
$('mqtt_need_pwd').checked = !!m.need_username_pwd;
|
||||
$('web_enabled').checked = w.enabled ?? true;
|
||||
$('web_bind').value = w.bind ?? '0.0.0.0';
|
||||
$('web_port').value = w.port ?? 18080;
|
||||
$('web_username').value = w.username ?? 'admin';
|
||||
$('web_password').value = w.password ?? '';
|
||||
$('srs_root').value = s.root ?? '/opt/vehicle-video-service/srs';
|
||||
$('record_config').value = s.record_config ?? '';
|
||||
$('public_interface').value = s.public_interface ?? 'enP2p33s0';
|
||||
$('stream_app').value = s.stream_app ?? 'camera';
|
||||
$('live_enabled').checked = s.live_enabled ?? true;
|
||||
$('record_enabled').checked = s.record_enabled ?? true;
|
||||
$('record_path').value = s.record_path ?? '/media/record';
|
||||
$('dvr_duration').value = s.dvr_duration_sec ?? 60;
|
||||
$('retention_days').value = s.retention_days ?? 14;
|
||||
$('usage_threshold').value = s.usage_threshold ?? 0.9;
|
||||
['live_rtmp_port','live_http_api_port','live_http_server_port','live_rtc_port','record_rtmp_port','record_http_api_port','record_http_server_port'].forEach(id => $(id).value = s[id] ?? '');
|
||||
renderCameras();
|
||||
}
|
||||
|
||||
function renderCameras() {
|
||||
const tbody = $('camera_rows');
|
||||
tbody.innerHTML = '';
|
||||
(config.cameras || []).forEach((c, i) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td><input type="checkbox" data-i="${i}" data-k="enabled" ${c.enabled ? 'checked' : ''}></td>
|
||||
<td><input data-i="${i}" data-k="name" value="${c.name ?? ''}"></td>
|
||||
<td><input data-i="${i}" data-k="device" value="${c.device ?? ''}"></td>
|
||||
<td><input type="number" data-i="${i}" data-k="bitrate" value="${c.bitrate ?? 2000000}"></td>
|
||||
<td><input type="number" data-i="${i}" data-k="width" value="${c.width ?? 1280}"></td>
|
||||
<td><input type="number" data-i="${i}" data-k="height" value="${c.height ?? 960}"></td>
|
||||
<td><input type="number" data-i="${i}" data-k="fps" value="${c.fps ?? 30}"></td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function collectConfig() {
|
||||
config.mqtt_server = config.mqtt_server || {};
|
||||
Object.assign(config.mqtt_server, {
|
||||
veh_id: num('veh_id'), address: $('mqtt_addr').value, port: num('mqtt_port'),
|
||||
need_username_pwd: $('mqtt_need_pwd').checked, client_id: $('mqtt_client').value,
|
||||
username: $('mqtt_user').value, password: $('mqtt_pass').value,
|
||||
mqtt_heart_threshold: num('mqtt_keepalive')
|
||||
});
|
||||
config.web = { enabled:$('web_enabled').checked, bind:$('web_bind').value, port:num('web_port'), username:$('web_username').value, password:$('web_password').value };
|
||||
config.srs = config.srs || {};
|
||||
Object.assign(config.srs, {
|
||||
root:$('srs_root').value, record_config:$('record_config').value, public_interface:$('public_interface').value,
|
||||
stream_app:$('stream_app').value, live_enabled:$('live_enabled').checked, record_enabled:$('record_enabled').checked,
|
||||
record_path:$('record_path').value, dvr_duration_sec:num('dvr_duration'), retention_days:num('retention_days'),
|
||||
usage_threshold:Number($('usage_threshold').value),
|
||||
live_rtmp_port:num('live_rtmp_port'), live_http_api_port:num('live_http_api_port'), live_http_server_port:num('live_http_server_port'),
|
||||
live_rtc_port:num('live_rtc_port'), record_rtmp_port:num('record_rtmp_port'), record_http_api_port:num('record_http_api_port'),
|
||||
record_http_server_port:num('record_http_server_port')
|
||||
});
|
||||
document.querySelectorAll('#camera_rows input').forEach(input => {
|
||||
const i = Number(input.dataset.i), k = input.dataset.k;
|
||||
if (!config.cameras[i]) return;
|
||||
config.cameras[i][k] = input.type === 'checkbox' ? input.checked : (input.type === 'number' ? Number(input.value) : input.value);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadConfig() { config = await api('/api/config'); fillConfig(); }
|
||||
async function loadStatus() {
|
||||
const st = await api('/api/status');
|
||||
const disk = st.disk || {};
|
||||
$('status').innerHTML = `
|
||||
<div>对外IP:<span class="pill">${st.public_ip || 'unknown'}</span></div>
|
||||
<div>录像磁盘:${disk.ok ? `<span class="ok">${disk.used_percent.toFixed(1)}% used</span>` : `<span class="warn">${disk.error || 'unavailable'}</span>`}</div>
|
||||
<div>服务:${(st.services||[]).map(s => `${s.name}: <span class="${s.active==='active'?'ok':'warn'}">${s.active}</span>`).join(' | ')}</div>
|
||||
<div>通道:${(st.channels||[]).map(c => `${c.name} ${c.device_exists?'':'(无设备)'} ${c.running?'运行':'停止'}`).join(',')}</div>`;
|
||||
}
|
||||
async function saveConfig() {
|
||||
try {
|
||||
collectConfig();
|
||||
const res = await api('/api/config', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(config, null, 2) });
|
||||
setToast(res.message || '已保存,重启服务后生效', res.ok ? 'ok' : 'warn');
|
||||
} catch (e) { setToast(e.message, 'warn'); }
|
||||
}
|
||||
async function loadAll() { try { await loadConfig(); await loadStatus(); setToast('已刷新'); } catch(e) { setToast(e.message, 'warn'); } }
|
||||
loadAll();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
)HTML";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
ConfigServer::~ConfigServer() { stop(); }
|
||||
|
||||
bool ConfigServer::start(const std::string& config_path)
|
||||
{
|
||||
if (!g_app_config.web.enabled) return true;
|
||||
config_path_ = config_path;
|
||||
running_.store(true);
|
||||
thread_ = std::thread([this]() { run(); });
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConfigServer::stop()
|
||||
{
|
||||
running_.store(false);
|
||||
if (server_fd_ >= 0)
|
||||
{
|
||||
::shutdown(server_fd_, SHUT_RDWR);
|
||||
::close(server_fd_);
|
||||
server_fd_ = -1;
|
||||
}
|
||||
if (thread_.joinable()) thread_.join();
|
||||
}
|
||||
|
||||
void ConfigServer::run()
|
||||
{
|
||||
server_fd_ = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (server_fd_ < 0)
|
||||
{
|
||||
LOG_ERROR("[ConfigServer] socket failed");
|
||||
return;
|
||||
}
|
||||
|
||||
int opt = 1;
|
||||
setsockopt(server_fd_, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
|
||||
|
||||
sockaddr_in addr{};
|
||||
addr.sin_family = AF_INET;
|
||||
addr.sin_port = htons(g_app_config.web.port);
|
||||
if (inet_pton(AF_INET, g_app_config.web.bind.c_str(), &addr.sin_addr) != 1)
|
||||
{
|
||||
addr.sin_addr.s_addr = INADDR_ANY;
|
||||
}
|
||||
|
||||
if (bind(server_fd_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0)
|
||||
{
|
||||
LOG_ERROR(std::string("[ConfigServer] bind failed: ") + strerror(errno));
|
||||
return;
|
||||
}
|
||||
if (listen(server_fd_, 16) != 0)
|
||||
{
|
||||
LOG_ERROR(std::string("[ConfigServer] listen failed: ") + strerror(errno));
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("[ConfigServer] listening on " + g_app_config.web.bind + ":" + std::to_string(g_app_config.web.port));
|
||||
|
||||
while (running_.load())
|
||||
{
|
||||
int client = accept(server_fd_, nullptr, nullptr);
|
||||
if (client < 0)
|
||||
{
|
||||
if (running_.load()) LOG_WARN(std::string("[ConfigServer] accept failed: ") + strerror(errno));
|
||||
continue;
|
||||
}
|
||||
handle_client(client);
|
||||
close(client);
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigServer::handle_client(int client_fd)
|
||||
{
|
||||
std::string req;
|
||||
char buf[4096];
|
||||
while (req.find("\r\n\r\n") == std::string::npos)
|
||||
{
|
||||
ssize_t n = recv(client_fd, buf, sizeof(buf), 0);
|
||||
if (n <= 0) return;
|
||||
req.append(buf, n);
|
||||
if (req.size() > 1024 * 1024) return;
|
||||
}
|
||||
|
||||
auto header_end = req.find("\r\n\r\n");
|
||||
std::string headers = req.substr(0, header_end + 4);
|
||||
std::string body = req.substr(header_end + 4);
|
||||
|
||||
std::istringstream first_line(headers.substr(0, headers.find("\r\n")));
|
||||
std::string method, target, version;
|
||||
first_line >> method >> target >> version;
|
||||
std::string path = target.substr(0, target.find('?'));
|
||||
path = url_decode(path);
|
||||
|
||||
size_t content_length = 0;
|
||||
auto cl = headers.find("Content-Length:");
|
||||
if (cl != std::string::npos)
|
||||
{
|
||||
cl += strlen("Content-Length:");
|
||||
auto end = headers.find("\r\n", cl);
|
||||
content_length = std::stoul(headers.substr(cl, end - cl));
|
||||
}
|
||||
while (body.size() < content_length)
|
||||
{
|
||||
ssize_t n = recv(client_fd, buf, sizeof(buf), 0);
|
||||
if (n <= 0) break;
|
||||
body.append(buf, n);
|
||||
}
|
||||
|
||||
if (!authorized(headers))
|
||||
{
|
||||
send_response(client_fd, 401, "text/plain; charset=utf-8", "Unauthorized",
|
||||
{{"WWW-Authenticate", "Basic realm=\"Vehicle Video Service\""}});
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (method == "GET" && (path == "/" || path == "/index.html"))
|
||||
{
|
||||
send_response(client_fd, 200, "text/html; charset=utf-8", index_html());
|
||||
}
|
||||
else if (method == "GET" && path == "/api/config")
|
||||
{
|
||||
std::string content = read_file(config_path_);
|
||||
if (content.empty()) send_response(client_fd, 500, "application/json", R"({"ok":false})");
|
||||
else send_response(client_fd, 200, "application/json; charset=utf-8", content);
|
||||
}
|
||||
else if (method == "POST" && path == "/api/config")
|
||||
{
|
||||
json cfg = json::parse(body);
|
||||
std::string dumped = cfg.dump(2);
|
||||
if (!write_file_atomic(config_path_, dumped + "\n"))
|
||||
{
|
||||
send_response(client_fd, 500, "application/json", R"({"ok":false,"message":"failed to write config"})");
|
||||
return;
|
||||
}
|
||||
std::string msg = "saved. restart services to apply runtime changes";
|
||||
bool srs_ok = write_srs_configs(cfg, msg);
|
||||
send_response(client_fd, 200, "application/json; charset=utf-8",
|
||||
json({{"ok", srs_ok}, {"message", msg}, {"requires_restart", true}}).dump(2));
|
||||
}
|
||||
else if (method == "GET" && path == "/api/status")
|
||||
{
|
||||
send_response(client_fd, 200, "application/json; charset=utf-8", status_json().dump(2));
|
||||
}
|
||||
else
|
||||
{
|
||||
send_response(client_fd, 404, "text/plain; charset=utf-8", "Not found");
|
||||
}
|
||||
}
|
||||
catch (const std::exception& e)
|
||||
{
|
||||
send_response(client_fd, 400, "application/json; charset=utf-8",
|
||||
json({{"ok", false}, {"message", e.what()}}).dump(2));
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
#include <thread>
|
||||
|
||||
#include "app_config.hpp"
|
||||
#include "config_server.hpp"
|
||||
#include "logger.hpp"
|
||||
#include "mqtt_client_wrapper.hpp"
|
||||
#include "record_manager.hpp"
|
||||
@ -45,8 +46,13 @@ int main()
|
||||
return -1;
|
||||
}
|
||||
|
||||
const std::string config_path = get_executable_dir_file_path("config.json");
|
||||
|
||||
g_record_manager = std::make_shared<RecordManager>(g_app_config.srs.record_config);
|
||||
|
||||
ConfigServer config_server;
|
||||
config_server.start(config_path);
|
||||
|
||||
RTMPManager::init();
|
||||
|
||||
LOG_INFO("[MAIN] Starting all record streams...");
|
||||
@ -73,6 +79,8 @@ int main()
|
||||
|
||||
if (g_record_manager) g_record_manager->stopAutoScan();
|
||||
|
||||
config_server.stop();
|
||||
|
||||
RTMPManager::stop_all();
|
||||
|
||||
if (mqtt_thread.joinable())
|
||||
|
||||
@ -115,24 +115,25 @@ GstElement* RTMPManager::create_pipeline(const Camera& cam)
|
||||
" ! mpph264enc bps=" +
|
||||
std::to_string(bitrate) + " gop=" + std::to_string(fps) +
|
||||
" rc-mode=cbr "
|
||||
" ! h264parse ! tee name=t "
|
||||
" ! h264parse ! tee name=t ";
|
||||
|
||||
// ------ 分支1:live ------
|
||||
"t. ! queue max-size-buffers=5 leaky=downstream "
|
||||
" ! flvmux streamable=true name=mux_live "
|
||||
" ! rtmpsink location=\"" +
|
||||
live_rtmp +
|
||||
"\" sync=false async=false "
|
||||
if (srs.live_enabled)
|
||||
{
|
||||
pipeline_str += "t. ! queue max-size-buffers=5 leaky=downstream "
|
||||
" ! flvmux streamable=true name=mux_live "
|
||||
" ! rtmpsink location=\"" +
|
||||
live_rtmp + "\" sync=false async=false ";
|
||||
}
|
||||
|
||||
// ------ 分支2:record ------
|
||||
"t. ! queue max-size-buffers=5 leaky=downstream "
|
||||
" ! flvmux streamable=true name=mux_record "
|
||||
" ! rtmpsink location=\"" +
|
||||
record_rtmp +
|
||||
"\" sync=false async=false "
|
||||
if (srs.record_enabled)
|
||||
{
|
||||
pipeline_str += "t. ! queue max-size-buffers=5 leaky=downstream "
|
||||
" ! flvmux streamable=true name=mux_record "
|
||||
" ! rtmpsink location=\"" +
|
||||
record_rtmp + "\" sync=false async=false ";
|
||||
}
|
||||
|
||||
// ------ 分支3:AI ------
|
||||
"t. ! queue ! fakesink sync=false";
|
||||
pipeline_str += "t. ! queue ! fakesink sync=false";
|
||||
|
||||
GError* error = nullptr;
|
||||
GstElement* pipeline = gst_parse_launch(pipeline_str.c_str(), &error);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user