🌈 Sync 2026-07-29 14:47:30

This commit is contained in:
github-actions[bot] committed 2026-07-29 14:47:30 +08:00
1 parent d31b90c3d6
commit 9419456fd2
48 files changed
+13901 -3399

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
# luci-app-pingpacket
`luci-app-pingpacket` 是一个面向 OpenWrt 的 LuCI 状态监控插件,用于持续观察国内直连质量和国外经代理访问质量,并提供统计、日志和快速排障能力。
## 界面截图
<table>
<tr>
<td width="50%" align="center">
<img src="jpg/1.png" alt="Ping丢包监控总览" width="100%">
<br>
<sub>监控总览</sub>
</td>
<td width="50%" align="center">
<img src="jpg/2.png" alt="Ping丢包监控设置" width="100%">
<br>
<sub>监控设置</sub>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<img src="jpg/3.png" alt="Ping丢包监控运行日志" width="78%">
<br>
<sub>运行日志</sub>
</td>
</tr>
</table>
## 适用平台
- OpenWrt 24.10.x,发布 `ipk` 安装包
- OpenWrt 25.12.x,发布 `apk` 安装包
- 已安装 LuCI Web 管理界面
- 设备需具备 `ping`、`curl`、`awk`、`sed`、`grep`、`tail` 等基础命令
当前仓库的 GitHub Actions 工作流默认构建 `x86/64` SDK 版本;插件本身为 LuCI 页面和 shell 脚本,原则上也可移植到其他支持 LuCI 的 OpenWrt 架构。
## 页面位置
后台路径:
`状态 -> Ping丢包监控`
子页面:
- `监控`
- `日志`
## 主要功能
### 监控页
- 支持分别配置国内目标和国外测速 URL
- 国内目标通过本机 `ping` 进行 ICMP 探测
- 国外目标通过 `curl` + `socks5h` 代理进行连续测速,更接近代理实际访问质量
- 国外测速仅将 HTTP `200/204` 视为成功
- 页面内可直接启用或关闭监控服务
- 点击“保存并应用”后会保存配置;如果当前已启用监控,服务会自动重启并按新配置重新开始累计统计
- 当检测到“数据停滞”时,页面会直接显示“重新启动服务”按钮
- 实时显示开始时间、运行时长、最近更新时间、CPU 占用、内存占用和服务状态
### 统计卡片
国内和国外分别独立统计以下数据:
- 平均延迟
- 最低延迟
- 最高延迟
- 丢包率
- 丢包数
- 样本数
统计口径说明:
- 以上数据均为“本次服务启用后的累计统计值”
- 停止服务、重新启用服务或重启服务后,统计会重新开始
- 国内和国外分别独立累计,不合并计算
### 日志页
- 显示插件运行日志
- 顶部支持“自动刷新”开关,默认勾选
- 支持“清除日志”按钮
- 显示日志最后更新时间、日志行数和日志大小
- 默认保留最近 500 行日志
当前日志会记录:
- 服务启动
- 服务停止
- 单次丢包事件
- 恢复事件
- 国外代理测速失败原因
日志规则:
- 只要单次探测失败,就立即记录一条日志
- 连续失败时,每次失败都会单独记录
- 故障后的首次成功响应会记录恢复日志
## 当前探测方式
### 国内
- 目标类型:IP 或域名
- 探测方式:ICMP `ping`
- 默认目标:`www.baidu.com`
- 默认 Ping 间隔:`1` 秒
### 国外
- 目标类型:域名或完整 URL
- 探测方式:通过 `socks5h` 代理发起 `curl` 请求
- 默认测速 URL:`https://www.google.com/generate_204`
- 默认代理端口:`7891`
- 默认测速间隔:`5` 秒
- 代理主机固定为 OpenWrt 本机 `127.0.0.1`
## 配置规则
- 国内和国外目标可以只填其中一个
- 两个目标都为空时,不能“保存并应用”
- 两个目标都为空时,也不能通过开关启用服务
- 若填写国外测速 URL,则必须同时填写代理端口
- 代理端口必须为 `1-65535` 的数字
- 外国测速 URL 未填写协议时,系统会自动按 `https://` 处理
- 页面轮询刷新时,不会覆盖用户正在编辑但尚未保存的输入内容
## 默认配置
安装或补齐配置时,默认值如下:
- 国内目标:`www.baidu.com`
- 国外测速 URL:`https://www.google.com/generate_204`
- 国外代理类型:`socks5h`
- 国外代理端口:`7891`
- 国内 Ping 间隔:`1`
- 国外测速间隔:`5`
默认 UCI 配置示例:
```uci
config pingpacket 'config'
option enabled '0'
option domestic_target 'www.baidu.com'
option foreign_target 'https://www.google.com/generate_204'
option foreign_proxy_type 'socks5h'
option foreign_proxy_host '127.0.0.1'
option foreign_proxy_port '7891'
option domestic_interval '1'
option foreign_interval '5'
```
## 依赖
- `luci-base`
- `luci-lua-runtime`
- `luci-compat`
- `curl`
## 安装
### OpenWrt 24.10.x
上传 `ipk` 安装包后执行:
```sh
opkg install luci-app-pingpacket_v1.0.22_all.ipk
```
### OpenWrt 25.12.x
上传 `apk` 安装包后执行:
```sh
apk add --allow-untrusted ./luci-app-pingpacket_v1.0.22_all.apk
```
说明:
- 升级时会尽量保留已有 `/etc/config/pingpacket`
- 若系统中存在历史遗留的 `/etc/config/pingpacket-opkg`,安装脚本会尽量兼容处理
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

