commit
966cc666d0
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
polygraph
|
||||
formspecs
|
|
@ -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 )
|
|
@ -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( )
|
Loading…
Reference in New Issue