update 2026-08-26 20:40:17

This commit is contained in:
action committed 2026-08-26 20:40:17 +08:00
1 parent d64e131165
commit 1bfa02b7c5
397 files changed
+52332 -16577

No files matched your search

@@ -1,573 +0,0 @@
module("luci.controller.appfilter", package.seeall)
local utl = require "luci.util"
function index()
if not nixio.fs.access("/etc/config/appfilter") then
return
end
local page
entry({"admin", "services", "appfilter"}, alias("admin", "services", "appfilter", "user_list"),_("App Filter"), 10).dependent = true
entry({"admin", "services", "appfilter", "user_list"},
arcombine(cbi("appfilter/user_list",{hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}),
cbi("appfilter/dev_status", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true})),
_("User List"), 20).leaf=true
entry({"admin", "services", "appfilter", "time"}, cbi("appfilter/time", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), _("Time Configuration"), 25).leaf=true
entry({"admin", "services", "appfilter", "app_filter"}, cbi("appfilter/app_filter", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), _("App Filter"), 21).leaf=true
entry({"admin", "services", "appfilter", "feature"}, cbi("appfilter/feature", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), _("App Feature Library"), 26).leaf=true
entry({"admin", "services", "appfilter", "user"}, cbi("appfilter/user", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), _("User Configuration"), 24).leaf=true
entry({"admin", "services", "appfilter", "advance"}, cbi("appfilter/advance", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), _("Advanced Settings"), 27).leaf=true
entry({"admin", "network", "user_status"}, call("user_status"), nil).leaf = true
entry({"admin", "network", "get_user_list"}, call("get_user_list"), nil).leaf = true
entry({"admin", "network", "dev_visit_list"}, call("get_dev_visit_list"), nil).leaf = true
entry({"admin", "network", "feature_upgrade"}, call("handle_feature_upgrade"), nil).leaf = true
entry({"admin", "network", "dev_visit_time"}, call("get_dev_visit_time"), nil).leaf = true
entry({"admin", "network", "app_class_visit_time"}, call("get_app_class_visit_time"), nil).leaf = true
entry({"admin", "network", "class_list"}, call("get_class_list"), nil).leaf = true
entry({"admin", "network", "set_app_filter"}, call("set_app_filter"), nil).leaf = true
entry({"admin", "network", "get_app_filter"}, call("get_app_filter"), nil).leaf = true
entry({"admin", "network", "get_app_filter_base"}, call("get_app_filter_base"), nil).leaf = true
entry({"admin", "network", "set_app_filter_base"}, call("set_app_filter_base"), nil).leaf = true
entry({"admin", "network", "set_app_filter_time"}, call("set_app_filter_time"), nil).leaf = true
entry({"admin", "network", "get_app_filter_time"}, call("get_app_filter_time"), nil).leaf = true
entry({"admin", "network", "get_all_users"}, call("get_all_users"), nil).leaf = true
entry({"admin", "network", "get_app_filter_user"}, call("get_app_filter_user"), nil).leaf = true
entry({"admin", "network", "set_app_filter_user"}, call("set_app_filter_user"), nil).leaf = true
entry({"admin", "network", "del_app_filter_user"}, call("del_app_filter_user"), nil).leaf = true
entry({"admin", "network", "add_app_filter_user"}, call("add_app_filter_user"), nil).leaf = true
entry({"admin", "network", "get_whitelist_user"}, call("get_whitelist_user"), nil).leaf = true
entry({"admin", "network", "add_whitelist_user"}, call("add_whitelist_user"), nil).leaf = true
entry({"admin", "network", "del_whitelist_user"}, call("del_whitelist_user"), nil).leaf = true
entry({"admin", "network", "upload_file"}, call("handle_file_upload"), nil).leaf = true
entry({"admin", "network", "set_nickname"}, call("set_nickname"), nil).leaf = true
entry({"admin", "network", "get_oaf_status"}, call("get_oaf_status"), nil).leaf = true
entry({"admin", "network", "get_app_filter_adv"}, call("get_app_filter_adv"), nil).leaf = true
entry({"admin", "network", "set_app_filter_adv"}, call("set_app_filter_adv"), nil).leaf = true
entry({"admin", "network", "disable_flow_offloading"}, call("disable_flow_offloading"), nil).leaf = true
entry({"admin", "network", "cmd"}, call("handle_cmd"), nil).leaf = true
entry({"admin", "appfilter", "feature", "info"}, call("get_feature_info"), nil).leaf = true
entry({"admin", "appfilter", "feature", "class_list"}, call("get_feature_class_list"), nil).leaf = true
entry({"admin", "appfilter", "feature", "upgrade_status"}, call("get_feature_upgrade_status"), nil).leaf = true
end
function get_hostname_by_mac(dst_mac)
leasefile="/tmp/dhcp.leases"
local fd = io.open(leasefile, "r")
if not fd then return end
while true do
local ln = fd:read("*l")
if not ln then
break
end
local ts, mac, ip, name, duid = ln:match("^(%d+) (%S+) (%S+) (%S+) (%S+)")
if dst_mac == mac then
fd:close()
return name
end
end
fd:close()
return ""
end
function handle_feature_upgrade()
local fs = require "nixio.fs"
local http = require "luci.http"
local image_tmp = "/tmp/feature.cfg"
local fp
http.setfilehandler(
function(meta, chunk, eof)
fp = io.open(image_tmp, "w")
if fp and chunk then
fp:write(chunk)
end
if fp and eof then
fp:close()
end
end
)
end
function get_app_name_by_id(appid)
local class_fd = io.popen("find /tmp/appfilter/ -type f -name *.class |xargs cat |grep "..appid.."|awk '{print $2}'")
if class_fd then
local name = class_fd:read("*l")
class_fd:close()
return name
end
return ""
end
function cmp_func(a,b)
return a.latest_time > b.latest_time
end
function user_status()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local fd = io.open("/proc/net/af_client","r")
status_buf=fd:read('*a')
fd:close()
user_array=json.parse(status_buf)
local visit_obj=utl.ubus("appfilter", "visit_list", {});
local user_array=visit_obj.dev_list
local history={}
for i, v in pairs(user_array) do
visit_array=user_array[i].visit_info
for j,s in pairs(visit_array) do
print(user_array[i].mac, user_array[i].ip,visit_array[j].appid, visit_array[j].latest_time)
total_time=visit_array[j].latest_time - visit_array[j].first_time;
history[#history+1]={
mac=user_array[i].mac,
ip=user_array[i].ip,
hostname=get_hostname_by_mac(user_array[i].mac),
appid=visit_array[j].appid,
appname=get_app_name_by_id(visit_array[j].appid),
total_num=0,
drop_num=0,
latest_action=visit_array[j].latest_action,
latest_time=os.date("%Y/%m/%d %H:%M:%S", visit_array[j].latest_time),
first_time=os.date("%Y/%m/%d %H:%M:%S", visit_array[j].first_time),
total_time=total_time
}
end
end
table.sort(history, cmp_func)
luci.http.write_json(history);
end
function get_user_list()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local visit_obj=utl.ubus("appfilter", "dev_list", {});
luci.http.write_json(visit_obj);
end
function get_class_list()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local class_obj=utl.ubus("appfilter", "class_list", {});
llog("get class list");
luci.http.write_json(class_obj);
end
function get_all_users()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local flag = luci.http.formvalue("flag")
local page = luci.http.formvalue("page")
local page_size = luci.http.formvalue("page_size")
local params = {flag=flag, page=page}
if page_size then
params.page_size = page_size
end
local class_obj=utl.ubus("appfilter", "get_all_users", params);
luci.http.write_json(class_obj);
end
function get_oaf_status()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local resp_obj=utl.ubus("appfilter", "get_oaf_status", {});
luci.http.write_json(resp_obj);
end
function get_app_filter_user()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local resp_obj=utl.ubus("appfilter", "get_app_filter_user", {});
luci.http.write_json(resp_obj);
end
function del_app_filter_user()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.mac = luci.http.formvalue("mac")
llog("del appfilter user "..req_obj.mac);
local resp_obj=utl.ubus("appfilter", "del_app_filter_user", req_obj);
luci.http.write_json(resp_obj);
end
function add_app_filter_user()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
local data_str = luci.http.formvalue("data")
req_obj = json.parse(data_str)
local resp_obj=utl.ubus("appfilter", "add_app_filter_user", req_obj);
luci.http.write_json(resp_obj);
end
function get_whitelist_user()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local resp_obj=utl.ubus("appfilter", "get_whitelist_user", {});
luci.http.write_json(resp_obj);
end
function add_whitelist_user()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
local data_str = luci.http.formvalue("data")
req_obj = json.parse(data_str)
local resp_obj=utl.ubus("appfilter", "add_whitelist_user", req_obj);
luci.http.write_json(resp_obj);
end
function del_whitelist_user()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.mac = luci.http.formvalue("mac")
llog("del whitelist user "..req_obj.mac);
local resp_obj=utl.ubus("appfilter", "del_whitelist_user", req_obj);
luci.http.write_json(resp_obj);
end
function get_app_filter()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local resp_obj=utl.ubus("appfilter", "get_app_filter", {});
luci.http.write_json(resp_obj);
end
function set_app_filter()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local app_list_str = luci.http.formvalue("app_list")
local app_list = {}
for id in app_list_str:gmatch("([^,]+)") do
table.insert(app_list, tonumber(id))
end
local req_obj = {
app_list = app_list
}
local resp_obj = utl.ubus("appfilter", "set_app_filter", req_obj)
luci.http.write_json(resp_obj)
end
function set_nickname()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.mac = luci.http.formvalue("mac")
req_obj.nickname = luci.http.formvalue("nickname")
llog("set nickname "..req_obj.mac.." "..req_obj.nickname);
local resp_obj=utl.ubus("appfilter", "set_nickname", req_obj);
luci.http.write_json(resp_obj);
end
function get_app_filter_base()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local resp_obj=utl.ubus("appfilter", "get_app_filter_base", {});
luci.http.write_json(resp_obj);
end
function set_app_filter_user()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.mode = luci.http.formvalue("mode")
local resp_obj=utl.ubus("appfilter", "set_app_filter_user", req_obj);
luci.http.write_json(resp_obj);
end
function set_app_filter_base()
local json = require "luci.jsonc"
llog("set appfilter base");
luci.http.prepare_content("application/json")
local req_obj = {}
local enable = luci.http.formvalue("enable")
local work_mode = luci.http.formvalue("work_mode")
local record_enable = luci.http.formvalue("record_enable")
local disable_quic = luci.http.formvalue("disable_quic")
local app_filter_mode = luci.http.formvalue("app_filter_mode")
llog("enable: "..enable.." work_mode: "..work_mode.." record_enable: "..record_enable.." disable_quic: "..(disable_quic or "nil").." app_filter_mode: "..(app_filter_mode or "nil"))
req_obj.enable = enable
req_obj.work_mode = work_mode
req_obj.record_enable = record_enable
if disable_quic then
req_obj.disable_quic = disable_quic
end
if app_filter_mode then
req_obj.app_filter_mode = app_filter_mode
end
local resp_obj=utl.ubus("appfilter", "set_app_filter_base", req_obj);
luci.http.write_json(resp_obj);
end
function get_app_filter_adv()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local resp_obj=utl.ubus("appfilter", "get_app_filter_adv", {});
luci.http.write_json(resp_obj);
end
function set_app_filter_adv()
local json = require "luci.jsonc"
llog("set appfilter base");
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.lan_ifname = luci.http.formvalue("lan_ifname")
req_obj.disable_hnat = luci.http.formvalue("disable_hnat")
req_obj.auto_load_engine = luci.http.formvalue("auto_load_engine")
local resp_obj=utl.ubus("appfilter", "set_app_filter_adv", req_obj);
luci.http.write_json(resp_obj);
end
function disable_flow_offloading()
local json = require "luci.jsonc"
llog("disable flow offloading");
luci.http.prepare_content("application/json")
local resp_obj=utl.ubus("appfilter", "disable_flow_offloading", {});
luci.http.write_json(resp_obj);
end
function handle_cmd()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local action = luci.http.formvalue("action")
if not action then
luci.http.write_json({code = -1, message = "action parameter is required"});
return
end
local req_obj = {}
req_obj.action = action
local resp_obj=utl.ubus("appfilter", "cmd", req_obj);
luci.http.write_json(resp_obj);
end
-- data: {"mode":1,"weekday_list":[1,2,3,4,5,6,0],"start_time":"22:22","end_time":"12:00","allow_time":30,"deny_time":5}
function set_app_filter_time()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj = json.parse(luci.http.formvalue("data"))
local resp_obj=utl.ubus("appfilter", "set_app_filter_time", req_obj);
luci.http.write_json(resp_obj);
end
function get_app_filter_time()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local resp_obj=utl.ubus("appfilter", "get_app_filter_time", {});
llog("controller get_app_filter_time: ubus response received");
if resp_obj and resp_obj.data then
llog("controller get_app_filter_time: mode=" .. tostring(resp_obj.data.mode or "nil"));
if resp_obj.data.time_list then
llog("controller get_app_filter_time: time_list length=" .. tostring(#resp_obj.data.time_list));
for i, time_item in ipairs(resp_obj.data.time_list) do
local weekday_str = "nil"
if time_item.weekday_list then
weekday_str = table.concat(time_item.weekday_list, ",")
end
end
else
llog("controller get_app_filter_time: time_list is nil");
end
local response_json = json.stringify(resp_obj);
if response_json then
llog("controller get_app_filter_time: final JSON response length=" .. tostring(string.len(response_json)));
else
llog("controller get_app_filter_time: failed to encode JSON");
end
else
llog("controller get_app_filter_time: resp_obj or resp_obj.data is nil");
end
luci.http.write_json(resp_obj);
end
function get_dev_visit_time(mac)
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.mac = mac;
local visit_obj=utl.ubus("appfilter", "dev_visit_time", req_obj);
local visit_list=visit_obj.list
luci.http.write_json(visit_list);
end
function get_app_class_visit_time(mac)
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.mac = mac;
local visit_obj=utl.ubus("appfilter", "app_class_visit_time", req_obj);
local class_array=visit_obj.class_list
luci.http.write_json(class_array);
end
function get_dev_visit_list(mac)
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.mac = mac;
local page = luci.http.formvalue("page")
local page_size = luci.http.formvalue("page_size")
if page then
req_obj.page = page
end
if page_size then
req_obj.page_size = page_size
end
local resp_obj=utl.ubus("appfilter", "dev_visit_list", req_obj);
luci.http.write_json(resp_obj);
end
function handle_file_upload()
local http = require "luci.http"
local fs = require "nixio.fs"
local upload_dir = "/tmp/uploads/"
local file_name = "uploaded_file"
llog("handle_file_upload started");
-- Ensure the upload directory exists
if not fs.access(upload_dir) then
fs.mkdir(upload_dir)
end
llog("Upload directory checked/created");
local file_path = upload_dir .. file_name
local fp
llog("file_path: " .. file_path);
http.setfilehandler(
function(meta, chunk, eof)
-- Log metadata information
llog("File upload metadata: " .. (meta and meta.name or "nil") .. ", " .. (meta and meta.file or "nil"))
llog("File upload chunk size: " .. (chunk and #chunk or 0))
if not fp then
fp = io.open(file_path, "w")
llog("File opened for writing: " .. file_path)
end
if fp and chunk then
fp:write(chunk)
llog("Chunk written to file")
end
if fp and eof then
fp:close()
llog("File upload completed and file closed")
-- Ensure the file is processed or moved to the correct location
process_uploaded_file(file_path)
luci.http.prepare_content("application/json")
luci.http.write_json({ success = true, message = "File uploaded successfully" })
end
end
)
llog("handle_file_upload setup complete");
end
function process_uploaded_file(file_path)
-- Add logic here to process the uploaded file
llog("Processing uploaded file: " .. file_path)
-- Example: Move the file to a permanent location
local permanent_path = "/etc/config/" .. file_name
os.execute("mv " .. file_path .. " " .. permanent_path)
llog("File moved to: " .. permanent_path)
end
function llog(message)
local log_file = "/tmp/log/oaf_luci.log"
local fd = io.open(log_file, "a")
if fd then
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
fd:write(string.format("[%s] %s\n", timestamp, message))
fd:close()
end
end
function get_feature_upgrade_status()
local fs = require "nixio.fs"
local json = require "luci.jsonc"
local http = require "luci.http"
local status_file = "/tmp/feature_upgrade.status"
local status = 0
if fs.access(status_file) then
local content = fs.readfile(status_file) or ""
status = tonumber(content:match("(%d+)")) or 0
fs.writefile(status_file, "0")
end
http.prepare_content("application/json")
http.write(json.stringify({code = 0, status = status}))
end
function get_feature_info()
local json = require "luci.jsonc"
local nfs = require "nixio.fs"
local sys = require "luci.sys"
luci.http.prepare_content("application/json")
local info = {
version = "",
format = "v3.0",
app_count = 0
}
if nfs.access("/tmp/feature.cfg") then
info.app_count = tonumber(sys.exec("cat /tmp/feature.cfg | grep -v ^$ |grep -v ^# | wc -l")) or 0
info.version = sys.exec("cat /tmp/feature.cfg |grep \"#version\" | awk '{print $2}'") or ""
end
luci.http.write(json.stringify({code = 0, data = info, message = "success"}))
end
function get_feature_class_list()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "class_list"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write(json.stringify(resp_obj.data))
else
luci.http.write(json.stringify({class_list = {}}))
end
end
+5
View File
@@ -0,0 +1,5 @@
module("luci.controller.oaf", package.seeall)
function index()
entry({"admin", "services", "oaf"}, firstchild(), _("Parental Control"), 20).dependent = true
end
@@ -0,0 +1,5 @@
module("luci.controller.oaf_about", package.seeall)
function index()
entry({"admin", "services", "oaf", "about"}, template("oaf/about"), _("About"), 100).leaf = true
end
@@ -0,0 +1,127 @@
module("luci.controller.oaf_advanced", package.seeall)
local util = require "luci.util"
function index()
entry({"admin", "services", "oaf", "advanced"}, template("oaf/advanced"), _("Settings"), 90).leaf = true
entry({"admin", "services", "oaf", "api", "system", "get_system_info"}, call("get_system_info"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "system", "set_system_info"}, call("set_system_info"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "system", "get_work_mode"}, call("get_work_mode"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "system", "set_work_mode"}, call("set_work_mode"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "system", "get_tcp_rst"}, call("get_tcp_rst"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "system", "set_tcp_rst"}, call("set_tcp_rst"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "system", "get_advanced_settings"}, call("get_advanced_settings"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "system", "set_advanced_settings"}, call("set_advanced_settings"), nil).leaf = true
end
local function ubus_call(api, payload)
payload = payload or {}
payload.api = api
return util.ubus("fwx", "common", payload) or {code = 1}
end
function get_system_info()
local http = require "luci.http"
local resp = ubus_call("get_system_info", {data = {}})
http.prepare_content("application/json")
http.write_json(resp)
end
function set_system_info()
local http = require "luci.http"
local lan_ifname = http.formvalue("lan_ifname") or ""
local theme_mode = tonumber(http.formvalue("theme_mode") or "0") or 0
if theme_mode ~= 0 and theme_mode ~= 1 then
theme_mode = 0
end
local resp = ubus_call("set_system_info", {
data = {
fwx = {
lan_ifname = lan_ifname,
theme_mode = theme_mode
}
}
})
http.prepare_content("application/json")
http.write_json(resp)
end
function get_work_mode()
local http = require "luci.http"
local resp = ubus_call("get_work_mode", {data = {}})
http.prepare_content("application/json")
http.write_json(resp)
end
function set_work_mode()
local http = require "luci.http"
local work_mode = tonumber(http.formvalue("work_mode") or "0") or 0
if work_mode ~= 0 and work_mode ~= 1 then
http.prepare_content("application/json")
http.write_json({code = 1})
return
end
local resp = ubus_call("set_work_mode", {
data = {
work_mode = work_mode
}
})
http.prepare_content("application/json")
http.write_json(resp)
end
function get_tcp_rst()
local http = require "luci.http"
local resp = ubus_call("get_tcp_rst", {data = {}})
http.prepare_content("application/json")
http.write_json(resp)
end
function set_tcp_rst()
local http = require "luci.http"
local tcp_rst = tonumber(http.formvalue("tcp_rst") or "1") or 1
tcp_rst = tcp_rst == 0 and 0 or 1
local resp = ubus_call("set_tcp_rst", {
data = {
tcp_rst = tcp_rst
}
})
http.prepare_content("application/json")
http.write_json(resp)
end
function get_advanced_settings()
local http = require "luci.http"
local resp = ubus_call("get_advanced_settings", {data = {}})
http.prepare_content("application/json")
http.write_json(resp)
end
function set_advanced_settings()
local http = require "luci.http"
local disable_hnat = tonumber(http.formvalue("disable_hnat") or "0") or 0
disable_hnat = disable_hnat == 1 and 1 or 0
local resp = ubus_call("set_advanced_settings", {
data = {
disable_hnat = disable_hnat
}
})
http.prepare_content("application/json")
http.write_json(resp)
end
@@ -0,0 +1,371 @@
module("luci.controller.oaf_app_filter", package.seeall)
local utl = require "luci.util"
local nixio = require "nixio"
function index()
if not nixio.fs.access("/etc/config/appfilter") then
return
end
entry({"admin", "services", "oaf", "app_filter"}, alias("admin", "services", "oaf", "app_filter", "rules"), _("App Filter"), 30).dependent = true
entry({"admin", "services", "oaf", "app_filter", "rules"}, cbi("oaf/app_filter/rules", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), _("Filter Rules"), 10).leaf=true
entry({"admin", "services", "oaf", "api", "app_filter", "class_list"}, call("get_class_list"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "app_filter", "get_all_users"}, call("get_all_users"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "app_filter", "get_filter_rules"}, call("get_filter_rules"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "app_filter", "add_filter_rule"}, call("add_filter_rule"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "app_filter", "update_filter_rule"}, call("update_filter_rule"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "app_filter", "delete_filter_rule"}, call("delete_filter_rule"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "app_filter", "get_appfilter_whitelist"}, call("get_appfilter_whitelist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "app_filter", "add_appfilter_whitelist"}, call("add_appfilter_whitelist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "app_filter", "del_appfilter_whitelist"}, call("del_appfilter_whitelist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "app_filter", "get_app_filter_adv"}, call("get_app_filter_adv"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "app_filter", "set_app_filter_adv"}, call("set_app_filter_adv"), nil).leaf = true
end
function get_class_list()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.CopyRight = "www.fanchmwrt.com"
req_obj.api = "class_list"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
llog("get class list");
luci.http.write_json(resp_obj.data)
else
llog("get class list failed");
luci.http.write_json({class_list = {}})
end
end
function get_all_users()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_all_users"
req_obj.data = {
flag = luci.http.formvalue("flag"),
page = luci.http.formvalue("page")
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json({data = resp_obj.data})
else
luci.http.write_json({data = resp_obj or {}})
end
end
function handle_file_upload()
local http = require "luci.http"
local fs = require "nixio.fs"
local upload_dir = "/tmp/uploads/"
local file_name = "uploaded_file"
llog("handle_file_upload started");
if not fs.access(upload_dir) then
fs.mkdir(upload_dir)
end
llog("Upload directory checked/created");
local file_path = upload_dir .. file_name
local fp
llog("file_path: " .. file_path);
http.setfilehandler(
function(meta, chunk, eof)
llog("File upload metadata: " .. (meta and meta.name or "nil") .. ", " .. (meta and meta.file or "nil"))
llog("File upload chunk size: " .. (chunk and #chunk or 0))
if not fp then
fp = io.open(file_path, "w")
llog("File opened for writing: " .. file_path)
end
if fp and chunk then
fp:write(chunk)
llog("Chunk written to file")
end
if fp and eof then
fp:close()
llog("File upload completed and file closed")
process_uploaded_file(file_path)
luci.http.prepare_content("application/json")
luci.http.write_json({ success = true, message = "File uploaded successfully" })
end
end
)
llog("handle_file_upload setup complete");
end
function process_uploaded_file(file_path)
llog("Processing uploaded file: " .. file_path)
local permanent_path = "/etc/config/" .. file_name
os.execute("mv " .. file_path .. " " .. permanent_path)
llog("File moved to: " .. permanent_path)
end
function llog(message)
local log_file = "/tmp/log/oaf_luci.log"
local fd = io.open(log_file, "a")
if fd then
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
fd:write(string.format("[%s] %s\n", timestamp, message))
fd:close()
end
end
function get_filter_rules()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_filter_rules"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data and resp_obj.data.list then
local rules_data = resp_obj.data.list
llog("get_filter_rules: returning " .. #rules_data .. " rules")
local json_str = json.stringify({code = 0, data = rules_data, message = "success"})
luci.http.write(json_str)
else
llog("get_filter_rules: failed, returning empty array")
local json_str = json.stringify({code = 0, data = {}, message = "success"})
luci.http.write(json_str)
end
end
function add_filter_rule()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local data_str = luci.http.formvalue("data")
if not data_str then
luci.http.write_json({code = 1, message = "Invalid request data"})
return
end
local rule_data = json.parse(data_str)
llog("add_filter_rule: " .. json.stringify(rule_data))
if not rule_data.name or not rule_data.mode or not rule_data.time_rules or not rule_data.app_ids then
luci.http.write_json({code = 1, message = "Missing required fields"})
return
end
local get_req_obj = {}
get_req_obj.api = "get_filter_rules"
get_req_obj.data = {}
local get_resp_obj = utl.ubus("fwx", "common", get_req_obj)
if get_resp_obj and get_resp_obj.code == 2000 and get_resp_obj.data and get_resp_obj.data.data then
local existing_rules = get_resp_obj.data.data
if #existing_rules >= 32 then
luci.http.write_json({code = 1, message = "Maximum 32 rules allowed"})
return
end
end
local req_obj = {}
req_obj.api = "add_filter_rule"
req_obj.data = rule_data
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 0, message = "Rule added successfully"})
else
luci.http.write_json({code = 1, message = "Failed to add rule"})
end
end
function update_filter_rule()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local data_str = luci.http.formvalue("data")
if not data_str then
luci.http.write_json({code = 1, message = "Invalid request data"})
return
end
local rule_data = json.parse(data_str)
llog("update_filter_rule: " .. json.stringify(rule_data))
if not rule_data.id then
luci.http.write_json({code = 1, message = "Missing rule id"})
return
end
local req_obj = {}
req_obj.api = "update_filter_rule"
req_obj.data = rule_data
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 0, message = "Rule updated successfully"})
else
luci.http.write_json({code = 1, message = "Failed to update rule"})
end
end
function delete_filter_rule()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local rule_id = luci.http.formvalue("rule_id")
if not rule_id then
luci.http.write_json({code = 1, message = "Invalid rule_id"})
return
end
llog("delete_filter_rule: " .. rule_id)
local req_obj = {}
req_obj.api = "delete_filter_rule"
req_obj.data = {
id = tonumber(rule_id)
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 0, message = "Rule deleted successfully"})
else
luci.http.write_json({code = 1, message = "Failed to delete rule"})
end
end
function get_appfilter_whitelist()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_appfilter_whitelist"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json({code = 2000, data = resp_obj.data, message = "success"})
else
luci.http.write_json({code = 2000, data = {list = {}}, message = "success"})
end
end
function add_appfilter_whitelist()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local data_str = luci.http.formvalue("data")
if not data_str then
luci.http.write_json({code = 1, message = "Invalid request data"})
return
end
local whitelist_data = json.parse(data_str)
llog("add_appfilter_whitelist: " .. json.stringify(whitelist_data))
if not whitelist_data.mac_list or type(whitelist_data.mac_list) ~= "table" then
luci.http.write_json({code = 1, message = "Invalid mac_list"})
return
end
local req_obj = {}
req_obj.api = "add_appfilter_whitelist"
req_obj.data = whitelist_data
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 2000, message = "Whitelist added successfully"})
else
luci.http.write_json({code = 1, message = "Failed to add whitelist"})
end
end
function del_appfilter_whitelist()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local mac = luci.http.formvalue("mac")
if not mac then
luci.http.write_json({code = 1, message = "Invalid mac address"})
return
end
llog("del_appfilter_whitelist: " .. mac)
local req_obj = {}
req_obj.api = "del_appfilter_whitelist"
req_obj.data = {
mac = mac
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 2000, message = "Whitelist deleted successfully"})
else
luci.http.write_json({code = 1, message = "Failed to delete whitelist"})
end
end
function get_app_filter_adv()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_app_filter_adv"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json({code = 0, data = resp_obj.data, message = "success"})
end
end
function set_app_filter_adv()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local enable = tonumber(luci.http.formvalue("enable")) or 1
local req_obj = {}
req_obj.api = "set_app_filter_adv"
req_obj.data = {
enable = enable,
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 0, message = "Saved successfully"})
else
luci.http.write_json({code = 1, message = "Failed to save"})
end
end
@@ -0,0 +1,83 @@
module("luci.controller.oaf_app_record", package.seeall)
local function normalize_page(value, default_value)
local n = tonumber(value or "") or default_value
if n < 1 then
n = default_value
end
return n
end
local function write_empty_list(page, page_size)
luci.http.write_json({
total_num = 0,
total_page = 1,
page = page,
page_size = page_size,
list = {}
})
end
function index()
entry({"admin", "services", "oaf", "app_record"}, template("oaf/app_record"), _("App Record"), 70).leaf = true
entry({"admin", "services", "oaf", "api", "app_record", "get_active_app_records"}, call("get_active_app_records")).leaf = true
entry({"admin", "services", "oaf", "api", "app_record", "get_app_history_records"}, call("get_app_history_records")).leaf = true
end
function get_active_app_records()
local util = require "luci.util"
local page = normalize_page(luci.http.formvalue("page"), 1)
local page_size = normalize_page(luci.http.formvalue("page_size"), 15)
luci.http.prepare_content("application/json")
local req_obj = {
api = "get_active_app_records",
data = {
page = page,
page_size = page_size
}
}
local resp_obj = util.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
write_empty_list(page, page_size)
end
end
function get_app_history_records()
local util = require "luci.util"
local mac = luci.http.formvalue("mac")
local start_time = tonumber(luci.http.formvalue("start_time") or "0") or 0
local end_time = tonumber(luci.http.formvalue("end_time") or "0") or 0
local appid = tonumber(luci.http.formvalue("appid") or "0") or 0
local page = normalize_page(luci.http.formvalue("page"), 1)
local page_size = normalize_page(luci.http.formvalue("page_size"), 15)
if appid < 0 then
appid = 0
end
luci.http.prepare_content("application/json")
local req_obj = {
api = "get_app_history_records",
data = {
mac = mac,
start_time = start_time,
end_time = end_time,
appid = appid,
page = page,
page_size = page_size
}
}
local resp_obj = util.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
write_empty_list(page, page_size)
end
end
@@ -0,0 +1,229 @@
module("luci.controller.oaf_dashboard", package.seeall)
function index()
entry({"admin", "services", "oaf", "dashboard"}, cbi("oaf/dashboard", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}),
_("Dashboard"), 10).leaf = true
entry({"admin", "services", "oaf", "api", "dashboard", "get_dashboard_common"}, call("get_dashboard_common")).leaf = true
entry({"admin", "services", "oaf", "api", "dashboard", "get_init_status"}, call("get_init_status")).leaf = true
entry({"admin", "services", "oaf", "api", "dashboard", "set_init_status"}, call("set_init_status")).leaf = true
entry({"admin", "services", "oaf", "api", "dashboard", "set_notice_status"}, call("set_notice_status")).leaf = true
entry({"admin", "services", "oaf", "api", "dashboard", "get_daily_top_users"}, call("get_daily_top_users")).leaf = true
entry({"admin", "services", "oaf", "api", "dashboard", "get_history_traffic_stats"}, call("get_history_traffic_stats")).leaf = true
entry({"admin", "services", "oaf", "api", "dashboard", "get_global_traffic_stats"}, call("get_global_traffic_stats")).leaf = true
entry({"admin", "services", "oaf", "api", "dashboard", "get_active_users"}, call("get_active_users")).leaf = true
entry({"admin", "services", "oaf", "api", "dashboard", "get_app_type_stats"}, call("get_app_type_stats")).leaf = true
end
function get_dashboard_common()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_dashboard_common"
req_obj.CopyRight = "www.fanchmwrt.com"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({
system_status = {},
network_status = {},
active_app = {total = 0, list = {}},
advanced = {disable_hnat = 0, notice_status = 0},
interface_traffic = {interface = "wan", traffic = {}}
})
end
end
function get_global_traffic_stats()
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_global_traffic_stats"
req_obj.data = {}
local date = luci.http.formvalue("date")
if date then
req_obj.data.date = tonumber(date) or 0
end
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({
date = 0,
is_today = 1,
hourly_traffic = {}
})
end
end
function get_history_traffic_stats()
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_history_traffic_stats"
req_obj.data = {}
local days = luci.http.formvalue("days")
if days then
req_obj.data.days = tonumber(days) or 30
else
req_obj.data.days = 30
end
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({
days = req_obj.data.days,
total_up_bytes = 0,
total_down_bytes = 0,
total_bytes = 0,
list = {}
})
end
end
function get_init_status()
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_init_status"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({init_status = 1})
end
end
function set_init_status()
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "set_init_status"
req_obj.data = { init_status = 1 }
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({init_status = 0})
end
end
function set_notice_status()
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "set_dashboard_notice_status"
req_obj.data = { notice_status = 1 }
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 2000, notice_status = 1})
else
luci.http.write_json({code = 1})
end
end
function get_daily_top_users()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_daily_top_users"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({
date = 0,
total_count = 0,
users = {}
})
end
end
function get_active_users()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_active_users"
req_obj.data = {}
local count = luci.http.formvalue("count")
if count then
req_obj.data.count = tonumber(count) or 8
else
req_obj.data.count = 8
end
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({
total_count = 0,
users = {}
})
end
end
function get_app_type_stats()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local stat_type = luci.http.formvalue("type") or "hourly"
local req_obj = {}
req_obj.api = "get_global_app_type_stats"
req_obj.data = {
type = stat_type,
limit = 10
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({
type = stat_type,
limit = 10,
total_count = 0,
types = {}
})
end
end
@@ -0,0 +1,118 @@
module("luci.controller.oaf_feature", package.seeall)
function index()
entry({"admin", "services", "oaf", "feature"},
template("oaf/feature"),
_("Feature Library"), 80).dependent = true
entry({"admin", "services", "oaf", "feature", "info"}, call("get_feature_info"), nil).leaf = true
entry({"admin", "services", "oaf", "feature", "class_list"}, call("get_feature_class_list"), nil).leaf = true
entry({"admin", "services", "oaf", "feature", "online_config"}, call("get_feature_online_config"), nil).leaf = true
entry({"admin", "services", "oaf", "feature", "online_save"}, call("set_feature_online_config"), nil).leaf = true
entry({"admin", "services", "oaf", "feature", "online_list"}, call("get_feature_online_list"), nil).leaf = true
entry({"admin", "services", "oaf", "feature", "online_start"}, call("start_feature_online_update"), nil).leaf = true
entry({"admin", "services", "oaf", "feature", "online_status"}, call("get_feature_online_update_status"), nil).leaf = true
entry({"admin", "services", "oaf", "feature", "custom_list"}, call("get_custom_feature_list"), nil).leaf = true
entry({"admin", "services", "oaf", "feature", "custom_class_list"}, call("get_custom_feature_class_list"), nil).leaf = true
entry({"admin", "services", "oaf", "feature", "custom_save"}, call("set_custom_feature_list"), nil).leaf = true
end
function get_feature_info()
local json = require "luci.jsonc"
local util = require "luci.util"
local http = require "luci.http"
local resp = util.ubus("fwx", "common", {api = "get_feature_info", data = {}})
http.prepare_content("application/json")
http.write(json.stringify(resp or {code = 4000}))
end
local function write_fwx_response(api, data)
local json = require "luci.jsonc"
local util = require "luci.util"
local http = require "luci.http"
local resp = util.ubus("fwx", "common", {api = api, data = data or {}})
http.prepare_content("application/json")
http.write(json.stringify(resp or {code = 4000}))
end
function get_feature_online_config()
write_fwx_response("get_feature_online_config", {})
end
function set_feature_online_config()
local http = require "luci.http"
write_fwx_response("set_feature_online_config", {
token = http.formvalue("token") or ""
})
end
function get_feature_online_list()
local http = require "luci.http"
local lang = http.formvalue("lang") or "cn"
local refresh = tonumber(http.formvalue("refresh") or "0") or 0
if lang ~= "cn" and lang ~= "en" then
lang = "cn"
end
write_fwx_response("get_feature_online_list", {
lang = lang,
device_lang = http.formvalue("device_lang") or "",
refresh = refresh
})
end
function start_feature_online_update()
local http = require "luci.http"
local lang = http.formvalue("lang") or "cn"
if lang ~= "cn" and lang ~= "en" then
lang = "cn"
end
write_fwx_response("start_feature_online_update", {
id = http.formvalue("id") or "",
lang = lang,
md5 = http.formvalue("md5") or ""
})
end
function get_feature_online_update_status()
write_fwx_response("get_feature_online_update_status", {})
end
function get_custom_feature_list()
write_fwx_response("get_custom_feature", {})
end
function get_custom_feature_class_list()
write_fwx_response("get_custom_feature_class_list", {})
end
function set_custom_feature_list()
local json = require "luci.jsonc"
local http = require "luci.http"
local data_str = http.formvalue("data")
local ok, data_obj = pcall(json.parse, data_str or "")
if not ok then
data_obj = nil
end
if type(data_obj) ~= "table" or type(data_obj.app_list) ~= "table" then
http.prepare_content("application/json")
http.write(json.stringify({code = 4000, data = {error = "invalid request data"}}))
return
end
write_fwx_response("set_custom_feature", data_obj)
end
function get_feature_class_list()
local json = require "luci.jsonc"
local util = require "luci.util"
local http = require "luci.http"
local resp = util.ubus("fwx", "common", {CopyRight = "www.fanchmwrt.com", api = "class_list", data = {}})
http.prepare_content("application/json")
if resp and resp.code == 2000 and resp.data then
http.write(json.stringify(resp.data))
else
http.write(json.stringify({class_list = {}}))
end
end
@@ -0,0 +1,113 @@
module("luci.controller.oaf_internet_audit", package.seeall)
function index()
entry({"admin", "services", "oaf", "api", "internet_audit", "get_record_base"}, call("get_record_base"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "internet_audit", "set_record_base"}, call("set_record_base"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "internet_audit", "record_action"}, call("record_action"), nil).leaf = true
end
function ensure_record_section(cur)
local sid
cur:foreach("fwx", "record", function(s)
sid = s[".name"]
end)
if not sid then
sid = cur:add("fwx", "record")
end
return sid
end
function get_record_base()
local json = require "luci.jsonc"
local http = require "luci.http"
local utl = require "luci.util"
local req_obj = { api = "get_record_base", data = {} }
local resp = utl.ubus("fwx", "common", req_obj) or {code = 1}
http.prepare_content("application/json")
http.write(json.stringify(resp))
end
function set_record_base()
local json = require "luci.jsonc"
local http = require "luci.http"
local utl = require "luci.util"
local enable = tonumber(http.formvalue("enable") or 0) or 0
local record_time = tonumber(http.formvalue("record_time") or 0) or 0
local app_valid_time = tonumber(http.formvalue("app_valid_time") or 0) or 0
local history_data_size = http.formvalue("history_data_size") or ""
local history_data_path = http.formvalue("history_data_path") or ""
local base_data_path = http.formvalue("base_data_path") or http.formvalue("terminal_data_path") or ""
if record_time < 0 then record_time = 0 end
if app_valid_time < 0 then app_valid_time = 0 end
if history_data_size and history_data_size ~= "" then
local size_num = tonumber(history_data_size)
if not size_num or size_num < 1 or size_num > 1024 or size_num ~= math.floor(size_num) then
http.prepare_content("application/json")
http.write(json.stringify({code = 1, msg = "History data size must be an integer between 1 and 1024 MB"}))
return
end
end
if not history_data_path or history_data_path == "" or history_data_path == "/" then
http.prepare_content("application/json")
http.write(json.stringify({code = 1, msg = "History data path cannot be empty or /"}))
return
end
if not base_data_path or base_data_path == "" or base_data_path == "/" then
http.prepare_content("application/json")
http.write(json.stringify({code = 1, msg = "Base data path cannot be empty or /"}))
return
end
if #history_data_path > 64 then
http.prepare_content("application/json")
http.write(json.stringify({code = 1, msg = "History data path maximum length is 64 characters"}))
return
end
if #base_data_path > 64 then
http.prepare_content("application/json")
http.write(json.stringify({code = 1, msg = "Base data path maximum length is 64 characters"}))
return
end
local req_obj = {
api = "set_record_base",
data = {
enable = enable,
record_time = record_time,
app_valid_time = app_valid_time,
history_data_size = history_data_size,
history_data_path = history_data_path,
base_data_path = base_data_path
}
}
local resp = utl.ubus("fwx", "common", req_obj) or {code = 1}
http.prepare_content("application/json")
http.write(json.stringify(resp))
end
function record_action()
local json = require "luci.jsonc"
local http = require "luci.http"
local utl = require "luci.util"
local action = http.formvalue("action") or ""
local req_obj = {
api = "record_action",
data = {
action = action
}
}
local resp = utl.ubus("fwx", "common", req_obj) or {code = 1}
http.prepare_content("application/json")
http.write(json.stringify(resp))
end
@@ -0,0 +1,461 @@
module("luci.controller.oaf_mac_filter", package.seeall)
local utl = require "luci.util"
local nixio = require "nixio"
function index()
if not nixio.fs.access("/etc/config/macfilter") then
return
end
entry({"admin", "services", "oaf", "mac_filter"}, alias("admin", "services", "oaf", "mac_filter", "rules"), _("Access Control"), 40).dependent = true
entry({"admin", "services", "oaf", "mac_filter", "rules"}, cbi("oaf/mac_filter/rules", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), _("Filter Rules"), 10).leaf=true
entry({"admin", "services", "oaf", "api", "mac_filter", "get_mac_filter_base"}, call("get_mac_filter_base"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "set_mac_filter_base"}, call("set_mac_filter_base"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "set_mac_filter_time"}, call("set_mac_filter_time"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "get_mac_filter_time"}, call("get_mac_filter_time"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "get_mac_filter_user"}, call("get_mac_filter_user"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "set_mac_filter_user"}, call("set_mac_filter_user"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "del_mac_filter_user"}, call("del_mac_filter_user"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "add_mac_filter_user"}, call("add_mac_filter_user"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "get_mf_status"}, call("get_mf_status"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "get_mac_filter_whitelist"}, call("get_mac_filter_whitelist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "del_mac_filter_whitelist"}, call("del_mac_filter_whitelist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "add_mac_filter_whitelist"}, call("add_mac_filter_whitelist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "get_mac_filter_rules"}, call("get_mac_filter_rules"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "add_mac_filter_rule"}, call("add_mac_filter_rule"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "update_mac_filter_rule"}, call("update_mac_filter_rule"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "delete_mac_filter_rule"}, call("delete_mac_filter_rule"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "get_mac_filter_adv"}, call("get_mac_filter_adv"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "mac_filter", "set_mac_filter_adv"}, call("set_mac_filter_adv"), nil).leaf = true
end
function get_mf_status()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_mf_status"
req_obj.data = {}
local resp_obj=utl.ubus("fwx", "common", req_obj);
luci.http.write_json(resp_obj);
end
function get_mac_filter_user()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_mac_filter_user"
req_obj.data = {}
local resp_obj=utl.ubus("fwx", "common", req_obj);
luci.http.write_json(resp_obj);
end
function del_mac_filter_user()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
local mac = luci.http.formvalue("mac")
llog("del macfilter user "..mac);
req_obj.api = "del_mac_filter_user"
req_obj.data = {
mac = mac
}
local resp_obj=utl.ubus("fwx", "common", req_obj);
luci.http.write_json(resp_obj);
end
function add_mac_filter_user()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "add_mac_filter_user"
local data_str = luci.http.formvalue("data")
local data = json.parse(data_str)
req_obj.data = data
local resp_obj=utl.ubus("fwx", "common", req_obj);
luci.http.write_json(resp_obj);
end
function get_mac_filter_base()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_mac_filter_base"
req_obj.data = {}
local resp_obj=utl.ubus("fwx", "common", req_obj);
luci.http.write_json(resp_obj);
end
function set_mac_filter_user()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "set_mac_filter_user"
local mode = luci.http.formvalue("mode")
req_obj.data = {
mode = mode
}
local resp_obj=utl.ubus("fwx", "common", req_obj);
luci.http.write_json(resp_obj);
end
function set_mac_filter_base()
local json = require "luci.jsonc"
llog("set macfilter base");
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "set_mac_filter_base"
local enable = luci.http.formvalue("enable")
req_obj.data = {
enable = enable
}
local resp_obj=utl.ubus("fwx", "common", req_obj);
luci.http.write_json(resp_obj);
end
function set_mac_filter_time()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "set_mac_filter_time"
local data_str = luci.http.formvalue("data")
local data = json.parse(data_str)
req_obj.data = data
local resp_obj=utl.ubus("fwx", "common", req_obj);
luci.http.write_json(resp_obj);
end
function get_mac_filter_time()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_mac_filter_time"
req_obj.data = {}
local resp_obj=utl.ubus("fwx", "common", req_obj);
luci.http.write_json(resp_obj);
end
function get_mac_filter_whitelist()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_mac_filter_whitelist"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json({code = 2000, data = resp_obj.data, message = "success"})
else
luci.http.write_json({code = 2000, data = {list = {}}, message = "success"})
end
end
function add_mac_filter_whitelist()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local data_str = luci.http.formvalue("data")
if not data_str then
luci.http.write_json({code = 1, message = "Invalid request data"})
return
end
local whitelist_data = json.parse(data_str)
llog("add_mac_filter_whitelist: " .. json.stringify(whitelist_data))
if not whitelist_data.mac_list or type(whitelist_data.mac_list) ~= "table" then
luci.http.write_json({code = 1, message = "Invalid mac_list"})
return
end
local req_obj = {}
req_obj.api = "add_mac_filter_whitelist"
req_obj.data = whitelist_data
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 2000, message = "Whitelist added successfully"})
else
luci.http.write_json({code = 1, message = "Failed to add whitelist"})
end
end
function del_mac_filter_whitelist()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local mac = luci.http.formvalue("mac")
if not mac then
luci.http.write_json({code = 1, message = "Invalid mac address"})
return
end
llog("del_mac_filter_whitelist: " .. mac)
local req_obj = {}
req_obj.api = "del_mac_filter_whitelist"
req_obj.data = {
mac = mac
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 2000, message = "Whitelist deleted successfully"})
else
luci.http.write_json({code = 1, message = "Failed to delete whitelist"})
end
end
function llog(message)
local log_file = "/tmp/log/oaf_luci.log"
local fd = io.open(log_file, "a")
if fd then
local timestamp = os.date("%Y-%m-%d %H:%M:%S")
fd:write(string.format("[%s] %s\n", timestamp, message))
fd:close()
end
end
function get_mac_filter_rules()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_mac_filter_rules"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data and resp_obj.data.list then
local rules_data = resp_obj.data.list
local json_str = json.stringify({code = 0, data = rules_data, message = "success"})
luci.http.write(json_str)
else
local json_str = json.stringify({code = 0, data = {}, message = "success"})
luci.http.write(json_str)
end
end
function add_mac_filter_rule()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local data_str = luci.http.formvalue("data")
if not data_str then
luci.http.write_json({code = 1, message = "Invalid request data"})
return
end
local rule_data = json.parse(data_str)
llog("add_mac_filter_rule: " .. json.stringify(rule_data))
if not rule_data.name or not rule_data.mode then
luci.http.write_json({code = 1, message = "Missing required fields"})
return
end
local time_mode = tonumber(rule_data.time_mode) or 1
if time_mode == 1 then
if (not rule_data.time_list or #rule_data.time_list == 0) and (not rule_data.time_rules or #rule_data.time_rules == 0) then
luci.http.write_json({code = 1, message = "Missing required time_list"})
return
end
elseif time_mode == 2 then
if (not rule_data.time_limit or rule_data.time_limit == "") and (not rule_data.time_rules or #rule_data.time_rules == 0) then
luci.http.write_json({code = 1, message = "Missing required time_limit"})
return
end
elseif time_mode == 3 then
if (not rule_data.flow_limit or rule_data.flow_limit == "") and (not rule_data.time_rules or #rule_data.time_rules == 0) then
luci.http.write_json({code = 1, message = "Missing required flow_limit"})
return
end
end
local get_req_obj = {}
get_req_obj.api = "get_mac_filter_rules"
get_req_obj.data = {}
local get_resp_obj = utl.ubus("fwx", "common", get_req_obj)
if get_resp_obj and get_resp_obj.code == 2000 and get_resp_obj.data and get_resp_obj.data.data then
local existing_rules = get_resp_obj.data.data
if #existing_rules >= 32 then
luci.http.write_json({code = 1, message = "Maximum 32 rules allowed"})
return
end
end
local req_obj = {}
req_obj.api = "add_mac_filter_rule"
req_obj.data = rule_data
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 0, message = "Rule added successfully"})
else
luci.http.write_json({code = 1, message = "Failed to add rule"})
end
end
function update_mac_filter_rule()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local data_str = luci.http.formvalue("data")
if not data_str then
luci.http.write_json({code = 1, message = "Invalid request data"})
return
end
local rule_data = json.parse(data_str)
llog("update_mac_filter_rule: " .. json.stringify(rule_data))
if not rule_data.id then
luci.http.write_json({code = 1, message = "Missing rule id"})
return
end
local req_obj = {}
req_obj.api = "update_mac_filter_rule"
req_obj.data = rule_data
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 0, message = "Rule updated successfully"})
else
luci.http.write_json({code = 1, message = "Failed to update rule"})
end
end
function delete_mac_filter_rule()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local rule_id = luci.http.formvalue("rule_id")
if not rule_id then
luci.http.write_json({code = 1, message = "Invalid rule_id"})
return
end
llog("delete_mac_filter_rule: " .. rule_id)
local req_obj = {}
req_obj.api = "delete_mac_filter_rule"
req_obj.data = {
id = tonumber(rule_id)
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 0, message = "Rule deleted successfully"})
else
luci.http.write_json({code = 1, message = "Failed to delete rule"})
end
end
function get_mac_filter_adv()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_mac_filter_adv"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json({code = 0, data = resp_obj.data, message = "success"})
else
luci.http.write_json({code = 1, message = "Failed to load"})
end
end
function set_mac_filter_adv()
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local enable = tonumber(luci.http.formvalue("enable")) or 0
if enable ~= 0 and enable ~= 1 then
luci.http.write_json({code = 1, message = "Invalid enable value, must be 0 or 1"})
return
end
local req_obj = {}
req_obj.api = "set_mac_filter_adv"
req_obj.data = {
enable = enable
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 then
llog("Set macfilter enable: " .. enable)
luci.http.write_json({code = 0, message = "Saved successfully"})
else
luci.http.write_json({code = 1, message = "Failed to save"})
end
end
+452
View File
@@ -0,0 +1,452 @@
module("luci.controller.oaf_user", package.seeall)
local utl = require "luci.util"
function index()
local page
entry({"admin", "services", "oaf", "users"}, alias("admin", "services", "oaf", "users", "list"), _("User List"), 20).dependent = true
entry({"admin", "services", "oaf", "users", "list"}, cbi("oaf/user_list", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}),
nil).leaf = true
entry({"admin", "services", "oaf", "users", "detail"}, cbi("oaf/user_detail", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), nil).leaf = true
entry({"admin", "services", "oaf", "api", "user_status"}, call("user_status"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "dev_visit_list"}, call("get_dev_visit_list"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "dev_visit_time"}, call("get_dev_visit_time"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "app_class_visit_time"}, call("get_app_class_visit_time"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "get_class_list"}, call("get_class_list"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "get_all_users"}, call("get_all_users"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "get_system_base_info"}, call("get_system_base_info"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "get_mac_blacklist"}, call("get_mac_blacklist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "add_mac_blacklist"}, call("add_mac_blacklist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "del_mac_blacklist"}, call("del_mac_blacklist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "get_parental_control_detail"}, call("get_parental_control_detail"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "set_nickname"}, call("set_nickname"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "get_hourly_stats"}, call("get_hourly_stats"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "get_user_basic_info"}, call("get_user_basic_info"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "get_online_offline_records"}, call("get_online_offline_records"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "get_user_parental_control_rules"}, call("get_user_parental_control_rules"), nil).leaf = true
end
function get_hostname_by_mac(dst_mac)
leasefile="/tmp/dhcp.leases"
local fd = io.open(leasefile, "r")
if not fd then return end
while true do
local ln = fd:read("*l")
if not ln then
break
end
local ts, mac, ip, name, duid = ln:match("^(%d+) (%S+) (%S+) (%S+) (%S+)")
if dst_mac == mac then
fd:close()
return name
end
end
fd:close()
return ""
end
function get_app_name_by_id(appid)
local class_fd = io.popen("find /tmp/appfilter/ -type f -name *.class |xargs cat |grep "..appid.."|awk '{print $2}'")
if class_fd then
local name = class_fd:read("*l")
class_fd:close()
if name and name ~= "" then
return name
end
end
if tonumber(appid) and tonumber(appid) > 0 then
return "App" .. tostring(appid)
end
return ""
end
function cmp_func(a,b)
return a.latest_time > b.latest_time
end
function normalize_mac(mac)
if not mac then
return ""
end
return tostring(mac):gsub("^%s+", ""):gsub("%s+$", ""):upper()
end
function call_fwx_common(api, data)
local req_obj = {}
req_obj.api = api
req_obj.data = data or {}
return utl.ubus("fwx", "common", req_obj)
end
function user_status()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local fd = io.open("/proc/net/af_client","r")
status_buf=fd:read('*a')
fd:close()
user_array=json.parse(status_buf)
local req_obj = {}
req_obj.api = "visit_list"
req_obj.data = {}
local visit_obj = utl.ubus("fwx", "common", req_obj)
local user_array = {}
if visit_obj and visit_obj.code == 2000 and visit_obj.data and visit_obj.data.dev_list then
user_array = visit_obj.data.dev_list
end
local history={}
for i, v in pairs(user_array) do
visit_array=user_array[i].visit_info
for j,s in pairs(visit_array) do
print(user_array[i].mac, user_array[i].ip,visit_array[j].appid, visit_array[j].latest_time)
total_time=visit_array[j].latest_time - visit_array[j].first_time;
history[#history+1]={
mac=user_array[i].mac,
ip=user_array[i].ip,
hostname=get_hostname_by_mac(user_array[i].mac),
appid=visit_array[j].appid,
appname=get_app_name_by_id(visit_array[j].appid),
total_num=0,
drop_num=0,
latest_action=visit_array[j].latest_action,
latest_time=os.date("%Y/%m/%d %H:%M:%S", visit_array[j].latest_time),
first_time=os.date("%Y/%m/%d %H:%M:%S", visit_array[j].first_time),
total_time=total_time
}
end
end
table.sort(history, cmp_func)
luci.http.write_json(history);
end
function get_class_list()
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.CopyRight = "www.fanchmwrt.com"
req_obj.api = "class_list"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({class_list = {}})
end
end
function get_all_users()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_all_users"
req_obj.data = {
flag = luci.http.formvalue("flag"),
page = luci.http.formvalue("page"),
page_size = luci.http.formvalue("page_size")
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json({data = resp_obj.data})
else
luci.http.write_json({data = resp_obj or {}})
end
end
function get_system_base_info()
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_system_base_info"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({
user_session_enable = 0
})
end
end
function get_mac_blacklist()
luci.http.prepare_content("application/json")
local resp_obj = call_fwx_common("get_mac_blacklist", {})
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json({code = 0, data = resp_obj.data, message = "success"})
else
luci.http.write_json({code = 1, data = {list = {}}, message = "failed"})
end
end
function add_mac_blacklist()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local mac_list = {}
local mac = normalize_mac(luci.http.formvalue("mac"))
if mac ~= "" then
table.insert(mac_list, mac)
else
local data_str = luci.http.formvalue("data")
if data_str and data_str ~= "" then
local data = json.parse(data_str)
if data and type(data.mac_list) == "table" then
for _, item in ipairs(data.mac_list) do
local normalized_mac = normalize_mac(item)
if normalized_mac ~= "" then
table.insert(mac_list, normalized_mac)
end
end
end
end
end
if #mac_list == 0 then
luci.http.write_json({code = 1, message = "Invalid mac"})
return
end
local resp_obj = call_fwx_common("add_mac_blacklist", {mac_list = mac_list})
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 0, message = "success"})
else
luci.http.write_json({code = 1, message = "failed"})
end
end
function del_mac_blacklist()
luci.http.prepare_content("application/json")
local mac = normalize_mac(luci.http.formvalue("mac"))
if mac == "" then
luci.http.write_json({code = 1, message = "Invalid mac"})
return
end
local resp_obj = call_fwx_common("del_mac_blacklist", {mac = mac})
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json({code = 0, message = "success"})
else
luci.http.write_json({code = 1, message = "failed"})
end
end
function get_parental_control_detail()
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_parental_control_detail"
req_obj.data = {
mac = luci.http.formvalue("mac")
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({
pc_status = "unlimited",
pc_status_key = "unlimited",
af_whitelist = 0,
mf_whitelist = 0,
appfilter_rules = {},
macfilter_rules = {}
})
end
end
function get_user_parental_control_rules()
luci.http.prepare_content("application/json")
local target_mac = normalize_mac(luci.http.formvalue("mac"))
if target_mac == "" then
luci.http.write_json({
mac = "",
af_whitelist = 0,
mf_whitelist = 0,
list = {}
})
return
end
local req_obj = {
api = "get_user_parental_control_rules",
data = {
mac = target_mac
}
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and type(resp_obj.data) == "table" then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({
mac = target_mac,
af_whitelist = 0,
mf_whitelist = 0,
list = {}
})
end
end
function get_oaf_status()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_oaf_status"
req_obj.data = {}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json(resp_obj or {})
end
end
function set_nickname()
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "set_nickname"
req_obj.data = {
mac = luci.http.formvalue("mac"),
nickname = luci.http.formvalue("nickname")
}
local resp_obj = utl.ubus("fwx", "common", req_obj);
if resp_obj and resp_obj.code == 2000 then
luci.http.write_json(resp_obj.data or {})
else
luci.http.write_json(resp_obj or {})
end
end
function get_dev_visit_time(mac)
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "dev_visit_time"
req_obj.data = {
mac = mac
}
local visit_obj = utl.ubus("fwx", "common", req_obj)
local visit_list = {}
if visit_obj and visit_obj.code == 2000 and visit_obj.data and visit_obj.data.list then
visit_list = visit_obj.data.list
end
luci.http.write_json(visit_list)
end
function get_app_class_visit_time(mac)
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "app_class_visit_time"
req_obj.data = {
mac = mac
}
local visit_obj = utl.ubus("fwx", "common", req_obj)
local class_array = {}
if visit_obj and visit_obj.code == 2000 and visit_obj.data and visit_obj.data.class_list then
class_array = visit_obj.data.class_list
end
luci.http.write_json(class_array)
end
function get_dev_visit_list(mac)
local json = require "luci.jsonc"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "dev_visit_list"
req_obj.data = {
mac = mac
}
local page = luci.http.formvalue("page")
local page_size = luci.http.formvalue("page_size")
if page then
req_obj.data.page = tonumber(page) or 1
end
if page_size then
req_obj.data.page_size = tonumber(page_size) or 15
end
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json(resp_obj or {})
end
end
function get_hourly_stats(mac)
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_hourly_top_apps"
req_obj.data = {
mac = mac
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({
mac = mac,
date = 0,
is_today = 1,
hourly_stats = {}
})
end
end
function get_user_basic_info(mac)
local json = require "luci.jsonc"
local utl = require "luci.util"
luci.http.prepare_content("application/json")
local req_obj = {}
req_obj.api = "get_user_basic_info"
req_obj.data = {
mac = mac
}
local resp_obj = utl.ubus("fwx", "common", req_obj)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
luci.http.write_json(resp_obj.data)
else
luci.http.write_json({})
end
end
@@ -0,0 +1,181 @@
module("luci.controller.oaf_whitelist", package.seeall)
local nixio = require "nixio"
local util = require "luci.util"
local jsonc = require "luci.jsonc"
local function normalize_mac(mac)
return string.upper((mac or ""):gsub("^%s+", ""):gsub("%s+$", ""))
end
local function is_valid_mac(mac)
return mac and mac:match("^%x%x:%x%x:%x%x:%x%x:%x%x:%x%x$") ~= nil
end
local function write_json(data)
luci.http.prepare_content("application/json")
luci.http.write_json(data)
end
local function get_record_whitelist_page(page, page_size)
local req_obj = {
api = "get_record_whitelist",
data = {
page = page,
page_size = page_size
}
}
return util.ubus("fwx", "common", req_obj)
end
function index()
local has_app_filter = nixio.fs.access("/etc/config/appfilter")
local has_access_control = nixio.fs.access("/etc/config/macfilter")
local has_record = nixio.fs.access("/etc/config/fwx_record")
if not has_app_filter and not has_access_control and not has_record then
return
end
entry({"admin", "services", "oaf", "whitelist"}, cbi("oaf/whitelist", {hideapplybtn=true, hidesavebtn=true, hideresetbtn=true}), _("Whitelist"), 50).leaf = true
entry({"admin", "services", "oaf", "api", "record_whitelist", "get_record_whitelist"}, call("get_record_whitelist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "record_whitelist", "get_record_whitelist_all"}, call("get_record_whitelist_all"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "record_whitelist", "add_record_whitelist"}, call("add_record_whitelist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "record_whitelist", "del_record_whitelist"}, call("del_record_whitelist"), nil).leaf = true
entry({"admin", "services", "oaf", "api", "record_whitelist", "get_all_users"}, call("get_all_users"), nil).leaf = true
end
function get_record_whitelist()
local page = tonumber(luci.http.formvalue("page") or "1") or 1
local page_size = tonumber(luci.http.formvalue("page_size") or "200") or 200
if page < 1 then page = 1 end
if page_size < 1 then page_size = 200 end
if page_size > 200 then page_size = 200 end
local resp_obj = get_record_whitelist_page(page, page_size)
if resp_obj and resp_obj.code == 2000 and resp_obj.data then
write_json({code = 2000, data = resp_obj.data, message = "success"})
else
write_json({code = 2000, data = {total_num = 0, total_page = 1, page = page, page_size = page_size, list = {}}, message = "success"})
end
end
function get_record_whitelist_all()
local all_list = {}
local total_page = 1
local page = 1
local page_size = 200
while page <= total_page do
local resp_obj = get_record_whitelist_page(page, page_size)
if not (resp_obj and resp_obj.code == 2000 and resp_obj.data and type(resp_obj.data.list) == "table") then
break
end
local i
for i = 1, #resp_obj.data.list do
all_list[#all_list + 1] = resp_obj.data.list[i]
end
total_page = tonumber(resp_obj.data.total_page or 1) or 1
if total_page < 1 then total_page = 1 end
page = page + 1
end
write_json({code = 2000, data = {list = all_list}, message = "success"})
end
function add_record_whitelist()
local data_str = luci.http.formvalue("data")
local mac = normalize_mac(luci.http.formvalue("mac"))
local mac_list = {}
if data_str and data_str ~= "" then
local parsed = jsonc.parse(data_str)
if parsed and type(parsed.mac_list) == "table" then
local i
for i = 1, #parsed.mac_list do
local one_mac = normalize_mac(parsed.mac_list[i])
if is_valid_mac(one_mac) then
mac_list[#mac_list + 1] = one_mac
end
end
end
end
if #mac_list == 0 and is_valid_mac(mac) then
mac_list = {mac}
end
if #mac_list == 0 then
write_json({code = 1, message = "Invalid mac_list"})
return
end
local resp_obj = util.ubus("fwx", "common", {
api = "add_record_whitelist",
data = {mac_list = mac_list}
})
if resp_obj and resp_obj.code == 2000 then
write_json({code = 2000, message = "Whitelist added successfully"})
else
write_json({code = 1, message = "Failed to add whitelist"})
end
end
function del_record_whitelist()
local mac = normalize_mac(luci.http.formvalue("mac"))
if not is_valid_mac(mac) then
write_json({code = 1, message = "Invalid mac address"})
return
end
local resp_obj = util.ubus("fwx", "common", {
api = "del_record_whitelist",
data = {mac = mac}
})
if resp_obj and resp_obj.code == 2000 then
write_json({code = 2000, message = "Whitelist deleted successfully"})
else
write_json({code = 1, message = "Failed to delete whitelist"})
end
end
function get_all_users()
local all_list = {}
local total_page = 1
local page = 1
local page_size = 200
while page <= total_page do
local req_obj = {
api = "get_all_users",
data = {
flag = 2,
page = page,
page_size = page_size
}
}
local resp_obj = util.ubus("fwx", "common", req_obj)
if not (resp_obj and resp_obj.code == 2000 and resp_obj.data and type(resp_obj.data.list) == "table") then
break
end
local i
for i = 1, #resp_obj.data.list do
all_list[#all_list + 1] = resp_obj.data.list[i]
end
total_page = tonumber(resp_obj.data.total_page or 1) or 1
if total_page < 1 then total_page = 1 end
page = page + 1
end
write_json({code = 2000, data = {list = all_list}, message = "success"})
end
@@ -1,177 +0,0 @@
local nfs = require "nixio.fs"
local sys = require "luci.sys"
local SYS = require "luci.sys"
local http = luci.http
local m, s
m = Map("appfilter", translate("App Feature Library"), translate("The App feature library is used to describe the packet protocol of applications, including port, domain, and Layer7 payload. It is the core of the DPI engine and affects the effectiveness of OAF. You can also add or modify App features according to the official website tutorial."))
s = m:section(SimpleSection)
s.template = "admin_network/feature"
s.anonymous = true
local dir, fd
dir = "/tmp/upload/"
nixio.fs.mkdir(dir)
local STATUS_FILE = "/tmp/feature_upgrade.status"
local MAX_SIZE = 20 * 1024 * 1024
local function write_status(code)
local f = io.open(STATUS_FILE, "w+")
if f then
f:write(tostring(code))
f:close()
end
end
local function log(msg)
local f = io.open("/tmp/log/luci.log", "a+")
if f then
f:write(os.date("%Y-%m-%d %H:%M:%S") .. " [fwx_feature_upload] " .. tostring(msg) .. "\n")
f:close()
end
end
local function get_overlay_free_space()
local df_output = SYS.exec("df -k /overlay 2>/dev/null | tail -1 | awk '{print $4}'")
if df_output then
df_output = string.gsub(df_output, "%s+", "")
local free_kb = tonumber(df_output)
if free_kb then
return free_kb * 1024 -- 转换为字节
end
end
return 0
end
local function get_dir_size(dir_path)
local du_output = SYS.exec("du -sb " .. dir_path .. " 2>/dev/null | awk '{print $1}'")
if du_output then
du_output = string.gsub(du_output, "%s+", "")
local size_bytes = tonumber(du_output)
if size_bytes then
return size_bytes
end
end
return 0
end
http.setfilehandler(function(meta, chunk, eof)
local feature_file = "/etc/appfilter/feature.cfg"
local f_format = "v3.0"
local format = "v3.0"
if not fd then
if not meta then
return
end
if meta and chunk then
log("start upload filename=" .. (meta.file or ""))
fd = nixio.open(dir .. meta.file, "w")
write_status(1)
end
if not fd then
log("open file failed: " .. (dir .. (meta.file or "")))
write_status(401)
return
end
end
if chunk and fd then
fd:write(chunk)
end
if eof and fd then
fd:close()
log("upload finished, saved to " .. dir .. (meta.file or ""))
local meta_size = 0
do
local file_path = dir .. (meta.file or "")
local stat = nixio.fs.stat(file_path)
if stat and stat.size then
meta_size = stat.size
end
end
log("meta_size: " .. tostring(meta_size) .. ", MAX_SIZE: " .. tostring(MAX_SIZE))
if meta_size > MAX_SIZE then
log("file too large: " .. tostring(meta_size))
write_status(402)
os.execute("rm /tmp/upload/* -fr")
return
end
local tar_cmd = "tar -zxvf /tmp/upload/" .. meta.file .. " -C /tmp/upload/ >/dev/null"
local success = os.execute(tar_cmd)
if success ~= 0 then
log("tar extract failed: " .. tar_cmd)
write_status(401)
return
end
local feature_dir = "/tmp/upload/feature"
local fd2 = io.open("/tmp/upload/feature.cfg")
if not fd2 then
log("feature.cfg not found after extract")
write_status(401)
os.execute("rm /tmp/upload/* -fr")
return
end
local version_line = fd2:read("*l")
local format_line = fd2:read("*l")
fd2:close()
local ret = string.match(version_line, "#version")
if ret ~= nil then
if string.match(format_line, "#format") then
f_format = SYS.exec("echo '"..format_line.."'|awk '{print $2}'")
end
if not string.match(f_format, format) then
log("format mismatch: got " .. f_format .. ", expected " .. format)
write_status(401)
os.execute("rm /tmp/upload/* -fr")
return
end
local cmd = "cp /tmp/upload/feature.cfg " .. feature_file
os.execute(cmd)
local app_icons_src = "/tmp/upload/app_icons"
local app_icons_dst = "/www/luci-static/resources/app_icons"
if nixio.fs.stat(app_icons_src) then
local app_icons_size = get_dir_size(app_icons_src)
local overlay_free = get_overlay_free_space()
log("app_icons size: " .. tostring(app_icons_size) .. " bytes, overlay free: " .. tostring(overlay_free) .. " bytes")
if overlay_free >= app_icons_size then
log("overlay space sufficient, copying app_icons to /www")
os.execute("rm -rf " .. app_icons_dst .. "/*")
cmd = "cp -r " .. app_icons_src .. "/* " .. app_icons_dst .. "/ >/dev/null 2>&1"
os.execute(cmd)
log("app_icons copied to /www/luci-static/resources/app_icons")
else
log("overlay space insufficient (" .. tostring(overlay_free) .. " < " .. tostring(app_icons_size) .. "), skipping app_icons copy")
end
else
log("app_icons directory not found in upload package, skipping")
end
os.execute("chmod 666 " .. feature_file)
luci.sys.exec("killall -SIGUSR1 oafd")
log("feature updated successfully")
write_status(200)
else
log("missing #version marker")
write_status(401)
end
os.execute("rm /tmp/upload/* -fr")
end
end)
if luci.http.formvalue("upload") then
local f = luci.http.formvalue("ulfile")
if #f <= 0 then
end
elseif luci.http.formvalue("download") then
end
return m
@@ -1,49 +0,0 @@
local ds = require "luci.dispatcher"
local nxo = require "nixio"
local nfs = require "nixio.fs"
local ipc = require "luci.ip"
local sys = require "luci.sys"
local utl = require "luci.util"
local dsp = require "luci.dispatcher"
local uci = require "luci.model.uci"
local lng = require "luci.i18n"
local jsc = require "luci.jsonc"
local http = luci.http
local SYS = require "luci.sys"
local m, s
m = Map("appfilter", translate(""), translate(""))
s = m:section(TypedSection, "time", translate("Time Setting"),translate("The second time is optional, the end time must be greater than the start time"))
s.anonymous = true
o=s:option(ListValue, "time_mode", translate("Time Mode"),translate(""))
o.default=0
o:value(0,translate("Blacklist mode"))
o:value(1,translate("Whitelist mode"))
days = s:option(MultiValue, "days", "", translate(""))
days.widget = "checkbox"
days.size = 10
days:value("0", translate("Sun"));
days:value("1", translate("Mon"));
days:value("2", translate("Tue"));
days:value("3", translate("Wed"));
days:value("4", translate("Thur"));
days:value("5", translate("Fri"));
days:value("6", translate("Sat"));
hv = s:option(Value, "start_time", translate("Start Time1"),translate("xx:xx"))
hv.optional = false
hv = s:option(Value, "end_time", translate("End Time1"))
hv.optional = false
hv = s:option(Value, "start_time2", translate("Start Time2"))
hv.optional = false
hv = s:option(Value, "end_time2", translate("End Time2"))
hv.optional = false
return m
@@ -11,10 +11,10 @@ local jsc = require "luci.jsonc"
local m, s
arg[1] = arg[1] or ""
m = Map("appfilter", translate(""), translate(""))
m = Map("app_filter", translate(""), translate(""))
local v
v = m:section(SimpleSection)
v.template = "admin_network/app_filter"
v.template = "oaf/app_filter/rules"
return m
@@ -11,9 +11,9 @@ local jsc = require "luci.jsonc"
local m, s
arg[1] = arg[1] or ""
m = Map("appfilter", translate(""), translate(""))
m = Map("fwx_dashboard", translate(""), translate(""))
local v
v = m:section(SimpleSection)
v.template = "admin_network/time"
v.template = "oaf/dashboard"
return m
@@ -11,11 +11,10 @@ local jsc = require "luci.jsonc"
local m, s
arg[1] = arg[1] or ""
m = Map("appfilter", translate("Data Statistics") .. "(" .. arg[1] .. ")", translate(""))
m = Map("macfilter", translate(""), translate(""))
local v
v = m:section(SimpleSection)
v.template = "admin_network/dev_status"
v.mac = arg[1]
m.redirect = luci.dispatcher.build_url("admin", "services", "appfilter")
v.template = "oaf/mac_filter/mac_filter"
return m
@@ -11,9 +11,9 @@ local jsc = require "luci.jsonc"
local m, s
arg[1] = arg[1] or ""
m = Map("appfilter", translate(""), translate(""))
m = Map("macfilter", translate(""), translate(""))
local v
v = m:section(SimpleSection)
v.template = "admin_network/advance"
v.template = "oaf/mac_filter/rules"
return m
@@ -11,9 +11,9 @@ local jsc = require "luci.jsonc"
local m, s
arg[1] = arg[1] or ""
m = Map("appfilter", translate(""), translate(""))
m = Map("macfilter", translate(""), translate(""))
local v
v = m:section(SimpleSection)
v.template = "admin_network/user"
return m
v.template = "oaf/mac_filter/user"
return m
@@ -0,0 +1,9 @@
local ds = require "luci.dispatcher"
local m, s
m = Map("appfilter", translate(""), translate(""))
m:section(SimpleSection).template = "oaf/user_detail"
return m
@@ -14,6 +14,6 @@ local m, s
m = Map("appfilter", translate(""), translate(""))
m:section(SimpleSection).template = "admin_network/user_status"
m:section(SimpleSection).template = "oaf/user_status"
return m
@@ -0,0 +1,15 @@
local nfs = require "nixio.fs"
local config = "fwx_record"
if nfs.access("/etc/config/appfilter") then
config = "appfilter"
elseif nfs.access("/etc/config/macfilter") then
config = "macfilter"
end
local m = Map(config, translate(""), translate(""))
local s = m:section(SimpleSection)
s.template = "oaf/whitelist"
return m
@@ -1,321 +0,0 @@
<link rel="stylesheet" href="<%=resource%>/css/common.css">
<style>
/* 页面卡片边框 */
.cbi-section.cbi-tblsection {
border: 1px solid var(--border-color-low, #e0e0e0);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
margin-top: 20px;
padding: 4px;
}
.config-label {
font-weight: 500;
font-size: 14px;
width: 150px;
flex-shrink: 0;
}
.config-row {
margin-top: 14px;
display: flex;
align-items: center;
}
.config-row input[type="text"] {
padding: 6px 10px;
border: 1px solid var(--border-color-medium, #dadce0);
border-radius: 4px;
font-size: 13px;
width: 200px;
}
.desc {
margin-top: 8px;
max-width: 650px;
padding: 10px 14px;
border-radius: 6px;
border-left: 3px solid #1a73e8;
opacity: 0.75;
}
.desc span {
font-size: 13px;
line-height: 1.5;
display: block;
}
.submit-button {
padding: 8px 20px;
width: 150px;
height: auto;
margin-top: 0;
background-color: #1a73e8;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
line-height: 1.4;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s;
}
.submit-button:hover {
background-color: #1557b0;
}
.version-value {
font-size: 14px;
font-weight: 500;
}
.switch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #1a73e8;
}
input:checked + .slider:before {
transform: translateX(20px);
}
</style>
<script type="text/javascript">//<![CDATA[
function init_data() {
getAppFilterAdvData();
}
function showSuccessMessage(message = '<%:Settings saved successfully%>') {
const modal = document.getElementById('modal');
const messageElement = modal.querySelector('p');
messageElement.textContent = message;
modal.style.display = 'flex';
setTimeout(() => {
modal.style.display = 'none';
}, 1000);
}
function validateLanIfname(lanIfname) {
const regex = /^[a-zA-Z0-9.-]{2,8}$/;
return regex.test(lanIfname);
}
function submitAppFilterAdv() {
const lanIfname = document.getElementById('lan_ifname').value;
const autoLoadEngineSwitch = document.getElementById('autoLoadEngineSwitch').checked;
const disableHnatSwitch = document.getElementById('disableHnatSwitch').checked;
if (!validateLanIfname(lanIfname)) {
alert('<%:Invalid LAN interface name. Please ensure it is no more than 8 characters long and contains only letters, numbers, dot, and -.%>');
return;
}
new XHR().post('<%=url('admin/network/set_app_filter_adv')%>', {
lan_ifname: lanIfname,
auto_load_engine: autoLoadEngineSwitch ? 1 : 0,
disable_hnat: disableHnatSwitch ? 1 : 0,
},
function (x, data) {
showSuccessMessage();
});
}
function disableFlowOffloading() {
new XHR().post('<%=url('admin/network/disable_flow_offloading')%>', {},
function (x, data) {
showSuccessMessage('<%:关闭成功%>');
});
}
function clearActiveTime() {
if (!confirm('<%:Are you sure you want to clear active time of all users?%>')) {
return;
}
new XHR().post('<%=url('admin/network/cmd')%>', {
action: 'clear_active_time'
},
function (x, data) {
if (data && data.code === 0) {
showSuccessMessage('<%:Active time cleared successfully%>');
} else {
alert(data ? (data.message || '<%:Failed to clear active time%>') : '<%:Failed to clear active time%>');
}
});
}
function clearOfflineUsers() {
if (!confirm('<%:Are you sure you want to clear all offline users?%>')) {
return;
}
new XHR().post('<%=url('admin/network/cmd')%>', {
action: 'clear_offline_users'
},
function (x, data) {
if (data && data.code === 0) {
showSuccessMessage('<%:Offline users cleared successfully%>');
} else {
alert(data ? (data.message || '<%:Failed to clear offline users%>') : '<%:Failed to clear offline users%>');
}
});
}
function submitHandle() {
submitAppFilterAdv();
}
function openModal() {
document.getElementById('modal').style.display = 'block';
setTimeout(closeModal, 2000);
}
function closeModal() {
document.getElementById('modal').style.display = 'none';
}
function getAppFilterAdvData() {
console.log("getAppFilterAdvData");
new XHR().get('<%=url('admin/network/get_app_filter_adv')%>', null,
function (x, data) {
document.getElementById('lan_ifname').value = data.data.lan_ifname;
if (data.data.auto_load_engine == 1) {
document.getElementById('autoLoadEngineSwitch').checked = true;
} else {
document.getElementById('autoLoadEngineSwitch').checked = false;
}
if (data.data.disable_hnat == 1) {
document.getElementById('disableHnatSwitch').checked = true;
} else {
document.getElementById('disableHnatSwitch').checked = false;
}
}
);
new XHR().get('<%=url('admin/network/get_oaf_status')%>', null,
function (x, data) {
if (data && data.data) {
document.getElementById('oafVersion').textContent = data.data.version || '--';
document.getElementById('engineVersion').textContent = data.data.engine_version || '--';
}
}
);
}
window.onload = function() {
init_data();
};
//]]></script>
<div id="modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: transparent; z-index: 1000; justify-content: center; align-items: center;">
<div style="background-color: rgba(0, 0, 0, 0.65); padding: 14px 24px; border-radius: 6px; text-align: center; color: white; display: flex; justify-content: center; align-items: center;">
<p style="margin: 0; color: white; font-size: 14px;"><%:Settings saved successfully%></p>
</div>
</div>
<div class="cbi-section cbi-tblsection">
<div style="max-width: 1000px; margin-left: 10px; padding: 10px 15px;">
<div class="config-row">
<label class="config-label"><%:OAF Version%></label>
<span id="oafVersion" class="version-value">--</span>
</div>
<div class="config-row">
<label class="config-label"><%:OAF Driver Version%></label>
<span id="engineVersion" class="version-value">--</span>
</div>
<div class="config-row">
<label for="autoLoadEngineSwitch" class="config-label" style="cursor: pointer;"><%:Auto-load OAF driver%></label>
<label class="switch">
<input type="checkbox" id="autoLoadEngineSwitch" name="autoLoadEngineSwitch" />
<span class="slider"></span>
</label>
</div>
<div class="desc">
<span><%:If the OAF driver cannot be manually unloaded or the current driver is unstable, you can turn off auto-loading at startup and manually install a suitable driver. It is recommended to use the official stable OpenWrt firmware.%></span>
</div>
<div class="config-row">
<label for="disableHnatSwitch" class="config-label" style="cursor: pointer;"><%:Disable Hardware Acceleration%></label>
<label class="switch">
<input type="checkbox" id="disableHnatSwitch" name="disableHnatSwitch" />
<span class="slider"></span>
</label>
</div>
<div class="desc">
<span><%:Disable hardware acceleration (HNAT/ECM) and flow offloading to ensure application filtering works correctly. When enabled, this will automatically disable hardware acceleration and flow offloading features.%></span>
</div>
<div class="config-row">
<label for="lan_ifname" class="config-label"><%:LAN Interface%></label>
<input type="text" id="lan_ifname">
</div>
<div class="desc">
<span><%:The name of the LAN interface, used for detecting client info, supports fuzzy matching, but a complete interface name must be specified in bypass mode %></span>
</div>
<!--
<div class="config-row">
<label class="config-label"><%:Flow Offloading%></label>
<button type="button" class="submit-button" onclick="disableFlowOffloading()"><%:Disable Flow Offloading%></button>
</div>
<div class="desc">
<span><%:Flow offloading may affect filtering function. If the filtering feature does not work, you can disable flow offloading here and test again. Note: This only disables flow offloading temporarily. To permanently disable flow offloading, please turn it off in the firewall settings.%></span>
</div> -->
<div class="config-row">
<label class="config-label"><%:Clear Active Time%></label>
<button type="button" class="submit-button" onclick="clearActiveTime()" style="background-color: #ea4335;"><%:Clear Active Time%></button>
</div>
<div class="config-row">
<label class="config-label"><%:Clear Offline Users%></label>
<button type="button" class="submit-button" onclick="clearOfflineUsers()" style="background-color: #ea4335;"><%:Clear Offline Users%></button>
</div>
<div style="margin-top: 20px; display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" class="submit-button" onclick="location.reload()" style="background: transparent; color: #1a73e8; border: 1px solid var(--border-color-medium, #d1d5db);"><%:Refresh%></button>
<button type="button" class="submit-button" onclick="submitHandle()"><%:Save%></button>
</div>
</div>
</div>
File diff suppressed because it is too large. Load diff
@@ -1,394 +0,0 @@
<link rel="stylesheet" href="<%=resource%>/css/common.css">
<style type="text/css">
<% local dsp=require "luci.dispatcher"
-%>#display {
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 20px;
}
#main {
min-width: 600px;
height: 300px;
display: inline-block;
flex: 2 2 10%;
}
#main2 {
min-width: 600px;
height: 300px;
display: inline-block;
flex: 2 2 10%;
}
table.imagetable {
font-family: verdana, arial, sans-serif;
font-size: 11px;
color: #333333;
border-width: 1px;
border-color: #999999;
border-collapse: collapse;
padding-top: 10px;
}
< !-- table.imagetable th {
background: #f5f5f5 border-width: 0px;
padding: 5px;
border-style: solid;
border-color: #999999;
}
table.imagetable td {
background: #ffffffff border-width: 0px;
padding: 5px;
border-style: solid;
border-color: #999999;
}
-->
</style>
<script type="text/javascript" src="<%=resource%>/echarts.min.js?v=5.0"></script>
<script type="text/javascript">//<![CDATA[
window.onload = function () {
}
var app_class_data;
var app_time_data;
var mac = '<%=self.mac%>';
function get_display_time(total_time) {
var hour = parseInt(total_time / 3600);
var seconds = total_time % 3600;
var min = parseInt(seconds / 60)
var seconds2 = seconds % 60;
var total_time_str;
if (hour > 0)
total_time_str = hour + "<%:h%>" + min + "<%:m%>"
else {
if (min == 0 && seconds2 != 0)
min = 1;
total_time_str = min + "<%:m%>"
}
return total_time_str;
}
function display_app_visit_view(data) {
var myChart = echarts.init(document.getElementById('main2'));
var dev_array = new Array();
var m2R2Data = new Array()
var total_time = 0
for (var i = 0; i < data.length; i++) {
var dev_obj = data[i];
var m2_obj = {};
m2_obj.value = dev_obj.visit_time;
m2_obj.legendname = dev_obj.app_id;
var tmp_time = get_display_time(dev_obj.visit_time);
m2_obj.name = dev_obj.app_id + " " + tmp_time;
total_time += dev_obj.visit_time
m2R2Data.push(m2_obj);
}
var total_time_str = get_display_time(total_time);
option = {
title: [
{
text: "<%:App Time Statistics%>",
textStyle: {
fontSize: 16,
color: "black"
},
left: "2%"
},
{
text: '',
subtext: total_time_str,
textStyle: {
fontSize: 15,
color: "black"
},
subtextStyle: {
fontSize: 15,
color: 'black'
},
textAlign: "center",
x: '34.5%',
y: '44%',
}],
tooltip: {
trigger: 'item',
formatter: function (parms) {
var total_time = get_display_time(parms.data.value);
var str = parms.seriesName + "</br>" +
parms.marker + "" + parms.data.legendname + "</br>" +
"<%:Visit Time%>: " + total_time + "</br>" +
"<%:Percentage%>: " + parms.percent + "%";
return str;
}
},
legend: {
type: "scroll",
orient: 'vertical',
left: '70%',
align: 'left',
top: 'middle',
textStyle: {
color: '#8C8C8C'
},
height: 250
},
series: [
{
name: "<%:Visit Time%>",
type: 'pie',
center: ['35%', '50%'],
radius: ['58%', '70%'],
clockwise: false,
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 2,
borderColor: "#fff",
borderWidth: 1,
},
label: {
normal: {
show: true,
position: 'outter',
formatter: function (parms) {
return parms.data.legendname
}
}
},
labelLine: {
normal: {
length: 8,
length2: 7,
smooth: true,
}
},
data: m2R2Data
}
]
};
myChart.setOption(option);
}
function display_app_class_view(data) {
console.log("begin display.");
var myChart = echarts.init(document.getElementById('main'));
var dev_array = new Array();
var m2R2Data = new Array()
var total_time = 0
for (var i = 0; i < data.length; i++) {
var dev_obj = data[i];
if (dev_obj.visit_time == 0)
continue;
var m2_obj = {};
m2_obj.value = dev_obj.visit_time;
m2_obj.legendname = dev_obj.name;
var tmp_time = get_display_time(dev_obj.visit_time);
m2_obj.name = dev_obj.name + " " + tmp_time;
total_time += dev_obj.visit_time
m2R2Data.push(m2_obj);
}
var total_time_str = get_display_time(total_time);
option = {
title: [
{
text: "<%:App classification time statistics%>",
textStyle: {
fontSize: 16,
color: "black"
},
left: "2%"
},
{
text: '',
subtext: total_time_str,
textStyle: {
fontSize: 15,
color: "black"
},
subtextStyle: {
fontSize: 15,
color: 'black'
},
textAlign: "center",
x: '34.5%',
y: '44%',
}],
tooltip: {
trigger: 'item',
formatter: function (parms) {
var total_time = get_display_time(parms.data.value);
var str = parms.seriesName + "</br>" +
parms.marker + "" + parms.data.legendname + "</br>" + "<%:Visit Time%>: " + total_time + "</br>" +
"<%:Percentage%>: " + parms.percent + "%";
return str;
}
},
legend: {
type: "scroll",
orient: 'vertical',
left: '70%',
align: 'left',
top: 'middle',
textStyle: {
color: '#8C8C8C'
},
height: 250
},
series: [
{
name: "<%:Visit Time%>",
type: 'pie',
center: ['35%', '50%'],
radius: ['58%', '70%'],
clockwise: false,
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 2,
borderColor: "#fff",
borderWidth: 1,
},
label: {
normal: {
show: true,
position: 'outter',
formatter: function (parms) {
return parms.data.legendname
}
}
},
labelLine: {
normal: {
length: 8,
length2: 7,
smooth: true,
}
},
data: m2R2Data
}
]
};
myChart.setOption(option);
}
new XHR().get('<%=url('admin/network/app_class_visit_time')%>/' + mac, null,
function (x, st) {
display_app_class_view(st);
}
);
new XHR().get('<%=url('admin/network/dev_visit_time')%>/' + mac, null,
function (x, st) {
display_app_visit_view(st);
}
);
new XHR().get('<%=url('admin/network/dev_visit_list')%>/' + mac, null,
function (x, st) {
var tb = document.getElementById('user_status_table');
var str = JSON.stringify(st);
if (st && tb) {
/* clear all rows */
while (tb.rows.length > 1)
tb.deleteRow(1);
for (var i = 0; i < st.length; i++) {
var action_status = ""
if (st[i].latest_action == 1)
action_status = "<%:Filtered%>"
else
action_status = "<%:Unfiltered%>"
var hostname = ""
if (st[i].hostname == "" || st[i].hostname == "*") {
hostname = "--";
}
else {
hostname = st[i].hostname;
}
var tr = tb.insertRow(-1);
tr.className = 'tr cbi-rowstyle-' + ((i % 2) + 1);
tr.insertCell(-1).innerHTML = st[i].appname;
tr.insertCell(-1).innerHTML = hostname;
tr.insertCell(-1).innerHTML = st[i].mac;
tr.insertCell(-1).innerHTML = st[i].first_time;
var hour = parseInt(st[i].total_time / 3600);
var seconds = st[i].total_time % 3600;
var min = parseInt(seconds / 60)
var total_time_str;
if (st[i].latest_action == 1)
total_time_str = "-"
else {
if (hour > 0)
total_time_str = hour + "<%:h%>" + min + "<%:m%>"
else {
if (min == 0)
min = 1;
total_time_str = min + "<%:m%>"
}
}
tr.insertCell(-1).innerHTML = total_time_str;
tr.insertCell(-1).innerHTML = action_status;
var childs = tr.childNodes;
Array.prototype.forEach.call(childs, function (child) {
child.className = 'td';
});
}
}
}
);
//]]></script>
<div class="cbi-section cbi-tblsection">
<div id="display">
<div id="main" class="main left"></div>
<div id="main2" class="main2 left"></div>
</div>
<table class="table cbi-section-table" id="user_status_table">
<tr class="tr table-titles">
<th class="th">
<%:App Name%>
</th>
<th class="th">
<%:Hostname%>
</th>
<th class="th">
<%:Mac%>
</th>
<th class="th">
<%:Start Time%>
</th>
<th class="th">
<%:Visit Time%>
</th>
<th class="th">
<%:Filter Status%>
</th>
</tr>
<tr class="tr table-titles">
<td class="td" colspan="8"><em><br />
<%:Collecting data...%>
</em></td>
</tr>
</table>
</div>
@@ -1,347 +0,0 @@
<link rel="stylesheet" href="<%=resource%>/css/common.css">
<div class="feature-container">
<!-- 基本信息卡片 -->
<div id="feature-info" class="feature-info-card">
<div class="feature-info-grid">
<div class="feature-info-item">
<span class="feature-info-label"><%:Current version%></span>
<span class="feature-info-value" id="info-version">--</span>
</div>
<div class="feature-info-item">
<span class="feature-info-label"><%:Feature format%></span>
<span class="feature-info-value" id="info-format">--</span>
</div>
<div class="feature-info-item">
<span class="feature-info-label"><%:App number%></span>
<span class="feature-info-value" id="info-appnum">--</span>
</div>
<div class="feature-info-item">
<span class="feature-info-label"><%:Feature download%></span>
<span class="feature-info-value"><a href="https://www.openappfilter.com" target="_blank">www.openappfilter.com</a></span>
</div>
</div>
<div class="feature-card">
<div class="feature-row">
<label class="feature-label" for="ulfile"><%:Feature Upgrade%>:</label>
<div class="feature-inputs">
<input class="cbi-input-file feature-file" type="file" id="ulfile" name="ulfile" />
<input type="submit" class="cbi-button cbi-input-apply feature-btn" name="upload" value="<%:Upload%>" onclick="startUpgradeStatusPoll(); return true;"/>
</div>
</div>
<div class="feature-hint" id="upload-hint">
<%:You can download the App feature library from the website(www.openappfilter.com),then upload it here.Please note that after downloading, you need to extract the files and select the one in .bin format.%>
</div>
<div class="feature-status" id="upgrade-status" style="display:none;"></div>
</div>
<div class="feature-card" style="margin-top: 16px;">
<div class="feature-row" style="margin-bottom: 8px;">
<label class="feature-label"><%:App List%>:</label>
</div>
<div id="app-list" class="app-list"></div>
</div>
<div style="margin-top: 20px; display: flex; justify-content: flex-end;">
<button type="button" class="submit-button" onclick="location.reload()" style="padding: 8px 20px; width: 150px; background: transparent; color: #1a73e8; border: 1px solid var(--border-color-medium, #d1d5db); border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; display: inline-flex; align-items: center; justify-content: center; line-height: 1.4;"><%:Refresh%></button>
</div>
</div>
<div id="modal" class="feature-modal">
<div class="feature-modal-content">
<p><%:Updating, please wait...%></p>
</div>
</div>
<style>
/* 让顶部描述和下方内容左侧对齐 */
.cbi-map-descr {
margin: 0 10px 10px 10px;
font-size: 13px;
line-height: 1.6;
}
.feature-container { padding: 10px; }
.feature-info-card{padding:16px;border:1px solid var(--border-color-low, #e5e7eb);border-radius:8px;background:var(--background-color-high, #ffffff);margin-bottom:12px;}
.feature-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;}
.feature-info-item{padding:10px 12px;border:1px solid var(--border-color-low, #e5e7eb);border-radius:6px;background:var(--background-color-low, #f5f5f5);}
.feature-info-label{display:block;font-size:13px;color:var(--text-color-medium, #6b7280);margin-bottom:4px;}
.feature-info-value{font-weight:600;font-size:15px;color:var(--text-color-high, #333);}
.feature-card {background: var(--background-color-high, #ffffff);border: 1px solid var(--border-color-low, #e5e7eb);border-radius: 8px;padding: 16px;margin-top: 12px;}
.feature-row {display: flex;align-items: center;gap: 12px;flex-wrap: wrap;}
.feature-label {min-width: 120px;font-weight: 600;}
.feature-inputs {display: flex;align-items: center;gap: 10px;flex: 1;}
.feature-file {width: 100%;max-width: 360px;}
.feature-btn {min-width: 110px;}
.feature-hint {margin-top: 10px;color: var(--text-color-medium, #6b7280);font-size: 13px;line-height: 1.5;}
.feature-status {margin-top: 8px;font-size: 14px;font-weight: 600;}
.feature-status.success {color: #34d399;}
.feature-status.error {color: #f87171;}
.feature-modal {display: none;position: fixed;top: 0; left: 0; right: 0; bottom: 0;background-color: rgba(0, 0, 0, 0.5);z-index: 1000;justify-content: center;align-items: center;}
.feature-modal-content {background: var(--background-color-high, #ffffff);padding: 16px 20px;border-radius: 6px;color: var(--text-color-high, #333);min-width: 200px;text-align: center;box-shadow: 0 4px 12px rgba(0,0,0,0.4);border: 1px solid var(--border-color-low, #e5e7eb);}
.app-list {margin-top: 10px;}
.class-card {margin-bottom: 16px;border:1px solid var(--border-color-low, #e5e7eb);border-radius:8px;background:var(--background-color-high, #ffffff);}
.class-header {padding:12px 16px;font-weight:600;font-size:15px;border-bottom:1px solid var(--border-color-low, #e5e7eb);color:var(--text-color-high, #333);}
.app-items {display:grid;grid-template-columns:repeat(4,1fr);gap:10px;padding:12px 16px;}
.app-item {display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--border-color-medium, #d1d5db);border-radius:6px;background:var(--background-color-low, #f5f5f5);font-size:13px;cursor:default;transition:all 0.2s ease;}
.app-item:hover {background:var(--background-color-medium, #eeeeee);border-color:var(--border-color-high, #9ca3af);transform:translateY(-1px);}
.app-item img {width:20px;height:20px;object-fit:contain;border-radius:4px;}
.app-item span {color:var(--text-color-medium, #6b7280);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100px;}
@media (min-width: 800px) {
.app-items {grid-template-columns:repeat(6,1fr);}
}
@media (min-width: 1200px) {
.app-items {grid-template-columns:repeat(8,1fr);}
}
</style>
<script>
var pollTimer = null;
var pollTimeout = null;
var classListLoaded = false;
// Helper functions
function $(selector) {
return document.querySelector(selector);
}
function $$(selector) {
return document.querySelectorAll(selector);
}
function showModal() {
var modal = $('#modal');
if (modal) {
modal.style.display = 'flex';
setTimeout(function() {
modal.style.display = 'none';
}, 15000);
}
}
function hideModal() {
var modal = $('#modal');
if (modal) {
modal.style.display = 'none';
}
}
function showToast(msg, type) {
var hint = $('#upload-hint');
if (!hint) return;
hint.textContent = msg || '';
hint.classList.remove('text-error', 'text-success');
if (type === 'error') {
hint.classList.add('text-error');
} else if (type === 'success') {
hint.classList.add('text-success');
}
}
function stopPoll() {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
if (pollTimeout) {
clearTimeout(pollTimeout);
pollTimeout = null;
}
}
function statusMessage(status) {
if (status === 200) return '<%:Update the feature file successfully%>';
if (status === 401) return '<%:Failed to update feature file, format error%>';
if (status === 402) return '<%:Failed to update feature file%> (<%:File too large%>)';
if (status === 1) return '<%:Updating, please wait...%>';
return '<%:Failed to update feature file%>';
}
function renderUpgradeStatus(status) {
var box = $('#upgrade-status');
if (!box) return;
if (!status || status === 0) {
box.style.display = 'none';
box.textContent = '';
box.classList.remove('success', 'error');
return;
}
var msg = statusMessage(status);
box.textContent = msg;
box.style.display = '';
box.classList.remove('success', 'error');
if (status === 200) {
box.classList.add('success');
} else {
box.classList.add('error');
}
}
function fetchUpgradeStatusOnce() {
fetch('<%=url("admin/appfilter/feature/upgrade_status")%>')
.then(function(response) {
return response.json();
})
.then(function(resp) {
var status = (resp && resp.status) ? resp.status : 0;
renderUpgradeStatus(status);
})
.catch(function(error) {
console.error('Failed to fetch upgrade status:', error);
});
}
function startUpgradeStatusPoll() {
showModal();
pollTimeout = setTimeout(function() {
stopPoll();
hideModal();
}, 60000);
}
function loadFeatureInfo() {
new XHR().get('<%=url("admin/appfilter/feature/info")%>', null, function(x, data) {
if (data && data.code === 0 && data.data) {
var versionEl = $('#info-version');
var formatEl = $('#info-format');
var appnumEl = $('#info-appnum');
if (versionEl) versionEl.textContent = data.data.version || '--';
if (formatEl) formatEl.textContent = data.data.format || '--';
if (appnumEl) appnumEl.textContent = data.data.app_count || 0;
}
});
}
function loadClassList() {
fetch('<%=url("admin/network/class_list")%>')
.then(function(response) {
return response.json();
})
.then(function(data) {
var list = [];
if (data && data.class_list) {
list = data.class_list;
} else if (data && data.data && Array.isArray(data.data)) {
list = data.data;
} else if (data && data.classes && Array.isArray(data.classes)) {
list = data.classes;
}
renderClassList(list);
classListLoaded = true;
})
.catch(function(error) {
console.error('Failed to load class list:', error);
var appList = $('#app-list');
if (appList) {
appList.innerHTML = '<div class="feature-hint"><%:Failed to load app list%></div>';
}
});
}
function updateAppItemsColumns() {
var container = $('.feature-container');
var containerWidth = container ? container.offsetWidth : window.innerWidth;
var appItems = $$('.app-items');
var cols = 4;
if (containerWidth >= 1200) {
cols = 8;
} else if (containerWidth >= 800) {
cols = 6;
}
appItems.forEach(function(item) {
item.style.gridTemplateColumns = 'repeat(' + cols + ', 1fr)';
});
}
function renderClassList(list) {
var container = $('#app-list');
if (!container) return;
container.innerHTML = '';
if (!list || list.length === 0) {
var noDataDiv = document.createElement('div');
noDataDiv.className = 'feature-hint';
noDataDiv.textContent = '<%:No data%>';
container.appendChild(noDataDiv);
return;
}
list.forEach(function(category) {
var appList = category.app_list || [];
var card = document.createElement('div');
card.className = 'class-card';
var categoryName = category.name || ('Class ' + (category.id || ''));
var appCount = appList.length;
var header = document.createElement('div');
header.className = 'class-header';
header.textContent = categoryName + ' (' + appCount + ')';
card.appendChild(header);
var apps = document.createElement('div');
apps.className = 'app-items';
if (appList.length === 0) {
var noAppsDiv = document.createElement('div');
noAppsDiv.className = 'feature-hint';
noAppsDiv.textContent = '<%:No apps%>';
apps.appendChild(noAppsDiv);
} else {
appList.forEach(function(app) {
var parts = app.split(',');
var appId = parts[0] || '';
var appName = parts[1] || 'Unknown';
var hasIcon = parts[2] === '1';
var appItem = document.createElement('div');
appItem.className = 'app-item';
var iconSrc = hasIcon ? '<%=resource%>/app_icons/' + appId + '.png' : '<%=resource%>/app_icons/default.png';
var icon = document.createElement('img');
icon.src = iconSrc;
icon.alt = appName;
icon.onerror = function() {
this.src = '<%=resource%>/app_icons/default.png';
};
var name = document.createElement('span');
name.textContent = appName;
appItem.appendChild(icon);
appItem.appendChild(name);
apps.appendChild(appItem);
});
}
card.appendChild(apps);
container.appendChild(card);
});
setTimeout(function() {
updateAppItemsColumns();
}, 100);
}
document.addEventListener('DOMContentLoaded', function() {
fetchUpgradeStatusOnce();
loadFeatureInfo();
loadClassList();
var resizeTimer;
window.addEventListener('resize', function() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function() {
updateAppItemsColumns();
}, 200);
});
});
</script>
File diff suppressed because it is too large. Load diff
@@ -1,768 +0,0 @@
<link rel="stylesheet" href="<%=resource%>/css/common.css">
<style>
/* 固定按钮样式,不受主题影响 - 蓝色背景,白色文字 */
.cbi-button.cbi-button-add {
background-color: #1a73e8 !important;
color: #ffffff !important;
border: none !important;
padding: 6px 12px !important;
border-radius: 4px !important;
cursor: pointer !important;
font-size: 13px !important;
font-weight: 500 !important;
transition: background-color 0.2s ease !important;
}
.cbi-button.cbi-button-add:hover {
background-color: #1557b0 !important;
color: #ffffff !important;
}
.cbi-button.cbi-button-add:active {
background-color: #0d47a1 !important;
color: #ffffff !important;
}
/* 页面卡片边框 */
.cbi-section.cbi-tblsection {
border: 1px solid var(--border-color-low, #e5e7eb);
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
margin-top: 20px;
padding: 4px;
overflow-x: auto;
}
.cbi-section.cbi-tblsection > div {
min-width: 600px;
}
/* 确保表格在手机端也保持正常表格布局 */
.cbi-section-table .tr {
display: table-row !important;
}
.cbi-section-table .th,
.cbi-section-table .td {
display: table-cell !important;
}
.cbi-section-table thead {
display: table-header-group !important;
}
.cbi-section-table tbody {
display: table-row-group !important;
}
/* Table styles - 表格美化 */
.cbi-section-table {
width: 100%;
border-collapse: collapse;
margin: 15px 0;
border: none;
display: table;
}
.cbi-section-table th, .cbi-section-table td {
padding: 10px 16px;
text-align: left;
border-bottom: 1px solid var(--border-color-low, #e5e7eb);
}
.cbi-section-table th {
background: var(--background-color-low, #f5f5f5);
color: var(--text-color-medium, #6b7280);
font-weight: 600;
font-size: 12.5px;
letter-spacing: 0.2px;
border-bottom: 1px solid var(--border-color-medium, #d1d5db);
white-space: nowrap;
}
.cbi-section-table tr:hover td {
background-color: var(--background-color-low, #f5f5f5);
}
.cbi-section-table tr:last-child td {
border-bottom: none;
}
/* 单元格内容样式 */
.cbi-section-table td {
font-size: 13px;
vertical-align: middle;
line-height: 1.5;
}
.mode-selection {
margin-bottom: 20px;
margin-top: 10px;
display: flex !important;
align-items: center !important;
gap: 16px;
}
.mode-selection label {
display: inline-flex !important;
align-items: center !important;
gap: 6px;
cursor: pointer;
font-size: 14px;
line-height: 1.4;
margin: 0 !important;
padding: 0 !important;
}
.mode-selection label input[type="radio"] {
margin: 0 !important;
vertical-align: middle !important;
flex-shrink: 0;
position: relative !important;
top: 0 !important;
transform: none !important;
}
.mode-selection label span,
.mode-selection label {
vertical-align: middle !important;
}
/* 按钮样式优化 */
.delete-button {
padding: 5px 12px;
color: #d93025;
border: 1px solid var(--border-color-medium, #d1d5db);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: all 0.2s;
background: transparent;
}
.delete-button:hover {
background-color: #fce8e6;
border-color: #d93025;
color: #c5221f;
}
/* 表格内操作按钮 */
.cbi-section-table .cbi-button,
.cbi-section-table .cbi-button-add {
padding: 5px 12px;
font-size: 12px;
border-radius: 4px;
border: 1px solid var(--border-color-medium, #d1d5db);
background: transparent;
color: #1a73e8;
cursor: pointer;
transition: all 0.2s;
font-weight: 500;
}
.cbi-section-table .cbi-button:hover,
.cbi-section-table .cbi-button-add:hover {
background-color: #e8f0fe;
border-color: #1a73e8;
}
/* 独立的添加按钮 - 蓝底白字 */
.cbi-section-create .cbi-button-add,
.cbi-section-create input.cbi-button-add {
padding: 8px 16px !important;
background-color: #1a73e8 !important;
color: white !important;
border: none !important;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
}
.cbi-section-create .cbi-button-add:hover,
.cbi-section-create input.cbi-button-add:hover {
background-color: #1557b0 !important;
}
/* Modal styles - 与 user_status 完全一致 */
.oaf-modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(3px);
z-index: 1000;
justify-content: center;
align-items: center;
}
.oaf-modal-box {
background-color: var(--background-color-high, #fff);
padding: 28px;
border-radius: 12px;
text-align: left;
display: flex;
flex-direction: column;
position: relative;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
width: 450px;
max-width: 90%;
}
.user-selection {
max-height: 300px;
overflow-y: auto;
margin: 15px 0;
border: 1px solid var(--border-color-low, #e5e7eb);
border-radius: 4px;
}
.user-item {
padding: 12px;
border-bottom: 1px solid var(--border-color-low, #e5e7eb);
display: flex;
align-items: center;
transition: background-color 0.2s;
}
.user-item:last-child {
border-bottom: none;
}
.user-item:hover {
background-color: var(--background-color-low, #f5f5f5);
}
h3 {
font-weight: 600;
font-size: 16px;
}
/* Keyframes */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from { transform: translateY(20px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
</style>
<script type="text/javascript">//<![CDATA[
let userData = {
users: [],
mode: 1
};
let whitelistData = {
users: []
};
let allUsers = [];
function initData() {
userData.list = [];
getAppFilterUserData();
getAllUsersData();
getWhitelistData();
}
function getAllUsersData() {
new XHR().get('<%=url('admin/network/get_all_users')%>', {flag: 2, page: 0},
function (x, data) {
if (Array.isArray(data.data.list))
allUsers = data.data.list;
renderAutoUserData();
}
);
}
function getAppFilterUserData() {
new XHR().get('<%=url('admin/network/get_app_filter_user')%>', null,
function (x, data) {
userData = data.data;
if (!Array.isArray(userData.list))
userData.list=[]
renderUserData();
render_mode_description(userData.mode);
}
);
}
function renderUserData() {
const table = document.getElementById('user_table');
let tbody = table.querySelector('tbody');
if (!tbody) {
tbody = document.createElement('tbody');
table.appendChild(tbody);
}
tbody.innerHTML = '';
const modeRadios = document.querySelectorAll('input[name="mode"]');
modeRadios.forEach(radio => {
radio.checked = parseInt(radio.value) === userData.mode;
});
if (userData.mode === 1) {
document.getElementById('user_table').style.display = 'table';
document.getElementById('auto_user_section').style.display = 'none';
document.getElementById('whitelist_section').style.display = 'none';
} else {
document.getElementById('user_table').style.display = 'none';
document.getElementById('auto_user_section').style.display = 'block';
document.getElementById('whitelist_section').style.display = 'block';
}
// Hide add button and actions column header in manual mode
const manualAddButton = document.getElementById('manualAddUserBtn');
const whitelistAddButton = document.getElementById('whitelistAddUserBtn');
const actionsHeader = document.querySelector('#user_table .th:last-child');
if (manualAddButton) {
manualAddButton.style.display = userData.mode === 1 ? 'inline-block' : 'none';
}
if (whitelistAddButton) {
whitelistAddButton.style.display = userData.mode === 0 ? 'inline-block' : 'none';
}
if (actionsHeader) {
actionsHeader.style.display = userData.mode === 1 ? '' : 'none';
}
const displayList = userData.mode === 1 ? userData.list : allUsers;
let html = '';
displayList.forEach((user, index) => {
const hostname = user.hostname || '--';
const nickname = user.nickname || '--';
html += `<tr class="tr">
<td class="td">${user.mac}</td>
<td class="td">${hostname}</td>
<td class="td">${nickname}</td>
<td class="td" style="${userData.mode === 1 ? '' : 'display: none;'}">
<div style="display: flex; white-space: nowrap;">
${userData.mode === 1 ? `<button type="button" class="delete-button" onclick="removeUser(${index})"><%:Delete%></button>` : ''}
</div>
</td>
</tr>`;
});
tbody.innerHTML = html;
}
function renderAutoUserData() {
const table = document.getElementById('auto_user_table');
let tbody = table.querySelector('tbody');
if (!tbody) {
tbody = document.createElement('tbody');
table.appendChild(tbody);
}
tbody.innerHTML = '';
// Get whitelist MAC addresses
const whitelistMacs = whitelistData.users.map(user => user.mac || user);
// Filter out users already in whitelist and users with is_whitelist flag
const displayList = allUsers.filter(user => {
return user.is_whitelist !== 1 && !whitelistMacs.includes(user.mac);
});
if (displayList.length === 0) {
document.getElementById('no_users_message').style.display = 'block';
} else {
document.getElementById('no_users_message').style.display = 'none';
let html = '';
displayList.forEach((user, index) => {
const hostname = user.hostname || '--';
const nickname = user.nickname || '--';
html += `<tr class="tr">
<td class="td">${user.mac}</td>
<td class="td">${hostname}</td>
<td class="td">${nickname}</td>
<td class="td">
<div style="display: flex; white-space: nowrap;">
<button type="button" class="cbi-button cbi-button-add" onclick="addUserToWhitelist('${user.mac}')"><%:Add to Whitelist%></button>
</div>
</td>
</tr>`;
});
tbody.innerHTML = html;
}
}
function addUserToWhitelist(mac) {
// Check if user is already in whitelist
const whitelistMacs = whitelistData.users.map(user => user.mac || user);
if (whitelistMacs.includes(mac)) {
showSuccessMessage('<%:User already in whitelist%>');
return;
}
const data = {
mac_list: [mac]
};
const data_str = JSON.stringify(data);
new XHR().post('<%=url('admin/network/add_whitelist_user')%>',
{ data: data_str },
function(x, data) {
setTimeout(() => {
getWhitelistData();
getAllUsersData();
renderAutoUserData();
}, 500);
showSuccessMessage('<%:Add to whitelist successful%>');
}
);
}
function showSuccessMessage(message = '<%:Settings saved successfully%>') {
const modal = document.getElementById('modal');
const messageElement = modal.querySelector('p');
messageElement.textContent = message;
modal.style.display = 'flex';
setTimeout(() => {
modal.style.display = 'none';
}, 1000);
}
function removeUser(index) {
const userToRemove = userData.list[index];
new XHR().post('<%=url('admin/network/del_app_filter_user')%>',
{ mac: userToRemove.mac },
function(x, data) {
getAppFilterUserData();
showSuccessMessage('<%:Delete successful%>');
}
);
}
function showUserModal() {
console.log("showUserModal");
const modal = document.getElementById('userModal');
modal.style.display = 'flex';
const userList = document.getElementById('userList');
userList.innerHTML = '';
allUsers.forEach(user => {
const userElement = document.createElement('div');
userElement.className = 'user-item';
// Determine the display name
const displayName = user.nickname || user.hostname || '';
userElement.innerHTML = `
<label style="display: flex; align-items: center;">
<input type="checkbox" value="${user.mac}" style="margin-right: 8px;">
<span>${user.mac}</span>
<span style="opacity: 0.6; margin-left: 8px;">${displayName ? `(${displayName})` : ''}</span>
</label>
`;
userList.appendChild(userElement);
});
}
function closeUserModal() {
const modal = document.getElementById('userModal');
modal.style.display = 'none';
}
function addSelectedUsers() {
const userList = document.getElementById('userList');
const checkboxes = userList.querySelectorAll('input[type="checkbox"]:checked');
const selectedMacs = Array.from(checkboxes).map(checkbox => checkbox.value);
var data = {
mac_list: selectedMacs
}
var data_str = JSON.stringify(data);
console.log(selectedMacs);
new XHR().post('<%=url('admin/network/add_app_filter_user')%>',
{ data: data_str },
function(x, data) {
getAppFilterUserData();
closeUserModal();
showSuccessMessage('<%:Add successful%>');
}
);
}
function render_mode_description(newMode) {
const modeDescription = document.getElementById('modeDescription');
if (newMode === 0) {
modeDescription.textContent = '<%:In automatic mode, all newly joined terminals will be controlled, and random MAC addresses on mobile phones are also applicable%>';
} else {
modeDescription.textContent = '<%:In manual mode, only specified terminals are controlled%>';
}
}
function switchMode(newMode) {
userData.mode = newMode;
renderUserData();
render_mode_description(newMode);
new XHR().post('<%=url('admin/network/set_app_filter_user')%>',
{ mode: newMode },
function(x, data) {
console.log('Mode updated successfully');
showSuccessMessage('<%:Mode switch successful%>');
});
}
function getWhitelistData() {
new XHR().get('<%=url('admin/network/get_whitelist_user')%>', null,
function (x, data) {
if (data.data && Array.isArray(data.data.list)) {
whitelistData.users = data.data.list;
} else {
whitelistData.users = [];
}
renderWhitelistData();
}
);
}
function renderWhitelistData() {
const table = document.getElementById('whitelist_table');
if (!table) return;
let tbody = table.querySelector('tbody');
if (!tbody) {
tbody = document.createElement('tbody');
table.appendChild(tbody);
}
tbody.innerHTML = '';
let html = '';
whitelistData.users.forEach((user, index) => {
const hostname = user.hostname || '--';
const nickname = user.nickname || '--';
html += `<tr class="tr">
<td class="td">${user.mac}</td>
<td class="td">${hostname}</td>
<td class="td">${nickname}</td>
<td class="td">
<div style="display: flex; white-space: nowrap;">
<button type="button" class="delete-button" onclick="removeWhitelistUser(${index})"><%:Delete%></button>
</div>
</td>
</tr>`;
});
tbody.innerHTML = html;
}
function showWhitelistModal() {
console.log("showWhitelistModal");
const modal = document.getElementById('whitelistModal');
modal.style.display = 'flex';
const userList = document.getElementById('whitelistUserList');
userList.innerHTML = '';
// Get whitelist MAC addresses
const whitelistMacs = whitelistData.users.map(user => user.mac || user);
// Filter out users already in whitelist
const availableUsers = allUsers.filter(user => {
return !whitelistMacs.includes(user.mac);
});
if (availableUsers.length === 0) {
userList.innerHTML = '<div style="text-align: center; padding: 20px; opacity: 0.6;"><%:No available users to add%></div>';
return;
}
availableUsers.forEach(user => {
const userElement = document.createElement('div');
userElement.className = 'user-item';
// Determine the display name
const displayName = user.nickname || user.hostname || '';
userElement.innerHTML = `
<label style="display: flex; align-items: center;">
<input type="checkbox" value="${user.mac}" style="margin-right: 8px;">
<span>${user.mac}</span>
<span style="opacity: 0.6; margin-left: 8px;">${displayName ? `(${displayName})` : ''}</span>
</label>
`;
userList.appendChild(userElement);
});
}
function closeWhitelistModal() {
const modal = document.getElementById('whitelistModal');
modal.style.display = 'none';
}
function addSelectedWhitelistUsers() {
console.log("addSelectedWhitelistUsers");
const userList = document.getElementById('whitelistUserList');
const checkboxes = userList.querySelectorAll('input[type="checkbox"]:checked');
const selectedMacs = Array.from(checkboxes).map(checkbox => checkbox.value);
if (selectedMacs.length === 0) {
showSuccessMessage('<%:Please select at least one user%>');
return;
}
// Check if any selected user is already in whitelist
const whitelistMacs = whitelistData.users.map(user => user.mac || user);
const alreadyInWhitelist = selectedMacs.filter(mac => whitelistMacs.includes(mac));
if (alreadyInWhitelist.length > 0) {
showSuccessMessage('<%:Some users are already in whitelist%>');
return;
}
var data = {
mac_list: selectedMacs
}
var data_str = JSON.stringify(data);
console.log(selectedMacs);
new XHR().post('<%=url('admin/network/add_whitelist_user')%>',
{ data: data_str },
function(x, data) {
setTimeout(() => {
getWhitelistData();
getAllUsersData();
renderAutoUserData();
}, 500);
closeWhitelistModal();
showSuccessMessage('<%:Add to whitelist successful%>');
}
);
}
function removeWhitelistUser(index) {
const userToRemove = whitelistData.users[index];
new XHR().post('<%=url('admin/network/del_whitelist_user')%>',
{ mac: userToRemove.mac },
function(x, data) {
setTimeout(() => {
getWhitelistData();
getAllUsersData();
renderAutoUserData();
}, 500);
showSuccessMessage('<%:Remove from whitelist successful%>');
}
);
}
window.onload = initData;
//]]></script>
<div class="cbi-section cbi-tblsection">
<div style="max-width: 1000px; margin-left: 5px;padding: 5px;">
<div class="mode-selection">
<label><input type="radio" name="mode" value="0" onclick="switchMode(0)"> <%:Automatic Mode%></label>
<label><input type="radio" name="mode" value="1" checked onclick="switchMode(1)"> <%:Manual Mode%></label>
</div>
<div id="modeDescription" style="margin-bottom: 15px;">
<%:In manual mode, only the following added terminals are controlled%>
</div>
<table class="cbi-section-table" id="user_table" style="display: none;">
<thead>
<tr class="tr table-titles">
<th class="th"><%:MAC%></th>
<th class="th"><%:Hostname%></th>
<th class="th"><%:Remark%></th>
<th class="th"><%:Actions%></th>
</tr>
</thead>
<tbody></tbody>
</table>
<div id="auto_user_section" style="display: none; margin-top: 20px;">
<h3 style="margin-bottom: 15px;"><%:Effective Users%></h3>
<table class="cbi-section-table" id="auto_user_table">
<thead>
<tr class="tr table-titles">
<th class="th"><%:MAC%></th>
<th class="th"><%:Hostname%></th>
<th class="th"><%:Remark%></th>
<th class="th"><%:Actions%></th>
</tr>
</thead>
<tbody></tbody>
</table>
<div id="no_users_message" style="display: none; text-align: center; padding: 40px 0; opacity: 0.6; font-size: 14px;">
<%:No Users Available%>
</div>
</div>
<div id="whitelist_section" style="display: none; margin-top: 20px;">
<h3 style="margin-bottom: 15px;"><%:Whitelist Users%></h3>
<div style="margin-bottom: 15px; opacity: 0.6; font-size: 13px;"><%:Users in whitelist are not controlled%></div>
<table class="cbi-section-table" id="whitelist_table">
<thead>
<tr class="tr table-titles">
<th class="th"><%:MAC%></th>
<th class="th"><%:Hostname%></th>
<th class="th"><%:Remark%></th>
<th class="th"><%:Actions%></th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="cbi-section-create" style="margin-top: 15px;">
<input type="button" value="<%:Add User%>" onclick="showUserModal()" id="manualAddUserBtn" style="padding: 8px 16px; background-color: #1a73e8; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; display: none;" />
<input type="button" value="<%:Add to Whitelist%>" onclick="showWhitelistModal()" id="whitelistAddUserBtn" style="padding: 8px 16px; background-color: #1a73e8; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; display: none;" />
</div>
<div style="margin-top: 20px; display: flex; justify-content: flex-end;">
<button type="button" onclick="location.reload()" style="padding: 8px 20px; width: 150px; background: transparent; color: #1a73e8; border: 1px solid var(--border-color-medium, #d1d5db); border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500; display: inline-flex; align-items: center; justify-content: center; line-height: 1.4;"><%:Refresh%></button>
</div>
</div>
</div>
<div id="userModal" class="oaf-modal-overlay">
<div class="oaf-modal-box">
<h3><%:Select Device%></h3>
<div id="userList" class="user-selection">
</div>
<div style="margin-top: 20px; display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" onclick="closeUserModal()" style="padding: 7px 16px; border: 1px solid var(--border-color-medium, #d1d5db); border-radius: 4px; cursor: pointer; font-size: 13px; background: transparent;"><%:Cancel%></button>
<button type="button" onclick="addSelectedUsers()" style="padding: 7px 16px; background-color: #1a73e8; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500;"><%:OK%></button>
</div>
</div>
</div>
<div id="whitelistModal" class="oaf-modal-overlay">
<div class="oaf-modal-box">
<h3><%:Select Device for Whitelist%></h3>
<div id="whitelistUserList" class="user-selection">
</div>
<div style="margin-top: 20px; display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" onclick="closeWhitelistModal()" style="padding: 7px 16px; border: 1px solid var(--border-color-medium, #d1d5db); border-radius: 4px; cursor: pointer; font-size: 13px; background: transparent;"><%:Cancel%></button>
<button type="button" onclick="addSelectedWhitelistUsers()" style="padding: 7px 16px; background-color: #1a73e8; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; font-weight: 500;"><%:OK%></button>
</div>
</div>
</div>
<div id="modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: transparent; z-index: 1000; justify-content: center; align-items: center;">
<div style="background-color: rgba(0, 0, 0, 0.65); padding: 14px 24px; border-radius: 6px; text-align: center; color: white; display: flex; justify-content: center; align-items: center;">
<p style="margin: 0; color: white; font-size: 14px;"><%:Settings saved successfully%></p>
</div>
</div>
File diff suppressed because it is too large. Load diff
@@ -1,10 +0,0 @@
<%+cbi/valueheader%>
<span style="color: green">
<%
local val = self:cfgvalue(section) or self.default or ""
write(pcdata(val))
%>
</span>
<%+cbi/valuefooter%>
@@ -1,32 +0,0 @@
<%+cbi/valueheader%>
<div style="display: flex; align-items: center;height: 30px;margin-top: 20px;">
<label class="cbi-value" style="width: 110px;margin-top:10px;" for="ulfile"><%:Feature Library Update%>:</label>
<input class="cbi-input-file" style="width: 400px" type="file" id="ulfile" name="ulfile" />
<input type="submit" class="cbi-button cbi-input-apply " name="upload" value="<%:Upload%>" onclick="showModal()"/>
</div>
<div id="modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1000; justify-content: center; align-items: center;">
<div style="background-color: rgba(0, 0, 0, 0.5); padding: 10px; border-radius: 5px; text-align: center; width: 200px; height: 100px; color: white; display: flex; justify-content: center; align-items: center;">
<p style="margin: 0;color: white;"><%:Updating, please wait...%></p>
</div>
</div>
<div style="margin-top: 10px;">
<span style="color: gray;font-size: 13px;"><%:Feature library files can be downloaded from the official website. After downloading, upload to upgrade. Note the feature code format version, which needs to be consistent with the current feature code format!%></span>
</div>
<%+cbi/valuefooter%>
<style>
</style>
<script>
function showModal() {
var modal = document.getElementById('modal');
modal.style.display = 'flex'; // Show the modal
setTimeout(function() {
modal.style.display = 'none'; // Hide the modal after 10 seconds
}, 15000); // 10000 milliseconds = 10 seconds
}
</script>
Binary file not shown.
+128
View File
@@ -0,0 +1,128 @@
<%+header%>
<link rel="stylesheet" href="<%=resource%>/oaf/css/common.css">
<div class="oaf-about-page">
<section class="oaf-about-card">
<div class="oaf-about-brand">OpenAppFilter</div>
<p class="oaf-about-intro">
<%:OAF is a powerful parental control software. FanchmWrt integrates OAF by default and includes more plugins developed by the author.%>
</p>
<div class="oaf-about-info">
<div class="oaf-about-row">
<div class="oaf-about-label"><%:Author%></div>
<div class="oaf-about-value">destan19(TT)</div>
</div>
<div class="oaf-about-row">
<div class="oaf-about-label">OAF</div>
<div class="oaf-about-value">
<a href="http://www.openappfilter.com" target="_blank" rel="noopener noreferrer">www.openappfilter.com</a>
</div>
</div>
<div class="oaf-about-row">
<div class="oaf-about-label">FanchmWrt</div>
<div class="oaf-about-value">
<a href="http://www.fanchmwrt.com" target="_blank" rel="noopener noreferrer">www.fanchmwrt.com</a>
</div>
</div>
<div class="oaf-about-row">
<div class="oaf-about-label"><%:GitHub Source%></div>
<div class="oaf-about-value">
<a href="https://github.com/destan19/OpenAppFilter" target="_blank" rel="noopener noreferrer">https://github.com/destan19/OpenAppFilter</a>
</div>
</div>
</div>
<div class="oaf-about-copyright">
Copyright © 2026 FanchmWrt. All rights reserved.
</div>
</section>
</div>
<style>
.oaf-about-page {
padding: 10px;
}
.oaf-about-card {
padding: 22px;
border: 1px solid var(--border-color-low, #e5e7eb);
border-radius: 8px;
background: var(--background-color-high, #fff);
color: var(--text-color-high, #333);
max-width: 860px;
}
.oaf-about-brand {
font-size: 28px;
font-weight: 700;
line-height: 1.25;
margin-bottom: 6px;
}
.oaf-about-intro {
margin: 0 0 20px;
color: var(--text-color-high, #333);
font-size: 16px;
line-height: 1.7;
}
.oaf-about-info {
display: grid;
gap: 10px;
max-width: 760px;
margin-bottom: 22px;
}
.oaf-about-row {
display: grid;
grid-template-columns: 130px minmax(0, 1fr);
gap: 16px;
align-items: center;
padding: 12px 14px;
border: 1px solid var(--border-color-low, #e5e7eb);
border-radius: 6px;
background: var(--background-color-low, #f5f5f5);
font-size: 15px;
}
.oaf-about-label {
color: var(--text-color-medium, #6b7280);
font-weight: 600;
}
.oaf-about-value {
min-width: 0;
color: var(--text-color-high, #333);
word-break: break-word;
}
.oaf-about-value a {
color: var(--primary-color, #2563eb);
text-decoration: none;
}
.oaf-about-value a:hover {
text-decoration: underline;
}
.oaf-about-copyright {
padding-top: 14px;
border-top: 1px solid var(--border-color-low, #e5e7eb);
color: var(--text-color-medium, #6b7280);
font-size: 14px;
}
@media (max-width: 600px) {
.oaf-about-card {
padding: 16px;
}
.oaf-about-row {
grid-template-columns: 1fr;
gap: 5px;
}
}
</style>
<%+footer%>
+462
View File
@@ -0,0 +1,462 @@
<%
local nfs = require "nixio.fs"
local has_app_filter = nfs.access("/etc/config/appfilter")
local has_access_control = nfs.access("/etc/config/macfilter")
%>
<%+header%>
<link rel="stylesheet" href="<%=resource%>/oaf/css/common.css">
<script src="<%=resource%>/oaf/jquery-3.7.1.min.js"></script>
<script src="<%=resource%>/oaf/common.js"></script>
<div class="settings-page">
<div class="settings-heading"><%:Settings%></div>
<section class="settings-panel">
<button type="button" class="settings-panel-header" aria-expanded="true" aria-controls="basic-settings-panel">
<span><%:Basic Settings%></span>
<span class="settings-chevron" aria-hidden="true"></span>
</button>
<div class="settings-panel-body" id="basic-settings-panel">
<div class="form-row">
<label class="form-label" for="work-mode"><%:Work Mode%></label>
<select id="work-mode">
<option value="0"><%:Gateway Mode%></option>
<option value="1"><%:Bypass Mode%></option>
</select>
</div>
<ol class="work-mode-warning" id="work-mode-warning">
<li><%:Bypass mode cannot count downstream traffic because traffic is directly forwarded to terminals at Layer 2 through the gateway.%></li>
<li><%:In bypass mode, it is recommended to disable IPv6 on terminals to prevent terminals from forwarding directly to the main router through IPv6.%></li>
</ol>
<div class="form-row">
<label class="form-label" for="lan-ifname"><%:LAN Interface%></label>
<div class="settings-field">
<input id="lan-ifname" type="text" placeholder="br-lan" class="input-short">
<div class="field-error" id="lan-ifname-error"></div>
<div class="form-desc-inline"><%:LAN interface name for terminal detection. Default is bridge interface (br-lan). If LAN port is changed to physical interface, please modify to the corresponding name, such as eth0.%></div>
</div>
</div>
<div class="form-row">
<label class="form-label" for="tcp-rst"><%:TCP Connection Reset%></label>
<div class="settings-field">
<label class="switch">
<input type="checkbox" id="tcp-rst">
<span class="slider"></span>
</label>
<div class="form-desc-inline"><%:After enabled, OAF sends TCP RST for filtered TCP connections.%></div>
</div>
</div>
<% if has_app_filter then %>
<div class="form-row">
<label class="form-label" for="app-filter-enable"><%:Enable App Filter%></label>
<label class="switch">
<input type="checkbox" id="app-filter-enable">
<span class="slider"></span>
</label>
</div>
<% end %>
<% if has_access_control then %>
<div class="form-row">
<label class="form-label" for="access-control-enable"><%:Enable Access Control%></label>
<label class="switch">
<input type="checkbox" id="access-control-enable">
<span class="slider"></span>
</label>
</div>
<% end %>
</div>
</section>
<section class="settings-panel">
<button type="button" class="settings-panel-header" aria-expanded="true" aria-controls="audit-settings-panel">
<span><%:Internet Audit%></span>
<span class="settings-chevron" aria-hidden="true"></span>
</button>
<div class="settings-panel-body" id="audit-settings-panel">
<div class="form-row">
<label class="form-label" for="rec-enable"><%:Enable%></label>
<label class="switch">
<input type="checkbox" id="rec-enable">
<span class="slider"></span>
</label>
</div>
<div class="form-row" id="data-size-group" style="display:none;">
<label class="form-label"><%:History Data Usage%></label>
<div class="data-size-field">
<span id="data-size-display"></span>
<button type="button" class="btn btn-secondary data-clean-btn" id="clean-history"><%:Clean%></button>
</div>
</div>
<div class="form-row">
<label class="form-label" for="rec-keep"><%:Record Time%></label>
<div class="settings-field">
<select id="rec-keep">
<option value="1">1 <%:days%></option>
<option value="2">2 <%:days%></option>
<option value="3">3 <%:days%></option>
<option value="4">4 <%:days%></option>
<option value="5">5 <%:days%></option>
<option value="6">6 <%:days%></option>
<option value="7">7 <%:days%></option>
<option value="10">10 <%:days%></option>
<option value="15">15 <%:days%></option>
<option value="30">30 <%:days%></option>
</select>
<div class="form-desc-inline"><%:Internet record retention time%></div>
</div>
</div>
<div class="form-row">
<label class="form-label" for="rec-valid"><%:App Valid Time%></label>
<div class="settings-field">
<select id="rec-valid">
<option value="1">1 <%:minutes%></option>
<option value="2">2 <%:minutes%></option>
<option value="3">3 <%:minutes%></option>
<option value="4">4 <%:minutes%></option>
<option value="5">5 <%:minutes%></option>
<option value="10">10 <%:minutes%></option>
<option value="15">15 <%:minutes%></option>
<option value="20">20 <%:minutes%></option>
<option value="30">30 <%:minutes%></option>
<option value="60">60 <%:minutes%></option>
</select>
<div class="form-desc-inline"><%:App records minimum duration%></div>
</div>
</div>
<div class="form-row">
<label class="form-label" for="rec-history-size"><%:History Data Size%></label>
<div class="settings-field">
<div class="size-field">
<input type="text" id="rec-history-size">
<span class="unit">MB</span>
</div>
<div class="form-desc-inline"><%:History data size limit%></div>
</div>
</div>
<div class="form-row">
<label class="form-label" for="rec-history-path"><%:History Data Path%></label>
<div class="settings-field">
<input type="text" id="rec-history-path">
<div class="form-desc-inline"><%:User's App record data will be stored in this directory.%></div>
</div>
</div>
<div class="form-row">
<label class="form-label" for="rec-base-path"><%:Base Data Path%></label>
<div class="settings-field">
<input type="text" id="rec-base-path">
<div class="form-desc-inline"><%:The user's basic data and real-time data are stored in this directory, such as online duration, traffic usage and other information. The default is a temporary directory and will not be retained after reboot. You can change it to a persistent directory, such as /etc/fwx.%></div>
</div>
</div>
</div>
</section>
<section class="settings-panel">
<button type="button" class="settings-panel-header" aria-expanded="true" aria-controls="advanced-settings-panel">
<span><%:Advanced Settings%></span>
<span class="settings-chevron" aria-hidden="true"></span>
</button>
<div class="settings-panel-body" id="advanced-settings-panel">
<div class="form-row">
<label class="form-label" for="disable-hnat"><%:Auto Disable Acceleration%></label>
<div class="settings-field">
<label class="switch">
<input type="checkbox" id="disable-hnat">
<span class="slider"></span>
</label>
<div class="form-desc-inline"><%:After enabled, related acceleration modules will be automatically disabled, including hardware acceleration, software acceleration and others, to prevent filtering failures caused by acceleration. It is recommended to restart the device after modification.%></div>
</div>
</div>
</div>
</section>
<div class="settings-actions">
<button class="btn btn-secondary" type="button" id="settings-refresh"><%:Refresh%></button>
<button class="btn" type="button" id="settings-save" disabled><%:Save%></button>
</div>
<div class="status-tip" id="settings-tip"></div>
</div>
<style>
.settings-page{max-width:860px;margin:0 auto 16px;padding:0 10px}.settings-heading{font-size:20px;font-weight:600;margin:8px 0 14px}.settings-panel{border:1px solid var(--border-color-low,#e5e7eb);border-radius:7px;margin-bottom:12px;background:var(--background-color-high,transparent);overflow:hidden}.settings-panel-header{width:100%;border:0;border-bottom:1px solid var(--border-color-low,#e5e7eb);background:var(--background-color-low,rgba(0,0,0,.025));color:inherit;display:flex;align-items:center;justify-content:space-between;padding:12px 16px;font-size:16px;font-weight:600;text-align:left;cursor:pointer}.settings-panel-header.collapsed{border-bottom-color:transparent}.settings-chevron{width:8px;height:8px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .15s ease;margin:0 4px 4px 12px}.settings-panel-header.collapsed .settings-chevron{transform:rotate(-45deg);margin-bottom:0}.settings-panel-body{padding:16px}.settings-panel-body .form-row{align-items:flex-start;margin-bottom:16px}.settings-panel-body .switch{margin-top:2px;flex:none}.settings-field{flex:1;min-width:0}.settings-field>input,.settings-field>select{width:360px;max-width:100%}.form-desc-inline{margin-top:5px;font-size:12px;line-height:1.5;color:var(--text-color-medium,#6b7280)}.field-error{display:none;color:#f87171;font-size:12px;margin-top:4px}.work-mode-warning{display:none;margin:0 0 16px 150px;padding:10px 14px 10px 30px;border:1px solid rgba(239,68,68,.4);border-radius:6px;background:rgba(239,68,68,.08);color:#dc2626;font-size:13px;line-height:1.6}.work-mode-warning li{margin:2px 0}.data-size-field,.size-field{display:flex;align-items:center;gap:10px}.data-clean-btn{min-width:64px}.size-field input{width:320px!important}.size-field .unit{color:var(--text-color-medium,#6b7280)}.settings-actions{display:flex;justify-content:flex-end;gap:8px;padding-top:4px}.settings-page>.status-tip{text-align:right}.settings-panel-header:focus-visible{outline:2px solid #3b82f6;outline-offset:-2px}[data-darkmode="true"] .work-mode-warning{border-color:rgba(248,113,113,.55);background:rgba(127,29,29,.26);color:#fca5a5}
@media(max-width:600px){.settings-page{padding:0 4px}.settings-panel-header{padding:11px 12px}.settings-panel-body{padding:13px 12px}.settings-panel-body .form-row{display:block}.settings-panel-body .form-label{display:block;width:auto;margin-bottom:7px}.work-mode-warning{margin-left:0}.settings-field>input,.settings-field>select,.settings-panel-body>.form-row>select{width:100%}.size-field input{width:100%!important;min-width:0}.settings-actions{padding-right:2px}}
</style>
<script type="text/javascript">//<![CDATA[
(function() {
var text = {
saved: '<%:Saved successfully%>',
saveFailed: '<%:Save failed%>',
loading: '<%:Loading...%>',
loadFailed: '<%:Load failed%>',
invalidLan: '<%:Invalid LAN interface name%>',
invalidSize: '<%:History data size must be an integer between 1 and 1024 MB%>',
emptyHistoryPath: '<%:History data path cannot be empty or /%>',
longHistoryPath: '<%:History data path maximum length is 64 characters%>',
emptyBasePath: '<%:Base data path cannot be empty or /%>',
longBasePath: '<%:Base data path maximum length is 64 characters%>',
cleanConfirm: '<%:Are you sure you want to clean all history data? This action cannot be undone.%>',
cleanSuccess: '<%:Data cleaned successfully%>',
cleanFailed: '<%:Clean failed%>'
};
var api = {
workModeGet: '<%=url("admin/services/oaf/api/system/get_work_mode")%>',
workModeSet: '<%=url("admin/services/oaf/api/system/set_work_mode")%>',
systemGet: '<%=url("admin/services/oaf/api/system/get_system_info")%>',
systemSet: '<%=url("admin/services/oaf/api/system/set_system_info")%>',
tcpRstGet: '<%=url("admin/services/oaf/api/system/get_tcp_rst")%>',
tcpRstSet: '<%=url("admin/services/oaf/api/system/set_tcp_rst")%>',
appFilterGet: '<%=url("admin/services/oaf/api/app_filter/get_app_filter_adv")%>',
appFilterSet: '<%=url("admin/services/oaf/api/app_filter/set_app_filter_adv")%>',
accessControlGet: '<%=url("admin/services/oaf/api/mac_filter/get_mac_filter_adv")%>',
accessControlSet: '<%=url("admin/services/oaf/api/mac_filter/set_mac_filter_adv")%>',
advancedGet: '<%=url("admin/services/oaf/api/system/get_advanced_settings")%>',
advancedSet: '<%=url("admin/services/oaf/api/system/set_advanced_settings")%>',
recordGet: '<%=url("admin/services/oaf/api/internet_audit/get_record_base")%>',
recordSet: '<%=url("admin/services/oaf/api/internet_audit/set_record_base")%>',
recordAction: '<%=url("admin/services/oaf/api/internet_audit/record_action")%>'
};
var currentThemeMode = 0;
var settingsLoaded = false;
function isSuccess(resp) {
return !!resp && (resp.code === 0 || resp.code === 2000);
}
function showResult(message, success) {
if (typeof showCommonModal === 'function') {
showCommonModal(message || '', 2000, success ? 'success' : 'error');
}
}
function runTasks(tasks, done) {
var remaining = tasks.length;
var success = true;
if (!remaining) {
done(true);
return;
}
tasks.forEach(function(task) {
task(function(ok) {
success = success && !!ok;
remaining--;
if (!remaining) done(success);
});
});
}
function loadTask(url, applyData) {
return function(done) {
$.getJSON(url).done(function(resp) {
if (!isSuccess(resp) || !resp.data) {
done(false);
return;
}
done(applyData(resp.data) !== false);
}).fail(function() {
done(false);
});
};
}
function postTask(url, data) {
return function(done) {
$.ajax({url:url,method:'POST',dataType:'json',data:data}).done(function(resp) {
done(isSuccess(resp));
}).fail(function() {
done(false);
});
};
}
function updateWorkModeWarning(value) {
$('#work-mode-warning').toggle(parseInt(value, 10) === 1);
}
function applyRecordData(data) {
$('#rec-enable').prop('checked', data.enable == 1);
$('#rec-keep').val(data.record_time || 0);
$('#rec-valid').val(data.app_valid_time || 0);
$('#rec-history-size').val(data.history_data_size || '');
$('#rec-history-path').val(data.history_data_path || '');
$('#rec-base-path').val(data.base_data_path || data.terminal_data_path || '');
if (data.status && data.status.data_size !== undefined) {
var size = data.status.data_size || 0;
var unit = data.status.data_size_unit || 'KB';
var display = size + unit;
if (unit === 'KB' && size >= 1024) {
var mb = size / 1024;
display = (mb % 1 === 0 ? mb.toFixed(0) : mb.toFixed(1)) + 'MB';
}
$('#data-size-display').text(display);
$('#data-size-group').show();
} else {
$('#data-size-group').hide();
}
}
function refreshAll() {
var tasks = [];
settingsLoaded = false;
$('#settings-refresh,#settings-save').prop('disabled', true);
$('#settings-tip').text(text.loading);
tasks.push(loadTask(api.workModeGet, function(data) {
var value = parseInt(data.work_mode, 10) === 1 ? '1' : '0';
$('#work-mode').val(value);
updateWorkModeWarning(value);
}));
tasks.push(loadTask(api.systemGet, function(data) {
var fwx = data.fwx;
if (!fwx) return false;
$('#lan-ifname').val(fwx.lan_ifname || 'br-lan');
currentThemeMode = fwx.theme_mode == 1 ? 1 : 0;
}));
tasks.push(loadTask(api.tcpRstGet, function(data) {
$('#tcp-rst').prop('checked', data.tcp_rst != 0);
}));
<% if has_app_filter then %>
tasks.push(loadTask(api.appFilterGet, function(data) {
$('#app-filter-enable').prop('checked', data.enable == 1);
}));
<% end %>
<% if has_access_control then %>
tasks.push(loadTask(api.accessControlGet, function(data) {
$('#access-control-enable').prop('checked', data.enable == 1);
}));
<% end %>
tasks.push(loadTask(api.advancedGet, function(data) {
$('#disable-hnat').prop('checked', data.disable_hnat == 1);
}));
tasks.push(loadTask(api.recordGet, applyRecordData));
runTasks(tasks, function(success) {
settingsLoaded = success;
$('#settings-refresh').prop('disabled', false);
$('#settings-save').prop('disabled', !success);
$('#settings-tip').text(success ? '' : text.loadFailed);
});
}
function validateSettings() {
var lanIfname = $.trim($('#lan-ifname').val() || '');
var historySize = $.trim($('#rec-history-size').val() || '');
var historyPath = $.trim($('#rec-history-path').val() || '');
var basePath = $.trim($('#rec-base-path').val() || '');
$('#lan-ifname-error').hide();
$('#lan-ifname').css('border-color', '');
if (!/^[a-zA-Z0-9-]{2,16}$/.test(lanIfname)) {
$('#lan-ifname-error').text(text.invalidLan).show();
$('#lan-ifname').css('border-color', '#f87171');
return null;
}
if (historySize) {
var size = Number(historySize);
if (!/^\d+$/.test(historySize) || isNaN(size) || size < 1 || size > 1024) {
showResult(text.invalidSize, false);
return null;
}
}
if (!historyPath || historyPath === '/') {
showResult(text.emptyHistoryPath, false);
return null;
}
if (historyPath.length > 64) {
showResult(text.longHistoryPath, false);
return null;
}
if (!basePath || basePath === '/') {
showResult(text.emptyBasePath, false);
return null;
}
if (basePath.length > 64) {
showResult(text.longBasePath, false);
return null;
}
return {lanIfname:lanIfname,historySize:historySize,historyPath:historyPath,basePath:basePath};
}
function saveAll() {
var values;
var tasks = [];
if (!settingsLoaded) {
showResult(text.loadFailed, false);
return;
}
values = validateSettings();
if (!values) return;
tasks.push(postTask(api.workModeSet, {work_mode:$('#work-mode').val() || '0'}));
tasks.push(postTask(api.systemSet, {lan_ifname:values.lanIfname,theme_mode:currentThemeMode}));
tasks.push(postTask(api.tcpRstSet, {tcp_rst:$('#tcp-rst').is(':checked') ? 1 : 0}));
<% if has_app_filter then %>
tasks.push(postTask(api.appFilterSet, {enable:$('#app-filter-enable').is(':checked') ? 1 : 0}));
<% end %>
<% if has_access_control then %>
tasks.push(postTask(api.accessControlSet, {enable:$('#access-control-enable').is(':checked') ? 1 : 0}));
<% end %>
tasks.push(postTask(api.advancedSet, {disable_hnat:$('#disable-hnat').is(':checked') ? 1 : 0}));
tasks.push(postTask(api.recordSet, {
enable:$('#rec-enable').is(':checked') ? 1 : 0,
record_time:parseInt($('#rec-keep').val(), 10) || 0,
app_valid_time:parseInt($('#rec-valid').val(), 10) || 0,
history_data_size:values.historySize,
history_data_path:values.historyPath,
base_data_path:values.basePath
}));
$('#settings-refresh,#settings-save').prop('disabled', true);
runTasks(tasks, function(success) {
$('#settings-refresh,#settings-save').prop('disabled', false);
updateWorkModeWarning($('#work-mode').val());
showResult(success ? text.saved : text.saveFailed, success);
});
}
function cleanHistory() {
if (!window.confirm(text.cleanConfirm)) return;
$('#clean-history').prop('disabled', true);
$.ajax({url:api.recordAction,method:'POST',dataType:'json',data:{action:'clean_all_data'}}).done(function(resp) {
if (isSuccess(resp)) {
showResult(text.cleanSuccess, true);
loadTask(api.recordGet, applyRecordData)(function() {});
} else {
showResult(text.cleanFailed, false);
}
}).fail(function() {
showResult(text.cleanFailed, false);
}).always(function() {
$('#clean-history').prop('disabled', false);
});
}
$('.settings-panel-header').on('click', function() {
var header = $(this);
var panel = $('#' + header.attr('aria-controls'));
var expanded = header.attr('aria-expanded') === 'true';
header.attr('aria-expanded', expanded ? 'false' : 'true').toggleClass('collapsed', expanded);
panel.stop(true, true).slideToggle(120);
});
$('#work-mode').on('change', function() { updateWorkModeWarning(this.value); });
$('#settings-refresh').on('click', refreshAll);
$('#settings-save').on('click', saveAll);
$('#clean-history').on('click', cleanHistory);
$(refreshAll);
})();
//]]></script>
<%+footer%>
File diff suppressed because it is too large. Load diff
+437
View File
@@ -0,0 +1,437 @@
<%+header%>
<link rel="stylesheet" href="<%=resource%>/oaf/css/common.css">
<script src="<%=resource%>/oaf/jquery-3.7.1.min.js"></script>
<script src="<%=resource%>/oaf/oaf_icon.js"></script>
<div style="padding: 8px;">
<div class="cbi-section">
<ul class="tab-list">
<li class="tab-item active" data-tab="online-records"><%:Online Records%></li>
<li class="tab-item" data-tab="history-records"><%:History Records%></li>
</ul>
<div id="tab-online-records" class="tab-body active">
<table class="table cbi-section-table" id="online-record-table" style="table-layout: fixed; width: 100%;">
<colgroup>
<col class="record-app-col">
<col class="record-mac-col">
<col class="record-hostname-col">
<col class="record-nickname-col">
<col class="record-time-col">
<col class="record-time-col">
<col class="record-duration-col">
<col class="record-status-col">
<col class="record-status-col">
</colgroup>
<tr class="tr table-titles">
<th class="th"><%:App%></th>
<th class="th">MAC</th>
<th class="th"><%:Hostname%></th>
<th class="th"><%:Nickname%></th>
<th class="th"><%:First Visit%></th>
<th class="th"><%:Last Visit%></th>
<th class="th"><%:Duration%></th>
<th class="th"><%:Online%></th>
<th class="th"><%:Filter Status%></th>
</tr>
<tr class="tr">
<td class="td" colspan="9"><em><br /><%:Loading...%></em></td>
</tr>
</table>
<div id="online-pagination" style="margin-top: 12px;"></div>
</div>
<div id="tab-history-records" class="tab-body">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:10px 0 12px 0;">
<input id="q-history-mac" class="cbi-input-text" type="text" placeholder="<%:Terminal MAC%>" style="width:200px;">
<input id="q-history-start" class="cbi-input-text" type="datetime-local" style="width:210px;">
<input id="q-history-end" class="cbi-input-text" type="datetime-local" style="width:210px;">
<button type="button" class="cbi-button cbi-button-action" id="btn-history-search"><%:Search%></button>
<button type="button" class="cbi-button" id="btn-history-reset"><%:Reset%></button>
</div>
<table class="table cbi-section-table" id="history-record-table" style="table-layout: fixed; width: 100%;">
<colgroup>
<col class="record-app-col">
<col class="record-mac-col">
<col class="record-hostname-col">
<col class="record-nickname-col">
<col class="record-time-col">
<col class="record-time-col">
<col class="record-duration-col">
<col class="record-status-col">
<col class="record-status-col">
</colgroup>
<tr class="tr table-titles">
<th class="th"><%:App%></th>
<th class="th">MAC</th>
<th class="th"><%:Hostname%></th>
<th class="th"><%:Nickname%></th>
<th class="th"><%:First Visit%></th>
<th class="th"><%:Last Visit%></th>
<th class="th"><%:Duration%></th>
<th class="th"><%:Online%></th>
<th class="th"><%:Filter Status%></th>
</tr>
<tr class="tr">
<td class="td" colspan="9"><em><br /><%:Loading...%></em></td>
</tr>
</table>
<div id="history-pagination" style="margin-top: 12px;"></div>
</div>
</div>
</div>
<style>
.tab-list {
display: flex;
list-style: none;
padding: 0;
margin: 0;
border-bottom: 1px solid var(--border-color-medium, #d1d5db);
}
.tab-item {
padding: 3px 16px;
cursor: pointer;
border: 1px solid var(--border-color-medium, #d1d5db);
border-bottom: none;
border-radius: 5px 5px 0 0;
margin-right: 2px;
background-color: var(--background-color-low, #f5f5f5);
color: var(--text-color-medium, #6b7280);
}
.tab-item.active {
font-weight: bold;
background-color: var(--background-color-high, #ffffff);
color: var(--text-color-high, #333);
}
.tab-item:hover {
background-color: var(--background-color-medium, #eeeeee);
}
.tab-body {
margin-top: 6px;
display: none;
}
.tab-body.active {
display: block;
}
#online-record-table .th, #history-record-table .th {
padding-top: 12px;
padding-bottom: 12px;
}
#online-record-table .td, #history-record-table .td {
padding-top: 12px;
padding-bottom: 12px;
line-height: 1.55;
}
#online-record-table,
#history-record-table {
--record-app-col-width: 138px;
--record-app-cell-padding: 14px;
--record-app-icon-size: 22px;
--record-app-icon-gap: 7px;
}
#online-record-table .record-app-col,
#history-record-table .record-app-col {
width: var(--record-app-col-width);
}
#online-record-table .record-mac-col,
#history-record-table .record-mac-col {
width: 15%;
}
#online-record-table .record-hostname-col,
#history-record-table .record-hostname-col,
#online-record-table .record-nickname-col,
#history-record-table .record-nickname-col {
width: 11%;
}
#online-record-table .record-time-col,
#history-record-table .record-time-col {
width: 15%;
}
#online-record-table .record-duration-col,
#history-record-table .record-duration-col {
width: 8%;
}
#online-record-table .record-status-col,
#history-record-table .record-status-col {
width: 6%;
}
#online-record-table .td:nth-child(5),
#online-record-table .td:nth-child(6),
#history-record-table .td:nth-child(5),
#history-record-table .td:nth-child(6) {
white-space: nowrap;
}
.record-app-cell {
display: flex;
align-items: center;
gap: var(--record-app-icon-gap);
min-width: 0;
width: calc(var(--record-app-col-width) - var(--record-app-cell-padding));
}
.record-app-cell .record-app-name {
min-width: 0;
max-width: calc(var(--record-app-col-width) - var(--record-app-cell-padding) - var(--record-app-icon-size) - var(--record-app-icon-gap));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<script type="text/javascript">
//<![CDATA[
(function() {
var text = {
filtered: '<%:Filtered%>',
unfiltered: '<%:Unfiltered%>',
online: '<%:Online%>',
offline: '<%:Offline%>',
noData: '<%:No Data%>',
previous: '<%:Previous%>',
next: '<%:Next%>',
total: '<%:Total%>',
records: '<%:Records%>'
};
var online_state = { page: 1, page_size: 15, total_num: 0, total_page: 1 };
var history_state = { page: 1, page_size: 15, total_num: 0, total_page: 1 };
var history_loaded = false;
function pad2(n) {
n = parseInt(n || 0, 10);
return n < 10 ? ("0" + n) : ("" + n);
}
function format_time(ts) {
var t = parseInt(ts || 0, 10);
if (!t) return "--";
var d = new Date(t * 1000);
if (isNaN(d.getTime())) return "--";
return d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()) + " "
+ pad2(d.getHours()) + ":" + pad2(d.getMinutes());
}
function format_duration(sec) {
var s = parseInt(sec || 0, 10);
if (s < 0) s = 0;
var h = Math.floor(s / 3600);
var m = Math.floor((s % 3600) / 60);
var r = s % 60;
if (h > 0) return h + "h" + (m > 0 ? " " + m + "m" : "");
if (m > 0) return m + "m" + (r > 0 ? " " + r + "s" : "");
return r + "s";
}
function get_ts_from_input(selector) {
var v = $(selector).val();
if (!v) return 0;
var ms = new Date(v).getTime();
if (!ms || ms < 0) return 0;
return Math.floor(ms / 1000);
}
function render_filter_status(act) {
return parseInt(act || 0, 10) === 1
? '<span style="color:#F44336;">' + text.filtered + '</span>'
: '<span style="color:#22c55e;">' + text.unfiltered + '</span>';
}
function render_online_status(online) {
return parseInt(online || 0, 10) === 1
? '<span style="color:#22c55e;">' + text.online + '</span>'
: '<span style="color:#F44336;">' + text.offline + '</span>';
}
function app_display_name(app_id, app_name) {
var id = app_id === undefined || app_id === null ? "" : String(app_id);
var name = $.trim(app_name || "");
if (!name || name === "--" || name === "Unknown" || name === "Unknown App") {
return id && id !== "0" ? ("App" + id) : "--";
}
return name;
}
function render_app_cell($cell, app_id, app_name, app_icon) {
var id = app_id === undefined || app_id === null ? "" : String(app_id);
var name = app_display_name(id, app_name);
var $wrap = $('<div>').addClass('record-app-cell').attr('title', name);
var $icon = window.OAFIcon
? window.OAFIcon.createAppIcon(id, name, '<%=resource%>', { size: '22px', radius: '5px', icon: app_icon })
: $('<span>').text('');
$wrap.append($icon).append($('<span>').addClass('record-app-name').text(name));
$cell.empty().append($wrap);
}
function render_table(table_id, list) {
var $tb = $("#" + table_id);
$tb.find("tr:gt(0)").remove();
if (!Array.isArray(list) || list.length === 0) {
$tb.append('<tr class="tr"><td class="td" colspan="9"><em><br />' + text.noData + '</em></td></tr>');
return;
}
list.forEach(function(item) {
var app_id = item.id || item.appid || "";
var app_name = app_display_name(app_id, item.name || item.appname || "");
var mac = item.mac || "--";
var hostname = item.hostname || "--";
var nickname = item.nickname || "--";
var ft = item.ft || item.first_time || 0;
var lt = item.lt || item.latest_time || 0;
var tt = item.tt || item.total_time || 0;
var act = item.act || item.latest_action || 0;
var online = item.online || 0;
var $row = $('<tr>').addClass('tr');
var $appCell = $('<td>').addClass('td record-app-td');
render_app_cell($appCell, app_id, app_name, item.icon);
$row
.append($appCell)
.append($('<td>').addClass('td').attr('title', mac).text(mac))
.append($('<td>').addClass('td').attr('title', hostname).text(hostname))
.append($('<td>').addClass('td').attr('title', nickname).text(nickname))
.append($('<td>').addClass('td').text(format_time(ft)))
.append($('<td>').addClass('td').text(format_time(lt)))
.append($('<td>').addClass('td').text(format_duration(tt)))
.append($('<td>').addClass('td').html(render_online_status(online)))
.append($('<td>').addClass('td').html(render_filter_status(act)));
$tb.append($row);
});
}
function render_pagination(container_id, state, go_fn) {
var $p = $("#" + container_id);
if (state.total_page <= 1) {
$p.html("");
return;
}
var $wrap = $('<div>').css({
display: 'flex',
gap: '6px',
alignItems: 'center',
justifyContent: 'center',
flexWrap: 'wrap'
});
var $prev = $('<button type="button">').addClass('cbi-button').text(text.previous).prop('disabled', state.page <= 1);
var $next = $('<button type="button">').addClass('cbi-button').text(text.next).prop('disabled', state.page >= state.total_page);
var $pageText = $('<span>').text(state.page + ' / ' + state.total_page);
var $jumpInput = $('<input type="number">')
.addClass('cbi-input-text')
.attr({ min: 1, max: state.total_page })
.css({ width: '72px', height: '28px', boxSizing: 'border-box' })
.val(state.page);
var $jumpBtn = $('<button type="button">').addClass('cbi-button').text('Go');
var $total = $('<span>').css('color', 'var(--text-color-medium, #999)')
.text(text.total + ' ' + state.total_num + ' ' + text.records);
function jump_to_input_page() {
var target = parseInt($jumpInput.val(), 10);
if (isNaN(target)) {
target = state.page;
}
target = Math.max(1, Math.min(state.total_page, target));
$jumpInput.val(target);
if (target !== state.page) {
go_fn(target);
}
}
$prev.on("click", function() {
if (state.page > 1) go_fn(state.page - 1);
});
$next.on("click", function() {
if (state.page < state.total_page) go_fn(state.page + 1);
});
$jumpBtn.on("click", jump_to_input_page);
$jumpInput.on("keydown", function(e) {
if (e.key === "Enter") {
e.preventDefault();
jump_to_input_page();
}
});
$wrap.append($prev, $pageText, $next, $jumpInput, $jumpBtn, $total);
$p.empty().append($wrap);
}
function load_online_records() {
new XHR().get("<%=url('admin/services/oaf/api/app_record/get_active_app_records')%>", {
page: online_state.page,
page_size: online_state.page_size
}, function(x, data) {
var resp = data || {};
online_state.total_num = parseInt(resp.total_num || 0, 10);
online_state.total_page = parseInt(resp.total_page || 1, 10);
online_state.page = parseInt(resp.page || online_state.page, 10);
online_state.page_size = parseInt(resp.page_size || online_state.page_size, 10);
render_table("online-record-table", resp.list || []);
render_pagination("online-pagination", online_state, function(p) {
online_state.page = p;
load_online_records();
});
});
}
function load_history_records() {
new XHR().get("<%=url('admin/services/oaf/api/app_record/get_app_history_records')%>", {
mac: $("#q-history-mac").val() || "",
start_time: get_ts_from_input("#q-history-start"),
end_time: get_ts_from_input("#q-history-end"),
page: history_state.page,
page_size: history_state.page_size
}, function(x, data) {
var resp = data || {};
history_state.total_num = parseInt(resp.total_num || 0, 10);
history_state.total_page = parseInt(resp.total_page || 1, 10);
history_state.page = parseInt(resp.page || history_state.page, 10);
history_state.page_size = parseInt(resp.page_size || history_state.page_size, 10);
render_table("history-record-table", resp.list || []);
render_pagination("history-pagination", history_state, function(p) {
history_state.page = p;
load_history_records();
});
});
}
function switch_tab(tab_id) {
$(".tab-item").removeClass("active");
$('.tab-item[data-tab="' + tab_id + '"]').addClass("active");
$(".tab-body").removeClass("active");
$("#tab-" + tab_id).addClass("active");
if (tab_id === "history-records" && !history_loaded) {
history_loaded = true;
load_history_records();
}
}
$(".tab-item").on("click", function() {
var tab_id = $(this).attr("data-tab");
if (!tab_id) return;
switch_tab(tab_id);
});
$("#btn-history-search").on("click", function() {
history_state.page = 1;
load_history_records();
});
$("#btn-history-reset").on("click", function() {
$("#q-history-mac").val("");
$("#q-history-start").val("");
$("#q-history-end").val("");
history_state.page = 1;
load_history_records();
});
var urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('tab') === 'history') {
switch_tab('history-records');
}
load_online_records();
})();
//]]>
</script>
<%+footer%>
File diff suppressed because it is too large. Load diff
+295
View File
@@ -0,0 +1,295 @@
<%+header%>
<link rel="stylesheet" href="<%=resource%>/oaf/css/common.css">
<script type="text/javascript" src="<%=resource%>/oaf/jquery-3.7.1.min.js"></script>
<script type="text/javascript" src="<%=resource%>/oaf/common.js"></script>
<script type="text/javascript" src="<%=resource%>/oaf/oaf_icon.js"></script>
<div class="feature-container">
<div class="feature-page-heading">
<div class="feature-page-title"><%:App Feature Library%></div>
<div class="feature-page-desc"><%:The feature library is used to describe app features, and determines the effectiveness of app filtering and internet usage records%></div>
</div>
<ul class="tab-list cbi-tabmenu">
<li class="tab-item cbi-tab active" data-tab="tab-basic"><%:Basic Info%></li>
<li class="tab-item cbi-tab" data-tab="tab-online"><%:Online Update%></li>
<li class="tab-item cbi-tab" data-tab="tab-custom"><%:Custom Applications%></li>
</ul>
<div class="cbi-tabcontainer">
<div id="tab-basic" class="tab-body active cbi-tab">
<div id="feature-info" class="feature-info-card">
<div class="feature-info-grid">
<div class="feature-info-item">
<span class="feature-info-label"><%:Current version%></span>
<span class="feature-info-value" id="info-version">--</span>
</div>
<div class="feature-info-item">
<span class="feature-info-label"><%:Feature Library Type%></span>
<span class="feature-info-value" id="info-free">--</span>
</div>
<div class="feature-info-item">
<span class="feature-info-label"><%:Feature format%></span>
<span class="feature-info-value" id="info-format">--</span>
</div>
<div class="feature-info-item">
<span class="feature-info-label"><%:App number%></span>
<span class="feature-info-value" id="info-appnum">--</span>
</div>
<div class="feature-info-item">
<span class="feature-info-label"><%:Official Website%></span>
<span class="feature-info-value"><a href="http://www.openappfilter.com" target="_blank">www.openappfilter.com</a></span>
</div>
</div>
</div>
<div class="feature-app-panel">
<div class="custom-title"><%:App List%></div>
<div id="app-list" class="app-list"></div>
</div>
</div>
<div id="tab-online" class="tab-body cbi-tab">
<div class="online-panel">
<div class="custom-title"><%:Online Feature Library Update%></div>
<div class="feature-hint"><%:Visit%> <a href="http://www.openappfilter.com" target="_blank">www.openappfilter.com</a> <%:to obtain the advanced feature library update Key.%></div>
<div class="online-control-stack">
<div class="online-control-row">
<label for="online-token"><%:Subscription Key%></label>
<div class="online-token-input">
<input type="password" id="online-token" autocomplete="off">
<button type="button" class="online-token-eye" id="online-token-toggle" title="<%:Show%>" aria-label="<%:Show%>"></button>
</div>
<div class="online-actions">
<button type="button" class="cbi-button cbi-button-apply" id="online-save-token"><%:Save%></button>
</div>
</div>
<div class="feature-hint"><%:When the Key is empty, the free version can be used. After setting the Key, the premium version can be used. The premium version supports more applications, is updated regularly, and the latest supported application list can be viewed on the official website.%></div>
<div class="online-control-row">
<label for="online-lang"><%:Language%></label>
<select id="online-lang" class="online-lang-select">
<option value="en"><%:English%></option>
<option value="cn"><%:Chinese%></option>
</select>
<div class="online-actions">
<button type="button" class="cbi-button online-fetch-button" id="online-fetch-list"><%:Update List%></button>
</div>
</div>
</div>
<div class="online-device-info">
<span><%:Current Version Number%>: <strong id="online-current-version">--</strong></span>
<span><%:Feature Library Type%>: <strong id="online-current-free">--</strong></span>
<span><%:Feature format%>: <strong id="online-version">v4.0</strong></span>
</div>
<div id="online-status" class="feature-status" style="display:none;"></div>
<div id="online-announcement" class="online-announcement" style="display:none;">
<span class="announcement-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 10v4a1 1 0 0 0 1 1h3l5 4V5L8 9H5a1 1 0 0 0-1 1Z"/><path d="M16 9.5a4 4 0 0 1 0 5"/><path d="M19 7a8 8 0 0 1 0 10"/></svg>
</span>
<span id="online-announcement-text"></span>
</div>
<div id="online-list-empty" class="custom-empty"><%:Click Update List to load available feature libraries%></div>
<div id="online-list-wrap" class="online-list-wrap" style="display:none;">
<table class="common-table online-table">
<thead><tr>
<th><%:Feature Library%></th>
<th><%:Type%></th>
<th><%:Application Count%></th>
<th><%:Language%></th>
<th><%:Release Date%></th>
<th><%:Actions%></th>
</tr></thead>
<tbody id="online-list-body"></tbody>
</table>
<div id="online-card-list" class="online-card-list"></div>
</div>
</div>
</div>
<div id="tab-custom" class="tab-body cbi-tab">
<div class="custom-panel">
<div class="custom-toolbar">
<div class="custom-title"><%:Custom Applications%></div>
<button type="button" class="cbi-button cbi-button-add" id="custom-add"><%:Add Application%></button>
</div>
<div id="custom-status" class="feature-hint"><%:Loading...%></div>
<div id="custom-empty" class="custom-empty" style="display:none;"><%:No custom applications%></div>
<div class="custom-table-wrap" id="custom-table-wrap" style="display:none;">
<table class="common-table custom-table">
<thead><tr>
<th><%:Application Name%></th>
<th><%:Category%></th>
<th><%:Feature Count%></th>
<th><%:Actions%></th>
</tr></thead>
<tbody id="custom-table-body"></tbody>
</table>
<div id="custom-card-list" class="custom-card-list"></div>
</div>
</div>
</div>
</div>
</div>
<div id="custom-editor" class="custom-editor-mask" style="display:none;">
<div class="custom-editor-dialog">
<div class="custom-editor-header">
<h3 id="custom-editor-title"><%:Add Application%></h3>
<button type="button" class="editor-close" id="custom-editor-close" title="<%:Close%>">&times;</button>
</div>
<div class="custom-editor-body">
<div class="editor-basic-grid">
<div class="editor-field">
<label for="custom-name"><%:Application Name%> *</label>
<input type="text" id="custom-name" maxlength="63">
</div>
<div class="editor-field">
<label for="custom-class"><%:Category%> *</label>
<select id="custom-class"></select>
</div>
</div>
<div class="feature-editor-toolbar">
<span><%:Application Features%></span>
<button type="button" class="cbi-button cbi-button-add" id="add-feature"><%:Add Feature%></button>
</div>
<div id="feature-editors"></div>
<div id="custom-editor-error" class="editor-error" style="display:none;"></div>
</div>
<div class="custom-editor-footer">
<button type="button" class="cbi-button" id="custom-cancel"><%:Cancel%></button>
<button type="button" class="cbi-button cbi-button-apply" id="custom-save"><%:Save%></button>
</div>
</div>
</div>
<style>
.feature-container{padding:10px}.feature-page-heading{margin:2px 0 14px}.feature-page-title{font-size:22px;font-weight:700;color:var(--text-color-high,#333);line-height:1.35}.feature-page-desc{margin-top:4px;color:var(--text-color-medium,#6b7280);font-size:13px;line-height:1.5}.feature-container .tab-list{display:flex;list-style:none;padding:0;margin:10px 0 15px;border-bottom:1px solid var(--border-color-medium,#d1d5db);overflow-x:auto}.feature-container .tab-item{padding:3px 16px;cursor:pointer;border:1px solid var(--border-color-medium,#d1d5db)!important;border-bottom:none!important;margin-right:6px;border-radius:5px 5px 0 0;background:var(--background-color-low,#f5f5f5)!important;color:var(--text-color-medium,#6b7280)!important;white-space:nowrap}.feature-container .tab-item.active{background:var(--background-color-high,#fff)!important;color:var(--text-color-high,#333)!important;font-weight:bold}.feature-container .tab-item:hover{background:var(--background-color-medium,#eee)!important}.tab-body{display:none}.tab-body.active{display:block}.feature-info-card,.custom-panel{padding:16px;border:1px solid var(--border-color-low,#e5e7eb);border-radius:8px;background:var(--background-color-high,#fff);margin-bottom:12px}.feature-info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.feature-info-item{padding:10px 12px;border:1px solid var(--border-color-low,#e5e7eb);border-radius:6px;background:var(--background-color-low,#f5f5f5)}.feature-info-label{display:block;font-size:13px;color:var(--text-color-medium,#6b7280);margin-bottom:4px}.feature-info-value{font-weight:600;font-size:15px;color:var(--text-color-high,#333)}.feature-card{background:var(--background-color-high,#fff);border:1px solid var(--border-color-low,#e5e7eb);border-radius:8px;padding:16px;margin-top:12px}.feature-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.feature-label{min-width:120px;font-weight:600}.feature-inputs{display:flex;align-items:center;gap:10px;flex:1}.feature-file{width:100%;max-width:360px}.feature-btn{min-width:110px}.feature-hint{margin-top:10px;color:var(--text-color-medium,#6b7280);font-size:13px;line-height:1.5}.feature-status{margin-top:8px;font-size:14px;font-weight:600}.feature-status.success{color:#34d399}.feature-status.error,.editor-error{color:#f87171}.feature-modal{display:none;position:fixed;inset:0;background-color:rgba(0,0,0,.5);z-index:1000;justify-content:center;align-items:center}.feature-modal-content{background:var(--background-color-high,#fff);padding:16px 20px;border-radius:6px;color:var(--text-color-high,#333);min-width:200px;text-align:center;box-shadow:0 4px 12px rgba(0,0,0,.4);border:1px solid var(--border-color-low,#e5e7eb)}.app-list{margin-top:10px}.class-card{margin-bottom:16px;border:1px solid var(--border-color-low,#e5e7eb);border-radius:8px;background:var(--background-color-high,#fff)}.class-header{padding:12px 16px;font-weight:600;font-size:15px;border-bottom:1px solid var(--border-color-low,#e5e7eb);color:var(--text-color-high,#333)}.app-items{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;padding:12px 16px}.app-item{display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--border-color-medium,#d1d5db);border-radius:6px;background:var(--background-color-low,#f5f5f5);font-size:13px;cursor:default}.app-item img,.app-icon-letter{width:20px;height:20px;border-radius:4px;flex-shrink:0}.app-item img{object-fit:contain}.app-icon-letter{display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff}.app-item .app-name{color:var(--text-color-medium,#6b7280);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.custom-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px}.custom-title{font-size:17px;font-weight:600;color:var(--text-color-high,#333)}.custom-empty{text-align:center;padding:34px 12px;color:var(--text-color-medium,#6b7280);border:1px dashed var(--border-color-medium,#d1d5db);border-radius:6px}.custom-table-wrap{overflow-x:auto}.custom-table th:last-child,.custom-table td:last-child{text-align:right;width:110px}.custom-actions{display:flex;justify-content:flex-end;gap:6px}.icon-action{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;padding:0;border:1px solid var(--border-color-medium,#d1d5db);border-radius:5px;background:transparent;color:var(--text-color-medium,#6b7280);cursor:pointer}.icon-action:hover{background:var(--background-color-low,#f5f5f5);color:var(--text-color-high,#333)}.icon-action.delete:hover{color:#ef4444;border-color:#ef4444}.icon-action svg{width:17px;height:17px}.custom-card-list{display:none}.custom-editor-mask{position:fixed;inset:0;z-index:1100;background:rgba(0,0,0,.55);align-items:center;justify-content:center;padding:16px}.custom-editor-dialog{display:flex;flex-direction:column;width:min(820px,100%);max-height:calc(100vh - 32px);background:var(--background-coloLine truncated
.online-panel{padding:16px;border:1px solid var(--border-color-low,#e5e7eb);border-radius:8px;background:var(--background-color-high,#fff)}.online-token-row{display:grid;grid-template-columns:140px minmax(240px,1fr) auto;align-items:center;gap:12px;margin-top:16px}.online-token-row>label{font-weight:600}.online-token-input,.online-actions{display:flex;align-items:center;gap:8px}.online-token-input{position:relative}.online-token-input input{min-width:0;flex:1;padding-right:38px}.online-token-eye{position:absolute;right:4px;top:50%;transform:translateY(-50%);display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;border:0;background:transparent;color:var(--text-color-medium,#6b7280);cursor:pointer}.online-token-eye:hover{color:var(--text-color-high,#333)}.online-token-eye svg{width:18px;height:18px}.online-device-info{display:flex;flex-wrap:wrap;gap:10px 24px;margin:14px 0;color:var(--text-color-medium,#6b7280);font-size:13px}.online-device-info strong{color:var(--text-color-high,#333);font-weight:500}.online-announcement{display:flex;align-items:flex-start;gap:8px;margin:10px 0 12px;padding:9px 12px;border:1px solid var(--border-color-low,#e5e7eb);border-radius:6px;background:var(--background-color-low,#f5f5f5);color:var(--text-color-high,#333);font-size:13px;line-height:1.5}.announcement-icon{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;margin-top:1px;color:#f59e0b;flex-shrink:0}.announcement-icon svg{width:18px;height:18px}.online-list-wrap{overflow-x:auto;margin-top:14px}.online-table th:last-child,.online-table td:last-child{text-align:left;width:106px}.online-table tr.current-version td,.online-card.current-version{background:var(--background-color-low,#f5f5f5)}.current-version-label{color:#22c55e;font-weight:600}.online-use-version:disabled{opacity:.55;cursor:not-allowed}.online-card-list{display:none}.online-card{padding:12px;border:1px solid var(--border-color-low,#e5e7eb);border-radius:6px;background:var(--background-color-low,#f5f5f5);margin-bottom:10px}.online-card-title{font-weight:600;overflow-wrap:anywhere}.online-card-meta{margin-top:6px;color:var(--text-color-medium,#6b7280);font-size:13px}.online-card-action{margin-top:10px;text-align:left}
.online-control-stack{display:flex;flex-direction:column;gap:12px;margin-top:16px}.online-control-row{display:grid;grid-template-columns:140px minmax(240px,1fr) auto;align-items:center;gap:12px}.online-control-row>label{font-weight:600}.online-lang-select{width:180px;max-width:100%}.online-confirm-mask{position:fixed;inset:0;z-index:1200;display:flex;align-items:center;justify-content:center;padding:16px;background:rgba(0,0,0,.45)}.online-confirm-dialog{width:min(420px,calc(100vw - 32px));min-height:150px;box-sizing:border-box;padding:22px 24px;border:1px solid var(--border-color-low,#e5e7eb);border-radius:8px;background:var(--background-color-high,#fff);color:var(--text-color-high,#333);box-shadow:0 12px 30px rgba(0,0,0,.28)}.online-confirm-message{min-height:56px;line-height:1.6;text-align:center;font-size:14px;color:var(--text-color-high,#333)}.online-confirm-actions{display:flex;align-items:center;justify-content:center;gap:10px;margin-top:18px}.online-confirm-actions .cbi-button{min-width:68px;height:30px;padding:0 14px;font-size:13px;line-height:28px;border-radius:4px}.online-confirm-cancel{background:var(--background-color-low,#f5f5f5)!important;color:var(--text-color-high,#333)!important;border:1px solid var(--border-color-medium,#d1d5db)!important}.online-confirm-ok{background:#2885e8!important;color:#fff!important;border:1px solid #2885e8!important}
.common-modal-content{width:auto!important;min-width:150px!important;height:auto!important;min-height:86px!important;padding:18px 24px!important;box-sizing:border-box;font-size:14px;line-height:1.5}
.online-progress-mask{position:fixed;inset:0;z-index:1210;display:none;align-items:center;justify-content:center;padding:16px;background:rgba(0,0,0,.45)}.online-progress-mask.show{display:flex}.online-progress-modal{width:320px;max-width:90vw;min-height:120px;border-radius:10px;padding:18px 16px;background:var(--background-color-high,#fff);color:var(--text-color-high,#333);display:flex;align-items:center;justify-content:center;flex-direction:column;text-align:center;box-sizing:border-box;gap:12px;box-shadow:0 12px 30px rgba(0,0,0,.28);border:1px solid var(--border-color-low,#e5e7eb)}.online-progress-spinner{width:30px;height:30px;border:3px solid rgba(25,118,210,.2);border-top-color:var(--primary-color-high,#1976d2);border-radius:50%;animation:online-progress-spin .8s linear infinite}.online-progress-text{font-size:13px;line-height:1.5;color:var(--text-color-high,#374151)}[data-darkmode="true"] .online-progress-modal{background:#2d2d2d;color:#f5f5f5}[data-darkmode="true"] .online-progress-spinner{border-color:rgba(59,130,246,.2);border-top-color:#3b82f6}[data-darkmode="true"] .online-progress-text{color:rgba(255,255,255,.88)}@keyframes online-progress-spin{to{transform:rotate(360deg)}}
.online-premium-text,.online-premium-cell,.online-card.online-premium-row .online-card-title,.online-card.online-premium-row .online-card-meta{color:#2885e8}[data-darkmode="true"] .online-premium-text,[data-darkmode="true"] .online-premium-cell,[data-darkmode="true"] .online-card.online-premium-row .online-card-title,[data-darkmode="true"] .online-card.online-premium-row .online-card-meta{color:#58a6ff}
.online-fetch-button{background:#2885e8!important;border-color:#2885e8!important;color:#fff!important}.online-fetch-button:hover{background:#1d74d8!important;border-color:#1d74d8!important;color:#fff!important}.online-fetch-button:disabled{opacity:.6;color:#fff!important}
.feature-app-panel{padding:16px;border:1px solid var(--border-color-low,#e5e7eb);border-radius:8px;background:var(--background-color-high,#fff);margin-top:12px}.class-header{display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;box-sizing:border-box;background:transparent;border:0;cursor:pointer;text-align:left}.class-title{font-weight:600;color:var(--text-color-high,#333);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.class-count{display:inline-flex;align-items:center;justify-content:center;min-width:32px;height:22px;padding:0 8px;border-radius:999px;background:var(--background-color-low,#f5f5f5);color:var(--text-color-medium,#6b7280);font-size:12px}.class-chevron{width:8px;height:8px;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg);transition:transform .2s;color:var(--text-color-medium,#6b7280);flex-shrink:0}.class-card.collapsed .class-chevron{transform:rotate(-45deg)}.class-card.collapsed .app-items{display:none}
.feature-app-panel .class-card{margin-bottom:5px;padding:10px;border:1px solid var(--border-color-low,#e5e7eb);border-radius:6px;background:var(--background-color-low,#f5f5f5)}.feature-app-panel .class-header{padding:8px;border:0!important;border-radius:4px;background:var(--background-color-medium,#eee);margin:0;color:var(--text-color-high,#333)}.feature-app-panel .class-title{font-size:14px}.feature-app-panel .app-items{display:flex;flex-wrap:wrap;gap:15px;padding:10px 8px 4px}.feature-app-panel .app-item{width:calc(20% - 12px);min-width:120px;max-width:180px;padding:5px 0;border:0;background:transparent;box-sizing:border-box}.feature-app-panel .app-name{color:var(--text-color-medium,#6b7280)}
@media(min-width:800px){.app-items{grid-template-columns:repeat(6,1fr)}}@media(min-width:1200px){.app-items{grid-template-columns:repeat(8,1fr)}}
@media(max-width:640px){.feature-container{padding:4px}.feature-info-card,.custom-panel,.online-panel{padding:10px}.feature-inputs{align-items:stretch;flex-direction:column}.feature-btn{width:100%}.custom-table,.online-table{display:none}.custom-card-list,.online-card-list{display:block}.custom-toolbar{align-items:stretch;flex-direction:column}.custom-toolbar .cbi-button{width:100%}.editor-basic-grid,.feature-field-grid{grid-template-columns:1fr}.custom-editor-mask{padding:8px}.custom-editor-dialog{max-height:calc(100vh - 16px)}.custom-editor-footer .cbi-button{flex:1}.online-token-row{grid-template-columns:1fr}.online-actions{align-items:stretch;flex-direction:column}.online-actions .cbi-button{width:100%}.online-token-input{width:100%}}
@media(max-width:640px){.online-control-row{grid-template-columns:1fr}.online-lang-select{width:100%}}
</style>
<script>
(function($){
var apiClassList='<%=url("admin/services/oaf/feature/class_list")%>';
var apiInfo='<%=url("admin/services/oaf/feature/info")%>';
var apiOnlineConfig='<%=url("admin/services/oaf/feature/online_config")%>';
var apiOnlineSave='<%=url("admin/services/oaf/feature/online_save")%>';
var apiOnlineList='<%=url("admin/services/oaf/feature/online_list")%>';
var apiOnlineStart='<%=url("admin/services/oaf/feature/online_start")%>';
var apiOnlineStatus='<%=url("admin/services/oaf/feature/online_status")%>';
var apiCustomList='<%=url("admin/services/oaf/feature/custom_list")%>';
var apiCustomClasses='<%=url("admin/services/oaf/feature/custom_class_list")%>';
var apiCustomSave='<%=url("admin/services/oaf/feature/custom_save")%>';
var txt={loadFailed:'<%:Load failed%>',saveFailed:'<%:Save failed%>',saved:'<%:Saved successfully%>',loading:'<%:Loading...%>',noData:'<%:No data%>',noApps:'<%:No apps%>',unknown:'<%:Unknown%>',add:'<%:Add Application%>',edit:'<%:Edit Application%>',feature:'<%:Feature%>',position:'<%:Position%>',value:'<%:Value%>',deleteConfirm:'<%:Are you sure you want to delete this application?%>',invalidName:'<%:Invalid application name%>',invalidClass:'<%:Please select a category%>',invalidPort:'<%:Destination port must be a single port or port range between 1 and 65535%>',invalidDomain:'<%:Invalid domain%>',invalidUri:'<%:Invalid URI%>',invalidPayload:'<%:Invalid payload match format%>',invalidPosition:'<%:Payload position must be a signed integer%>',emptyFeature:'<%:At least one match field must be configured for each feature%>',maxApps:'<%:A maximum of 64 custom applications is allowed%>',maxFeatures:'<%:A maximum of 16 features is allowed%>',maxPayloads:'<%:A maximum of 15 payload matches is allowed%>'};
var onlineTxt={useVersion:'<%:Switch%>',confirm:'<%:Use this feature library version now?%>',confirmAction:'<%:Confirm%>',cancel:'<%:Cancel%>',success:'<%:Feature library updated successfully%>',failed:'<%:Feature library update failed%>',timeout:'<%:Feature library update timeout%>',invalidToken:'<%:Invalid subscription key%>',vipRequired:'<%:A valid subscription is required%>',rateLimited:'<%:Too many requests. Please try again later.%>',deviceLimited:'<%:The subscription device limit has been reached%>',serviceUnavailable:'<%:Subscription service is unavailable%>',formatError:'<%:Feature library format error. Please use the feature library for this version.%>',fileTooLarge:'<%:The downloaded file is too large%>',checksumError:'<%:Feature library checksum verification failed%>',updating:'<%:Updating, please wait...%>',updatingList:'<%:Updating list, please wait...%>',tokenSaved:'<%:Saved successfully%>',updated:'<%:Updated successfully%>',switchSuccess:'<%:Switched successfully%>',show:'<%:Show%>',hide:'<%:Hide%>',downloading:'<%:Downloading%>',extracting:'<%:Extracting%>',validating:'<%:Validating%>',applying:'<%:Applying%>',lite:'<%:Lite%>',plus:'<%:Plus%>',applicationCount:'<%:Application Count%>',chinese:'<%:Chinese%>',english:'<%:English%>',currentVersion:'<%:Current Version%>',updateListHint:'<%:Click Update List to get the latest feature libraries%>',freeVersion:'<%:Free%>',premiumVersion:'<%:Premium%>',keyRequired:'<%:Please enter a valid Key. You can apply for a Key on the official website.%>',keyRequiredVersion:'<%:Enter the Key to use this feature library version%>',invalidKeyPermission:'<%:Invalid Key. Please get the correct Key from the official website and enable the advanced feature library permission.%>'};
var customApps=[];
var customClasses=[];
var customLoaded=false;
var editingIndex=-1;
var originalClassId=0;
var iconStatusCache={};
var onlineLoaded=false;
var onlinePolling=false;
var onlinePollStartedAt=0;
var onlinePollTimeout=180000;
var currentVersionNumber=$.trim($('#online-current-version').text()||$('#info-version').text()||'');
var currentFeatureType=0;
var currentFeatureFree=0;
var currentOnlineFiles=[];
function clone(value){return JSON.parse(JSON.stringify(value));}
function utf8Length(value){return unescape(encodeURIComponent(value || '')).length;}
function showMessage(message,ok){if(window.showCommonModal){showCommonModal(message,2500,ok?'success':'error');}}
function showOnlineToast(message,ok){if(window.showCommonModal)showCommonModal(message,2000,ok?'success':'error');else{setOnlineStatus(message,ok);setTimeout(function(){setOnlineStatus('',true);},2000);}}
function showOnlineProgress(message){var mask=$('.online-progress-mask');if(!mask.length){mask=$('<div>').addClass('online-progress-mask');var modal=$('<div>').addClass('online-progress-modal');modal.append($('<div>').addClass('online-progress-spinner'),$('<div>').addClass('online-progress-text'));mask.append(modal).appendTo(document.body);}mask.find('.online-progress-text').text(message||txt.loading);mask.addClass('show');}
function updateOnlineProgress(message){var mask=$('.online-progress-mask');if(mask.length)mask.find('.online-progress-text').text(message||txt.loading);else showOnlineProgress(message);}
function closeOnlineProgress(){$('.online-progress-mask').removeClass('show').remove();}
function browserRawLanguage(){var langs=navigator.languages&&navigator.languages.length?navigator.languages:[navigator.language||navigator.userLanguage||''];return String(langs[0]||'');}
function browserDefaultOnlineLang(){var lang=browserRawLanguage().toLowerCase();if(lang.indexOf('zh')===0)return 'cn';if(lang.indexOf('en')===0)return 'en';return 'en';}
function showOnlineSwitchConfirm(onConfirm){$('.online-confirm-mask').remove();var mask=$('<div>').addClass('online-confirm-mask');var dialog=$('<div>').addClass('online-confirm-dialog');var message=$('<div>').addClass('online-confirm-message').text(onlineTxt.confirm);var actions=$('<div>').addClass('online-confirm-actions');var cancel=$('<button>').attr('type','button').addClass('cbi-button online-confirm-cancel').text(onlineTxt.cancel);var ok=$('<button>').attr('type','button').addClass('cbi-button cbi-button-apply online-confirm-ok').text(onlineTxt.confirmAction);cancel.on('click',function(){mask.remove();});ok.on('click',function(){mask.remove();if(onConfirm)onConfirm();});actions.append(cancel,ok);dialog.append(message,actions);mask.append(dialog).appendTo(document.body);}
function setEditorError(message){$('#custom-editor-error').text(message||'').toggle(!!message);}
function className(classId){for(var i=0;i<customClasses.length;i++){if(Number(customClasses[i].class_id)===Number(classId))return customClasses[i].class_name;}return '--';}
function appLetterColor(name){return window.OAFIcon?window.OAFIcon.hashColor(name):'#3b82f6';}
function editIcon(){return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L8 18l-4 1 1-4Z"/></svg>';}
function deleteIcon(){return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v5M14 11v5"/></svg>';}
function eyeIcon(visible){return visible?'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3l18 18"/><path d="M10.6 10.6a2 2 0 0 0 2.8 2.8"/><path d="M9.9 4.2A10.7 10.7 0 0 1 12 4c5 0 9 5 10 8a14.5 14.5 0 0 1-3.1 4.8"/><path d="M6.6 6.6A14.5 14.5 0 0 0 2 12c1 3 5 8 10 8 1.5 0 2.9-.4 4.1-1.1"/></svg>':'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>';}
function onlineError(data){var code=Number(data&&data.status_code)||400;var message=data&&data.message?String(data.message):'';if(code===40000||code===40001||code===40010)return onlineTxt.invalidKeyPermission;if(code===40031)return onlineTxt.deviceLimited;if(code===40029)return onlineTxt.rateLimited;if(code===401)return onlineTxt.formatError;if(code===402)return onlineTxt.fileTooLarge;if(code===403)return onlineTxt.checksumError;return message?message:onlineTxt.failed;}
function setOnlineStatus(message,ok){var box=$('#online-status');box.text(message||'').toggle(!!message).removeClass('success error');if(message&&ok===true)box.addClass('success');else if(message&&ok===false)box.addClass('error');}
function setOnlineBusy(busy){$('#online-save-token,#online-fetch-list,.online-use-version,#online-lang').prop('disabled',!!busy);}
function formatOnlineSize(bytes){var value=Number(bytes)||0;var units=['B','KB','MB','GB'];var index=0;while(value>=1024&&index<units.length-1){value=value/1024;index++;}if(index===0)return Math.floor(value)+units[index];return (Math.floor(value*10)/10).toFixed(1).replace(/\.0$/,'')+units[index];}
function onlineProgressMessage(data,stageText){var now=Number(data&&data.download_now)||0;var total=Number(data&&data.download_total)||0;if(data&&data.stage==='downloading'&&(now>0||total>0)){var text=now>0?formatOnlineSize(now):'0B';if(total>0){var percent=Math.min(100,Math.max(0,Math.floor(now*100/total)));text+=' / '+formatOnlineSize(total)+' ('+percent+'%)';}return onlineTxt.updating+' ('+stageText+' '+text+')';}return onlineTxt.updating+' ('+stageText+')';}
function featureVersionDisplay(version,type){var text=String(version||'').trim();return text?'v'+text.replace(/^[vV]/,'')+'('+onlineTypeLabel(type)+')':'--';}
function refreshFeatureInfo(done){$.get(apiInfo,function(resp){var data=resp&&resp.code===2000?resp.data:null;if(!data)return;currentVersionNumber=data.version||'';currentFeatureType=data.type;currentFeatureFree=data.free;$('#info-version').text(featureVersionDisplay(data.version,data.type));$('#info-free').text(onlineFreeLabel(data.free));$('#online-current-version').text(featureVersionDisplay(data.version,data.type));$('#online-current-free').text(onlineFreeLabel(data.free));$('#info-format').text(data.format||'--');$('#info-appnum').text(data.app_count===undefined?'--':data.app_count);}).always(function(){if(done)done();});}
function loadOnlineConfig(){setOnlineStatus(txt.loading);$.get(apiOnlineConfig,function(resp){if(!resp||resp.code!==2000||!resp.data){setOnlineStatus(onlineError(resp&&resp.data),false);return;}$('#online-token').val(resp.data.token||'');$('#online-version').text(resp.data.version||'v4.0');$('#online-current-version').text(featureVersionDisplay(currentVersionNumber,currentFeatureType));$('#online-current-free').text(onlineFreeLabel(currentFeatureFree));onlineLoaded=true;setOnlineStatus('',true);loadOnlineList(false);}).fail(function(){setOnlineStatus(txt.loadFailed,false);});}
function saveOnlineToken(){var token=$.trim($('#online-token').val()||'');$('#online-save-token').prop('disabled',true);$.post(apiOnlineSave,{token:token},function(resp){if(resp&&resp.code===2000){setOnlineStatus('',true);showOnlineToast(onlineTxt.tokenSaved,true);}else{setOnlineStatus(onlineError(resp&&resp.data),false);}},'json').fail(function(){setOnlineStatus(txt.saveFailed,false);}).always(function(){$('#online-save-token').prop('disabled',false);});}
function normalizeFeatureVersion(version){return String(version||'').trim().replace(/^[vV]/,'').toLowerCase();}
function onlineHasKey(){return !!$.trim($('#online-token').val()||'');}
function updateOnlineUseButtons(){var hasKey=onlineHasKey();$('.online-use-version').each(function(){var btn=$(this);var current=String(btn.attr('data-current')||'0')==='1';var premium=String(btn.attr('data-free')||'0')!=='1';var blocked=premium&&!hasKey;btn.prop('disabled',current||blocked).toggleClass('disabled',current||blocked).attr('title',blocked?onlineTxt.keyRequiredVersion:'');});}
function onlineUseButton(id,disabled,lang,md5,free){var blocked=Number(free)!==1&&!onlineHasKey();return $('<button>').attr({type:'button','data-id':id,'data-lang':lang||'cn','data-md5':md5||'','data-free':Number(free)===1?1:0,'data-current':disabled?1:0,title:blocked?onlineTxt.keyRequiredVersion:''}).prop('disabled',!!disabled||blocked).toggleClass('disabled',!!disabled||blocked).addClass('cbi-button cbi-button-apply online-use-version').text(onlineTxt.useVersion);}
function onlineTypeLabel(type){return Number(type)===1?onlineTxt.plus:onlineTxt.lite;}
function onlineFreeLabel(value){return Number(value)===1?onlineTxt.freeVersion:onlineTxt.premiumVersion;}
function onlineLangLabel(lang){return String(lang||'cn').toLowerCase()==='en'?onlineTxt.english:onlineTxt.chinese;}
function isCurrentOnlineVersion(file){var current=normalizeFeatureVersion(currentVersionNumber);var target=normalizeFeatureVersion(file&&file.version);return !!current&&!!target&&current===target&&Number(currentFeatureType)===Number(file&&file.type)&&Number(currentFeatureFree)===Number(file&&file.free);}
function onlineVersionCell(file){var wrap=$('<span>').text(featureVersionDisplay(file&&file.version,file&&file.type));if(isCurrentOnlineVersion(file))wrap.append(document.createTextNode(' - '),$('<span>').addClass('current-version-label').text(onlineTxt.currentVersion));return wrap;}
function renderOnlineList(data,refresh){currentOnlineFiles=(data&&data.files?data.files:[]).slice().sort(function(a,b){return (Number(a&&a.free)===1?1:0)-(Number(b&&b.free)===1?1:0);});var body=$('#online-list-body').empty();var cards=$('#online-card-list').empty();var announcement=$.trim(data&&data.announcement?data.announcement:'');if(!currentOnlineFiles.length){$('#online-announcement').hide();$('#online-list-wrap').hide();$('#online-list-empty').text(refresh?txt.noData:onlineTxt.updateListHint).show();return;}if(announcement){$('#online-announcement-text').text(announcement);$('#online-announcement').css('display','flex');}else{$('#online-announcement').hide();}$('#online-list-empty').hide();$('#online-list-wrap').show();currentOnlineFiles.forEach(function(file){var count=Math.max(0,Number(file.count)||0);var current=isCurrentOnlineVersion(file);var isFree=Number(file&&file.free)===1;var freeText=onlineFreeLabel(file.free);var premiumClass=isFree?'':'online-premium-cell';var freeCell=$('<span>').text(freeText).toggleClass('online-premium-text',!isFree);var row=$('<tr>').toggleClass('current-version',current).toggleClass('online-premium-row',!isFree);row.append($('<td>').append(onlineVersionCell(file).addClass(premiumClass)),$('<td>').append(freeCell),$('<td>').append($('<span>').addClass(premiumClass).text(count)),$('<td>').append($('<span>').addClass(premiumClass).text(onlineLangLabel(file.lang))),$('<td>').append($('<span>').addClass(premiumClass).text(file.date||'')),$('<td>').append(onlineUseButton(file.id,current,file.lang,file.md5,file.free)));body.append(row);var card=$('<div>').addClass('online-card').toggleClass('current-version',current).toggleClass('online-premium-row',!isFree);card.append($('<div>').addClass('online-card-title').append(onlineVersionCell(file)),$('<div>').addClass('online-card-meta').append($('<span>').text(freeText).toggleClass('online-premium-text',!isFree),document.createTextNode(' / '+onlineTxt.applicationCount+': '+count+' / '+onlineLangLabel(file.lang))),$('<div>').addClass('online-card-meta').text(file.date||''),$('<div>').addClass('online-card-action').append(onlineUseButton(file.id,current,file.lang,file.md5,file.free)));cards.append(card);});}
function loadOnlineList(refresh){if(refresh)showOnlineProgress(onlineTxt.updatingList);else setOnlineStatus(txt.loading);$('#online-fetch-list').prop('disabled',true);$.get(apiOnlineList,{lang:$('#online-lang').val()||'cn',device_lang:browserRawLanguage(),refresh:refresh?1:0},function(resp){if(resp&&resp.code===2000&&resp.data){renderOnlineList(resp.data,refresh);setOnlineStatus('',true);if(refresh){closeOnlineProgress();showOnlineToast(onlineTxt.updated,true);}}else{if(refresh)closeOnlineProgress();setOnlineStatus(onlineError(resp&&resp.data),false);}}).fail(function(){if(refresh)closeOnlineProgress();setOnlineStatus(txt.loadFailed,false);}).always(function(){$('#online-fetch-list').prop('disabled',false);});}
function fetchOnlineList(){loadOnlineList(true);}
function handleOnlineStatus(data){var state=data&&data.state?data.state:'idle';var stages={downloading:onlineTxt.downloading,extracting:onlineTxt.extracting,validating:onlineTxt.validating,applying:onlineTxt.applying};var stageText=stages[data&&data.stage]||data&&data.stage||'';if(state==='running'){if(!onlinePollStartedAt)onlinePollStartedAt=Date.now();setOnlineBusy(true);updateOnlineProgress(onlineProgressMessage(data,stageText));return true;}setOnlineBusy(false);if(!onlinePollStartedAt)return false;closeOnlineProgress();if(state==='success'){setOnlineStatus('',true);showOnlineToast(onlineTxt.switchSuccess,true);setTimeout(function(){refreshFeatureInfo(function(){loadOnlineList(false);});},2000);}else if(state==='failed'){setOnlineStatus(onlineError(data),false);}return false;}
function pollOnlineStatus(){if(onlinePolling)return;onlinePolling=true;var request=function(){if(onlinePollStartedAt&&Date.now()-onlinePollStartedAt>onlinePollTimeout){onlinePolling=false;onlinePollStartedAt=0;setOnlineBusy(false);closeOnlineProgress();setOnlineStatus(onlineTxt.timeout,false);return;}$.ajax({url:apiOnlineStatus,dataType:'json',timeout:5000}).done(function(resp){var running=resp&&resp.code===2000&&handleOnlineStatus(resp.data||{});if(running)setTimeout(request,1000);else{onlinePolling=false;onlinePollStartedAt=0;}}).fail(function(){onlinePolling=false;onlinePollStartedAt=0;setOnlineBusy(false);closeOnlineProgress();setOnlineStatus(txt.loadFailed,false);});};request();}
function startOnlineUpdate(id,lang,md5){var run=function(){setOnlineBusy(true);onlinePollStartedAt=Date.now();setOnlineStatus('',true);showOnlineProgress(onlineTxt.updating);$.ajax({url:apiOnlineStart,type:'POST',data:{id:id,lang:lang||'cn',md5:md5||''},dataType:'json',timeout:15000}).done(function(resp){if(resp&&resp.code===2000){onlinePolling=false;pollOnlineStatus();}else{onlinePollStartedAt=0;setOnlineBusy(false);closeOnlineProgress();setOnlineStatus(onlineError(resp&&resp.data),false);}}).fail(function(){onlinePollStartedAt=0;setOnlineBusy(false);closeOnlineProgress();setOnlineStatus(onlineTxt.failed,false);});};showOnlineSwitchConfirm(run);}
function loadClassList(){$.get(apiClassList,function(data){renderClassList(data&&data.class_list?data.class_list:[]);}).fail(function(){$('#app-list').empty().append($('<div>').addClass('feature-hint').text(txt.loadFailed));});}
function renderClassList(list){var container=$('#app-list').empty();if(!list.length){container.append($('<div>').addClass('feature-hint').text(txt.noData));return;}list.forEach(function(category,index){var collapsed=index!==0;var card=$('<div>').addClass('class-card').toggleClass('collapsed',collapsed);var appList=category.app_list||[];var header=$('<button>').attr({type:'button','aria-expanded':collapsed?'false':'true','data-index':index}).addClass('class-header');header.append($('<span>').addClass('class-title').text((category.name||txt.unknown)+'('+appList.length+')'),$('<span>').addClass('class-chevron').attr('aria-hidden','true'));var apps=$('<div>').addClass('app-items');if(!appList.length){apps.append($('<div>').addClass('feature-hint').text(txt.noApps));}else{appList.forEach(function(raw){var parts=raw.split(',');var appId=parts[0]||'';var appName=parts[1]||txt.unknown;var hasIcon=parts[2]!=='0';var item=$('<div>').addClass('app-item');var icon=$('<span>').addClass('app-icon-letter').css('background',appLetterColor(appName)).text(appName.charAt(0).toUpperCase());var src='<%=resource%>/oaf/app_icons/'+encodeURIComponent(appId)+'.png';if(hasIcon&&appId&&iconStatusCache[appId]!=='failed'){var loader=new Image();loader.onload=function(){iconStatusCache[appId]='loaded';icon.replaceWith($('<img>').attr({src:src,alt:appName}));};loader.onerror=function(){iconStatusCache[appId]='failed';};loader.src=src;}item.append(icon).append($('<span>').addClass('app-name').text(appName));apps.append(item);});}card.append(header,apps);container.append(card);});}
function loadCustomData(){customLoaded=false;$('#custom-status').text(txt.loading).show();$('#custom-empty,#custom-table-wrap').hide();$.when($.get(apiCustomClasses),$.get(apiCustomList)).done(function(classResult,appResult){var classResp=classResult[0]||{};var appResp=appResult[0]||{};if(classResp.code!==2000||appResp.code!==2000){renderCustomLoadError();return;}customClasses=classResp.data&&classResp.data.class_list?classResp.data.class_list:[];customApps=appResp.data&&appResp.data.app_list?appResp.data.app_list:[];customLoaded=true;renderCustomList();}).fail(renderCustomLoadError);}
function renderCustomLoadError(){$('#custom-status').text(txt.loadFailed).show();$('#custom-empty,#custom-table-wrap').hide();}
function actionButtons(index){var wrap=$('<div>').addClass('custom-actions');wrap.append($('<button>').attr({type:'button',title:'<%:Edit%>','data-index':index}).addClass('icon-action custom-edit').html(editIcon()));wrap.append($('<button>').attr({type:'button',title:'<%:Delete%>','data-index':index}).addClass('icon-action delete custom-delete').html(deleteIcon()));return wrap;}
function renderCustomList(){var body=$('#custom-table-body').empty();var cards=$('#custom-card-list').empty();$('#custom-status').hide();$('#custom-add').prop('disabled',customApps.length>=64);if(!customApps.length){$('#custom-empty').show();$('#custom-table-wrap').hide();return;}$('#custom-empty').hide();$('#custom-table-wrap').show();customApps.forEach(function(app,index){var row=$('<tr>');row.append($('<td>').text(app.name||''),$('<td>').text(className(app.class_id)),$('<td>').text((app.features||[]).length),$('<td>').append(actionButtons(index)));body.append(row);var card=$('<div>').addClass('custom-card');var head=$('<div>').addClass('custom-card-head').append($('<span>').text(app.name||''),actionButtons(index));card.append(head,$('<div>').addClass('custom-card-meta').text(className(app.class_id)+' · '+(app.features||[]).length+' <%:Features%>'));cards.append(card);});}
function emptyFeature(){return {protocol:'tcp',dest_port:'',domain:'',uri:'',payload:[]};}
function populateClassSelect(selected){var select=$('#custom-class').empty();customClasses.forEach(function(item){select.append($('<option>').val(item.class_id).text(item.class_name));});if(selected)select.val(String(selected));}
function editorField(label,input,help){var field=$('<div>').addClass('editor-field').append($('<label>').text(label),input);if(help)field.append($('<span>').addClass('field-help').text(help));return field;}
function validDestPort(value){var match=/^(\d{1,5})(?:-(\d{1,5}))?$/.exec(value);if(!match)return false;var start=Number(match[1]);var end=match[2]!==undefined?Number(match[2]):start;return start>=1&&start<=65535&&end>=start&&end<=65535;}
function payloadToText(payload){return (payload||[]).map(function(item){var position=Number(item.position);var positionText=position>=0&&position<10?'0'+position:String(position);return positionText+':'+String(item.value||'').toLowerCase();}).join(',');}
function parsePayloadText(text){if(!text)return[];var items=text.split(',');if(items.length>15)throw txt.maxPayloads;return items.map(function(item){var match=/^(-?\d+):([0-9a-f]{2})$/i.exec($.trim(item));if(!match)throw txt.invalidPayload;var position=Number(match[1]);if(position<-2147483648||position>2147483647)throw txt.invalidPosition;return {position:position,value:match[2].toLowerCase()};});}
function addFeatureEditor(feature){var container=$('#feature-editors');if(container.children('.feature-editor').length>=16){setEditorError(txt.maxFeatures);return;}feature=feature||emptyFeature();var editor=$('<div>').addClass('feature-editor');var header=$('<div>').addClass('feature-editor-header').append($('<span>').addClass('feature-title').text(txt.feature),$('<button>').attr({type:'button',title:'<%:Remove%>'}).addClass('feature-remove').html('&times;'));var body=$('<div>').addClass('feature-editor-body');var generalHelp=$('<div>').addClass('field-help feature-general-help').text('<%:Leave blank for no restriction%>');var grid=$('<div>').addClass('feature-field-grid');var protocol=$('<select>').addClass('feature-protocol').append($('<option>').val('tcp').text('TCP'),$('<option>').val('udp').text('UDP')).val(feature.protocol||'tcp');grid.append(editorField('<%:Protocol%> *',protocol,'<%:Select TCP or UDP.%>'),editorField('<%:Destination Port%>',$('<input>').attr({type:'text',maxlength:'11'}).addClass('feature-port').val(feature.dest_port||''),'<%:Supports a single destination port or port range, for example 80 or 80-443.%>'),editorField('<%:Domain%>',$('<input>').attr({type:'text',maxlength:'31'}).addClass('feature-domain').val(feature.domain||'')),editorField('URI',$('<input>').attr({type:'text',maxlength:'63'}).addClass('feature-uri').val(feature.uri||''),'<%:The HTTP request URI. HTTPS does not support URI matching.%>'));
var payloadInput=$('<input>').attr({type:'text',maxlength:'255'}).addClass('feature-payload').val(payloadToText(feature.payload));var payloadField=editorField('<%:Payload Match%>',payloadInput,'<%:Matches hexadecimal Layer 7 payload content at specified positions. Format: pos:value,pos:value, for example 00:0a,02:ab. Position 0 is the first byte.%>');payloadField.css('margin-top','12px');body.append(generalHelp,grid,payloadField);editor.append(header,body);container.append(editor);}
function openEditor(index){if(!customLoaded||!customClasses.length){showMessage(txt.loadFailed,false);return;}editingIndex=typeof index==='number'?index:-1;if(editingIndex<0&&customApps.length>=64){showMessage(txt.maxApps,false);return;}var app=editingIndex>=0?clone(customApps[editingIndex]):{appid:0,class_id:customClasses[0].class_id,name:'',features:[emptyFeature()]};originalClassId=Number(app.class_id)||0;$('#custom-editor-title').text(editingIndex>=0?txt.edit:txt.add);$('#custom-name').val(app.name||'');populateClassSelect(app.class_id);$('#feature-editors').empty();(app.features&&app.features.length?app.features:[emptyFeature()]).forEach(addFeatureEditor);setEditorError('');$('#custom-editor').css('display','flex');}
function closeEditor(){$('#custom-editor').hide();setEditorError('');}
function collectEditor(){var name=$.trim($('#custom-name').val()||'');var classId=Number($('#custom-class').val());if(!name||utf8Length(name)>63||/[~\:\[\];,]/.test(name))throw txt.invalidName;if(!classId)throw txt.invalidClass;var features=[];$('#feature-editors .feature-editor').each(function(){var editor=$(this);var port=$.trim(editor.find('.feature-port').val()||'');var domain=$.trim(editor.find('.feature-domain').val()||'');var uri=$.trim(editor.find('.feature-uri').val()||'');var payload=parsePayloadText($.trim(editor.find('.feature-payload').val()||''));if(port&&!validDestPort(port))throw txt.invalidPort;if(utf8Length(domain)>31||/[\s;,\[\]]/.test(domain))throw txt.invalidDomain;if(utf8Length(uri)>63||/[\s;,\[\]]/.test(uri))throw txt.invalidUri;if(!port&&!domain&&!uri&&!payload.length)throw txt.emptyFeature;features.push({protocol:editor.find('.feature-protocol').val(),dest_port:port,domain:domain,uri:uri,payload:payload});});if(!features.length)throw txt.emptyFeature;var old=editingIndex>=0?customApps[editingIndex]:null;return {appid:old&&Number(old.class_id)===classId?old.appid:0,class_id:classId,name:name,features:features};}
function saveAppList(list,onSuccess,onFailure){$('#custom-save').prop('disabled',true);$.ajax({url:apiCustomSave,type:'POST',dataType:'json',data:{data:JSON.stringify({app_list:list})}}).done(function(resp){if(resp&&resp.code===2000&&resp.data&&resp.data.app_list){customApps=resp.data.app_list;renderCustomList();if(onSuccess)onSuccess();showMessage(txt.saved,true);}else{var error=resp&&resp.data&&resp.data.error?resp.data.error:txt.saveFailed;if(onFailure)onFailure(error);else showMessage(error,false);}}).fail(function(){if(onFailure)onFailure(txt.saveFailed);else showMessage(txt.saveFailed,false);}).always(function(){$('#custom-save').prop('disabled',false);});}
function saveEditor(){setEditorError('');if(editingIndex<0&&customApps.length>=64){setEditorError(txt.maxApps);return;}var app;try{app=collectEditor();}catch(error){setEditorError(String(error));return;}var next=clone(customApps);if(editingIndex>=0)next[editingIndex]=app;else next.push(app);saveAppList(next,function(){closeEditor();},setEditorError);}
function deleteApplication(index){var remove=function(){var next=clone(customApps);next.splice(index,1);saveAppList(next);};if(window.showConfirmModal)showConfirmModal(txt.deleteConfirm,remove,null,'<%:Delete%>','<%:Cancel%>');else if(window.confirm(txt.deleteConfirm))remove();}
$(document).on('click','.tab-item',function(){var tabId=$(this).data('tab');$('.tab-item').removeClass('active');$(this).addClass('active');$('.tab-body').removeClass('active');$('#'+tabId).addClass('active');if(tabId==='tab-basic'){refreshFeatureInfo();loadClassList();}else if(tabId==='tab-online'){refreshFeatureInfo(function(){loadOnlineConfig();});pollOnlineStatus();}else if(tabId==='tab-custom'){loadCustomData();}});
$(document).on('click','.class-header',function(){var card=$(this).closest('.class-card');var collapsed=!card.hasClass('collapsed');card.toggleClass('collapsed',collapsed);$(this).attr('aria-expanded',collapsed?'false':'true');});
$('#online-token-toggle').html(eyeIcon(false));
$(document).on('click','#online-token-toggle',function(){var input=$('#online-token');var show=input.attr('type')==='password';input.attr('type',show?'text':'password');$(this).html(eyeIcon(show)).attr({title:show?onlineTxt.hide:onlineTxt.show,'aria-label':show?onlineTxt.hide:onlineTxt.show});});
$(document).on('input','#online-token',updateOnlineUseButtons);
$(document).on('click','#online-save-token',saveOnlineToken);
$(document).on('click','#online-fetch-list',fetchOnlineList);
$(document).on('click','.online-use-version',function(){if($(this).prop('disabled'))return;if(String($(this).attr('data-free')||'0')!=='1'&&!$.trim($('#online-token').val()||'')){showOnlineToast(onlineTxt.keyRequired,false);return;}startOnlineUpdate(String($(this).attr('data-id')||''),String($(this).attr('data-lang')||'cn'),String($(this).attr('data-md5')||''));});
$(document).on('click','#custom-add',function(){openEditor();});
$(document).on('click','.custom-edit',function(){openEditor(Number($(this).data('index')));});
$(document).on('click','.custom-delete',function(){deleteApplication(Number($(this).data('index')));});
$(document).on('click','#custom-editor-close,#custom-cancel',closeEditor);
$(document).on('click','#custom-save',saveEditor);
$(document).on('click','#add-feature',function(){addFeatureEditor(emptyFeature());});
$(document).on('click','.feature-remove',function(){if($('#feature-editors .feature-editor').length<=1){setEditorError(txt.emptyFeature);return;}$(this).closest('.feature-editor').remove();});
$('#custom-editor').on('click',function(event){if(event.target===this)closeEditor();});
$(function(){var defaultLang=browserDefaultOnlineLang();$('#online-lang').val(defaultLang);refreshFeatureInfo();pollOnlineStatus();loadClassList();});
})(jQuery);
</script>
<%+footer%>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,495 @@
<link rel="stylesheet" href="<%=resource%>/oaf/css/common.css">
<style>
.user-list {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
.user-list th, .user-list td {
padding: 8px;
border: 1px solid #ddd;
text-align: left;
}
/* Add user button */
.add-user-btn {
margin-top: 10px;
padding: 5px 15px;
background-color: #113861;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
.mode-selection {
margin-bottom: 10px;
margin-top: 10px;
}
/* Modal styles */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.modal-content {
background-color: white;
margin: 15% auto;
padding: 20px;
border-radius: 5px;
width: 60%;
max-width: 600px;
}
.user-selection {
max-height: 300px;
overflow-y: auto;
margin: 15px 0;
}
.user-item {
padding: 8px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
}
.whitelist-section {
margin-top: 30px;
display: none;
}
.whitelist-section.show {
display: block;
}
.whitelist-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
}
</style>
<script type="text/javascript">//<![CDATA[
let userData = {
users: [],
mode: 1
};
let allUsers = [
];
function initData() {
userData.list = [
];
getAppFilterUserData();
getAllUsersData();
getWhitelistData();
}
function getAllUsersData() {
new XHR().get('<%=url('admin/services/oaf/api/get_all_users')%>', {flag: 2, page: 0},
function (x, data) {
if (Array.isArray(data.data.list))
allUsers = data.data.list;
renderAutoUserData();
}
);
}
function getAppFilterUserData() {
new XHR().get('<%=url('admin/services/oaf/api/mac_filter/get_mac_filter_user')%>', null,
function (x, data) {
userData = data.data;
if (!Array.isArray(userData.list))
userData.list=[]
renderUserData();
render_mode_description(userData.mode);
}
);
}
function renderUserData() {
const table = document.getElementById('user_table');
const rows = table.querySelectorAll('.tr:not(.table-titles)');
rows.forEach(row => row.remove());
const modeRadios = document.querySelectorAll('input[name="mode"]');
console.log("userData.mode = " + userData.mode);
modeRadios.forEach(radio => {
console.log("radio.value = " + radio.value);
radio.checked = parseInt(radio.value) === userData.mode;
});
if (userData.mode === 1) {
document.getElementById('user_table').style.display = 'table';
document.getElementById('auto_user_table').style.display = 'none';
} else {
document.getElementById('user_table').style.display = 'none';
document.getElementById('auto_user_table').style.display = 'table';
}
const addButton = document.querySelector('.cbi-button-add');
const actionsHeader = document.querySelector('.table-titles .th:last-child');
if (addButton) {
addButton.style.display = userData.mode === 1 ? 'inline-block' : 'none';
}
if (actionsHeader) {
actionsHeader.style.display = userData.mode === 1 ? '' : 'none';
}
const displayList = userData.mode === 1 ? userData.list : allUsers;
displayList.forEach((user, index) => {
const hostname = user.hostname || '--';
const nickname = user.nickname || '--';
const row = document.createElement('div');
row.className = 'tr';
row.innerHTML = `
<div class="td">${user.mac}</div>
<div class="td">${hostname}</div>
<div class="td">${nickname}</div>
${userData.mode === 1 ? `<div class="td"><button type="button" class="btn-delete" onclick="removeUser(${index})"><%:Delete%></button></div>` : ''}
`;
table.appendChild(row);
});
}
function renderAutoUserData() {
const table = document.getElementById('auto_user_table');
const rows = table.querySelectorAll('.tr:not(.table-titles)');
rows.forEach(row => row.remove());
const displayList = allUsers.filter(user =>
!whitelistData.list.some(wUser => wUser.mac === user.mac)
);
displayList.forEach((user, index) => {
const hostname = user.hostname || '--';
const nickname = user.nickname || '--';
const row = document.createElement('div');
row.className = 'tr';
row.innerHTML = `
<div class="td">${user.mac}</div>
<div class="td">${hostname}</div>
<div class="td">${nickname}</div>
<div class="td">
<button type="button" class="add-user-btn" style="padding: 3px 8px;" onclick="addToWhitelist('${user.mac}')"><%:Add to whitelist%></button>
</div>
`;
table.appendChild(row);
});
}
function showSuccessMessage(message = '<%:Setting success%>') {
const modal = document.getElementById('modal');
const messageElement = modal.querySelector('p');
messageElement.textContent = message;
modal.style.display = 'flex';
setTimeout(() => {
modal.style.display = 'none';
}, 1000);
}
function removeUser(index) {
const userToRemove = userData.list[index];
new XHR().post('<%=url('admin/services/oaf/api/mac_filter/del_mac_filter_user')%>',
{ mac: userToRemove.mac },
function(x, data) {
getAppFilterUserData();
showSuccessMessage('<%:Delete success%>');
}
);
}
function showUserModal() {
const modal = document.getElementById('userModal');
modal.style.display = 'flex';
const userList = document.getElementById('userList');
userList.innerHTML = '';
allUsers.forEach(user => {
const userElement = document.createElement('div');
userElement.style.padding = '8px';
userElement.style.borderBottom = '1px solid #eee';
const displayName = user.nickname || user.hostname || '';
userElement.innerHTML = `
<label style="display: flex; align-items: center;">
<input type="checkbox" value="${user.mac}" style="margin-right: 8px;">
<span>${user.mac}</span>
<span style="color: #666; margin-left: 8px;">${displayName ? `(${displayName})` : ''}</span>
</label>
`;
userList.appendChild(userElement);
});
}
function closeUserModal() {
const modal = document.getElementById('userModal');
modal.style.display = 'none';
}
function addSelectedUsers() {
const userList = document.getElementById('userList');
const checkboxes = userList.querySelectorAll('input[type="checkbox"]:checked');
const selectedMacs = Array.from(checkboxes).map(checkbox => checkbox.value);
var data = {
mac_list: selectedMacs
}
var data_str = JSON.stringify(data);
console.log(selectedMacs);
new XHR().post('<%=url('admin/services/oaf/api/mac_filter/add_mac_filter_user')%>',
{ data: data_str },
function(x, data) {
getAppFilterUserData();
closeUserModal();
showSuccessMessage('<%:Add success%>');
}
);
}
function render_mode_description(newMode) {
const modeDescription = document.getElementById('modeDescription');
if (newMode === 0) {
modeDescription.textContent = '<%:In auto mode, all new devices will be managed, including those with random MAC addresses%>';
} else {
modeDescription.textContent = '<%:In manual mode, only specified devices will be managed%>';
}
const whitelistSection = document.getElementById('whitelist_section');
if (newMode === 0) {
whitelistSection.classList.add('show');
} else {
whitelistSection.classList.remove('show');
}
}
function switchMode(newMode) {
userData.mode = newMode;
renderUserData();
render_mode_description(newMode);
new XHR().post('<%=url('admin/services/oaf/api/mac_filter/set_mac_filter_user')%>',
{ mode: newMode },
function(x, data) {
console.log('Mode updated successfully');
showSuccessMessage('<%:Mode switched successfully%>');
});
}
let whitelistData = {
list: []
};
function getWhitelistData() {
new XHR().get('<%=url('admin/services/oaf/api/mac_filter/get_mac_filter_whitelist')%>', null,
function (x, data) {
whitelistData = data.data;
if (!Array.isArray(whitelistData.list))
whitelistData.list = [];
renderWhitelistData();
renderAutoUserData();
}
);
}
function renderWhitelistData() {
const table = document.getElementById('whitelist_table');
const rows = table.querySelectorAll('.tr:not(.table-titles)');
rows.forEach(row => row.remove());
whitelistData.list.forEach((user, index) => {
const hostname = user.hostname || '--';
const nickname = user.nickname || '--';
const row = document.createElement('div');
row.className = 'tr';
row.innerHTML = `
<div class="td">${user.mac}</div>
<div class="td">${hostname}</div>
<div class="td">${nickname}</div>
<div class="td"><button type="button" class="btn-delete" onclick="removeWhitelistUser(${index})"><%:Delete%></button></div>
`;
table.appendChild(row);
});
}
function showWhitelistModal() {
const modal = document.getElementById('whitelistModal');
modal.style.display = 'flex';
const userList = document.getElementById('whitelistUserList');
userList.innerHTML = '';
allUsers.forEach(user => {
const userElement = document.createElement('div');
userElement.style.padding = '8px';
userElement.style.borderBottom = '1px solid #eee';
const displayName = user.nickname || user.hostname || '';
userElement.innerHTML = `
<label style="display: flex; align-items: center;">
<input type="checkbox" value="${user.mac}" style="margin-right: 8px;">
<span>${user.mac}</span>
<span style="color: #666; margin-left: 8px;">${displayName ? `(${displayName})` : ''}</span>
</label>
`;
userList.appendChild(userElement);
});
}
function closeWhitelistModal() {
const modal = document.getElementById('whitelistModal');
modal.style.display = 'none';
}
function addSelectedWhitelistUsers() {
const userList = document.getElementById('whitelistUserList');
const checkboxes = userList.querySelectorAll('input[type="checkbox"]:checked');
const selectedMacs = Array.from(checkboxes).map(checkbox => checkbox.value);
var data = {
mac_list: selectedMacs
};
new XHR().post('<%=url('admin/services/oaf/api/mac_filter/add_mac_filter_whitelist')%>',
{ data: JSON.stringify(data) },
function(x, res) {
closeWhitelistModal();
getWhitelistData();
showSuccessMessage('<%:Add success%>');
}
);
}
function removeWhitelistUser(index) {
const userToRemove = whitelistData.list[index];
new XHR().post('<%=url('admin/services/oaf/api/mac_filter/del_mac_filter_whitelist')%>',
{ mac: userToRemove.mac },
function(x, data) {
getWhitelistData();
showSuccessMessage('<%:Delete success%>');
}
);
}
function addToWhitelist(mac) {
var data = {
mac_list: [mac]
};
new XHR().post('<%=url('admin/services/oaf/api/mac_filter/add_mac_filter_whitelist')%>',
{ data: JSON.stringify(data) },
function(x, res) {
getWhitelistData();
renderAutoUserData();
showSuccessMessage('<%:Add success%>');
}
);
}
window.onload = initData;
//]]></script>
<div class="cbi-section cbi-tblsection">
<div style="max-width: 1000px; margin-left: 5px;padding: 5px;">
<div class="mode-selection">
<input type="radio" name="mode" value="0" onclick="switchMode(0)"> <%:Auto mode%>
<input type="radio" name="mode" value="1" checked onclick="switchMode(1)"> <%:Manual mode%>
</div>
<div id="modeDescription" style="margin-bottom: 15px;">
<%:In manual mode, only specified devices will be managed%>
</div>
<div class="table" id="user_table" style="display: none;">
<div class="tr table-titles">
<div class="th"><%:MAC%></div>
<div class="th"><%:Hostname%></div>
<div class="th"><%:Remark%></div>
<div class="th"><%:Actions%></div>
</div>
</div>
<div class="table" id="auto_user_table" style="display: none;">
<div class="tr table-titles">
<div class="th"><%:MAC%></div>
<div class="th"><%:Hostname%></div>
<div class="th"><%:Device nickname%></div>
<div class="th"><%:Operation%></div>
</div>
</div>
<div class="cbi-section-create">
<input type="button" class="cbi-button cbi-button-add" value="<%:Add user%>" onclick="showUserModal()" />
</div>
</div>
</div>
<div id="userModal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1000; justify-content: center; align-items: center;">
<div class="modal-content" style="background-color: #2d2d2d; color: #ffffff;">
<h3 style="margin: 0 0 15px 0;"><%:Select device%></h3>
<div id="userList" style="flex: 1; overflow-y: auto;">
</div>
<div style="margin-top: 15px; text-align: right;">
<button type="button" onclick="addSelectedUsers()" style="margin-right: 10px; padding: 5px 15px;"><%:OK%></button>
<button type="button" onclick="closeUserModal()" style="padding: 5px 15px;"><%:Cancel%></button>
</div>
</div>
</div>
<div id="modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1000; justify-content: center; align-items: center;">
<div style="background-color: rgba(0, 0, 0, 0.5); padding: 10px; border-radius: 5px; text-align: center; width: 130px; height: 70px; color: white; display: flex; justify-content: center; align-items: center;">
<p style="margin: 0;color: white;"><%:Setting success%></p>
</div>
</div>
<div class="whitelist-section" id="whitelist_section">
<div class="whitelist-title"><%:Whitelist%></div>
<div class="table" id="whitelist_table">
<div class="tr table-titles">
<div class="th"><%:MAC%></div>
<div class="th"><%:Hostname%></div>
<div class="th"><%:Device nickname%></div>
<div class="th"><%:Operation%></div>
</div>
</div>
</div>
<div class="modal" id="whitelistModal">
<div class="modal-content">
<h3><%:Add to whitelist%></h3>
<div class="user-selection" id="whitelistUserList"></div>
<div style="text-align: right; margin-top: 15px;">
<button type="button" class="add-user-btn" onclick="addSelectedWhitelistUsers()"><%:OK%></button>
<button type="button" onclick="closeWhitelistModal()" style="margin-left: 10px;"><%:Cancel%></button>
</div>
</div>
</div>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+672
View File
@@ -0,0 +1,672 @@
<%
local dsp = require "luci.dispatcher"
local nfs = require "nixio.fs"
local has_app_filter = nfs.access("/etc/config/appfilter")
local has_access_control = nfs.access("/etc/config/macfilter")
local has_record = nfs.access("/etc/config/fwx_record")
%>
<link rel="stylesheet" href="<%=resource%>/oaf/css/common.css">
<script src="<%=resource%>/oaf/jquery-3.7.1.min.js"></script>
<script src="<%=resource%>/oaf/common.js"></script>
<style>
.oaf-whitelist-tip {
margin: 8px 0 14px;
padding: 10px 14px;
border: 1px solid var(--border-color-low, #e5e7eb);
border-radius: 6px;
background: var(--background-color-low, #f5f5f5);
color: var(--text-color-medium, #6b7280);
font-size: 13px;
line-height: 1.6;
}
.oaf-whitelist-tabs {
display: flex;
list-style: none;
gap: 0;
padding: 0;
margin: 10px 0 15px;
border-bottom: 1px solid var(--border-color-medium, #d1d5db);
overflow-x: auto;
}
.oaf-whitelist-tab {
padding: 3px 16px;
border: 1px solid var(--border-color-medium, #d1d5db) !important;
border-bottom: none !important;
border-radius: 5px 5px 0 0;
margin-right: 6px;
background: var(--background-color-low, #f5f5f5) !important;
color: var(--text-color-medium, #6b7280) !important;
cursor: pointer;
font-size: 14px;
line-height: 1.6;
white-space: nowrap;
}
.oaf-whitelist-tab:hover {
background: var(--background-color-medium, #eee) !important;
color: var(--text-color-high, #333) !important;
}
.oaf-whitelist-tab.active {
background: var(--background-color-high, #fff) !important;
color: var(--text-color-high, #333) !important;
font-weight: bold;
}
.oaf-whitelist-panel {
display: none;
}
.oaf-whitelist-panel.active {
display: block;
}
.oaf-whitelist-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.oaf-whitelist-panel-header h3 {
min-width: 0;
}
.oaf-whitelist-table-wrap {
width: 100%;
overflow-x: auto;
}
.oaf-whitelist-table {
width: 100%;
min-width: 680px;
}
.oaf-whitelist-add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: auto;
min-width: max-content;
white-space: nowrap;
line-height: 1.4;
margin: 0;
padding: 9px 18px;
background-color: #2885e8;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 14px;
}
.oaf-whitelist-add-btn:hover {
background-color: #1e6bb8;
}
.oaf-whitelist-delete-btn {
padding: 5px 12px;
background-color: #e5484d;
color: #fff;
border: none;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
}
.oaf-whitelist-delete-btn:hover {
background-color: #c9373c;
}
.oaf-whitelist-modal,
.oaf-whitelist-message {
display: none;
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
justify-content: center;
align-items: center;
}
.oaf-whitelist-modal {
z-index: 10000;
}
.oaf-whitelist-modal-content {
width: calc(100vw - 32px);
max-width: 700px;
max-height: calc(100vh - 64px);
display: flex;
flex-direction: column;
overflow: hidden;
box-sizing: border-box;
border-radius: 8px;
border: 1px solid var(--border-color-low, #d1d5db);
background: var(--background-color-high, #fff);
color: var(--text-color-high, #1f2937);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
}
.oaf-whitelist-modal-header {
padding: 20px 24px 14px;
border-bottom: 1px solid var(--border-color-low, #d1d5db);
color: var(--text-color-high, #1f2937);
font-size: 20px;
font-weight: 600;
}
.oaf-whitelist-selection-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
padding: 16px 24px 10px;
}
.oaf-whitelist-selection-stat {
color: var(--text-color-medium, #6b7280);
font-size: 13px;
white-space: nowrap;
}
.oaf-whitelist-user-list {
min-height: 180px;
margin: 0 24px;
overflow-y: auto;
border: 1px solid var(--border-color-low, #d1d5db);
border-radius: 4px;
background: var(--background-color-low, #f5f5f5);
}
.oaf-whitelist-user-item {
display: flex;
align-items: center;
padding: 12px;
border-bottom: 1px solid var(--border-color-low, #d1d5db);
background: var(--background-color-high, #fff);
color: var(--text-color-high, #1f2937);
cursor: pointer;
}
.oaf-whitelist-user-item:last-child {
border-bottom: none;
}
.oaf-whitelist-user-item:hover {
background: var(--background-color-medium, #eee);
}
.oaf-whitelist-user-item .oaf-check-item {
width: 100%;
display: flex;
align-items: center;
gap: 0;
}
.oaf-whitelist-user-item .oaf-check-item > input[type="checkbox"] {
margin: 0 12px 0 0;
flex: 0 0 auto;
}
.oaf-whitelist-user-item .oaf-check-item > label {
display: flex;
align-items: center;
flex: 1;
min-width: 0;
margin: 0;
line-height: 1.4;
}
.oaf-whitelist-user-mac {
color: var(--text-color-high, #1f2937);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.oaf-whitelist-empty-state {
padding: 40px;
color: var(--text-color-medium, #6b7280);
text-align: center;
}
.oaf-whitelist-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 24px;
border-top: 1px solid var(--border-color-low, #d1d5db);
}
.oaf-whitelist-modal-btn {
min-width: 84px;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
line-height: 20px;
}
.oaf-whitelist-modal-btn-primary {
border: none;
background-color: #2885e8;
color: #fff;
}
.oaf-whitelist-modal-btn-secondary {
border: 1px solid var(--border-color-medium, #d1d5db);
background: transparent;
color: var(--text-color-high, #1f2937);
}
[data-darkmode="true"] .oaf-whitelist-user-item:hover {
background: var(--background-color-medium, rgba(255, 255, 255, 0.08));
}
.oaf-whitelist-message {
z-index: 10001;
}
.oaf-whitelist-message-content {
padding: 20px 30px;
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.75);
color: #fff;
text-align: center;
}
@media (max-width: 600px) {
.oaf-whitelist-tabs {
overflow-x: auto;
}
.oaf-whitelist-tab {
min-width: max-content;
}
.oaf-whitelist-panel-header,
.oaf-whitelist-selection-toolbar {
align-items: stretch;
flex-direction: column;
}
.oaf-whitelist-modal-content {
width: calc(100vw - 20px);
max-height: calc(100vh - 24px);
}
.oaf-whitelist-modal-buttons .oaf-whitelist-modal-btn {
flex: 1;
}
}
</style>
<script type="text/javascript">//<![CDATA[
var whitelistText = {
noEntries: '<%:No whitelist entries yet%>',
loading: '<%:Loading...%>',
deleteLabel: '<%:Delete%>',
noDevices: '<%:No available devices to add%>',
selectDevice: '<%:Please select at least one device%>',
addSuccess: '<%:Add success%>',
addFailed: '<%:Add failed%>',
confirmRemove: '<%:Are you sure you want to remove this device from whitelist?%>',
deleteSuccess: '<%:Delete success%>',
deleteFailed: '<%:Delete failed%>',
selected: '<%:Selected%>'
};
var whitelistTypes = {};
<% if has_app_filter then %>
whitelistTypes.app_filter = {
panelId: 'appFilterWhitelistPanel',
tableBodyId: 'appFilterWhitelistTableBody',
getUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "app_filter", "get_appfilter_whitelist")%>',
addUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "app_filter", "add_appfilter_whitelist")%>',
delUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "app_filter", "del_appfilter_whitelist")%>',
usersUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "app_filter", "get_all_users")%>',
list: [],
users: [],
usersLoaded: false
};
<% end %>
<% if has_access_control then %>
whitelistTypes.access_control = {
panelId: 'accessControlWhitelistPanel',
tableBodyId: 'accessControlWhitelistTableBody',
getUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "mac_filter", "get_mac_filter_whitelist")%>',
addUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "mac_filter", "add_mac_filter_whitelist")%>',
delUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "mac_filter", "del_mac_filter_whitelist")%>',
usersUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "get_all_users")%>',
list: [],
users: [],
usersLoaded: false
};
<% end %>
<% if has_record then %>
whitelistTypes.record = {
panelId: 'recordWhitelistPanel',
tableBodyId: 'recordWhitelistTableBody',
getUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "record_whitelist", "get_record_whitelist")%>',
addUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "record_whitelist", "add_record_whitelist")%>',
delUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "record_whitelist", "del_record_whitelist")%>',
usersUrl: '<%=dsp.build_url("admin", "services", "oaf", "api", "record_whitelist", "get_all_users")%>',
list: [],
users: [],
usersLoaded: false
};
<% end %>
var activeWhitelistType = null;
var modalWhitelistType = null;
function escapeWhitelistHtml(value) {
return $('<div>').text(value == null ? '' : String(value)).html();
}
function normalizeWhitelistMac(mac) {
return (mac || '').replace(/^\s+|\s+$/g, '').toUpperCase();
}
function switchWhitelistTab(type) {
if (!whitelistTypes[type]) {
return;
}
activeWhitelistType = type;
$('.oaf-whitelist-tab').removeClass('active').attr('aria-selected', 'false');
$('.oaf-whitelist-tab[data-type="' + type + '"]').addClass('active').attr('aria-selected', 'true');
$('.oaf-whitelist-panel').removeClass('active');
$('#' + whitelistTypes[type].panelId).addClass('active');
}
function loadWhitelist(type) {
var config = whitelistTypes[type];
new XHR().get(config.getUrl, null, function(x, data) {
config.list = data && data.data && Array.isArray(data.data.list) ? data.data.list : [];
renderWhitelistTable(type);
}, function() {
config.list = [];
renderWhitelistTable(type);
});
}
function loadWhitelistUsers(type, callback) {
var config = whitelistTypes[type];
new XHR().get(config.usersUrl, {flag: 2, page: 0}, function(x, data) {
config.users = data && data.data && Array.isArray(data.data.list) ? data.data.list : [];
config.usersLoaded = true;
if (callback) {
callback();
}
}, function() {
config.users = [];
config.usersLoaded = true;
if (callback) {
callback();
}
});
}
function renderWhitelistTable(type) {
var config = whitelistTypes[type];
var tableBody = document.getElementById(config.tableBodyId);
tableBody.innerHTML = '';
if (config.list.length === 0) {
tableBody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:30px;color:#999;">' + whitelistText.noEntries + '</td></tr>';
return;
}
config.list.forEach(function(user, index) {
var row = document.createElement('tr');
var mac = normalizeWhitelistMac(user.mac || '');
row.innerHTML = '<td>' + (index + 1) + '</td>' +
'<td style="font-family:monospace;font-weight:bold;">' + escapeWhitelistHtml(mac) + '</td>' +
'<td>' + escapeWhitelistHtml(user.hostname || '--') + '</td>' +
'<td>' + escapeWhitelistHtml(user.nickname || '--') + '</td>' +
'<td><button type="button" class="oaf-whitelist-delete-btn" onclick="removeWhitelistUser(\'' + type + '\',\'' + mac + '\')">' + whitelistText.deleteLabel + '</button></td>';
tableBody.appendChild(row);
});
}
function showWhitelistModal(type) {
modalWhitelistType = type;
$('#oafWhitelistModal').css('display', 'flex');
if (!whitelistTypes[type].usersLoaded) {
$('#oafWhitelistUserList').html('<div class="oaf-whitelist-empty-state">' + whitelistText.loading + '</div>');
updateWhitelistSelectionStats();
loadWhitelistUsers(type, function() {
if (modalWhitelistType === type) {
renderAvailableWhitelistUsers(type);
}
});
return;
}
renderAvailableWhitelistUsers(type);
}
function renderAvailableWhitelistUsers(type) {
var config = whitelistTypes[type];
var whitelistMacs = config.list.map(function(user) { return normalizeWhitelistMac(user.mac || ''); });
var availableUsers = config.users.filter(function(user) { return whitelistMacs.indexOf(normalizeWhitelistMac(user.mac || '')) === -1; });
var userList = $('#oafWhitelistUserList').empty();
if (availableUsers.length === 0) {
userList.html('<div class="oaf-whitelist-empty-state">' + whitelistText.noDevices + '</div>');
updateWhitelistSelectionStats();
return;
}
availableUsers.forEach(function(user) {
var mac = normalizeWhitelistMac(user.mac || '');
var extraInfo = user.nickname || ((user.hostname && user.hostname !== '*') ? user.hostname : user.ip) || '';
var displayText = extraInfo ? mac + ' (' + extraInfo + ')' : mac;
var item = $('<div class="oaf-whitelist-user-item"></div>');
var checkId = 'oaf-whitelist-user-' + modalWhitelistType + '-' + mac.replace(/[^a-zA-Z0-9_-]/g, '-');
var checkWrap = $('<div class="oaf-check-item"></div>');
var checkbox = $('<input type="checkbox">')
.attr('id', checkId)
.val(mac)
.on('change', updateWhitelistSelectionStats);
var label = $('<label class="oaf-whitelist-user-mac"></label>')
.attr('for', checkId)
.text(displayText);
checkWrap.append(checkbox).append(label);
item.append(checkWrap);
item.on('click', function(event) {
if (!$(event.target).closest('.oaf-check-item').length) {
checkbox.prop('checked', !checkbox.prop('checked')).trigger('change');
}
});
userList.append(item);
});
updateWhitelistSelectionStats();
}
function updateWhitelistSelectionStats() {
var allCount = $('#oafWhitelistUserList input[type="checkbox"]').length;
var selectedCount = $('#oafWhitelistUserList input[type="checkbox"]:checked').length;
$('#oafWhitelistSelectionStats').text(whitelistText.selected + ': ' + selectedCount + ' / ' + allCount);
}
function toggleAllWhitelistUsers() {
var checkboxes = $('#oafWhitelistUserList input[type="checkbox"]');
var selectedCount = checkboxes.filter(':checked').length;
checkboxes.prop('checked', checkboxes.length > 0 && selectedCount !== checkboxes.length);
updateWhitelistSelectionStats();
}
function closeWhitelistModal() {
$('#oafWhitelistModal').hide();
modalWhitelistType = null;
}
function addWhitelistUsers() {
var config = whitelistTypes[modalWhitelistType];
var selectedMacs = $('#oafWhitelistUserList input[type="checkbox"]:checked').map(function() {
return this.value;
}).get();
if (selectedMacs.length === 0) {
showWhitelistMessage(whitelistText.selectDevice);
return;
}
new XHR().post(config.addUrl, {data: JSON.stringify({mac_list: selectedMacs})}, function(x, data) {
if (data && data.code == 2000) {
var type = modalWhitelistType;
closeWhitelistModal();
loadWhitelist(type);
showWhitelistMessage(whitelistText.addSuccess);
} else {
showWhitelistMessage(whitelistText.addFailed);
}
}, function() {
showWhitelistMessage(whitelistText.addFailed);
});
}
function removeWhitelistUser(type, mac) {
var config = whitelistTypes[type];
if (!confirm(whitelistText.confirmRemove)) {
return;
}
new XHR().post(config.delUrl, {mac: mac}, function(x, data) {
if (data && data.code == 2000) {
loadWhitelist(type);
showWhitelistMessage(whitelistText.deleteSuccess);
} else {
showWhitelistMessage(whitelistText.deleteFailed);
}
}, function() {
showWhitelistMessage(whitelistText.deleteFailed);
});
}
function showWhitelistMessage(message) {
$('#oafWhitelistMessageText').text(message);
$('#oafWhitelistMessage').css('display', 'flex');
setTimeout(function() {
$('#oafWhitelistMessage').hide();
}, 1500);
}
$(function() {
var types = Object.keys(whitelistTypes);
if (types.length === 0) {
return;
}
switchWhitelistTab(types[0]);
types.forEach(function(type) {
loadWhitelist(type);
loadWhitelistUsers(type);
});
$('#oafWhitelistModal').on('click', function(event) {
if (event.target === this) {
closeWhitelistModal();
}
});
});
//]]></script>
<div class="cbi-section cbi-tblsection">
<div class="oaf-whitelist-tip">
<%:Users in the whitelist are not subject to control. For terminals using random MAC addresses, you can use the whitelist mechanism together with filtering rules: select All Users in a filtering rule, and the rule will apply to all new users.%>
</div>
<div class="oaf-whitelist-tabs" role="tablist">
<% if has_app_filter then %>
<button type="button" class="oaf-whitelist-tab" data-type="app_filter" role="tab" aria-selected="false" onclick="switchWhitelistTab('app_filter')"><%:App Filter Whitelist%></button>
<% end %>
<% if has_access_control then %>
<button type="button" class="oaf-whitelist-tab" data-type="access_control" role="tab" aria-selected="false" onclick="switchWhitelistTab('access_control')"><%:Access Control Whitelist%></button>
<% end %>
<% if has_record then %>
<button type="button" class="oaf-whitelist-tab" data-type="record" role="tab" aria-selected="false" onclick="switchWhitelistTab('record')"><%:Record Whitelist%></button>
<% end %>
</div>
<% if has_app_filter then %>
<div id="appFilterWhitelistPanel" class="oaf-whitelist-panel" role="tabpanel">
<div class="oaf-whitelist-panel-header">
<h3 style="margin:0;"><%:App Filter Whitelist%></h3>
<button type="button" class="oaf-whitelist-add-btn" onclick="showWhitelistModal('app_filter')">+ <%:Add to Whitelist%></button>
</div>
<div class="oaf-whitelist-table-wrap">
<table class="common-table oaf-whitelist-table">
<thead><tr><th><%:No.%></th><th><%:MAC Address%></th><th><%:Hostname%></th><th><%:Nickname%></th><th><%:Operation%></th></tr></thead>
<tbody id="appFilterWhitelistTableBody"><tr><td colspan="5" style="text-align:center;padding:30px;color:#999;"><%:Loading...%></td></tr></tbody>
</table>
</div>
</div>
<% end %>
<% if has_access_control then %>
<div id="accessControlWhitelistPanel" class="oaf-whitelist-panel" role="tabpanel">
<div class="oaf-whitelist-panel-header">
<h3 style="margin:0;"><%:Access Control Whitelist%></h3>
<button type="button" class="oaf-whitelist-add-btn" onclick="showWhitelistModal('access_control')">+ <%:Add to Whitelist%></button>
</div>
<div class="oaf-whitelist-table-wrap">
<table class="common-table oaf-whitelist-table">
<thead><tr><th><%:No.%></th><th><%:MAC Address%></th><th><%:Hostname%></th><th><%:Nickname%></th><th><%:Operation%></th></tr></thead>
<tbody id="accessControlWhitelistTableBody"><tr><td colspan="5" style="text-align:center;padding:30px;color:#999;"><%:Loading...%></td></tr></tbody>
</table>
</div>
</div>
<% end %>
<% if has_record then %>
<div id="recordWhitelistPanel" class="oaf-whitelist-panel" role="tabpanel">
<div class="oaf-whitelist-panel-header">
<h3 style="margin:0;"><%:Record Whitelist%></h3>
<button type="button" class="oaf-whitelist-add-btn" onclick="showWhitelistModal('record')">+ <%:Add to Whitelist%></button>
</div>
<div class="oaf-whitelist-table-wrap">
<table class="common-table oaf-whitelist-table">
<thead><tr><th><%:No.%></th><th><%:MAC Address%></th><th><%:Hostname%></th><th><%:Nickname%></th><th><%:Operation%></th></tr></thead>
<tbody id="recordWhitelistTableBody"><tr><td colspan="5" style="text-align:center;padding:30px;color:#999;"><%:Loading...%></td></tr></tbody>
</table>
</div>
</div>
<% end %>
</div>
<div id="oafWhitelistModal" class="oaf-whitelist-modal">
<div class="oaf-whitelist-modal-content">
<div class="oaf-whitelist-modal-header"><%:Add to Whitelist%></div>
<div class="oaf-whitelist-selection-toolbar">
<button type="button" class="oaf-whitelist-modal-btn oaf-whitelist-modal-btn-secondary" onclick="toggleAllWhitelistUsers()"><%:Select All%></button>
<span id="oafWhitelistSelectionStats" class="oaf-whitelist-selection-stat"><%:Selected%>: 0 / 0</span>
</div>
<div id="oafWhitelistUserList" class="oaf-whitelist-user-list"></div>
<div class="oaf-whitelist-modal-buttons">
<button type="button" class="oaf-whitelist-modal-btn oaf-whitelist-modal-btn-primary" onclick="addWhitelistUsers()"><%:OK%></button>
<button type="button" class="oaf-whitelist-modal-btn oaf-whitelist-modal-btn-secondary" onclick="closeWhitelistModal()"><%:Cancel%></button>
</div>
</div>
</div>
<div id="oafWhitelistMessage" class="oaf-whitelist-message">
<div class="oaf-whitelist-message-content">
<p id="oafWhitelistMessageText" style="margin:0;"></p>
</div>
</div>