- initial beta version
This commit is contained in:
Leslie Krause 2018-08-05 23:19:06 -04:00
commit 966cc666d0
6 changed files with 1159 additions and 0 deletions

66
README.txt Normal file
View File

@ -0,0 +1,66 @@
DataMiner Mod v2.2b
By Leslie Krause
DataMiner is an analytical tool for Minetest server operators, providing comprehensive
daily server and player statistics within a graphical user-interface, in addition to an
API for custom-tailored log analysis and reporting via mods or command-line scripts.
Repository
----------------------
Browse source code...
https://bitbucket.org/sorcerykid/dataminer
Download archive...
https://bitbucket.org/sorcerykid/dataminer/get/master.zip
https://bitbucket.org/sorcerykid/dataminer/get/master.tar.gz
Revision History
----------------------
Version 2.2b (05-Aug-2018)
- initial beta version
Dependencies
----------------------
Auth Redux Mod (by sorcerykid)
https://bitbucket.org/sorcerykid/auth_rx
ActiveFormspecs Mod (by sorcerykid)
https://bitbucket.org/sorcerykid/formspecs
Polygraph Mod (by sorcerykid)
https://bitbucket.org/sorcerykid/polygraph
Installation
----------------------
1) Unzip the archive into the mods directory of your game
2) Rename the dataminer-master directory to "dataminer"
Source Code License
----------------------
The MIT License (MIT)
Copyright (c) 2016-2018, Leslie Krause (leslie@searstower.org)
Permission is hereby granted, free of charge, to any person obtaining a copy of this
software and associated documentation files (the "Software"), to deal in the Software
without restriction, including without limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
For more details:
https://opensource.org/licenses/MIT

101
catalog.lua Normal file
View File

@ -0,0 +1,101 @@
--------------------------------------------------------
-- Minetest :: DataMiner Mod v2.10 (dataminer)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2017-2018, Leslie E. Krause
--------------------------------------------------------
local TX_SESSION_OPENED = 50
local TX_SESSION_CLOSED = 51
local LOG_STARTED = 10
local LOG_CHECKED = 11
local LOG_STOPPED = 12
local LOG_INDEXED = 13
JournalIndex = function ( path, name )
local journal, err = io.open( path .. "/" .. name, "r" )
if not journal then
error( "Cannot open journal file for reading." )
end
local self = { }
local catalog = { }
self.get_players = function ( period )
if catalog[ period ] then return catalog[ period ].players end
end
self.get_is_online = function ( period )
if catalog[ period ] then return catalog[ period ].is_online end
end
self.records = function ( period )
if not catalog[ period ] then return function ( ) end end
-- iterate over only transactions in specified period
local count = catalog[ period ].length
journal:seek( "set", catalog[ period ].cursor )
return function ( )
if count == 0 then
count = count - 1
return os.time( ), LOG_INDEXED, { }
elseif count > 0 then
-- sanity check, altho read should never return nil
local record = assert( journal:read( "*line" ) )
local fields = string.split( record, " ", true )
count = count - 1
return tonumber( fields[ 1 ] ), tonumber( fields[ 2 ] ), { select( 3, unpack( fields ) ) }
end
end
end
self.prepare = function ( on_repeat )
local period
local length = 0
local cursor = 0
local players = { }
local is_online = false
for record in journal:lines( ) do
local fields = string.split( record, " ", true )
local optime = tonumber( fields[ 1 ] )
local opcode = tonumber( fields[ 2 ] )
if not period or optime >= period * 86400 + 86400 then
if period then catalog[ period ].length = length end
local names = { }
for k, v in pairs( players ) do
table.insert( names, k )
end
period = math.floor( optime / 86400 )
catalog[ period ] = { cursor = cursor, players = names, is_online = is_online }
length = 0
end
if opcode == LOG_STARTED then
players = { }
is_online = true
elseif opcode == LOG_STOPPED or opcode == LOG_CHECKED then
players = { }
is_online = false
elseif opcode == TX_SESSION_OPENED then
players[ fields[ 3 ] ] = optime
elseif opcode == TX_SESSION_CLOSED then
players[ fields[ 3 ] ] = nil
end
cursor = journal:seek( )
length = length + 1
on_repeat( )
end
if period then catalog[ period ].length = length end
end
self.close = function ( )
journal:close( )
end
return self
end

