Persistence Panel (#22979)

# Summary

This PR adds a new admin panel for persistence.

## Contents

- The panel features information about the current round and detailed
tracking of objects, records and generics.
- The previously introduced toggle-persistence verb has been removed and
integrated into the new panel.
- The panel can be seen by admins, mods and devs, but the
toggle-persistence is still locked to admin permissions only.

## Preview

The images are WIP and subject to change.

<img width="918" height="229" alt="image"
src="https://github.com/user-attachments/assets/16d3d73d-32e6-4907-bdff-d96699c27d49"
/>

<img width="919" height="253" alt="image"
src="https://github.com/user-attachments/assets/92e7f4d9-ccd4-4e79-949d-b8a35039da01"
/>

<img width="923" height="285" alt="image"
src="https://github.com/user-attachments/assets/e3796504-4cc6-4fce-b5f1-4df3486bfbee"
/>

<img width="923" height="270" alt="image"
src="https://github.com/user-attachments/assets/9fbb185d-181a-40ec-b89b-58eafcd47e4d"
/>
This commit is contained in:
FabianK3
2026-08-08 20:47:55 +00:00
committed by GitHub
parent f6d688f9b6
commit 65a9f06765
17 changed files with 986 additions and 55 deletions
@@ -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.
@@ -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"])
@@ -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)
@@ -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()
@@ -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
@@ -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
@@ -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
@@ -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