diff --git a/code/__DEFINES/logging.dm b/code/__DEFINES/logging.dm index 0883e7e5cbc..7bec0da19cd 100644 --- a/code/__DEFINES/logging.dm +++ b/code/__DEFINES/logging.dm @@ -1,3 +1,10 @@ +/// The number of entries to store per category, don't make this too large or you'll start to see performance issues +#define CONFIG_MAX_CACHED_LOG_ENTRIES 1000 + +/// The number of *minimum* ticks between each log re-render, making this small will cause performance issues +/// Admins can still manually request a re-render +#define LOG_UPDATE_TIMEOUT 5 SECONDS + //Investigate logging defines #define INVESTIGATE_ACCESSCHANGES "id_card_changes" #define INVESTIGATE_ATMOS "atmos" @@ -66,6 +73,16 @@ #define LOG_JSON_ENTRIES "entries" #define LOG_JSON_LOGGING_START "log-start" +// Log entry keys +#define LOG_ENTRY_KEY_TIMESTAMP "ts" +#define LOG_ENTRY_KEY_CATEGORY "cat" +#define LOG_ENTRY_KEY_MESSAGE "msg" +#define LOG_ENTRY_KEY_DATA "data" +#define LOG_ENTRY_KEY_WORLD_STATE "w-state" +#define LOG_ENTRY_KEY_SEMVER_STORE "s-store" +#define LOG_ENTRY_KEY_ID "id" +#define LOG_ENTRY_KEY_SCHEMA_VERSION "s-ver" + // Category for invalid/missing categories #define LOG_CATEGORY_NOT_FOUND "invalid-category" diff --git a/code/__HELPERS/logging/manifest.dm b/code/__HELPERS/logging/manifest.dm index 6009195e411..5cb8ebc03d8 100644 --- a/code/__HELPERS/logging/manifest.dm +++ b/code/__HELPERS/logging/manifest.dm @@ -1,5 +1,8 @@ /// Logging for player manifest (ckey, name, job, special role, roundstart/latejoin) /proc/log_manifest(ckey, datum/mind/mind, mob/body, latejoin = FALSE) - logger.Log(LOG_CATEGORY_MANIFEST, "manifest entry added", list( + var/message = {"([body.real_name])\[[ckey]\] added to manifest + as a(n) [latejoin ? "LATEJOIN " : ""][mind.assigned_role.title][mind.special_role ? mind.special_role : ""] + with a location of [loc_name(body.loc)]"} + logger.Log(LOG_CATEGORY_MANIFEST, message, list( "mind" = mind, "body" = body, "latejoin" = latejoin )) diff --git a/code/__HELPERS/type2type.dm b/code/__HELPERS/type2type.dm index 820ddc91be8..e9df60b699e 100644 --- a/code/__HELPERS/type2type.dm +++ b/code/__HELPERS/type2type.dm @@ -372,7 +372,3 @@ GLOBAL_LIST_INIT(modulo_angle_to_dir, list(NORTH,NORTHEAST,EAST,SOUTHEAST,SOUTH, /// for use inside of browse() calls to html assets that might be loaded on a cdn. /proc/url2htmlloader(url) return {""} - -/// Formats a larger number to correct textual representation without losing data -/proc/big_number_to_text(number) - return num2text(number, INFINITY) diff --git a/code/controllers/subsystem/lua.dm b/code/controllers/subsystem/lua.dm index 55b4856b890..d8fb642c1c9 100644 --- a/code/controllers/subsystem/lua.dm +++ b/code/controllers/subsystem/lua.dm @@ -21,6 +21,10 @@ SUBSYSTEM_DEF(lua) var/gc_guard /datum/controller/subsystem/lua/Initialize() + if(!CONFIG_GET(flag/auxtools_enabled)) + warning("SSlua requires auxtools to be enabled to run.") + return SS_INIT_NO_NEED + try // Initialize the auxtools library AUXTOOLS_CHECK(AUXLUA) diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index f9a217bc083..657dabd0cdc 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -91,6 +91,7 @@ GLOBAL_PROTECT(admin_verbs_admin) /client/proc/toggle_combo_hud, /* toggle display of the combination pizza antag and taco sci/med/eng hud */ /client/proc/toggle_view_range, /*changes how far we can see*/ /client/proc/cmd_admin_law_panel, + /client/proc/log_viewer_new, ) GLOBAL_LIST_INIT(admin_verbs_ban, list(/client/proc/unban_panel, /client/proc/ban_panel, /client/proc/stickybanpanel)) GLOBAL_PROTECT(admin_verbs_ban) diff --git a/code/modules/logging/log_category.dm b/code/modules/logging/log_category.dm index fe86491b902..b388370ce77 100644 --- a/code/modules/logging/log_category.dm +++ b/code/modules/logging/log_category.dm @@ -23,6 +23,12 @@ /// Whether the readable version of the log message is formatted internally instead of by rustg var/internal_formatting = TRUE + /// List of log entries for this category + var/list/entries = list() + + /// Total number of entries this round so far + var/entry_count = 0 + GENERAL_PROTECT_DATUM(/datum/log_category) /// Backup log category to catch attempts to log to a category that doesn't exist @@ -42,6 +48,9 @@ GENERAL_PROTECT_DATUM(/datum/log_category) ) write_entry(entry) + entry_count += 1 + if(entry_count <= CONFIG_MAX_CACHED_LOG_ENTRIES) + entries += entry /// Allows for category specific file splitting. Needs to accept a null entry for the default file. /// If master_category it will always return the output of master_category.get_output_file(entry) diff --git a/code/modules/logging/log_entry.dm b/code/modules/logging/log_entry.dm index 4b5bf19775b..e696e30d811 100644 --- a/code/modules/logging/log_entry.dm +++ b/code/modules/logging/log_entry.dm @@ -81,34 +81,23 @@ GENERAL_PROTECT_DATUM(/datum/log_entry) #endif return output +#define MANUAL_JSON_ENTRY(list, key, value) list.Add("\"[key]\":[(!isnull(value)) ? json_encode(value) : "null"]") + /// Converts the log entry to a JSON string. /datum/log_entry/proc/to_json_text() - // I do not trust byond's json encoder, so we're doing it manually - var/list/json_strings = list() + // I do not trust byond's json encoder, and need to ensure the order doesn't change. + var/list/json_entries = list() + MANUAL_JSON_ENTRY(json_entries, LOG_ENTRY_KEY_TIMESTAMP, timestamp) + MANUAL_JSON_ENTRY(json_entries, LOG_ENTRY_KEY_CATEGORY, category) + MANUAL_JSON_ENTRY(json_entries, LOG_ENTRY_KEY_MESSAGE, message) + MANUAL_JSON_ENTRY(json_entries, LOG_ENTRY_KEY_DATA, data) + MANUAL_JSON_ENTRY(json_entries, LOG_ENTRY_KEY_WORLD_STATE, world.get_world_state_for_logging()) + MANUAL_JSON_ENTRY(json_entries, LOG_ENTRY_KEY_SEMVER_STORE, semver_store) + MANUAL_JSON_ENTRY(json_entries, LOG_ENTRY_KEY_ID, id) + MANUAL_JSON_ENTRY(json_entries, LOG_ENTRY_KEY_SCHEMA_VERSION, schema_version) + return "{[json_entries.Join(",")]}" - json_strings += json_encode(timestamp) - - json_strings += json_encode(category) - - json_strings += json_encode(message) - - if(length(data)) - json_strings += json_encode(data) - else - json_strings += "null" - - json_strings += json_encode(world.get_world_state_for_logging()) - - if(length(semver_store)) - json_strings += json_encode(semver_store) - else - json_strings += "null" - - json_strings += "[id]" - - json_strings += json_encode(schema_version) - - return "\[[json_strings.Join(",")]\]" +#undef MANUAL_JSON_ENTRY /// Writes the log entry to a file. /datum/log_entry/proc/write_entry_to_file(file) diff --git a/code/modules/logging/log_holder.dm b/code/modules/logging/log_holder.dm index 526cf1f9ccb..a60bbc91b2e 100644 --- a/code/modules/logging/log_holder.dm +++ b/code/modules/logging/log_holder.dm @@ -1,4 +1,3 @@ - GLOBAL_REAL(logger, /datum/log_holder) /** * Main datum to manage logging actions @@ -22,11 +21,100 @@ GLOBAL_REAL(logger, /datum/log_holder) /// Whether or not logging as human readable text is enabled var/human_readable_enabled = FALSE + /// Cached ui_data + var/list/data_cache = list() + + /// Last time the ui_data was updated + var/last_data_update = 0 + var/initialized = FALSE var/shutdown = FALSE GENERAL_PROTECT_DATUM(/datum/log_holder) +/client/proc/log_viewer_new() + set name = "View Round Logs" + set category = "Admin" + logger.ui_interact(mob) + +/datum/log_holder/ui_interact(mob/user, datum/tgui/ui) + if(!check_rights_for(user.client, R_ADMIN)) + return + + ui = SStgui.try_update_ui(user, src, ui) + if(isnull(ui)) + ui = new(user, src, "LogViewer") + ui.set_autoupdate(FALSE) + ui.open() + +/datum/log_holder/ui_state(mob/user) + return GLOB.admin_state + +/datum/log_holder/ui_static_data(mob/user) + var/list/data = list( + "round_id" = GLOB.round_id, + "logging_start_timestamp" = logging_start_timestamp, + ) + + var/list/tree = list() + data["tree"] = tree + var/list/enabled_categories = list() + for(var/enabled in log_categories) + enabled_categories += enabled + tree["enabled"] = enabled_categories + + var/list/disabled_categories = list() + for(var/disabled in src.disabled_categories) + disabled_categories += disabled + tree["disabled"] = disabled_categories + + return data + +/datum/log_holder/ui_data(mob/user) + if(!last_data_update || (world.time - last_data_update) > LOG_UPDATE_TIMEOUT) + cache_ui_data() + return data_cache + +/datum/log_holder/proc/cache_ui_data() + var/list/category_map = list() + for(var/datum/log_category/category as anything in log_categories) + category = log_categories[category] + var/list/category_data = list() + + var/list/entries = list() + for(var/datum/log_entry/entry as anything in category.entries) + entries += list(list( + "id" = entry.id, + "message" = entry.message, + "timestamp" = entry.timestamp, + "data" = entry.data, + "semver" = entry.semver_store, + )) + category_data["entries"] = entries + category_data["entry_count"] = category.entry_count + + category_map[category.category] = category_data + + data_cache.Cut() + last_data_update = world.time + + data_cache["categories"] = category_map + data_cache["last_data_update"] = last_data_update + +/datum/log_holder/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + + switch(action) + if("re-render") + cache_ui_data() + SStgui.update_uis(src) + return TRUE + + else + stack_trace("unknown ui_act action [action] for [type]") + /// Assembles basic information for logging, creating the log category datums and checking for config flags as required /datum/log_holder/proc/init_logging() if(initialized) @@ -145,7 +233,7 @@ GENERAL_PROTECT_DATUM(/datum/log_holder) var/list/category_header = list( LOG_HEADER_INIT_TIMESTAMP = logging_start_timestamp, - LOG_HEADER_ROUND_ID = big_number_to_text(GLOB.round_id), + LOG_HEADER_ROUND_ID = GLOB.round_id, LOG_HEADER_SECRET = category_instance.secret, LOG_HEADER_CATEGORY_LIST = contained_categories, LOG_HEADER_CATEGORY = category_instance.category, diff --git a/tgui/packages/tgui/components/Section.tsx b/tgui/packages/tgui/components/Section.tsx index 0941b519d8a..7399671498d 100644 --- a/tgui/packages/tgui/components/Section.tsx +++ b/tgui/packages/tgui/components/Section.tsx @@ -9,7 +9,7 @@ import { Component, InfernoNode, RefObject, createRef } from 'inferno'; import { addScrollableNode, removeScrollableNode } from '../events'; import { canRender, classes } from 'common/react'; -type SectionProps = BoxProps & { +export type SectionProps = BoxProps & { className?: string; title?: InfernoNode; buttons?: InfernoNode; diff --git a/tgui/packages/tgui/interfaces/LogViewer.tsx b/tgui/packages/tgui/interfaces/LogViewer.tsx new file mode 100644 index 00000000000..a0234eb769e --- /dev/null +++ b/tgui/packages/tgui/interfaces/LogViewer.tsx @@ -0,0 +1,281 @@ +import { useBackend, useLocalState } from '../backend'; +import { Button, Collapsible, Input, NoticeBox, Section, Stack } from '../components'; +import { Window } from '../layouts'; + +type LogViewerData = { + round_id: number; + logging_start_timestamp: string; + tree: LogViewerCategoryTree; + last_data_update: number; + categories: Record; +}; + +type LogViewerCategoryTree = { + enabled: string[]; + disabled: string[]; +}; + +type LogViewerCategoryData = { + entry_count: number; + entries: LogEntryData[]; +}; + +type LogEntryData = { + id: number; + message: string; + timestamp: string; + semver?: Record; + data?: any[]; +}; + +const CATEGORY_ALL = 'all'; + +export const LogViewer = (_: any, context: any) => { + const { data, act } = useBackend(context); + + const [activeCategory, setActiveCategory] = useLocalState( + context, + 'activeCategory', + '' + ); + + let viewerData: LogViewerCategoryData = { + entry_count: 0, + entries: [], + }; + + if (activeCategory) { + if (activeCategory !== CATEGORY_ALL) { + viewerData = data.categories[activeCategory]; + } else { + for (const category in data.categories) { + const categoryData = data.categories[category]; + for (const entry of categoryData.entries) { + viewerData.entries.push(entry); + } + viewerData.entry_count += categoryData.entry_count; + } + viewerData.entries.sort((a, b) => a.id - b.id); + } + } + + return ( + + +
+
+ + +
+
+ ); +}; + +type CategoryBarProps = { + options: string[]; + active: string; + setActive: (active: string) => void; +}; + +const CategoryBar = (props: CategoryBarProps, context: any) => { + const sorted = [...props.options].sort(); + const [categorySearch, setCategorySearch] = useLocalState( + context, + 'categorySearch', + '' + ); + + return ( +
setCategorySearch(value)} + /> + }> + + {/** these are not in stack items to have them directly next to eachother */} +
+ ); +}; + +type CategoryViewerProps = { + activeCategory: string; + data?: LogViewerCategoryData; +}; + +const validateRegExp = (str: string) => { + try { + new RegExp(str); + return true; + } catch (e) { + return e; + } +}; + +const CategoryViewer = (props: CategoryViewerProps, context: any) => { + const [search, setSearch] = useLocalState(context, 'search', ''); + let [searchRegex, setSearchRegex] = useLocalState( + context, + 'searchRegex', + false + ); + let [caseSensitive, setCaseSensitive] = useLocalState( + context, + 'caseSensitive', + false + ); + if (!search && searchRegex) { + setSearchRegex(false); + searchRegex = false; + } + let regexValidation: boolean | SyntaxError = true; + if (searchRegex) { + regexValidation = validateRegExp(search); + } + + return ( +
+ setSearch(value)} + /> +
+ ); +}; + +const JsonViewer = (props: { data: any; title: string }) => { + return ( + +
{JSON.stringify(props.data, null, 2)}
+
+ ); +};