refactor: refactor luci interface

This commit is contained in:
SunBK201 2025-11-08 20:51:03 +08:00
parent 409545d0b5
commit 5dfa30cf34
11 changed files with 740 additions and 601 deletions

View File

@ -98,17 +98,15 @@ ipkg_build=ipkg-build.sh
mkdir -p \
$opkg_template/usr/bin \
$opkg_template/usr/lib/lua/luci/controller \
$opkg_template/usr/lib/lua/luci/model/cbi \
$opkg_template/usr/lib/lua/luci/model/cbi/ua3f \
$opkg_template/usr/lib/lua/luci/view/ua3f \
$opkg_template/usr/lib/lua/luci/i18n \
$opkg_template/etc/init.d \
$opkg_template/etc/config
cp openwrt/files/luci/controller.lua $opkg_template/usr/lib/lua/luci/controller/ua3f.lua
cp openwrt/files/luci/cbi.lua $opkg_template/usr/lib/lua/luci/model/cbi/ua3f.lua
cp openwrt/files/luci/statistics.htm $opkg_template/usr/lib/lua/luci/view/ua3f/statistics.htm
cp openwrt/files/ua3f.init $opkg_template/etc/init.d/ua3f
cp openwrt/files/ua3f.uci $opkg_template/etc/config/ua3f
cp -r openwrt/files/luci/* $opkg_template/usr/lib/lua/luci/
./po2lmo openwrt/po/zh_cn/ua3f.po $opkg_template/usr/lib/lua/luci/i18n/ua3f.zh-cn.lmo
# 仅 Linux 平台生成 ipk 包

View File

@ -1,6 +1,7 @@
#!/bin/sh
rm -rf /etc/config/ua3f >/dev/null 2>&1
rm -rf /var/log/ua3f >/dev/null 2>&1
rm -rf /usr/lib/lua/luci/model/cbi/ua3f >/dev/null 2>&1
rm -rf /usr/lib/lua/luci/model/cbi/ua3f.lua >/dev/null 2>&1
rm -rf /usr/lib/lua/luci/controller/ua3f.lua >/dev/null 2>&1
rm -rf /usr/lib/lua/luci/view/ua3f >/dev/null 2>&1

View File

@ -54,15 +54,9 @@ define Package/ua3f/install
$(INSTALL_BIN) ./files/ua3f.init $(1)/etc/init.d/ua3f
$(INSTALL_DIR) $(1)/etc/config/
$(INSTALL_CONF) ./files/ua3f.uci $(1)/etc/config/ua3f
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/model/cbi/
$(INSTALL_CONF) ./files/luci/cbi.lua $(1)/usr/lib/lua/luci/model/cbi/ua3f.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/controller/
$(INSTALL_CONF) ./files/luci/controller.lua $(1)/usr/lib/lua/luci/controller/ua3f.lua
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/view/ua3f/
$(INSTALL_CONF) ./files/luci/statistics.htm $(1)/usr/lib/lua/luci/view/ua3f/statistics.htm
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n/
$(INSTALL_DATA) $(PKG_BUILD_DIR)/ua3f.zh-cn.lmo $(1)/usr/lib/lua/luci/i18n/ua3f.zh-cn.lmo
$(CP) $(PKG_BUILD_DIR)/files/luci/* $(1)/usr/lib/lua/luci/
endef
$(eval $(call GoBinPackage,ua3f))

View File

@ -1,145 +0,0 @@
local uci = require("luci.model.uci").cursor()
ua3f = Map("ua3f",
"UA3F",
[[
<a href="https://github.com/SunBK201/UA3F" target="_blank">Version: 1.5.0</a>
<br>
Across the Campus we can reach every corner in the world.
]]
)
status = ua3f:section(NamedSection, "enabled", "ua3f", translate("Status"))
general = ua3f:section(NamedSection, "main", "ua3f", translate("General"))
status:option(Flag, "enabled", translate("Enabled"))
running = status:option(DummyValue, "running", translate("Status"))
running.rawhtml = true
running.cfgvalue = function(self, section)
local pid = luci.sys.exec("pidof ua3f")
if pid == "" then
return "<input disabled type='button' style='opacity: 1;' class='btn cbi-button cbi-button-reset' value='" ..
translate("Stop") .. "'/>"
else
return "<input disabled type='button' style='opacity: 1;' class='btn cbi-button cbi-button-add' value='" ..
translate("Running") .. "'/>"
end
end
general:tab("general", translate("Settings"))
general:tab("stats", translate("Statistics"))
general:tab("log", translate("Log"))
general:tab("others", translate("Others Settings"))
server_mode = general:taboption("general", ListValue, "server_mode", translate("Server Mode"))
server_mode:value("HTTP", "HTTP")
server_mode:value("SOCKS5", "SOCKS5")
server_mode:value("TPROXY", "TPROXY")
server_mode:value("REDIRECT", "REDIRECT")
server_mode:value("NFQUEUE", "NFQUEUE")
port = general:taboption("general", Value, "port", translate("Port"))
port.placeholder = "1080"
bind = general:taboption("general", Value, "bind", translate("Bind Address"))
bind:value("127.0.0.1")
bind:value("0.0.0.0")
log_level = general:taboption("general", ListValue, "log_level", translate("Log Level"))
log_level:value("debug")
log_level:value("info")
log_level:value("warn")
log_level:value("error")
log_level:value("fatal")
log_level:value("panic")
log_level.description = translate(
"Sets the logging level. Do not keep the log level set to debug/info/warn for an extended period of time.")
ua = general:taboption("general", Value, "ua", translate("User-Agent"))
ua.placeholder = "FFF"
ua.description = translate("User-Agent after rewrite")
uaRegexPattern = general:taboption("general", Value, "ua_regex", translate("User-Agent Regex Pattern"))
uaRegexPattern.description = translate("Regular expression pattern for matching User-Agent")
partialRepalce = general:taboption("general", Flag, "partial_replace", translate("Partial Replace"))
partialRepalce.description =
translate("Replace only the matched part of the User-Agent, only works when User-Agent Regex Pattern is not empty")
partialRepalce.default = "0"
directForward = general:taboption("general", Flag, "direct_forward", translate("Direct Forward"))
directForward.description =
translate("Directly forward packets without rewriting")
directForward.default = "0"
log = general:taboption("log", TextValue, "log")
log.readonly = true
log.rows = 30
function log.cfgvalue(self, section)
local logfile = "/var/log/ua3f/ua3f.log"
local fs = require("nixio.fs")
if not fs.access(logfile) then
return ""
end
local n = tonumber(luci.model.uci.cursor():get("ua3f", section, "log_lines")) or 1000
return luci.sys.exec("tail -n " .. n .. " " .. logfile)
end
function log.write(self, section, value) end
function log.render(self, section, scope)
TextValue.render(self, section, scope)
luci.http.write("<script>")
luci.http.write([[
var textarea = document.getElementById('cbid.ua3f.main.log');
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
]])
luci.http.write("</script>")
end
logLines = general:taboption("log", Value, "log_lines", translate("Display Lines"))
logLines.default = "1000"
logLines.datatype = "uinteger"
logLines.rmempty = false
clearlog = general:taboption("log", Button, "_clearlog", translate("Clear Logs"))
clearlog.inputtitle = translate("Clear Logs")
clearlog.inputstyle = "reset"
function clearlog.write(self, section)
end
function clearlog.render(self, section, scope)
Button.render(self, section, scope)
luci.http.write([[
<script>
document.querySelector("input[name='cbid.ua3f.main._clearlog']").addEventListener("click", function(e) {
e.preventDefault();
fetch(']] .. luci.dispatcher.build_url("admin/services/ua3f/clear_log") .. [[', {method: 'POST'})
.then(resp => {
if (resp.ok) {
var textarea = document.getElementById('cbid.ua3f.main.log');
if (textarea) textarea.value = "";
}
});
});
</script>
]])
end
download = general:taboption("log", Button, "_download", translate("Download Logs"))
download.inputtitle = translate("Download Logs")
download.inputstyle = "apply"
function download.write(self, section)
luci.http.redirect(luci.dispatcher.build_url("admin/services/ua3f/download_log"))
end
stats = general:taboption("stats", DummyValue, "")
stats.template = "ua3f/statistics"
ttl = general:taboption("others", Flag, "set_ttl", translate("Set TTL"))
ttl.description = translate("Set the TTL 64 for packets")
return ua3f

View File

@ -6,27 +6,30 @@ function index()
entry({ "admin", "services", "ua3f", "clear_log" }, call("clear_log")).leaf = true
end
function action_download_log()
local nixio = require "nixio"
local fs = require "nixio.fs"
local http = require "luci.http"
local tmpfile = "/tmp/ua3f_logs.tar.gz"
local fs = require("nixio.fs")
function create_log_archive()
local tmpfile = "/tmp/ua3f_logs.tar.gz"
local cmd = "cd /var/log && tar -czf " .. tmpfile .. " ua3f >/dev/null 2>&1"
os.execute(cmd)
return tmpfile
end
if not fs.access(tmpfile) then
function send_file_download(filepath, filename)
local http = require("luci.http")
if not fs.access(filepath) then
http.status(500, "Internal Server Error")
http.prepare_content("text/plain")
http.write("Failed to create archive")
return
return false
end
http.header("Content-Type", "application/gzip")
http.header("Content-Disposition", 'attachment; filename="ua3f_logs.tar.gz"')
http.header("Content-Length", tostring(fs.stat(tmpfile).size))
http.header("Content-Disposition", 'attachment; filename="' .. filename .. '"')
http.header("Content-Length", tostring(fs.stat(filepath).size))
local fp = io.open(tmpfile, "rb")
local fp = io.open(filepath, "rb")
if fp then
while true do
local chunk = fp:read(2048)
@ -36,15 +39,35 @@ function action_download_log()
fp:close()
end
nixio.fs.remove(tmpfile)
return true
end
function clear_log_file(logfile)
if fs.access(logfile) then
fs.writefile(logfile, "")
return true
end
return false
end
function action_download_log()
local tmpfile = create_log_archive()
local success = send_file_download(tmpfile, "ua3f_logs.tar.gz")
if success then
fs.remove(tmpfile)
end
end
function clear_log()
local logfile = "/var/log/ua3f/ua3f.log"
local fs = require "nixio.fs"
if fs.access(logfile) then
fs.writefile(logfile, "")
local success = clear_log_file(logfile)
local http = luci.http
if success then
http.status(200, "OK")
http.write("Log cleared")
else
http.status(404, "Not Found")
http.write("Log file not found")
end
luci.http.status(200, "OK")
luci.http.write("Log cleared")
end

View File

@ -0,0 +1,40 @@
local cbi = require("luci.cbi")
local i18n = require("luci.i18n")
local translate = i18n.translate
local NamedSection = cbi.NamedSection
local ua3f = cbi.Map("ua3f",
"UA3F",
[[
<a href="https://github.com/SunBK201/UA3F" target="_blank">Version: 1.5.0</a>
<br>
Across the Campus we can reach every corner in the world.
]]
)
local fields = require("luci.model.cbi.ua3f.fields")
function create_sections(map)
local sections = {}
-- Status Section
sections.status = map:section(NamedSection, "enabled", "ua3f", translate("Status"))
-- General Section with tabs
sections.general = map:section(NamedSection, "main", "ua3f", translate("General"))
sections.general:tab("general", translate("Settings"))
sections.general:tab("stats", translate("Statistics"))
sections.general:tab("log", translate("Log"))
sections.general:tab("others", translate("Others Settings"))
return sections
end
local sections = create_sections(ua3f)
fields.add_status_fields(sections.status)
fields.add_general_fields(sections.general)
fields.add_stats_fields(sections.general)
fields.add_log_fields(sections.general)
fields.add_others_fields(sections.general)
return ua3f

View File

@ -0,0 +1,173 @@
local M = {}
local cbi = require("luci.cbi")
local i18n = require("luci.i18n")
local sys = require("luci.sys")
local translate = i18n.translate
local Flag = cbi.Flag
local Value = cbi.Value
local ListValue = cbi.ListValue
local DummyValue = cbi.DummyValue
local TextValue = cbi.TextValue
local Button = cbi.Button
-- Status Section Fields
function M.add_status_fields(section)
-- Enabled Flag
section:option(Flag, "enabled", translate("Enabled"))
-- Running Status Display
local running = section:option(DummyValue, "running", translate("Status"))
running.rawhtml = true
running.cfgvalue = function(self, section)
local pid = sys.exec("pidof ua3f")
if pid == "" then
return "<input disabled type='button' style='opacity: 1;' class='btn cbi-button cbi-button-reset' value='" ..
translate("Stop") .. "'/>"
else
return "<input disabled type='button' style='opacity: 1;' class='btn cbi-button cbi-button-add' value='" ..
translate("Running") .. "'/>"
end
end
end
-- General Tab Fields
function M.add_general_fields(section)
-- Server Mode
local server_mode = section:taboption("general", ListValue, "server_mode", translate("Server Mode"))
server_mode:value("HTTP", "HTTP")
server_mode:value("SOCKS5", "SOCKS5")
server_mode:value("TPROXY", "TPROXY")
server_mode:value("REDIRECT", "REDIRECT")
server_mode:value("NFQUEUE", "NFQUEUE")
-- Port
local port = section:taboption("general", Value, "port", translate("Port"))
port.placeholder = "1080"
-- Bind Address
local bind = section:taboption("general", Value, "bind", translate("Bind Address"))
bind:value("127.0.0.1")
bind:value("0.0.0.0")
-- Log Level
local log_level = section:taboption("general", ListValue, "log_level", translate("Log Level"))
log_level:value("debug")
log_level:value("info")
log_level:value("warn")
log_level:value("error")
log_level:value("fatal")
log_level:value("panic")
log_level.description = translate(
"Sets the logging level. Do not keep the log level set to debug/info/warn for an extended period of time.")
-- User-Agent
local ua = section:taboption("general", Value, "ua", translate("User-Agent"))
ua.placeholder = "FFF"
ua.description = translate("User-Agent after rewrite")
-- User-Agent Regex
local uaRegexPattern = section:taboption("general", Value, "ua_regex", translate("User-Agent Regex"))
uaRegexPattern.description = translate("Regular expression pattern for matching User-Agent")
-- Partial Replace
local partialReplace = section:taboption("general", Flag, "partial_replace", translate("Partial Replace"))
partialReplace.description =
translate(
"Replace only the matched part of the User-Agent, only works when User-Agent Regex is not empty")
partialReplace.default = "0"
-- Direct Forward
local directForward = section:taboption("general", Flag, "direct_forward", translate("Direct Forward"))
directForward.description =
translate("Directly forward packets without rewriting")
directForward.default = "0"
end
-- Statistics Tab Fields
function M.add_stats_fields(section)
local stats = section:taboption("stats", DummyValue, "")
stats.template = "ua3f/statistics"
end
-- Log Tab Fields
function M.add_log_fields(section)
-- Log Display
local log = section:taboption("log", TextValue, "log")
log.readonly = true
log.rows = 30
function log.cfgvalue(self, section)
local logfile = "/var/log/ua3f/ua3f.log"
local fs = require("nixio.fs")
if not fs.access(logfile) then
return ""
end
local n = tonumber(luci.model.uci.cursor():get("ua3f", section, "log_lines")) or 1000
return luci.sys.exec("tail -n " .. n .. " " .. logfile)
end
function log.write(self, section, value) end
function log.render(self, section, scope)
TextValue.render(self, section, scope)
luci.http.write("<script>")
luci.http.write([[
var textarea = document.getElementById('cbid.ua3f.main.log');
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
]])
luci.http.write("</script>")
end
-- Log Lines
local logLines = section:taboption("log", Value, "log_lines", translate("Display Lines"))
logLines.default = "1000"
logLines.datatype = "uinteger"
logLines.rmempty = false
-- Clear Log Button
local clearlog = section:taboption("log", Button, "_clearlog", translate("Clear Logs"))
clearlog.inputtitle = translate("Clear Logs")
clearlog.inputstyle = "reset"
function clearlog.write(self, section)
end
function clearlog.render(self, section, scope)
Button.render(self, section, scope)
luci.http.write([[
<script>
document.querySelector("input[name='cbid.ua3f.main._clearlog']").addEventListener("click", function(e) {
e.preventDefault();
fetch(']] .. luci.dispatcher.build_url("admin/services/ua3f/clear_log") .. [[', {method: 'POST'})
.then(resp => {
if (resp.ok) {
var textarea = document.getElementById('cbid.ua3f.main.log');
if (textarea) textarea.value = "";
}
});
});
</script>
]])
end
-- Download Log Button
local download = section:taboption("log", Button, "_download", translate("Download Logs"))
download.inputtitle = translate("Download Logs")
download.inputstyle = "apply"
function download.write(self, section)
luci.http.redirect(luci.dispatcher.build_url("admin/services/ua3f/download_log"))
end
return log
end
-- Others Tab Fields
function M.add_others_fields(section)
-- TTL Setting
local ttl = section:taboption("others", Flag, "set_ttl", translate("Set TTL"))
ttl.description = translate("Set the TTL 64 for packets")
end
return M

View File

@ -0,0 +1,72 @@
-- UA3F Statistics Data Module
local M = {}
-- Read rewrite statistics from file
function M.read_rewrite_stats()
local stats = {}
local file = io.open("/var/log/ua3f/rewrite_stats", "r")
if file then
for line in file:lines() do
local host, count, origin_ua, mocked_ua = line:match("^(%S+)%s+(%d+)%s+(.-)SEQSEQ(.-)%s*$")
if host and count then
table.insert(stats, {
host = host,
count = count,
origin_ua = origin_ua,
mocked_ua = mocked_ua
})
end
end
file:close()
end
return stats
end
-- Read pass-through statistics from file
function M.read_pass_stats()
local stats = {}
local file = io.open("/var/log/ua3f/pass_stats", "r")
if file then
for line in file:lines() do
local srcAddr, destAddr, count, ua = line:match("^(%S+)%s(%S+)%s(%d+)%s(.+)$")
if ua and count then
table.insert(stats, {
ua = ua,
count = count,
srcAddr = srcAddr,
destAddr = destAddr
})
end
end
file:close()
end
return stats
end
-- Read connection statistics from file
function M.read_conn_stats()
local stats = {}
local file = io.open("/var/log/ua3f/conn_stats", "r")
if file then
for line in file:lines() do
local protocol, srcAddr, destAddr, duration = line:match("^(%S+)%s(%S+)%s(%S+)%s(.+)$")
if protocol and srcAddr and destAddr and duration then
table.insert(stats, {
protocol = protocol,
srcAddr = srcAddr,
destAddr = destAddr,
duration = duration
})
end
end
file:close()
end
return stats
end
-- Generate row style class
function M.rowstyle(i)
return (i % 2 == 0) and "cbi-rowstyle-2" or "cbi-rowstyle-1"
end
return M

View File

@ -1,427 +0,0 @@
<%
local rewrite_stats = {}
local rewrite_stats_file = io.open("/var/log/ua3f/rewrite_stats", "r")
if rewrite_stats_file then
for line in rewrite_stats_file:lines() do
local host, count, origin_ua, mocked_ua = line:match("^(%S+)%s+(%d+)%s+(.-)SEQSEQ(.-)%s*$")
if host and count then
table.insert(rewrite_stats, {host = host, count = count, origin_ua = origin_ua, mocked_ua = mocked_ua})
end
end
rewrite_stats_file:close()
end
local pass_stats = {}
local pass_stats_file = io.open("/var/log/ua3f/pass_stats", "r")
if pass_stats_file then
for line in pass_stats_file:lines() do
local srcAddr, destAddr, count, ua = line:match("^(%S+)%s(%S+)%s(%d+)%s(.+)$")
if ua and count then
table.insert(pass_stats, {ua = ua, count = count, srcAddr = srcAddr, destAddr = destAddr})
end
end
pass_stats_file:close()
end
local conn_stats = {}
local conn_stats_file = io.open("/var/log/ua3f/conn_stats", "r")
if conn_stats_file then
for line in conn_stats_file:lines() do
local protocol, srcAddr, destAddr, duration = line:match("^(%S+)%s(%S+)%s(%S+)%s(.+)$")
if protocol and srcAddr and destAddr and duration then
table.insert(conn_stats, {protocol = protocol, srcAddr = srcAddr, destAddr = destAddr, duration = duration})
end
end
conn_stats_file:close()
end
local function rowstyle(i)
return (i % 2 == 0) and "cbi-rowstyle-2" or "cbi-rowstyle-1"
end
%>
<h3><%:Statistics%></h3>
<div class="cbi-section-descr" style="font-weight:bold;"><%:User-Agent Rewrite Statistics%></div>
<table id="rewrite-stats-table" class="table cbi-section-table">
<tr class="tr table-titles">
<th class="th" data-sortable-row="true"><%:Host%></th>
<th class="th" data-sortable-row="true"><%:Rewrite Count%></th>
<th class="th" data-sortable-row="true"><%:Original User-Agent%></th>
<th class="th" data-sortable-row="true"><%:Modified User-Agent%></th>
</tr>
<% for i, item in ipairs(rewrite_stats) do %>
<tr class="tr <%= rowstyle(i) %>">
<td class="td" data-title="<%:Host%>"><span><%= item.host %></span></td>
<td class="td" data-title="<%:Rewrite Count%>"><%= item.count %></td>
<td class="td" data-title="<%:Original User-Agent%>"><span><%= item.origin_ua %></span></td>
<td class="td" data-title="<%:Modified User-Agent%>"><span><%= item.mocked_ua %></span></td>
</tr>
<% end %>
</table>
<div class="cbi-section-descr" style="font-weight:bold;"><%:User-Agent Pass-Through Statistics%></div>
<table id="pass-stats-table" class="table cbi-section-table">
<tr class="tr table-titles">
<th class="th" data-sortable-row="true"><%:User-Agent%></th>
<th class="th" data-sortable-row="true"><%:Pass-Through Count%></th>
<th class="th" data-sortable-row="true"><%:Last Source Address%></th>
<th class="th" data-sortable-row="true"><%:Last Destination Address%></th>
</tr>
<% for i, item in ipairs(pass_stats) do %>
<tr class="tr <%= rowstyle(i) %>">
<td class="td" data-title="<%:User-Agent%>"><span><%= item.ua %></span></td>
<td class="td" data-title="<%:Pass-Through Count%>"><%= item.count %></td>
<td class="td" data-title="<%:Last Source Address%>"><span><%= item.srcAddr %></span></td>
<td class="td" data-title="<%:Last Destination Address%>"><span><%= item.destAddr %></span></td>
</tr>
<% end %>
</table>
<div class="cbi-section-descr" style="font-weight:bold;"><%:Connection Statistics%></div>
<table id="conn-stats-table" class="table cbi-section-table">
<tr>
<td colspan="4" style="padding:5px 0;">
<%:Total Connections%>: <%= #conn_stats %>
</td>
</tr>
<tr class="tr table-titles">
<th class="th" data-sortable-row="true"><%:Protocol%></th>
<th class="th" data-sortable-row="true"><%:Source Address%></th>
<th class="th" data-sortable-row="true"><%:Destination Address%></th>
<th class="th" data-sortable-row="true"><%:Duration%></th>
</tr>
<% for i, item in ipairs(conn_stats) do %>
<tr class="tr <%= rowstyle(i) %>">
<td class="td" data-title="<%:Protocol%>"><span><%= item.protocol %></span></td>
<td class="td" data-title="<%:Source Address%>"><span><%= item.srcAddr %></span></td>
<td class="td" data-title="<%:Destination Address%>"><span><%= item.destAddr %></span></td>
<td class="td" data-title="<%:Duration%>" data-seconds="<%= item.duration %>"><span class="duration-text"><%= item.duration %></span></td>
</tr>
<% end %>
</table>
<script type="text/javascript">
function formatDuration(seconds) {
seconds = parseInt(seconds);
if (isNaN(seconds) || seconds < 0) return '0s';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const parts = [];
if (days > 0) parts.push(days + 'd');
if (hours > 0) parts.push(hours + 'h');
if (minutes > 0) parts.push(minutes + 'm');
if (secs > 0 || parts.length === 0) parts.push(secs + 's');
return parts.join(' ');
}
function formatAllDurations() {
const durationCells = document.querySelectorAll('#conn-stats-table td[data-seconds]');
durationCells.forEach(cell => {
const seconds = cell.getAttribute('data-seconds');
const textSpan = cell.querySelector('.duration-text');
if (textSpan && seconds) {
textSpan.textContent = formatDuration(seconds);
}
});
}
function sortTable(tableId, columnIndex, isNumeric) {
const table = document.getElementById(tableId);
const tbody = table.querySelector('tbody') || table;
const rows = Array.from(tbody.querySelectorAll('tr.tr')).filter(row => !row.classList.contains('table-titles'));
const sortKey = `${tableId}-sort`;
let sortState = sessionStorage.getItem(sortKey);
let currentSort = sortState ? JSON.parse(sortState) : {column: -1, order: 'asc'};
let order = 'asc';
if (currentSort.column === columnIndex) {
order = currentSort.order === 'asc' ? 'desc' : 'asc';
}
rows.sort((a, b) => {
let aVal, bVal;
if (tableId === 'conn-stats-table' && columnIndex === 3) {
aVal = parseFloat(a.children[columnIndex].getAttribute('data-seconds')) || 0;
bVal = parseFloat(b.children[columnIndex].getAttribute('data-seconds')) || 0;
} else {
aVal = a.children[columnIndex].textContent.trim();
bVal = b.children[columnIndex].textContent.trim();
if (isNumeric) {
aVal = parseFloat(aVal) || 0;
bVal = parseFloat(bVal) || 0;
}
}
if (aVal < bVal) return order === 'asc' ? -1 : 1;
if (aVal > bVal) return order === 'asc' ? 1 : -1;
return 0;
});
rows.forEach((row, index) => {
row.className = row.className.replace(/cbi-rowstyle-[12]/, (index % 2 === 0) ? 'cbi-rowstyle-2' : 'cbi-rowstyle-1');
tbody.appendChild(row);
});
sessionStorage.setItem(sortKey, JSON.stringify({column: columnIndex, order: order}));
updateSortIndicators(tableId, columnIndex, order);
}
function updateSortIndicators(tableId, activeColumn, order) {
const table = document.getElementById(tableId);
const headers = table.querySelectorAll('th[data-sortable-row="true"]');
headers.forEach((th, index) => {
th.style.position = 'relative';
th.style.cursor = 'pointer';
let indicator = th.querySelector('.sort-indicator');
if (!indicator) {
indicator = document.createElement('span');
indicator.className = 'sort-indicator';
indicator.style.fontSize = '0.8em';
indicator.style.marginLeft = '4px';
indicator.style.display = 'inline-block';
indicator.style.width = '12px';
indicator.style.textAlign = 'center';
th.appendChild(indicator);
}
if (index === activeColumn) {
indicator.textContent = order === 'asc' ? '▲' : '▼';
indicator.style.visibility = 'visible';
} else {
indicator.textContent = '';
indicator.style.visibility = 'hidden';
}
});
}
function restoreTableSort(tableId, numericColumns) {
const sortKey = `${tableId}-sort`;
const sortState = sessionStorage.getItem(sortKey);
if (sortState) {
const {column, order} = JSON.parse(sortState);
const table = document.getElementById(tableId);
const tbody = table.querySelector('tbody') || table;
const rows = Array.from(tbody.querySelectorAll('tr.tr')).filter(row => !row.classList.contains('table-titles'));
if (rows.length > 0) {
const isNumeric = numericColumns.includes(column);
rows.sort((a, b) => {
let aVal, bVal;
if (tableId === 'conn-stats-table' && column === 3) {
aVal = parseFloat(a.children[column].getAttribute('data-seconds')) || 0;
bVal = parseFloat(b.children[column].getAttribute('data-seconds')) || 0;
} else {
aVal = a.children[column].textContent.trim();
bVal = b.children[column].textContent.trim();
if (isNumeric) {
aVal = parseFloat(aVal) || 0;
bVal = parseFloat(bVal) || 0;
}
}
if (aVal < bVal) return order === 'asc' ? -1 : 1;
if (aVal > bVal) return order === 'asc' ? 1 : -1;
return 0;
});
rows.forEach((row, index) => {
row.className = row.className.replace(/cbi-rowstyle-[12]/, (index % 2 === 0) ? 'cbi-rowstyle-2' : 'cbi-rowstyle-1');
tbody.appendChild(row);
});
}
updateSortIndicators(tableId, column, order);
}
}
function bindTableEvents(tableId, numericColumns) {
const table = document.getElementById(tableId);
if (!table) return;
const headers = table.querySelectorAll('th[data-sortable-row="true"]');
headers.forEach((th, index) => {
th.style.cursor = 'pointer';
th.style.userSelect = 'none';
const newTh = th.cloneNode(true);
th.parentNode.replaceChild(newTh, th);
if (!newTh.querySelector('.sort-indicator')) {
const indicator = document.createElement('span');
indicator.className = 'sort-indicator';
indicator.style.fontSize = '0.8em';
indicator.style.marginLeft = '4px';
indicator.style.display = 'inline-block';
indicator.style.width = '12px';
indicator.style.textAlign = 'center';
indicator.style.visibility = 'hidden';
indicator.textContent = '';
newTh.appendChild(indicator);
}
newTh.addEventListener('click', () => {
const isNumeric = numericColumns.includes(index);
sortTable(tableId, index, isNumeric);
});
});
}
function initTableSort() {
const tables = [
{id: 'rewrite-stats-table', numericColumns: [1]},
{id: 'pass-stats-table', numericColumns: [1]},
{id: 'conn-stats-table', numericColumns: [3]}
];
formatAllDurations();
tables.forEach(tableInfo => {
bindTableEvents(tableInfo.id, tableInfo.numericColumns);
restoreTableSort(tableInfo.id, tableInfo.numericColumns);
});
}
async function updateStats() {
try {
const response = await fetch(window.location.href, {cache: "no-store"});
const text = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, "text/html");
const newRewriteTable = doc.querySelector("#rewrite-stats-table");
const newPassTable = doc.querySelector("#pass-stats-table");
const newConnTable = doc.querySelector("#conn-stats-table");
const newLog = doc.querySelector("#cbid\\.ua3f\\.main\\.log");
if (newRewriteTable) {
document.querySelector("#rewrite-stats-table").innerHTML = newRewriteTable.innerHTML;
bindTableEvents('rewrite-stats-table', [1]);
restoreTableSort('rewrite-stats-table', [1]);
}
if (newPassTable) {
document.querySelector("#pass-stats-table").innerHTML = newPassTable.innerHTML;
bindTableEvents('pass-stats-table', [1]);
restoreTableSort('pass-stats-table', [1]);
}
if (newConnTable) {
document.querySelector("#conn-stats-table").innerHTML = newConnTable.innerHTML;
formatAllDurations();
bindTableEvents('conn-stats-table', [3]);
restoreTableSort('conn-stats-table', [3]);
}
if (newLog) {
document.querySelector("#cbid\\.ua3f\\.main\\.log").value = newLog.value;
}
} catch (err) {
console.error("update stats error:", err);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTableSort);
} else {
initTableSort();
}
setInterval(updateStats, 5000);
</script>
<style>
th[data-sortable-row="true"] {
cursor: pointer;
user-select: none;
position: relative;
}
th[data-sortable-row="true"]:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.sort-indicator {
color: #0066cc;
font-weight: bold;
}
#rewrite-stats-table th:nth-child(1),
#rewrite-stats-table td:nth-child(1) {
width: 20%;
}
#rewrite-stats-table th:nth-child(2),
#rewrite-stats-table td:nth-child(2) {
width: 10%;
text-align: center;
}
#rewrite-stats-table th:nth-child(3),
#rewrite-stats-table td:nth-child(3) {
width: 35%;
}
#rewrite-stats-table th:nth-child(4),
#rewrite-stats-table td:nth-child(4) {
width: 35%;
}
#pass-stats-table th:nth-child(1),
#pass-stats-table td:nth-child(1) {
width: 30%;
}
#pass-stats-table th:nth-child(2),
#pass-stats-table td:nth-child(2) {
width: 10%;
text-align: center;
}
#pass-stats-table th:nth-child(3),
#pass-stats-table td:nth-child(3) {
width: 30%;
}
#pass-stats-table th:nth-child(4),
#pass-stats-table td:nth-child(4) {
width: 30%;
}
#conn-stats-table th:nth-child(1),
#conn-stats-table td:nth-child(1) {
width: 15%;
}
#conn-stats-table th:nth-child(2),
#conn-stats-table td:nth-child(2) {
width: 30%;
}
#conn-stats-table th:nth-child(3),
#conn-stats-table td:nth-child(3) {
width: 40%;
}
#conn-stats-table th:nth-child(4),
#conn-stats-table td:nth-child(4) {
width: 15%;
text-align: center;
}
</style>

View File

@ -0,0 +1,410 @@
<%
local data = require("luci.model.cbi.ua3f.statistics")
local rewrite_stats = data.read_rewrite_stats()
local pass_stats = data.read_pass_stats()
local conn_stats = data.read_conn_stats()
local rowstyle = data.rowstyle
%>
<h3><%:Statistics%></h3>
<!-- Rewrite Statistics Table -->
<div class="cbi-section-descr" style="font-weight:bold;"><%:User-Agent Rewrite Statistics%></div>
<table id="rewrite-stats-table" class="table cbi-section-table">
<tr class="tr table-titles">
<th class="th" data-sortable-row="true"><%:Host%></th>
<th class="th" data-sortable-row="true"><%:Rewrite Count%></th>
<th class="th" data-sortable-row="true"><%:Original User-Agent%></th>
<th class="th" data-sortable-row="true"><%:Modified User-Agent%></th>
</tr>
<% for i, item in ipairs(rewrite_stats) do %>
<tr class="tr <%= rowstyle(i) %>">
<td class="td" data-title="<%:Host%>"><span><%= item.host %></span></td>
<td class="td" data-title="<%:Rewrite Count%>"><%= item.count %></td>
<td class="td" data-title="<%:Original User-Agent%>"><span><%= item.origin_ua %></span></td>
<td class="td" data-title="<%:Modified User-Agent%>"><span><%= item.mocked_ua %></span></td>
</tr>
<% end %>
</table>
<!-- Pass-Through Statistics Table -->
<div class="cbi-section-descr" style="font-weight:bold;"><%:User-Agent Pass-Through Statistics%></div>
<table id="pass-stats-table" class="table cbi-section-table">
<tr class="tr table-titles">
<th class="th" data-sortable-row="true"><%:User-Agent%></th>
<th class="th" data-sortable-row="true"><%:Pass-Through Count%></th>
<th class="th" data-sortable-row="true"><%:Last Source Address%></th>
<th class="th" data-sortable-row="true"><%:Last Destination Address%></th>
</tr>
<% for i, item in ipairs(pass_stats) do %>
<tr class="tr <%= rowstyle(i) %>">
<td class="td" data-title="<%:User-Agent%>"><span><%= item.ua %></span></td>
<td class="td" data-title="<%:Pass-Through Count%>"><%= item.count %></td>
<td class="td" data-title="<%:Last Source Address%>"><span><%= item.srcAddr %></span></td>
<td class="td" data-title="<%:Last Destination Address%>"><span><%= item.destAddr %></span></td>
</tr>
<% end %>
</table>
<!-- Connection Statistics Table -->
<div class="cbi-section-descr" style="font-weight:bold;"><%:Connection Statistics%></div>
<table id="conn-stats-table" class="table cbi-section-table">
<tr>
<td colspan="4" style="padding:5px 0;">
<%:Total Connections%>: <%= #conn_stats %>
</td>
</tr>
<tr class="tr table-titles">
<th class="th" data-sortable-row="true"><%:Protocol%></th>
<th class="th" data-sortable-row="true"><%:Source Address%></th>
<th class="th" data-sortable-row="true"><%:Destination Address%></th>
<th class="th" data-sortable-row="true"><%:Duration%></th>
</tr>
<% for i, item in ipairs(conn_stats) do %>
<tr class="tr <%= rowstyle(i) %>">
<td class="td" data-title="<%:Protocol%>"><span><%= item.protocol %></span></td>
<td class="td" data-title="<%:Source Address%>"><span><%= item.srcAddr %></span></td>
<td class="td" data-title="<%:Destination Address%>"><span><%= item.destAddr %></span></td>
<td class="td" data-title="<%:Duration%>" data-seconds="<%= item.duration %>"><span class="duration-text"><%= item.duration %></span></td>
</tr>
<% end %>
</table>
<script type="text/javascript">
// Format duration in seconds to human-readable format
function formatDuration(seconds) {
seconds = parseInt(seconds);
if (isNaN(seconds) || seconds < 0) return '0s';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
const parts = [];
if (days > 0) parts.push(days + 'd');
if (hours > 0) parts.push(hours + 'h');
if (minutes > 0) parts.push(minutes + 'm');
if (secs > 0 || parts.length === 0) parts.push(secs + 's');
return parts.join(' ');
}
// Format all duration cells in the connection statistics table
function formatAllDurations() {
const durationCells = document.querySelectorAll('#conn-stats-table td[data-seconds]');
durationCells.forEach(cell => {
const seconds = cell.getAttribute('data-seconds');
const textSpan = cell.querySelector('.duration-text');
if (textSpan && seconds) {
textSpan.textContent = formatDuration(seconds);
}
});
}
// Sort table by column
function sortTable(tableId, columnIndex, isNumeric) {
const table = document.getElementById(tableId);
const tbody = table.querySelector('tbody') || table;
const rows = Array.from(tbody.querySelectorAll('tr.tr')).filter(row => !row.classList.contains('table-titles'));
const sortKey = `${tableId}-sort`;
let sortState = sessionStorage.getItem(sortKey);
let currentSort = sortState ? JSON.parse(sortState) : {column: -1, order: 'asc'};
let order = 'asc';
if (currentSort.column === columnIndex) {
order = currentSort.order === 'asc' ? 'desc' : 'asc';
}
rows.sort((a, b) => {
let aVal, bVal;
if (tableId === 'conn-stats-table' && columnIndex === 3) {
aVal = parseFloat(a.children[columnIndex].getAttribute('data-seconds')) || 0;
bVal = parseFloat(b.children[columnIndex].getAttribute('data-seconds')) || 0;
} else {
aVal = a.children[columnIndex].textContent.trim();
bVal = b.children[columnIndex].textContent.trim();
if (isNumeric) {
aVal = parseFloat(aVal) || 0;
bVal = parseFloat(bVal) || 0;
}
}
if (aVal < bVal) return order === 'asc' ? -1 : 1;
if (aVal > bVal) return order === 'asc' ? 1 : -1;
return 0;
});
rows.forEach((row, index) => {
row.className = row.className.replace(/cbi-rowstyle-[12]/, (index % 2 === 0) ? 'cbi-rowstyle-2' : 'cbi-rowstyle-1');
tbody.appendChild(row);
});
sessionStorage.setItem(sortKey, JSON.stringify({column: columnIndex, order: order}));
updateSortIndicators(tableId, columnIndex, order);
}
// Update sort indicators on table headers
function updateSortIndicators(tableId, activeColumn, order) {
const table = document.getElementById(tableId);
const headers = table.querySelectorAll('th[data-sortable-row="true"]');
headers.forEach((th, index) => {
th.style.position = 'relative';
th.style.cursor = 'pointer';
let indicator = th.querySelector('.sort-indicator');
if (!indicator) {
indicator = document.createElement('span');
indicator.className = 'sort-indicator';
indicator.style.fontSize = '0.8em';
indicator.style.marginLeft = '4px';
indicator.style.display = 'inline-block';
indicator.style.width = '12px';
indicator.style.textAlign = 'center';
th.appendChild(indicator);
}
if (index === activeColumn) {
indicator.textContent = order === 'asc' ? '▲' : '▼';
indicator.style.visibility = 'visible';
} else {
indicator.textContent = '';
indicator.style.visibility = 'hidden';
}
});
}
// Restore table sort from session storage
function restoreTableSort(tableId, numericColumns) {
const sortKey = `${tableId}-sort`;
const sortState = sessionStorage.getItem(sortKey);
if (sortState) {
const {column, order} = JSON.parse(sortState);
const table = document.getElementById(tableId);
const tbody = table.querySelector('tbody') || table;
const rows = Array.from(tbody.querySelectorAll('tr.tr')).filter(row => !row.classList.contains('table-titles'));
if (rows.length > 0) {
const isNumeric = numericColumns.includes(column);
rows.sort((a, b) => {
let aVal, bVal;
if (tableId === 'conn-stats-table' && column === 3) {
aVal = parseFloat(a.children[column].getAttribute('data-seconds')) || 0;
bVal = parseFloat(b.children[column].getAttribute('data-seconds')) || 0;
} else {
aVal = a.children[column].textContent.trim();
bVal = b.children[column].textContent.trim();
if (isNumeric) {
aVal = parseFloat(aVal) || 0;
bVal = parseFloat(bVal) || 0;
}
}
if (aVal < bVal) return order === 'asc' ? -1 : 1;
if (aVal > bVal) return order === 'asc' ? 1 : -1;
return 0;
});
rows.forEach((row, index) => {
row.className = row.className.replace(/cbi-rowstyle-[12]/, (index % 2 === 0) ? 'cbi-rowstyle-2' : 'cbi-rowstyle-1');
tbody.appendChild(row);
});
}
updateSortIndicators(tableId, column, order);
}
}
// Bind click events to table headers
function bindTableEvents(tableId, numericColumns) {
const table = document.getElementById(tableId);
if (!table) return;
const headers = table.querySelectorAll('th[data-sortable-row="true"]');
headers.forEach((th, index) => {
th.style.cursor = 'pointer';
th.style.userSelect = 'none';
const newTh = th.cloneNode(true);
th.parentNode.replaceChild(newTh, th);
if (!newTh.querySelector('.sort-indicator')) {
const indicator = document.createElement('span');
indicator.className = 'sort-indicator';
indicator.style.fontSize = '0.8em';
indicator.style.marginLeft = '4px';
indicator.style.display = 'inline-block';
indicator.style.width = '12px';
indicator.style.textAlign = 'center';
indicator.style.visibility = 'hidden';
indicator.textContent = '';
newTh.appendChild(indicator);
}
newTh.addEventListener('click', () => {
const isNumeric = numericColumns.includes(index);
sortTable(tableId, index, isNumeric);
});
});
}
// Initialize table sorting functionality
function initTableSort() {
const tables = [
{id: 'rewrite-stats-table', numericColumns: [1]},
{id: 'pass-stats-table', numericColumns: [1]},
{id: 'conn-stats-table', numericColumns: [3]}
];
formatAllDurations();
tables.forEach(tableInfo => {
bindTableEvents(tableInfo.id, tableInfo.numericColumns);
restoreTableSort(tableInfo.id, tableInfo.numericColumns);
});
}
// Update statistics via AJAX
async function updateStats() {
try {
const response = await fetch(window.location.href, {cache: "no-store"});
const text = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, "text/html");
const newRewriteTable = doc.querySelector("#rewrite-stats-table");
const newPassTable = doc.querySelector("#pass-stats-table");
const newConnTable = doc.querySelector("#conn-stats-table");
const newLog = doc.querySelector("#cbid\\.ua3f\\.main\\.log");
if (newRewriteTable) {
document.querySelector("#rewrite-stats-table").innerHTML = newRewriteTable.innerHTML;
bindTableEvents('rewrite-stats-table', [1]);
restoreTableSort('rewrite-stats-table', [1]);
}
if (newPassTable) {
document.querySelector("#pass-stats-table").innerHTML = newPassTable.innerHTML;
bindTableEvents('pass-stats-table', [1]);
restoreTableSort('pass-stats-table', [1]);
}
if (newConnTable) {
document.querySelector("#conn-stats-table").innerHTML = newConnTable.innerHTML;
formatAllDurations();
bindTableEvents('conn-stats-table', [3]);
restoreTableSort('conn-stats-table', [3]);
}
if (newLog) {
document.querySelector("#cbid\\.ua3f\\.main\\.log").value = newLog.value;
}
} catch (err) {
console.error("update stats error:", err);
}
}
// Initialize on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTableSort);
} else {
initTableSort();
}
// Auto-refresh every 5 seconds
setInterval(updateStats, 5000);
</script>
<style>
/* Sortable table headers */
th[data-sortable-row="true"] {
cursor: pointer;
user-select: none;
position: relative;
}
th[data-sortable-row="true"]:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.sort-indicator {
color: #0066cc;
font-weight: bold;
}
/* Rewrite Statistics Table */
#rewrite-stats-table th:nth-child(1),
#rewrite-stats-table td:nth-child(1) {
width: 20%;
}
#rewrite-stats-table th:nth-child(2),
#rewrite-stats-table td:nth-child(2) {
width: 10%;
text-align: center;
}
#rewrite-stats-table th:nth-child(3),
#rewrite-stats-table td:nth-child(3) {
width: 35%;
}
#rewrite-stats-table th:nth-child(4),
#rewrite-stats-table td:nth-child(4) {
width: 35%;
}
/* Pass Statistics Table */
#pass-stats-table th:nth-child(1),
#pass-stats-table td:nth-child(1) {
width: 30%;
}
#pass-stats-table th:nth-child(2),
#pass-stats-table td:nth-child(2) {
width: 10%;
text-align: center;
}
#pass-stats-table th:nth-child(3),
#pass-stats-table td:nth-child(3) {
width: 30%;
}
#pass-stats-table th:nth-child(4),
#pass-stats-table td:nth-child(4) {
width: 30%;
}
/* Connection Statistics Table */
#conn-stats-table th:nth-child(1),
#conn-stats-table td:nth-child(1) {
width: 15%;
}
#conn-stats-table th:nth-child(2),
#conn-stats-table td:nth-child(2) {
width: 30%;
}
#conn-stats-table th:nth-child(3),
#conn-stats-table td:nth-child(3) {
width: 40%;
}
#conn-stats-table th:nth-child(4),
#conn-stats-table td:nth-child(4) {
width: 15%;
text-align: center;
}
</style>

View File

@ -44,7 +44,7 @@ msgstr "User-Agent"
msgid "User-Agent after rewrite"
msgstr "重写后的 User-Agent"
msgid "User-Agent Regex Pattern"
msgid "User-Agent Regex"
msgstr "User-Agent 正则表达式"
msgid "Regular expression pattern for matching User-Agent"
@ -53,7 +53,7 @@ msgstr "用于匹配 User-Agent只有匹配成功的 User-Agent 才会被修
msgid "Partial Replace"
msgstr "部分替换"
msgid "Replace only the matched part of the User-Agent, only works when User-Agent Regex Pattern is not empty"
msgid "Replace only the matched part of the User-Agent, only works when User-Agent Regex is not empty"
msgstr "仅替换 User-Agent 正则匹配的部分,仅在 User-Agent 正则表达式非空时有效"
msgid "Direct Forward"