diff --git a/code/__DEFINES/opensearch.dm b/code/__DEFINES/opensearch.dm new file mode 100644 index 000000000000..b42b4a2a76f8 --- /dev/null +++ b/code/__DEFINES/opensearch.dm @@ -0,0 +1,12 @@ +// States of query + +/// Query is ready to be built +#define OPENSEARCH_QUERY_STATUS_READY 1 +/// Query was unable to be built +#define OPENSEARCH_QUERY_STATUS_BUILD_ERROR 2 +/// Query is executing +#define OPENSEARCH_QUERY_STATUS_EXECUTING 3 +/// Query failed to execute +#define OPENSEARCH_QUERY_STATUS_FAILED 4 +/// Query executed successfully +#define OPENSEARCH_QUERY_STATUS_SUCCESS 5 diff --git a/code/__DEFINES/subsystems.dm b/code/__DEFINES/subsystems.dm index 447d212cc6d8..7ed879f98050 100644 --- a/code/__DEFINES/subsystems.dm +++ b/code/__DEFINES/subsystems.dm @@ -193,6 +193,7 @@ #define SS_PRIORITY_FZ_TRANSITIONS 88 #define SS_PRIORITY_ROUND_RECORDING 83 #define SS_PRIORITY_SHUTTLE 80 +#define SS_PRIORITY_OPENSEARCH 75 #define SS_PRIORITY_EVENT 65 #define SS_PRIORITY_DISEASE 60 #define SS_PRIORITY_DEFENSES 55 diff --git a/code/controllers/subsystem/opensearch.dm b/code/controllers/subsystem/opensearch.dm new file mode 100644 index 000000000000..2620996b74c9 --- /dev/null +++ b/code/controllers/subsystem/opensearch.dm @@ -0,0 +1,116 @@ +/// Handles querying OpenSearch for logs information +SUBSYSTEM_DEF(opensearch) + name = "OpenSearch" + priority = SS_PRIORITY_OPENSEARCH + runlevels = RUNLEVELS_DEFAULT|RUNLEVEL_LOBBY + wait = 0.2 SECONDS + + /// Incrementing id counter to assign to queries + var/query_counter = 1 + /// Incrementing counter of completed requests + var/total_requests = 0 + /// AList of ID -> /datum/opensearch_query + var/alist/queries = alist() + /// List of /datum/opensearch_query currently performing a request, mapped to their http_request object + var/list/datum/opensearch_query/active = list() + /// Snapshot of the above for scheduling + var/list/datum/opensearch_query/currentrun + +/datum/controller/subsystem/opensearch/Initialize() + if(!CONFIG_GET(string/opensearch_host)) + can_fire = FALSE + return SS_INIT_NO_NEED + return SS_INIT_SUCCESS + +/datum/controller/subsystem/opensearch/stat_entry(msg) + if(!can_fire) + msg = "Disabled" + else + msg = "Completed: [total_requests], Queries: [length(queries)], Active: [length(active)]" + return ..() + +/datum/controller/subsystem/opensearch/fire(resumed) + if(!resumed) + currentrun = active.Copy() + while(length(currentrun)) + var/datum/opensearch_query/query = currentrun[currentrun.len] + var/datum/http_request/http_request = currentrun[query] + currentrun.len-- + handle_request(query, http_request) + if(MC_TICK_CHECK) + return + +/// Create a new query and register it +/// [bootstrap] : Important text to seed the query with +/datum/controller/subsystem/opensearch/proc/new_query(bootstrap, boostfield, boostfactor) + RETURN_TYPE(/datum/opensearch_query) + var/id = query_counter++ + var/datum/opensearch_query/query = new(id, bootstrap, boostfield, boostfactor) + queries[id] = query + return query + +/// Remove a query from controller tracking +/datum/controller/subsystem/opensearch/proc/forget(datum/opensearch_query/query) + if(length(currentrun)) + currentrun -= query + active -= query + queries -= query.id + +/// Handle a request completion during fire +/datum/controller/subsystem/opensearch/proc/handle_request(datum/opensearch_query/query, datum/http_request/http_request) + PRIVATE_PROC(TRUE) + var/completed = http_request.is_complete() + if(completed) + total_requests++ + active -= query + var/datum/http_response/response = http_request.into_response() + if(!response || response.errored) + query.on_error(response) + else if(response.status_code == 200) + query.on_success(response) + else + // This should have different handling to catch OpenSearch errors, but rustg is dumb + // and won't actually let us grab the response body on an opensearch error. + // It'll just trip over itself in parsing and report "host returned status 400" + query.on_error(response) + +/// Queues a new HTTP request to the OpenSearch backend: +/// * [query] : The query this request is attributed to. Has no real effect, is just to know who to fire callbacks to. +/// * [endpoint] : The HTTP endpoint to make the request to. For example, /_search +/// * [payload] : The full HTTP payload with a properly built query for the backend +/datum/controller/subsystem/opensearch/proc/queue(datum/opensearch_query/query, endpoint, list/payload) + if(!can_fire || query.query_status != OPENSEARCH_QUERY_STATUS_READY || (query in active)) + return FALSE + var/bare_payload = payload + if(islist(payload)) + bare_payload = json_encode(payload) + var/datum/http_request/request = new + request.prepare(RUSTG_HTTP_METHOD_GET, "[CONFIG_GET(string/opensearch_host)]/[CONFIG_GET(string/opensearch_pattern)]/[endpoint]", bare_payload, list("Content-Type" = "application/json", "Authorization" = "Basic [CONFIG_GET(string/opensearch_authtoken)]")) + request.begin_async() + active[query] = request + query.query_status = OPENSEARCH_QUERY_STATUS_EXECUTING + . = TRUE + + +/// The host to make OpenSearch queries to, for example "https://opensearch.cm-ss13.com" +/datum/config_entry/string/opensearch_host + protection = CONFIG_ENTRY_LOCKED + +/// The Basic HTTP auth token to use for authentication to OpenSearch, in the form of user:password base64ed +/datum/config_entry/string/opensearch_authtoken + protection = CONFIG_ENTRY_HIDDEN | CONFIG_ENTRY_LOCKED | CONFIG_ENTRY_SENSITIVE + +/// Index patterns to be accessed by OpenSearch query tool +/datum/config_entry/string/opensearch_pattern + protection = CONFIG_ENTRY_LOCKED + +/// Max results to fetch in an OpenSearch query. Without pagination, OpenSearch limits it to 10000 results on top of that. +/datum/config_entry/number/opensearch_max_results + config_entry_value = 500 + +/// URL to the OpenSearch-Dashboards instance for the "link to dashboards" button +/datum/config_entry/string/opensearch_dashboards_url + protection = CONFIG_ENTRY_LOCKED + +/// OpenSearch-Dashboards Discovery Saved Object that will be used as basis for the link-to-dashboards feature +/datum/config_entry/string/opensearch_dashboards_saved_discover diff --git a/code/datums/opensearch/opensearch_query.dm b/code/datums/opensearch/opensearch_query.dm new file mode 100644 index 000000000000..33d11862322e --- /dev/null +++ b/code/datums/opensearch/opensearch_query.dm @@ -0,0 +1,343 @@ +/// Models a single instance of an OpenSearch query to be used +/// The query can be changed over time, and this datum also serves as the +/// handler of the tgui associated with viewing the search + +/datum/opensearch_query + /// Numerical ID used to reference the search internally and in links + var/id = NONE + /// User given name to the query + var/name = "" + + // Query state + /// Current status of the query + var/query_status = NONE + /// Last error message if applicable + var/error_text + /// Raw results of the last query, as stringified JSON, to be passed to TGUI for parsing clientside + var/query_results + + // Query parameters + /// How long ago to stop search, this lets us look further back in time unbothered or do simple pagination + /// If this is 3600 for example, we'll search from forever ago to 1 hour ago. + /// The beginning range is configured separately in config entries. + var/time_ago = 0 + /// Round ID to look at + var/roundid + /// The actual Lucene query text that the user is requesting. It'll be mashed with everything else. + var/user_query = "" + /// Ranging part of the filter, allowing an user to set to only display events near a position + var/range_query + /// Filter for the type of logs to view. If empty, show everything. + var/list/log_types = list() + /// If true, we sort by query score instead of by time + var/ranking_mode = TRUE + +/datum/opensearch_query/New(id, bootstrap, boostfield, boostfactor = 3) + . = ..() + src.id = id // To be attributed by SSopensearch, don't instantiate this directly + name = "Query ~[id]" + + if(bootstrap) + // First we allow searching all fields by the user's query + user_query = "\"[bootstrap]\"" + // Then we add a second boosted search term for the query if applicable + // This is because we routinely want to do ckey searches, and a direct match + // to the ckey field is more important than in the other fields. + // Note that we could also do this via injecting boosted parameters in the DSL, + // but then that needs a whole new UI for user interaction + if(boostfield && boostfactor) + user_query = "([boostfield]:\"[bootstrap]\"^[boostfactor]) [user_query]" + + roundid = GLOB.round_id + query_status = OPENSEARCH_QUERY_STATUS_READY + +/datum/opensearch_query/Destroy(force) + SStgui.close_uis(src) + SSopensearch.forget(src) + return ..() + +/// Launches the query, returning an error text if something went wrong +/datum/opensearch_query/proc/execute(mob/logging_user) + PRIVATE_PROC(TRUE) + error_text = "" + if(query_status == OPENSEARCH_QUERY_STATUS_EXECUTING) + error_text = "Already executing" + return + var/list/dsl = build_dsl() // Build the final query to be made to OpenSearch + if(dsl) + if(logging_user) + log_debug("OPENSEARCH: [key_name(logging_user)] executing a query: [json_encode(dsl)]") + if(!SSopensearch.queue(src, "_search", dsl)) + error_text = "Could not start query" + +/// Builds the query DSL based on the query builder parameters +/// This returns either null on success or an error message +/datum/opensearch_query/proc/build_dsl() + PRIVATE_PROC(TRUE) + RETURN_TYPE(/list) + var/list/dsl = bootstrap_lucene_dsl() + if(!dsl || !inject_parameters_in_dsl(dsl)) + query_status = OPENSEARCH_QUERY_STATUS_BUILD_ERROR + return + query_status = OPENSEARCH_QUERY_STATUS_READY + return dsl + +/// Creates the base DSL for executing a Lucene based search +/datum/opensearch_query/proc/bootstrap_lucene_dsl() + PRIVATE_PROC(TRUE) + var/list/dsl = list("query" = list("bool" = list("must" = list()))) + var/list/must = dsl["query"]["bool"]["must"] + if(length(user_query)) + must[++must.len] = list("query_string" = list("query" = create_lucene_query())) + else + must[++must.len] = list("match_all" = alist()) // This must be an alist so we get a JSON {} and not a [] + return dsl + +/// + +/// Adds additional query builder parameters into the DSL +/datum/opensearch_query/proc/inject_parameters_in_dsl(list/dsl) + PRIVATE_PROC(TRUE) + dsl["size"] = CONFIG_GET(number/opensearch_max_results) + + var/list/bool = dsl["query"]["bool"] + if(!bool["filter"]) + bool["filter"] = list() + if(!bool["must"]) + bool["must"] = list() + + var/list/filter = bool["filter"] + var/list/must = bool["must"] + + // If requested, inject log_type filtering in the must clause + if(length(log_types)) + var/list/lower_logtypes = list() + for(var/logtype in log_types) + lower_logtypes += lowertext(logtype) // OpenSearch wants them in lower case, presumably because it's stored as a keyword + must[++must.len] = list("terms" = list("logtype" = lower_logtypes)) + + // Inject Time range in the filter clause. Arbitrarily limit to 30 days, this isn't meant for long term use anyway. Use dashboards. + var/timefilter = list("range" = list("@timestamp" = list("gte" = "now-30d", "lte" = render_time_param(time_ago)))) + filter[++filter.len] = timefilter + + // Inject Round ID filtering in the filter clause + var/roundidfilter = list("term" = list("roundid" = "[roundid]")) + filter[++filter.len] = roundidfilter + + // Set sorting mode by most recent by default, unless we want to have ranking sorting + if(!ranking_mode) + dsl["sort"] = list(list("@timestamp" = list("order" = "desc"))) + + // Output the ranking information regardless of time sorting + dsl["track_scores"] = "true" // BYOND json won't let us get a real true, but OpenSearch is nice enough to accept it as string + + return dsl + +/// Creates the full Lucene query for use by us +/// All the extras handled here could be injected into the DSL instead, +/// but this requires more intricate handling of internal state and UI work. +/datum/opensearch_query/proc/create_lucene_query() + if(range_query) + return "([range_query]) AND ([user_query])" + return user_query + +/// Creates an expanded Lucene query with additional filters that are normally injected into the DSL +/// This is because passing filters and such in a Dashboards URL is very unwieldy. +/// So instead, we just do best-effort by shoving equivalent filters into the Lucene query. +/// Note that this doesn't include the time filter, which we can set easily in URL. +/datum/opensearch_query/proc/create_dashboards_lucene_query() + var/query = "roundid:[roundid]" + if(length(log_types)) + query = " AND logtype:([log_types.Join(" OR ")])" + if(range_query) + query += " AND ([range_query])" + query += " AND ([user_query])" + return query + +/// Creates the Link to Dashboards for our query +/datum/opensearch_query/proc/create_dashboards_link() + if(!CONFIG_GET(string/opensearch_dashboards_url) || !CONFIG_GET(string/opensearch_dashboards_saved_discover)) + return + var/lucene_query = rustg_url_encode(create_dashboards_lucene_query()) + lucene_query = replacetext(lucene_query, "'", "!'") // Escaping + var/_q = "(filters:!(),query:(language:lucene,query:'[lucene_query]'))" + // By default we create the discover view with 30d max and refresh every 30 seconds + var/_g = "(filters:!(),refreshInterval:(pause:!f,value:30000),time:(from:now-30d,to:[render_time_param(time_ago)]))" + var/url = "[CONFIG_GET(string/opensearch_dashboards_url)]" + url += "/app/data-explorer/discover/#/view/" + url += "[CONFIG_GET(string/opensearch_dashboards_saved_discover)]" + return "[url]?_q=[_q]&_g=[_g]" + + +/// Gets the correct time string for a given time +/datum/opensearch_query/proc/render_time_param(time_param) + SHOULD_BE_PURE(TRUE) + return "now-[time_param]s" // Expand this if you want absolute time ranges + +/// Callback for an errored HTTP request (probably at network level) +/datum/opensearch_query/proc/on_error(datum/http_response/response_object) + query_status = OPENSEARCH_QUERY_STATUS_FAILED + error_text = response_object.error + SStgui.update_uis(src) + +/// Callback for a successful request +/datum/opensearch_query/proc/on_success(datum/http_response/response_object) + query_status = OPENSEARCH_QUERY_STATUS_SUCCESS + // We don't actually do anything here. For server performance reasons, we just pass the + // entire response object as string to TGUI, without any JSON encoding. + query_results = response_object.body + error_text = "" + SStgui.update_uis(src) + + +// ==== +// UI Work goes below +// ==== + +/datum/opensearch_query/tgui_interact(mob/user, datum/tgui/ui) + ui = SStgui.try_update_ui(user, src, ui) + if (!ui) + ui = new(user, src, "OpenSearchQuery", "Query ~[id]") + ui.open() + +/datum/opensearch_query/ui_data(mob/user) + . = list() + .["queryId"] = id + .["queryName"] = name + .["queryStatus"] = query_status + .["queryResults"] = query_results + .["queryTimeAgo"] = time_ago + .["rankingMode"] = ranking_mode + .["userQuery"] = user_query + .["logTypes"] = log_types + .["errorText"] = error_text + .["roundid"] = roundid + .["rangedQuery"] = !!range_query + +/datum/opensearch_query/ui_state(mob/user) + return GLOB.admin_state + +/datum/opensearch_query/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return FALSE + switch(action) + if("open_queries") + ui.user?.client?.opensearch_query_builder() + + if("delete") + log_admin("[key_name(usr)] deleted OpenSearch Query ~[id]") + qdel(src) + + if("update_query") + if(params["query_name"]) + name = params["query_name"] + if(!isnull(params["ranking_mode"])) + ranking_mode = params["ranking_mode"] + if(!isnull(params["roundid"])) + roundid = params["roundid"] + if(params["user_query"]) + user_query = params["user_query"] + if(params["log_types"]) + log_types = params["log_types"] + if(!isnull(params["time_ago"])) + time_ago = params["time_ago"] + if(params["execute"]) + execute(ui.user) + SStgui.update_uis(src) + + if("jmp") + var/x = text2num(params["x"]) + var/y = text2num(params["y"]) + var/z = text2num(params["z"]) + var/client/client = ui.user?.client + if(x && y && z && client) + client.jumptocoord(x, y, z) + + if("playerpanel") + var/client/target_client = GLOB.directory[params["ckey"]] + var/mob/target_mob = target_client?.mob + var/datum/admins/admin_user = GLOB.admin_datums[ui.user?.client?.ckey] + if(target_mob && admin_user) + admin_user.show_player_panel(target_mob) + + if("toggle_range") // Add a ranging component to the query + if(range_query) + // Remove it. + range_query = null + SStgui.update_uis(src) + return + + var/turf/turf = get_turf(ui.user) + if(!turf.z) + return + var/range = tgui_input_number(ui.user, "How far from here? Zero to cancel.", "Range Picker", 8, 1000, 0) + if(!range) + return + + // Scan min/max Z coordinates + var/turf/scan_turf + + // Up + var/max_z = turf.z + scan_turf = turf + while(scan_turf) + scan_turf = SSmapping.get_turf_above(scan_turf) + if(scan_turf) + max_z = scan_turf.z + max_z = min(turf.z + range, max_z) + + // Down + var/min_z = turf.z + scan_turf = turf + while(scan_turf) + scan_turf = SSmapping.get_turf_below(scan_turf) + if(scan_turf) + min_z = scan_turf.z + min_z = max(turf.z - range, min_z) + + // X / Y is easier, fill out the query now + range_query = "loc_x: \[[turf.x - range] TO [turf.x + range]\]" + range_query += " AND loc_y: \[[turf.y - range] TO [turf.y + range]\]" + range_query += " AND loc_z: \[[min_z] TO [max_z]\]" + + SStgui.update_uis(src) + + + if("open_dashboards") + var/link = create_dashboards_link() + if(link) + ui.user << link(link) + + +/// Opens the full on builder letting us select a query to edit +/client/proc/opensearch_query_builder() + set name = "Open OpenSearch Query Builder" + set category = "Admin" + + var/list/options = list("New") + + for(var/id in SSopensearch.queries) + var/datum/opensearch_query/query = SSopensearch.queries[id] + options[query] = "[query.id] : [query.name]" + + var/reply = tgui_input_list(usr, "Select the query to edit", "OpenSearch Query Builder", options) + + if(!usr) + return + + var/datum/opensearch_query/query = reply + if(reply == "New") + query = SSopensearch.new_query() + if(!istype(query) || QDELETED(query)) + return + query.tgui_interact(usr) + +/// Creates a new query quickly with the passed text +/client/proc/opensearch_quick_query(bootstrap as text) + set name = ".opensearch" + set desc = "Create an OpenSearch query quickly" + set category = null + + var/datum/opensearch_query/query = SSopensearch.new_query(bootstrap) + query.tgui_interact(usr) diff --git a/code/modules/admin/admin_verbs.dm b/code/modules/admin/admin_verbs.dm index 86c8c3ceacee..0c433a8d0fa7 100644 --- a/code/modules/admin/admin_verbs.dm +++ b/code/modules/admin/admin_verbs.dm @@ -111,6 +111,8 @@ GLOBAL_LIST_INIT(admin_verbs_logs, list( /datum/admins/proc/view_runtime_log, /*shows the server runtime log for this round*/ /datum/admins/proc/view_href_log, /*shows the server HREF log for this round*/ /datum/admins/proc/view_tgui_log, /*shows the server TGUI log for this round*/ + /client/proc/opensearch_query_builder, + /client/proc/opensearch_quick_query, )) GLOBAL_LIST_INIT(admin_verbs_sounds, list( diff --git a/code/modules/admin/player_panel/actions/general.dm b/code/modules/admin/player_panel/actions/general.dm index d37def5f0337..a2aad7c4c58f 100644 --- a/code/modules/admin/player_panel/actions/general.dm +++ b/code/modules/admin/player_panel/actions/general.dm @@ -137,6 +137,18 @@ user.cmd_admin_pm(target.client) return TRUE +/datum/player_action/opensearch_query + action_tag = "opensearch_query" + name = "OpenSearch" + +/datum/player_action/opensearch_query/act(client/user, mob/target, list/params) + if(!target || !user.mob) + return + + var/datum/opensearch_query/query = SSopensearch.new_query(target.persistent_ckey, "ckey", 3) + query.tgui_interact(user.mob) + return TRUE + /datum/player_action/alert_message action_tag = "alert_message" name = "Alert Message" diff --git a/code/modules/admin/tabs/admin_tab.dm b/code/modules/admin/tabs/admin_tab.dm index d82dbdd66830..2eb9d1d8c68a 100644 --- a/code/modules/admin/tabs/admin_tab.dm +++ b/code/modules/admin/tabs/admin_tab.dm @@ -273,7 +273,7 @@ SET_PROTECTED_PROC(/client/proc/cmd_admin_say) REDIS_PUBLISH("byond.asay", "author" = src.username(), "message" = strip_html(msg), "admin" = CLIENT_HAS_RIGHTS(src, R_ADMIN), "rank" = admin_holder.rank) - if(findtext(msg, "@") || findtext(msg, "#")) + if(findtext(msg, "@") || findtext(msg, "#") || findtext(msg, "~")) var/list/link_results = check_asay_links(msg) if(length(link_results)) msg = link_results[ASAY_LINK_NEW_MESSAGE_INDEX] diff --git a/code/modules/admin/topic/topic.dm b/code/modules/admin/topic/topic.dm index f7ef21b8ae79..d634d83dec8b 100644 --- a/code/modules/admin/topic/topic.dm +++ b/code/modules/admin/topic/topic.dm @@ -2072,6 +2072,10 @@ if((R_ADMIN|R_MOD) & staff.admin_holder.rights) to_chat(staff, SPAN_STAFF_IC("ADMINS/MODS: [SPAN_RED("[src.owner] marked [key_name(speaker)]'s ARES message for response.")]")) + if(href_list["osquery"]) + var/datum/opensearch_query/query = SSopensearch.queries[text2num(href_list["osquery"])] + query?.tgui_interact(usr) + return /datum/admins/proc/accept_ert(mob/approver, mob/ref_person) diff --git a/code/modules/admin/verbs/adminhelp.dm b/code/modules/admin/verbs/adminhelp.dm index 1ad555c40590..52f22b514131 100644 --- a/code/modules/admin/verbs/adminhelp.dm +++ b/code/modules/admin/verbs/adminhelp.dm @@ -1034,7 +1034,7 @@ CLIENT_VERB(view_latest_ticket) var/datum/datum_check = locate(word_with_brackets) if(!istype(datum_check)) continue - msglist[i] = "[word]" + msglist[i] = "[word]" modified = TRUE if("#") // check if we're linking a ticket @@ -1055,9 +1055,21 @@ CLIENT_VERB(view_latest_ticket) if(AHELP_RESOLVED) state_word = "Resolved" - msglist[i]= "[word] ([state_word] | [ahelp_check.initiator_key_name])" + msglist[i]= "[word] ([state_word] | [ahelp_check.initiator_key_name])" modified = TRUE + if("~") // Check if we're linking an OpenSearch Query + var/possible_query_id = text2num(copytext(word, 2)) + if(!possible_query_id) + continue + var/datum/opensearch_query/query = SSopensearch.queries[possible_query_id] + if(!query) + continue + + msglist[i] = "[word] ([query.name])" + modified = TRUE + + if(modified) var/list/return_list = list() return_list[ASAY_LINK_NEW_MESSAGE_INDEX] = jointext(msglist, " ") // without tuples, we must make do! diff --git a/colonialmarines.dme b/colonialmarines.dme index 691301171f88..4c85396a746f 100644 --- a/colonialmarines.dme +++ b/colonialmarines.dme @@ -93,6 +93,7 @@ #include "code\__DEFINES\movement.dm" #include "code\__DEFINES\nightmare.dm" #include "code\__DEFINES\objects.dm" +#include "code\__DEFINES\opensearch.dm" #include "code\__DEFINES\origins.dm" #include "code\__DEFINES\pain.dm" #include "code\__DEFINES\paperwork.dm" @@ -305,6 +306,7 @@ #include "code\controllers\subsystem\nanoui.dm" #include "code\controllers\subsystem\nightmare.dm" #include "code\controllers\subsystem\objectives_controller.dm" +#include "code\controllers\subsystem\opensearch.dm" #include "code\controllers\subsystem\pager_status.dm" #include "code\controllers\subsystem\perf_logging.dm" #include "code\controllers\subsystem\ping.dm" @@ -660,6 +662,7 @@ #include "code\datums\looping_sounds\_looping_sound.dm" #include "code\datums\looping_sounds\item_sounds.dm" #include "code\datums\looping_sounds\misc_sounds.dm" +#include "code\datums\opensearch\opensearch_query.dm" #include "code\datums\origin\civilian.dm" #include "code\datums\origin\cmb.dm" #include "code\datums\origin\origin.dm" diff --git a/tgui/packages/tgui/interfaces/OpenSearchQuery.tsx b/tgui/packages/tgui/interfaces/OpenSearchQuery.tsx new file mode 100644 index 000000000000..d80e3a9a0783 --- /dev/null +++ b/tgui/packages/tgui/interfaces/OpenSearchQuery.tsx @@ -0,0 +1,518 @@ +import { useEffect, useState } from 'react'; +import { useBackend, useSharedState } from 'tgui/backend'; +import { + Box, + Button, + Input, + NoticeBox, + Section, + Stack, + Table, + TextArea, +} from 'tgui/components'; +import { Window } from 'tgui/layouts'; + +type Data = { + queryId: number; + queryName: string; + queryStatus: number; + queryResults: string; + rankingMode: boolean; + queryTimeAgo: number; + userQuery: string; + roundid: string; + logTypes: Array; + errorText: string; + rangedQuery: boolean; +}; + +const OPENSEARCH_QUERY_STATUS_READY = 1; +const OPENSEARCH_QUERY_STATUS_BUILD_ERROR = 2; +const OPENSEARCH_QUERY_STATUS_EXECUTING = 3; +const OPENSEARCH_QUERY_STATUS_FAILED = 4; +const OPENSEARCH_QUERY_STATUS_SUCCESS = 5; +const STATUS_TO_NAME = [ + 'INVALID', + 'READY', + 'BUILD ERROR', + 'RUNNING', + 'FAILED', + 'SUCCESS', +]; +const STATUS_TO_ICON = [ + 'circle-xmark', + 'check', + 'triangle-exclamation', + 'spinner', + 'circle-xmark', + 'check', +]; +const STATUS_TO_COLOR = ['bad', 'good', 'average', 'blue', 'bad', 'good']; + +const LOGTYPE_TO_COLOR = { + ATTACK: 'bad', + SAY: 'purple', + HIVEMIND: 'purple', + EMOTE: 'purple', + OOC: 'violet', + GAME: 'blue', + ADMIN: 'good', +}; + +// A vastly simplified version of TGUI's Collapsible, with no icon, less margins, etc +const MiniCollapsible = (props) => { + const { children, header, color, ...rest } = props; + const [open, setOpen] = useState(props.open); + + return ( + + + {open && {children}} + + ); +}; + +export const OpenSearchQuery = (props) => { + const { act, data } = useBackend(); + const { + queryId, + queryName, + queryStatus, + queryResults, + rankingMode, + queryTimeAgo, + roundid, + userQuery, + logTypes, + errorText, + rangedQuery, + } = data; + + const [localUserQuery, setUserQuery] = useState(userQuery); + const [localQueryName, setQueryName] = useState(queryName); + const [localRangedQuery, setRangedQuery] = useState(rangedQuery); + + // Force refresh the important bits if they're updated by backend + useEffect(() => { + setQueryName(queryName); + }, [queryName]); + + useEffect(() => { + setUserQuery(userQuery); + }, [userQuery]); + + useEffect(() => { + setRangedQuery(rangedQuery); + }, [rangedQuery]); + + // The smaller stuff is fine as shared states as they're manipulated only in UI + const [localQueryTimeAgo, setQueryTimeAgo] = useSharedState( + 'queryTimeAgo', + queryTimeAgo, + ); + const [localLogTypes, setLogTypes] = useSharedState('logTypes', logTypes); + const [localRankingMode, setRankingMode] = useSharedState( + 'rankingMode', + rankingMode, + ); + const [localRoundid, setRoundid] = useSharedState('roundid', roundid); + + const parsedResults = queryResults ? JSON.parse(queryResults) : {}; + const resultsTotal = parsedResults?.hits?.total?.value; + const resultsFetched = parsedResults?.hits?.hits?.length; + const timeElapsed = parsedResults?.took; + + const toggleLogType = (logType: string) => { + let newLogTypes = [...localLogTypes]; + if (newLogTypes.indexOf(logType) !== -1) { + newLogTypes = newLogTypes.filter((e) => e !== logType); + } else { + newLogTypes.push(logType); + } + setLogTypes(newLogTypes); + }; + + const logTypeButton = (logType: string) => { + return ( + + toggleLogType(logType)} + > + {logType} + + + ); + }; + + // For some reason, storing the state within a onEnter does NOT work, + // so we'll have to override the query when doing that + const submitQuery = (executeNow: boolean, queryOverride) => { + act('update_query', { + execute: executeNow, + query_name: localQueryName, + ranking_mode: localRankingMode, + time_ago: localQueryTimeAgo, + log_types: localLogTypes, + roundid: localRoundid, + user_query: queryOverride || localUserQuery, + }); + }; + + const localTimeOffsetToNumber = (string) => { + const suffix = string[string.length - 1]; + let numericPart = parseInt(string, 10); + switch (suffix) { + case 'm': + numericPart *= 60; + break; + case 'h': + numericPart *= 60 * 60; + break; + case 'd': + numericPart *= 24 * 60 * 60; + break; + case 'w': + numericPart *= 7 * 24 * 60 * 60; + break; + } + return numericPart; + }; + + const timeOffsetToDisplay = (offset) => { + let suffix = 's'; + let remaining = offset; + if (remaining && remaining % 60 === 0) { + remaining /= 60; + suffix = 'm'; + } + if (remaining && remaining % 60 === 0) { + remaining /= 60; + suffix = 'h'; + } + if (remaining && remaining % 24 === 0) { + remaining /= 24; + suffix = 'd'; + } + if (remaining && remaining % 7 === 0) { + remaining /= 7; + suffix = 'w'; + } + return `${remaining}${suffix}`; + }; + + const debloatDocument = (input) => { + let cloned = { ...input }; + delete cloned['host']; + delete cloned['@timestamp']; + delete cloned['@version']; + delete cloned['event']; + delete cloned['log']; + delete cloned['roundid']; + delete cloned['filetype']; + delete cloned['tags']; + // Those are already in the quick view above, no need to display again + delete cloned['logtype']; + delete cloned['ckey']; + delete cloned['character_name']; + delete cloned['playertext']; + return cloned; + }; + + const doubleDigitize = (input) => { + return input >= 10 ? `${input}` : `0${input}`; + }; + + const generateTimestampButton = (timestamp) => { + let dd: Date = new Date(timestamp); + return ( + + ); + }; + + const generateLogElements = (results) => { + let parsed = JSON.parse(results); + let documents = parsed?.hits?.hits; + return documents + ? documents.map((onedoc) => generateSingleLogElement(onedoc)) + : ''; + }; + + const generateSingleLogElement = (onedoc) => { + return ( + onedoc._source && ( + + + + {generateTimestampButton(onedoc._source.timestamp)} + + {onedoc._source.loc_x && + onedoc._source.loc_y && + onedoc._source.loc_z && ( + + )} + {onedoc._source.message_mode && ( + + )} + {onedoc._source.ckey && ( + + )} + {onedoc._source.character_name && ( + + )} + {onedoc._source.area && ( + + )} + + + {onedoc._source.playertext || onedoc._source.message} + +
+ + } + > + {JSON.stringify(debloatDocument(onedoc._source))} +
+ ) + ); + }; + + return ( + + + + + + + + + + + + + + + + + + + setQueryName(value)} + /> + + + setRoundid(value)} + placeholder="#round" + /> + + + + act('delete')} + color="bad" + confirmColor="bad" + tooltip="Delete the query" + > + Delete + + + + + + + + setRankingMode(!localRankingMode)} + tooltip="In Ranking mode, return the most relevant queries at top, irrespective of time." + color={localRankingMode ? 'good' : 'blue'} + > + {localRankingMode ? 'Sort: Rank' : 'Sort: Time'} + + + + act('toggle_range')} + tooltip="Click to toggle additional filtering based on the position you're standing at when clicking it. Only events that have location information can be filtered that way, so some might be omitted." + > + Filter Nearby + + + {logTypeButton('GAME')} + {logTypeButton('ADMIN')} + {logTypeButton('SAY')} + {logTypeButton('ATTACK')} + + + + + setQueryTimeAgo(localTimeOffsetToNumber(value)) + } + /> + + + + + + + +