Adds a new front end for viewing logs | fixes the manifest log message too (#75617)

## About The Pull Request


![image](https://github.com/tgstation/tgstation/assets/12817816/13d5f3c7-c0cc-4930-8119-e6bde66a1f61)

![image](https://github.com/tgstation/tgstation/assets/12817816/034a17d8-c552-4c3a-8e5f-b210fc4231e5)

## Why It's Good For The Game

I promised I would add it; and while it's not as nice as my previous
iteration it is faster and more streamlined.
## Changelog
🆑
admin: new log viewer, try it out. (View Round Logs)
/🆑

Fixes #75605

---------

Co-authored-by: san7890 <the@san7890.com>
This commit is contained in:
Zephyr
2023-05-29 17:56:15 -07:00
committed by GitHub
co-authored by san7890
parent 9add5a2569
commit c6205dd063
10 changed files with 421 additions and 33 deletions
+17
View File
@@ -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"
+4 -1
View File
@@ -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
))
-4
View File
@@ -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 {"<html><head><meta http-equiv="refresh" content="0;URL='[url]'"/></head><body onLoad="parent.location='[url]'"></body></html>"}
/// Formats a larger number to correct textual representation without losing data
/proc/big_number_to_text(number)
return num2text(number, INFINITY)
+4
View File
@@ -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)
+1
View File
@@ -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)
+9
View File
@@ -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)
+14 -25
View File
@@ -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)
+90 -2
View File
@@ -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,
+1 -1
View File
@@ -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;
+281
View File
@@ -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<string, LogViewerCategoryData>;
};
type LogViewerCategoryTree = {
enabled: string[];
disabled: string[];
};
type LogViewerCategoryData = {
entry_count: number;
entries: LogEntryData[];
};
type LogEntryData = {
id: number;
message: string;
timestamp: string;
semver?: Record<string, string>;
data?: any[];
};
const CATEGORY_ALL = 'all';
export const LogViewer = (_: any, context: any) => {
const { data, act } = useBackend<LogViewerData>(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 (
<Window width={720} height={720}>
<Window.Content scrollable>
<Section>
<Button icon="sync" onClick={() => act('refresh')} />
</Section>
<CategoryBar
options={data.tree.enabled}
active={activeCategory}
setActive={setActiveCategory}
/>
<CategoryViewer activeCategory={activeCategory} data={viewerData} />
</Window.Content>
</Window>
);
};
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 (
<Section
title="Categories"
scrollableHorizontal
buttons={
<Input
grow
placeholder="Search"
value={categorySearch}
onChange={(_: any, value: string) => setCategorySearch(value)}
/>
}>
<Stack scrollableHorizontal>
{/** these are not in stack items to have them directly next to eachother */}
<Button
textAlign="left"
content="None"
selected={props.active === ''}
onClick={() => props.setActive('')}
/>
<Button
textAlign="left"
content="All"
tooltip="This can be slow!"
selected={props.active === CATEGORY_ALL}
onClick={() => props.setActive(CATEGORY_ALL)}
/>
{sorted.map((category) => {
if (!category.toLowerCase().includes(categorySearch.toLowerCase())) {
return null;
}
return (
<Stack.Item key={category}>
<Button
textAlign="left"
content={category}
selected={category === props.active}
onClick={() => props.setActive(category)}
/>
</Stack.Item>
);
})}
</Stack>
</Section>
);
};
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 (
<Section
title={`Category Viewer${
props.activeCategory
? ` - ${props.activeCategory}[${props.data?.entry_count}]`
: ' - Select a category'
}`}
buttons={
<>
<Input
grow
fill
placeholder="Search"
value={search}
onChange={(_: any, value: string) => setSearch(value)}
/>
<Button
icon={'code'}
tooltip="RegEx Search"
selected={searchRegex}
onClick={() => setSearchRegex(!searchRegex)}
/>
<Button
icon={'font'}
selected={caseSensitive}
tooltip="Case Sensitive"
onClick={() => setCaseSensitive(!caseSensitive)}
/>
<Button
icon="trash"
tooltip="Clear Search"
color="bad"
onClick={() => {
setSearch('');
setSearchRegex(false);
}}
/>
</>
}>
<Stack vertical>
{!searchRegex || regexValidation === true ? (
props.data?.entries.map((entry) => {
if (search) {
if (searchRegex) {
const regex = new RegExp(search, caseSensitive ? 'g' : 'gi');
if (!regex.test(entry.message)) {
return null;
}
} else {
if (caseSensitive) {
if (!entry.message.includes(search)) {
return null;
}
} else {
if (
!entry.message.toLowerCase().includes(search.toLowerCase())
) {
return null;
}
}
}
}
return (
<Stack.Item key={entry.id}>
<Collapsible
fitted
tooltip={entry.timestamp}
title={`[${entry.id}] - ${entry.message}`}>
<Stack vertical fill>
<Stack.Item>
<p font-family="Courier">{entry.message}</p>
</Stack.Item>
<Stack.Item>
{entry.semver && (
<Stack.Item>
<JsonViewer data={entry.semver} title="Semver" />
</Stack.Item>
)}
</Stack.Item>
{entry.data && (
<Stack.Item>
<JsonViewer data={entry.data} title="Data" />
</Stack.Item>
)}
</Stack>
</Collapsible>
</Stack.Item>
);
})
) : (
<NoticeBox danger>
Invalid RegEx: {(regexValidation as SyntaxError).message}
</NoticeBox>
)}
</Stack>
</Section>
);
};
const JsonViewer = (props: { data: any; title: string }) => {
return (
<Collapsible title={props.title}>
<pre>{JSON.stringify(props.data, null, 2)}</pre>
</Collapsible>
);
};