summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock133
-rw-r--r--Cargo.toml1
-rw-r--r--src/config.rs1
-rw-r--r--src/main.rs195
-rw-r--r--src/system/message_parser.rs14
-rw-r--r--src/system/mod.rs84
6 files changed, 387 insertions, 41 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 26cf5ed..67cc02c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -211,6 +211,31 @@ dependencies = [
 ]
 
 [[package]]
+name = "crossterm"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
+dependencies = [
+ "bitflags 2.6.0",
+ "crossterm_winapi",
+ "mio 1.0.2",
+ "parking_lot",
+ "rustix",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
 name = "crypto-common"
 version = "0.1.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -476,6 +501,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "hermit-abi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
+
+[[package]]
 name = "http"
 version = "0.2.11"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -697,9 +728,9 @@ dependencies = [
 
 [[package]]
 name = "libc"
-version = "0.2.152"
+version = "0.2.162"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7"
+checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398"
 
 [[package]]
 name = "libz-sys"
@@ -719,6 +750,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89"
 
 [[package]]
+name = "lock_api"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
 name = "log"
 version = "0.4.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -766,6 +807,19 @@ dependencies = [
 ]
 
 [[package]]
+name = "mio"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
+dependencies = [
+ "hermit-abi",
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
 name = "native-tls"
 version = "0.2.12"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -866,6 +920,29 @@ dependencies = [
 ]
 
 [[package]]
+name = "parking_lot"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-targets 0.52.0",
+]
+
+[[package]]
 name = "percent-encoding"
 version = "2.3.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -970,6 +1047,15 @@ dependencies = [
 ]
 
 [[package]]
+name = "redox_syscall"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f"
+dependencies = [
+ "bitflags 2.6.0",
+]
+
+[[package]]
 name = "regex"
 version = "1.10.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1078,9 +1164,9 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
 
 [[package]]
 name = "rustix"
