From c24ffb7fb25484ff8af317a91759b45354885f29 Mon Sep 17 00:00:00 2001 From: Drathek <76988376+Drulikar@users.noreply.github.com> Date: Sat, 24 Feb 2024 02:01:15 -0800 Subject: [PATCH] Relay Ping Browser (#5777) # About the pull request This PR adds a way to ping the relays and also easily connect to them. You can access this panel by clicking the ping near the top right of chat. The ping reported here will likely be inflated from what you will actually experience or see reported by TGChat (its highly dependent on how fast your computer runs the JavaScript, and is based only on a single ping per connection), but should still give an accurate relative difference between the different connections. When the component is loaded, it starts a timer to begin the pings in 1 second. Whenever the component is unloaded, it indicates this to the ping javascript which will cause it to early return on any pending operations. The configs are added to examples in relays.txt. You can include it in config.txt. # Explain why it's good for the game This should give more visibility to the relays, provide an easy way to use them, and give more of an idea the differences in connection speeds to each relay. # Testing Photographs and Procedure Random values during testing: ![image](https://github.com/cmss13-devs/cmss13/assets/76988376/1a67bf8d-bc45-4fe8-888c-2360e6d54656) An actual test example (with last forced to time out): ![image](https://github.com/cmss13-devs/cmss13/assets/76988376/4bcf24b9-e942-4936-a2a5-d6be3b4756f1) Now with gauges: ![image](https://github.com/cmss13-devs/cmss13/assets/76988376/62d903fe-0311-48fc-90d5-9c07d388650a) Now with flex layout: ![layout rework](https://github.com/cmss13-devs/cmss13/assets/76988376/c9621ad4-9396-48b5-ae15-e415f53e3b06) # Changelog :cl: Drathek ui: Added the relay ping browser accessed by the tgchat ping to test and use alternative connections to the server ui: Added onConfirmChange prop to Button.Confirm component. config: Added CONNECTION_RELAY_PING and CONNECTION_RELAY_CON in the relays.txt config that is optionally included in config.txt /:cl: --- .../controllers/configuration/config_entry.dm | 7 +- .../configuration/entries/general.dm | 11 ++ code/modules/tgui_panel/ping_relay.dm | 52 ++++++ code/modules/tgui_panel/tgui_panel.dm | 3 + colonialmarines.dme | 1 + config/example/config.txt | 1 + config/example/relays.txt | 22 +++ tgui/docs/component-reference.md | 1 + tgui/packages/common/ping.js | 108 +++++++++++ .../packages/tgui-panel/ping/PingIndicator.js | 19 +- tgui/packages/tgui/components/Button.jsx | 4 + .../tgui/interfaces/PingRelaysPanel.jsx | 175 ++++++++++++++++++ 12 files changed, 398 insertions(+), 6 deletions(-) create mode 100644 code/modules/tgui_panel/ping_relay.dm create mode 100644 config/example/relays.txt create mode 100644 tgui/packages/common/ping.js create mode 100644 tgui/packages/tgui/interfaces/PingRelaysPanel.jsx diff --git a/code/controllers/configuration/config_entry.dm b/code/controllers/configuration/config_entry.dm index c47531f5fc45..d71bf1d747c9 100644 --- a/code/controllers/configuration/config_entry.dm +++ b/code/controllers/configuration/config_entry.dm @@ -4,6 +4,7 @@ #define KEY_MODE_TEXT 0 #define KEY_MODE_TYPE 1 +#define KEY_MODE_TEXT_UNALTERED 2 /datum/config_entry var/name //read-only, this is determined by the last portion of the derived entry type @@ -153,7 +154,9 @@ var/key_value = null if(key_pos || value_mode == VALUE_MODE_FLAG) - key_name = lowertext(copytext(str_val, 1, key_pos)) + key_name = copytext(str_val, 1, key_pos) + if(key_mode != KEY_MODE_TEXT_UNALTERED) + key_name = lowertext(key_name) if(key_pos) key_value = copytext(str_val, key_pos + length(str_val[key_pos])) var/new_key @@ -161,7 +164,7 @@ var/continue_check_value var/continue_check_key switch(key_mode) - if(KEY_MODE_TEXT) + if(KEY_MODE_TEXT, KEY_MODE_TEXT_UNALTERED) new_key = key_name continue_check_key = new_key if(KEY_MODE_TYPE) diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index 83929ecf8803..e2572e5e2d61 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -629,3 +629,14 @@ This maintains a list of ip addresses that are able to bypass topic filtering. /datum/config_entry/flag/guest_ban /datum/config_entry/flag/auto_profile + +/// Relay Ping Browser configuration +/datum/config_entry/keyed_list/connection_relay_ping + splitter = "|" + key_mode = KEY_MODE_TEXT_UNALTERED + value_mode = VALUE_MODE_TEXT + +/datum/config_entry/keyed_list/connection_relay_con + splitter = "|" + key_mode = KEY_MODE_TEXT_UNALTERED + value_mode = VALUE_MODE_TEXT diff --git a/code/modules/tgui_panel/ping_relay.dm b/code/modules/tgui_panel/ping_relay.dm new file mode 100644 index 000000000000..36929a2e33b0 --- /dev/null +++ b/code/modules/tgui_panel/ping_relay.dm @@ -0,0 +1,52 @@ +GLOBAL_DATUM_INIT(relays_panel, /datum/ping_relay_tgui, new) + +/datum/tgui_panel/proc/ping_relays() + GLOB.relays_panel.tgui_interact(client.mob) + +/datum/ping_relay_tgui/tgui_interact(mob/user, datum/tgui/ui) + var/list/relay_ping_conf = CONFIG_GET(keyed_list/connection_relay_ping) + if(!length(relay_ping_conf)) + to_chat(user, "There are no relays configured to test.") + return + + ui = SStgui.try_update_ui(user, src, ui) + if(!ui) + ui = new(user, src, "PingRelaysPanel", "Relay Pings") + ui.open() + ui.set_autoupdate(FALSE) + +/datum/ping_relay_tgui/ui_state(mob/user) + return GLOB.always_state + +/datum/ping_relay_tgui/ui_static_data(mob/user) + var/list/data = list() + var/list/relay_names = list() + var/list/relay_pings = list() + var/list/relay_cons = list() + + var/list/relay_ping_conf = CONFIG_GET(keyed_list/connection_relay_ping) + var/list/relay_con_conf = CONFIG_GET(keyed_list/connection_relay_con) + for(var/key in relay_ping_conf) + // assumption: keys are the same in both configs + relay_names += key + relay_pings += relay_ping_conf[key] + relay_cons += relay_con_conf[key] + + data["relay_names"] = relay_names + data["relay_pings"] = relay_pings + data["relay_cons"] = relay_cons + return data + +/datum/ping_relay_tgui/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) + . = ..() + if(.) + return + + var/mob/user = ui.user + + switch(action) + if("connect") + to_chat(user, "Now connecting via [params["desc"]]. Please wait..."); + user << link(params["url"]) + ui.close() + return diff --git a/code/modules/tgui_panel/tgui_panel.dm b/code/modules/tgui_panel/tgui_panel.dm index f33f190d80e0..25338a4a3b5d 100644 --- a/code/modules/tgui_panel/tgui_panel.dm +++ b/code/modules/tgui_panel/tgui_panel.dm @@ -92,6 +92,9 @@ if(type == "telemetry") analyze_telemetry(payload) return TRUE + if(type == "act/ping_relays") + ping_relays() + return TRUE /** * public diff --git a/colonialmarines.dme b/colonialmarines.dme index 9579aff5397d..12862cb2c544 100644 --- a/colonialmarines.dme +++ b/colonialmarines.dme @@ -2361,6 +2361,7 @@ #include "code\modules\tgui_input\text.dm" #include "code\modules\tgui_panel\audio.dm" #include "code\modules\tgui_panel\external.dm" +#include "code\modules\tgui_panel\ping_relay.dm" #include "code\modules\tgui_panel\telemetry.dm" #include "code\modules\tgui_panel\tgui_panel.dm" #include "code\modules\tooltip\tooltip.dm" diff --git a/config/example/config.txt b/config/example/config.txt index f9e0956593a9..f055a5d65bff 100644 --- a/config/example/config.txt +++ b/config/example/config.txt @@ -4,6 +4,7 @@ # $include dbconfig.txt # $include resources.txt # $include icon_source.txt +# $include relays.txt ## Server name: This appears at the top of the screen in-game. In this case it will read "tgstation: station_name" where station_name is the randomly generated name of the station for the round. Remove the # infront of SERVERNAME and replace 'tgstation' with the name of your choice # SERVERNAME spacestation13 diff --git a/config/example/relays.txt b/config/example/relays.txt new file mode 100644 index 000000000000..562e917fa484 --- /dev/null +++ b/config/example/relays.txt @@ -0,0 +1,22 @@ +## Settings controlling the relay ping browser accessed via PingIndicator in TGChat +## Pings are performed by timing how long it takes to download favicon.ico from the PingURL + +## Connection Relays: Name must match both ping and connect pairs +## CONNECTION_RELAY_PING Name|PingURL +## CONNECTION_RELAY_CON Name|ConnectURL +CONNECTION_RELAY_PING Direct|play.cm-ss13.com:8998 +CONNECTION_RELAY_CON Direct|play.cm-ss13.com:1400 +CONNECTION_RELAY_PING United Kingdom, London|uk.cm-ss13.com:8998 +CONNECTION_RELAY_CON United Kingdom, London|uk.cm-ss13.com:1400 +CONNECTION_RELAY_PING France, Gravelines|eu-w.cm-ss13.com:8998 +CONNECTION_RELAY_CON France, Gravelines|eu-w.cm-ss13.com:1400 +CONNECTION_RELAY_PING Poland, Warsaw|eu-e.cm-ss13.com:8998 +CONNECTION_RELAY_CON Poland, Warsaw|eu-e.cm-ss13.com:1400 +CONNECTION_RELAY_PING Oregon, Hillsboro|us-w.cm-ss13.com:8998 +CONNECTION_RELAY_CON Oregon, Hillsboro|us-w.cm-ss13.com:1400 +CONNECTION_RELAY_PING Virginia, Vint Hill|us-e.cm-ss13.com:8998 +CONNECTION_RELAY_CON Virginia, Vint Hill|us-e.cm-ss13.com:1400 +CONNECTION_RELAY_PING Singapore|asia-se.cm-ss13.com:8998 +CONNECTION_RELAY_CON Singapore|asia-se.cm-ss13.com:1400 +CONNECTION_RELAY_PING Australia, Sydney|aus.cm-ss13.com:8998 +CONNECTION_RELAY_CON Australia, Sydney|aus.cm-ss13.com:1400 diff --git a/tgui/docs/component-reference.md b/tgui/docs/component-reference.md index d814fc343a72..3789016447cc 100644 --- a/tgui/docs/component-reference.md +++ b/tgui/docs/component-reference.md @@ -250,6 +250,7 @@ A button with an extra confirmation step, using native button component. - See inherited props: [Button](#button) - `confirmContent: string` - Text to display after first click; defaults to "Confirm?" - `confirmColor: string` - Color to display after first click; defaults to "bad" +- `onConfirmChange: function` - Called when the clickedOnce state changes: When the element is clicked the first time or unfocused. ### `Button.Input` diff --git a/tgui/packages/common/ping.js b/tgui/packages/common/ping.js new file mode 100644 index 000000000000..e8f52882b6ae --- /dev/null +++ b/tgui/packages/common/ping.js @@ -0,0 +1,108 @@ +/** + * Adapted pinging library based on: + * @file https://www.jsdelivr.com/package/npm/ping.js + * @copyright 2021 Alfred Gutierrez + * @license MIT + */ + +/** + * Creates a Ping instance. + * @returns {Ping} + * @constructor + */ +export class Ping { + constructor(opt) { + this.opt = opt || {}; + this.favicon = this.opt.favicon || '/favicon.ico'; + this.timeout = this.opt.timeout || 10000; + this.logError = this.opt.logError || false; + this.abort = false; + } + + /** + * Pings source after a delay and triggers a callback when completed. + * @param source Source of the website or server, including protocol and port. + * @param callback Callback function to trigger when completed. Returns error and ping value. + * @param delay Optional number of milliseconds to wait before starting. + */ + ping(source, callback, delay = 1000) { + this.abort = false; + let timer; + if (delay > 0) { + timer = setTimeout(() => { + if (this.abort) { + return; + } + this.pingNow(source, callback); + }, delay); + return; + } + this.pingNow(source, callback); + } + + /** + * Pings source immediately and triggers a callback when completed. + * @param source Source of the website or server, including protocol and port. + * @param callback Callback function to trigger when completed. Returns error and ping value. + */ + pingNow(source, callback) { + let self = this; + self.abort = false; + self.wasSuccess = false; + self.img = new Image(); + self.img.onload = (e) => { + self.wasSuccess = true; + pingCheck.call(self, e); + }; + self.img.onerror = (e) => { + self.wasSuccess = false; + pingCheck.call(self, e); + }; + + let timer; + let start = new Date(); + + if (self.timeout) { + timer = setTimeout(() => { + self.wasSuccess = false; + pingCheck.call(self, undefined); + }, self.timeout); + } + + /** + * Times ping and triggers callback. + */ + const pingCheck = function (e) { + if (timer) { + clearTimeout(timer); + } + if (this.abort) { + return; + } + let pong = new Date() - start; + + if (typeof callback === 'function') { + // When operating in timeout mode, the timeout callback doesn't pass [event] as e. + // Notice [this] instead of [self], since .call() was used with context + if (!this.wasSuccess) { + if (self.logError) { + console.error('error loading resource: ' + e.error); + } + return callback(e ? 'Error' : 'Timed Out', pong); + } + return callback(null, pong); + } else { + throw new Error('Callback is not a function.'); + } + }; + + self.img.src = source + self.favicon + '?' + +new Date(); // Trigger image load with cache buster + } + + /** + * Aborts any pending ping request. + */ + cancel() { + this.abort = true; + } +} diff --git a/tgui/packages/tgui-panel/ping/PingIndicator.js b/tgui/packages/tgui-panel/ping/PingIndicator.js index b2355820e58a..4e64207d115c 100644 --- a/tgui/packages/tgui-panel/ping/PingIndicator.js +++ b/tgui/packages/tgui-panel/ping/PingIndicator.js @@ -6,11 +6,12 @@ import { Color } from 'common/color'; import { toFixed } from 'common/math'; -import { useSelector } from 'tgui/backend'; -import { Box } from 'tgui/components'; +import { useSelector, useBackend } from 'tgui/backend'; +import { Button, Box } from 'tgui/components'; import { selectPing } from './selectors'; export const PingIndicator = (props) => { + const { act } = useBackend(); const ping = useSelector(selectPing); const color = Color.lookup(ping.networkQuality, [ new Color(220, 40, 40), @@ -19,9 +20,19 @@ export const PingIndicator = (props) => { ]); const roundtrip = ping.roundtrip ? toFixed(ping.roundtrip) : '--'; return ( -
+
+ ); }; diff --git a/tgui/packages/tgui/components/Button.jsx b/tgui/packages/tgui/components/Button.jsx index 4264b0767115..dbffb6a72c95 100644 --- a/tgui/packages/tgui/components/Button.jsx +++ b/tgui/packages/tgui/components/Button.jsx @@ -172,6 +172,9 @@ export class ButtonConfirm extends Component { } else { window.removeEventListener('click', this.handleClick); } + if (this.props.onConfirmChange) { + this.props.onConfirmChange(clickedOnce); + } } render() { @@ -183,6 +186,7 @@ export class ButtonConfirm extends Component { color, content, onClick, + onConfirmChange, ...rest } = this.props; return ( diff --git a/tgui/packages/tgui/interfaces/PingRelaysPanel.jsx b/tgui/packages/tgui/interfaces/PingRelaysPanel.jsx new file mode 100644 index 000000000000..7b6c3016504e --- /dev/null +++ b/tgui/packages/tgui/interfaces/PingRelaysPanel.jsx @@ -0,0 +1,175 @@ +import { useBackend } from '../backend'; +import { round } from 'common/math'; +import { Box, Stack, Button, Icon, RoundGauge, Flex } from '../components'; +import { Window } from '../layouts'; +import { Color } from 'common/color'; +import { Ping } from 'common/ping'; +import { Component } from 'react'; + +const RED = new Color(220, 40, 40); + +export class PingResult { + constructor(desc = 'Loading...', url = '', ping = -1) { + this.desc = desc; + this.url = url; + this.ping = ping; + this.error = null; + } + + update = function (desc, url, ping, error) { + this.desc = desc; + this.url = url; + this.ping = ping; + this.error = error; + }; +} + +class PingApp extends Component { + constructor() { + super(); + + this.pinger = new Ping(); + this.results = new Array(); + this.state = { + currentIndex: 0, + lastClickedIndex: 0, + lastClickedState: false, + }; + } + + startTest(desc, pingURL, connectURL) { + this.pinger.ping('http://' + pingURL, (error, pong) => { + this.results[this.state.currentIndex]?.update( + desc, + 'byond://' + connectURL, + round(pong * 0.75), // The ping is inflated so lets compensate a bit + error + ); + this.setState((prevState) => ({ + currentIndex: prevState.currentIndex + 1, + })); + }); + } + + handleConfirmChange(index, newState) { + if (newState || this.state.lastClickedIndex === index) { + this.setState({ lastClickedIndex: index }); + this.setState({ lastClickedState: newState }); + } + } + + componentDidMount() { + this.setState({ currentIndex: 0 }); + for (let i = 0; i < this.props.relayNames.length; i++) { + this.results.push(new PingResult()); + this.startTest( + this.props.relayNames[i], + this.props.relayPings[i], + this.props.relayCons[i] + ); + } + } + + componentWillUnmount() { + this.pinger.cancel(); + } + + render() { + const { act } = useBackend(); + + return ( + + {this.results.map((result, i) => ( + + + this.handleConfirmChange(i, clickedOnce) + } + onClick={() => + act('connect', { url: result.url, desc: result.desc }) + }> + {result.ping <= -1 && result.error === null && ( + + + + + + {result.desc} + + + + )} + {result.ping > -1 && result.error === null && ( + + + + + + + {this.state.lastClickedIndex === i && + this.state.lastClickedState + ? 'Connect via ' + result.desc + '?' + : result.desc} + + + + x + 'ms'} + inline + /> + + + )} + {result.error !== null && ( + + + + + + {result.desc} + + + + {' (' + result.error + ')'} + + + + )} + + + ))} + + ); + } +} + +export const PingRelaysPanel = () => { + const { data } = useBackend(); + const { relay_names, relay_pings, relay_cons } = data; + + return ( + + + + + + ); +};