75
db.lua Normal file
View File

@ -0,0 +1,75 @@
--------------------------------------------------------
-- Minetest :: DataMiner v2.2 (auth_rx)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2017-2018, Leslie E. Krause
--------------------------------------------------------
----------------------------
-- AuthDatabaseReader Class
----------------------------
function AuthDatabaseReader( path, name )
local data, index
local self = { }
-- Private methods
local db_reload = function ( )
print( "Reading authentication data from disk..." )
local file, errmsg = io.open( path .. "/" .. name, "r" )
if not file then
error( "Fatal exception in AuthDatabaseReader:db_reload( ), aborting." )
end
local head = assert( file:read( "*line" ) )
index = tonumber( string.match( head, "^auth_rx/2.1 @(%d+)$" ) )
if not index or index < 0 then
error( "Fatal exception in AuthDatabaseReader:reload( ), aborting." )
end
for line in file:lines( ) do
if line ~= "" then
local fields = string.split( line, ":", true )
if #fields ~= 10 then
error( "Fatal exception in AuthDatabaseReader:reload( ), aborting." )
end
data[ fields[ 1 ] ] = {
password = fields[ 2 ],
oldlogin = tonumber( fields[ 3 ] ),
newlogin = tonumber( fields[ 4 ] ),
lifetime = tonumber( fields[ 5 ] ),
total_sessions = tonumber( fields[ 6 ] ),
total_attempts = tonumber( fields[ 7 ] ),
total_failures = tonumber( fields[ 8 ] ),
approved_addrs = string.split( fields[ 9 ], "," ),
assigned_privs = string.split( fields[ 10 ], "," ),
}
end
end
file:close( )
end
-- Public methods
self.connect = function ( )
data = { }
db_reload( )
end
self.disconnect = function ( )
data = nil
end
self.records = function ( )
return pairs( data )
end
self.select_record = function ( username )
return data[ username ]
end
return self
end

2
depends.txt Normal file
View File

@ -0,0 +1,2 @@
polygraph
formspecs

419
init.lua Normal file
View File