@@ -0,0 +1,2 @@
# Auto detect text files and perform LF normalization
* text=auto
@@ -0,0 +1,55 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI web interface for Pingpacket monitoring
LUCI_DEPENDS:=+luci-base +luci-lua-runtime +luci-compat +curl
LUCI_PKGARCH:=all
PKG_NAME:=luci-app-pingpacket
PKG_VERSION:=1.0.0
PKG_RELEASE:=20
PKG_LICENSE:=Apache-2.0
include $(TOPDIR)/feeds/luci/luci.mk
define Package/$(PKG_NAME)/install
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller
$(INSTALL_DATA) ./luasrc/controller/pingpacket.lua $(1)/usr/lib/lua/luci/controller/pingpacket.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/view/pingpacket
$(INSTALL_DATA) ./luasrc/view/pingpacket/status.htm $(1)/usr/lib/lua/luci/view/pingpacket/status.htm
$(INSTALL_DATA) ./luasrc/view/pingpacket/logs.htm $(1)/usr/lib/lua/luci/view/pingpacket/logs.htm
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) ./root/etc/config/pingpacket $(1)/etc/config/pingpacket
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./root/etc/init.d/pingpacket $(1)/etc/init.d/pingpacket
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) ./root/usr/bin/pingpacket.sh $(1)/usr/bin/pingpacket.sh
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-pingpacket.json \
$(1)/usr/share/rpcd/acl.d/luci-app-pingpacket.json
endef
define Package/$(PKG_NAME)/postinst
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] && exit 0
rm -f /tmp/luci-indexcache.*
rm -rf /tmp/luci-modulecache/
/etc/init.d/rpcd reload 2>/dev/null
exit 0
endef
define Package/$(PKG_NAME)/prerm
#!/bin/sh
[ -n "$${IPKG_INSTROOT}" ] && exit 0
[ -f /etc/init.d/pingpacket ] && {
/etc/init.d/pingpacket disable 2>/dev/null
/etc/init.d/pingpacket stop 2>/dev/null
}
exit 0
endef
$(eval $(call BuildPackage,$(PKG_NAME)))
@@ -0,0 +1,518 @@
module("luci.controller.pingpacket", package.seeall)
local LOG_FILE = "/tmp/pingpacket.log"
local CPU_STATE_FILE = "/tmp/pingpacket_cpu_state"
local DEFAULT_PROXY_HOST = "127.0.0.1"
local function trim(value)
return (value or ""):gsub("%c", ""):match("^%s*(.-)%s*$") or ""
end
local function normalize_enabled(value)
return value == "1" and "1" or "0"
end
local function normalize_proxy_type(value)
return "socks5h"
end
local function normalize_proxy_port(value)
value = trim(value)
if value == "" then
return ""
end
if not value:match("^%d+$") then
return nil
end
local port = tonumber(value)
if not port or port < 1 or port > 65535 then
return nil
end
return tostring(port)
end
local function normalize_proxy_host(value)
value = trim(value)
if value ~= "" then
return value
end
return DEFAULT_PROXY_HOST
end
local function normalize_probe_interval(value)
value = trim(value)
if value == "" then
return "1"
end
if not value:match("^%d+$") then
return nil
end
local interval = tonumber(value)
if not interval or interval < 1 then
return nil
end
return tostring(interval)
end
local function normalize_foreign_probe_interval(value)
value = trim(value)
if value == "" then
return "5"
end
return normalize_probe_interval(value)
end
local function write_json(payload)
local http = require("luci.http")
local jsonc = require("luci.jsonc")
http.prepare_content("application/json")
http.write(jsonc.stringify(payload))
end
local function read_start_time()
local start_time_file = io.open("/var/run/pingpacket/start_time", "r")
if not start_time_file then
return ""
end
local value = trim(start_time_file:read("*a"))
start_time_file:close()
return value
end
local function should_require_target(request_source, enabled)
if request_source == "save" then
return true
end
return enabled == "1"
end
local function has_any_target(uci)
local domestic_target = trim(uci:get("pingpacket", "config", "domestic_target"))
local foreign_target = trim(uci:get("pingpacket", "config", "foreign_target"))
return domestic_target ~= "" or foreign_target ~= ""
end
local function has_valid_foreign_proxy(uci)
local foreign_target = trim(uci:get("pingpacket", "config", "foreign_target"))
local proxy_port = trim(uci:get("pingpacket", "config", "foreign_proxy_port"))
if foreign_target == "" then
return true
end
return proxy_port:match("^%d+$") and tonumber(proxy_port) and tonumber(proxy_port) >= 1 and tonumber(proxy_port) <= 65535
end
local function read_file_content(path)
local file = io.open(path, "r")
if not file then
return ""
end
local content = file:read("*a") or ""
file:close()
return content
end
local function read_log_payload()
local nixio_fs = require("nixio.fs")
local stat = nixio_fs.stat(LOG_FILE)
return {
content = read_file_content(LOG_FILE),
updated_at = (stat and stat.mtime) and os.date("%Y-%m-%d %H:%M:%S", stat.mtime) or "",
size = (stat and stat.size) or 0
}
end
local function read_cpu_sample()
local file = io.open("/proc/stat", "r")
if not file then
return nil, nil
end
local line = file:read("*l") or ""
file:close()
local total = 0
local idle = 0
local index = 0
for token in line:gmatch("%S+") do
if token ~= "cpu" then
index = index + 1
if index > 8 then
break
end
local value = tonumber(token) or 0
total = total + value
if index == 4 or index == 5 then
idle = idle + value
end
end
end
if total <= 0 then
return nil, nil
end
return total, idle
end
local function read_saved_cpu_sample()
local content = trim(read_file_content(CPU_STATE_FILE))
local total, idle, timestamp = content:match("^(%d+)%s+(%d+)%s*(%d*)$")
if not total or not idle then
return nil, nil, nil
end
timestamp = tonumber(timestamp)
return tonumber(total), tonumber(idle), timestamp
end
local function write_cpu_sample(total, idle)
local file = io.open(CPU_STATE_FILE, "w")
if not file then
return
end
file:write(string.format("%d %d %d\n", total, idle, os.time()))
file:close()
end
local function format_percent(value)
value = tonumber(value) or 0
if value < 0 then
value = 0
elseif value > 100 then
value = 100
end
return string.format("%.1f", value)
end
local function read_memory_usage()
local file = io.open("/proc/meminfo", "r")
if not file then
return "0.0"
end
local mem_total
local mem_available
local mem_free = 0
local buffers = 0
local cached = 0
for line in file:lines() do
local key, value = line:match("^(%w+):%s+(%d+)")
value = tonumber(value)
if key == "MemTotal" then
mem_total = value
elseif key == "MemAvailable" then
mem_available = value
elseif key == "MemFree" then
mem_free = value or 0
elseif key == "Buffers" then
buffers = value or 0
elseif key == "Cached" then
cached = value or 0
end
end
file:close()
if not mem_total or mem_total <= 0 then
return "0.0"
end
if not mem_available then
mem_available = mem_free + buffers + cached
end
return format_percent(((mem_total - mem_available) * 100) / mem_total)
end
local function read_system_metrics()
local current_total, current_idle = read_cpu_sample()
local previous_total, previous_idle, previous_timestamp = read_saved_cpu_sample()
local cpu_usage = "0.0"
if current_total and current_idle then
if not previous_timestamp or (os.time() - previous_timestamp) > 10 then
previous_total = nil
previous_idle = nil
end
if previous_total and previous_idle and current_total > previous_total then
local total_delta = current_total - previous_total
local idle_delta = current_idle - previous_idle
if total_delta > 0 then
cpu_usage = format_percent(((total_delta - idle_delta) * 100) / total_delta)
end
end
write_cpu_sample(current_total, current_idle)
end
return {
cpu_usage = cpu_usage,
memory_usage = read_memory_usage()
}
end
local function validate_config(request_source, enabled, domestic, foreign, proxy_port)
if should_require_target(request_source, enabled) and domestic == "" and foreign == "" then
return false, "请至少填写一个监控目标。"
end
if foreign ~= "" and proxy_port == "" then
return false, "已填写国外目标时,请同时填写代理端口。"
end
return true, ""
end
function index()
if not nixio.fs.access("/etc/config/pingpacket") then
return
end
local page = entry(
{"admin", "status", "pingpacket"},
alias("admin", "status", "pingpacket", "monitor"),
"Ping丢包监控",
40
)
page.dependent = true
page.acl_depends = { "luci-app-pingpacket" }
entry({"admin", "status", "pingpacket", "monitor"}, template("pingpacket/status"), "监控", 1).leaf = true
entry({"admin", "status", "pingpacket", "logs"}, template("pingpacket/logs"), "日志", 2).leaf = true
entry({"admin", "status", "pingpacket", "status"}, alias("admin", "status", "pingpacket", "monitor")).leaf = true
entry({"admin", "status", "pingpacket", "get_data"}, call("action_get_data")).leaf = true
entry({"admin", "status", "pingpacket", "get_logs"}, call("action_get_logs")).leaf = true
entry({"admin", "status", "pingpacket", "clear_logs"}, call("action_clear_logs")).leaf = true
entry({"admin", "status", "pingpacket", "save_config"}, call("action_save_config")).leaf = true
entry({"admin", "status", "pingpacket", "restart_service"}, call("action_restart_service")).leaf = true
end
function action_get_data()
local uci = require("luci.model.uci").cursor()
local sys = require("luci.sys")
local jsonc = require("luci.jsonc")
local enabled = normalize_enabled(uci:get("pingpacket", "config", "enabled"))
local domestic_target = trim(uci:get("pingpacket", "config", "domestic_target"))
local foreign_target = trim(uci:get("pingpacket", "config", "foreign_target"))
local foreign_proxy_type = normalize_proxy_type(uci:get("pingpacket", "config", "foreign_proxy_type"))
local foreign_proxy_host = normalize_proxy_host(uci:get("pingpacket", "config", "foreign_proxy_host"))
local foreign_proxy_port = trim(uci:get("pingpacket", "config", "foreign_proxy_port"))
local domestic_interval = normalize_probe_interval(uci:get("pingpacket", "config", "domestic_interval")) or "1"
local foreign_interval = normalize_foreign_probe_interval(uci:get("pingpacket", "config", "foreign_interval")) or "5"
local result = {
enabled = enabled,
domestic_target = domestic_target,
foreign_target = foreign_target,
foreign_proxy_type = foreign_proxy_type,
foreign_proxy_host = foreign_proxy_host,
foreign_proxy_port = foreign_proxy_port,
domestic_interval = domestic_interval,
foreign_interval = foreign_interval,
server_time = os.date("%Y-%m-%d %H:%M:%S"),
start_time = read_start_time(),
updated_at = "",
service_running = (sys.call("[ -s /var/run/pingpacket/start_time ]") == 0),
system = read_system_metrics(),
domestic = {
avg = "0.0",
min = "0.0",
max = "0.0",
loss_rate = "0.0",
loss_count = 0,
count = 0,
samples = 0
},
foreign = {
avg = "0.0",
min = "0.0",
max = "0.0",
loss_rate = "0.0",
loss_count = 0,
count = 0,
samples = 0
}
}
local status_file = io.open("/tmp/pingpacket_status.json", "r")
if status_file then
local content = status_file:read("*a")
status_file:close()
local ok, data = pcall(jsonc.parse, content)
if ok and data then
if data.start_time then
result.start_time = data.start_time
end
if data.updated_at then
result.updated_at = data.updated_at
end
if data.domestic then
result.domestic = data.domestic
end
if data.foreign then
result.foreign = data.foreign
end
end
end
write_json(result)
end
function action_get_logs()
local payload = read_log_payload()
payload.server_time = os.date("%Y-%m-%d %H:%M:%S")
write_json(payload)
end
function action_clear_logs()
local nixio_fs = require("nixio.fs")
nixio_fs.remove(LOG_FILE)
write_json({
success = true,
message = ""
})
end
function action_save_config()
local uci = require("luci.model.uci").cursor()
local sys = require("luci.sys")
local http = require("luci.http")
local enabled = normalize_enabled(http.formvalue("enabled"))
local domestic = trim(http.formvalue("domestic_target"))
local foreign = trim(http.formvalue("foreign_target"))
local proxy_type = normalize_proxy_type(http.formvalue("foreign_proxy_type"))
local proxy_host = DEFAULT_PROXY_HOST
local proxy_port = normalize_proxy_port(http.formvalue("foreign_proxy_port"))
local domestic_interval = normalize_probe_interval(http.formvalue("domestic_interval"))
local foreign_interval = normalize_foreign_probe_interval(http.formvalue("foreign_interval"))
local request_source = trim(http.formvalue("request_source"))
if proxy_port == nil then
write_json({
success = false,
message = "代理端口必须是 1 到 65535 之间的数字。"
})
return
end
if domestic_interval == nil then
write_json({
success = false,
message = "国内 Ping间隔必须是大于 0 的整数秒。"
})
return
end
if foreign_interval == nil then
write_json({
success = false,
message = "国外 Ping间隔必须是大于 0 的整数秒。"
})
return
end
local valid, message = validate_config(request_source, enabled, domestic, foreign, proxy_port)
if not valid then
write_json({
success = false,
message = message
})
return
end
uci:set("pingpacket", "config", "enabled", enabled)
uci:set("pingpacket", "config", "domestic_target", domestic)
uci:set("pingpacket", "config", "foreign_target", foreign)
uci:set("pingpacket", "config", "foreign_proxy_type", proxy_type)
uci:set("pingpacket", "config", "foreign_proxy_host", proxy_host)
uci:set("pingpacket", "config", "foreign_proxy_port", proxy_port)
uci:set("pingpacket", "config", "domestic_interval", domestic_interval)
uci:set("pingpacket", "config", "foreign_interval", foreign_interval)
uci:commit("pingpacket")
sys.call("rm -f /tmp/luci-indexcache*")
local rc
if enabled == "1" then
rc = sys.call("/bin/sh /etc/init.d/pingpacket restart >/dev/null 2>&1")
else
rc = sys.call("/bin/sh /etc/init.d/pingpacket stop >/dev/null 2>&1")
end
write_json({
success = (rc == 0),
message = (rc == 0) and "" or "服务状态更新失败。"
})
end
function action_restart_service()
local uci = require("luci.model.uci").cursor()
local sys = require("luci.sys")
local enabled = normalize_enabled(uci:get("pingpacket", "config", "enabled"))
if enabled ~= "1" then
write_json({
success = false,
message = "当前监控未启用。"
})
return
end
if not has_any_target(uci) then
write_json({
success = false,
message = "请至少填写一个监控目标。"
})
return
end
if not has_valid_foreign_proxy(uci) then
write_json({
success = false,
message = "国外目标已填写,但代理端口无效。"
})
return
end
local rc = sys.call("/bin/sh /etc/init.d/pingpacket restart >/dev/null 2>&1")
write_json({
success = (rc == 0),
message = (rc == 0) and "" or "重新启动监控服务失败。"
})
end
@@ -0,0 +1,477 @@
<%+header%>
<style>
.pp-log-page {
max-width: 1080px;
margin: 0 auto;
padding: 8px 10px 20px;
}
.pp-log-hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
padding: 24px 26px;
margin: 12px 0 18px;
border-radius: 18px;
color: #fff;
background:
radial-gradient(circle at top right, rgba(255, 255, 255, 0.2), transparent 34%),
linear-gradient(135deg, #10615b, #0f766e 60%, #164e63);
box-shadow: 0 18px 38px rgba(15, 118, 110, 0.18);
}
.pp-log-copy {
max-width: 720px;
}
.pp-log-kicker {
margin: 0 0 8px;
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
opacity: 0.78;
}
.pp-log-title {
margin: 0;
font-size: 30px;
line-height: 1.15;
font-weight: 700;
}
.pp-log-subtitle {
margin: 10px 0 0;
font-size: 14px;
line-height: 1.65;
color: rgba(255, 255, 255, 0.88);
}
.pp-log-summary {
min-width: 250px;
padding: 16px 18px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(6px);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.pp-log-summary-label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.75);
}
.pp-log-summary-value {
font-size: 15px;
font-weight: 600;
line-height: 1.5;
font-variant-numeric: tabular-nums;
font-family: "JetBrains Mono", "Consolas", "Menlo", monospace;
word-break: break-word;
}
.pp-log-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.pp-log-meta,
.pp-log-panel {
background: linear-gradient(180deg, #ffffff, #fbfdff);
border: 1px solid #dde8f6;
box-shadow: 0 10px 28px rgba(27, 73, 124, 0.08);
}
.pp-log-meta {
padding: 14px 16px;
border-radius: 14px;
}
.pp-log-meta-label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: #6d7d92;
}
.pp-log-meta-value {
font-size: 15px;
font-weight: 600;
color: #24364d;
font-variant-numeric: tabular-nums;
font-family: "JetBrains Mono", "Consolas", "Menlo", monospace;
word-break: break-word;
}
.pp-log-panel {
padding: 22px;
border-radius: 18px;
}
.pp-log-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.pp-log-tools {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.pp-log-refresh {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: #344a63;
}
.pp-log-refresh input {
width: 16px;
height: 16px;
margin: 0;
}
.pp-log-note {
font-size: 13px;
color: #6f8198;
}
.pp-log-button {
height: 40px;
padding: 0 16px;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
color: #a34717;
background: linear-gradient(135deg, #ffe0c7, #ffd2b0);
cursor: pointer;
box-shadow: 0 10px 24px rgba(240, 123, 45, 0.14);
transition: transform 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
}
.pp-log-button:hover {
transform: translateY(-1px);
box-shadow: 0 12px 28px rgba(240, 123, 45, 0.18);
}
.pp-log-button:disabled {
cursor: not-allowed;
opacity: 0.7;
transform: none;
box-shadow: none;
}
.pp-log-message {
min-height: 20px;
font-size: 13px;
}
.pp-log-message.success {
color: #1f8f52;
}
.pp-log-message.error {
color: #d13d38;
}
.pp-log-output {
min-height: 420px;
max-height: 620px;
margin: 0;
padding: 18px;
border-radius: 16px;
border: 1px solid #dce7f5;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0)),
#102132;
color: #d5e5f6;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
overflow: auto;
box-sizing: border-box;
font-family: "JetBrains Mono", "Consolas", "Menlo", monospace;
}
@media (max-width: 860px) {
.pp-log-hero,
.pp-log-toolbar {
flex-direction: column;
align-items: stretch;
}
.pp-log-summary {
width: 100%;
min-width: 0;
}
}
@media (max-width: 760px) {
.pp-log-grid {
grid-template-columns: 1fr;
}
.pp-log-page {
padding-left: 4px;
padding-right: 4px;
}
.pp-log-hero,
.pp-log-panel {
padding-left: 18px;
padding-right: 18px;
}
}
</style>
<div class="pp-log-page">
<div class="pp-log-hero">
<div class="pp-log-copy">
<div class="pp-log-kicker">OpenWrt Status</div>
<h2 class="pp-log-title">运行日志</h2>
<p class="pp-log-subtitle">这里会持续记录监控服务的启动、停止、单次丢包、恢复事件,以及国外代理 curl 探测失败时的原因,便于快速排查最近一次异常发生的时间和内容。</p>
</div>
<div class="pp-log-summary">
<span class="pp-log-summary-label">日志说明</span>
<div class="pp-log-summary-value">默认保留最近 500 行事件记录,适合排查服务重启、连续失败、单次丢包、代理配置问题和恢复过程。</div>
</div>
</div>
<div class="pp-log-grid">
<div class="pp-log-meta">
<span class="pp-log-meta-label">最后更新时间</span>
<div class="pp-log-meta-value" id="logUpdatedAt">--</div>
</div>
<div class="pp-log-meta">
<span class="pp-log-meta-label">日志行数</span>
<div class="pp-log-meta-value" id="logLineCount">0</div>
</div>
<div class="pp-log-meta">
<span class="pp-log-meta-label">日志大小</span>
<div class="pp-log-meta-value" id="logSize">0 B</div>
</div>
</div>
<div class="pp-log-panel">
<div class="pp-log-toolbar">
<div class="pp-log-tools">
<label class="pp-log-refresh">
<input type="checkbox" id="autoRefresh" checked>
<span>自动刷新</span>
</label>
<button class="pp-log-button" id="clearLogsButton" type="button">清除日志</button>
<span class="pp-log-message" id="logMessage"></span>
</div>
<div class="pp-log-note">取消勾选后会停止自动轮询,当前页面内容会保持不变。</div>
</div>
<pre class="pp-log-output" id="logContent">正在加载日志...</pre>
</div>
</div>
<script>
(function() {
var apiBase = '<%=luci.dispatcher.build_url("admin", "status", "pingpacket")%>';
var token = (window.L && L.env && L.env.token) ? L.env.token : '<%=token%>';
var logsUrl = apiBase + '/get_logs';
var clearUrl = apiBase + '/clear_logs';
var refreshTimer = null;
var fetchInFlight = false;
var clearInFlight = false;
var refreshInterval = 2000;
function $(id) {
return document.getElementById(id);
}
function setMessage(text, type) {
var node = $('logMessage');
node.textContent = text || '';
node.className = type ? 'pp-log-message ' + type : 'pp-log-message';
}
function scheduleRefresh(delay) {
if (refreshTimer) {
clearTimeout(refreshTimer);
}
if (!$('autoRefresh').checked) {
refreshTimer = null;
return;
}
refreshTimer = setTimeout(fetchLogs, typeof delay === 'number' ? delay : refreshInterval);
}
function formatBytes(size) {
var value = Number(size) || 0;
if (value < 1024) {
return value + ' B';
}
if (value < 1024 * 1024) {
return (value / 1024).toFixed(1) + ' KB';
}
return (value / (1024 * 1024)).toFixed(1) + ' MB';
}
function countLines(content) {
var normalized;
if (!content) {
return 0;
}
normalized = content.replace(/\n+$/, '');
if (!normalized) {
return 0;
}
return normalized.split('\n').length;
}
function updateView(payload) {
var content = payload.content || '';
var output = $('logContent');
output.textContent = content || '当前暂无日志记录。';
$('logUpdatedAt').textContent = payload.updated_at || '--';
$('logLineCount').textContent = String(countLines(content));
$('logSize').textContent = formatBytes(payload.size || 0);
if ($('autoRefresh').checked) {
output.scrollTop = output.scrollHeight;
}
}
function fetchLogs() {
var xhr;
if (fetchInFlight || clearInFlight) {
scheduleRefresh(refreshInterval);
return;
}
fetchInFlight = true;
xhr = new XMLHttpRequest();
xhr.open('GET', logsUrl, true);
xhr.onload = function() {
var response = null;
if (xhr.status === 200) {
try {
response = JSON.parse(xhr.responseText);
} catch (e) {}
}
if (response) {
updateView(response);
} else {
setMessage('日志读取失败,请稍后重试。', 'error');
}
};
xhr.onerror = function() {
setMessage('日志读取失败,请稍后重试。', 'error');
};
xhr.onloadend = function() {
fetchInFlight = false;
scheduleRefresh(refreshInterval);
};
xhr.send();
}
function clearLogs() {
var xhr;
if (fetchInFlight || clearInFlight) {
return;
}
clearInFlight = true;
$('clearLogsButton').disabled = true;
setMessage('正在清除日志...', '');
xhr = new XMLHttpRequest();
xhr.open('POST', clearUrl, true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onload = function() {
var response = null;
if (xhr.status === 200) {
try {
response = JSON.parse(xhr.responseText);
} catch (e) {}
}
if (response && response.success) {
updateView({
content: '',
updated_at: '',
size: 0
});
setMessage('日志已清除。', 'success');
} else {
setMessage('清除日志失败,请稍后重试。', 'error');
}
};
xhr.onerror = function() {
setMessage('清除日志失败,请稍后重试。', 'error');
};
xhr.onloadend = function() {
clearInFlight = false;
$('clearLogsButton').disabled = false;
if ($('autoRefresh').checked) {
scheduleRefresh(refreshInterval);
}
};
xhr.send('token=' + encodeURIComponent(token));
}
$('autoRefresh').addEventListener('change', function() {
if (this.checked) {
setMessage('已恢复自动刷新。', 'success');
fetchLogs();
return;
}
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
setMessage('已暂停自动刷新。', '');
});
$('clearLogsButton').addEventListener('click', clearLogs);
document.addEventListener('visibilitychange', function() {
if (!document.hidden && $('autoRefresh').checked) {
fetchLogs();
}
});
window.addEventListener('focus', function() {
if ($('autoRefresh').checked) {
fetchLogs();
}
});
fetchLogs();
})();
</script>
<%+footer%>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,81 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
msgid "Ping Packet Loss Monitor"
msgstr "Ping丢包监控"
msgid "Disabled"
msgstr "已关闭"
msgid "Enabled"
msgstr "已启用"
msgid "Start Time"
msgstr "开始时间"
msgid "Duration"
msgstr "运行时长"
msgid "Domestic"
msgstr "国内"
msgid "Foreign"
msgstr "国外"
msgid "Average"
msgstr "平均延迟"
msgid "Minimum"
msgstr "最低延迟"
msgid "Maximum"
msgstr "最高延迟"
msgid "Packet Loss"
msgstr "丢包率"
msgid "Not configured"
msgstr "未配置"
msgid "Monitoring Settings"
msgstr "监控设置"
msgid "Ping Domestic IP or Domain"
msgstr "Ping 国内 IP 或域名"
msgid "e.g. 114.114.114.114 or baidu.com"
msgstr "例如:114.114.114.114 或 baidu.com"
msgid "Leave empty to disable domestic monitoring."
msgstr "留空则不启用国内监控。"
msgid "Ping Foreign IP or Domain"
msgstr "Ping 国外 IP 或域名"
msgid "e.g. 8.8.8.8 or google.com"
msgstr "例如:8.8.8.8 或 google.com"
msgid "Leave empty to disable foreign monitoring."
msgstr "留空则不启用国外监控。"
msgid "Save & Apply"
msgstr "保存并应用"
msgid "Saved successfully."
msgstr "保存成功。"
msgid "Save failed."
msgstr "保存失败。"
msgid "Ping Packet Loss"
msgstr "Ping丢包监控"
msgid "Status"
msgstr "状态"
msgid "Please configure at least one target."
msgstr "请至少配置一个目标。"
msgid "Failed to update service state."
msgstr "服务状态更新失败。"
@@ -0,0 +1,9 @@
config pingpacket 'config'
option enabled '0'
option domestic_target 'www.baidu.com'
option foreign_target 'https://www.google.com/generate_204'
option foreign_proxy_type 'socks5h'
option foreign_proxy_host '127.0.0.1'
option foreign_proxy_port '7891'
option domestic_interval '1'
option foreign_interval '5'
@@ -0,0 +1,103 @@
#!/bin/sh /etc/rc.common
START=90
STOP=10
USE_PROCD=1
PROG=/usr/bin/pingpacket.sh
RUN_DIR=/var/run/pingpacket
STATUS_FILE=/tmp/pingpacket_status.json
CONFIG_FILE=$RUN_DIR/config
LOG_FILE=/tmp/pingpacket.log
LOG_MAX_LINES=500
log_rotate() {
[ -f "$LOG_FILE" ] || return 0
tail -n "$LOG_MAX_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp" 2>/dev/null && \
mv -f "${LOG_FILE}.tmp" "$LOG_FILE"
}
log_event() {
local event_type="$1"
local message="$2"
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
printf '[%s] [%s] %s\n' "$timestamp" "$event_type" "$message" >> "$LOG_FILE"
log_rotate
}
format_foreign_summary() {
local target="$1"
local proxy_type="$2"
local proxy_host="$3"
local proxy_port="$4"
[ -n "$target" ] || {
echo "未配置"
return
}
if [ -n "$proxy_port" ]; then
echo "${target}(代理 ${proxy_type} 本机端口 ${proxy_port})"
else
echo "${target}(代理端口未配置)"
fi
}
start_service() {
local enabled domestic foreign proxy_type proxy_host proxy_port domestic_interval foreign_interval foreign_summary
config_load pingpacket
config_get enabled config enabled "0"
config_get domestic config domestic_target ""
config_get foreign config foreign_target ""
config_get proxy_type config foreign_proxy_type "socks5h"
config_get proxy_host config foreign_proxy_host "127.0.0.1"
config_get proxy_port config foreign_proxy_port "7891"
config_get domestic_interval config domestic_interval "1"
config_get foreign_interval config foreign_interval "5"
proxy_type="socks5h"
[ "$enabled" = "1" ] || return 0
[ -n "$domestic" ] || [ -n "$foreign" ] || return 0
rm -rf "$RUN_DIR"
rm -f "$STATUS_FILE"
mkdir -p "$RUN_DIR"
printf '%s\n' "$domestic" > "$CONFIG_FILE"
printf '%s\n' "$foreign" >> "$CONFIG_FILE"
printf '%s\n' "$proxy_type" >> "$CONFIG_FILE"
printf '%s\n' "${proxy_host:-127.0.0.1}" >> "$CONFIG_FILE"
printf '%s\n' "$proxy_port" >> "$CONFIG_FILE"
printf '%s\n' "$domestic_interval" >> "$CONFIG_FILE"
printf '%s\n' "$foreign_interval" >> "$CONFIG_FILE"
foreign_summary="$(format_foreign_summary "$foreign" "$proxy_type" "$proxy_host" "$proxy_port")"
log_event "启动" "监控服务已启动,国内目标:${domestic:-未配置},国外目标:${foreign_summary}"
procd_open_instance
procd_set_param command /bin/sh "$PROG"
procd_set_param respawn 3600 10 5
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
stop_service() {
if [ -d "$RUN_DIR" ] || [ -f "$STATUS_FILE" ]; then
log_event "停止" "监控服务已停止。"
fi
rm -rf "$RUN_DIR"
rm -f "$STATUS_FILE"
}
reload_service() {
stop
start
}
service_triggers() {
procd_add_reload_trigger "pingpacket"
}
@@ -0,0 +1,682 @@
#!/bin/sh
STATUS_FILE="/tmp/pingpacket_status.json"
STATUS_TMP="${STATUS_FILE}.tmp"
LOG_FILE="/tmp/pingpacket.log"
LOG_MAX_LINES=500
RUN_DIR="/var/run/pingpacket"
START_TIME_FILE="$RUN_DIR/start_time"
child_pids=""
domestic_fail_count=0
foreign_fail_count=0
domestic_fault_open=0
foreign_fault_open=0
domestic_total_samples=0
foreign_total_samples=0
domestic_success_count=0
foreign_success_count=0
domestic_loss_count=0
foreign_loss_count=0
domestic_rtt_sum="0"
foreign_rtt_sum="0"
domestic_min_rtt=""
foreign_min_rtt=""
domestic_max_rtt=""
foreign_max_rtt=""
domestic_last_probe_at=0
foreign_last_probe_at=0
domestic_last_fault_probe_at=0
foreign_last_fault_probe_at=0
domestic_last_stats_probe_at=0
foreign_last_stats_probe_at=0
log_rotate() {
[ -f "$LOG_FILE" ] || return 0
tail -n "$LOG_MAX_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp" 2>/dev/null && \
mv -f "${LOG_FILE}.tmp" "$LOG_FILE"
}
log_event() {
local event_type="$1"
local scope="$2"
local message="$3"
local timestamp
timestamp="$(date '+%Y-%m-%d %H:%M:%S')"
if [ -n "$scope" ]; then
printf '[%s] [%s] [%s] %s\n' "$timestamp" "$event_type" "$scope" "$message" >> "$LOG_FILE"
else
printf '[%s] [%s] %s\n' "$timestamp" "$event_type" "$message" >> "$LOG_FILE"
fi
log_rotate
}
kill_children() {
[ -n "$child_pids" ] || return 0
kill $child_pids 2>/dev/null
wait $child_pids 2>/dev/null
child_pids=""
}
cleanup() {
kill_children
exit 0
}
trap cleanup INT TERM
mkdir -p "$RUN_DIR"
date '+%Y-%m-%d %H:%M:%S' > "$START_TIME_FILE"
write_result_file() {
local path="$1"
local success="$2"
local rtt="$3"
local detail="$4"
local probe_at="$5"
[ -n "$probe_at" ] || probe_at="$(date '+%s' 2>/dev/null || echo 0)"
mkdir -p "$(dirname "$path")"
printf '%s\n%s\n%s\n%s\n' "$success" "$rtt" "$detail" "$probe_at" > "$path"
}
normalize_foreign_url() {
case "$1" in
http://*|https://*)
printf '%s\n' "$1"
;;
*)
printf 'https://%s\n' "$1"
;;
esac
}
ensure_runtime_state() {
local domestic
local foreign
local proxy_type
local proxy_host
local proxy_port
local domestic_interval
local foreign_interval
mkdir -p "$RUN_DIR"
if [ ! -s "$START_TIME_FILE" ]; then
date '+%Y-%m-%d %H:%M:%S' > "$START_TIME_FILE"
fi
if [ -f "$RUN_DIR/config" ]; then
return 0
fi
if ! command -v uci >/dev/null 2>&1; then
return 0
fi
domestic="$(uci -q get pingpacket.config.domestic_target)"
foreign="$(uci -q get pingpacket.config.foreign_target)"
proxy_type="$(uci -q get pingpacket.config.foreign_proxy_type)"
proxy_host="$(uci -q get pingpacket.config.foreign_proxy_host)"
proxy_port="$(uci -q get pingpacket.config.foreign_proxy_port)"
domestic_interval="$(uci -q get pingpacket.config.domestic_interval)"
foreign_interval="$(uci -q get pingpacket.config.foreign_interval)"
proxy_type="socks5h"
[ -n "$proxy_host" ] || proxy_host="127.0.0.1"
[ -n "$proxy_port" ] || proxy_port="7891"
[ -n "$domestic_interval" ] || domestic_interval="1"
[ -n "$foreign_interval" ] || foreign_interval="5"
printf '%s\n' "$domestic" > "$RUN_DIR/config"
printf '%s\n' "$foreign" >> "$RUN_DIR/config"
printf '%s\n' "$proxy_type" >> "$RUN_DIR/config"
printf '%s\n' "$proxy_host" >> "$RUN_DIR/config"
printf '%s\n' "$proxy_port" >> "$RUN_DIR/config"
printf '%s\n' "$domestic_interval" >> "$RUN_DIR/config"
printf '%s\n' "$foreign_interval" >> "$RUN_DIR/config"
}
normalize_interval() {
local value="$1"
case "$value" in
""|*[!0-9]*)
printf '1\n'
return 0
;;
esac
if [ "$value" -ge 1 ] 2>/dev/null; then
printf '%s\n' "$value"
else
printf '1\n'
fi
}
should_probe_now() {
local last_probe_at="${1:-0}"
local interval="${2:-1}"
local now_ts="${3:-0}"
if [ "$last_probe_at" -le 0 ] 2>/dev/null; then
return 0
fi
if [ $((now_ts - last_probe_at)) -ge "$interval" ] 2>/dev/null; then
return 0
fi
return 1
}
is_ip_literal() {
case "$1" in
*:*|*.*)
printf '%s\n' "$1" | awk '
/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ { found=1 }
/^[0-9A-Fa-f:]+$/ { found=1 }
END { exit(found ? 0 : 1) }
'
;;
*)
return 1
;;
esac
}
resolve_target_ip() {
local target="$1"
local name="$2"
local cache_target_file="$RUN_DIR/${name}_resolved_target"
local cache_ip_file="$RUN_DIR/${name}_resolved_ip"
local cache_time_file="$RUN_DIR/${name}_resolved_at"
local cached_target
local cached_ip
local cached_time
local now
local resolved_ip=""
if is_ip_literal "$target"; then
printf '%s\n' "$target"
return 0
fi
cached_target="$(cat "$cache_target_file" 2>/dev/null || echo "")"
cached_ip="$(cat "$cache_ip_file" 2>/dev/null || echo "")"
cached_time="$(cat "$cache_time_file" 2>/dev/null || echo "")"
now="$(date '+%s' 2>/dev/null || echo 0)"
if [ "$cached_target" = "$target" ] && [ -n "$cached_ip" ] && [ -n "$cached_time" ]; then
if [ $((now - cached_time)) -lt 300 ] 2>/dev/null; then
printf '%s\n' "$cached_ip"
return 0
fi
fi
if command -v resolveip >/dev/null 2>&1; then
resolved_ip="$(
resolveip -4 -t 3 "$target" 2>/dev/null | awk '
{
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) {
print $i
exit
}
}
}
'
)"
fi
if [ -z "$resolved_ip" ] && command -v nslookup >/dev/null 2>&1; then
resolved_ip="$(
nslookup "$target" 2>/dev/null | awk '
{
for (i = 1; i <= NF; i++) {
if ($i ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/) {
print $i
exit
}
}
}
'
)"
fi
if [ -n "$resolved_ip" ]; then
printf '%s\n' "$target" > "$cache_target_file"
printf '%s\n' "$resolved_ip" > "$cache_ip_file"
printf '%s\n' "$now" > "$cache_time_file"
printf '%s\n' "$resolved_ip"
return 0
fi
if [ "$cached_target" = "$target" ] && [ -n "$cached_ip" ]; then
printf '%s\n' "$cached_ip"
return 0
fi
printf '%s\n' "$target"
}
do_ping() {
local target="$1"
local name="$2"
local result_file="$RUN_DIR/${name}_last_result"
local ping_target
local result
local rtt
ping_target="$(resolve_target_ip "$target" "$name")"
result="$(ping -n -c 1 -W 2 "$ping_target" 2>/dev/null)"
if echo "$result" | grep -q "bytes from"; then
rtt="$(echo "$result" | sed -n 's/.*time=\([0-9.]*\).*/\1/p')"
write_result_file "$result_file" "1" "${rtt:-0}" ""
else
write_result_file "$result_file" "0" "" "ping 无响应"
fi
}
do_proxy_curl() {
local target="$1"
local proxy_type="$2"
local proxy_host="${3:-127.0.0.1}"
local proxy_port="$4"
local name="$5"
local result_file="$RUN_DIR/${name}_last_result"
local error_file="$RUN_DIR/${name}_curl_error"
local url
local proxy_addr
local curl_result
local http_code
local time_pretransfer
local rtt_ms
local detail
local rc
if ! command -v curl >/dev/null 2>&1; then
write_result_file "$result_file" "0" "" "系统未安装 curl"
return 0
fi
if [ -z "$proxy_port" ]; then
write_result_file "$result_file" "0" "" "代理端口未配置"
return 0
fi
url="$(normalize_foreign_url "$target")"
proxy_addr="${proxy_host}:${proxy_port}"
curl_result="$(
curl -k -I -L -sS --noproxy "" \
--connect-timeout 5 --max-time 10 \
--socks5-hostname "$proxy_addr" \
-o /dev/null -w '%{http_code}:%{time_pretransfer}' \
"$url" 2>"$error_file"
)"
rc=$?
http_code="$(printf '%s' "$curl_result" | awk -F ':' 'NR==1 { print $1 }')"
time_pretransfer="$(printf '%s' "$curl_result" | awk -F ':' 'NR==1 { print $2 }')"
if [ "$rc" -eq 0 ] && [ -n "$http_code" ] && [ -n "$time_pretransfer" ] && { [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; }; then
rtt_ms="$(awk -v sec="$time_pretransfer" 'BEGIN { printf "%.1f", (sec + 0) * 1000 }')"
write_result_file "$result_file" "1" "$rtt_ms" "curl 连接探测成功,HTTP ${http_code}"
else
detail="$(sed -n '1p' "$error_file" 2>/dev/null)"
[ -n "$detail" ] || detail="curl 退出码 ${rc},HTTP ${http_code:-000}"
write_result_file "$result_file" "0" "" "$detail"
fi
rm -f "$error_file"
}
get_target_state() {
local name="$1"
case "$name" in
domestic)
fail_count="$domestic_fail_count"
fault_open="$domestic_fault_open"
;;
foreign)
fail_count="$foreign_fail_count"
fault_open="$foreign_fault_open"
;;
*)
fail_count=0
fault_open=0
;;
esac
}
set_target_state() {
local name="$1"
local next_fail_count="$2"
local next_fault_open="$3"
case "$name" in
domestic)
domestic_fail_count="$next_fail_count"
domestic_fault_open="$next_fault_open"
;;
foreign)
foreign_fail_count="$next_fail_count"
foreign_fault_open="$next_fault_open"
;;
esac
}
get_processed_probe_at() {
local name="$1"
local kind="$2"
case "${name}:${kind}" in
domestic:fault)
processed_probe_at="$domestic_last_fault_probe_at"
;;
foreign:fault)
processed_probe_at="$foreign_last_fault_probe_at"
;;
domestic:stats)
processed_probe_at="$domestic_last_stats_probe_at"
;;
foreign:stats)
processed_probe_at="$foreign_last_stats_probe_at"
;;
*)
processed_probe_at=0
;;
esac
}
set_processed_probe_at() {
local name="$1"
local kind="$2"
local value="${3:-0}"
case "${name}:${kind}" in
domestic:fault)
domestic_last_fault_probe_at="$value"
;;
foreign:fault)
foreign_last_fault_probe_at="$value"
;;
domestic:stats)
domestic_last_stats_probe_at="$value"
;;
foreign:stats)
foreign_last_stats_probe_at="$value"
;;
esac
}
reset_target_state() {
local name="$1"
rm -f "$RUN_DIR/${name}_last_result"
set_target_state "$name" 0 0
set_processed_probe_at "$name" "fault" 0
set_processed_probe_at "$name" "stats" 0
}
update_target_faults() {
local name="$1"
local label="$2"
local target="$3"
local result_file="$RUN_DIR/${name}_last_result"
local success="0"
local rtt=""
local detail=""
local probe_at=""
[ -n "$target" ] || {
reset_target_state "$name"
return 0
}
get_target_state "$name"
if [ -f "$result_file" ]; then
success="$(sed -n '1p' "$result_file" 2>/dev/null)"
rtt="$(sed -n '2p' "$result_file" 2>/dev/null)"
detail="$(sed -n '3p' "$result_file" 2>/dev/null)"
probe_at="$(sed -n '4p' "$result_file" 2>/dev/null)"
fi
get_processed_probe_at "$name" "fault"
if [ -n "$probe_at" ] && [ "$probe_at" = "$processed_probe_at" ]; then
return 0
fi
if [ "$success" = "1" ]; then
if [ "$fault_open" = "1" ]; then
log_event "恢复" "$label" "目标 ${target} 已恢复响应,当前延迟 ${rtt:-0} ms"
fi
set_target_state "$name" 0 0
set_processed_probe_at "$name" "fault" "${probe_at:-0}"
return 0
fi
fail_count=$((fail_count + 1))
if [ -n "$detail" ]; then
log_event "丢包" "$label" "目标 ${target} 本次探测失败(连续第 ${fail_count} 次):${detail}"
else
log_event "丢包" "$label" "目标 ${target} 本次探测失败(连续第 ${fail_count} 次)"
fi
fault_open=1
set_target_state "$name" "$fail_count" "$fault_open"
set_processed_probe_at "$name" "fault" "${probe_at:-0}"
}
calc_stats() {
local name="$1"
local total_samples=0
local success_count=0
local loss_count=0
local rtt_sum="0"
local min_rtt=""
local max_rtt=""
local avg="0.0"
local min="0.0"
local max="0.0"
local loss_rate="0.0"
case "$name" in
domestic)
total_samples="$domestic_total_samples"
success_count="$domestic_success_count"
loss_count="$domestic_loss_count"
rtt_sum="$domestic_rtt_sum"
min_rtt="$domestic_min_rtt"
max_rtt="$domestic_max_rtt"
;;
foreign)
total_samples="$foreign_total_samples"
success_count="$foreign_success_count"
loss_count="$foreign_loss_count"
rtt_sum="$foreign_rtt_sum"
min_rtt="$foreign_min_rtt"
max_rtt="$foreign_max_rtt"
;;
esac
if [ "$success_count" -gt 0 ]; then
avg="$(awk -v sum="$rtt_sum" -v count="$success_count" 'BEGIN { printf "%.1f", (sum + 0) / count }')"
min="$(awk -v value="${min_rtt:-0}" 'BEGIN { printf "%.1f", value + 0 }')"
max="$(awk -v value="${max_rtt:-0}" 'BEGIN { printf "%.1f", value + 0 }')"
fi
if [ "$total_samples" -gt 0 ]; then
loss_rate="$(awk -v loss="$loss_count" -v total="$total_samples" 'BEGIN { printf "%.1f", loss * 100.0 / total }')"
fi
printf '{"avg":"%s","min":"%s","max":"%s","loss_rate":"%s","loss_count":%d,"count":%d,"samples":%d}' \
"$avg" "$min" "$max" "$loss_rate" "$loss_count" "$success_count" "$total_samples"
}
update_cumulative_stats() {
local name="$1"
local target="$2"
local result_file="$RUN_DIR/${name}_last_result"
local success="0"
local rtt=""
local probe_at=""
local total_samples=0
local success_count=0
local loss_count=0
local rtt_sum="0"
local min_rtt=""
local max_rtt=""
[ -n "$target" ] || return 0
case "$name" in
domestic)
total_samples="$domestic_total_samples"
success_count="$domestic_success_count"
loss_count="$domestic_loss_count"
rtt_sum="$domestic_rtt_sum"
min_rtt="$domestic_min_rtt"
max_rtt="$domestic_max_rtt"
;;
foreign)
total_samples="$foreign_total_samples"
success_count="$foreign_success_count"
loss_count="$foreign_loss_count"
rtt_sum="$foreign_rtt_sum"
min_rtt="$foreign_min_rtt"
max_rtt="$foreign_max_rtt"
;;
esac
total_samples=$((total_samples + 1))
if [ -f "$result_file" ]; then
success="$(sed -n '1p' "$result_file" 2>/dev/null)"
rtt="$(sed -n '2p' "$result_file" 2>/dev/null)"
probe_at="$(sed -n '4p' "$result_file" 2>/dev/null)"
fi
get_processed_probe_at "$name" "stats"
if [ -n "$probe_at" ] && [ "$probe_at" = "$processed_probe_at" ]; then
return 0
fi
if [ "$success" = "1" ]; then
rtt="${rtt:-0}"
success_count=$((success_count + 1))
rtt_sum="$(awk -v sum="$rtt_sum" -v value="$rtt" 'BEGIN { printf "%.3f", (sum + 0) + (value + 0) }')"
if [ -z "$min_rtt" ]; then
min_rtt="$rtt"
else
min_rtt="$(awk -v current="$min_rtt" -v value="$rtt" 'BEGIN { printf "%.3f", ((value + 0) < (current + 0)) ? (value + 0) : (current + 0) }')"
fi
if [ -z "$max_rtt" ]; then
max_rtt="$rtt"
else
max_rtt="$(awk -v current="$max_rtt" -v value="$rtt" 'BEGIN { printf "%.3f", ((value + 0) > (current + 0)) ? (value + 0) : (current + 0) }')"
fi
else
loss_count=$((loss_count + 1))
fi
case "$name" in
domestic)
domestic_total_samples="$total_samples"
domestic_success_count="$success_count"
domestic_loss_count="$loss_count"
domestic_rtt_sum="$rtt_sum"
domestic_min_rtt="$min_rtt"
domestic_max_rtt="$max_rtt"
;;
foreign)
foreign_total_samples="$total_samples"
foreign_success_count="$success_count"
foreign_loss_count="$loss_count"
foreign_rtt_sum="$rtt_sum"
foreign_min_rtt="$min_rtt"
foreign_max_rtt="$max_rtt"
;;
esac
set_processed_probe_at "$name" "stats" "${probe_at:-0}"
}
while true; do
ensure_runtime_state
START_TIME="$(cat "$START_TIME_FILE" 2>/dev/null || echo "")"
UPDATED_AT="$(date '+%Y-%m-%d %H:%M:%S')"
NOW_TS="$(date '+%s' 2>/dev/null || echo 0)"
DOMESTIC_TARGET=""
FOREIGN_TARGET=""
FOREIGN_PROXY_TYPE="socks5h"
FOREIGN_PROXY_HOST="127.0.0.1"
FOREIGN_PROXY_PORT="7891"
DOMESTIC_INTERVAL="1"
FOREIGN_INTERVAL="5"
if [ -f "$RUN_DIR/config" ]; then
DOMESTIC_TARGET="$(sed -n '1p' "$RUN_DIR/config")"
FOREIGN_TARGET="$(sed -n '2p' "$RUN_DIR/config")"
FOREIGN_PROXY_TYPE="$(sed -n '3p' "$RUN_DIR/config")"
FOREIGN_PROXY_HOST="$(sed -n '4p' "$RUN_DIR/config")"
FOREIGN_PROXY_PORT="$(sed -n '5p' "$RUN_DIR/config")"
DOMESTIC_INTERVAL="$(sed -n '6p' "$RUN_DIR/config")"
FOREIGN_INTERVAL="$(sed -n '7p' "$RUN_DIR/config")"
fi
FOREIGN_PROXY_TYPE="socks5h"
[ -n "$FOREIGN_PROXY_HOST" ] || FOREIGN_PROXY_HOST="127.0.0.1"
[ -n "$FOREIGN_PROXY_PORT" ] || FOREIGN_PROXY_PORT="7891"
[ -n "$FOREIGN_INTERVAL" ] || FOREIGN_INTERVAL="5"
DOMESTIC_INTERVAL="$(normalize_interval "$DOMESTIC_INTERVAL")"
FOREIGN_INTERVAL="$(normalize_interval "$FOREIGN_INTERVAL")"
child_pids=""
if [ -n "$DOMESTIC_TARGET" ]; then
if should_probe_now "$domestic_last_probe_at" "$DOMESTIC_INTERVAL" "$NOW_TS"; then
do_ping "$DOMESTIC_TARGET" "domestic" &
child_pids="$!"
domestic_last_probe_at="$NOW_TS"
fi
else
domestic_last_probe_at=0
reset_target_state "domestic"
fi
if [ -n "$FOREIGN_TARGET" ]; then
if should_probe_now "$foreign_last_probe_at" "$FOREIGN_INTERVAL" "$NOW_TS"; then
do_proxy_curl "$FOREIGN_TARGET" "$FOREIGN_PROXY_TYPE" "$FOREIGN_PROXY_HOST" "$FOREIGN_PROXY_PORT" "foreign" &
child_pids="${child_pids:+$child_pids }$!"
foreign_last_probe_at="$NOW_TS"
fi
else
foreign_last_probe_at=0
reset_target_state "foreign"
fi
[ -n "$child_pids" ] && wait $child_pids 2>/dev/null
child_pids=""
update_target_faults "domestic" "国内" "$DOMESTIC_TARGET"
update_target_faults "foreign" "国外" "$FOREIGN_TARGET"
update_cumulative_stats "domestic" "$DOMESTIC_TARGET"
update_cumulative_stats "foreign" "$FOREIGN_TARGET"
DOMESTIC_STATS="$(calc_stats "domestic")"
FOREIGN_STATS="$(calc_stats "foreign")"
printf '{"start_time":"%s","updated_at":"%s","domestic":%s,"foreign":%s}\n' \
"$START_TIME" "$UPDATED_AT" "$DOMESTIC_STATS" "$FOREIGN_STATS" > "$STATUS_TMP" && \
mv -f "$STATUS_TMP" "$STATUS_FILE"
sleep 1
done
@@ -0,0 +1,15 @@
{
"luci-app-pingpacket": {
"description": "Grant access to luci-app-pingpacket",
"read": {
"uci": [
"pingpacket"
]
},
"write": {
"uci": [
"pingpacket"
]
}
}
}