-version = "0.38.31"
+version = "0.38.39"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949"
+checksum = "375116bee2be9ed569afe2154ea6a99dfdffd257f533f187498c2a8f5feaf4ee"
 dependencies = [
  "bitflags 2.6.0",
  "errno",
@@ -1178,6 +1264,12 @@ dependencies = [
 ]
 
 [[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
 name = "sct"
 version = "0.7.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1191,6 +1283,7 @@ dependencies = [
 name = "seance-rs"
 version = "0.1.0"
 dependencies = [
+ "crossterm",
  "futures",
  "lru",
  "regex",
@@ -1313,6 +1406,36 @@ dependencies = [
 ]
 
 [[package]]
+name = "signal-hook"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
+dependencies = [
+ "libc",
+ "mio 1.0.2",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1"
+dependencies = [
+ "libc",
+]
+
+[[package]]
 name = "slab"
 version = "0.4.9"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1479,7 +1602,7 @@ dependencies = [
  "backtrace",
  "bytes",
  "libc",
- "mio",
+ "mio 0.8.10",
  "pin-project-lite",
  "socket2",
  "windows-sys 0.48.0",
diff --git a/Cargo.toml b/Cargo.toml
index 09575e6..1a7acb7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ edition = "2021"
 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
 
 [dependencies]
+crossterm = "0.28.1"
 lru = "0.12.3"
 futures = "0.3.30"
 regex = "1.10.2"
diff --git a/src/config.rs b/src/config.rs
index 28fe422..efbb50d 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -55,6 +55,7 @@ pub struct System {
     pub forward_pings: bool,
     pub autoproxy: Option<AutoproxyConfig>,
     pub pluralkit: Option<PluralkitConfig>,
+    pub ui_color: Option<String>,
 }
 
 fn default_forward_pings() -> bool {
diff --git a/src/main.rs b/src/main.rs
index 6e37938..287cd4f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -2,41 +2,141 @@
 
 mod config;
 mod system;
+use crossterm::{cursor::{self, MoveTo}, terminal::{Clear, ClearType, DisableLineWrap, EnableLineWrap, EnterAlternateScreen, LeaveAlternateScreen}};
 use system::{Manager, SystemThreadCommand};
-use std::{fs, thread::{self, sleep, JoinHandle}, time::Duration, sync::mpsc};
+use std::{collections::{HashMap, VecDeque}, fs, io::{self, Write}, sync::mpsc, thread::{self, sleep, JoinHandle}, time::Duration};
 use tokio::runtime;
 
+pub struct UiState {
+    pub systems: HashMap<String, SystemState>,
+    pub logs: VecDeque<String>,
+}
+
+pub enum SystemState {
+    Running(HashMap<String, MemberState>),
+    Reloading,
+    Restarting,
+    Shutdown,
+}
+
+pub struct MemberState {
+    pub connected: bool,
+    pub autoproxied: bool,
+}
+
+pub enum SystemUiEvent {
+    SystemClose,
+    MemberAutoproxy(Option<String>),
+    GatewayDisconnect(String),
+    GatewayConnect(String),
+    LogLine(String),
+}
+
+const MAX_LOG : usize = 1000;
+
 fn main() {
     let initial_config = fs::read_to_string("./config.toml").expect("Could not find config file");
     let config = config::Config::load(initial_config.to_string());
 
-    let (waker, waiter) = mpsc::channel::<()>();
+    let (waker, waiter) = mpsc::channel::<(String, SystemUiEvent)>();
     let mut join_handles = Vec::<(String, JoinHandle<_>)>::new();
 
+    let mut ui_state = UiState {
+        systems: HashMap::new(),
+        logs: VecDeque::new(),
+    };
+
     for (system_name, system_config) in config.systems.iter() {
         let handle = spawn_system(system_name, system_config.clone(), waker.clone());
 
+        let mut member_states = HashMap::new();
+        for member_name in system_config.members.iter() {
+            member_states.insert(member_name.name.clone(), MemberState {
+                connected: false,
+                autoproxied: false,
+            });
+        }
+
+        let system_state = SystemState::Running(member_states);
+        ui_state.systems.insert(system_name.clone(), system_state);
+
         join_handles.push((system_name.clone(), handle));
     }
 
+    crossterm::execute!(io::stdout(), EnterAlternateScreen).unwrap();
+    crossterm::execute!(io::stdout(), DisableLineWrap).unwrap();
+
     loop {
-        // Check manually every 10 seconds just in case
-        let _ = waiter.recv_timeout(Duration::from_secs(10));
+        // Wait for an event from one of the threads
+        let ui_event = waiter.recv_timeout(Duration::from_millis(500));
+
+        if let Ok((system_name, ui_event)) = ui_event {
+            let system_state = ui_state.systems.get_mut(&system_name).unwrap();
+            match system_state {
+                SystemState::Running(member_states) => match ui_event {
+                    // We will check for the join in a second
+                    SystemUiEvent::SystemClose => (),
+
+                    SystemUiEvent::MemberAutoproxy(member_name) => {
+                        member_states.iter_mut().for_each(|(_, member_state)| {
+                            member_state.autoproxied = false;
+                        });
+
+                        if let Some(member_name) = member_name {
+                            member_states.get_mut(&member_name).unwrap()
+                                .autoproxied = true;
+                        }
+                    },
+
+                    SystemUiEvent::GatewayDisconnect(member_name) => {
+                        member_states.get_mut(&member_name).unwrap()
+                            .connected = false;
+                    },
+
+                    SystemUiEvent::GatewayConnect(member_name) => {
+                        member_states.get_mut(&member_name).unwrap()
+                            .connected = true;
+
+                    },
+
+                    SystemUiEvent::LogLine(log) => {
+                        if log.len() == MAX_LOG {
+                            let _ = ui_state.logs.pop_front();
+                        }
+
+                        ui_state.logs.push_back(
+                            format!("{system_name:>8.8}: {log}")
+                        );
+                    },
+
+                },
+                _ => (),
+            }
+        }
 
         // Just to make sure the join handle is updated by the time we check
-        sleep(Duration::from_millis(100));
+        sleep(Duration::from_millis(10));
 
         if let Some(completed_index) = join_handles.iter().position(|(_, handle)| handle.is_finished()) {
             let (name, next_join) = join_handles.swap_remove(completed_index);
 
             match next_join.join() {
                 Err(err) => {
-                    println!("Thread for system {} panicked!", name);
-                    println!("{:?}", err);
+                    let _ = ui_state.systems.insert(name.clone(), SystemState::Shutdown);
+                    ui_state.logs.push_back(
+                        format!("Thread for system {} panicked!", name)
+                    );
+
+                    ui_state.logs.push_back(
+                        format!("{:?}", err)
+                    );
                 },
 
                 Ok(SystemThreadCommand::Restart) => {
-                    println!("Thread for system {} requested restart", name);
+                    let _ = ui_state.systems.insert(name.clone(), SystemState::Restarting);
+                    ui_state.logs.push_back(
+                        format!("Thread for system {} requested restart", name)
+                    );
                     if let Some((_, config)) = config.systems.iter().find(|(system_name, _)| name == **system_name) {
                         let handle = spawn_system(&name, config.clone(), waker.clone());
                         join_handles.push((name, handle));
@@ -44,15 +144,24 @@ fn main() {
                 },
 
                 Ok(SystemThreadCommand::ShutdownSystem) => {
-                    println!("Thread for system {} requested shutdown", name);
+                    let _ = ui_state.systems.insert(name.clone(), SystemState::Shutdown);
+                    ui_state.logs.push_back(
+                        format!("Thread for system {} requested shutdown", name)
+                    );
                     continue;
                 },
+
                 Ok(SystemThreadCommand::ReloadConfig) => {
-                    println!("Thread for system {} requested config reload", name);
+                    let _ = ui_state.systems.insert(name.clone(), SystemState::Reloading);
+                    ui_state.logs.push_back(
+                        format!("Thread for system {} requested config reload", name)
+                    );
                     let config_file = if let Ok(config_file) = fs::read_to_string("./config.toml") {
                         config_file
                     } else {
-                        println!("Could not open config file, continuing with initial config");
+                        ui_state.logs.push_back(
+                            format!("Could not open config file, continuing with initial config")
+                        );
                         initial_config.clone()
                     };
 
@@ -62,17 +171,24 @@ fn main() {
                         let handle = spawn_system(&name, system_config, waker.clone());
                         join_handles.push((name.clone(), handle));
                     } else {
-                        println!("New config file but this system no longer exists, exiting.");
+                        ui_state.logs.push_back(
+                            format!("New config file but this system no longer exists, exiting.")
+                        );
                         continue;
                     }
                 },
                 Ok(SystemThreadCommand::ShutdownAll) => break,
             }
         }
+
+        update_ui(&ui_state, &config);
     }
+
+    crossterm::execute!(io::stdout(), EnableLineWrap).unwrap();
+    crossterm::execute!(io::stdout(), LeaveAlternateScreen).unwrap();
 }
 
-fn spawn_system(system_name : &String, system_config: config::System, waker: mpsc::Sender<()>) -> JoinHandle<SystemThreadCommand> {
+fn spawn_system(system_name : &String, system_config: config::System, waker: mpsc::Sender<(String, SystemUiEvent)>) -> JoinHandle<SystemThreadCommand> {
     let name = system_name.clone();
     let config = system_config.clone();
 
@@ -80,16 +196,63 @@ fn spawn_system(system_name : &String, system_config: config::System, waker: mps
         .name(format!("seance_{}", &name))
         .spawn(move || -> _ {
             let thread_local_runtime = runtime::Builder::new_current_thread().enable_all().build().unwrap();
+            let dup_waker = waker.clone();
 
-            // TODO: allow system manager runtime to return a command
             thread_local_runtime.block_on(async {
-                let mut system = Manager::new(name, config);
+                let mut system = Manager::new(name.clone(), config, waker);
                 system.start_clients().await;
             });
 
-            let _ = waker.send(());
+            let _ = dup_waker.send((name, SystemUiEvent::SystemClose));
             SystemThreadCommand::Restart
         }).unwrap()
 }
 
+fn update_ui(ui_state: &UiState, config: &config::Config) {
+    crossterm::execute!(io::stdout(), Clear(ClearType::FromCursorUp)).unwrap();
+    crossterm::execute!(io::stdout(), MoveTo(0, 0)).unwrap();
+
+    let (width, height) = crossterm::terminal::size().unwrap();
 
+    let status_lines = (ui_state.systems.len() * 2) + ui_state.systems.values().map(|system| match system {
+        SystemState::Running(members) => members.len(),
+        SystemState::Reloading => 1,
+        SystemState::Restarting => 1,
+        SystemState::Shutdown => 1,
+    } ).sum::<usize>() + 1;
+
+    let log_space = height as usize - status_lines - 1;
+    let log_height = ui_state.logs.len();
+
+    for (name, state) in ui_state.systems.iter() {
+        println!("{name}");
+        match state {
+            SystemState::Shutdown => println!("  - [System stopped]"),
+            SystemState::Reloading => println!("  - [System reloading]"),
+            SystemState::Restarting => println!("  - [System restarting]"),
+            SystemState::Running(members) => for (name, state) in members {
+                if !state.connected {
+                    println!("  - {name} (connecting)")
+                } else if state.autoproxied {
+                    println!("  - {name} (autoproxy)")
+                } else {
+                    println!("  - {name}")
+                }
+            },
+        }
+
+        println!("");
+    }
+
+    println!("{:-<width$}", "", width = width as usize);
+
+    let range = if log_height <= log_space {
+        0..log_height
+    } else {
+        log_height - log_space .. log_height
+    };
+
+    for line in ui_state.logs.range(range) {
+        println!("{line}");
+    }
+}
diff --git a/src/system/message_parser.rs b/src/system/message_parser.rs
index 349ce0e..2c6d6d2 100644
--- a/src/system/message_parser.rs
+++ b/src/system/message_parser.rs
@@ -15,7 +15,7 @@ pub enum ParsedMessage {
         message_content: String,
         latch: bool,
     },
-    UnproxiedMessage,
+    UnproxiedMessage(Option<String>),
     LatchClear(MemberId),
 
     // TODO: Figure out how to represent emotes
@@ -28,6 +28,7 @@ pub enum Command {
     Reproxy(MemberId, MessageId),
     Delete(MessageId),
     Nick(MemberId, String),
+    Log(String),
     ReloadSystemConfig,
     ExitSéance,
     UnknownCommand,
@@ -51,13 +52,14 @@ impl MessageParser {
         }
 
         if message.content.starts_with(r"\") {
-            return ParsedMessage::UnproxiedMessage
+            return ParsedMessage::UnproxiedMessage(None)
         }
 
         if message.content.starts_with(r"!") {
             if let Some(parse) = MessageParser::check_command(message, secondary_message, system_config, latch_state) {
                 return ParsedMessage::Command(parse);
-
+            } else {
+                return ParsedMessage::UnproxiedMessage(Some(format!("Unknown command string: {}", message.content)));
             }
         }
 
@@ -76,7 +78,7 @@ impl MessageParser {
         }
 
         // If nothing else
-        ParsedMessage::UnproxiedMessage
+        ParsedMessage::UnproxiedMessage(None)
     }
 
     fn check_command(message: &FullMessage, secondary_message: Option<&FullMessage>, system_config: &System, latch_state: Option<(MemberId, Timestamp)>) -> Option<Command> {
@@ -86,6 +88,10 @@ impl MessageParser {
         match first_word {
             None => return None,
             Some(command_name) => match command_name {
+                "log" => {
+                    let remainder = words.remainder().unwrap().to_string();
+                    return Some(Command::Log(remainder));
+                },
                 "edit" => {
                     let editing_member = Self::get_member_id_from_user_id(secondary_message.as_ref().unwrap().author.id, system_config).unwrap();
                     return Some(Command::Edit(editing_member, secondary_message.unwrap().id, words.remainder().unwrap().to_string()));
diff --git a/src/system/mod.rs b/src/system/mod.rs
index 44c6da8..e2bd1ee 100644
--- a/src/system/mod.rs
+++ b/src/system/mod.rs
@@ -1,5 +1,6 @@
 use std::{collections::HashMap, num::NonZeroUsize, str::FromStr, time::Duration};
 
+use std::sync::mpsc::Sender as ThreadSender;
 use lru::LruCache;
 use tokio::{
     sync::mpsc::{channel, Sender},
@@ -10,6 +11,7 @@ use twilight_model::{channel::message::{MessageReference, MessageType, ReactionT
 use twilight_model::util::Timestamp;
 
 use crate::config::{AutoproxyConfig, AutoproxyLatchScope, Member};
+use crate::SystemUiEvent;
 
 mod aggregator;
 mod bot;
@@ -33,10 +35,11 @@ pub struct Manager {
     pub aggregator: MessageAggregator,
     pub send_cache: LruCache<ChannelId, TwiMessage>,
     pub reference_user_id: UserId,
+    pub ui_sender: ThreadSender<(String, SystemUiEvent)>,
 }
 
 impl Manager {
-    pub fn new(system_name: String, system_config: crate::config::System) -> Self {
+    pub fn new(system_name: String, system_config: crate::config::System, ui_sender : ThreadSender<(String, SystemUiEvent)>) -> Self {
         Self {
             reference_user_id: Id::from_str(&system_config.reference_user_id.as_str())
                 .expect(format!("Invalid user id for system {}", &system_name).as_str()),
@@ -47,6 +50,7 @@ impl Manager {
             latch_state: None,
             system_sender: None,
             send_cache: LruCache::new(NonZeroUsize::new(15).unwrap()),
+            ui_sender,
         }
     }
 
@@ -71,7 +75,9 @@ impl Manager {
     }
 
     pub async fn start_clients(&mut self) {
-        println!("Starting clients for system {}", self.name);
+        let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+            format!("Starting clients for system {}", self.name)
+        )));
 
         let (system_sender, mut system_receiver) = channel::<SystemEvent>(100);
         self.system_sender = Some(system_sender.clone());
@@ -83,7 +89,9 @@ impl Manager {
         }
 
         if self.config.members.len() < 1 {
-            println!("WARNING: System {} has no configured members", &self.name);
+            let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                format!("WARNING: System {} has no configured members", &self.name)
+            )));
         }
 
         loop {
@@ -94,19 +102,29 @@ impl Manager {
 
                     let member = self.find_member_by_id(member_id).unwrap();
 
-                    println!("Gateway client {} ({}) connected", member.name, member_id);
+                    let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::GatewayConnect(member.name.clone())));
+
+                    let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                        format!("Gateway client {} ({}) connected", member.name, member_id)
+                    )));
                 }
 
                 Some(SystemEvent::GatewayError(member_id, message)) => {
                     let member = self.find_member_by_id(member_id).unwrap();
 
-                    println!("Gateway client {} ran into error {}", member.name, message);
+                    let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                        format!("Gateway client {} ran into error {}", member.name, message)
+                    )));
                 }
 
                 Some(SystemEvent::GatewayClosed(member_id)) => {
                     let member = self.find_member_by_id(member_id).unwrap();
 
-                    println!("Gateway client {} closed", member.name);
+                    let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::GatewayDisconnect(member.name.clone())));
+
+                    let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                        format!("Gateway client {} closed", member.name)
+                    )));
 
                     self.start_bot(member_id).await;
                 }
@@ -123,7 +141,11 @@ impl Manager {
                 Some(SystemEvent::AutoproxyTimeout(time_scheduled)) => {
                     if let Some((_member, current_last_message)) = self.latch_state.clone() {
                         if current_last_message == time_scheduled {
-                            println!("Autoproxy timeout has expired: {} (last sent), {} (timeout scheduled)", current_last_message.as_secs(), time_scheduled.as_secs());
+                            let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::MemberAutoproxy(None)));
+
+                            let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                                format!("Autoproxy timeout has expired: {} (last sent), {} (timeout scheduled)", current_last_message.as_secs(), time_scheduled.as_secs())
+                            )));
                             self.latch_state = None;
                             self.update_status_of_system().await;
                         }
@@ -195,7 +217,9 @@ impl Manager {
                 if let Some(last) = last_in_channel {
                     self.send_cache.put(message.channel_id, last);
                 } else {
-                    println!("WARNING: Could not look up most recent message in channel {}", message.channel_id);
+                    let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                        format!("WARNING: Could not look up most recent message in channel {}", message.channel_id)
+                    )));
                 };
 
                 // Return the message referenced from cache so there's no unnecessary clone
@@ -206,12 +230,17 @@ impl Manager {
         let parsed_message = MessageParser::parse(&message, referenced_message, &self.config, self.latch_state);
 
         match parsed_message {
-            message_parser::ParsedMessage::UnproxiedMessage => (),
+            message_parser::ParsedMessage::UnproxiedMessage(log_string) => if let Some(log_string) = log_string {
+                let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                    format!("Parse error: {log_string}")
+                )));
+            },
 
             message_parser::ParsedMessage::LatchClear(member_id) => {
                 let _ = self.bots.get(&member_id).unwrap().delete_message(message.channel_id, message.id).await;
                 self.latch_state = None;
                 self.update_status_of_system().await;
+                let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::MemberAutoproxy(None)));
             },
 
             message_parser::ParsedMessage::SetProxyAndDelete(member_id) => {
@@ -234,7 +263,9 @@ impl Manager {
 
                 let author = MessageParser::get_member_id_from_user_id(referenced_message.unwrap().author.id, &self.config);
                 if author.is_none() {
-                    println!("Cannot edit another user's message");
+                    let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                        format!("Cannot edit another user's message")
+                    )));
                     let _ = self.bots.get(&member_id).unwrap().react_message(message.channel_id, message.id, &RequestReactionType::Unicode { name: "🛑" }).await;
                     return
                 }