@ -0,0 +1,419 @@
--------------------------------------------------------
-- Minetest :: DataMiner Mod v2.2 (dataminer)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2017-2018, Leslie E. Krause
--------------------------------------------------------
dofile( minetest.get_modpath( "dataminer" ) .. "/catalog.lua" )
local TX_CREATE = 20
local TX_SESSION_OPENED = 50
local TX_SESSION_CLOSED = 51
local TX_LOGIN_ATTEMPT = 30
local TX_LOGIN_FAILURE = 31
local LOG_STARTED = 10
local LOG_CHECKED = 11
local LOG_STOPPED = 12
local LOG_INDEXED = 13
------------------------------------------------------------
local function analyze_log( days )
local rel_date = math.floor( os.time( ) / 86400 ) - days
local rel_time = rel_date * 86400
local cur_clients
local player_login = { }
local player_added = { }
local cur_period = 1
local player_check
local server_start
local server_uptime = 0
local total_players = 0
local total_players_new = 0
local total_sessions = 0
local total_failures = 0
local total_attempts = 0
local max_clients = 0
local min_clients = 0
local max_lifetime = 0
local total_lifetime = 0
local player_stats = { }
local hourly_stats = { }
-------------------------
local function get_period( t )
return math.floor( ( t - rel_time ) / 3600 ) + 1
end
local function on_login_failure( cur_time )
local p = get_period( cur_time )
hourly_stats[ p ].failures = hourly_stats[ p ].failures + 1
total_failures = total_failures + 1
end
local function on_login_attempt( cur_time )
local p = get_period( cur_time )
hourly_stats[ p ].attempts = hourly_stats[ p ].attempts + 1
total_attempts = total_attempts + 1
end
local function on_server_startup( cur_time )
server_start = cur_time
end
local function on_server_shutdown( cur_time )
server_uptime = server_uptime + ( cur_time - server_start )
server_start = nil
end
local function on_session_opened( cur_time, cur_user )
while cur_period < get_period( cur_time ) do
-- initialize client and player stats in prior periods
local hourly = hourly_stats[ cur_period ]
if not hourly.players then
hourly.clients_max = cur_clients
hourly.clients_min = cur_clients
hourly.players = cur_clients
end
cur_period = cur_period + 1
end
local hourly = hourly_stats[ cur_period ]
if not hourly.players then
-- initialize client and player stats for this period
hourly.clients_max = cur_clients + 1
hourly.clients_min = cur_clients
hourly.players = cur_clients
player_check = { }
elseif cur_clients + 1 > hourly.clients_max then
-- update client stats for this period, if needed
hourly.clients_max = cur_clients + 1
end
if not player_check[ cur_user ] then
-- track another unique player
player_check[ cur_user ] = 1
hourly.players = hourly.players + 1
end
-- update some general stats
if player_added[ cur_user ] then
-- only count new players after joining game (sanity check)
total_players_new = total_players_new + 1
end
if not max_clients or cur_clients + 1 > max_clients then
max_clients = cur_clients + 1
end
if not min_clients or cur_clients < min_clients then
min_clients = cur_clients
end
end
local function on_session_closed( cur_time, cur_user )
local old_time = player_login[ cur_user ]
local lifetime = cur_time - old_time
while cur_period < get_period( cur_time ) do
-- initialize client and player stats in prior periods
local hourly = hourly_stats[ cur_period ]
if not hourly.players then
hourly.clients_max = cur_clients
hourly.clients_min = cur_clients
hourly.players = cur_clients
end
cur_period = cur_period + 1
end
local hourly = hourly_stats[ cur_period ]
local player = player_stats[ cur_user ]
if not hourly.players then
-- initialize client and player stats for this period
hourly.clients_max = cur_clients
hourly.clients_min = cur_clients - 1
hourly.players = cur_clients
player_check = { }
elseif cur_clients - 1 < hourly.clients_min then
-- update client stats for this period, if needed
hourly.clients_min = cur_clients - 1
end
if not player_check[ cur_user ] then
-- track another unique player
player_check[ cur_user ] = 1
end
for p = get_period( old_time ), cur_period do
-- update session stats in all prior periods
hourly_stats[ p ].sessions = hourly_stats[ p ].sessions + 1
end
-- update some general stats
if lifetime > max_lifetime then
max_lifetime = lifetime
end
if max_clients == nil or cur_clients > max_clients then
max_clients = cur_clients
end
if min_clients == nil or cur_clients - 1 < min_clients then
min_clients = cur_clients - 1
end
total_sessions = total_sessions + 1
total_lifetime = total_lifetime + lifetime
if player then
player.lifetime = player.lifetime + lifetime
player.sessions = player.sessions + 1
else
player_stats[ cur_user ] = { is_new = player_added[ cur_user ], lifetime = lifetime, sessions = 1 }
-- if no previous sessions, it's a unique player
total_players = total_players + 1
end
end
-------------------------
local player_list = catalog.get_players( rel_date ) or { }
for i = 1, 24 do
hourly_stats[ i ] = { attempts = 0, failures = 0, sessions = 0 }
end
if catalog.get_is_online( rel_date ) then
server_start = rel_time
end
cur_clients = #player_list
for i, v in ipairs( player_list ) do
-- initalize pre-existing players
player_login[ v ] = rel_time
end
for optime, opcode, fields in catalog.records( rel_date ) do
if opcode == TX_LOGIN_ATTEMPT then
on_login_attempt( optime )
elseif opcode == TX_LOGIN_FAILURE then
on_login_failure( optime )
elseif opcode == TX_CREATE then
local cur_user = fields[ 1 ]
player_added[ cur_user ] = optime
on_login_attempt( optime )
elseif opcode == TX_SESSION_OPENED then
-- player joined game
local cur_user = fields[ 1 ]
player_login[ cur_user ] = optime
on_session_opened( optime, cur_user )
cur_clients = cur_clients + 1
elseif opcode == TX_SESSION_CLOSED then
-- player left game
local cur_user = fields[ 1 ]
on_session_closed( optime, cur_user )
cur_clients = cur_clients - 1
player_login[ cur_user ] = nil
elseif opcode == LOG_STARTED then
on_server_startup( optime )
-- sanity check (these should already not exist!)
player_login = { }
player_added = { }
cur_clients = 0
elseif opcode == LOG_STOPPED or opcode == LOG_CHECKED then
on_server_shutdown( optime )
-- on server shutdown, all players logged off
for cur_user in pairs( player_login ) do
on_session_closed( optime, cur_user )
end
-- purge stale data for next server startup
player_login = { }
player_added = { }
cur_clients = 0
elseif opcode == LOG_INDEXED then
if server_start then
on_server_shutdown( rel_time + 86399 )
end
for cur_user in pairs( player_login ) do
on_session_closed( rel_time + 86399, cur_user )
end
player_added = nil
player_login = nil
end
end
return {
server_uptime = server_uptime,
total_players = total_players,
total_players_new = total_players_new,
total_sessions = total_sessions,
total_failures = total_failures,
total_attempts = total_attempts,
max_clients = max_clients,
min_clients = min_clients,
max_lifetime = max_lifetime,
total_lifetime = total_lifetime,
player_stats = player_stats,
hourly_stats = hourly_stats
}
end
minetest.register_chatcommand( "statmon", {
description = "View graphical reports of player activity",
privs = { server = true },
func = function( name, param )
local res
local days = string.match( param, "^%d+$" ) or 1
local log_index = 1
local log_names = { "Total Players", "Total Sessions", "Total Attempts", "Total Failures", "Maximum Clients", "Minimum Clients" }
local graph_colors = { "#FFFF00", "#00FFFF", "#00FF00", "#FF0000", "#DDDDDD", "#BBBBBB" }
local graph_types = { GRAPH_TYPEBAR, GRAPH_TYPEBAR, GRAPH_TYPEBAR, GRAPH_TYPEBAR, GRAPH_TYPEBAR, GRAPH_TYPEBAR }
local get_formspec = function( )
local dataset = { }
local max_value = 5
local max_matrix = { 2, 1, 4, 3, 6, 5 }
for i, v in ipairs( res.hourly_stats ) do
-- convert the results into a linear dataset
local log_ids = { v.players, v.sessions, v.attempts, v.failures, v.clients_max, v.clients_min }
local value = log_ids[ log_index ] or 0
local value2 = log_ids[ max_matrix[ log_index ] ] or 0
table.insert( dataset, value )
-- find the maximum value to scale the y-axis
max_value = math.max( max_value, value, value2 )
end
-- calculate intervals with some headroom
max_value = max_value * 1.2
max_scale = math.ceil( max_value / 40 ) * 5
local graph = SimpleChart( dataset, {
vert_int = 4.5 / math.ceil( max_value / max_scale ),
vert_off = 5.6,
horz_int = 0.5,
horz_off = 0.8,
horz_pad = 0.5,
y_range = math.ceil( max_value / max_scale ),
y_start = 0,
y_scale = max_scale,
x_range = 24,
bar_color = graph_colors[ log_index ],
tag_color = "#AAAAAA",
idx_color = "#DDDDDD",
on_plot_y = function( y, y_index, v_min, v_max, prop, meta )
prop.idx_label = tostring( y_index )
end,
on_plot_x = function( x, x_index, v_min, v_max, v, prop, meta )
prop.idx_label = x_index % 2 == 0 and string.format( "%02d:00", x_index ) or ""
prop.tag_label = string.format( "%3s", v ) -- hack for centering
return v
end,
} )
local avg_lifetime = res.total_players > 0 and res.total_lifetime / res.total_sessions or 0
local rel_date = math.floor( os.time( ) / 86400 ) - days
local formspec = "size[13.5,9]"
.. default.gui_bg
.. default.gui_bg_img
.. string.format( "label[0.3,0.4;%s:]", "Dataset" )
.. string.format( "dropdown[1.5,0.3;3.3,1;log_name;%s;%d]", table.concat( log_names, "," ), log_index )
.. string.format( "label[5.1,0.4;Player Analytics Report - %s]", os.date( "!%d-%b-%Y", rel_date * 86400 ) )
.. "button[10.2,0.2;0.8,1;prev_week;<<]"
.. "button[10.9,0.2;0.8,1;prev;<]"
.. "button[11.6,0.2;0.8,1;next;>]"
.. "button[12.3,0.2;0.8,1;next_week;>>]"
.. "label[0.8,6.8;" .. minetest.colorize( "#BBBBBB", table.concat( {
"Total Players:",
"Total New Players:",
"Total Player Sessions:",
"Total Login Failures:",
"Total Login Attempts:"
}, "\n" ) ) .. "]"
.. "label[5.0,6.8;" .. table.concat( {
res.total_players,
res.total_players_new,
res.total_sessions,
res.total_failures,
res.total_attempts }, "\n" ) .. "]"
.. "label[7.0,6.8;" .. minetest.colorize( "#BBBBBB", table.concat( {
"Overall Server Uptime:",
"Maximum Connected Clients:",
"Minimum Connected Clients:",
"Maximum Player Lifetime:",
"Average Player Lifetime:"
}, "\n" ) ) .. "]"
.. "label[11.8,6.8;" .. table.concat( {
math.floor( res.server_uptime / 86399 * 100 ) .. "%",
res.max_clients,
res.min_clients,
math.floor( res.max_lifetime / 60 ) .. "m",
math.floor( avg_lifetime / 60 ) .. "m"
}, "\n" ) .. "]"
.. graph.draw( graph_types[ log_index ], 0, 0 )
return formspec
end
local on_close = function( meta, player, fields )
log_index = ( {
["Total Players"] = 1,
["Total Sessions"] = 2,
["Total Attempts"] = 3,
["Total Failures"] = 4,
["Maximum Clients"] = 5,
["Minimum Clients"] = 6
} )[ fields.log_name ] or 1
if fields.quit then return end
if fields.prev then
days = days + 1
res = analyze_log( days )
elseif fields.next and days > 0 then
days = days - 1
res = analyze_log( days )
elseif fields.prev_week then
days = days + 7
res = analyze_log( days )
elseif fields.next_week and days > 0 then
days = math.max( 0, days - 7 )
res = analyze_log( days )
end
minetest.update_form( player, get_formspec( ) )
end
res = analyze_log( days )
minetest.create_form( nil, name, get_formspec( ), on_close )
end
} )
------------------------------------------------------------
catalog = JournalIndex( minetest.get_worldpath( ), "auth.dbx" )
catalog.prepare( function ( ) end )

