diff --git a/aurorastation.dme b/aurorastation.dme index 6e5bb3de469..84cfb892b73 100644 --- a/aurorastation.dme +++ b/aurorastation.dme @@ -3975,6 +3975,7 @@ #include "code\modules\tgui\modules\ipc_diagnostic.dm" #include "code\modules\tgui\modules\narrate_panel.dm" #include "code\modules\tgui\modules\neural_configuration.dm" +#include "code\modules\tgui\modules\persistence_panel.dm" #include "code\modules\tgui\states\admin.dm" #include "code\modules\tgui\states\always.dm" #include "code\modules\tgui\states\conscious.dm" diff --git a/code/__DEFINES/persistence/models.dm b/code/__DEFINES/persistence/models.dm index b30bafe521b..5db40895fda 100644 --- a/code/__DEFINES/persistence/models.dm +++ b/code/__DEFINES/persistence/models.dm @@ -19,4 +19,6 @@ var/singleton/persistent_type/history/type_define = null // Definition type var/attribute = null // Attribute for aggregation into type+attribute var/content = null // Treat this as a string - Open for implementing caller (e.g. raw string or json) + var/created_at = null // Null if the generic hasn't been saved yet, otherwise timestamp when the generic was saved + var/expires_at = null // Null if the generic hasn't been saved yet, otherwise timestamp when the generic will expire var/expires_in_days = PERSISTENT_EXPIRATION_CLEANUP_DELAY_DAYS // Expiration timespan used when generic is saved diff --git a/code/__DEFINES/persistence/type_defines.dm b/code/__DEFINES/persistence/type_defines.dm index fa3ef0609a0..8f538efdd94 100644 --- a/code/__DEFINES/persistence/type_defines.dm +++ b/code/__DEFINES/persistence/type_defines.dm @@ -50,8 +50,8 @@ ABSTRACT_TYPE(/singleton/persistent_type/history/character) // Base type with ex #define CREATE_PERSISTENT_TYPE_GENERIC(TYPE_NAME, TITLE, DESCRIPTION, REQUIRES_ATTRIBUTE) \ /singleton/persistent_type/generic/##TYPE_NAME \ { \ - title = #TITLE; \ - description = #DESCRIPTION; \ + title = TITLE; \ + description = DESCRIPTION; \ requires_attribute = ##REQUIRES_ATTRIBUTE; \ } @@ -60,8 +60,8 @@ ABSTRACT_TYPE(/singleton/persistent_type/history/character) // Base type with ex #define CREATE_PERSISTENT_TYPE_HISTORY(TYPE_NAME, TITLE, DESCRIPTION, REQUIRES_ATTRIBUTE, EXPIRATION_RULE) \ /singleton/persistent_type/history/##TYPE_NAME \ { \ - title = #TITLE; \ - description = #DESCRIPTION; \ + title = TITLE; \ + description = DESCRIPTION; \ requires_attribute = ##REQUIRES_ATTRIBUTE; \ expiration_rule = ##EXPIRATION_RULE; \ } @@ -71,8 +71,8 @@ ABSTRACT_TYPE(/singleton/persistent_type/history/character) // Base type with ex #define CREATE_PERSISTENT_TYPE_HISTORY_CHARACTER(TYPE_NAME, TITLE, DESCRIPTION, EXPIRATION_RULE) \ /singleton/persistent_type/history/character/##TYPE_NAME \ { \ - title = #TITLE; \ - description = #DESCRIPTION; \ + title = TITLE; \ + description = DESCRIPTION; \ requires_attribute = TRUE; \ expiration_rule = ##EXPIRATION_RULE; \ } diff --git a/code/controllers/subsystems/persistence/persistence.dm b/code/controllers/subsystems/persistence/persistence.dm index bc4ed83c391..20aee5c1951 100644 --- a/code/controllers/subsystems/persistence/persistence.dm +++ b/code/controllers/subsystems/persistence/persistence.dm @@ -68,36 +68,6 @@ SUBSYSTEM_DEF(persistence) return FALSE return TRUE -/datum/admins/proc/toggle_persistence() - set name = "Toggle Persistence" - set category = "Special Verbs" - - if(!check_rights(R_ADMIN)) - return - - var/message = "" - var/options = list() - if(SSpersistence.prevent_saving) - message = "The persistence subsystem will NOT save at the end of the round. Do you want to re-enable it?" - options = list("Re-enable saving", "Cancel") - else - message = "The persistence subsystem will save at the end of the round. Do you want to prevent this? This can be un-done before the round ends." - options = list("Prevent saving", "Cancel") - - var/confirm = tgui_alert(usr, message, "Toggle Persistence Saving", options) - if(confirm == "Prevent saving") - SSpersistence.prevent_saving = TRUE - to_world(FONT_LARGE(EXAMINE_BLOCK_RED("Persistence saving at the end of the round has been [SPAN_BOLD(SPAN_WARNING("disabled"))] by an administrator."))) - log_and_message_admins("has toggled persistence saving at round end, it is now disabled", usr) - else if (confirm == "Re-enable saving") - SSpersistence.prevent_saving = FALSE - to_world(FONT_LARGE(EXAMINE_BLOCK_RED("Persistence saving at the end of the round has been [SPAN_BOLD(SPAN_GOOD("re-enabled"))] by an administrator."))) - log_and_message_admins("has toggled persistence saving at round end, it is now re-enabled", usr) - else - return - - feedback_add_details("admin_verb","TPS") //If you are copy-pasting this, ensure the 2nd parameter is unique to the new proc! - /** * Initialization of the persistence subsystem. * Includes generic startup checks and init of the different persistent data types. diff --git a/code/controllers/subsystems/persistence/persistence_objects.dm b/code/controllers/subsystems/persistence/persistence_objects.dm index 490ec81d4a3..ddb2ed7ad7f 100644 --- a/code/controllers/subsystems/persistence/persistence_objects.dm +++ b/code/controllers/subsystems/persistence/persistence_objects.dm @@ -30,6 +30,8 @@ // the object to continue with init logic after the subsystem is done in LateInitialize. var/obj/instance = new typepath() instance.persistent_objects_track_id = data["id"] + instance.persistent_objects_created_at = data["created_at"] + instance.persistent_objects_expires_at = data["expires_at"] objectsApplyTrackContent(instance, data["content"], data["x"], data["y"], data["z"]) objectsRegisterTrack(instance, data["author_ckey"]) diff --git a/code/controllers/subsystems/persistence/persistence_objects_sql.dm b/code/controllers/subsystems/persistence/persistence_objects_sql.dm index f1963ec9519..51ff26d8a74 100644 --- a/code/controllers/subsystems/persistence/persistence_objects_sql.dm +++ b/code/controllers/subsystems/persistence/persistence_objects_sql.dm @@ -21,8 +21,8 @@ qdel(cleanup_query) /** - * Retrieve persistent data entries that haven't expired. - * RETURN: List of JSON, with ID, author_ckey, type, content, x, y, z + * Retrieve active persistent object entries that have not expired. + * RETURN: List of associative lists, each containing ID, author_ckey, type, content, x, y, z, created_at, and expires_at. */ /datum/controller/subsystem/persistence/proc/objectsDatabaseGetActiveEntries() PRIVATE_PROC(TRUE) @@ -30,7 +30,7 @@ return var/datum/db_query/get_query = SSdbcore.NewQuery( - "SELECT id, author_ckey, type, content, x, y, z FROM ss13_persistent_objects WHERE NOW() < expires_at" + "SELECT id, author_ckey, type, content, x, y, z, created_at, expires_at FROM ss13_persistent_objects WHERE NOW() < expires_at" ) get_query.Execute() @@ -47,7 +47,9 @@ "content" = get_query.item[4], "x" = get_query.item[5], "y" = get_query.item[6], - "z" = get_query.item[7] + "z" = get_query.item[7], + "created_at" = get_query.item[8], + "expires_at" = get_query.item[9] ) results += list(entry) qdel(get_query) diff --git a/code/controllers/subsystems/persistence/persistence_types.dm b/code/controllers/subsystems/persistence/persistence_types.dm index 6957498213a..6ab0d04958a 100644 --- a/code/controllers/subsystems/persistence/persistence_types.dm +++ b/code/controllers/subsystems/persistence/persistence_types.dm @@ -142,7 +142,7 @@ */ /datum/controller/subsystem/persistence/proc/typesHistoryCacheSelectTopK(k, datum/persistent_record_container/container) PRIVATE_PROC(TRUE) - if(length(container.records)) + if(!length(container.records)) return list() var/list/datum/persistent_record/top = list() diff --git a/code/controllers/subsystems/persistence/persistence_types_generic_public.dm b/code/controllers/subsystems/persistence/persistence_types_generic_public.dm index 35184d1422f..6580d3691e0 100644 --- a/code/controllers/subsystems/persistence/persistence_types_generic_public.dm +++ b/code/controllers/subsystems/persistence/persistence_types_generic_public.dm @@ -41,10 +41,11 @@ * PARAMS: * target_type = Singleton persistent type definition. See /singleton/persistent_type/generic and subtypes. * attribute = Custom attribute of the generic, can be null if the type definition doesn't require it. Defaults to null. + * skip_caching = If set to TRUE, database results won't be added to the generic cache. Defaults to FALSE. * RETURN: * /persistent_generic or null if not available. */ -/datum/controller/subsystem/persistence/proc/genericLoad(var/singleton/persistent_type/generic/target_type, attribute = null) +/datum/controller/subsystem/persistence/proc/genericLoad(var/singleton/persistent_type/generic/target_type, attribute = null, skip_caching = FALSE) if(!target_type) log_subsystem_persistence_warning("Attempted to load generic with null target type.") return @@ -67,6 +68,40 @@ new_generic.type_define = target_type new_generic.attribute = attribute new_generic.content = json_decode(result["content"]) + new_generic.created_at = result["created_at"] + new_generic.expires_at = result["expires_at"] new_generic.expires_in_days = 0 - generic_cache[typesGetCacheName(target_type, attribute)] = new_generic + if(!skip_caching) + generic_cache[typesGetCacheName(target_type, attribute)] = new_generic return new_generic + +/** + * Retrieves all known attributes of a generic type. + * PARAMS: + * target_type = Singleton persistent type definition. See /singleton/persistent_type/generic and subtypes. + * RETURN: + * Distinct list of attributes or empty list. + */ +/datum/controller/subsystem/persistence/proc/genericGetAllAttributesForType(var/singleton/persistent_type/generic/target_type) + if(!target_type) + log_subsystem_persistence_warning("Attempted to load all generic attributes with null target type.") + return list() + + var/singleton/persistent_type/type_instance = GET_SINGLETON(target_type) + if(!type_instance) + log_subsystem_persistence_warning("Attempted to load all generic attributes of type [target_type], but no singleton instance was found.") + return list() + + if(!type_instance.requires_attribute) + log_subsystem_persistence_warning("Attempted to load all generic attributes of type [target_type], but this type does not support attributes.") + return list() + + var/list/attributes = genericDatabaseGetAllAttributes(type_instance.database_id) // Retrieve all attributes in the database + + for(var/datum/persistent_generic/cache_entry in generic_cache) // Get attributes that aren't in the database yet + if(cache_entry.type_define == target_type && cache_entry.attribute && !(cache_entry.attribute in attributes)) + attributes += cache_entry.attribute + + if(!attributes) + return list() + return attributes diff --git a/code/controllers/subsystems/persistence/persistence_types_generic_sql.dm b/code/controllers/subsystems/persistence/persistence_types_generic_sql.dm index 73b93079618..7af94a529b7 100644 --- a/code/controllers/subsystems/persistence/persistence_types_generic_sql.dm +++ b/code/controllers/subsystems/persistence/persistence_types_generic_sql.dm @@ -109,7 +109,7 @@ * type_id = Type of ID. * attribute = Custom attribute of the record, can be null. * RETURN: - * Associative list of keys "id", "content" (JSON). + * Associative list of keys "id", "content" (JSON), "created_at", "expires_at". */ /datum/controller/subsystem/persistence/proc/genericDatabaseLoad(type_id, attribute) PRIVATE_PROC(TRUE) @@ -117,7 +117,7 @@ return 0 var/datum/db_query/query = SSdbcore.NewQuery( - "SELECT id, content FROM ss13_persistent_generics \ + "SELECT id, content, created_at, expires_at FROM ss13_persistent_generics \ WHERE type = :type_id AND attribute <=> :attribute", list( "type_id" = type_id, @@ -132,6 +132,32 @@ var/result = null while(query.NextRow()) - result = list("id" = query.item[1], "content" = query.item[2]) + result = list("id" = query.item[1], "content" = query.item[2], "created_at" = query.item[3], "expires_at" = query.item[4]) + qdel(query) + return result + +/** + * Returns all attributes from persistent generics for a specified type. + * RETURN: + * Distinct list of attributes or empty list. + */ +/datum/controller/subsystem/persistence/proc/genericDatabaseGetAllAttributes(type_id) + PRIVATE_PROC(TRUE) + if(!databaseCheckConnection("genericDatabaseGetAllAttributes")) + return + + var/datum/db_query/query = SSdbcore.NewQuery( + "SELECT DISTINCT attribute FROM ss13_persistent_generics WHERE type = :type_id", + list("type_id" = type_id) + ) + query.Execute() + + if(!databaseCheckQueryResult(query, "genericDatabaseGetAllAttributes")) + qdel(query) + return null + + var/result = list() + while(query.NextRow()) + result += query.item[1] qdel(query) return result diff --git a/code/controllers/subsystems/persistence/persistence_types_history_public.dm b/code/controllers/subsystems/persistence/persistence_types_history_public.dm index 40164071543..c98773afdc7 100644 --- a/code/controllers/subsystems/persistence/persistence_types_history_public.dm +++ b/code/controllers/subsystems/persistence/persistence_types_history_public.dm @@ -105,11 +105,12 @@ * target_type = Singleton persistent type definition. See /singleton/persistent_type and subtypes. * If the type definition is a character record type, the attribute must be a valid character ID or the record will be rejected. * attribute = Custom attribute of the record, can be null if the type definition doesn't require it. + * skip_caching = If set to TRUE, the results won't be added to the types cache, defaults to FALSE. * RETURN: * Single /persistent_record or null. */ -/datum/controller/subsystem/persistence/proc/historyGetLastRecord(var/singleton/persistent_type/history/target_type, attribute) - var/result = historyGetLastRecords(target_type, attribute, 1) +/datum/controller/subsystem/persistence/proc/historyGetLastRecord(var/singleton/persistent_type/history/target_type, attribute, skip_caching = FALSE) + var/result = historyGetLastRecords(target_type, attribute, 1, skip_caching) if(length(result) == 0) return null else @@ -233,14 +234,20 @@ log_subsystem_persistence_warning("Attempted to draw more records then allowed for target type [target_type].") var/singleton/persistent_type/type_instance = GET_SINGLETON(target_type) - var/list/result = list() - var/attributes = historyDatabaseGetAllAttributes(type_instance.database_id) + var/attributes = list() + if(type_instance.requires_attribute) + attributes = historyDatabaseGetAllAttributes(type_instance.database_id) // Retrieve all attributes in the database + for(var/datum/persistent_record_container/cache_entry in history_cache) // Get attributes that aren't in the database yet + if(cache_entry.type_define == target_type && cache_entry.attribute && !(cache_entry.attribute in attributes)) + attributes += cache_entry.attribute + + var/list/result = list() if(attributes && length(attributes) > 0) for(var/attribute in attributes) - result += list(alist("attribute" = attribute, "records" = historyGetAllRecords(target_type, attribute, skip_caching))) + result += list(alist("attribute" = attribute, "records" = historyGetLastRecords(target_type, attribute, limit, skip_caching))) else - var/no_attribute_records = historyGetAllRecords(target_type, null, skip_caching) + var/no_attribute_records = historyGetLastRecords(target_type, null, limit, skip_caching) if(no_attribute_records && length(no_attribute_records) > 0) result = list(alist("attribute" = null, "records" = no_attribute_records)) return result diff --git a/code/controllers/subsystems/persistence/persistence_types_history_sql.dm b/code/controllers/subsystems/persistence/persistence_types_history_sql.dm index 1a2afe8d4ff..08ea66fff8c 100644 --- a/code/controllers/subsystems/persistence/persistence_types_history_sql.dm +++ b/code/controllers/subsystems/persistence/persistence_types_history_sql.dm @@ -230,6 +230,11 @@ var/records = list() while(query.NextRow()) - records += list(alist("id" = query.item[1], "created_at" = query.item[2], "value" = query.item[3])) + records += list(alist( + "id" = query.item[1], + "created_at" = query.item[2], + "value" = query.item[3], + "game_id" = query.item[4], + )) qdel(query) return records diff --git a/code/game/objects/objs.dm b/code/game/objects/objs.dm index 5cc82870eb5..32ae8262155 100644 --- a/code/game/objects/objs.dm +++ b/code/game/objects/objs.dm @@ -106,6 +106,10 @@ var/persistent_objects_author_ckey = null // Expiration time used when saving/updating a persistent type, this can be changed depending on the use case by assigning a new value var/persistent_objects_expiration_time_days = PERSISTENT_DEFAULT_EXPIRATION_DAYS + // Database timestamp when the tracked object was created + var/persistent_objects_created_at = null + // Database timestamp when the tracked object will expire + var/persistent_objects_expires_at = null /* END PERSISTENCE VARS */ /// for easy reference of talking atoms diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 55fae9f08cf..d14b0a71b48 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -88,6 +88,7 @@ GLOBAL_LIST_INIT(admin_verbs_admin, list( /client/proc/allow_stationbound_reset, /client/proc/end_round, /client/proc/event_manager_panel, + /client/proc/persistence_panel, /client/proc/empty_ai_core_toggle_latejoin, /client/proc/aooc, /client/proc/change_human_appearance_admin, // Allows an admin to change the basic appearance of human-based mobs , @@ -149,8 +150,7 @@ GLOBAL_LIST_INIT(admin_verbs_fun, list( /client/proc/fab_tip, /client/proc/apply_sunstate, /datum/admins/proc/ccannoucment, - /datum/admins/proc/set_odyssey, - /datum/admins/proc/toggle_persistence + /datum/admins/proc/set_odyssey )) GLOBAL_LIST_INIT(admin_verbs_spawn, list( @@ -456,6 +456,7 @@ GLOBAL_LIST_INIT(admin_verbs_mod, list( /client/proc/cmd_admin_check_contents, /client/proc/print_logout_report, /client/proc/check_ai_laws, /*shows AI and borg laws*/ + /client/proc/persistence_panel, /client/proc/aooc, /client/proc/toggle_aooc, /client/proc/alooc, @@ -491,6 +492,7 @@ GLOBAL_LIST_INIT(admin_verbs_dev, list( //will need to be altered - Ryan784 /client/proc/debug_controller, /client/proc/debug_variables, /client/proc/dsay, + /client/proc/persistence_panel, /client/proc/hide_most_verbs, /client/proc/kill_air, /client/proc/kill_airgroup, diff --git a/code/modules/admin/verbs/randomverbs.dm b/code/modules/admin/verbs/randomverbs.dm index 753b7a408a9..a33e2627679 100644 --- a/code/modules/admin/verbs/randomverbs.dm +++ b/code/modules/admin/verbs/randomverbs.dm @@ -1157,3 +1157,16 @@ Traitors and the like can also be revived with the previous role mostly intact. message_admins("[key_name_admin(usr)] used the ADPI Panel") log_admin("[key_name(usr)] used the ADPI Panel") feedback_add_details("admin_verb", "ADPI") + +/client/proc/persistence_panel() + set category = "Admin" + set name = "Persistence Panel" + set desc = "Open the persistence subsystem admin panel" + + if(!check_rights(R_ADMIN|R_MOD|R_DEV, TRUE)) + return + + var/datum/tgui_module/persistence_panel/panel = new + panel.ui_interact(usr) + + feedback_add_details("admin_verb", "PERSIST") diff --git a/code/modules/tgui/modules/persistence_panel.dm b/code/modules/tgui/modules/persistence_panel.dm new file mode 100644 index 00000000000..7fa816304cb --- /dev/null +++ b/code/modules/tgui/modules/persistence_panel.dm @@ -0,0 +1,236 @@ +/datum/tgui_module/persistence_panel/ui_interact(mob/user, var/datum/tgui/ui) + if(!can_use_persistence_panel(user)) + return + + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "PersistencePanel", "Persistence Panel", 900, 700) + ui.open() + +/datum/tgui_module/persistence_panel/ui_data(mob/user) + var/list/data = list() + data["status_initialized"] = SSpersistence.init_success + data["saving_active"] = !SSpersistence.prevent_saving + data["objects_tracked"] = length(SSpersistence.object_track_register) + data["records_cached"] = SSpersistence.history_cache_count + data["generics_cached"] = length(SSpersistence.generic_cache) + data["is_admin"] = check_rights(R_ADMIN, 0, user) + + return data + +/datum/tgui_module/persistence_panel/ui_static_data(mob/user) + var/list/data = list() + data["objects"] = get_tracked_objects_data() + data["records"] = get_history_records_data() + data["generics"] = get_generic_records_data() + + return data + +/datum/tgui_module/persistence_panel/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + + // Instead of return TRUE for a regular update, return FALSE and call ui.send_update() before. + // Returning TRUE will cause static_ui_data to be sent, which in this panel is costly. + + if(!can_use_persistence_panel(ui.user)) // Code behind permissions check + return FALSE + + if(action == "toggle_saving") // Toggle persistence saving at round end + if(!check_rights(R_ADMIN)) + return FALSE + + var/message = "" + var/options = list() + if(SSpersistence.prevent_saving) + message = "The persistence subsystem will NOT save at the end of the round. Do you want to re-enable it?" + options = list("Re-enable saving", "Cancel") + else + message = "The persistence subsystem will save at the end of the round. Do you want to prevent this? This can be un-done before the round ends." + options = list("Prevent saving", "Cancel") + + var/confirm = tgui_alert(usr, message, "Toggle Persistence Saving", options) + if(confirm == "Prevent saving") + SSpersistence.prevent_saving = TRUE + to_world(FONT_LARGE(EXAMINE_BLOCK_RED("Persistence saving at the end of the round has been [SPAN_BOLD(SPAN_WARNING("disabled"))] by an administrator."))) + log_and_message_admins("has toggled persistence saving at round end, it is now disabled", usr) + else if (confirm == "Re-enable saving") + SSpersistence.prevent_saving = FALSE + to_world(FONT_LARGE(EXAMINE_BLOCK_RED("Persistence saving at the end of the round has been [SPAN_BOLD(SPAN_GOOD("re-enabled"))] by an administrator."))) + log_and_message_admins("has toggled persistence saving at round end, it is now re-enabled", usr) + + ui.send_update() + return FALSE + + if(action == "refresh") // Full data pull + return TRUE + + if (action == "jump") // Object jump + var/atom/target = locate(params["ref"]) + if(!target || !istype(target)) + to_chat(ui.user, SPAN_WARNING("Object can no longer be found.")) + return FALSE + + var/turf/T = get_turf(target) + if(!T) + to_chat(ui.user, SPAN_WARNING("Object can no longer be found.")) + return FALSE + + var/client/C = ui.user?.client + if(!C) + return FALSE + if(!isghost(usr)) + C.admin_ghost() + C.jumptocoord(T.x, T.y, T.z) + return FALSE + + if(action == "edit") // Object VV + var/client/C = ui.user?.client + if(!C) + return FALSE + + var/atom/target = locate(params["ref"]) + if(!target || !istype(target)) + to_chat(ui.user, SPAN_WARNING("object can no longer be found.")) + return FALSE + + C.debug_variables(target) + return FALSE + + return FALSE + +/datum/tgui_module/persistence_panel/proc/get_tracked_objects_data() + var/list/objects = list() + for(var/obj/track in SSpersistence.object_track_register) + var/turf/T = get_turf(track) + objects += list(list( + "ref" = REF(track), // Needed for VV action + "type" = "[track.type]", + "name" = track.name, + "x" = T?.x || 0, + "y" = T?.y || 0, + "z" = T?.z || 0, + "created_at" = track.persistent_objects_created_at, + "expires_at" = track.persistent_objects_expires_at + )) + return objects + +/datum/tgui_module/persistence_panel/proc/get_history_records_data() + var/list/history_types = typesof(/singleton/persistent_type/history) - list(/singleton/persistent_type/history, /singleton/persistent_type/history/character) + var/list/records = list() + + for(var/type_path in history_types) + var/singleton/persistent_type/history/type_instance = GET_SINGLETON(type_path) + if(!type_instance) + continue + + records += list(build_history_type_data(type_instance)) + + return records + +/datum/tgui_module/persistence_panel/proc/build_history_type_data(var/singleton/persistent_type/history/type_instance) + var/list/type_attribute_groups = SSpersistence.historyGetAllRecordsForAllAttributes(type_instance, TRUE) + if(!type_attribute_groups) + type_attribute_groups = list() + + var/list/attribute_groups_history = list() + for(var/attribute_group in type_attribute_groups) + attribute_groups_history += list(build_history_attribute_group_data(type_instance, attribute_group)) + + return list( + "type" = get_persistent_type_display_name(type_instance), + "title" = type_instance.title, + "description" = type_instance.description, + "requires_attribute" = type_instance.requires_attribute, + "attributes" = attribute_groups_history + ) + +/datum/tgui_module/persistence_panel/proc/build_history_attribute_group_data(var/singleton/persistent_type/history/type_instance, var/list/attribute_group) + var/attribute = attribute_group["attribute"] + var/list/attribute_records = attribute_group["records"] || list() + var/list/record_items = list() + + for(var/datum/persistent_record/record in attribute_records) + record_items += list(list( + "created_at" = record.created_at, + "game_id" = record.game_id, + "value" = "[record.value]" + )) + + var/attribute_value = attribute + if(istype(type_instance, /singleton/persistent_type/history/character)) + // Character history is a special case, we want to show the character name instead of the ID + attribute_value = SSpersistence.historyGetCharnameByID(attribute) + + return list( + "attribute" = attribute_value, + "records" = record_items + ) + +/datum/tgui_module/persistence_panel/proc/get_generic_records_data() + var/list/generics_by_type = list() + var/custom_types = typesof(/singleton/persistent_type/generic) - /singleton/persistent_type/generic + + for(var/type_path in custom_types) + var/singleton/persistent_type/generic/type_instance = GET_SINGLETON(type_path) + if(!type_instance) + continue + + var/type_key = "[type_instance.type]" + if(!generics_by_type[type_key]) + generics_by_type[type_key] = build_generic_type_data(type_instance) + + append_generic_attribute_data(type_instance, generics_by_type[type_key]["attributes"]) + + var/list/generics = list() + for(var/type_key in generics_by_type) + generics += list(generics_by_type[type_key]) + + return generics + +/datum/tgui_module/persistence_panel/proc/build_generic_type_data(var/singleton/persistent_type/generic/type_instance) + var/list/type_parts = return_typenames(type_instance.type) + return list( + "type" = type_parts[length(type_parts)], + "title" = type_instance.title, + "description" = type_instance.description, + "requires_attribute" = type_instance.requires_attribute, + "attributes" = list() + ) + +/datum/tgui_module/persistence_panel/proc/append_generic_attribute_data(var/singleton/persistent_type/generic/type_instance, var/list/attribute_groups_generic) + if(type_instance.requires_attribute) + var/list/attributes = SSpersistence.genericGetAllAttributesForType(type_instance) + for(var/attribute in attributes) + var/datum/persistent_generic/generic_entry = SSpersistence.genericLoad(type_instance, attribute, TRUE) + if(!generic_entry) + continue + + attribute_groups_generic += list(list( + "attribute" = attribute, + "created_at" = generic_entry.created_at, + "expires_at" = generic_entry.expires_at, + "json" = json_encode(generic_entry.content) + )) + return + + var/datum/persistent_generic/generic_entry = SSpersistence.genericLoad(type_instance, null, TRUE) + if(generic_entry) + attribute_groups_generic += list(list( + "attribute" = null, + "created_at" = generic_entry.created_at, + "expires_at" = generic_entry.expires_at, + "json" = json_encode(generic_entry.content) + )) + +/datum/tgui_module/persistence_panel/proc/get_persistent_type_display_name(var/singleton/persistent_type/type_instance) + var/list/type_parts = return_typenames(type_instance.type) + var/type_name = type_parts[length(type_parts)] + if(istype(type_instance, /singleton/persistent_type/history/character)) + return "Character - [type_name]" + + return type_name + +/datum/tgui_module/persistence_panel/proc/can_use_persistence_panel(var/mob/user) + return check_rights(R_ADMIN|R_MOD|R_DEV, 0, user) diff --git a/html/changelogs/fabiank3-persistence-panel.yml b/html/changelogs/fabiank3-persistence-panel.yml new file mode 100644 index 00000000000..9171f7e9979 --- /dev/null +++ b/html/changelogs/fabiank3-persistence-panel.yml @@ -0,0 +1,8 @@ +author: FabianK3 + +delete-after: True + +changes: + - bugfix: "Fixed quotation marks being added to persistent type defines in the database." + - rscadd: "Added a new TGUI panel for admins called persistence panel. Features subsystem status and detailed information on all persistent content." + - qol: "Removed the toggle persistence admin verb and moved it into the persistence panel." diff --git a/tgui/packages/tgui/interfaces/PersistencePanel.tsx b/tgui/packages/tgui/interfaces/PersistencePanel.tsx new file mode 100644 index 00000000000..cab2df6de79 --- /dev/null +++ b/tgui/packages/tgui/interfaces/PersistencePanel.tsx @@ -0,0 +1,618 @@ +import { + Box, + Button, + Collapsible, + LabeledList, + NoticeBox, + Section, + Table, + Tabs, +} from 'tgui-core/components'; +import { useBackend, useLocalState } from '../backend'; +import { Window } from '../layouts'; +import { SearchBar } from './common/SearchBar'; + +type PersistencePanelData = { + status_initialized: boolean; + saving_active: boolean; + objects_tracked: number; + records_cached: number; + generics_cached: number; + objects: PersistenceObject[]; + records: PersistenceRecordTypeGroup[]; + generics: PersistenceGenericTypeGroup[]; + is_admin: boolean; +}; + +type PersistenceObject = { + ref: string; + type: string; + name: string; + x: number | null; + y: number | null; + z: number | null; + created_at: string; + expires_at: string; +}; + +type PersistenceRecordEntry = { + created_at: string; + game_id: string; + value: string; +}; + +type PersistenceAttributeGroup = { + attribute: string | null; + records?: T[]; +}; + +type PersistenceRecordAttributeGroup = PersistenceAttributeGroup; + +type PersistenceRecordTypeGroup = { + type: string; + title: string; + description?: string | null; + requires_attribute: boolean; + attributes: PersistenceRecordAttributeGroup[]; +}; + +type PersistenceGenericAttributeGroup = { + attribute: string | null; + created_at: string | null; + expires_at: string | null; + json: string; +}; + +type PersistenceGenericTypeGroup = { + type: string; + title: string; + description?: string | null; + requires_attribute: boolean; + attributes: PersistenceGenericAttributeGroup[]; +}; + +const getObjectPosition = (object: PersistenceObject) => { + if (object.x === null || object.y === null || object.z === null) { + return 'Unknown'; + } + + return `${object.x}-${object.y}-${object.z}`; +}; + +const getAttributeLabel = (attribute: string | null) => attribute || 'Unattributed'; + +const formatTitleWithCount = (title: string, count: number) => { + if (!count) { + return title; + } + + return `${title} (${count})`; +}; + +const sortObjects = (objects: PersistenceObject[]) => + objects.slice().sort((a, b) => { + const typeCompare = a.type.localeCompare(b.type); + if (typeCompare !== 0) { + return typeCompare; + } + return a.name.localeCompare(b.name); + }); + +const groupObjectsByType = (objects: PersistenceObject[]) => + objects.reduce>((acc, object) => { + const currentGroup = acc[object.type] || []; + currentGroup.push(object); + acc[object.type] = currentGroup; + return acc; + }, {}); + +const sortAttributeGroups = ( + groups: T[], +) => + groups.slice().sort((a, b) => { + const attributeCompare = getAttributeLabel(a.attribute).localeCompare( + getAttributeLabel(b.attribute), + ); + if (attributeCompare !== 0) { + return attributeCompare; + } + return 0; + }); + +const sortRecords = (records: PersistenceRecordEntry[]) => + records.slice().sort((a, b) => { + const createdCompare = b.created_at.localeCompare(a.created_at); + if (createdCompare !== 0) { + return createdCompare; + } + return a.game_id.localeCompare(b.game_id); + }); + +const filterObjectGroups = ( + objects: PersistenceObject[], + searchValue: string, +): Record => { + const normalizedSearch = searchValue.trim().toLowerCase(); + const groupedObjects = groupObjectsByType(sortObjects(objects)); + const filteredObjectGroups: Record = {}; + + for (const type of Object.keys(groupedObjects).sort((a, b) => a.localeCompare(b))) { + const matches = groupedObjects[type].filter((object) => { + const haystack = [ + object.name, + object.type, + getObjectPosition(object), + object.created_at, + object.expires_at, + ] + .join(' ') + .toLowerCase(); + + return !normalizedSearch || haystack.includes(normalizedSearch); + }); + + if (matches.length > 0) { + filteredObjectGroups[type] = matches; + } + } + + return filteredObjectGroups; +}; + +const filterRecordGroups = ( + groups: PersistenceRecordTypeGroup[], + searchValue: string, +): PersistenceRecordTypeGroup[] => { + const normalizedSearch = searchValue.trim().toLowerCase(); + const filteredRecordGroups: PersistenceRecordTypeGroup[] = []; + + for (const group of groups) { + const filteredAttributes: PersistenceRecordAttributeGroup[] = []; + + for (const attributeGroup of sortAttributeGroups(group.attributes || [])) { + const matches = sortRecords(attributeGroup.records || []); + const attributeSearch = !normalizedSearch + ? matches + : matches.filter((record) => { + const haystack = [ + group.type, + getAttributeLabel(attributeGroup.attribute), + record.created_at, + record.game_id, + record.value, + ] + .join(' ') + .toLowerCase(); + + return haystack.includes(normalizedSearch); + }); + + if (attributeSearch.length > 0) { + filteredAttributes.push({ + attribute: attributeGroup.attribute, + records: attributeSearch, + }); + } + } + + if (filteredAttributes.length > 0) { + filteredRecordGroups.push({ + type: group.type, + title: group.title, + description: group.description, + requires_attribute: group.requires_attribute, + attributes: filteredAttributes, + }); + } + } + + return filteredRecordGroups; +}; + +const filterGenericGroups = ( + groups: PersistenceGenericTypeGroup[], + searchValue: string, +): PersistenceGenericTypeGroup[] => { + const normalizedSearch = searchValue.trim().toLowerCase(); + const filteredGenericGroups: PersistenceGenericTypeGroup[] = []; + + for (const group of groups) { + const attributeGroups = Array.isArray(group.attributes) ? group.attributes : []; + const filteredAttributes: PersistenceGenericAttributeGroup[] = []; + + for (const attributeGroup of attributeGroups) { + const haystack = [ + group.type, + group.title, + group.description || '', + getAttributeLabel(attributeGroup.attribute), + attributeGroup.created_at || '', + attributeGroup.expires_at || '', + attributeGroup.json || '', + ] + .join(' ') + .toLowerCase(); + + if (!normalizedSearch || haystack.includes(normalizedSearch)) { + filteredAttributes.push(attributeGroup); + } + } + + const groupHaystack = [group.type, group.title, group.description || ''] + .join(' ') + .toLowerCase(); + const shouldIncludeGroup = + !normalizedSearch || + groupHaystack.includes(normalizedSearch) || + filteredAttributes.length > 0; + + if (shouldIncludeGroup) { + filteredGenericGroups.push({ + type: group.type, + title: group.title, + description: group.description, + requires_attribute: group.requires_attribute, + attributes: filteredAttributes, + }); + } + } + + return filteredGenericGroups; +}; + +export const PersistencePanel = (props) => { + const { act, data } = useBackend(); + const [tab, setTab] = useLocalState('tab', 'Subsystem'); + const [objectsSearch, setObjectsSearch] = useLocalState('objectsSearch', ''); + const [recordsSearch, setRecordsSearch] = useLocalState('recordsSearch', ''); + const [genericsSearch, setGenericsSearch] = useLocalState('genericsSearch', ''); + + const statusText = data.status_initialized ? 'Initialized' : 'Error'; + const statusColor = data.status_initialized ? 'good' : 'bad'; + const savingText = data.saving_active ? 'Active' : 'Disabled'; + const savingColor = data.saving_active ? 'good' : 'bad'; + const savingButtonText = data.saving_active ? 'Disable saving' : 'Re-enable saving'; + const savingButtonColor = data.saving_active ? 'good' : 'bad'; + + const objects = sortObjects(Array.isArray(data.objects) ? data.objects : []); + const filteredObjectGroups = filterObjectGroups(objects, objectsSearch); + const filteredObjectTypes = Object.keys(filteredObjectGroups).sort((a, b) => + a.localeCompare(b), + ); + + const recordGroups = (Array.isArray(data.records) ? data.records : []) + .slice() + .sort((a, b) => a.type.localeCompare(b.type)) + .map((group) => ({ + ...group, + attributes: sortAttributeGroups((group.attributes || []).slice()), + })); + + const genericGroups = (Array.isArray(data.generics) ? data.generics : []) + .slice() + .sort((a, b) => a.type.localeCompare(b.type)) + .map((group) => ({ + ...group, + attributes: sortAttributeGroups((group.attributes || []).slice()), + })); + + const filteredRecordGroups = filterRecordGroups(recordGroups, recordsSearch); + const filteredGenericGroups = filterGenericGroups(genericGroups, genericsSearch); + + return ( + + +
+ + setTab('Subsystem')}> + Subsystem + + setTab('Objects')}> + Objects + + setTab('Records')}> + Records + + setTab('Generics')}> + Generics + + + {tab !== 'Subsystem' && ( +
+ + {tab === 'Subsystem' && ( +
act('toggle_saving')} + /> + } + > + + + + {statusText} + + + + + {savingText} + + + + {data.objects_tracked} + + + {data.generics_cached} + + + {data.records_cached} + + +
+ )} + + {tab === 'Objects' && ( +
setObjectsSearch(value)} + style={{ width: '18rem' }} + /> + } + > + + This panel displays database content and requires a manual refresh. Expired objects are excluded. + + {data.objects.length === 0 && ( + Nothing to display. + )} + {data.objects.length > 0 && filteredObjectTypes.length === 0 && ( + No tracked objects match the current filter. + )} + {filteredObjectTypes.map((type) => ( + + + + Name + Position + Jump + Create date + Expire date + Edit + + {filteredObjectGroups[type].map((object) => ( + + {object.name} + {getObjectPosition(object)} + +
+
+ ))} +
+ )} + + {tab === 'Records' && ( +
setRecordsSearch(value)} + style={{ width: '18rem' }} + /> + } + > + + This panel displays database content (up to 1000 rows) and requires a manual refresh. + + {data.records.length === 0 && ( + Nothing to display. + )} + {data.records.length > 0 && filteredRecordGroups.length === 0 && ( + No history records match the current filter. + )} + {filteredRecordGroups.map((group) => ( + count + (attributeGroup.records?.length || 0), 0), + )} + > + {group.description && ( + + {group.description} + + )} + {group.requires_attribute ? ( + group.attributes.map((attributeGroup) => ( + + + + + Created at + Game ID + Value + + {(attributeGroup.records || []).map((record, index) => ( + + {record.created_at} + {record.game_id} + {record.value} + + ))} +
+
+
+ )) + ) : ( + + + Created at + Game ID + Value + + {(group.attributes[0]?.records || []).map((record, index) => ( + + {record.created_at} + {record.game_id} + {record.value} + + ))} +
+ )} +
+ ))} +
+ )} + + {tab === 'Generics' && ( +
setGenericsSearch(value)} + style={{ width: '18rem' }} + /> + } + > + + This panel displays database content and requires a manual refresh. + + {data.generics.length === 0 && ( + Nothing to display. + )} + {data.generics.length > 0 && filteredGenericGroups.length === 0 && ( + No persistent generics match the current filter. + )} + {filteredGenericGroups.map((group) => ( + + {group.description && ( + + {group.description} + + )} + {group.requires_attribute ? ( + group.attributes.map((attributeGroup) => ( + + +
+ + + + {attributeGroup.created_at || 'This round'} + + + {attributeGroup.expires_at || 'To be determined'} + + + +
+
+ + {attributeGroup.json} + +
+
+
+ )) + ) : ( + +
+ + + + {group.attributes[0]?.created_at || 'This round'} + + + {group.attributes[0]?.expires_at || 'To be determined'} + + + +
+
+ + {group.attributes[0]?.json} + +
+
+ )} +
+ ))} +
+ )} +
+
+ ); +};