ntopng/scripts/lua/modules/demo_utils.lua
GabrieleDeri 3648ffc963
Some checks are pending
Build / build (push) Waiting to run
CIFuzz / Fuzzing (address) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
CodeQL / Analyze-1 (push) Waiting to run
Fixes demo guide (#10692)
* Fixes demo guide

* Update dist
2026-08-05 00:01:06 +02:00

289 lines
9.1 KiB
Lua

--
-- (C) 2013-26 - ntop.org
--
local dirs = ntop.getDirs()
package.path = dirs.installdir .. "/scripts/lua/modules/?.lua;" .. package.path
local json = require "dkjson"
local os_utils = require "os_utils"
local demo_utils = {}
local DEMO_CONFIG_DIR = os_utils.fixPath(dirs.installdir .. "/httpdocs/demo_config")
local REGISTRY_FILE = os_utils.fixPath(DEMO_CONFIG_DIR .. "/demo_registry.json")
local REGISTRY_FILENAME = "demo_registry.json"
-- Default anti-spam grace period before a tour is allowed to show, per
-- trigger type.
local DEFAULT_DELAY_SECONDS = {
first_login = 0, -- 2 minutes: let them get their bearings first
new_feature = 20,
}
-- Named visibility conditions a demo can require via "visibility_condition"
-- in demo_registry.json (or the demo's own JSON file).
local VISIBILITY_CONDITIONS = {
has_nanalyst = function()
return ntop.hasnAnalyst and ntop.hasnAnalyst() or false
end,
}
-- ##############################################
local function read_json_file(path)
local f = io.open(path, "r")
if f == nil then
return nil
end
local content = f:read("*all")
f:close()
local decoded = json.decode(content)
return decoded
end
-- ##############################################
-- Auto-discovers every demo JSON file in httpdocs/demo_config/ (any *.json
-- except demo_registry.json itself) and registers it using its own top-level
-- id/title/trigger/version
function demo_utils.get_registry()
local manual = read_json_file(REGISTRY_FILE) or { demos = {} }
local manual_by_id = {}
for _, d in ipairs(manual.demos or {}) do
manual_by_id[d.id] = d
end
local demos = {}
local seen_ids = {}
local files = ntop.readdir(DEMO_CONFIG_DIR) or {}
for fname in pairs(files) do
if fname:match("%.json$") and fname ~= REGISTRY_FILENAME then
local config = read_json_file(os_utils.fixPath(DEMO_CONFIG_DIR .. "/" .. fname))
if config and config.id and not seen_ids[config.id] then
seen_ids[config.id] = true
local override = manual_by_id[config.id] or {}
demos[#demos + 1] = {
id = config.id,
file = fname,
title = override.title or config.title,
description = override.description,
trigger = override.trigger or config.trigger,
delay_seconds = override.delay_seconds
or DEFAULT_DELAY_SECONDS[override.trigger or config.trigger] or 0,
enabled = (override.enabled ~= false),
visibility_condition = override.visibility_condition or config.visibility_condition,
}
end
end
end
-- Manual entries whose file wasn't found by the scan (e.g. bad filename)
-- still surface, so a misconfiguration is visible via the status list
-- rather than silently disappearing.
for id, d in pairs(manual_by_id) do
if not seen_ids[id] then
demos[#demos + 1] = {
id = d.id,
file = d.file,
title = d.title,
description = d.description,
trigger = d.trigger,
delay_seconds = d.delay_seconds or DEFAULT_DELAY_SECONDS[d.trigger] or 0,
enabled = (d.enabled ~= false),
visibility_condition = d.visibility_condition,
}
end
end
return { demos = demos }
end
-- ##############################################
-- Evaluates a demo's "visibility_condition" (nil/unknown name => always visible)
function demo_utils.is_visible(demo)
if isEmptyString(demo.visibility_condition) then
return true
end
local check = VISIBILITY_CONDITIONS[demo.visibility_condition]
return check == nil or check() == true
end
-- ##############################################
-- Returns the full step config for a given demo id, nil if not found/disabled
function demo_utils.get_demo_config(demo_id)
local registry = demo_utils.get_registry()
for _, demo in ipairs(registry.demos or {}) do
if demo.id == demo_id and demo.enabled ~= false and demo.file and demo_utils.is_visible(demo) then
local path = os_utils.fixPath(DEMO_CONFIG_DIR .. "/" .. demo.file)
local config = read_json_file(path)
if config then
-- merge registry metadata (title/trigger) into the config response
config.trigger = config.trigger or demo.trigger
config.title = config.title or demo.title
end
return config
end
end
return nil
end
-- ##############################################
local function progress_key(username, demo_id)
return string.format("ntopng.user.%s.demo_tour.%s", username, demo_id)
end
-- ##############################################
-- Returns the progress table for a demo/user, or nil if never started
function demo_utils.get_progress(username, demo_id)
local raw = ntop.getCache(progress_key(username, demo_id))
if isEmptyString(raw) then
return nil
end
return json.decode(raw)
end
-- ##############################################
-- Persists a progress table for a demo/user
function demo_utils.save_progress(username, demo_id, progress)
ntop.setCache(progress_key(username, demo_id), json.encode(progress))
return true
end
-- ##############################################
-- Records the current step index reached by the user
function demo_utils.set_step(username, demo_id, step_index)
local progress = demo_utils.get_progress(username, demo_id) or {}
progress.step_index = step_index
progress.updated_at = os.time()
return demo_utils.save_progress(username, demo_id, progress)
end
-- ##############################################
-- Marks a demo as fully completed. `version` is the config version the user
-- just finished, so a later version bump can re-trigger the tour.
function demo_utils.mark_complete(username, demo_id, version)
local progress = demo_utils.get_progress(username, demo_id) or {}
progress.completed_at = os.time()
progress.seen_version = version
progress.dismissed = false
return demo_utils.save_progress(username, demo_id, progress)
end
-- ##############################################
-- Marks a demo as dismissed ("never show again")
function demo_utils.dismiss(username, demo_id)
local progress = demo_utils.get_progress(username, demo_id) or {}
progress.dismissed = true
progress.dismissed_at = os.time()
return demo_utils.save_progress(username, demo_id, progress)
end
-- ##############################################
-- Decides whether a demo should be shown to this user right now.
-- Re-shows automatically when the JSON config `version` is bumped.
-- `delay_seconds` is an anti-spam grace period: the first time a user is
-- ever eligible for this demo, we just record the timestamp and say no —
-- nothing pops up on the very first page they land on.
function demo_utils.should_show(username, demo_id, demo_version, delay_seconds)
delay_seconds = delay_seconds or 0
local progress = demo_utils.get_progress(username, demo_id)
if progress == nil then
demo_utils.save_progress(username, demo_id, { first_seen_at = os.time() })
return delay_seconds <= 0
end
if progress.dismissed then
return false
end
if progress.completed_at ~= nil then
if demo_version and (progress.seen_version == nil or progress.seen_version < demo_version) then
return true -- version bump: re-show immediately, user is already engaged
end
return false
end
if progress.first_seen_at == nil then
progress.first_seen_at = os.time()
demo_utils.save_progress(username, demo_id, progress)
return delay_seconds <= 0
end
return (os.time() - progress.first_seen_at) >= delay_seconds
end
-- ##############################################
-- Seeds first_seen_at for every existing user x registered demo that has no
-- progress key yet. Run once at ntopng startup so users who existed before a
-- demo was added (fresh install upgrade)
function demo_utils.seed_missing_progress()
local registry = demo_utils.get_registry()
local users = ntop.getUsers and ntop.getUsers() or {}
for username, _ in pairs(users) do
for _, demo in ipairs(registry.demos or {}) do
if demo.enabled ~= false and demo_utils.get_progress(username, demo.id) == nil then
demo_utils.save_progress(username, demo.id, { first_seen_at = os.time() })
end
end
end
end
-- ##############################################
-- Convenience: full status list (should_show flag) for every registered demo
function demo_utils.get_status_for_user(username)
local registry = demo_utils.get_registry()
local out = {}
for _, demo in ipairs(registry.demos or {}) do
if demo.enabled ~= false and demo_utils.is_visible(demo) then
local config = demo_utils.get_demo_config(demo.id)
local version = config and config.version or 1
out[#out + 1] = {
id = demo.id,
title = demo.title,
trigger = demo.trigger,
version = version,
should_show = demo_utils.should_show(username, demo.id, version, demo.delay_seconds),
}
end
end
return out
end
-- ##############################################
return demo_utils