496
tools/statmon.lua Normal file
View File

@ -0,0 +1,496 @@
--------------------------------------------------------
-- Minetest :: DataMiner Mod v2.2 (dataminer)
--
-- See README.txt for licensing and release notes.
-- Copyright (c) 2017-2018, Leslie E. Krause
--------------------------------------------------------
local TX_CREATE = 20
local TX_SESSION_OPENED = 50
local TX_SESSION_CLOSED = 51
local TX_LOGIN_ATTEMPT = 30
local TX_LOGIN_FAILURE = 31
local LOG_STARTED = 10
local LOG_CHECKED = 11
local LOG_STOPPED = 12
local LOG_INDEXED = 13
local catalog
local rel_date
local rel_time
local cur_clients
local cur_period
local player_login
local player_added
local player_check
local server_start
local server_uptime
local total_players
local total_players_new
local total_sessions
local total_failures
local total_attempts
local max_clients
local min_clients
local max_lifetime
local total_lifetime
local player_stats
local hourly_stats
------------------------------------------------------------
-- parse the required command-line arguments
local name = "auth.dbx"
local path = "."
if arg[ 2 ] and arg[ 2 ] ~= "auth.db" then
path, name = string.match( arg[ 2 ], "^(.*)/(.+%.dbx)$" )
if not path then
error( "The specified journal file is not recognized." )
end
end
if not arg[ 1 ] or not string.find( arg[ 1 ], "^days=[0-9]+$" ) then
error( "The 'days' parameter is invalid or missing." )
end
local days = tonumber( string.sub( arg[ 1 ], 6 ) )
------------------------------------------------------------
function string.split( str, sep, has_nil )
res = { }
for val in string.gmatch( str .. sep, "(.-)" .. sep ) do
if val ~= "" or has_nil then
table.insert( res, val )
end
end
return res
end
local function get_period( t )
return math.floor( ( t - rel_time ) / 3600 ) + 1
end
local function on_login_failure( cur_time )
local p = get_period( cur_time )
hourly_stats[ p ].failures = hourly_stats[ p ].failures + 1
total_failures = total_failures + 1
end
local function on_login_attempt( cur_time )
local p = get_period( cur_time )
hourly_stats[ p ].attempts = hourly_stats[ p ].attempts + 1
total_attempts = total_attempts + 1
end
local function on_server_startup( cur_time )
--print( "startup", cur_time )
server_start = cur_time
end
local function on_server_shutdown( cur_time )
--print( "shutdown", cur_time )
server_uptime = server_uptime + ( cur_time - server_start )
server_start = nil
end
local function on_session_opened( cur_time, cur_user )
while cur_period < get_period( cur_time ) do
-- initialize client and player stats in prior periods
local hourly = hourly_stats[ cur_period ]
if not hourly.players then
hourly.clients_max = cur_clients
hourly.clients_min = cur_clients
hourly.players = cur_clients
end
cur_period = cur_period + 1
end
local hourly = hourly_stats[ cur_period ]
if not hourly.players then
-- initialize client and player stats for this period
hourly.clients_max = cur_clients + 1
hourly.clients_min = cur_clients
hourly.players = cur_clients
player_check = { }
elseif cur_clients + 1 > hourly.clients_max then
-- update client stats for this period, if needed
hourly.clients_max = cur_clients + 1
end
if not player_check[ cur_user ] then
-- track another unique player
player_check[ cur_user ] = 1
hourly.players = hourly.players + 1
end
-- update some general stats
if player_added[ cur_user ] then
-- only count new players after joining game (sanity check)
total_players_new = total_players_new + 1
end
if not max_clients or cur_clients + 1 > max_clients then
max_clients = cur_clients + 1
end
if not min_clients or cur_clients < min_clients then
min_clients = cur_clients
end
end
local function on_session_closed( cur_time, cur_user )
local old_time = player_login[ cur_user ]
local lifetime = cur_time - old_time
while cur_period < get_period( cur_time ) do
-- initialize client and player stats in prior periods
local hourly = hourly_stats[ cur_period ]
if not hourly.players then
hourly.clients_max = cur_clients
hourly.clients_min = cur_clients
hourly.players = cur_clients
end
cur_period = cur_period + 1
end
local hourly = hourly_stats[ cur_period ]
local player = player_stats[ cur_user ]
if not hourly.players then
-- initialize client and player stats for this period
hourly.clients_max = cur_clients
hourly.clients_min = cur_clients - 1
hourly.players = cur_clients
player_check = { }
elseif cur_clients - 1 < hourly.clients_min then
-- update client stats for this period, if needed
hourly.clients_min = cur_clients - 1
end
if not player_check[ cur_user ] then
-- track another unique player
player_check[ cur_user ] = 1
end
for p = get_period( old_time ), cur_period do
-- update session stats in all prior periods
hourly_stats[ p ].sessions = hourly_stats[ p ].sessions + 1
end
-- update some general stats
if lifetime > max_lifetime then
max_lifetime = lifetime
end
if max_clients == nill or cur_clients > max_clients then
max_clients = cur_clients
end
if min_clients == nil or cur_clients - 1 < min_clients then
min_clients = cur_clients - 1
end
total_sessions = total_sessions + 1
total_lifetime = total_lifetime + lifetime
if player then
player.lifetime = player.lifetime + lifetime
player.sessions = player.sessions + 1
else
player_stats[ cur_user ] = { is_new = player_added[ cur_user ], lifetime = lifetime, sessions = 1 }
-- if no previous sessions, it's a unique player
total_players = total_players + 1
end
end
local function prepare_log( )
io.write( "Working on it..." )
local stat_bar = { "-", "\\", "|", "/" }
local stat_idx = 0
-- prepare a lookup table of the transaction log
catalog.prepare( function ( )
-- show an animated progress indicator
if stat_idx % 50001 == 0 then
io.write( stat_bar[ stat_idx % 4 + 1 ] .. "\b" )
io.flush( )
end
stat_idx = stat_idx + 1
end )
io.write( "Done!\n" )
end
local function analyze_log( )
local player_list = catalog.get_players( rel_date ) or { }
cur_clients = #player_list
player_login = { }
player_added = { }
cur_period = 1
server_uptime = 0
total_players = 0
total_players_new = 0
total_sessions = 0
total_failures = 0
total_attempts = 0
max_clients = 0
min_clients = 0
max_lifetime = 0
total_lifetime = 0
player_stats = { }
hourly_stats = { }
for i = 1, 24 do
hourly_stats[ i ] = { attempts = 0, failures = 0, sessions = 0 }
end
if catalog.get_is_online( rel_date ) then
server_start = rel_time
end
for i, v in ipairs( player_list ) do
-- initalize pre-existing players
player_login[ v ] = rel_time
end
for optime, opcode, fields in catalog.records( rel_date ) do
if opcode == TX_LOGIN_ATTEMPT then
on_login_attempt( optime )
elseif opcode == TX_LOGIN_FAILURE then
on_login_failure( optime )
elseif opcode == TX_CREATE then
local cur_user = fields[ 1 ]
player_added[ cur_user ] = optime
on_login_attempt( optime )
elseif opcode == TX_SESSION_OPENED then
-- player joined game
local cur_user = fields[ 1 ]
player_login[ cur_user ] = optime
on_session_opened( optime, cur_user )
cur_clients = cur_clients + 1
elseif opcode == TX_SESSION_CLOSED then
-- player left game
local cur_user = fields[ 1 ]
on_session_closed( optime, cur_user )
cur_clients = cur_clients - 1
player_login[ cur_user ] = nil
elseif opcode == LOG_STARTED then
on_server_startup( optime )
-- sanity check (these should already not exist!)
player_login = { }
player_added = { }
cur_clients = 0
elseif opcode == LOG_STOPPED or opcode == LOG_CHECKED then
on_server_shutdown( optime )
-- on server shutdown, all players logged off
for cur_user in pairs( player_login ) do
on_session_closed( optime, cur_user )
end
-- purge stale data for next server startup
player_login = { }
player_added = { }
cur_clients = 0
elseif opcode == LOG_INDEXED then
if server_start then
on_server_shutdown( rel_time + 86399 )
end
for cur_user in pairs( player_login ) do
on_session_closed( rel_time + 86399, cur_user )
end
player_added = nil
player_login = nil
end
end
end
local function print_layout( )
print( string.format( "\27[0J\27[7m %s\27[0m", "DataMiner Mod v2.2" .. string.rep( " ", 118 ) ) )
print( string.rep( "\n", 41 ) )
io.write( "\27[39F" )
print( "\27[1G Player Activity: Hourly Totals" )
print( "\27[1G======================================================" )
print( string.format( "\27[1G %-8s %10s %10s %10s %10s", "Period", "Sessions", "Failures", "Attempts", "Players" ) )
print( "\27[1G------------------------------------------------------" )
io.write( "\27[24B" )
print( "\27[1G------------------------------------------------------" )
io.write( "\27[29A" )
print( "\27[57G Player Activity: Hourly Trends" )
print( "\27[57G====================================" )
print( string.format( "\27[57G %-8s %12s %12s", "Period", "Min Clients", "Max Clients" ) )
print( "\27[57G------------------------------------" )
io.write( "\27[24B" )
print( "\27[57G------------------------------------" )
io.write( "\27[29A" )
print( "\27[95G Player Activity: 24-Hour Totals" )
print( "\27[95G===========================================" )
print( string.format( "\27[95G %-19s %10s %10s", "Player", "Sessions", "Lifetime" ) )
print( "\27[95G-------------------------------------------" )
io.write( "\27[24B" )
print( "\27[95G-------------------------------------------" )
io.write( "\27[1B" )
print( "\27[1G Player Activity: 24-Hour Summary" )
print( "\27[1G============================================================================================" )
io.write( "\27[5B" )
print( "\27[1G--------------------------------------------------------------------------------------------" )
io.write( "\27[8A" )
print( "\27[95G Player Details: " )
print( "\27[95G===========================================" )
io.write( "\27[5B" )
print( "\27[95G-------------------------------------------" )
io.write( "\27[1B\27[1G Press <J> or <L> to navigate days, <I> or <K> scroll the list of players, and <Q> to quit." )
io.write( "\27[95G Tip: Hold <SHIFT> to accelerate movement." )
end
local function print_report( off )
local player_list = { }
io.write( "\27[41F\27" )
print( "\27[1G Daily Player Analytics Report (" .. os.date( "!%d-%b-%Y UTC", rel_time ) .. ")" )
io.write( "\27[5B" )
for i = 1, 24 do
print( string.format( "\27[1G [%02d:00] %10s %10s %10s %10s", i - 1,
hourly_stats[ i ].sessions,
hourly_stats[ i ].failures,
hourly_stats[ i ].attempts,
hourly_stats[ i ].players or 0 ) )
end
io.write( "\27[24A" )
for i = 1, 24 do
print( string.format( "\27[57G [%02d:00] %12s %12s", i - 1,
hourly_stats[ i ].clients_min or 0,
hourly_stats[ i ].clients_max or 0 ) )
end
for k, v in pairs( player_stats ) do
-- copy entire table into array for sorting
table.insert( player_list, { username = k, is_new = v.is_new, sessions = v.sessions, lifetime = v.lifetime } )
end
table.sort( player_list, function( a, b ) return a.lifetime > b.lifetime end )
io.write( "\27[24A" )
for i = 1, 24 do
local player = player_list[ i + off ]
if player then
print( string.format( ( i == 1 and "\27[95G \27[7m" or "\27[95G " ) .. "%-19s %10d %5dm %02ds\27[0m",
player.is_new and "* " .. player.username or player.username,
player.sessions,
player.lifetime / 60,
player.lifetime % 60 ) )
if i == 1 then
username = player.username
end
else print( "\27[95G" .. string.rep( " ", 42 ) ) end
end
if username then
local rec = db.select_record( username )
io.write( "\27[4B" )
print( string.format( "\27[95G %-20s %20s", "Username:", username ) )
print( string.format( "\27[95G %-20s %20s", "Initial Login:", os.date( "!%d-%b-%Y", rec.oldlogin ) ) )
print( string.format( "\27[95G %-20s %20s", "Latest Login:", os.date( "!%d-%b-%Y", rec.newlogin ) ) )
print( string.format( "\27[95G %-20s %20d", "Total Sessions:", rec.total_sessions ) )
print( string.format( "\27[95G %-20s %19dm", "Total Lifetime:", rec.lifetime / 60 ) )
end
io.write( "\27[5A" )
print( string.format( "\27[1G %-30s %10d", "Total Players:", total_players ) )
print( string.format( "\27[1G %-30s %10d", "Total New Players:", total_players_new ) )
print( string.format( "\27[1G %-30s %10d", "Total Player Sessions:", total_sessions ) )
print( string.format( "\27[1G %-30s %10d", "Total Login Failures:", total_failures ) )
print( string.format( "\27[1G %-30s %10d", "Total Login Attempts:", total_attempts ) )
io.write( "\27[5A" )
print( string.format( "\27[49G %-30s %9d%%", "Overall Server Uptime:", ( server_uptime / 86399 ) * 100 ) )
print( string.format( "\27[49G %-30s %10d", "Maximum Connected Clients:", max_clients ) )
print( string.format( "\27[49G %-30s %10d", "Minimum Connected Clients:", min_clients ) )
print( string.format( "\27[49G %-30s %9dm", "Maximum Player Lifetime:", max_lifetime / 60 ) )
print( string.format( "\27[49G %-30s %9dm", "Average Player Lifetime:", total_players > 0 and ( total_lifetime / total_sessions ) / 60 or 0 ) )
io.write( "\27[2B" )
end
------------------------------------------------------------
dofile( "../catalog.lua" )
dofile( "../db.lua" )
catalog = JournalIndex( path, name )
prepare_log( )
db = AuthDatabaseReader( path, "auth.db" )
db.connect( )
print_layout( )
os.execute( "stty raw -echo" )
local off
local opt
while true do
-- calculate the relative date given an offset
rel_date = math.floor( os.time( ) / 86400 ) - days
rel_time = rel_date * 86400
if not off then
analyze_log( )
print_report( 0 )
off = 0
elseif opt then
print_report( off )
end
opt = io.read( 1 )
if opt == "q" or opt == "Q" then
break
elseif opt == "l" and days > 0 then
days = days - 1
off = nil
elseif opt == "j" then
days = days + 1
off = nil
elseif opt == "L" and days > 0 then
days = math.max( 0, days - 7 )
off = nil
elseif opt == "J" then
days = days + 7
off = nil
elseif opt == "i" and off then
off = math.max( 0, off - 1 )
elseif opt == "k" and off then
off = math.min( total_players - 1, off + 1 )
elseif opt == "I" and off then
off = math.max( 0, off - 12 )
elseif opt == "K" and off then
off = math.min( total_players - 1, off + 12 )
else
opt = nil
end
end
catalog.close( )
db.disconnect( )
os.execute( "stty sane" )
print( )