mirror of
https://gitlab.com/gabmus/envision.git
synced 2025-04-20 03:24:52 +00:00
chore: move CmdRunner to its own module
This commit is contained in:
parent
ea33f634c9
commit
aa9ea70b2d
12 changed files with 273 additions and 270 deletions
|
@ -1,4 +1,4 @@
|
|||
use crate::runner::CmdRunner;
|
||||
use crate::cmd_runner::CmdRunner;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn get_adb_install_runner(apk_path: &String) -> CmdRunner {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
use crate::runner::CmdRunner;
|
||||
use crate::cmd_runner::CmdRunner;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
use crate::{
|
||||
cmd_runner::CmdRunner,
|
||||
func_runner::{FuncRunner, FuncRunnerOut},
|
||||
profile::Profile,
|
||||
runner::CmdRunner,
|
||||
};
|
||||
use git2::Repository;
|
||||
use std::path::Path;
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
use crate::{
|
||||
build_tools::{cmake::Cmake, git::Git},
|
||||
cmd_runner::CmdRunner,
|
||||
file_utils::rm_rf,
|
||||
profile::Profile,
|
||||
runner::{CmdRunner, Runner},
|
||||
runner::Runner,
|
||||
};
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
use crate::{constants::pkg_data_dir, paths::get_cache_dir, profile::Profile, runner::CmdRunner};
|
||||
use crate::{
|
||||
cmd_runner::CmdRunner, constants::pkg_data_dir, paths::get_cache_dir, profile::Profile,
|
||||
};
|
||||
|
||||
pub fn get_build_mercury_runner(profile: &Profile) -> CmdRunner {
|
||||
let args = vec![profile.prefix.clone(), get_cache_dir()];
|
||||
|
|
253
src/cmd_runner.rs
Normal file
253
src/cmd_runner.rs
Normal file
|
@ -0,0 +1,253 @@
|
|||
use nix::{
|
||||
sys::signal::{kill, Signal::SIGTERM},
|
||||
unistd::Pid,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
file_utils::get_writer,
|
||||
profile::{Profile, XRServiceType},
|
||||
runner::{Runner, RunnerStatus},
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::{BufRead, BufReader, Write},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{
|
||||
mpsc::{sync_channel, Receiver, SyncSender},
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
};
|
||||
|
||||
pub struct CmdRunner {
|
||||
pub environment: HashMap<String, String>,
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub output: Vec<String>,
|
||||
sender: Arc<Mutex<SyncSender<String>>>,
|
||||
receiver: Receiver<String>,
|
||||
threads: Vec<JoinHandle<()>>,
|
||||
process: Option<Child>,
|
||||
}
|
||||
|
||||
macro_rules! logger_thread {
|
||||
($buf_fd: expr, $sender: expr) => {
|
||||
thread::spawn(move || {
|
||||
let mut reader = BufReader::new($buf_fd);
|
||||
loop {
|
||||
let mut buf = String::new();
|
||||
match reader.read_line(&mut buf) {
|
||||
Err(_) => return,
|
||||
Ok(bytes_read) => {
|
||||
if bytes_read == 0 {
|
||||
return;
|
||||
}
|
||||
if buf.is_empty() {
|
||||
continue;
|
||||
}
|
||||
print!("{}", buf);
|
||||
match $sender
|
||||
.clone()
|
||||
.lock()
|
||||
.expect("Could not lock sender")
|
||||
.send(buf)
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(_) => return,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
impl CmdRunner {
|
||||
pub fn new(
|
||||
environment: Option<HashMap<String, String>>,
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
) -> Self {
|
||||
let (sender, receiver) = sync_channel(64000);
|
||||
Self {
|
||||
environment: match environment {
|
||||
None => HashMap::new(),
|
||||
Some(e) => e.clone(),
|
||||
},
|
||||
command,
|
||||
args,
|
||||
output: Vec::new(),
|
||||
process: None,
|
||||
sender: Arc::new(Mutex::new(sender)),
|
||||
threads: Vec::new(),
|
||||
receiver,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn xrservice_runner_from_profile(profile: &Profile) -> Self {
|
||||
let mut env = profile.environment.clone();
|
||||
if !env.contains_key("LH_DRIVER") {
|
||||
env.insert(
|
||||
"LH_DRIVER".into(),
|
||||
profile.lighthouse_driver.to_string().to_lowercase(),
|
||||
);
|
||||
}
|
||||
Self::new(
|
||||
Some(env),
|
||||
match profile.xrservice_type {
|
||||
XRServiceType::Monado => format!("{pfx}/bin/monado-service", pfx = profile.prefix),
|
||||
XRServiceType::Wivrn => format!("{pfx}/bin/wivrn-server", pfx = profile.prefix),
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn try_start(&mut self) -> Result<(), std::io::Error> {
|
||||
self.threads = Vec::new();
|
||||
let cmd = Command::new(self.command.clone())
|
||||
.args(self.args.clone())
|
||||
.envs(self.environment.clone())
|
||||
.stderr(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
self.process = Some(cmd);
|
||||
let stdout = self.process.as_mut().unwrap().stdout.take().unwrap();
|
||||
let stderr = self.process.as_mut().unwrap().stderr.take().unwrap();
|
||||
let stdout_sender = self.sender.clone();
|
||||
let stderr_sender = self.sender.clone();
|
||||
self.threads.push(logger_thread!(stdout, stdout_sender));
|
||||
self.threads.push(logger_thread!(stderr, stderr_sender));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn join_threads(&mut self) {
|
||||
loop {
|
||||
match self.threads.pop() {
|
||||
None => break,
|
||||
Some(thread) => thread.join().expect("Failed to join reader thread"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn terminate(&mut self) {
|
||||
if self.status() != RunnerStatus::Running {
|
||||
return;
|
||||
}
|
||||
let process = self.process.take();
|
||||
if process.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut proc = process.unwrap();
|
||||
let child_pid = Pid::from_raw(proc.id().try_into().expect("Could not convert pid to u32"));
|
||||
kill(child_pid, SIGTERM).expect("Could not send sigterm to process");
|
||||
self.join_threads();
|
||||
proc.wait().expect("Failed to wait for process");
|
||||
}
|
||||
|
||||
pub fn join(&mut self) {
|
||||
let process = self.process.take();
|
||||
if process.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut proc = process.unwrap();
|
||||
proc.wait().expect("Failed to wait for process");
|
||||
self.join_threads();
|
||||
}
|
||||
|
||||
fn receive_output(&mut self) {
|
||||
while let Ok(data) = self.receiver.try_recv() {
|
||||
self.output.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
fn save_log(path_s: String, log: &[String]) -> Result<(), std::io::Error> {
|
||||
let mut writer = get_writer(&path_s);
|
||||
let log_s = log.concat();
|
||||
writer.write_all(log_s.as_ref())
|
||||
}
|
||||
|
||||
pub fn save_output(&mut self, path: String) -> Result<(), std::io::Error> {
|
||||
CmdRunner::save_log(path, &self.output)
|
||||
}
|
||||
}
|
||||
|
||||
impl Runner for CmdRunner {
|
||||
fn consume_output(&mut self) -> String {
|
||||
self.receive_output();
|
||||
let res = self.output.concat();
|
||||
self.output.clear();
|
||||
res
|
||||
}
|
||||
|
||||
fn consume_rows(&mut self) -> Vec<String> {
|
||||
self.receive_output();
|
||||
let res = self.output.clone();
|
||||
self.output.clear();
|
||||
res
|
||||
}
|
||||
|
||||
fn status(&mut self) -> RunnerStatus {
|
||||
match &mut self.process {
|
||||
None => RunnerStatus::Stopped(None),
|
||||
Some(proc) => match proc.try_wait() {
|
||||
Err(_) => RunnerStatus::Running,
|
||||
Ok(Some(code)) => RunnerStatus::Stopped(code.code()),
|
||||
Ok(None) => RunnerStatus::Running,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn start(&mut self) {
|
||||
self.try_start().expect("Failed to execute runner");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CmdRunner, RunnerStatus};
|
||||
use crate::profile::Profile;
|
||||
use crate::runner::Runner;
|
||||
use core::time;
|
||||
use std::{collections::HashMap, thread::sleep};
|
||||
|
||||
#[test]
|
||||
fn can_run_command_and_read_env() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("REX2TEST".to_string(), "Lorem ipsum dolor".to_string());
|
||||
let mut runner = CmdRunner::new(
|
||||
Some(env),
|
||||
"bash".into(),
|
||||
vec!["-c".into(), "echo \"REX2TEST: $REX2TEST\"".into()],
|
||||
);
|
||||
runner.start();
|
||||
sleep(time::Duration::from_millis(1000)); // TODO: ugly, fix
|
||||
runner.terminate();
|
||||
assert_eq!(runner.status(), RunnerStatus::Stopped(Some(0)));
|
||||
let out = runner.consume_output();
|
||||
assert_eq!(out, "REX2TEST: Lorem ipsum dolor\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_save_log() {
|
||||
let mut runner = CmdRunner::new(
|
||||
None,
|
||||
"bash".into(),
|
||||
vec!["-c".into(), "echo \"Lorem ipsum dolor sit amet\"".into()],
|
||||
);
|
||||
runner.start();
|
||||
while runner.status() == RunnerStatus::Running {
|
||||
sleep(time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
runner
|
||||
.save_output("./target/testout/testlog".into())
|
||||
.expect("Failed to save output file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_create_from_profile() {
|
||||
CmdRunner::xrservice_runner_from_profile(&Profile::load_profile(
|
||||
&"./test/files/profile.json".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
use crate::runner::{CmdRunner, Runner};
|
||||
use crate::{cmd_runner::CmdRunner, runner::Runner};
|
||||
use nix::{
|
||||
errno::Errno,
|
||||
sys::statvfs::{statvfs, FsFlags},
|
||||
|
|
|
@ -16,6 +16,7 @@ pub mod adb;
|
|||
pub mod build_tools;
|
||||
pub mod builders;
|
||||
pub mod checkerr;
|
||||
pub mod cmd_runner;
|
||||
pub mod config;
|
||||
pub mod constants;
|
||||
pub mod depcheck;
|
||||
|
|
263
src/runner.rs
263
src/runner.rs
|
@ -1,22 +1,8 @@
|
|||
use crate::{
|
||||
file_utils::get_writer,
|
||||
profile::{Profile, XRServiceType},
|
||||
};
|
||||
use nix::{
|
||||
sys::signal::{kill, Signal::SIGTERM},
|
||||
unistd::Pid,
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
io::{BufRead, BufReader, Write},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{
|
||||
mpsc::{sync_channel, Receiver, SyncSender},
|
||||
Arc, Mutex,
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
vec,
|
||||
};
|
||||
#[derive(PartialEq, Eq, Debug)]
|
||||
pub enum RunnerStatus {
|
||||
Running,
|
||||
Stopped(Option<i32>),
|
||||
}
|
||||
|
||||
pub trait Runner {
|
||||
fn start(&mut self);
|
||||
|
@ -24,242 +10,3 @@ pub trait Runner {
|
|||
fn consume_output(&mut self) -> String;
|
||||
fn consume_rows(&mut self) -> Vec<String>;
|
||||
}
|
||||
|
||||
pub struct CmdRunner {
|
||||
pub environment: HashMap<String, String>,
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub output: Vec<String>,
|
||||
sender: Arc<Mutex<SyncSender<String>>>,
|
||||
receiver: Receiver<String>,
|
||||
threads: Vec<JoinHandle<()>>,
|
||||
process: Option<Child>,
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug)]
|
||||
pub enum RunnerStatus {
|
||||
Running,
|
||||
Stopped(Option<i32>),
|
||||
}
|
||||
|
||||
macro_rules! logger_thread {
|
||||
($buf_fd: expr, $sender: expr) => {
|
||||
thread::spawn(move || {
|
||||
let mut reader = BufReader::new($buf_fd);
|
||||
loop {
|
||||
let mut buf = String::new();
|
||||
match reader.read_line(&mut buf) {
|
||||
Err(_) => return,
|
||||
Ok(bytes_read) => {
|
||||
if bytes_read == 0 {
|
||||
return;
|
||||
}
|
||||
if buf.is_empty() {
|
||||
continue;
|
||||
}
|
||||
print!("{}", buf);
|
||||
match $sender
|
||||
.clone()
|
||||
.lock()
|
||||
.expect("Could not lock sender")
|
||||
.send(buf)
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(_) => return,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
impl CmdRunner {
|
||||
pub fn new(
|
||||
environment: Option<HashMap<String, String>>,
|
||||
command: String,
|
||||
args: Vec<String>,
|
||||
) -> Self {
|
||||
let (sender, receiver) = sync_channel(64000);
|
||||
Self {
|
||||
environment: match environment {
|
||||
None => HashMap::new(),
|
||||
Some(e) => e.clone(),
|
||||
},
|
||||
command,
|
||||
args,
|
||||
output: Vec::new(),
|
||||
process: None,
|
||||
sender: Arc::new(Mutex::new(sender)),
|
||||
threads: Vec::new(),
|
||||
receiver,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn xrservice_runner_from_profile(profile: &Profile) -> Self {
|
||||
let mut env = profile.environment.clone();
|
||||
if !env.contains_key("LH_DRIVER") {
|
||||
env.insert(
|
||||
"LH_DRIVER".into(),
|
||||
profile.lighthouse_driver.to_string().to_lowercase(),
|
||||
);
|
||||
}
|
||||
Self::new(
|
||||
Some(env),
|
||||
match profile.xrservice_type {
|
||||
XRServiceType::Monado => format!("{pfx}/bin/monado-service", pfx = profile.prefix),
|
||||
XRServiceType::Wivrn => format!("{pfx}/bin/wivrn-server", pfx = profile.prefix),
|
||||
},
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn try_start(&mut self) -> Result<(), std::io::Error> {
|
||||
self.threads = Vec::new();
|
||||
let cmd = Command::new(self.command.clone())
|
||||
.args(self.args.clone())
|
||||
.envs(self.environment.clone())
|
||||
.stderr(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()?;
|
||||
self.process = Some(cmd);
|
||||
let stdout = self.process.as_mut().unwrap().stdout.take().unwrap();
|
||||
let stderr = self.process.as_mut().unwrap().stderr.take().unwrap();
|
||||
let stdout_sender = self.sender.clone();
|
||||
let stderr_sender = self.sender.clone();
|
||||
self.threads.push(logger_thread!(stdout, stdout_sender));
|
||||
self.threads.push(logger_thread!(stderr, stderr_sender));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn join_threads(&mut self) {
|
||||
loop {
|
||||
match self.threads.pop() {
|
||||
None => break,
|
||||
Some(thread) => thread.join().expect("Failed to join reader thread"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn terminate(&mut self) {
|
||||
if self.status() != RunnerStatus::Running {
|
||||
return;
|
||||
}
|
||||
let process = self.process.take();
|
||||
if process.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut proc = process.unwrap();
|
||||
let child_pid = Pid::from_raw(proc.id().try_into().expect("Could not convert pid to u32"));
|
||||
kill(child_pid, SIGTERM).expect("Could not send sigterm to process");
|
||||
self.join_threads();
|
||||
proc.wait().expect("Failed to wait for process");
|
||||
}
|
||||
|
||||
pub fn join(&mut self) {
|
||||
let process = self.process.take();
|
||||
if process.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut proc = process.unwrap();
|
||||
proc.wait().expect("Failed to wait for process");
|
||||
self.join_threads();
|
||||
}
|
||||
|
||||
fn receive_output(&mut self) {
|
||||
while let Ok(data) = self.receiver.try_recv() {
|
||||
self.output.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
fn save_log(path_s: String, log: &[String]) -> Result<(), std::io::Error> {
|
||||
let mut writer = get_writer(&path_s);
|
||||
let log_s = log.concat();
|
||||
writer.write_all(log_s.as_ref())
|
||||
}
|
||||
|
||||
pub fn save_output(&mut self, path: String) -> Result<(), std::io::Error> {
|
||||
CmdRunner::save_log(path, &self.output)
|
||||
}
|
||||
}
|
||||
|
||||
impl Runner for CmdRunner {
|
||||
fn consume_output(&mut self) -> String {
|
||||
self.receive_output();
|
||||
let res = self.output.concat();
|
||||
self.output.clear();
|
||||
res
|
||||
}
|
||||
|
||||
fn consume_rows(&mut self) -> Vec<String> {
|
||||
self.receive_output();
|
||||
let res = self.output.clone();
|
||||
self.output.clear();
|
||||
res
|
||||
}
|
||||
|
||||
fn status(&mut self) -> RunnerStatus {
|
||||
match &mut self.process {
|
||||
None => RunnerStatus::Stopped(None),
|
||||
Some(proc) => match proc.try_wait() {
|
||||
Err(_) => RunnerStatus::Running,
|
||||
Ok(Some(code)) => RunnerStatus::Stopped(code.code()),
|
||||
Ok(None) => RunnerStatus::Running,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn start(&mut self) {
|
||||
self.try_start().expect("Failed to execute runner");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{CmdRunner, RunnerStatus};
|
||||
use crate::profile::Profile;
|
||||
use crate::runner::Runner;
|
||||
use core::time;
|
||||
use std::{collections::HashMap, thread::sleep};
|
||||
|
||||
#[test]
|
||||
fn can_run_command_and_read_env() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("REX2TEST".to_string(), "Lorem ipsum dolor".to_string());
|
||||
let mut runner = CmdRunner::new(
|
||||
Some(env),
|
||||
"bash".into(),
|
||||
vec!["-c".into(), "echo \"REX2TEST: $REX2TEST\"".into()],
|
||||
);
|
||||
runner.start();
|
||||
sleep(time::Duration::from_millis(1000)); // TODO: ugly, fix
|
||||
runner.terminate();
|
||||
assert_eq!(runner.status(), RunnerStatus::Stopped(Some(0)));
|
||||
let out = runner.consume_output();
|
||||
assert_eq!(out, "REX2TEST: Lorem ipsum dolor\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_save_log() {
|
||||
let mut runner = CmdRunner::new(
|
||||
None,
|
||||
"bash".into(),
|
||||
vec!["-c".into(), "echo \"Lorem ipsum dolor sit amet\"".into()],
|
||||
);
|
||||
runner.start();
|
||||
while runner.status() == RunnerStatus::Running {
|
||||
sleep(time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
runner
|
||||
.save_output("./target/testout/testlog".into())
|
||||
.expect("Failed to save output file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn can_create_from_profile() {
|
||||
CmdRunner::xrservice_runner_from_profile(&Profile::load_profile(
|
||||
&"./test/files/profile.json".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ use crate::builders::build_mercury::get_build_mercury_runner;
|
|||
use crate::builders::build_monado::get_build_monado_runners;
|
||||
use crate::builders::build_opencomposite::get_build_opencomposite_runners;
|
||||
use crate::builders::build_wivrn::get_build_wivrn_runners;
|
||||
use crate::cmd_runner::CmdRunner;
|
||||
use crate::config::Config;
|
||||
use crate::constants::APP_NAME;
|
||||
use crate::depcheck::check_dependency;
|
||||
|
@ -32,7 +33,7 @@ use crate::profiles::lighthouse::lighthouse_profile;
|
|||
use crate::profiles::system_valve_index::system_valve_index_profile;
|
||||
use crate::profiles::valve_index::valve_index_profile;
|
||||
use crate::profiles::wivrn::wivrn_profile;
|
||||
use crate::runner::{CmdRunner, Runner, RunnerStatus};
|
||||
use crate::runner::{Runner, RunnerStatus};
|
||||
use crate::runner_pipeline::RunnerPipeline;
|
||||
use crate::ui::build_window::BuildWindowMsg;
|
||||
use crate::ui::debug_view::DebugViewInit;
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
use crate::{
|
||||
adb::get_adb_install_runner,
|
||||
cmd_runner::CmdRunner,
|
||||
depcheck::check_dependency,
|
||||
dependencies::adb_dep::adb_dep,
|
||||
downloader::download_file,
|
||||
paths::wivrn_apk_download_path,
|
||||
profile::{Profile, XRServiceType},
|
||||
runner::{CmdRunner, Runner, RunnerStatus},
|
||||
runner::{Runner, RunnerStatus},
|
||||
};
|
||||
use gtk::prelude::*;
|
||||
use relm4::{
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
use crate::{
|
||||
profile::Profile,
|
||||
runner::{CmdRunner, Runner},
|
||||
};
|
||||
use crate::{cmd_runner::CmdRunner, profile::Profile, runner::Runner};
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use relm4::prelude::*;
|
||||
|
|
Loading…
Add table
Reference in a new issue