ntopng/scripts/lua/hosts_stats.lua
2023-03-27 13:23:48 +00:00

737 lines
24 KiB
Lua

--
-- (C) 2013-23 - ntop.org
--
local dirs = ntop.getDirs()
package.path = dirs.installdir .. "/scripts/lua/modules/?.lua;" .. package.path
package.path = dirs.installdir .. "/scripts/lua/modules/pools/?.lua;" .. package.path
require "lua_utils"
local host_pools_nedge
if ntop.isnEdge() then
host_pools_nedge = require "host_pools_nedge"
end
local host_pools = require "host_pools"
-- Instantiate host pools
local host_pools_instance = host_pools:create()
local page_utils = require("page_utils")
local custom_column_utils = require("custom_column_utils")
local discover = require("discover_utils")
local have_nedge = ntop.isnEdge()
local wheel = nil
local function generate_map_url(map, map_type, query, icon)
local url = ""
if (ntop.isPro()) then
local map_available = table.len(map) > 0
if (map_available) then
url = "<a class='ms-1' href='"..ntop.getHttpPrefix().."/lua/pro/enterprise/network_maps.lua?" .. query .. "&map=" .. map_type .. "'><i class='".. icon .."'></i></a>"
end
end
return url
end
sendHTTPContentTypeHeader('text/html')
page_utils.set_active_menu_entry(page_utils.menu_entries.hosts)
local protocol = _GET["protocol"]
local asn = _GET["asn"]
local vlan = _GET["vlan"]
local network = _GET["network"]
local cidr = _GET["network_cidr"]
local country = _GET["country"]
local mac = _GET["mac"]
local os_ = _GET["os"]
local community = _GET["community"]
local pool = _GET["pool"]
local ipversion = _GET["version"]
local traffic_type = _GET["traffic_type"]
local device_ip = _GET["deviceIP"]
local base_url = ntop.getHttpPrefix() .. "/lua/hosts_stats.lua"
local page_params = {}
local charts_icon = ""
local mode = _GET["mode"]
if isEmptyString(mode) then
mode = "all"
else
page_params["mode"] = mode
end
local hosts_filter = ''
if ((mode ~= "all") or (not isEmptyString(pool))) then
hosts_filter = '<span class="fas fa-filter"></span>'
end
function getPageTitle(protocol_name, traffic_type_title, device_ip_title, network_name, cidr, ipver_title, os_, country, asninfo, mac, pool_, vlan_title, vlan_alias)
local mode_label = ""
if mode == "remote" then
mode_label = i18n("hosts_stats.remote")
elseif mode == "remote_no_tx" then
mode_label = i18n("hosts_stats.remote_no_tx")
elseif mode == "remote_no_tcp_tx" then
mode_label = i18n("hosts_stats.remote_no_tcp_tx")
elseif mode == "local" then
mode_label = i18n("hosts_stats.local")
elseif mode == "local_no_tx" then
mode_label = i18n("hosts_stats.local_no_tx")
elseif mode == "local_no_tcp_tx" then
mode_label = i18n("hosts_stats.local_no_tcp_tx")
elseif mode == "filtered" then
mode_label = i18n("hosts_stats.filtered")
elseif mode == "blacklisted" then
mode_label = i18n("hosts_stats.blacklisted")
elseif mode == "dhcp" then
mode_label = i18n("nedge.network_conf_dhcp")
end
if(network == nil) then
wheel = ""
charts_icon = ""
else
wheel = '<A HREF="'.. ntop.getHttpPrefix().. '/lua/network_details.lua?network='.. network ..'&page=config' ..'"><i class="fas fa-cog fa-sm"></i></A>'
charts_icon = charts_icon.."&nbsp; <a href='".. ntop.getHttpPrefix() .."/lua/network_details.lua?network=".. network .. "&page=historical'><i class='fas fa-sm fa-chart-area'></i></a>"
end
-- Note: we must use the empty string as fallback. Multiple spaces will be collapsed into one automatically.
return i18n("hosts_stats.hosts_page_title", {
all = isEmptyString(mode_label) and i18n("hosts_stats.all") or "",
traffic_type = traffic_type_title or "",
device_ip = device_ip_title or "",
local_remote = mode_label,
protocol = protocol_name or "",
network = not isEmptyString(network_name) and i18n("hosts_stats.in_network", {network=network_name}) or "",
network_cidr = not isEmptyString(cidr) and i18n("hosts_stats.in_network", {network=cidr}) or "",
ip_version = ipver_title or "",
["os"] = discover.getOsName(os_),
country_asn_or_mac = country or asninfo or mac or pool_ or "",
vlan = vlan_title or "",
vlan_name = vlan_alias or "",
charts_icon = charts_icon,
wheel = wheel
})
end
dofile(dirs.installdir .. "/scripts/lua/inc/menu.lua")
local ifstats = interface.getStats()
-- Parameters necessary for the page title
local protocol_name = nil
local network_name = nil
local traffic_type_title = nil
local device_ip_title = nil
local ipver_title = nil
local asninfo = nil
local os_title = nil
local country_title = nil
local mac_title = nil
local vlan_title = nil
local pool_title = nil
local vlan_alias = nil
if((protocol ~= nil) and (protocol ~= "")) then
protocol_name = interface.getnDPIProtoName(tonumber(protocol))
end
if(protocol_name == nil) then protocol_name = protocol end
if not isEmptyString(traffic_type) then
page_params["traffic_type"] = traffic_type
if traffic_type == "one_way" then
traffic_type_title = i18n("hosts_stats.traffic_type_one_way")
elseif traffic_type == "bidirectional" then
traffic_type_title = i18n("hosts_stats.traffic_type_two_ways")
end
else
traffic_type_title = ""
end
if not isEmptyString(device_ip) then
page_params["deviceIP"] = device_ip
device_ip_title = i18n("hosts_stats.probe_traffic", {device_ip = device_ip})
end
if(network ~= nil) then
local network_key = ntop.getNetworkNameById(tonumber(network))
network_name = getLocalNetworkAlias(network_key)
if isEmptyString(network_name) then
network_name = i18n("hosts_stats.remote")
end
else
network_name = ""
end
if not isEmptyString(ipversion) then
ipver_title = i18n("hosts_stats.ipver_title", {version_num=ipversion})
else
ipver_title = ""
end
if(asn ~= nil) then
asninfo = " " .. i18n("hosts_stats.asn_title",{asn=asn}) ..
"<small>&nbsp;<i class='fas fa-info-circle fa-sm' aria-hidden='true'></i> <A class='ntopng-external-link' href='https://stat.ripe.net/AS"..
asn .. "'><i class='fas fa-external-link-alt fa-sm' title=\\\"".. i18n("hosts_stats.more_info_about_as_popup_msg") ..
"\\\"></i></A> "..charts_icon.."&nbsp; <a href='".. ntop.getHttpPrefix() .."/lua/as_details.lua?asn="..
asn .. "&page=historical'><i class='fas fa-sm fa-chart-area'></i></a> </small>"
end
if(os_ ~= nil) then
os_title = " " .. os_
end
if(country ~= nil) then
country_title = " " .. i18n("hosts_stats.country_title", {country=country})
end
if(mac ~= nil) then
mac_title = " " .. i18n("hosts_stats.mac_title", {mac=mac})
end
if(vlan ~= nil) then
local link_service_map = generate_map_url(interface.serviceMap(nil, tonumber(vlan)), "service_map", "vlan=" .. vlan, "fas fa-concierge-bell")
local link_periodicity_map = generate_map_url(interface.periodicityMap(nil, tonumber(vlan)), "periodicity_map", "vlan=" .. vlan, "fas fa-clock")
vlan_title = " [".. i18n("hosts_stats.vlan_title", {vlan=vlan}) .."] <A HREF='".. ntop.getHttpPrefix().. "/lua/vlan_details.lua?vlan=".. vlan .."&page=config" .."'><i class='fas fa-cog fa-sm'></i></A>" .. link_service_map .. " " .. link_periodicity_map
if(vlan==getVlanAlias(vlan)) then
vlan_alias=""
else
vlan_alias = getVlanAlias(vlan)
end
end
if(pool ~= nil) then
local link_service_map = ""
local link_periodicity_map = ""
local charts_available = areHostPoolsTimeseriesEnabled(ifstats.id)
if (tonumber(pool) ~= host_pools_instance.DEFAULT_POOL_ID) then
link_service_map = generate_map_url(interface.serviceMap(nil, nil, tonumber(pool)), "service_map", "host_pool_id=".. pool, "fas fa-concierge-bell")
link_periodicity_map = generate_map_url(interface.periodicityMap(nil, nil, tonumber(pool)), "periodicity_map", "host_pool_id=".. pool, "fas fa-clock")
end
local pool_edit = ""
local pool_link
local title
if(tonumber(pool) ~= host_pools_instance.DEFAULT_POOL_ID) or (have_nedge) then
if have_nedge then
pool_link = "/lua/pro/nedge/admin/nf_edit_user.lua?username=" ..
ternary(pool == host_pools_nedge.DEFAULT_POOL_ID, "", host_pools_nedge.poolIdToUsername(pool))
title = i18n("nedge.edit_user")
else
pool_link = "/lua/admin/manage_host_members.lua?pool="..pool
title = i18n("host_pools.manage_pools")
end
pool_edit = "&nbsp; <A HREF='"..ntop.getHttpPrefix()..pool_link.."'><i class='fas fa-cog fa-sm' title='"..title .. "'></i></A>"
end
pool_title = " "..i18n(ternary(have_nedge, "hosts_stats.user_title", "hosts_stats.pool_title"),
{poolname=host_pools_instance:get_pool_name(pool)})
.."<small>".. pool_edit ..
ternary(charts_available, " <a href='"..ntop.getHttpPrefix().."/lua/pool_details.lua?page=historical&pool="..pool.."'><i class='fas fa-chart-area fa-sm' title='"..i18n("chart") .. "'></i></a>", "")
..link_service_map .. link_periodicity_map..
"</small>"
end
page_utils.print_page_title(getPageTitle(protocol_name, traffic_type_title, device_ip_title, network_name, cidr, ipver_title, os_title, country_title, asninfo, mac_title, pool_title, vlan_title,vlan_alias))
if (_GET["page"] ~= "historical") then
if(asn ~= nil) then
print [[
<div class='card'><div class='card-header'>
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item" class="active"><a class="nav-link active" data-bs-toggle="tab" href="#home">]] print(i18n("hosts_stats.hosts")) print[[</a></li>
]]
if(asn ~= "0") then
print [[
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#asinfo">]] print(i18n("hosts_stats.as_info")) print[[</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#aspath">]] print(i18n("hosts_stats.as_path")) print[[</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#prefix">]] print(i18n("hosts_stats.as_prefixes")) print[[</a></li>
<li class="nav-item"><a class="nav-link" data-bs-toggle="tab" href="#bgp">]] print(i18n("hosts_stats.bgp_updates")) print[[</a></li>
]]
end
end
print("</ul>") -- close .card-header
if (asn ~= nil) then
print("</div>")
end
if(asn ~= nil) then
print [[
<div class='card-body'>
<div class="tab-content">
<div id="home" class="tab-pane in active">
]]
end
-- build the current filter url
page_params["os"] = os_
page_params["asn"] = asn
page_params["community"] = community
page_params["vlan"] = vlan
page_params["country"] = country
page_params["mac"] = mac
page_params["pool"] = pool
page_params["network_cidr"] = cidr
if(protocol ~= nil) then
-- Example HTTP.Facebook
dot = string.find(protocol, '%.')
if(dot ~= nil) then
protocol = string.sub(protocol, dot+1)
end
page_params["protocol"] = protocol
end
if(network ~= nil) then
page_params["network"] = network
local network_key = ntop.getNetworkNameById(tonumber(network))
if not isEmptyString(network_name) then
local charts_available = areInterfaceTimeseriesEnabled(ifstats.id)
charts_icon = " <small><a href='".. ntop.getHttpPrefix() .."/lua/network_details.lua?network="..
network .. "&page=config'><i class='fas fa-sm fa-cog'></i></a>"
if charts_available then
charts_icon = charts_icon.."&nbsp; <a href='".. ntop.getHttpPrefix() .."/lua/network_details.lua?network="..
network .. "&page=historical'><i class='fas fa-sm fa-chart-area'></i></a>"
end
charts_icon = charts_icon.."</small>"
end
end
if not isEmptyString(ipversion) then
page_params["version"] = ipversion
end
custom_column_utils.updateCustomColumn()
local custom_name, custom_key, custom_align = custom_column_utils.getCustomColumnName()
print [[
<div id="table-hosts"></div>
<script>
var url_update = "]] print(getPageUrl(ntop.getHttpPrefix() .. "/lua/get_hosts_data.lua", page_params)) print[[";]]
ntop.dumpFile(dirs.installdir .. "/httpdocs/inc/hosts_stats_id.inc")
if ((ifstats.vlan)) then show_vlan = true else show_vlan = false end
if(show_vlan) then print ('host_rows_option["vlan"] = true;\n') end
if((custom_key == "") or (custom_key == nil)) then custom_key = "score" end
print [[
host_rows_option["ip"] = true;
host_rows_option["custom_column"] = "]] print(custom_key) print[[";
$("#table-hosts").datatable({
title: "]] print(i18n("hosts_stats.hosts_list")) print[[",
url: url_update ,
]]
if(protocol == nil) then protocol = "" end
if not isEmptyString(protocol_name) then
charts_icon = " <a href='".. ntop.getHttpPrefix() .."/lua/if_stats.lua?ifid="..
ifstats.id .. "&page=historical&ts_schema=iface:ndpi&protocol=" .. protocol_name..
"'><i class='fas fa-sm fa-chart-area'></i></a>"
end
print('title: "",\n')
print ('rowCallback: function ( row ) { return host_table_setID(row); },')
print [[
tableCallback: function() { $("#dt-bottom-details > .float-left > p").first().append('. ]]
print(i18n('hosts_stats.idle_hosts_not_listed'))
print[['); },
]]
-- Set the preference table
preference = tablePreferences("rows_number",_GET["perPage"])
if (preference ~= "") then print ('perPage: '..preference.. ",\n") end
-- Automatic default sorted. NB: the column must exist.
print ('sort: [ ["' .. getDefaultTableSort("hosts") ..'","' .. getDefaultTableSortOrder("hosts").. '"] ],')
print [[ showPagination: true, ]]
print('buttons: [ ')
-- IP version selector
print[['<div class="btn-group float-right">]]
custom_column_utils.printCustomColumnDropdown(base_url, page_params)
print[[</div>']]
-- IP version selector
print[[, '<div class="btn-group float-right">]]
printIpVersionDropdown(base_url, page_params)
print[[</div>']]
-- VLAN selector
if ifstats.vlan then
print[[, '<div class="btn-group float-right">]]
printVLANFilterDropdown(base_url, page_params)
print[[</div>']]
end
print[[, '<div class="btn-group float-right">]]
printTrafficTypeFilterDropdown(base_url, page_params)
print[[</div>']]
-- Hosts filter
-- table.clone needed to modify some parameters while keeping the original unchanged
local hosts_filter_params = table.clone(page_params)
print(', \'<div class="btn-group"><button class="btn btn-link dropdown-toggle" data-bs-toggle="dropdown">'..i18n("hosts_stats.filter_hosts")..hosts_filter..'<span class="caret"></span></button> <ul class="dropdown-menu scrollable-dropdown" role="menu" style="min-width: 90px;"><li"><a class="dropdown-item" href="')
hosts_filter_params.mode = nil
hosts_filter_params.pool = nil
print (getPageUrl(base_url, hosts_filter_params))
print ('">'..i18n("hosts_stats.all_hosts")..'</a></li>')
hosts_filter_params.mode = "local"
print('<li ')
print('"><a class="dropdown-item ' .. ternary(mode == "local", "active", "") ..'" href="')
print (getPageUrl(base_url, hosts_filter_params))
print ('">'..i18n("hosts_stats.local_hosts_only")..'</a></li>')
hosts_filter_params.mode = "local_no_tx"
print('<li ')
print('"><a class="dropdown-item ' .. ternary(mode == "local_no_tx", "active", "") ..'" href="')
print (getPageUrl(base_url, hosts_filter_params))
print ('">'..i18n("hosts_stats.local_no_tx")..'</a></li>')
hosts_filter_params.mode = "local_no_tcp_tx"
print('<li ')
print('"><a class="dropdown-item ' .. ternary(mode == "local_no_tcp_tx", "active", "") ..'" href="')
print (getPageUrl(base_url, hosts_filter_params))
print ('">'..i18n("hosts_stats.local_no_tcp_tx")..'</a></li>')
hosts_filter_params.mode = "remote"
print('<li ')
print('"><a class="dropdown-item ' .. ternary(mode == "remote", "active", "") ..'" href="')
print (getPageUrl(base_url, hosts_filter_params))
print ('">'..i18n("hosts_stats.remote_hosts_only")..'</a></li>')
hosts_filter_params.mode = "remote_no_tx"
print('<li ')
print('"><a class="dropdown-item ' .. ternary(mode == "remote_no_tx", "active", "") ..'" href="')
print (getPageUrl(base_url, hosts_filter_params))
print ('">'..i18n("hosts_stats.remote_no_tx")..'</a></li>')
hosts_filter_params.mode = "remote_no_tcp_tx"
print('<li ')
print('"><a class="dropdown-item ' .. ternary(mode == "remote_no_tcp_tx", "active", "") ..'" href="')
print (getPageUrl(base_url, hosts_filter_params))
print ('">'..i18n("hosts_stats.remote_no_tcp_tx")..'</a></li>')
if interface.isPacketInterface() and not interface.isPcapDumpInterface() then
hosts_filter_params.mode = "dhcp"
print('<li ')
print('"><a class="dropdown-item ' .. ternary(mode == "dhcp", "active", "") ..'" href="')
print (getPageUrl(base_url, hosts_filter_params))
print ('">'..i18n("mac_stats.dhcp_only")..'</a></li>')
hosts_filter_params.mode = "broadcast_domain"
print('<li ')
print('"><a class="dropdown-item ' .. ternary(mode == "broadcast_domain", "active", "") ..'" href="')
print (getPageUrl(base_url, hosts_filter_params))
print ('">'..i18n("hosts_stats.broadcast_domain_hosts_only")..'</a></li>')
end
hosts_filter_params.mode = "blacklisted"
print('<li ')
print('"><a class="dropdown-item ' .. ternary(mode == "blacklisted", "active", "") ..'" href="')
print (getPageUrl(base_url, hosts_filter_params))
print ('">'..i18n("hosts_stats.blacklisted_hosts_only")..'</a></li>')
if isBridgeInterface(ifstats) then
hosts_filter_params.mode = "filtered"
print('<li ')
print('"><a class="dropdown-item ' .. ternary(mode == "filtered", "active", "") ..'" href="')
print (getPageUrl(base_url, hosts_filter_params))
print ('">'..i18n("hosts_stats.filtered_hosts_only")..'</a></li>')
end
-- Host pools
if not ifstats.isView then
hosts_filter_params.mode = nil
hosts_filter_params.pool = nil
print('<li role="separator" class="divider"></li>')
for _, _pool in ipairs(host_pools_instance:get_all_pools()) do
hosts_filter_params.pool = _pool.pool_id
print('<li ')
print('"><a class="dropdown-item ' .. ternary(pool == _pool.pool_id, "active", "") ..'" href="')
print(getPageUrl(base_url, hosts_filter_params)..'">'
..i18n(ternary(have_nedge, "hosts_stats.user", "hosts_stats.host_pool"),
{pool_name=string.gsub(_pool.name, "'", "\\'")}) ..'</li>')
end
end
print('</ul></div>\'')
if ntop.isPro() then
printHostsDeviceFilterDropdown(base_url, page_params)
end
print(' ],')
print [[
columns: [
{
title: "Key",
field: "key",
hidden: true,
css: {
textAlign: 'center'
}
},{
title: "",
field: "column_info",
sortable: false,
css: {
textAlign: 'center'
}
},{
title: "]] print(i18n("ip_address")) print[[",
field: "column_ip",
sortable: true,
css: {
textAlign: 'left'
}
},
]]
if(show_vlan) then
print('{ title: "'..i18n("vlan")..'",\n')
print [[
field: "column_vlan",
sortable: true,
css: {
textAlign: 'center'
}
},
]]
end
print [[
{
title: "]] print(i18n("flows")) print[[",
field: "column_num_flows",
sortable: true,
css: {
textAlign: 'center'
}
}, {
title: "]] print(i18n("if_stats_overview.blocked_flows")) print[[",
field: "column_num_dropped_flows",
sortable: true,
hidden: ]]
if isBridgeInterface(ifstats) then
print("false")
else
print("true")
end
print[[,
css: {
textAlign: 'center'
}
}, {
title: "]]
-- tprint({custom_name = custom_name, custom_key = custom_key, custom_align = custom_align})
print(custom_name)
print[[",
field: "column_]] print(custom_key) print[[",
sortable: true,
css: {
textAlign: ']] print(custom_align) print[['
}
},
{
title: "]] print(i18n("name")) print[[",
field: "column_name",
sortable: true,
css: {
textAlign: 'left'
}
},
{
title: "]] print(i18n("seen_since")) print[[",
field: "column_since",
sortable: true,
css: {
textAlign: 'center'
}
}, {
title: "]] print(i18n("score")) print[[",
field: "column_score",
sortable: true,
css: {
textAlign: 'center'
}
}, {
title: "]] print(i18n("breakdown")) print[[",
field: "column_breakdown",
sortable: false,
css: {
textAlign: 'center'
}
},
{
title: "]] print(i18n("throughput")) print[[",
field: "column_thpt",
sortable: true,
css: {
textAlign: 'right'
}
},
{
title: "]] print(i18n("flows_page.total_bytes")) print[[",
field: "column_traffic",
sortable: true,
css: {
textAlign: 'right'
}
}
]
});
</script>
]]
if(have_nedge) then
print[[
<script>
const block_host_csrf = "]] print(ntop.getRandomCSRFValue()) print[[";
function block_host(host_key, host_url) {
var url = "]] print(ntop.getHttpPrefix()) print[[/lua/pro/nedge/toggle_block_host.lua?" + host_url;
$.ajax({
type: 'GET',
url: url,
cache: false,
data: {
csrf: block_host_csrf
},
success: function(content) {
var data = jQuery.parseJSON(content);
if (data.status == "BLOCKED") {
$('#'+host_key+'_info').find('.block-badge')
.removeClass('btn-secondary').addClass('btn-danger');
$('#'+host_key+'_ip').find('a').css("text-decoration", "line-through");
} else if (data.status == "UNBLOCKED") {
$('#'+host_key+'_info').find('.block-badge')
.removeClass('btn-danger').addClass('btn-secondary');
$('#'+host_key+'_ip').find('a').css("text-decoration", "none");
}
},
error: function(content) {
console.log("error");
}
});
}
</script>
]]
end
if(asn ~= nil) then
print [[
</div>
<script>
let STAT_WIDGET_API_URL = 'https://stat.ripe.net/widgets/';
let STAT_DATA_API_URL = 'https://stat.ripe.net/data/';
let STAT_OTHER_API_URL = 'https://stat.ripe.net/api/';
let STAT_HOME = "https://stat.ripe.net/";
// DOM class for auto linking to div element
let STAT_DOM_CLASS_NAME = 'statwdgtauto';
// how many seconds to wait for scripts to load
// 0 ... disables the timeout
let STAT_REQUIRE_TIMEOUT = 120;
</script>
<script src="https://stat.ripe.net/widget-api-config"></script>
<script src="https://stat.ripe.net/widgets/js/version.js"></script>
<script src="https://stat.ripe.net/widgets/js/widget_api_main.js"></script>
<div id="asinfo" class="tab-pane"></div>
<div id="aspath" class="tab-pane"></div>
<div id="prefix" class="tab-pane"></div>
<div id="bgp" class="tab-pane"></div>
]]
if (asn ~= nil) then
print("</div>")
print("</div>")
print("</div>")
end
print[[
<script>
$(document).ready(function() {
var tab_id_to_widget = {
"#asinfo": "iana-registry-info",
"#aspath": "as-path-length",
"#prefix": "announced-prefixes",
"#bgp": "bgp-update-activity",
};
var loaded_widgets = {};
function load_widget(tab_id) {
var widget = tab_id_to_widget[tab_id];
if((typeof(widget) === "undefined") || loaded_widgets[widget])
return;
var tab = $(tab_id);
var script = $("<script>")
var div = $('<div class="statwdgtauto"></div>');
script.text('ripestat.init("' + widget + '",{"resource":"AS]] print(asn) print [["},null,{"disable":["controls"]});');
script.appendTo(div);
div.appendTo(tab);
loaded_widgets[widget] = true;
}
$('a[data-bs-toggle="tab"]').on('shown.bs.tab', function (e) {
var target = $(e.target).attr("href") // activated tab
load_widget(target);
});
});
</script>
]]
end -- if(asn ~= nil)
end
dofile(dirs.installdir .. "/scripts/lua/inc/footer.lua")