mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-08-26 06:29:23 +01:00
Adds a new front end for viewing logs | fixes the manifest log message too (#75617)
## About The Pull Request   ## 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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user