mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-07 04:34:05 +08:00
⛄ Sync 2026-11-03 04:53:29
This commit is contained in:
1 parent
7abc2a26a6
commit
bbccdf6fb0
17 files changed
+2571
-1000
No files matched your search
@@ -1,8 +1,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-open-box
|
||||
PKG_VERSION:=0.1.389
|
||||
PKG_RELEASE:=58
|
||||
PKG_VERSION:=0.1.390
|
||||
PKG_RELEASE:=59
|
||||
|
||||
PKG_MAINTAINER:=liandu2024
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
|
||||
@@ -76,6 +76,24 @@
|
||||
- 停止服务、重新启用服务或重启服务后,统计会重新开始
|
||||
- 国内和国外分别独立累计,不合并计算
|
||||
|
||||
### 丢包与时间的关系
|
||||
|
||||
监控页内置一张丢包率趋势图,用于观察网络质量随时间的变化:
|
||||
|
||||
- 国内、国外两条曲线分别独立展示,带面积填充与悬浮提示
|
||||
- 支持 `日`(最近 24 小时,10 分钟粒度)、`周`(最近 7 天,1 小时粒度)、`月`(最近 30 天,6 小时粒度)
|
||||
- 支持 `自定义` 时间范围,可选起点与终点,单次最多查询 90 天
|
||||
- 图表上方显示所选区间的平均丢包率、区间峰值丢包率与统计区间
|
||||
- 鼠标悬浮可查看某个时间点的丢包率、样本数与丢包数
|
||||
- 图表数据全部由本地接口生成,不依赖任何外部 CDN 或前端库
|
||||
|
||||
数据来源与保留策略:
|
||||
|
||||
- 采集脚本按分钟把探测结果(样本数、丢包数、延迟合计)写入 `/etc/pingpacket/history/YYYY-MM-DD.tsv`
|
||||
- 每分钟最多写一行,写盘压力很小;服务停止时会立即刷写当前分钟的数据
|
||||
- 历史文件最多保留 35 天,超期文件会被自动清理
|
||||
- 与统计卡片不同:图表记录的是历史趋势,服务重启或设备重启后仍然保留;统计卡片的累计值则会重新开始
|
||||
|
||||
### 日志页
|
||||
|
||||
- 显示插件运行日志
|
||||
@@ -179,5 +197,28 @@ apk add --allow-untrusted ./luci-app-pingpacket_v1.0.22_all.apk
|
||||
说明:
|
||||
|
||||
- 升级时会尽量保留已有 `/etc/config/pingpacket`
|
||||
- 若系统中存在历史遗留的 `/etc/config/pingpacket-opkg`,安装脚本会尽量兼容处理
|
||||
- 若系统中存在历史遗留的 `/etc/config/pingpacket-opkg`,安装脚本会用其中缺失的默认项补齐现有配置,然后删除该文件
|
||||
- 安装脚本会自动创建 `/etc/pingpacket/history` 目录用于保存丢包历史
|
||||
|
||||
## 运行文件
|
||||
|
||||
| 路径 | 说明 | 生命周期 |
|
||||
| --- | --- | --- |
|
||||
| `/tmp/pingpacket_status.json` | 监控状态与累计统计 | 服务停止后删除 |
|
||||
| `/tmp/pingpacket.log` | 运行日志,最多 500 行 | 设备重启后丢失 |
|
||||
| `/tmp/pingpacket_cpu_state` | CPU 采样缓存 | 设备重启后丢失 |
|
||||
| `/var/run/pingpacket/config` | 本次运行的配置缓存 | 服务停止后删除 |
|
||||
| `/var/run/pingpacket/start_time` | 本次服务启动时间 | 服务停止后删除 |
|
||||
| `/etc/pingpacket/history/*.tsv` | 按分钟聚合的丢包历史 | 保留 35 天 |
|
||||
|
||||
## 接口
|
||||
|
||||
| 接口 | 说明 |
|
||||
| --- | --- |
|
||||
| `get_data` | 返回配置、服务状态、系统资源与累计统计 |
|
||||
| `get_history` | 返回丢包趋势数据,参数 `range=day\|week\|month\|custom`,自定义时附加 `from`、`to`(Unix 秒) |
|
||||
| `get_logs` | 返回日志内容与元信息 |
|
||||
| `clear_logs` | 清空日志文件 |
|
||||
| `save_config` | 保存配置,并按启用状态重启或停止服务 |
|
||||
| `restart_service` | 数据停滞时手动重启服务 |
|
||||
|
||||
@@ -36,6 +36,38 @@ endef
|
||||
define Package/$(PKG_NAME)/postinst
|
||||
#!/bin/sh
|
||||
[ -n "$${IPKG_INSTROOT}" ] && exit 0
|
||||
|
||||
# 丢包历史数据目录(按天分文件保存,最多保留 35 天)
|
||||
mkdir -p /etc/pingpacket/history
|
||||
|
||||
# 升级时 opkg 会把新的默认配置文件留成 pingpacket-opkg,
|
||||
# 这里只补齐旧配置中缺失的选项,已有取值保持不变。
|
||||
merge_pingpacket_options() {
|
||||
local source_file="/etc/config/pingpacket-opkg"
|
||||
local key
|
||||
local value
|
||||
|
||||
[ -f "$$source_file" ] || return 0
|
||||
|
||||
if [ ! -f /etc/config/pingpacket ]; then
|
||||
mv -f "$$source_file" /etc/config/pingpacket
|
||||
return 0
|
||||
fi
|
||||
|
||||
sed -n "s/^[[:space:]]*option[[:space:]]\+\([A-Za-z0-9_]*\)[[:space:]]\+\(.*\)$$/\1=\2/p" "$$source_file" | tr -d "'" |
|
||||
while IFS='=' read -r key value; do
|
||||
[ -n "$$key" ] || continue
|
||||
if [ -z "$$(uci -q get "pingpacket.config.$$key")" ]; then
|
||||
uci -q set "pingpacket.config.$$key=$$value"
|
||||
fi
|
||||
done
|
||||
|
||||
uci -q commit pingpacket
|
||||
rm -f "$$source_file"
|
||||
}
|
||||
|
||||
merge_pingpacket_options
|
||||
|
||||
rm -f /tmp/luci-indexcache.*
|
||||
rm -rf /tmp/luci-modulecache/
|
||||
/etc/init.d/rpcd reload 2>/dev/null
|
||||
|
||||
@@ -92,6 +92,119 @@ local function read_start_time()
|
||||
return value
|
||||
end
|
||||
|
||||
local HISTORY_DIR = "/etc/pingpacket/history"
|
||||
local HISTORY_MAX_SPAN = 90 * 86400
|
||||
local HISTORY_BUCKET_STEPS = { 1, 2, 5, 10, 15, 30, 60, 120, 180, 240, 360, 720, 1440 }
|
||||
|
||||
local function day_start(timestamp)
|
||||
local parts = os.date("*t", timestamp)
|
||||
|
||||
return os.time({
|
||||
year = parts.year,
|
||||
month = parts.month,
|
||||
day = parts.day,
|
||||
hour = 0,
|
||||
min = 0,
|
||||
sec = 0
|
||||
})
|
||||
end
|
||||
|
||||
local function next_day_start(timestamp)
|
||||
local parts = os.date("*t", timestamp)
|
||||
|
||||
return os.time({
|
||||
year = parts.year,
|
||||
month = parts.month,
|
||||
day = parts.day + 1,
|
||||
hour = 0,
|
||||
min = 0,
|
||||
sec = 0
|
||||
})
|
||||
end
|
||||
|
||||
local function pick_bucket_minutes(span_seconds, target_points)
|
||||
local ideal = (span_seconds / 60) / (target_points or 160)
|
||||
|
||||
for _, step in ipairs(HISTORY_BUCKET_STEPS) do
|
||||
if step >= ideal then
|
||||
return step
|
||||
end
|
||||
end
|
||||
|
||||
return 1440
|
||||
end
|
||||
|
||||
local function round1(value)
|
||||
return tonumber(string.format("%.1f", value)) or 0
|
||||
end
|
||||
|
||||
local function bucket_label(timestamp, bucket_minutes, span_seconds)
|
||||
if bucket_minutes >= 1440 then
|
||||
return os.date("%m-%d", timestamp)
|
||||
end
|
||||
|
||||
if span_seconds > 2 * 86400 then
|
||||
return os.date("%m-%d %H:%M", timestamp)
|
||||
end
|
||||
|
||||
return os.date("%H:%M", timestamp)
|
||||
end
|
||||
|
||||
local function collect_history(from_ts, to_ts, bucket_minutes)
|
||||
local bucket_seconds = bucket_minutes * 60
|
||||
local total = math.floor((to_ts - from_ts) / bucket_seconds) + 1
|
||||
local buckets = {}
|
||||
|
||||
for index = 1, total do
|
||||
buckets[index] = { ds = 0, dl = 0, dr = 0, fs = 0, fl = 0, fr = 0 }
|
||||
end
|
||||
|
||||
local day_ts = day_start(from_ts)
|
||||
local guard = 0
|
||||
|
||||
while day_ts <= to_ts and guard < 400 do
|
||||
guard = guard + 1
|
||||
|
||||
local handle = io.open(string.format("%s/%s.tsv", HISTORY_DIR, os.date("%Y-%m-%d", day_ts)), "r")
|
||||
|
||||
if handle then
|
||||
for line in handle:lines() do
|
||||
local clock, ds, dl, dr, fs, fl, fr = line:match("^(%d%d:%d%d)%s+(%d+)%s+(%d+)%s+([%d%.]+)%s+(%d+)%s+(%d+)%s+([%d%.]+)")
|
||||
|
||||
if clock then
|
||||
local hour, minute = clock:match("^(%d%d):(%d%d)")
|
||||
local probe_ts = day_ts + (tonumber(hour) * 3600) + (tonumber(minute) * 60)
|
||||
|
||||
if probe_ts >= from_ts and probe_ts <= to_ts then
|
||||
local index = math.floor((probe_ts - from_ts) / bucket_seconds) + 1
|
||||
local bucket = buckets[index]
|
||||
|
||||
if bucket then
|
||||
bucket.ds = bucket.ds + (tonumber(ds) or 0)
|
||||
bucket.dl = bucket.dl + (tonumber(dl) or 0)
|
||||
bucket.dr = bucket.dr + (tonumber(dr) or 0)
|
||||
bucket.fs = bucket.fs + (tonumber(fs) or 0)
|
||||
bucket.fl = bucket.fl + (tonumber(fl) or 0)
|
||||
bucket.fr = bucket.fr + (tonumber(fr) or 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
handle:close()
|
||||
end
|
||||
|
||||
local following = next_day_start(day_ts)
|
||||
if following <= day_ts then
|
||||
break
|
||||
end
|
||||
|
||||
day_ts = following
|
||||
end
|
||||
|
||||
return buckets, total
|
||||
end
|
||||
|
||||
local function should_require_target(request_source, enabled)
|
||||
if request_source == "save" then
|
||||
return true
|
||||
@@ -312,12 +425,160 @@ function index()
|
||||
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_history"}, call("action_get_history")).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_history()
|
||||
local http = require("luci.http")
|
||||
local range = trim(http.formvalue("range"))
|
||||
local now = os.time()
|
||||
local from_ts
|
||||
local to_ts
|
||||
local bucket_minutes
|
||||
|
||||
if range ~= "day" and range ~= "week" and range ~= "month" and range ~= "custom" then
|
||||
range = "day"
|
||||
end
|
||||
|
||||
if range == "custom" then
|
||||
from_ts = tonumber(http.formvalue("from"))
|
||||
to_ts = tonumber(http.formvalue("to"))
|
||||
|
||||
if not from_ts or not to_ts then
|
||||
write_json({ success = false, message = "自定义时间范围无效。" })
|
||||
return
|
||||
end
|
||||
|
||||
from_ts = math.floor(from_ts)
|
||||
to_ts = math.floor(to_ts)
|
||||
|
||||
if to_ts <= from_ts then
|
||||
write_json({ success = false, message = "结束时间必须晚于开始时间。" })
|
||||
return
|
||||
end
|
||||
|
||||
if to_ts - from_ts > HISTORY_MAX_SPAN then
|
||||
write_json({ success = false, message = "自定义时间范围最多支持 90 天。" })
|
||||
return
|
||||
end
|
||||
|
||||
if to_ts > now then
|
||||
to_ts = now
|
||||
end
|
||||
|
||||
if to_ts <= from_ts then
|
||||
write_json({ success = false, message = "所选时间范围内没有可展示的数据。" })
|
||||
return
|
||||
end
|
||||
|
||||
bucket_minutes = pick_bucket_minutes(to_ts - from_ts, 160)
|
||||
else
|
||||
to_ts = now
|
||||
|
||||
if range == "week" then
|
||||
from_ts = now - (7 * 86400)
|
||||
bucket_minutes = 60
|
||||
elseif range == "month" then
|
||||
from_ts = now - (30 * 86400)
|
||||
bucket_minutes = 360
|
||||
else
|
||||
from_ts = now - 86400
|
||||
bucket_minutes = 10
|
||||
end
|
||||
end
|
||||
|
||||
local bucket_seconds = bucket_minutes * 60
|
||||
|
||||
-- 将起点对齐到聚合桶边界,使图表刻度落在整点或整十分钟上
|
||||
from_ts = from_ts - (from_ts % bucket_seconds)
|
||||
|
||||
local buckets, total = collect_history(from_ts, to_ts, bucket_minutes)
|
||||
local span = to_ts - from_ts
|
||||
local points = {}
|
||||
local sum_ds, sum_dl, sum_fs, sum_fl = 0, 0, 0, 0
|
||||
local sum_dr, sum_fr = 0, 0
|
||||
local peak_d, peak_f = -1, -1
|
||||
|
||||
for index = 1, total do
|
||||
local bucket = buckets[index]
|
||||
local timestamp = from_ts + ((index - 1) * bucket_minutes * 60)
|
||||
local d_rate, f_rate = -1, -1
|
||||
local d_avg, f_avg = -1, -1
|
||||
|
||||
if bucket.ds > 0 then
|
||||
d_rate = round1(bucket.dl * 100 / bucket.ds)
|
||||
sum_ds = sum_ds + bucket.ds
|
||||
sum_dl = sum_dl + bucket.dl
|
||||
|
||||
if bucket.dr > 0 and bucket.ds > bucket.dl then
|
||||
d_avg = round1(bucket.dr / (bucket.ds - bucket.dl))
|
||||
sum_dr = sum_dr + bucket.dr
|
||||
end
|
||||
|
||||
if d_rate > peak_d then
|
||||
peak_d = d_rate
|
||||
end
|
||||
end
|
||||
|
||||
if bucket.fs > 0 then
|
||||
f_rate = round1(bucket.fl * 100 / bucket.fs)
|
||||
sum_fs = sum_fs + bucket.fs
|
||||
sum_fl = sum_fl + bucket.fl
|
||||
|
||||
if bucket.fr > 0 and bucket.fs > bucket.fl then
|
||||
f_avg = round1(bucket.fr / (bucket.fs - bucket.fl))
|
||||
sum_fr = sum_fr + bucket.fr
|
||||
end
|
||||
|
||||
if f_rate > peak_f then
|
||||
peak_f = f_rate
|
||||
end
|
||||
end
|
||||
|
||||
points[index] = {
|
||||
t = timestamp,
|
||||
label = bucket_label(timestamp, bucket_minutes, span),
|
||||
ds = bucket.ds,
|
||||
dl = bucket.dl,
|
||||
fs = bucket.fs,
|
||||
fl = bucket.fl,
|
||||
d_rate = d_rate,
|
||||
f_rate = f_rate,
|
||||
d_avg = d_avg,
|
||||
f_avg = f_avg
|
||||
}
|
||||
end
|
||||
|
||||
write_json({
|
||||
success = true,
|
||||
range = range,
|
||||
from = os.date("%Y-%m-%d %H:%M", from_ts),
|
||||
to = os.date("%Y-%m-%d %H:%M", to_ts),
|
||||
bucket_minutes = bucket_minutes,
|
||||
points = points,
|
||||
summary = {
|
||||
domestic = {
|
||||
samples = sum_ds,
|
||||
loss = sum_dl,
|
||||
rate = (sum_ds > 0) and round1(sum_dl * 100 / sum_ds) or -1,
|
||||
avg = (sum_ds > sum_dl) and round1(sum_dr / (sum_ds - sum_dl)) or -1,
|
||||
peak = peak_d
|
||||
},
|
||||
foreign = {
|
||||
samples = sum_fs,
|
||||
loss = sum_fl,
|
||||
rate = (sum_fs > 0) and round1(sum_fl * 100 / sum_fs) or -1,
|
||||
avg = (sum_fs > sum_fl) and round1(sum_fr / (sum_fs - sum_fl)) or -1,
|
||||
peak = peak_f
|
||||
}
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
function action_get_data()
|
||||
local uci = require("luci.model.uci").cursor()
|
||||
local sys = require("luci.sys")
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
.pp-log-summary {
|
||||
min-width: 250px;
|
||||
padding: 16px 18px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(6px);
|
||||
@@ -348,14 +349,18 @@
|
||||
function updateView(payload) {
|
||||
var content = payload.content || '';
|
||||
var output = $('logContent');
|
||||
var displayText = content || '当前暂无日志记录。';
|
||||
|
||||
if (output.textContent !== displayText) {
|
||||
output.textContent = displayText;
|
||||
if ($('autoRefresh').checked) {
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
@@ -368,7 +373,8 @@
|
||||
|
||||
fetchInFlight = true;
|
||||
xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', logsUrl, true);
|
||||
xhr.open('GET', logsUrl + '?_=' + Date.now(), true);
|
||||
xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
xhr.onload = function() {
|
||||
var response = null;
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
.pp-toggle-panel {
|
||||
min-width: 240px;
|
||||
padding: 16px 18px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(6px);
|
||||
@@ -523,6 +524,246 @@
|
||||
color: #d13d38;
|
||||
}
|
||||
|
||||
.pp-chart-panel {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.pp-range-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: 13px;
|
||||
background: #eef4fc;
|
||||
border: 1px solid #dde8f6;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pp-range-btn {
|
||||
min-width: 54px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #4a5f78;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
|
||||
.pp-range-btn:hover {
|
||||
background: #e2edfb;
|
||||
}
|
||||
|
||||
.pp-range-btn.active {
|
||||
background: #fff;
|
||||
color: #1256b4;
|
||||
box-shadow: 0 4px 12px rgba(18, 86, 180, 0.16);
|
||||
}
|
||||
|
||||
.pp-custom-range {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
background: #f5f9ff;
|
||||
border: 1px solid #e5eef8;
|
||||
}
|
||||
|
||||
.pp-custom-range.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pp-custom-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #6d7d92;
|
||||
}
|
||||
|
||||
.pp-custom-field input {
|
||||
height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #cedbeb;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: #24364d;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.pp-custom-field input:focus {
|
||||
outline: none;
|
||||
border-color: #1d71e8;
|
||||
box-shadow: 0 0 0 4px rgba(29, 113, 232, 0.12);
|
||||
}
|
||||
|
||||
.pp-custom-apply {
|
||||
height: 38px;
|
||||
padding: 0 18px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #1d71e8, #1256b4);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.pp-custom-apply:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 18px rgba(18, 86, 180, 0.2);
|
||||
}
|
||||
|
||||
.pp-chart-msg {
|
||||
font-size: 12px;
|
||||
line-height: 38px;
|
||||
color: #d13d38;
|
||||
}
|
||||
|
||||
.pp-chart-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pp-chart-chip {
|
||||
padding: 12px 14px;
|
||||
border-radius: 13px;
|
||||
background: #f5f9ff;
|
||||
border: 1px solid #e5eef8;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pp-chart-chip-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: #70839b;
|
||||
}
|
||||
|
||||
.pp-chart-chip-value {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1f3147;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: "JetBrains Mono", "Consolas", "Menlo", monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pp-chart-chip-value.domestic {
|
||||
color: #1d71e8;
|
||||
}
|
||||
|
||||
.pp-chart-chip-value.foreign {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.pp-chart-chip-value.range-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pp-chart-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #fbfdff, #f7fbff);
|
||||
border: 1px solid #e8f0fa;
|
||||
padding: 6px 4px 2px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pp-chart-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.pp-chart-tip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
padding: 8px 11px;
|
||||
border-radius: 10px;
|
||||
background: rgba(16, 32, 51, 0.94);
|
||||
color: #eaf3ff;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -100%);
|
||||
transition: opacity 0.12s ease;
|
||||
box-shadow: 0 12px 26px rgba(9, 22, 38, 0.28);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.pp-chart-tip.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.pp-chart-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: #8b9cb0;
|
||||
}
|
||||
|
||||
.pp-chart-empty.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pp-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #5c6f86;
|
||||
}
|
||||
|
||||
.pp-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.pp-legend-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pp-legend-swatch.domestic {
|
||||
background: linear-gradient(135deg, #4d95f0, #1d71e8);
|
||||
}
|
||||
|
||||
.pp-legend-swatch.foreign {
|
||||
background: linear-gradient(135deg, #fba05a, #ea580c);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.pp-hero,
|
||||
.pp-panel-head,
|
||||
@@ -531,6 +772,15 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.pp-range-group {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.pp-range-btn {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.pp-toggle-panel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@@ -560,6 +810,19 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pp-chart-summary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.pp-custom-field {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.pp-custom-field input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pp-page {
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
@@ -730,6 +993,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pp-panel pp-chart-panel">
|
||||
<div class="pp-panel-head">
|
||||
<div>
|
||||
<h3 class="pp-panel-title">丢包与时间的关系</h3>
|
||||
<div class="pp-panel-note">按分钟聚合探测结果,展示国内与国外目标各自的丢包率变化趋势。可切换日、周、月或自定义时间范围,鼠标悬浮可查看具体时段的样本数与丢包情况。</div>
|
||||
</div>
|
||||
<div class="pp-range-group" id="rangeGroup">
|
||||
<button type="button" class="pp-range-btn active" data-range="day">日</button>
|
||||
<button type="button" class="pp-range-btn" data-range="week">周</button>
|
||||
<button type="button" class="pp-range-btn" data-range="month">月</button>
|
||||
<button type="button" class="pp-range-btn" data-range="custom">自定义</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pp-custom-range hidden" id="customRange">
|
||||
<label class="pp-custom-field">
|
||||
<span>开始时间</span>
|
||||
<input type="datetime-local" id="chartFrom">
|
||||
</label>
|
||||
<label class="pp-custom-field">
|
||||
<span>结束时间</span>
|
||||
<input type="datetime-local" id="chartTo">
|
||||
</label>
|
||||
<button type="button" class="pp-custom-apply" id="chartApply">应用范围</button>
|
||||
<span class="pp-chart-msg" id="chartMsg"></span>
|
||||
</div>
|
||||
|
||||
<div class="pp-chart-summary">
|
||||
<div class="pp-chart-chip">
|
||||
<span class="pp-chart-chip-label">国内平均丢包率</span>
|
||||
<div class="pp-chart-chip-value domestic" id="chartDomesticRate">--</div>
|
||||
</div>
|
||||
<div class="pp-chart-chip">
|
||||
<span class="pp-chart-chip-label">国外平均丢包率</span>
|
||||
<div class="pp-chart-chip-value foreign" id="chartForeignRate">--</div>
|
||||
</div>
|
||||
<div class="pp-chart-chip">
|
||||
<span class="pp-chart-chip-label">区间峰值丢包率</span>
|
||||
<div class="pp-chart-chip-value" id="chartPeak">--</div>
|
||||
</div>
|
||||
<div class="pp-chart-chip">
|
||||
<span class="pp-chart-chip-label">统计区间</span>
|
||||
<div class="pp-chart-chip-value range-text" id="chartRange">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pp-chart-wrap" id="chartWrap">
|
||||
<svg class="pp-chart-svg" id="chartSvg" viewBox="0 0 800 300" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div class="pp-chart-empty hidden" id="chartEmpty">暂无历史数据,启用监控后会自动开始记录。</div>
|
||||
<div class="pp-chart-tip" id="chartTip"></div>
|
||||
</div>
|
||||
|
||||
<div class="pp-legend">
|
||||
<span class="pp-legend-item"><span class="pp-legend-swatch domestic"></span>国内丢包率</span>
|
||||
<span class="pp-legend-item"><span class="pp-legend-swatch foreign"></span>国外丢包率</span>
|
||||
<span class="pp-legend-item" id="chartBucketNote"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pp-panel">
|
||||
<div class="pp-panel-head">
|
||||
<div>
|
||||
@@ -795,6 +1117,7 @@
|
||||
var apiBase = '<%=url("admin/status/pingpacket")%>';
|
||||
var formToken = (window.L && L.env && L.env.token) ? L.env.token : '<%=token%>';
|
||||
var dataUrl = apiBase + '/get_data';
|
||||
var historyUrl = apiBase + '/get_history';
|
||||
var saveUrl = apiBase + '/save_config';
|
||||
var restartUrl = apiBase + '/restart_service';
|
||||
var pollTimer = null;
|
||||
@@ -1310,6 +1633,503 @@
|
||||
);
|
||||
};
|
||||
|
||||
/* ---------------- 丢包与时间的关系图表 ---------------- */
|
||||
|
||||
var CHART_PAD = { left: 48, right: 18, top: 16, bottom: 28 };
|
||||
var CHART_COLORS = { domestic: '#1d71e8', foreign: '#ea580c' };
|
||||
var chartState = {
|
||||
range: 'day',
|
||||
payload: null,
|
||||
loading: false,
|
||||
layout: null,
|
||||
resizeTimer: null
|
||||
};
|
||||
|
||||
function pad2(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDateTimeInput(date) {
|
||||
return date.getFullYear() + '-' + pad2(date.getMonth() + 1) + '-' + pad2(date.getDate()) +
|
||||
'T' + pad2(date.getHours()) + ':' + pad2(date.getMinutes());
|
||||
}
|
||||
|
||||
function setChartMessage(text) {
|
||||
var node = $('chartMsg');
|
||||
if (node) {
|
||||
node.textContent = text || '';
|
||||
}
|
||||
}
|
||||
|
||||
function formatRateText(value) {
|
||||
if (typeof value !== 'number' || value < 0) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return value.toFixed(1) + '%';
|
||||
}
|
||||
|
||||
function updateChartSummary(payload) {
|
||||
var domestic = (payload.summary && payload.summary.domestic) || {};
|
||||
var foreign = (payload.summary && payload.summary.foreign) || {};
|
||||
var peak = Math.max(
|
||||
typeof domestic.peak === 'number' ? domestic.peak : -1,
|
||||
typeof foreign.peak === 'number' ? foreign.peak : -1
|
||||
);
|
||||
var bucket = Number(payload.bucket_minutes) || 0;
|
||||
var bucketText = bucket >= 60 ? (bucket / 60) + ' 小时' : bucket + ' 分钟';
|
||||
var totalPoints = (payload.points || []).length;
|
||||
|
||||
$('chartDomesticRate').textContent = formatRateText(domestic.rate);
|
||||
$('chartForeignRate').textContent = formatRateText(foreign.rate);
|
||||
$('chartPeak').textContent = peak < 0 ? '--' : peak.toFixed(1) + '%';
|
||||
|
||||
var fromText = String(payload.from || '--').replace(/^\d{4}-/, '');
|
||||
var toText = String(payload.to || '--').replace(/^\d{4}-/, '');
|
||||
var rangeNode = $('chartRange');
|
||||
|
||||
rangeNode.textContent = fromText + ' ~ ' + toText;
|
||||
rangeNode.title = (payload.from || '--') + ' ~ ' + (payload.to || '--');
|
||||
$('chartBucketNote').textContent = '聚合粒度 ' + bucketText + ',共 ' + totalPoints + ' 个数据点';
|
||||
}
|
||||
|
||||
function niceAxisMax(value) {
|
||||
if (!(value > 0)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
var steps = [0.5, 1, 2, 5, 10, 20, 25, 50, 100];
|
||||
|
||||
for (var i = 0; i < steps.length; i++) {
|
||||
if (value <= steps[i]) {
|
||||
return steps[i];
|
||||
}
|
||||
}
|
||||
|
||||
return 100;
|
||||
}
|
||||
|
||||
function formatAxisTick(value) {
|
||||
if (value >= 10 || value % 1 === 0) {
|
||||
return value.toFixed(0) + '%';
|
||||
}
|
||||
|
||||
return value.toFixed(1) + '%';
|
||||
}
|
||||
|
||||
function buildChartSeries(points, key, xOf, yOf, baseY) {
|
||||
var segments = [];
|
||||
var current = null;
|
||||
|
||||
for (var i = 0; i < points.length; i++) {
|
||||
var value = points[i][key];
|
||||
|
||||
if (typeof value !== 'number' || value < 0) {
|
||||
if (current) {
|
||||
segments.push(current);
|
||||
current = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) {
|
||||
current = [];
|
||||
}
|
||||
|
||||
current.push({ x: xOf(i), y: yOf(value) });
|
||||
}
|
||||
|
||||
if (current) {
|
||||
segments.push(current);
|
||||
}
|
||||
|
||||
var line = '';
|
||||
var area = '';
|
||||
|
||||
for (var s = 0; s < segments.length; s++) {
|
||||
var segment = segments[s];
|
||||
|
||||
for (var p = 0; p < segment.length; p++) {
|
||||
line += (p === 0 ? 'M' : 'L') + segment[p].x.toFixed(1) + ' ' + segment[p].y.toFixed(1);
|
||||
}
|
||||
|
||||
area += 'M' + segment[0].x.toFixed(1) + ' ' + baseY.toFixed(1);
|
||||
for (var q = 0; q < segment.length; q++) {
|
||||
area += 'L' + segment[q].x.toFixed(1) + ' ' + segment[q].y.toFixed(1);
|
||||
}
|
||||
area += 'L' + segment[segment.length - 1].x.toFixed(1) + ' ' + baseY.toFixed(1) + 'Z';
|
||||
}
|
||||
|
||||
return { line: line, area: area };
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
var payload = chartState.payload;
|
||||
var wrap = $('chartWrap');
|
||||
var svg = $('chartSvg');
|
||||
var empty = $('chartEmpty');
|
||||
|
||||
if (!wrap || !svg || !empty) {
|
||||
return;
|
||||
}
|
||||
|
||||
var points = (payload && payload.points) || [];
|
||||
var hasData = false;
|
||||
|
||||
for (var i = 0; i < points.length; i++) {
|
||||
if (points[i].ds > 0 || points[i].fs > 0) {
|
||||
hasData = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasData) {
|
||||
svg.innerHTML = '';
|
||||
empty.className = 'pp-chart-empty';
|
||||
chartState.layout = null;
|
||||
return;
|
||||
}
|
||||
|
||||
empty.className = 'pp-chart-empty hidden';
|
||||
|
||||
var width = Math.max(320, Math.round(wrap.clientWidth || 800));
|
||||
var height = width < 560 ? 220 : 300;
|
||||
var pad = CHART_PAD;
|
||||
var innerW = width - pad.left - pad.right;
|
||||
var innerH = height - pad.top - pad.bottom;
|
||||
var count = points.length;
|
||||
var step = count > 1 ? innerW / (count - 1) : 0;
|
||||
var baseY = pad.top + innerH;
|
||||
|
||||
var maxRate = 0;
|
||||
for (var j = 0; j < count; j++) {
|
||||
if (points[j].d_rate > maxRate) {
|
||||
maxRate = points[j].d_rate;
|
||||
}
|
||||
if (points[j].f_rate > maxRate) {
|
||||
maxRate = points[j].f_rate;
|
||||
}
|
||||
}
|
||||
|
||||
var yMax = niceAxisMax(maxRate);
|
||||
|
||||
function xOf(index) {
|
||||
return count > 1 ? pad.left + (index * step) : pad.left + (innerW / 2);
|
||||
}
|
||||
|
||||
function yOf(value) {
|
||||
return pad.top + innerH - (Math.min(value, yMax) / yMax) * innerH;
|
||||
}
|
||||
|
||||
var parts = [];
|
||||
|
||||
parts.push(
|
||||
'<defs>' +
|
||||
'<linearGradient id="ppDomesticFill" x1="0" y1="0" x2="0" y2="1">' +
|
||||
'<stop offset="0%" stop-color="' + CHART_COLORS.domestic + '" stop-opacity="0.26"/>' +
|
||||
'<stop offset="100%" stop-color="' + CHART_COLORS.domestic + '" stop-opacity="0.02"/>' +
|
||||
'</linearGradient>' +
|
||||
'<linearGradient id="ppForeignFill" x1="0" y1="0" x2="0" y2="1">' +
|
||||
'<stop offset="0%" stop-color="' + CHART_COLORS.foreign + '" stop-opacity="0.22"/>' +
|
||||
'<stop offset="100%" stop-color="' + CHART_COLORS.foreign + '" stop-opacity="0.02"/>' +
|
||||
'</linearGradient>' +
|
||||
'</defs>'
|
||||
);
|
||||
|
||||
for (var g = 0; g <= 4; g++) {
|
||||
var tickValue = (yMax * g) / 4;
|
||||
var tickY = yOf(tickValue);
|
||||
|
||||
parts.push('<line x1="' + pad.left + '" y1="' + tickY.toFixed(1) + '" x2="' + (width - pad.right) +
|
||||
'" y2="' + tickY.toFixed(1) + '" stroke="' + (g === 0 ? '#d9e5f3' : '#eaf1fa') + '" stroke-width="1"/>');
|
||||
parts.push('<text x="' + (pad.left - 10) + '" y="' + (tickY + 3.5).toFixed(1) +
|
||||
'" text-anchor="end" font-size="10" fill="#94a5ba">' + formatAxisTick(tickValue) + '</text>');
|
||||
}
|
||||
|
||||
var labelEvery = Math.max(1, Math.ceil(count / 7));
|
||||
for (var k = 0; k < count; k += labelEvery) {
|
||||
parts.push('<text x="' + xOf(k).toFixed(1) + '" y="' + (height - 8) +
|
||||
'" text-anchor="middle" font-size="10" fill="#94a5ba">' + points[k].label + '</text>');
|
||||
}
|
||||
|
||||
if ((count - 1) % labelEvery !== 0) {
|
||||
parts.push('<text x="' + xOf(count - 1).toFixed(1) + '" y="' + (height - 8) +
|
||||
'" text-anchor="middle" font-size="10" fill="#94a5ba">' + points[count - 1].label + '</text>');
|
||||
}
|
||||
|
||||
var domestic = buildChartSeries(points, 'd_rate', xOf, yOf, baseY);
|
||||
var foreign = buildChartSeries(points, 'f_rate', xOf, yOf, baseY);
|
||||
|
||||
if (foreign.area) {
|
||||
parts.push('<path d="' + foreign.area + '" fill="url(#ppForeignFill)" stroke="none"/>');
|
||||
}
|
||||
|
||||
if (domestic.area) {
|
||||
parts.push('<path d="' + domestic.area + '" fill="url(#ppDomesticFill)" stroke="none"/>');
|
||||
}
|
||||
|
||||
if (foreign.line) {
|
||||
parts.push('<path d="' + foreign.line + '" fill="none" stroke="' + CHART_COLORS.foreign +
|
||||
'" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>');
|
||||
}
|
||||
|
||||
if (domestic.line) {
|
||||
parts.push('<path d="' + domestic.line + '" fill="none" stroke="' + CHART_COLORS.domestic +
|
||||
'" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>');
|
||||
}
|
||||
|
||||
parts.push('<line id="chartHoverLine" x1="0" y1="' + pad.top + '" x2="0" y2="' + baseY +
|
||||
'" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>');
|
||||
parts.push('<circle id="chartHoverDomestic" r="4" fill="' + CHART_COLORS.domestic +
|
||||
'" stroke="#fff" stroke-width="2" opacity="0"/>');
|
||||
parts.push('<circle id="chartHoverForeign" r="4" fill="' + CHART_COLORS.foreign +
|
||||
'" stroke="#fff" stroke-width="2" opacity="0"/>');
|
||||
|
||||
svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
|
||||
svg.setAttribute('width', width);
|
||||
svg.setAttribute('height', height);
|
||||
svg.innerHTML = parts.join('');
|
||||
|
||||
chartState.layout = {
|
||||
pad: pad,
|
||||
width: width,
|
||||
count: count,
|
||||
step: step,
|
||||
points: points,
|
||||
xOf: xOf,
|
||||
yOf: yOf
|
||||
};
|
||||
}
|
||||
|
||||
function hideChartTip() {
|
||||
var tip = $('chartTip');
|
||||
var hoverLine = $('chartHoverLine');
|
||||
var domesticDot = $('chartHoverDomestic');
|
||||
var foreignDot = $('chartHoverForeign');
|
||||
|
||||
if (tip) {
|
||||
tip.className = 'pp-chart-tip';
|
||||
}
|
||||
if (hoverLine) {
|
||||
hoverLine.setAttribute('opacity', '0');
|
||||
}
|
||||
if (domesticDot) {
|
||||
domesticDot.setAttribute('opacity', '0');
|
||||
}
|
||||
if (foreignDot) {
|
||||
foreignDot.setAttribute('opacity', '0');
|
||||
}
|
||||
}
|
||||
|
||||
function showChartTip(event) {
|
||||
var layout = chartState.layout;
|
||||
var wrap = $('chartWrap');
|
||||
var tip = $('chartTip');
|
||||
|
||||
if (!layout || !wrap || !tip) {
|
||||
return;
|
||||
}
|
||||
|
||||
var rect = wrap.getBoundingClientRect();
|
||||
var offsetX = event.clientX - rect.left;
|
||||
var offsetY = event.clientY - rect.top;
|
||||
var index = layout.count > 1 ? Math.round((offsetX - layout.pad.left) / layout.step) : 0;
|
||||
|
||||
if (index < 0) {
|
||||
index = 0;
|
||||
}
|
||||
if (index > layout.count - 1) {
|
||||
index = layout.count - 1;
|
||||
}
|
||||
|
||||
var point = layout.points[index];
|
||||
var pointX = layout.xOf(index);
|
||||
var hoverLine = $('chartHoverLine');
|
||||
var domesticDot = $('chartHoverDomestic');
|
||||
var foreignDot = $('chartHoverForeign');
|
||||
|
||||
if (hoverLine) {
|
||||
hoverLine.setAttribute('x1', pointX.toFixed(1));
|
||||
hoverLine.setAttribute('x2', pointX.toFixed(1));
|
||||
hoverLine.setAttribute('opacity', '1');
|
||||
}
|
||||
|
||||
if (domesticDot) {
|
||||
if (point.d_rate >= 0) {
|
||||
domesticDot.setAttribute('cx', pointX.toFixed(1));
|
||||
domesticDot.setAttribute('cy', layout.yOf(point.d_rate).toFixed(1));
|
||||
domesticDot.setAttribute('opacity', '1');
|
||||
} else {
|
||||
domesticDot.setAttribute('opacity', '0');
|
||||
}
|
||||
}
|
||||
|
||||
if (foreignDot) {
|
||||
if (point.f_rate >= 0) {
|
||||
foreignDot.setAttribute('cx', pointX.toFixed(1));
|
||||
foreignDot.setAttribute('cy', layout.yOf(point.f_rate).toFixed(1));
|
||||
foreignDot.setAttribute('opacity', '1');
|
||||
} else {
|
||||
foreignDot.setAttribute('opacity', '0');
|
||||
}
|
||||
}
|
||||
|
||||
tip.innerHTML =
|
||||
'<div style="font-weight:600;margin-bottom:4px;">' + point.label + '</div>' +
|
||||
'<div>国内 ' + (point.d_rate >= 0 ? point.d_rate.toFixed(1) + '%' : '无数据') +
|
||||
' · 样本 ' + point.ds + ' · 丢包 ' + point.dl + '</div>' +
|
||||
'<div>国外 ' + (point.f_rate >= 0 ? point.f_rate.toFixed(1) + '%' : '无数据') +
|
||||
' · 样本 ' + point.fs + ' · 丢包 ' + point.fl + '</div>';
|
||||
|
||||
var tipLeft = Math.min(Math.max(pointX, 80), layout.width - 80);
|
||||
var tipTop = Math.max(offsetY - 10, 46);
|
||||
|
||||
tip.style.left = tipLeft + 'px';
|
||||
tip.style.top = tipTop + 'px';
|
||||
tip.className = 'pp-chart-tip visible';
|
||||
}
|
||||
|
||||
function fetchHistory() {
|
||||
if (chartState.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
var url = historyUrl + '?range=' + encodeURIComponent(chartState.range) + '&_=' + Date.now();
|
||||
|
||||
if (chartState.range === 'custom') {
|
||||
var fromValue = $('chartFrom').value;
|
||||
var toValue = $('chartTo').value;
|
||||
var fromTime = fromValue ? new Date(fromValue).getTime() : NaN;
|
||||
var toTime = toValue ? new Date(toValue).getTime() : NaN;
|
||||
|
||||
if (!isFinite(fromTime) || !isFinite(toTime)) {
|
||||
setChartMessage('请选择完整的开始时间和结束时间。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (toTime <= fromTime) {
|
||||
setChartMessage('结束时间必须晚于开始时间。');
|
||||
return;
|
||||
}
|
||||
|
||||
if (toTime - fromTime > 90 * 24 * 3600 * 1000) {
|
||||
setChartMessage('自定义时间范围最多支持 90 天。');
|
||||
return;
|
||||
}
|
||||
|
||||
url += '&from=' + Math.floor(fromTime / 1000) + '&to=' + Math.floor(toTime / 1000);
|
||||
}
|
||||
|
||||
chartState.loading = true;
|
||||
setChartMessage('');
|
||||
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', url, true);
|
||||
xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||
xhr.onload = function() {
|
||||
var response = null;
|
||||
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
response = JSON.parse(xhr.responseText);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
chartState.loading = false;
|
||||
|
||||
if (response && response.success) {
|
||||
chartState.payload = response;
|
||||
updateChartSummary(response);
|
||||
renderChart();
|
||||
} else {
|
||||
setChartMessage((response && response.message) || '历史数据加载失败,请稍后重试。');
|
||||
}
|
||||
};
|
||||
xhr.onerror = function() {
|
||||
chartState.loading = false;
|
||||
setChartMessage('历史数据加载失败,请检查网络后重试。');
|
||||
};
|
||||
xhr.send();
|
||||
}
|
||||
|
||||
function setChartRange(range) {
|
||||
chartState.range = range;
|
||||
|
||||
var buttons = $('rangeGroup').getElementsByTagName('button');
|
||||
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
buttons[i].className = buttons[i].getAttribute('data-range') === range ? 'pp-range-btn active' : 'pp-range-btn';
|
||||
}
|
||||
|
||||
if (range === 'custom') {
|
||||
$('customRange').className = 'pp-custom-range';
|
||||
|
||||
if (!$('chartFrom').value || !$('chartTo').value) {
|
||||
var now = new Date();
|
||||
$('chartTo').value = formatDateTimeInput(now);
|
||||
$('chartFrom').value = formatDateTimeInput(new Date(now.getTime() - 24 * 3600 * 1000));
|
||||
}
|
||||
} else {
|
||||
$('customRange').className = 'pp-custom-range hidden';
|
||||
}
|
||||
|
||||
setChartMessage('');
|
||||
fetchHistory();
|
||||
}
|
||||
|
||||
function initChart() {
|
||||
var buttons = $('rangeGroup').getElementsByTagName('button');
|
||||
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
buttons[i].addEventListener('click', function() {
|
||||
setChartRange(this.getAttribute('data-range'));
|
||||
});
|
||||
}
|
||||
|
||||
$('chartApply').addEventListener('click', function() {
|
||||
if (chartState.range !== 'custom') {
|
||||
setChartRange('custom');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchHistory();
|
||||
});
|
||||
|
||||
$('chartWrap').addEventListener('mousemove', showChartTip);
|
||||
$('chartWrap').addEventListener('mouseleave', hideChartTip);
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
if (chartState.resizeTimer) {
|
||||
clearTimeout(chartState.resizeTimer);
|
||||
}
|
||||
|
||||
chartState.resizeTimer = setTimeout(function() {
|
||||
if (chartState.payload) {
|
||||
renderChart();
|
||||
}
|
||||
}, 180);
|
||||
});
|
||||
|
||||
var chartTick = 0;
|
||||
|
||||
setInterval(function() {
|
||||
chartTick += 1;
|
||||
|
||||
if (document.hidden || chartState.loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 日视图每分钟刷新,周/月视图数据变化慢,每 5 分钟刷新一次以降低路由器压力
|
||||
if (chartTick % (chartState.range === 'day' ? 1 : 5) !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchHistory();
|
||||
}, 60000);
|
||||
|
||||
fetchHistory();
|
||||
}
|
||||
|
||||
$('domesticInput').addEventListener('input', function() {
|
||||
inputDirty.domestic = true;
|
||||
});
|
||||
@@ -1369,6 +2189,7 @@
|
||||
});
|
||||
|
||||
fetchData();
|
||||
initChart();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -6,7 +6,14 @@ LOG_FILE="/tmp/pingpacket.log"
|
||||
LOG_MAX_LINES=500
|
||||
RUN_DIR="/var/run/pingpacket"
|
||||
START_TIME_FILE="$RUN_DIR/start_time"
|
||||
HIST_DIR="/etc/pingpacket/history"
|
||||
HIST_RETENTION_DAYS=35
|
||||
|
||||
child_pids=""
|
||||
probe_seq=0
|
||||
log_line_count=0
|
||||
current_minute=""
|
||||
|
||||
domestic_fail_count=0
|
||||
foreign_fail_count=0
|
||||
domestic_fault_open=0
|
||||
@@ -25,15 +32,25 @@ 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
|
||||
domestic_last_fault_seq=0
|
||||
foreign_last_fault_seq=0
|
||||
domestic_last_stats_seq=0
|
||||
foreign_last_stats_seq=0
|
||||
|
||||
hist_bucket_key=""
|
||||
hist_flush_count=0
|
||||
hist_d_samples=0
|
||||
hist_d_loss=0
|
||||
hist_d_rtt_sum="0"
|
||||
hist_f_samples=0
|
||||
hist_f_loss=0
|
||||
hist_f_rtt_sum="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_line_count="$LOG_MAX_LINES"
|
||||
}
|
||||
|
||||
log_event() {
|
||||
@@ -42,13 +59,95 @@ log_event() {
|
||||
local message="$3"
|
||||
local timestamp
|
||||
|
||||
# 日志被页面手动清除后,行数计数需要重新开始
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
log_line_count=0
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
log_line_count=$((log_line_count + 1))
|
||||
if [ "$log_line_count" -gt "$LOG_MAX_LINES" ]; then
|
||||
log_rotate
|
||||
fi
|
||||
}
|
||||
|
||||
prune_history() {
|
||||
[ -d "$HIST_DIR" ] || return 0
|
||||
|
||||
find "$HIST_DIR" -type f -name '*.tsv' -mtime +"$HIST_RETENTION_DAYS" 2>/dev/null | while IFS= read -r stale_file; do
|
||||
rm -f "$stale_file" 2>/dev/null
|
||||
done
|
||||
}
|
||||
|
||||
flush_history_bucket() {
|
||||
[ -n "$hist_bucket_key" ] || return 0
|
||||
|
||||
if [ "$hist_d_samples" -le 0 ] && [ "$hist_f_samples" -le 0 ]; then
|
||||
hist_bucket_key=""
|
||||
return 0
|
||||
fi
|
||||
|
||||
local bucket_day="${hist_bucket_key% *}"
|
||||
local bucket_clock="${hist_bucket_key#* }"
|
||||
|
||||
mkdir -p "$HIST_DIR" 2>/dev/null
|
||||
printf '%s\t%d\t%d\t%s\t%d\t%d\t%s\n' \
|
||||
"$bucket_clock" \
|
||||
"$hist_d_samples" "$hist_d_loss" "$hist_d_rtt_sum" \
|
||||
"$hist_f_samples" "$hist_f_loss" "$hist_f_rtt_sum" \
|
||||
>> "$HIST_DIR/${bucket_day}.tsv" 2>/dev/null
|
||||
|
||||
hist_bucket_key=""
|
||||
hist_d_samples=0
|
||||
hist_d_loss=0
|
||||
hist_d_rtt_sum="0"
|
||||
hist_f_samples=0
|
||||
hist_f_loss=0
|
||||
hist_f_rtt_sum="0"
|
||||
|
||||
hist_flush_count=$((hist_flush_count + 1))
|
||||
if [ "$hist_flush_count" -ge 60 ]; then
|
||||
hist_flush_count=0
|
||||
prune_history
|
||||
fi
|
||||
}
|
||||
|
||||
accumulate_history() {
|
||||
local name="$1"
|
||||
local success="$2"
|
||||
local rtt="${3:-0}"
|
||||
|
||||
[ -n "$current_minute" ] || return 0
|
||||
|
||||
if [ "$hist_bucket_key" != "$current_minute" ]; then
|
||||
flush_history_bucket
|
||||
hist_bucket_key="$current_minute"
|
||||
fi
|
||||
|
||||
case "$name" in
|
||||
domestic)
|
||||
hist_d_samples=$((hist_d_samples + 1))
|
||||
if [ "$success" = "1" ]; then
|
||||
hist_d_rtt_sum="$(awk -v sum="$hist_d_rtt_sum" -v value="$rtt" 'BEGIN { printf "%.3f", (sum + 0) + (value + 0) }')"
|
||||
else
|
||||
hist_d_loss=$((hist_d_loss + 1))
|
||||
fi
|
||||
;;
|
||||
foreign)
|
||||
hist_f_samples=$((hist_f_samples + 1))
|
||||
if [ "$success" = "1" ]; then
|
||||
hist_f_rtt_sum="$(awk -v sum="$hist_f_rtt_sum" -v value="$rtt" 'BEGIN { printf "%.3f", (sum + 0) + (value + 0) }')"
|
||||
else
|
||||
hist_f_loss=$((hist_f_loss + 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
kill_children() {
|
||||
@@ -59,6 +158,7 @@ kill_children() {
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
flush_history_bucket
|
||||
kill_children
|
||||
exit 0
|
||||
}
|
||||
@@ -68,17 +168,26 @@ trap cleanup INT TERM
|
||||
mkdir -p "$RUN_DIR"
|
||||
date '+%Y-%m-%d %H:%M:%S' > "$START_TIME_FILE"
|
||||
|
||||
if [ -f "$LOG_FILE" ]; then
|
||||
log_line_count="$(wc -l < "$LOG_FILE" 2>/dev/null | tr -d ' ')"
|
||||
case "$log_line_count" in
|
||||
""|*[!0-9]*) log_line_count=0 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
write_result_file() {
|
||||
local path="$1"
|
||||
local success="$2"
|
||||
local rtt="$3"
|
||||
local detail="$4"
|
||||
local probe_at="$5"
|
||||
local seq="$6"
|
||||
|
||||
[ -n "$probe_at" ] || probe_at="$(date '+%s' 2>/dev/null || echo 0)"
|
||||
[ -n "$seq" ] || seq="0"
|
||||
|
||||
mkdir -p "$(dirname "$path")"
|
||||
printf '%s\n%s\n%s\n%s\n' "$success" "$rtt" "$detail" "$probe_at" > "$path"
|
||||
printf '%s\n%s\n%s\n%s\n%s\n' "$success" "$rtt" "$detail" "$probe_at" "$seq" > "$path"
|
||||
}
|
||||
|
||||
normalize_foreign_url() {
|
||||
@@ -264,6 +373,7 @@ resolve_target_ip() {
|
||||
do_ping() {
|
||||
local target="$1"
|
||||
local name="$2"
|
||||
local seq="$3"
|
||||
local result_file="$RUN_DIR/${name}_last_result"
|
||||
local ping_target
|
||||
local result
|
||||
@@ -273,9 +383,9 @@ do_ping() {
|
||||
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}" ""
|
||||
write_result_file "$result_file" "1" "${rtt:-0}" "" "" "$seq"
|
||||
else
|
||||
write_result_file "$result_file" "0" "" "ping 无响应"
|
||||
write_result_file "$result_file" "0" "" "ping 无响应" "" "$seq"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -285,6 +395,7 @@ do_proxy_curl() {
|
||||
local proxy_host="${3:-127.0.0.1}"
|
||||
local proxy_port="$4"
|
||||
local name="$5"
|
||||
local seq="$6"
|
||||
local result_file="$RUN_DIR/${name}_last_result"
|
||||
local error_file="$RUN_DIR/${name}_curl_error"
|
||||
local url
|
||||
@@ -297,12 +408,12 @@ do_proxy_curl() {
|
||||
local rc
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
write_result_file "$result_file" "0" "" "系统未安装 curl"
|
||||
write_result_file "$result_file" "0" "" "系统未安装 curl" "" "$seq"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ -z "$proxy_port" ]; then
|
||||
write_result_file "$result_file" "0" "" "代理端口未配置"
|
||||
write_result_file "$result_file" "0" "" "代理端口未配置" "" "$seq"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -323,11 +434,11 @@ do_proxy_curl() {
|
||||
|
||||
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}"
|
||||
write_result_file "$result_file" "1" "$rtt_ms" "curl 连接探测成功,HTTP ${http_code}" "" "$seq"
|
||||
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"
|
||||
write_result_file "$result_file" "0" "" "$detail" "" "$seq"
|
||||
fi
|
||||
|
||||
rm -f "$error_file"
|
||||
@@ -369,46 +480,46 @@ set_target_state() {
|
||||
esac
|
||||
}
|
||||
|
||||
get_processed_probe_at() {
|
||||
get_processed_seq() {
|
||||
local name="$1"
|
||||
local kind="$2"
|
||||
|
||||
case "${name}:${kind}" in
|
||||
domestic:fault)
|
||||
processed_probe_at="$domestic_last_fault_probe_at"
|
||||
processed_seq="$domestic_last_fault_seq"
|
||||
;;
|
||||
foreign:fault)
|
||||
processed_probe_at="$foreign_last_fault_probe_at"
|
||||
processed_seq="$foreign_last_fault_seq"
|
||||
;;
|
||||
domestic:stats)
|
||||
processed_probe_at="$domestic_last_stats_probe_at"
|
||||
processed_seq="$domestic_last_stats_seq"
|
||||
;;
|
||||
foreign:stats)
|
||||
processed_probe_at="$foreign_last_stats_probe_at"
|
||||
processed_seq="$foreign_last_stats_seq"
|
||||
;;
|
||||
*)
|
||||
processed_probe_at=0
|
||||
processed_seq=0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
set_processed_probe_at() {
|
||||
set_processed_seq() {
|
||||
local name="$1"
|
||||
local kind="$2"
|
||||
local value="${3:-0}"
|
||||
|
||||
case "${name}:${kind}" in
|
||||
domestic:fault)
|
||||
domestic_last_fault_probe_at="$value"
|
||||
domestic_last_fault_seq="$value"
|
||||
;;
|
||||
foreign:fault)
|
||||
foreign_last_fault_probe_at="$value"
|
||||
foreign_last_fault_seq="$value"
|
||||
;;
|
||||
domestic:stats)
|
||||
domestic_last_stats_probe_at="$value"
|
||||
domestic_last_stats_seq="$value"
|
||||
;;
|
||||
foreign:stats)
|
||||
foreign_last_stats_probe_at="$value"
|
||||
foreign_last_stats_seq="$value"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
@@ -418,8 +529,8 @@ reset_target_state() {
|
||||
|
||||
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
|
||||
set_processed_seq "$name" "fault" 0
|
||||
set_processed_seq "$name" "stats" 0
|
||||
}
|
||||
|
||||
update_target_faults() {
|
||||
@@ -430,7 +541,7 @@ update_target_faults() {
|
||||
local success="0"
|
||||
local rtt=""
|
||||
local detail=""
|
||||
local probe_at=""
|
||||
local seq=""
|
||||
|
||||
[ -n "$target" ] || {
|
||||
reset_target_state "$name"
|
||||
@@ -443,11 +554,13 @@ update_target_faults() {
|
||||
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)"
|
||||
seq="$(sed -n '5p' "$result_file" 2>/dev/null)"
|
||||
fi
|
||||
|
||||
get_processed_probe_at "$name" "fault"
|
||||
if [ -n "$probe_at" ] && [ "$probe_at" = "$processed_probe_at" ]; then
|
||||
[ -n "$seq" ] || return 0
|
||||
|
||||
get_processed_seq "$name" "fault"
|
||||
if [ "$seq" = "$processed_seq" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -457,7 +570,7 @@ update_target_faults() {
|
||||
fi
|
||||
|
||||
set_target_state "$name" 0 0
|
||||
set_processed_probe_at "$name" "fault" "${probe_at:-0}"
|
||||
set_processed_seq "$name" "fault" "$seq"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -470,7 +583,7 @@ update_target_faults() {
|
||||
fault_open=1
|
||||
|
||||
set_target_state "$name" "$fail_count" "$fault_open"
|
||||
set_processed_probe_at "$name" "fault" "${probe_at:-0}"
|
||||
set_processed_seq "$name" "fault" "$seq"
|
||||
}
|
||||
|
||||
calc_stats() {
|
||||
@@ -525,7 +638,7 @@ update_cumulative_stats() {
|
||||
local result_file="$RUN_DIR/${name}_last_result"
|
||||
local success="0"
|
||||
local rtt=""
|
||||
local probe_at=""
|
||||
local seq=""
|
||||
local total_samples=0
|
||||
local success_count=0
|
||||
local loss_count=0
|
||||
@@ -535,6 +648,19 @@ update_cumulative_stats() {
|
||||
|
||||
[ -n "$target" ] || return 0
|
||||
|
||||
if [ -f "$result_file" ]; then
|
||||
success="$(sed -n '1p' "$result_file" 2>/dev/null)"
|
||||
rtt="$(sed -n '2p' "$result_file" 2>/dev/null)"
|
||||
seq="$(sed -n '5p' "$result_file" 2>/dev/null)"
|
||||
fi
|
||||
|
||||
[ -n "$seq" ] || return 0
|
||||
|
||||
get_processed_seq "$name" "stats"
|
||||
if [ "$seq" = "$processed_seq" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "$name" in
|
||||
domestic)
|
||||
total_samples="$domestic_total_samples"
|
||||
@@ -556,17 +682,6 @@ update_cumulative_stats() {
|
||||
|
||||
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))
|
||||
@@ -606,7 +721,13 @@ update_cumulative_stats() {
|
||||
;;
|
||||
esac
|
||||
|
||||
set_processed_probe_at "$name" "stats" "${probe_at:-0}"
|
||||
set_processed_seq "$name" "stats" "$seq"
|
||||
|
||||
if [ "$success" = "1" ]; then
|
||||
accumulate_history "$name" "1" "${rtt:-0}"
|
||||
else
|
||||
accumulate_history "$name" "0" ""
|
||||
fi
|
||||
}
|
||||
|
||||
while true; do
|
||||
@@ -614,6 +735,7 @@ while true; do
|
||||
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)"
|
||||
current_minute="$(date '+%Y-%m-%d %H:%M')"
|
||||
DOMESTIC_TARGET=""
|
||||
FOREIGN_TARGET=""
|
||||
FOREIGN_PROXY_TYPE="socks5h"
|
||||
@@ -642,7 +764,8 @@ while true; do
|
||||
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" &
|
||||
probe_seq=$((probe_seq + 1))
|
||||
do_ping "$DOMESTIC_TARGET" "domestic" "$probe_seq" &
|
||||
child_pids="$!"
|
||||
domestic_last_probe_at="$NOW_TS"
|
||||
fi
|
||||
@@ -653,7 +776,8 @@ while true; do
|
||||
|
||||
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" &
|
||||
probe_seq=$((probe_seq + 1))
|
||||
do_proxy_curl "$FOREIGN_TARGET" "$FOREIGN_PROXY_TYPE" "$FOREIGN_PROXY_HOST" "$FOREIGN_PROXY_PORT" "foreign" "$probe_seq" &
|
||||
child_pids="${child_pids:+$child_pids }$!"
|
||||
foreign_last_probe_at="$NOW_TS"
|
||||
fi
|
||||
@@ -679,4 +803,3 @@ while true; do
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-vantage
|
||||
PKG_VERSION:=1.0.12
|
||||
PKG_RELEASE:=19
|
||||
PKG_VERSION:=1.0.13
|
||||
PKG_RELEASE:=20
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
|
||||
LUCI_TITLE:=Vantage dashboard
|
||||
|
||||
@@ -180,6 +180,33 @@ return baseclass.extend({
|
||||
return { mbps: +m[1], duplex: m[2] === 'H' ? 'half' : (m[2] === 'F' ? 'full' : null) };
|
||||
},
|
||||
|
||||
/* The switch ports behind a port that is a VLAN on a swconfig switch
|
||||
(eth1.1): `map` is luci.vantage wired's switches, `states` LuCI's
|
||||
getSwconfigPortState replies by switch name. { name, vlan, ports:
|
||||
[ { num, label, link, speed } ], up } or null; link and speed stay
|
||||
null while the switch has not answered. */
|
||||
switchOf: function(map, states, port) {
|
||||
var sw = typeof port === 'string' && own(map, port) ? obj(map[port]) : null;
|
||||
if (!sw || typeof sw.name !== 'string' || !/^[A-Za-z0-9_.-]{1,32}$/.test(sw.name) || !finite(sw.vlan)) return null;
|
||||
var state = own(states, sw.name) ? states[sw.name] : null, byNum = Object.create(null);
|
||||
arr(state).slice(0, 64).forEach(function(p) { p = obj(p); if (finite(p.port)) byNum[p.port] = p; });
|
||||
var known = Array.isArray(state) && state.length > 0, up = 0;
|
||||
var ports = arr(sw.ports).slice(0, 64).map(obj).filter(function(p) { return finite(p.num) && p.num >= 0 && p.num < 64; }).map(function(p) {
|
||||
var s = byNum[p.num], role = typeof p.role === 'string' && /^[a-z0-9]{1,16}$/.test(p.role) ? p.role.toUpperCase() : null;
|
||||
var link = known ? !!(s && s.link === true) : null;
|
||||
if (link) up++;
|
||||
return {
|
||||
num: p.num,
|
||||
/* "LAN 2" where board.json numbers the ports by role, else
|
||||
swconfig's own number: four "LAN" would tell them apart */
|
||||
label: role && finite(p.index) ? role + ' ' + p.index : _('Port %d').format(p.num),
|
||||
link: link,
|
||||
speed: link && finite(s.speed) && s.speed > 0 ? { mbps: s.speed, duplex: s.duplex === true ? 'full' : s.duplex === false ? 'half' : null } : null
|
||||
};
|
||||
});
|
||||
return { name: sw.name, vlan: sw.vlan, ports: ports, up: known ? up : null };
|
||||
},
|
||||
|
||||
speedLabel: function(sp) {
|
||||
if (!sp) return fmt.DASH;
|
||||
var v = sp.mbps >= 1000 ? _('%s Gbit/s').format(sp.mbps / 1000) : _('%s Mbit/s').format(sp.mbps);
|
||||
@@ -587,6 +614,10 @@ return baseclass.extend({
|
||||
wiredMacs.forEach(function(mac) { var p = fdb[mac].port; perPort[p] = (perPort[p] || 0) + 1; });
|
||||
var wired = wiredMacs.map(function(mac) {
|
||||
var h = fdb[mac], d = devs[h.port], hint = hintOf(mac), ips = self.hintAddrs(hint, wiredSubnets(mac));
|
||||
/* behind a switch VLAN the port's own link is the CPU's: the
|
||||
host's is a switch port's, known when only one has a link */
|
||||
var sw = self.switchOf(raw.switches, raw.switchState, h.port);
|
||||
var lit = sw && sw.up === 1 ? sw.ports.filter(function(p) { return p.link; })[0] : null;
|
||||
var alias = raw.aliases && raw.aliases[mac];
|
||||
var vendor = typeof raw.vendor === 'function' ? raw.vendor(names.ouiKey(mac)) : null;
|
||||
var nm = names.resolve({ mac: mac, alias: alias, ips: ips, rdns: raw.rdns, mdns: mdns, hint: hint, wps: null, vendor: vendor });
|
||||
@@ -610,8 +641,10 @@ return baseclass.extend({
|
||||
seen: h.seen,
|
||||
peers: perPort[h.port],
|
||||
shared: perPort[h.port] > 1,
|
||||
speed: d ? self.parseSpeed(d.speed) : null,
|
||||
carrier: d ? d.carrier !== false : null,
|
||||
speed: sw ? (lit ? lit.speed : null) : d ? self.parseSpeed(d.speed) : null,
|
||||
carrier: sw && sw.up != null ? sw.up > 0 : d ? d.carrier !== false : null,
|
||||
switch: sw,
|
||||
switchPort: lit ? lit.label : null,
|
||||
gateway: mac === gwMac
|
||||
};
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ var WINDOW_MS = 5 * 60 * 1000; /* throughput history shown */
|
||||
var HIST_MAX = 90;
|
||||
var GONE_KEEP_MS = 5 * 60 * 1000; /* disconnected stations stay listed */
|
||||
var TALKERS = 3; /* top talkers shown; the tile keeps room for all */
|
||||
var SWITCH_MS = 30 * 1000; /* LuCI runs swconfig for each switch's link state: at most this often */
|
||||
var STORE_KEY = 'vantage.hist.v1';
|
||||
var SVGNS = 'http://www.w3.org/2000/svg';
|
||||
var HOSTAPD_OBJ = /^[A-Za-z0-9_.-]{1,64}$/;
|
||||
@@ -37,8 +38,13 @@ var callDevs = rpc.declare({ object: 'network.device', method: 'status', expect:
|
||||
without keys or RADIUS secrets, and the validated alias write */
|
||||
var callWifi = rpc.declare({ object: 'luci.vantage', method: 'wireless', expect: { '': {} } });
|
||||
var callThermal = rpc.declare({ object: 'luci.vantage', method: 'thermal', expect: { zones: [] } });
|
||||
/* { hosts, crowded }: crowded when a bridge holds too many entries to list */
|
||||
/* { hosts, crowded, switches }: crowded when a bridge holds too many
|
||||
entries to list; switches, the swconfig switch ports behind a port */
|
||||
var callWired = rpc.declare({ object: 'luci.vantage', method: 'wired', expect: { '': {} } });
|
||||
/* LuCI's own (its switch page's): link and speed of a switch's ports, in
|
||||
result. The whole reply, and a failed call rejected: a default [] would
|
||||
read as every port down */
|
||||
var callSwitchPorts = rpc.declare({ object: 'luci', method: 'getSwconfigPortState', params: [ 'switch' ], expect: { '': {} }, reject: true });
|
||||
var callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', expect: { '': {} } });
|
||||
var callIwInfo = rpc.declare({ object: 'iwinfo', method: 'info', params: [ 'device' ], expect: { '': {} } });
|
||||
var callAssoc = rpc.declare({ object: 'iwinfo', method: 'assoclist', params: [ 'device' ], expect: { results: [] } });
|
||||
@@ -327,6 +333,17 @@ function rateLine(down, up, cls) {
|
||||
]);
|
||||
}
|
||||
|
||||
/* a switch VLAN's ports, a line each: "LAN 2 · 2.5 Gbit/s" (model.switchOf) */
|
||||
function switchLines(sw) {
|
||||
return sw.ports.map(function(p) {
|
||||
return p.label + ' · ' + (p.link === null ? fmt.DASH : !p.link ? _('down') : p.speed ? fmt.phyRate(p.speed.mbps * 1000) : _('link up'));
|
||||
});
|
||||
}
|
||||
|
||||
function switchNote(sw) {
|
||||
return _('Switch ports of VLAN %d on %s: %s').format(sw.vlan, sw.name, switchLines(sw).join(', '));
|
||||
}
|
||||
|
||||
function kv(label, value, mono) {
|
||||
return E('div', { 'class': 'vt-kv' }, [
|
||||
E('dt', {}, [ label ]),
|
||||
@@ -496,7 +513,7 @@ return view.extend({
|
||||
|
||||
/* one poll's worth of raw replies; missing pieces degrade to {} */
|
||||
fetch: function() {
|
||||
var started = Date.now();
|
||||
var started = Date.now(), self = this;
|
||||
return Promise.all([
|
||||
L.resolveDefault(callInfo(), {}),
|
||||
L.resolveDefault(callRead('/proc/stat'), ''),
|
||||
@@ -521,6 +538,26 @@ return view.extend({
|
||||
and SSID for each network (luci.vantage wireless carries
|
||||
configuration only) */
|
||||
var per = [];
|
||||
/* the link state of a switch with hosts behind it, every
|
||||
SWITCH_MS: each answer runs swconfig on the device. Asked
|
||||
on the side, so a slow switch holds up no poll; the answer
|
||||
shows from the next one */
|
||||
var switches = (wiredReply.switches && typeof wiredReply.switches === 'object' && !Array.isArray(wiredReply.switches)) ? wiredReply.switches : {};
|
||||
var swState = self.swState || (self.swState = Object.create(null));
|
||||
var hostPorts = (Array.isArray(wiredReply.hosts) ? wiredReply.hosts : []).map(function(h) { return h && h.port; });
|
||||
var due = [], asked = Date.now();
|
||||
Object.keys(switches).forEach(function(dev) {
|
||||
var n = switches[dev] && switches[dev].name;
|
||||
if (typeof n === 'string' && /^[A-Za-z0-9_.-]{1,32}$/.test(n) && hostPorts.indexOf(dev) >= 0 && due.indexOf(n) < 0 &&
|
||||
!(swState[n] && asked - swState[n].at < SWITCH_MS)) due.push(n);
|
||||
});
|
||||
due.slice(0, 4).forEach(function(n) {
|
||||
swState[n] = { at: asked, ports: swState[n] ? swState[n].ports : null };
|
||||
L.resolveDefault(callSwitchPorts(n), null).then(function(v) {
|
||||
/* no ports (an error, an unknown switch): not known, not down */
|
||||
swState[n] = { at: asked, ports: v && Array.isArray(v.result) && v.result.length ? v.result : null };
|
||||
});
|
||||
});
|
||||
ifnames.forEach(function(ifn) {
|
||||
per.push(L.resolveDefault(callAssoc(ifn), []).then(function(v) { return [ 'assoc', ifn, v ]; }));
|
||||
per.push(hostapd(ifn, 'get_clients').then(function(v) { return [ 'hapd', ifn, v ]; }));
|
||||
@@ -535,8 +572,10 @@ return view.extend({
|
||||
else if (p[0] === 'hapdStatus') hapdStatus[p[1]] = p[2];
|
||||
else iwinfo[p[1]] = p[2];
|
||||
});
|
||||
var switchState = Object.create(null);
|
||||
Object.keys(swState).forEach(function(n) { switchState[n] = swState[n].ports; });
|
||||
return { info: r[0] || {}, stat: r[1] || '', ifaces: r[2] || [], devs: r[3] || {}, wifi: wifiDevs, hints: r[5] || {}, thermal: r[6] || [],
|
||||
fdb: Array.isArray(wiredReply.hosts) ? wiredReply.hosts : [], fdbCrowded: wiredReply.crowded === true,
|
||||
fdb: Array.isArray(wiredReply.hosts) ? wiredReply.hosts : [], fdbCrowded: wiredReply.crowded === true, switches: switches, switchState: switchState,
|
||||
assoc: assoc, hapd: hapd, hapdStatus: hapdStatus, iwinfo: iwinfo, at: Date.now(), started: started };
|
||||
});
|
||||
});
|
||||
@@ -611,6 +650,7 @@ return view.extend({
|
||||
var m = model.build({
|
||||
board: this.board, info: raw.info, ifaces: raw.ifaces, devs: raw.devs, wifi: raw.wifi, iwinfo: raw.iwinfo,
|
||||
assoc: raw.assoc, hapd: raw.hapd, hapdStatus: raw.hapdStatus, hints: raw.hints, fdb: raw.fdb,
|
||||
switches: raw.switches, switchState: raw.switchState,
|
||||
aliases: this.aliases, rdns: st.rdns, mdns: st.mdns,
|
||||
vendor: st.oui ? function(k) { return k ? st.oui.lookup(k) : null; } : null
|
||||
});
|
||||
@@ -766,6 +806,7 @@ return view.extend({
|
||||
var m = model.build({
|
||||
board: this.board, info: raw.info, ifaces: raw.ifaces, devs: raw.devs, wifi: raw.wifi, iwinfo: raw.iwinfo,
|
||||
assoc: raw.assoc, hapd: raw.hapd, hapdStatus: raw.hapdStatus, hints: raw.hints, fdb: raw.fdb,
|
||||
switches: raw.switches, switchState: raw.switchState,
|
||||
aliases: this.aliases, rdns: st.rdns, mdns: st.mdns,
|
||||
vendor: st.oui ? function(k) { return k ? st.oui.lookup(k) : null; } : null
|
||||
});
|
||||
@@ -1483,9 +1524,9 @@ return view.extend({
|
||||
]);
|
||||
var wcol = E('div', { 'class': 'vt-branch-ssids' });
|
||||
ports.forEach(function(p) {
|
||||
var sn = { id: 'port:' + p, port: p, name: E('span'), count: span('vt-tn') };
|
||||
var sn = { id: 'port:' + p, port: p, name: E('span'), count: span('vt-tn'), rate: span('vt-node-sub vt-tn') };
|
||||
sn.el = E('button', { 'type': 'button', 'class': 'vt-node vt-node-ssid', 'click': function() { self.showWired(p); } }, [
|
||||
E('span', { 'class': 'vt-node-body' }, [ E('span', { 'class': 'vt-node-title vt-ssid-name' }, [ sn.name ]), E('span', { 'class': 'vt-node-sub' }, [ sn.count ]) ])
|
||||
E('span', { 'class': 'vt-node-body' }, [ E('span', { 'class': 'vt-node-title vt-ssid-name' }, [ sn.name ]), E('span', { 'class': 'vt-node-sub' }, [ sn.count ]), sn.rate ])
|
||||
]);
|
||||
wb.ssids.push(sn);
|
||||
wcol.appendChild(sn.el);
|
||||
@@ -1598,17 +1639,21 @@ return view.extend({
|
||||
},
|
||||
|
||||
/* the Wired branch of the path: its hosts, and per port the one host
|
||||
with its link speed or how many share the port */
|
||||
with its link speed or how many share the port, and the port's
|
||||
traffic: all of its hosts' together, never split between them */
|
||||
updateWiredBranch: function(b) {
|
||||
var st = this.st, hosts = this.m.wiredHosts || [], n = 0, rx = 0, tx = 0, seen = false;
|
||||
b.ssids.forEach(function(sn) {
|
||||
var on = hosts.filter(function(c) { return c.port === sn.port; }), r = st.devRates[sn.port], one = on.length === 1 ? on[0] : null;
|
||||
var sw = on.length ? on[0].switch : null;
|
||||
n += on.length;
|
||||
if (r) { rx += r.rx; tx += r.tx; seen = true; }
|
||||
setClass(sn.el, 'vt-node vt-node-ssid' + (on.length ? '' : ' vt-node-idle'));
|
||||
setTitle(sn.el, _('Show the devices on %s').format(sn.port));
|
||||
setTitle(sn.el, _('Show the devices on %s').format(sn.port) + (r ? '\n' + _('Port total: %s').format('↓ ' + fmt.bits(r.tx) + ' · ↑ ' + fmt.bits(r.rx)) : '') +
|
||||
(sw ? '\n' + switchNote(sw) : ''));
|
||||
txt(sn.name, one ? sn.port + ' · ' + one.name : sn.port);
|
||||
txt(sn.count, one ? (one.speed ? fmt.phyRate(one.speed.mbps * 1000) : fmt.DASH) : on.length === 1 ? _('1 device') : _('%d devices').format(on.length));
|
||||
txt(sn.rate, r ? '↓ ' + fmt.bitsShort(r.tx) + ' ↑ ' + fmt.bitsShort(r.rx) : fmt.DASH);
|
||||
sn.active = !!(r && r.rx + r.tx > 50e3);
|
||||
});
|
||||
txt(b.title, n === 1 ? _('1 device') : _('%d devices').format(n));
|
||||
@@ -2561,9 +2606,11 @@ return view.extend({
|
||||
var speed = c.speed ? fmt.phyRate(c.speed.mbps * 1000) : fmt.DASH;
|
||||
var link = E('td', { 'class': 'vt-td-link', 'data-title': _('Link', 'vantage') }, [
|
||||
E('div', { 'class': 'vt-phy vt-tn' }, [ E('b', { 'class': c.carrier === false ? 'vt-t-warn' : '' }, [ c.carrier === false ? _('down') : c.shared ? _('Shared port') : speed ]) ]),
|
||||
E('div', { 'class': 'vt-phy vt-phy-up vt-tn' }, [ c.shared ? _('%s · %d devices').format(c.port, c.peers) : c.port ])
|
||||
E('div', { 'class': 'vt-phy vt-phy-up vt-tn' }, [ c.shared ? _('%s · %d devices').format(c.port, c.peers) : c.switchPort ? c.port + ' · ' + c.switchPort : c.port ])
|
||||
]);
|
||||
link.setAttribute('title', c.shared ? _('%s is shared by %d devices (a switch or another access point behind it); its %s link is not theirs').format(c.port, c.peers, speed) : _('Port %s').format(c.port));
|
||||
link.setAttribute('title', (c.shared ? (c.switch ? _('%s is shared by %d devices behind the switch').format(c.port, c.peers)
|
||||
: _('%s is shared by %d devices (a switch or another access point behind it); its %s link is not theirs').format(c.port, c.peers, speed)) : _('Port %s').format(c.port)) +
|
||||
(c.switch ? '\n' + switchNote(c.switch) : ''));
|
||||
return [
|
||||
this.whoCell(c, tags),
|
||||
link,
|
||||
@@ -2877,7 +2924,7 @@ return view.extend({
|
||||
},
|
||||
|
||||
drawerWired: function(c) {
|
||||
var self = this, r = this.portRate(c);
|
||||
var self = this, r = this.portRate(c), shared = c.shared ? this.st.devRates[c.port] : null;
|
||||
var title = [
|
||||
avatar(c),
|
||||
E('div', { 'class': 'vt-drawer-titles' }, [
|
||||
@@ -2895,9 +2942,14 @@ return view.extend({
|
||||
this.el.renameSlot = E('div', { 'class': 'vt-rename-slot' }),
|
||||
this.section(_('Connection'), [ E('dl', { 'class': 'vt-kvs' }, [
|
||||
kv(_('Port'), c.port, true),
|
||||
kv(_('Link', 'vantage'), c.carrier === false ? _('down') : c.shared ? [ _('Shared by %d devices').format(c.peers), E('small', {}, [ _('the port: %s').format(speed) ]) ] : speed),
|
||||
kv(_('Traffic'), c.shared ? E('span', { 'class': 'vt-muted' }, [ _('Not counted per device on a shared port') ])
|
||||
: r ? '↓ ' + fmt.bits(r.tx) + ' · ↑ ' + fmt.bits(r.rx) : _('measuring')),
|
||||
kv(_('Link', 'vantage'), c.carrier === false ? _('down') : c.shared ? [ _('Shared by %d devices').format(c.peers),
|
||||
E('small', {}, [ c.switch ? _('behind %s, VLAN %d').format(c.switch.name, c.switch.vlan) : _('the port: %s').format(speed) ]) ]
|
||||
: c.switchPort ? [ speed, E('small', {}, [ _('switch port %s').format(c.switchPort) ]) ] : speed),
|
||||
c.switch ? kv(_('Switch ports'), switchLines(c.switch).map(function(l) { return E('div', {}, [ l ]); })) : '',
|
||||
kv(_('Traffic'), c.shared ? [
|
||||
E('span', { 'class': 'vt-muted' }, [ _('Not counted per device on a shared port') ]),
|
||||
shared ? E('small', {}, [ _('the port in total: %s').format('↓ ' + fmt.bits(shared.tx) + ' · ↑ ' + fmt.bits(shared.rx)) ]) : ''
|
||||
] : r ?'↓ ' + fmt.bits(r.tx) + ' · ↑ ' + fmt.bits(r.rx) : _('measuring')),
|
||||
kv(_('Network'), [ c.network || c.bridge || fmt.DASH, c.vlan != null ? E('small', {}, [ _('VLAN %d').format(c.vlan) ]) : '' ]),
|
||||
kv(_('Last heard'), c.seen == null ? _('static entry') : fmt.ago(c.seen * 1000))
|
||||
]) ]),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"luci-app-vantage": {
|
||||
"description": "Vantage dashboard: read-only status of the device, uplink, radios and stations (wireless configuration without keys, through luci.vantage wireless), its temperature sensors (luci.vantage thermal) and the wired hosts its bridges have learned (luci.vantage wired: MAC, port and when last heard)",
|
||||
"description": "Vantage dashboard: read-only status of the device, uplink, radios and stations (wireless configuration without keys, through luci.vantage wireless), its temperature sensors (luci.vantage thermal), the wired hosts its bridges have learned (luci.vantage wired: MAC, port and when last heard, and which switch ports a port's VLAN has) and the link state of those switch ports (luci getSwconfigPortState)",
|
||||
"read": {
|
||||
"file": {
|
||||
"/proc/stat": [ "read" ]
|
||||
@@ -11,6 +11,7 @@
|
||||
"network.interface": [ "dump" ],
|
||||
"network.device": [ "status" ],
|
||||
"luci-rpc": [ "getHostHints" ],
|
||||
"luci": [ "getSwconfigPortState" ],
|
||||
"luci.vantage": [ "thermal", "wired", "wireless" ],
|
||||
"iwinfo": [ "assoclist", "info" ],
|
||||
"hostapd.*": [ "get_clients", "get_status" ],
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
// when the bridges hold FDB_LIMIT entries or more between
|
||||
// them, counted first through sysfs without reading further: a flood of
|
||||
// made-up addresses must not make rpcd build the whole
|
||||
// table. Read group luci-app-vantage.
|
||||
// table. Where a port is a VLAN on a swconfig switch
|
||||
// (eth1.1), also which switch ports that VLAN has
|
||||
// (switches): from /etc/board.json (the CPU port's network
|
||||
// device, the ports' roles) and the switch_vlan sections of
|
||||
// /etc/config/network (device, vlan, vid and ports, nothing
|
||||
// else), or board.json's roles for a switch those give no
|
||||
// VLAN. Read group luci-app-vantage.
|
||||
// set_alias { mac, name, icon }: name one station, or remove its name
|
||||
// with name ''. Validated here, whatever the browser did, and
|
||||
// written to /etc/config/vantage only. Write group
|
||||
@@ -49,6 +55,10 @@ const MAX_PER_PORT = 128; // of them on one port (the uplink's would tak
|
||||
const NET = '/sys/class/net';
|
||||
const FDB_LIMIT = 4096; // forwarding entries, all bridges together, before wired() lists none
|
||||
const FDB_ENTRY = 16; // sizeof(struct __fdb_entry), brforward's record
|
||||
const NETWORK = 'network'; // read only, by switches(): switch_vlan sections
|
||||
const BOARD = '/etc/board.json';
|
||||
const BOARD_MAX = 65536; // bytes read of it
|
||||
const MAX_SWITCH_VLANS = 64;
|
||||
|
||||
/* ------------------------------------------------------------ helpers */
|
||||
|
||||
@@ -468,6 +478,120 @@ function wired() {
|
||||
return out;
|
||||
}
|
||||
|
||||
// a switch port's number (0..63), or null
|
||||
function switch_port(v) {
|
||||
return (type(v) == 'int' && v >= 0 && v < 64) ? v : null;
|
||||
}
|
||||
|
||||
// the network devices a switch VLAN reaches this device as (each CPU
|
||||
// port's device in it, with .vid where that port is tagged) and its other
|
||||
// ports, from a port list such as '0 1 2 3 5t'; null without a CPU port
|
||||
function vlan_ports(sw, list, vid) {
|
||||
let devs = [], members = [];
|
||||
|
||||
for (let t in split(list, /\s+/)) {
|
||||
let m = match(t, /^([0-9]{1,2})([t*]?)$/);
|
||||
let num = m ? switch_port(+m[1]) : null;
|
||||
|
||||
if (num == null || length(members) >= 64)
|
||||
continue;
|
||||
|
||||
if (sw.cpu[num] != null) {
|
||||
let dev = netdev((m[2] == 't') ? sw.cpu[num] + '.' + vid : sw.cpu[num]);
|
||||
|
||||
if (dev && index(devs, dev) < 0)
|
||||
push(devs, dev);
|
||||
}
|
||||
else
|
||||
push(members, sw.ports[num] ?? { num, role: null, index: null });
|
||||
}
|
||||
|
||||
return length(devs) ? { devs, members } : null;
|
||||
}
|
||||
|
||||
// { 'eth1.1': { name: 'switch0', vlan: 1, ports: [ { num, role, index } ] } }
|
||||
// for each VLAN of a swconfig switch: the network device it reaches this
|
||||
// device as and its other ports, as LuCI's switch page lays them out.
|
||||
// From the switch_vlan sections of the network config, or for a switch
|
||||
// they give no VLAN, from the roles board.json gives it (the first boot's
|
||||
// layout). Only switches /etc/board.json describes; {} without one, so a
|
||||
// device whose ports are all Linux ports (DSA) loads no network config.
|
||||
function switches() {
|
||||
let board = null, known = {}, used = {}, out = {}, n = 0;
|
||||
|
||||
try { board = json(readfile(BOARD, BOARD_MAX) ?? 'null'); }
|
||||
catch (e) { return out; }
|
||||
|
||||
for (let name, sw in ((type(board?.switch) == 'object') ? board.switch : {})) {
|
||||
if (!match(name, /^[A-Za-z0-9_.-]{1,32}$/) || type(sw?.ports) != 'array')
|
||||
continue;
|
||||
|
||||
let ports = {}, cpu = {};
|
||||
|
||||
for (let p in sw.ports) {
|
||||
let num = switch_port(p?.num);
|
||||
|
||||
if (num == null)
|
||||
continue;
|
||||
|
||||
if (netdev(p.device))
|
||||
cpu[num] = p.device;
|
||||
else
|
||||
ports[num] = {
|
||||
num,
|
||||
role: (type(p.role) == 'string' && match(p.role, /^[a-z0-9]{1,16}$/)) ? p.role : null,
|
||||
index: (type(p.index) == 'int' && p.index >= 0 && p.index < 100) ? p.index : null
|
||||
};
|
||||
}
|
||||
|
||||
if (length(cpu))
|
||||
known[name] = { ports, cpu, roles: (type(sw.roles) == 'array') ? slice(sw.roles, 0, 16) : [] };
|
||||
}
|
||||
|
||||
if (!length(known))
|
||||
return out;
|
||||
|
||||
let nw = cursor();
|
||||
|
||||
if (nw.load(NETWORK))
|
||||
nw.foreach(NETWORK, 'switch_vlan', (s) => {
|
||||
let sw = (type(s.device) == 'string') ? known[s.device] : null;
|
||||
let id = s.vid ?? s.vlan;
|
||||
let vid = (type(id) == 'string' && match(id, /^[0-9]{1,4}$/)) ? +id : null;
|
||||
|
||||
if (!sw || vid == null || vid > 4094 || type(s.ports) != 'string')
|
||||
return;
|
||||
|
||||
if (++n > MAX_SWITCH_VLANS)
|
||||
return false;
|
||||
|
||||
let v = vlan_ports(sw, s.ports, vid);
|
||||
|
||||
for (let dev in (v?.devs ?? [])) {
|
||||
if (!out[dev])
|
||||
out[dev] = { name: s.device, vlan: vid, ports: v.members };
|
||||
|
||||
used[s.device] = true;
|
||||
}
|
||||
});
|
||||
|
||||
for (let name, sw in known) {
|
||||
if (used[name])
|
||||
continue;
|
||||
|
||||
for (let r in sw.roles) {
|
||||
let m = (type(r?.device) == 'string' && type(r.ports) == 'string') ? match(r.device, /^[A-Za-z0-9_-]{1,10}\.([0-9]{1,4})$/) : null;
|
||||
let vid = m ? +m[1] : null;
|
||||
let v = (vid != null && vid <= 4094) ? vlan_ports(sw, r.ports, vid) : null;
|
||||
|
||||
if (v && index(v.devs, r.device) >= 0 && !out[r.device])
|
||||
out[r.device] = { name, vlan: vid, ports: v.members };
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ methods */
|
||||
|
||||
const methods = {
|
||||
@@ -497,9 +621,13 @@ const methods = {
|
||||
|
||||
wired: {
|
||||
call: function() {
|
||||
let hosts = wired();
|
||||
let hosts = wired(), sw = switches();
|
||||
let reply = (hosts == null) ? { hosts: [], crowded: true } : { hosts };
|
||||
|
||||
return (hosts == null) ? { hosts: [], crowded: true } : { hosts };
|
||||
if (length(sw))
|
||||
reply.switches = sw;
|
||||
|
||||
return reply;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-theme-vantage
|
||||
PKG_VERSION:=1.0.12
|
||||
PKG_RELEASE:=27
|
||||
PKG_VERSION:=1.0.13
|
||||
PKG_RELEASE:=28
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
|
||||
LUCI_TITLE:=Vantage theme
|
||||
|
||||
@@ -2556,9 +2556,6 @@ html:not([data-darkmode="true"]) .vt-login-page {
|
||||
position: relative; width: min(380px, 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
.vt-login-page{
|
||||
background:none !important;
|
||||
}
|
||||
.vt-login-page::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -2569,7 +2566,7 @@ html:not([data-darkmode="true"]) .vt-login-page {
|
||||
opacity: 0.4;
|
||||
}
|
||||
html:not([data-darkmode="true"]) .vt-login-page::before {
|
||||
opacity: 0.7;
|
||||
opacity: 0.6;
|
||||
}
|
||||
#packages {
|
||||
width: 100% !important;
|
||||
|
||||
+2
-2
@@ -5,13 +5,13 @@ include $(INCLUDE_DIR)/kernel.mk
|
||||
# The name and version of your package are used to define the variable to point to the build directory of your package: $(PKG_BUILD_DIR)
|
||||
PKG_NAME:=xt_tls
|
||||
PKG_VERSION:=0.3.5
|
||||
PKG_RELEASE:=2
|
||||
PKG_RELEASE:=3
|
||||
|
||||
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
|
||||
PKG_SOURCE_URL:=https://github.com/Lochnair/xt_tls.git
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_SUBDIR=$(PKG_NAME)-$(PKG_VERSION)
|
||||
PKG_SOURCE_VERSION:=137644fe539dd910c63ce527f6456ab38f148e3f
|
||||
PKG_SOURCE_VERSION:=00f905ace4ce5e6d24c0a2122f92b166455e99b4
|
||||
|
||||
PKG_MAINTAINER:=Nils Andreas Svee <me@lochnair.net>
|
||||
PKG_LICENSE=GPL-3.0
|
||||
|
||||
Reference in new issue
Block a user