⛄ Sync 2026-11-03 04:53:29

This commit is contained in:
github-actions[bot] committed 2026-11-03 04:53:29 +08:00
1 parent 7abc2a26a6
commit bbccdf6fb0
17 files changed
+2571 -1000

No files matched your search

+2 -2
View File
@@ -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
+42 -1
View File
@@ -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
+2 -2
View File
@@ -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;
}
},
+2 -2
View File
@@ -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
View File
@@ -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