@@ -254,14 +285,18 @@ impl Manager {
 
             message_parser::ParsedMessage::Command(Command::Reproxy(member_id, message_id)) => {
                 if !referenced_message.map(|message| message.id == message_id).unwrap_or(false) {
-                    println!("ERROR: Attempted reproxy on message other than referenced_message");
+                    let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                        format!("ERROR: Attempted reproxy on message other than referenced_message")
+                    )));
                     let _ = self.bots.get(&member_id).unwrap().react_message(message.channel_id, message.id, &RequestReactionType::Unicode { name: "⁉️" }).await;
                     return
                 }
 
                 let author = MessageParser::get_member_id_from_user_id(referenced_message.unwrap().author.id, &self.config);
                 if author.is_none() {
-                    println!("Cannot reproxy another user's message");
+                    let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                        format!("Cannot reproxy another user's message")
+                    )));
                     let _ = self.bots.get(&member_id).unwrap().react_message(message.channel_id, message.id, &RequestReactionType::Unicode { name: "🛑" }).await;
                     return
                 }
@@ -274,7 +309,9 @@ impl Manager {
                         self.update_status_of_system().await;
                     }
                 } else {
-                    println!("Not reproxying under same user");
+                    let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                        format!("Not reproxying under same user")
+                    )));
                 }
 
                 let bot = self.bots.get(&member_id).unwrap();
@@ -286,7 +323,9 @@ impl Manager {
 
                 let author = MessageParser::get_member_id_from_user_id(referenced_message.unwrap().author.id, &self.config);
                 if author.is_none() {
-                    println!("Cannot delete another user's message");
+                    let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                        format!("Cannot delete another user's message")
+                    )));
                     let _ = self.bots.get(&member_id).unwrap().react_message(message.channel_id, message.id, &RequestReactionType::Unicode { name: "🛑" }).await;
                     return
                 }
@@ -296,6 +335,12 @@ impl Manager {
                 let _ = bot.delete_message(message.channel_id, message.id).await;
             }
 
+            message_parser::ParsedMessage::Command(Command::Log(log_string)) => {
+                let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                    format!("Log: {log_string}")
+                )));
+            }
+
             message_parser::ParsedMessage::Command(Command::UnknownCommand) => {
                 let member_id = if let Some((member_id, _)) = self.latch_state {
                     member_id
@@ -317,7 +362,9 @@ impl Manager {
         let duplicate_result = bot.duplicate_message(message, content).await;
 
         if duplicate_result.is_err() {
-            println!("Could not copy message: {:?}", duplicate_result);
+            let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                format!("Could not copy message: {:?}", duplicate_result)
+            )));
             return Err(())
         }
 
@@ -325,7 +372,9 @@ impl Manager {
         let delete_result = bot.delete_message(message.channel_id, message.id).await;
 
         if delete_result.is_err() {
-            println!("Could not delete message: {:?}", delete_result);
+            let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::LogLine(
+                format!("Could not delete message: {:?}", delete_result)
+            )));
 
             // Delete the duplicated message if that failed
             let _ = bot.delete_message(message.channel_id, duplicate_result.unwrap().id).await;
@@ -350,6 +399,9 @@ impl Manager {
             }) => {
                 self.latch_state = Some((member, timestamp));
 
+                let member = self.find_member_by_id(member).unwrap();
+                let _ = self.ui_sender.send((self.name.clone(), SystemUiEvent::MemberAutoproxy(Some(member.name.clone()))));
+
                 if let Some(channel) = self.system_sender.clone() {
                     let last_message = timestamp.clone();
                     let timeout_seconds = timeout_seconds.clone();