Page MenuHomePhorge

No OneTemporary

Size
131 KB
Referenced Files
None
Subscribers
None
diff --git a/.cargo/config.toml b/.cargo/config.toml
index a787f80..30e7bb4 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -1,6 +1,8 @@
[env]
CC = "/usr/bin/clang"
CXX = "/usr/bin/clang++"
[profile.release]
strip = true
+codegen-units = 1
+opt-level = "z"
diff --git a/.gitignore b/.gitignore
index 32e03d7..1122d4f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,36 +1,37 @@
# src
build
target
tests
bin
# maid
.maid/cache
.maid/temp
.maid/server.toml
# build output
dist/
assets/
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# todo
*.todo
+*.hcl
# jetbrains
.idea
-.fleet
\ No newline at end of file
+.fleet
diff --git a/Cargo.toml b/Cargo.toml
index aba0b3c..a5f5215 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,78 +1,72 @@
[package]
name = "pmc"
version = "2.0.0"
edition = "2021"
license = "MIT"
repository = "https://lab.themackabu.dev/self/pmc"
description = "PMC is a simple and easy to use PM2 alternative"
-[profile.release]
-lto = true
-codegen-units = 16
-opt-level = "z"
-panic = "abort"
-
[build-dependencies]
tar = "0.4.40"
chrono = "0.4.31"
flate2 = "1.0.28"
cxx-build = "1.0.112"
[dependencies]
ron = "0.8.1"
log = "0.4.20"
toml = "0.8.8"
home = "0.5.9"
ryu = "1.0.16"
clap = "4.4.12"
cxx = "1.0.112"
bytes = "1.5.0"
tera = "1.19.1"
regex = "1.10.2"
libc = "0.2.151"
anyhow = "1.0.78"
colored = "2.1.0"
macros-rs = "0.5.0"
termcolor = "1.4.0"
once_cell = "1.19.0"
env_logger = "0.10.1"
merkle_hash = "3.5.0"
lazy_static = "1.4.0"
prometheus = "0.13.3"
include_dir = "0.7.3"
serde_json = "1.0.109"
simple-logging = "2.0.2"
utoipa-rapidoc = "2.0.0"
pretty_env_logger = "0.5.0"
utoipa-swagger-ui = "5.0.0"
clap-verbosity-flag = "2.1.2"
global_placeholders = "0.1.0"
tokio = { version = "1.35.1", features = ["full"] }
rocket = { version = "0.5.0", features = ["json"] }
psutil = { version = "3.2.2", features = ["serde"] }
tabled = { version = "0.15.0", features = ["ansi"] }
chrono = { version = "0.4.31", features = ["serde"] }
serde = { version = "1.0.193", features = ["derive"] }
nix = { version = "0.27.1", features = ["process", "signal"] }
utoipa = { version = "4.1.0", features = ["serde_yaml", "non_strict_integers"] }
update-informer = "1.1.0"
inquire = "0.7.5"
[dependencies.reqwest]
version = "0.11.23"
default-features = false
features = [
"blocking",
"json",
"rustls-tls",
]
[build-dependencies.reqwest]
version = "0.11.23"
default-features = false
features = [
"blocking",
"rustls-tls",
]
diff --git a/build.rs b/build.rs
index cbe98d7..b629391 100644
--- a/build.rs
+++ b/build.rs
@@ -1,168 +1,170 @@
use chrono::Datelike;
use flate2::read::GzDecoder;
use reqwest;
use tar::Archive;
use std::{
env,
fs::{self, File},
io::{self, copy},
path::{Path, PathBuf},
process::Command,
};
const NODE_VERSION: &str = "20.11.0";
fn extract_tar_gz(tar: &PathBuf, download_dir: &PathBuf) -> io::Result<()> {
let file = File::open(tar)?;
let decoder = GzDecoder::new(file);
let mut archive = Archive::new(decoder);
archive.unpack(download_dir)?;
Ok(fs::remove_file(tar)?)
}
fn download_file(url: String, destination: &PathBuf, download_dir: &PathBuf) {
if !download_dir.exists() {
fs::create_dir_all(download_dir).unwrap();
}
let mut response = reqwest::blocking::get(url).expect("Failed to send request");
let mut file = File::create(destination).expect("Failed to create file");
copy(&mut response, &mut file).expect("Failed to copy content");
}
fn download_node() -> PathBuf {
#[cfg(target_os = "linux")]
let target_os = "linux";
#[cfg(all(target_os = "macos"))]
let target_os = "darwin";
#[cfg(all(target_arch = "arm"))]
let target_arch = "armv7l";
#[cfg(all(target_arch = "x86_64"))]
let target_arch = "x64";
#[cfg(all(target_arch = "aarch64"))]
let target_arch = "arm64";
let download_url = format!("https://nodejs.org/dist/v{NODE_VERSION}/node-v{NODE_VERSION}-{target_os}-{target_arch}.tar.gz");
/* paths */
let download_dir = Path::new("target").join("downloads");
let node_extract_dir = download_dir.join(format!("node-v{NODE_VERSION}-{target_os}-{target_arch}"));
if node_extract_dir.is_dir() {
return node_extract_dir;
}
/* download node */
let node_archive = download_dir.join(format!("node-v{}-{}.tar.gz", NODE_VERSION, target_os));
download_file(download_url, &node_archive, &download_dir);
/* extract node */
if let Err(err) = extract_tar_gz(&node_archive, &download_dir) {
panic!("Failed to extract Node.js: {:?}", err)
}
println!("cargo:rustc-env=NODE_HOME={}", node_extract_dir.to_str().unwrap());
return node_extract_dir;
}
fn download_then_build(node_extract_dir: PathBuf) {
let base_dir = match fs::canonicalize(node_extract_dir) {
Ok(path) => path,
Err(err) => panic!("{err}"),
};
let bin = &base_dir.join("bin");
let node = &bin.join("node");
let project_dir = &Path::new("src").join("webui");
let npm = &base_dir.join("lib/node_modules/npm/index.js");
/* set path */
let mut paths = match env::var_os("PATH") {
Some(paths) => env::split_paths(&paths).collect::<Vec<PathBuf>>(),
None => vec![],
};
paths.push(bin.clone());
let path = match env::join_paths(paths) {
Ok(joined) => joined,
Err(err) => panic!("{err}"),
};
/* install deps */
Command::new(node)
.args([npm.to_str().unwrap(), "ci"])
.current_dir(project_dir)
.env("PATH", &path)
.status()
.expect("Failed to install dependencies");
/* build frontend */
Command::new(node)
.args(["node_modules/astro/astro.js", "build"])
.current_dir(project_dir)
.env("PATH", &path)
.status()
.expect("Failed to build frontend");
}
fn main() {
#[cfg(target_os = "windows")]
compile_error!("This project is not supported on Windows.");
#[cfg(target_arch = "x86")]
compile_error!("This project is not supported on 32 bit.");
/* version attributes */
let date = chrono::Utc::now();
let profile = env::var("PROFILE").unwrap();
let output = Command::new("git").args(&["rev-parse", "--short=10", "HEAD"]).output().unwrap();
let output_full = Command::new("git").args(&["rev-parse", "HEAD"]).output().unwrap();
println!("cargo:rustc-env=TARGET={}", env::var("TARGET").unwrap());
println!("cargo:rustc-env=GIT_HASH={}", String::from_utf8(output.stdout).unwrap());
println!("cargo:rustc-env=GIT_HASH_FULL={}", String::from_utf8(output_full.stdout).unwrap());
println!("cargo:rustc-env=BUILD_DATE={}-{}-{}", date.year(), date.month(), date.day());
/* profile matching */
match profile.as_str() {
"debug" => println!("cargo:rustc-env=PROFILE=debug"),
- "release" => println!("cargo:rustc-env=PROFILE=release"),
+ "release" => {
+ println!("cargo:rustc-env=PROFILE=release");
+
+ /* cleanup */
+ fs::remove_dir_all(format!("src/webui/dist")).ok();
+
+ /* pre-build */
+ let path = download_node();
+ download_then_build(path);
+
+ /* cc linking */
+ cxx_build::bridge("src/lib.rs")
+ .file("lib/bridge.cc")
+ .file("lib/process.cc")
+ .file("lib/fork.cc")
+ .include("lib/include")
+ .flag_if_supported("-std=c++17")
+ .compile("bridge");
+ }
_ => println!("cargo:rustc-env=PROFILE=none"),
}
- /* cleanup */
- fs::remove_dir_all(format!("src/webui/dist")).ok();
-
- /* pre-build */
- let path = download_node();
- download_then_build(path);
-
- /* cc linking */
- cxx_build::bridge("src/lib.rs")
- .file("lib/bridge.cc")
- .file("lib/process.cc")
- .file("lib/fork.cc")
- .include("lib/include")
- .flag_if_supported("-std=c++17")
- .compile("bridge");
-
let watched = vec![
"lib",
"src/lib.rs",
"lib/include",
"src/webui/src",
"src/webui/links.ts",
"src/webui/package.json",
"src/webui/tsconfig.json",
"src/webui/astro.config.mjs",
"src/webui/tailwind.config.mjs",
];
watched.iter().for_each(|file| println!("cargo:rerun-if-changed={file}"));
}
diff --git a/src/cli/internal.rs b/src/cli/internal.rs
index 986ee43..d5a871f 100644
--- a/src/cli/internal.rs
+++ b/src/cli/internal.rs
@@ -1,629 +1,646 @@
use colored::Colorize;
use macros_rs::{crashln, string, ternary, then};
use psutil::process::{MemoryInfo, Process};
use regex::Regex;
use serde::Serialize;
use serde_json::json;
use pmc::{
config, file,
helpers::{self, ColoredString},
log,
process::{http, ItemSingle, Runner},
};
use tabled::{
settings::{
object::{Columns, Rows},
style::{BorderColor, Style},
themes::Colorization,
Color, Modify, Rotate, Width,
},
Table, Tabled,
};
pub struct Internal<'i> {
pub id: usize,
pub runner: Runner,
pub kind: String,
pub server_name: &'i str,
}
impl<'i> Internal<'i> {
pub fn create(mut self, script: &String, name: &Option<String>, watch: &Option<String>) {
let config = config::read();
let name = match name {
Some(name) => string!(name),
None => string!(script.split_whitespace().next().unwrap_or_default()),
};
if matches!(self.server_name, "internal" | "local") {
let pattern = Regex::new(r"(?m)^[a-zA-Z0-9]+(/[a-zA-Z0-9]+)*(\.js|\.ts)?$").unwrap();
if pattern.is_match(script) {
let script = format!("{} {script}", config.runner.node);
self.runner.start(&name, &script, file::cwd(), watch).save();
} else {
self.runner.start(&name, script, file::cwd(), watch).save();
}
} else {
let Some(servers) = config::servers().servers else {
crashln!("{} Failed to read servers", *helpers::FAIL)
};
if let Some(server) = servers.get(self.server_name) {
match Runner::connect(self.server_name.into(), server.get(), false) {
Some(mut remote) => remote.start(&name, script, file::cwd(), watch),
None => crashln!("{} Failed to connect (name={}, address={})", *helpers::FAIL, self.server_name, server.address),
};
} else {
crashln!("{} Server '{}' does not exist", *helpers::FAIL, self.server_name,)
};
}
println!("{} Creating {}process with ({name})", *helpers::SUCCESS, self.kind);
println!("{} {}Created ({name}) ✓", *helpers::SUCCESS, self.kind);
}
pub fn restart(mut self, name: &Option<String>, watch: &Option<String>, silent: bool) -> Runner {
then!(!silent, println!("{} Applying {}action restartProcess on ({})", *helpers::SUCCESS, self.kind, self.id));
if matches!(self.server_name, "internal" | "local") {
let mut item = self.runner.get(self.id);
match watch {
Some(path) => item.watch(path),
None => item.disable_watch(),
}
name.as_ref().map(|n| item.rename(n.trim().replace("\n", "")));
item.restart();
self.runner = item.get_runner().clone();
} else {
let Some(servers) = config::servers().servers else {
crashln!("{} Failed to read servers", *helpers::FAIL)
};
if let Some(server) = servers.get(self.server_name) {
match Runner::connect(self.server_name.into(), server.get(), false) {
Some(remote) => {
let mut item = remote.get(self.id);
name.as_ref().map(|n| item.rename(n.trim().replace("\n", "")));
item.restart();
}
None => crashln!("{} Failed to connect (name={}, address={})", *helpers::FAIL, self.server_name, server.address),
}
} else {
crashln!("{} Server '{}' does not exist", *helpers::FAIL, self.server_name)
};
}
if !silent {
println!("{} Restarted {}({}) ✓", *helpers::SUCCESS, self.kind, self.id);
log!("process started (id={})", self.id);
}
return self.runner;
}
pub fn stop(mut self, silent: bool) -> Runner {
then!(!silent, println!("{} Applying {}action stopProcess on ({})", *helpers::SUCCESS, self.kind, self.id));
if !matches!(self.server_name, "internal" | "local") {
let Some(servers) = config::servers().servers else {
crashln!("{} Failed to read servers", *helpers::FAIL)
};
if let Some(server) = servers.get(self.server_name) {
self.runner = match Runner::connect(self.server_name.into(), server.get(), false) {
Some(remote) => remote,
None => crashln!("{} Failed to connect (name={}, address={})", *helpers::FAIL, self.server_name, server.address),
};
} else {
crashln!("{} Server '{}' does not exist", *helpers::FAIL, self.server_name)
};
}
let mut item = self.runner.get(self.id);
item.stop();
self.runner = item.get_runner().clone();
if !silent {
println!("{} Stopped {}({}) ✓", *helpers::SUCCESS, self.kind, self.id);
log!("process stopped {}(id={})", self.kind, self.id);
}
return self.runner;
}
pub fn remove(mut self) {
println!("{} Applying {}action removeProcess on ({})", *helpers::SUCCESS, self.kind, self.id);
if !matches!(self.server_name, "internal" | "local") {
let Some(servers) = config::servers().servers else {
crashln!("{} Failed to read servers", *helpers::FAIL)
};
if let Some(server) = servers.get(self.server_name) {
self.runner = match Runner::connect(self.server_name.into(), server.get(), false) {
Some(remote) => remote,
None => crashln!("{} Failed to remove (name={}, address={})", *helpers::FAIL, self.server_name, server.address),
};
} else {
crashln!("{} Server '{}' does not exist", *helpers::FAIL, self.server_name)
};
}
self.runner.remove(self.id);
println!("{} Removed {}({}) ✓", *helpers::SUCCESS, self.kind, self.id);
log!("process removed (id={})", self.id);
}
pub fn flush(&mut self) {
+ println!("{} Applying {}action flushLogs on ({})", *helpers::SUCCESS, self.kind, self.id);
+
+ if !matches!(self.server_name, "internal" | "local") {
+ let Some(servers) = config::servers().servers else {
+ crashln!("{} Failed to read servers", *helpers::FAIL)
+ };
+
+ if let Some(server) = servers.get(self.server_name) {
+ self.runner = match Runner::connect(self.server_name.into(), server.get(), false) {
+ Some(remote) => remote,
+ None => crashln!("{} Failed to remove (name={}, address={})", *helpers::FAIL, self.server_name, server.address),
+ };
+ } else {
+ crashln!("{} Server '{}' does not exist", *helpers::FAIL, self.server_name)
+ };
+ }
+
self.runner.flush(self.id);
- println!("{} Log Flushed {}({}) ✓", *helpers::SUCCESS, self.kind, self.id);
- log!("process log flushed (id={})", self.id);
+ println!("{} Flushed Logs {}({}) ✓", *helpers::SUCCESS, self.kind, self.id);
+ log!("process logs cleaned (id={})", self.id);
}
pub fn info(&self, format: &String) {
#[derive(Clone, Debug, Tabled)]
struct Info {
#[tabled(rename = "error log path ")]
log_error: String,
#[tabled(rename = "out log path")]
log_out: String,
#[tabled(rename = "cpu percent")]
cpu_percent: String,
#[tabled(rename = "memory usage")]
memory_usage: String,
#[tabled(rename = "path hash")]
hash: String,
#[tabled(rename = "watching")]
watch: String,
children: String,
#[tabled(rename = "exec cwd")]
path: String,
#[tabled(rename = "script command ")]
command: String,
#[tabled(rename = "script id")]
id: String,
restarts: u64,
uptime: String,
pid: String,
name: String,
status: ColoredString,
}
impl Serialize for Info {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let trimmed_json = json!({
"id": &self.id.trim(),
"pid": &self.pid.trim(),
"name": &self.name.trim(),
"path": &self.path.trim(),
"restarts": &self.restarts,
"hash": &self.hash.trim(),
"watch": &self.watch.trim(),
"children": &self.children,
"uptime": &self.uptime.trim(),
"status": &self.status.0.trim(),
"log_out": &self.log_out.trim(),
"cpu": &self.cpu_percent.trim(),
"command": &self.command.trim(),
"mem": &self.memory_usage.trim(),
"log_error": &self.log_error.trim(),
});
trimmed_json.serialize(serializer)
}
}
let render_info = |data: Vec<Info>| {
let table = Table::new(data.clone())
.with(Rotate::Left)
.with(Style::rounded().remove_horizontals())
.with(Colorization::exact([Color::FG_CYAN], Columns::first()))
.with(BorderColor::filled(Color::FG_BRIGHT_BLACK))
.to_string();
if let Ok(json) = serde_json::to_string(&data[0]) {
match format.as_str() {
"raw" => println!("{:?}", data[0]),
"json" => println!("{json}"),
_ => {
println!("{}\n{table}\n", format!("Describing {}process with id ({})", self.kind, self.id).on_bright_white().black());
println!(" {}", format!("Use `pmc logs {} [--lines <num>]` to display logs", self.id).white());
println!(" {}", format!("Use `pmc env {}` to display environment variables", self.id).white());
}
};
};
};
if matches!(self.server_name, "internal" | "local") {
if let Some(home) = home::home_dir() {
let config = config::read().runner;
let mut runner = Runner::new();
let item = runner.process(self.id);
let mut memory_usage: Option<MemoryInfo> = None;
let mut cpu_percent: Option<f32> = None;
let path = file::make_relative(&item.path, &home).to_string_lossy().into_owned();
let children = if item.children.is_empty() { "none".to_string() } else { format!("{:?}", item.children) };
if let Ok(mut process) = Process::new(item.pid as u32) {
memory_usage = process.memory_info().ok();
cpu_percent = process.cpu_percent().ok();
}
let cpu_percent = match cpu_percent {
Some(percent) => format!("{:.2}%", percent),
None => string!("0%"),
};
let memory_usage = match memory_usage {
Some(usage) => helpers::format_memory(usage.rss()),
None => string!("0b"),
};
let status = if item.running {
"online ".green().bold()
} else {
match item.crash.crashed {
true => "crashed ",
false => "stopped ",
}
.red()
.bold()
};
let data = vec![Info {
children,
cpu_percent,
memory_usage,
id: string!(self.id),
restarts: item.restarts,
name: item.name.clone(),
log_out: item.logs().out,
path: format!("{} ", path),
log_error: item.logs().error,
status: ColoredString(status),
pid: ternary!(item.running, format!("{}", item.pid), string!("n/a")),
command: format!("{} {} '{}'", config.shell, config.args.join(" "), item.script),
hash: ternary!(item.watch.enabled, format!("{} ", item.watch.hash), string!("none ")),
watch: ternary!(item.watch.enabled, format!("{path}/{} ", item.watch.path), string!("disabled ")),
uptime: ternary!(item.running, format!("{}", helpers::format_duration(item.started)), string!("none")),
}];
render_info(data)
} else {
crashln!("{} Impossible to get your home directory", *helpers::FAIL);
}
} else {
let data: (pmc::process::Process, Runner);
let Some(servers) = config::servers().servers else {
crashln!("{} Failed to read servers", *helpers::FAIL)
};
if let Some(server) = servers.get(self.server_name) {
data = match Runner::connect(self.server_name.into(), server.get(), false) {
Some(mut remote) => (remote.process(self.id).clone(), remote),
None => crashln!("{} Failed to connect (name={}, address={})", *helpers::FAIL, self.server_name, server.address),
};
} else {
crashln!("{} Server '{}' does not exist", *helpers::FAIL, self.server_name)
};
let (item, remote) = data;
let remote = remote.remote.unwrap();
let info = http::info(&remote, self.id);
let path = item.path.to_string_lossy().into_owned();
let status = if item.running {
"online ".green().bold()
} else {
match item.crash.crashed {
true => "crashed ",
false => "stopped ",
}
.red()
.bold()
};
if let Ok(info) = info {
let stats = info.json::<ItemSingle>().unwrap().stats;
let children = if item.children.is_empty() { "none".to_string() } else { format!("{:?}", item.children) };
let cpu_percent = match stats.cpu_percent {
Some(percent) => format!("{percent:.2}%"),
None => string!("0%"),
};
let memory_usage = match stats.memory_usage {
Some(usage) => helpers::format_memory(usage.rss),
None => string!("0b"),
};
let data = vec![Info {
children,
cpu_percent,
memory_usage,
id: string!(self.id),
path: path.clone(),
status: status.into(),
restarts: item.restarts,
name: item.name.clone(),
pid: ternary!(item.running, format!("{pid}", pid = item.pid), string!("n/a")),
log_out: format!("{}/{}-out.log", remote.config.log_path, item.name),
log_error: format!("{}/{}-error.log", remote.config.log_path, item.name),
hash: ternary!(item.watch.enabled, format!("{} ", item.watch.hash), string!("none ")),
command: format!("{} {} '{}'", remote.config.shell, remote.config.args.join(" "), item.script),
watch: ternary!(item.watch.enabled, format!("{path}/{} ", item.watch.path), string!("disabled ")),
uptime: ternary!(item.running, format!("{}", helpers::format_duration(item.started)), string!("none")),
}];
render_info(data)
}
}
}
pub fn logs(mut self, lines: &usize) {
if !matches!(self.server_name, "internal" | "local") {
let Some(servers) = config::servers().servers else {
crashln!("{} Failed to read servers", *helpers::FAIL)
};
if let Some(server) = servers.get(self.server_name) {
self.runner = match Runner::connect(self.server_name.into(), server.get(), false) {
Some(remote) => remote,
None => crashln!("{} Failed to connect (name={}, address={})", *helpers::FAIL, self.server_name, server.address),
};
} else {
crashln!("{} Server '{}' does not exist", *helpers::FAIL, self.server_name)
};
let item = self.runner.info(self.id).unwrap_or_else(|| crashln!("{} Process ({}) not found", *helpers::FAIL, self.id));
println!(
"{}",
format!("Showing last {lines} lines for {}process [{}] (change the value with --lines option)", self.kind, self.id).yellow()
);
for kind in vec!["error", "out"] {
let logs = http::logs(&self.runner.remote.as_ref().unwrap(), self.id, kind);
if let Ok(log) = logs {
if log.lines.is_empty() {
println!("{} No logs found for {}/{kind}", *helpers::FAIL, item.name);
continue;
}
file::logs_internal(log.lines, *lines, log.path, self.id, kind, &item.name)
}
}
} else {
let item = self.runner.info(self.id).unwrap_or_else(|| crashln!("{} Process ({}) not found", *helpers::FAIL, self.id));
println!(
"{}",
format!("Showing last {lines} lines for {}process [{}] (change the value with --lines option)", self.kind, self.id).yellow()
);
file::logs(item, *lines, "error");
file::logs(item, *lines, "out");
}
}
pub fn env(mut self) {
println!("{}", format!("Showing env for {}process {}:\n", self.kind, self.id).bright_yellow());
if !matches!(self.server_name, "internal" | "local") {
let Some(servers) = config::servers().servers else {
crashln!("{} Failed to read servers", *helpers::FAIL)
};
if let Some(server) = servers.get(self.server_name) {
self.runner = match Runner::connect(self.server_name.into(), server.get(), false) {
Some(remote) => remote,
None => crashln!("{} Failed to connect (name={}, address={})", *helpers::FAIL, self.server_name, server.address),
};
} else {
crashln!("{} Server '{}' does not exist", *helpers::FAIL, self.server_name)
};
}
let item = self.runner.process(self.id);
item.env.iter().for_each(|(key, value)| println!("{}: {}", key, value.green()));
}
pub fn save(server_name: &String) {
if !matches!(&**server_name, "internal" | "local") {
crashln!("{} Cannot force save on remote servers", *helpers::FAIL)
}
println!("{} Saved current processes to dumpfile", *helpers::SUCCESS);
Runner::new().save();
}
pub fn restore(server_name: &String) {
let mut runner = Runner::new();
let (kind, list_name) = super::format(server_name);
if !matches!(&**server_name, "internal" | "local") {
crashln!("{} Cannot restore on remote servers", *helpers::FAIL)
}
Runner::new().list().for_each(|(id, p)| {
if p.running == true {
runner = Internal {
id: *id,
server_name,
kind: kind.clone(),
runner: runner.clone(),
}
.restart(&None, &None, true);
}
});
println!("{} Restored process statuses from dumpfile", *helpers::SUCCESS);
Internal::list(&string!("default"), &list_name);
}
pub fn list(format: &String, server_name: &String) {
let render_list = |runner: &mut Runner, internal: bool| {
let mut processes: Vec<ProcessItem> = Vec::new();
#[derive(Tabled, Debug)]
struct ProcessItem {
id: ColoredString,
name: String,
pid: String,
uptime: String,
#[tabled(rename = "↺")]
restarts: String,
status: ColoredString,
cpu: String,
mem: String,
#[tabled(rename = "watching")]
watch: String,
}
impl serde::Serialize for ProcessItem {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let trimmed_json = json!({
"cpu": &self.cpu.trim(),
"mem": &self.mem.trim(),
"id": &self.id.0.trim(),
"pid": &self.pid.trim(),
"name": &self.name.trim(),
"watch": &self.watch.trim(),
"uptime": &self.uptime.trim(),
"status": &self.status.0.trim(),
"restarts": &self.restarts.trim(),
});
trimmed_json.serialize(serializer)
}
}
if runner.is_empty() {
println!("{} Process table empty", *helpers::SUCCESS);
} else {
for (id, item) in runner.items() {
let mut cpu_percent: String = string!("0%");
let mut memory_usage: String = string!("0b");
if internal {
let mut usage_internals: (Option<f32>, Option<MemoryInfo>) = (None, None);
if let Ok(mut process) = Process::new(item.pid as u32) {
usage_internals = (process.cpu_percent().ok(), process.memory_info().ok());
}
cpu_percent = match usage_internals.0 {
Some(percent) => format!("{:.0}%", percent),
None => string!("0%"),
};
memory_usage = match usage_internals.1 {
Some(usage) => helpers::format_memory(usage.rss()),
None => string!("0b"),
};
} else {
let info = http::info(&runner.remote.as_ref().unwrap(), id);
if let Ok(info) = info {
let stats = info.json::<ItemSingle>().unwrap().stats;
cpu_percent = match stats.cpu_percent {
Some(percent) => format!("{:.2}%", percent),
None => string!("0%"),
};
memory_usage = match stats.memory_usage {
Some(usage) => helpers::format_memory(usage.rss),
None => string!("0b"),
};
}
}
let status = if item.running {
"online ".green().bold()
} else {
match item.crash.crashed {
true => "crashed ",
false => "stopped ",
}
.red()
.bold()
};
processes.push(ProcessItem {
status: status.into(),
cpu: format!("{cpu_percent} "),
mem: format!("{memory_usage} "),
id: id.to_string().cyan().bold().into(),
restarts: format!("{} ", item.restarts),
name: format!("{} ", item.name.clone()),
pid: ternary!(item.running, format!("{} ", item.pid), string!("n/a ")),
watch: ternary!(item.watch.enabled, format!("{} ", item.watch.path), string!("disabled ")),
uptime: ternary!(item.running, format!("{} ", helpers::format_duration(item.started)), string!("none ")),
});
}
let table = Table::new(&processes)
.with(Style::rounded().remove_verticals())
.with(BorderColor::filled(Color::FG_BRIGHT_BLACK))
.with(Colorization::exact([Color::FG_BRIGHT_CYAN], Rows::first()))
.with(Modify::new(Columns::single(1)).with(Width::truncate(35).suffix("... ")))
.to_string();
if let Ok(json) = serde_json::to_string(&processes) {
match format.as_str() {
"raw" => println!("{:?}", processes),
"json" => println!("{json}"),
"default" => println!("{table}"),
_ => {}
};
};
}
};
if let Some(servers) = config::servers().servers {
let mut failed: Vec<(String, String)> = vec![];
if let Some(server) = servers.get(server_name) {
match Runner::connect(server_name.clone(), server.get(), true) {
Some(mut remote) => render_list(&mut remote, false),
None => println!("{} Failed to fetch (name={server_name}, address={})", *helpers::FAIL, server.address),
}
} else {
if matches!(&**server_name, "internal" | "all" | "global" | "local") {
if *server_name == "all" || *server_name == "global" {
println!("{} Internal daemon", *helpers::SUCCESS);
}
render_list(&mut Runner::new(), true);
} else {
crashln!("{} Server '{server_name}' does not exist", *helpers::FAIL);
}
}
if *server_name == "all" || *server_name == "global" {
for (name, server) in servers {
match Runner::connect(name.clone(), server.get(), true) {
Some(mut remote) => render_list(&mut remote, false),
None => failed.push((name, server.address)),
}
}
}
if !failed.is_empty() {
println!("{} Failed servers:", *helpers::FAIL);
failed
.iter()
.for_each(|server| println!(" {} {} {}", "-".yellow(), format!("{}", server.0), format!("[{}]", server.1).white()));
}
} else {
render_list(&mut Runner::new(), true);
}
}
}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 6d2553c..ae0c645 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -1,174 +1,175 @@
mod args;
pub use args::*;
pub(crate) mod internal;
pub(crate) mod server;
use internal::Internal;
use macros_rs::{crashln, string, ternary};
use pmc::{helpers, process::Runner};
use std::env;
fn format(server_name: &String) -> (String, String) {
let kind = ternary!(matches!(&**server_name, "internal" | "local"), "", "remote ").to_string();
return (kind, server_name.to_string());
}
pub fn get_version(short: bool) -> String {
return match short {
true => format!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION")),
false => match env!("GIT_HASH") {
"" => format!("{} ({}) [{}]", env!("CARGO_PKG_VERSION"), env!("BUILD_DATE"), env!("PROFILE")),
hash => format!("{} ({} {hash}) [{}]", env!("CARGO_PKG_VERSION"), env!("BUILD_DATE"), env!("PROFILE")),
},
};
}
pub fn start(name: &Option<String>, args: &Args, watch: &Option<String>, server_name: &String) {
let mut runner = Runner::new();
let (kind, list_name) = format(server_name);
let arg = match args.get_string() {
Some(arg) => arg,
None => "",
};
if arg == "all" {
println!("{} Applying {kind}action startAllProcess", *helpers::SUCCESS);
let largest = runner.size();
match largest {
Some(largest) => (0..*largest + 1).for_each(|id| {
runner = Internal {
id,
server_name,
kind: kind.clone(),
runner: runner.clone(),
}
.restart(&None, &None, true);
}),
None => println!("{} Cannot start all, no processes found", *helpers::FAIL),
}
} else {
match args {
Args::Id(id) => {
Internal { id: *id, runner, server_name, kind }.restart(name, watch, false);
}
Args::Script(script) => match runner.find(&script, server_name) {
Some(id) => {
Internal { id, runner, server_name, kind }.restart(name, watch, false);
}
None => Internal { id: 0, runner, server_name, kind }.create(script, name, watch),
},
}
}
Internal::list(&string!("default"), &list_name);
}
pub fn stop(item: &Item, server_name: &String) {
let mut runner: Runner = Runner::new();
let (kind, list_name) = format(server_name);
let arg = match item.get_string() {
Some(arg) => arg,
None => "",
};
if arg == "all" {
println!("{} Applying {kind}action stopAllProcess", *helpers::SUCCESS);
let largest = runner.size();
match largest {
Some(largest) => (0..*largest + 1).for_each(|id| {
runner = Internal {
id,
server_name,
kind: kind.clone(),
runner: runner.clone(),
}
.stop(true);
}),
None => println!("{} Cannot stop all, no processes found", *helpers::FAIL),
}
} else {
match item {
Item::Id(id) => {
Internal { id: *id, runner, server_name, kind }.stop(false);
}
Item::Name(name) => match runner.find(&name, server_name) {
Some(id) => {
Internal { id, runner, server_name, kind }.stop(false);
}
None => crashln!("{} Process ({name}) not found", *helpers::FAIL),
},
}
}
Internal::list(&string!("default"), &list_name);
}
pub fn remove(item: &Item, server_name: &String) {
let runner: Runner = Runner::new();
let (kind, _) = format(server_name);
match item {
Item::Id(id) => Internal { id: *id, runner, server_name, kind }.remove(),
Item::Name(name) => match runner.find(&name, server_name) {
Some(id) => Internal { id, runner, server_name, kind }.remove(),
None => crashln!("{} Process ({name}) not found", *helpers::FAIL),
},
}
}
pub fn info(item: &Item, format: &String, server_name: &String) {
let runner: Runner = Runner::new();
let (kind, _) = self::format(server_name);
match item {
Item::Id(id) => Internal { id: *id, runner, server_name, kind }.info(format),
Item::Name(name) => match runner.find(&name, server_name) {
Some(id) => Internal { id, runner, server_name, kind }.info(format),
None => crashln!("{} Process ({name}) not found", *helpers::FAIL),
},
}
}
pub fn logs(item: &Item, lines: &usize, server_name: &String) {
let runner: Runner = Runner::new();
let (kind, _) = format(server_name);
match item {
Item::Id(id) => Internal { id: *id, runner, server_name, kind }.logs(lines),
Item::Name(name) => match runner.find(&name, server_name) {
Some(id) => Internal { id, runner, server_name, kind }.logs(lines),
None => crashln!("{} Process ({name}) not found", *helpers::FAIL),
},
}
}
+// combine into a single function that handles multiple
pub fn env(item: &Item, server_name: &String) {
let runner: Runner = Runner::new();
let (kind, _) = format(server_name);
match item {
Item::Id(id) => Internal { id: *id, runner, server_name, kind }.env(),
Item::Name(name) => match runner.find(&name, server_name) {
Some(id) => Internal { id, runner, server_name, kind }.env(),
None => crashln!("{} Process ({name}) not found", *helpers::FAIL),
},
}
}
pub fn flush(item: &Item, server_name: &String) {
let runner: Runner = Runner::new();
let (kind, _) = format(server_name);
match item {
Item::Id(id) => Internal { id: *id, runner, server_name, kind }.flush(),
Item::Name(name) => match runner.find(&name, server_name) {
Some(id) => Internal { id, runner, server_name, kind }.flush(),
None => crashln!("{} Process ({name}) not found", *helpers::FAIL),
},
}
-}
\ No newline at end of file
+}
diff --git a/src/daemon/api/routes.rs b/src/daemon/api/routes.rs
index 384b260..e5435de 100644
--- a/src/daemon/api/routes.rs
+++ b/src/daemon/api/routes.rs
@@ -1,838 +1,839 @@
#![allow(non_snake_case)]
use chrono::{DateTime, Utc};
use global_placeholders::global;
use macros_rs::{fmtstr, string, ternary, then};
use prometheus::{Encoder, TextEncoder};
use psutil::process::{MemoryInfo, Process};
use reqwest::header::HeaderValue;
use serde::Deserialize;
use tera::{Context, Tera};
use utoipa::ToSchema;
use rocket::{
get,
http::{ContentType, Status},
post,
serde::{json::Json, Serialize},
State,
};
use super::{
helpers::{generic_error, not_found, GenericError, NotFound},
structs::ErrorMessage,
EnableWebUI, TeraState,
};
use pmc::{
config, file, helpers,
process::{dump, http::client, ItemSingle, ProcessItem, Runner},
};
use crate::daemon::{
api::{HTTP_COUNTER, HTTP_REQ_HISTOGRAM},
pid,
};
use std::{
collections::BTreeMap,
env,
fs::{self, File},
io::{self, BufRead, BufReader},
path::PathBuf,
};
pub(crate) struct Token;
type EnvList = Json<BTreeMap<String, String>>;
#[allow(dead_code)]
#[derive(ToSchema)]
#[schema(as = MemoryInfo)]
pub(crate) struct DocMemoryInfo {
rss: u64,
vms: u64,
#[cfg(target_os = "linux")]
shared: u64,
#[cfg(target_os = "linux")]
text: u64,
#[cfg(target_os = "linux")]
data: u64,
#[cfg(target_os = "macos")]
page_faults: u64,
#[cfg(target_os = "macos")]
pageins: u64,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub(crate) struct ActionBody {
#[schema(example = "restart")]
method: String,
}
#[derive(Serialize, ToSchema)]
pub(crate) struct ConfigBody {
#[schema(example = "bash")]
shell: String,
#[schema(min_items = 1, example = json!(["-c"]))]
args: Vec<String>,
#[schema(example = "/home/user/.pmc/logs")]
log_path: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub(crate) struct CreateBody {
#[schema(example = "app")]
name: Option<String>,
#[schema(example = "node index.js")]
script: String,
#[schema(value_type = String, example = "/projects/app")]
path: PathBuf,
#[schema(example = "src")]
watch: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub(crate) struct ActionResponse {
#[schema(example = true)]
done: bool,
#[schema(example = "name")]
action: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub(crate) struct LogResponse {
logs: Vec<String>,
}
#[derive(Serialize, ToSchema)]
pub struct MetricsRoot {
pub version: Version,
pub daemon: Daemon,
}
#[derive(Serialize, ToSchema)]
pub struct Version {
#[schema(example = "v1.0.0")]
pub pkg: String,
pub hash: Option<&'static str>,
#[schema(example = "2000-01-01")]
pub build_date: &'static str,
#[schema(example = "release")]
pub target: &'static str,
}
#[derive(Serialize, ToSchema)]
pub struct Daemon {
pub pid: Option<i32>,
#[schema(example = true)]
pub running: bool,
pub uptime: String,
pub process_count: usize,
#[schema(example = "default")]
pub daemon_type: String,
pub stats: Stats,
}
#[derive(Serialize, ToSchema)]
pub struct Stats {
pub memory_usage: String,
pub cpu_percent: String,
}
fn attempt(done: bool, method: &str) -> ActionResponse {
ActionResponse {
done,
action: ternary!(done, Box::leak(Box::from(method)), "DOES_NOT_EXIST").to_string(),
}
}
fn render(name: &str, tmpl: &Tera, ctx: &Context) -> Result<String, NotFound> { tmpl.render(name, &ctx).or(Err(not_found("Page was not found"))) }
#[get("/")]
pub async fn dashboard(state: &State<TeraState>, _webui: EnableWebUI) -> Result<(ContentType, String), NotFound> {
let mut ctx = Context::new();
ctx.insert("base_path", &state.path);
let payload = render("dashboard", &state.tera, &ctx)?;
Ok((ContentType::HTML, payload))
}
#[get("/login")]
pub async fn login(state: &State<TeraState>, _webui: EnableWebUI) -> Result<(ContentType, String), NotFound> {
let mut ctx = Context::new();
ctx.insert("base_path", &state.path);
let payload = render("login", &state.tera, &ctx)?;
Ok((ContentType::HTML, payload))
}
#[get("/view/<id>")]
pub async fn view_process(id: usize, state: &State<TeraState>, _webui: EnableWebUI) -> Result<(ContentType, String), NotFound> {
let mut ctx = Context::new();
ctx.insert("base_path", &state.path);
ctx.insert("process_id", &id);
let payload = render("view", &state.tera, &ctx)?;
Ok((ContentType::HTML, payload))
}
#[get("/daemon/prometheus")]
#[utoipa::path(get, tag = "Daemon", path = "/daemon/prometheus", security((), ("api_key" = [])),
responses(
(
description = "Get prometheus metrics", body = String, status = 200,
example = json!("# HELP daemon_cpu_percentage The cpu usage graph of the daemon.\n# TYPE daemon_cpu_percentage histogram\ndaemon_cpu_percentage_bucket{le=\"0.005\"} 0"),
),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn prometheus_handler(_t: Token) -> String {
let encoder = TextEncoder::new();
let mut buffer = Vec::<u8>::new();
let metric_families = prometheus::gather();
encoder.encode(&metric_families, &mut buffer).unwrap();
String::from_utf8(buffer.clone()).unwrap()
}
#[get("/daemon/servers")]
#[utoipa::path(get, tag = "Daemon", path = "/daemon/servers", security((), ("api_key" = [])),
responses(
(status = 200, description = "Get daemon servers successfully", body = [String]),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn servers_handler(_t: Token) -> Result<Json<Vec<String>>, GenericError> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["servers"]).start_timer();
if let Some(servers) = config::servers().servers {
HTTP_COUNTER.inc();
timer.observe_duration();
Ok(Json(servers.into_keys().collect()))
} else {
Err(generic_error(Status::BadRequest, string!("No servers have been added")))
}
}
#[get("/remote/<name>/list")]
#[utoipa::path(get, tag = "Remote", path = "/remote/{name}/list", security((), ("api_key" = [])),
params(("name" = String, Path, description = "Name of remote daemon", example = "example"),),
responses(
(status = 200, description = "Get list from remote daemon successfully", body = [ProcessItem]),
(status = NOT_FOUND, description = "Remote daemon does not exist", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn remote_list(name: String, _t: Token) -> Result<Json<Vec<ProcessItem>>, GenericError> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["list"]).start_timer();
if let Some(servers) = config::servers().servers {
let (address, (client, headers)) = match servers.get(&name) {
Some(server) => (&server.address, client(&server.token).await),
None => return Err(generic_error(Status::NotFound, string!("Server was not found"))),
};
HTTP_COUNTER.inc();
timer.observe_duration();
match client.get(fmtstr!("{address}/list")).headers(headers).send().await {
Ok(data) => {
if data.status() != 200 {
let err = data.json::<ErrorMessage>().await.unwrap();
Err(generic_error(err.code, err.message))
} else {
Ok(Json(data.json::<Vec<ProcessItem>>().await.unwrap()))
}
}
Err(err) => Err(generic_error(Status::InternalServerError, err.to_string())),
}
} else {
Err(generic_error(Status::BadRequest, string!("No servers have been added")))
}
}
#[get("/remote/<name>/info/<id>")]
#[utoipa::path(get, tag = "Remote", path = "/remote/{name}/info/{id}", security((), ("api_key" = [])),
params(
("name" = String, Path, description = "Name of remote daemon", example = "example"),
("id" = usize, Path, description = "Process id to get information for", example = 0)
),
responses(
(status = 200, description = "Get process info from remote daemon successfully", body = [ProcessItem]),
(status = NOT_FOUND, description = "Remote daemon does not exist", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn remote_info(name: String, id: usize, _t: Token) -> Result<Json<ItemSingle>, GenericError> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["info"]).start_timer();
if let Some(servers) = config::servers().servers {
let (address, (client, headers)) = match servers.get(&name) {
Some(server) => (&server.address, client(&server.token).await),
None => return Err(generic_error(Status::NotFound, string!("Server was not found"))),
};
HTTP_COUNTER.inc();
timer.observe_duration();
match client.get(fmtstr!("{address}/process/{id}/info")).headers(headers).send().await {
Ok(data) => {
if data.status() != 200 {
let err = data.json::<ErrorMessage>().await.unwrap();
Err(generic_error(err.code, err.message))
} else {
Ok(Json(data.json::<ItemSingle>().await.unwrap()))
}
}
Err(err) => Err(generic_error(Status::InternalServerError, err.to_string())),
}
} else {
Err(generic_error(Status::BadRequest, string!("No servers have been added")))
}
}
#[get("/remote/<name>/logs/<id>/<kind>")]
#[utoipa::path(get, tag = "Remote", path = "/remote/{name}/logs/{id}/{kind}", security((), ("api_key" = [])),
params(
("name" = String, Path, description = "Name of remote daemon", example = "example"),
("id" = usize, Path, description = "Process id to get information for", example = 0),
("kind" = String, Path, description = "Log output type", example = "out")
),
responses(
(status = 200, description = "Remote process logs of {type} fetched", body = LogResponse),
(status = NOT_FOUND, description = "Remote daemon does not exist", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn remote_logs(name: String, id: usize, kind: String, _t: Token) -> Result<Json<LogResponse>, GenericError> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["info"]).start_timer();
if let Some(servers) = config::servers().servers {
let (address, (client, headers)) = match servers.get(&name) {
Some(server) => (&server.address, client(&server.token).await),
None => return Err(generic_error(Status::NotFound, string!("Server was not found"))),
};
HTTP_COUNTER.inc();
timer.observe_duration();
match client.get(fmtstr!("{address}/process/{id}/logs/{kind}")).headers(headers).send().await {
Ok(data) => {
if data.status() != 200 {
let err = data.json::<ErrorMessage>().await.unwrap();
Err(generic_error(err.code, err.message))
} else {
Ok(Json(data.json::<LogResponse>().await.unwrap()))
}
}
Err(err) => Err(generic_error(Status::InternalServerError, err.to_string())),
}
} else {
Err(generic_error(Status::BadRequest, string!("No servers have been added")))
}
}
#[post("/remote/<name>/rename/<id>", format = "text", data = "<body>")]
#[utoipa::path(post, tag = "Remote", path = "/remote/{name}/rename/{id}",
security((), ("api_key" = [])),
request_body(content = String, example = json!("example_name")),
params(
("id" = usize, Path, description = "Process id to rename", example = 0),
("name" = String, Path, description = "Name of remote daemon", example = "example"),
),
responses(
(
description = "Remote rename process successful", body = ActionResponse,
example = json!({"action": "rename", "done": true }), status = 200,
),
(status = NOT_FOUND, description = "Process was not found", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn remote_rename(name: String, id: usize, body: String, _t: Token) -> Result<Json<ActionResponse>, GenericError> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["rename"]).start_timer();
if let Some(servers) = config::servers().servers {
let (address, (client, mut headers)) = match servers.get(&name) {
Some(server) => (&server.address, client(&server.token).await),
None => return Err(generic_error(Status::NotFound, string!("Server was not found"))),
};
HTTP_COUNTER.inc();
timer.observe_duration();
headers.insert("content-type", HeaderValue::from_static("text/plain"));
match client.post(fmtstr!("{address}/process/{id}/rename")).body(body).headers(headers).send().await {
Ok(data) => {
if data.status() != 200 {
let err = data.json::<ErrorMessage>().await.unwrap();
Err(generic_error(err.code, err.message))
} else {
Ok(Json(data.json::<ActionResponse>().await.unwrap()))
}
}
Err(err) => Err(generic_error(Status::InternalServerError, err.to_string())),
}
} else {
Err(generic_error(Status::BadRequest, string!("No servers have been added")))
}
}
#[post("/remote/<name>/action/<id>", format = "json", data = "<body>")]
#[utoipa::path(post, tag = "Remote", path = "/remote/{name}/action/{id}", request_body = ActionBody,
security((), ("api_key" = [])),
params(
("id" = usize, Path, description = "Process id to run action on", example = 0),
("name" = String, Path, description = "Name of remote daemon", example = "example")
),
responses(
(status = 200, description = "Run action on remote process successful", body = ActionResponse),
(status = NOT_FOUND, description = "Process/action was not found", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn remote_action(name: String, id: usize, body: Json<ActionBody>, _t: Token) -> Result<Json<ActionResponse>, GenericError> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["action"]).start_timer();
if let Some(servers) = config::servers().servers {
let (address, (client, headers)) = match servers.get(&name) {
Some(server) => (&server.address, client(&server.token).await),
None => return Err(generic_error(Status::NotFound, string!("Server was not found"))),
};
HTTP_COUNTER.inc();
timer.observe_duration();
match client.post(fmtstr!("{address}/process/{id}/action")).json(&body.0).headers(headers).send().await {
Ok(data) => {
if data.status() != 200 {
let err = data.json::<ErrorMessage>().await.unwrap();
Err(generic_error(err.code, err.message))
} else {
Ok(Json(data.json::<ActionResponse>().await.unwrap()))
}
}
Err(err) => Err(generic_error(Status::InternalServerError, err.to_string())),
}
} else {
Err(generic_error(Status::BadRequest, string!("No servers have been added")))
}
}
#[get("/daemon/dump")]
#[utoipa::path(get, tag = "Daemon", path = "/daemon/dump", security((), ("api_key" = [])),
responses(
(status = 200, description = "Dump processes successfully", body = [u8]),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn dump_handler(_t: Token) -> Vec<u8> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["dump"]).start_timer();
HTTP_COUNTER.inc();
timer.observe_duration();
dump::raw()
}
#[get("/daemon/config")]
#[utoipa::path(get, tag = "Daemon", path = "/daemon/config", security((), ("api_key" = [])),
responses(
(status = 200, description = "Get daemon config successfully", body = ConfigBody),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn config_handler(_t: Token) -> Json<ConfigBody> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["dump"]).start_timer();
let config = config::read().runner;
HTTP_COUNTER.inc();
timer.observe_duration();
Json(ConfigBody {
shell: config.shell,
args: config.args,
log_path: config.log_path,
})
}
#[get("/list")]
#[utoipa::path(get, path = "/list", tag = "Process", security((), ("api_key" = [])),
responses(
(status = 200, description = "List processes successfully", body = [ProcessItem]),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn list_handler(_t: Token) -> Json<Vec<ProcessItem>> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["list"]).start_timer();
let data = Runner::new().fetch();
HTTP_COUNTER.inc();
timer.observe_duration();
Json(data)
}
#[get("/process/<id>/logs/<kind>")]
#[utoipa::path(get, tag = "Process", path = "/process/{id}/logs/{kind}",
security((), ("api_key" = [])),
params(
("id" = usize, Path, description = "Process id to get logs for", example = 0),
("kind" = String, Path, description = "Log output type", example = "out")
),
responses(
(status = 200, description = "Process logs of {type} fetched", body = LogResponse),
(status = NOT_FOUND, description = "Process was not found", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn logs_handler(id: usize, kind: String, _t: Token) -> Result<Json<LogResponse>, NotFound> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["log"]).start_timer();
HTTP_COUNTER.inc();
match Runner::new().info(id) {
Some(item) => {
let log_file = match kind.as_str() {
"out" | "stdout" => item.logs().out,
"error" | "stderr" => item.logs().error,
_ => item.logs().out,
};
match File::open(log_file) {
Ok(data) => {
let reader = BufReader::new(data);
let logs: Vec<String> = reader.lines().collect::<io::Result<_>>().unwrap();
timer.observe_duration();
Ok(Json(LogResponse { logs }))
}
Err(_) => Ok(Json(LogResponse { logs: vec![] })),
}
}
None => {
timer.observe_duration();
Err(not_found("Process was not found"))
}
}
}
#[get("/process/<id>/logs/<kind>/raw")]
#[utoipa::path(get, tag = "Process", path = "/process/{id}/logs/{kind}/raw",
security((), ("api_key" = [])),
params(
("id" = usize, Path, description = "Process id to get logs for", example = 0),
("kind" = String, Path, description = "Log output type", example = "out")
),
responses(
(
description = "Process logs of {type} fetched raw", body = String, status = 200,
example = json!("# PATH path/of/file.log\nserver started on port 3000")
),
(status = NOT_FOUND, description = "Process was not found", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn logs_raw_handler(id: usize, kind: String, _t: Token) -> Result<String, NotFound> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["log"]).start_timer();
HTTP_COUNTER.inc();
match Runner::new().info(id) {
Some(item) => {
let log_file = match kind.as_str() {
"out" | "stdout" => item.logs().out,
"error" | "stderr" => item.logs().error,
_ => item.logs().out,
};
let data = match fs::read_to_string(&log_file) {
Ok(data) => format!("# PATH {log_file}\n{data}"),
Err(err) => err.to_string(),
};
timer.observe_duration();
Ok(data)
}
None => {
timer.observe_duration();
Err(not_found("Process was not found"))
}
}
}
#[get("/process/<id>/info")]
#[utoipa::path(get, tag = "Process", path = "/process/{id}/info", security((), ("api_key" = [])),
params(("id" = usize, Path, description = "Process id to get information for", example = 0)),
responses(
(status = 200, description = "Current process info retrieved", body = ItemSingle),
(status = NOT_FOUND, description = "Process was not found", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn info_handler(id: usize, _t: Token) -> Result<Json<ItemSingle>, NotFound> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["info"]).start_timer();
let runner = Runner::new();
if runner.exists(id) {
let item = runner.get(id);
HTTP_COUNTER.inc();
timer.observe_duration();
Ok(Json(item.fetch()))
} else {
Err(not_found("Process was not found"))
}
}
#[post("/process/create", format = "json", data = "<body>")]
#[utoipa::path(post, tag = "Process", path = "/process/create", request_body(content = CreateBody),
security((), ("api_key" = [])),
responses(
(
description = "Create process successful", body = ActionResponse,
example = json!({"action": "create", "done": true }), status = 200,
),
(status = INTERNAL_SERVER_ERROR, description = "Failed to create process", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn create_handler(body: Json<CreateBody>, _t: Token) -> Result<Json<ActionResponse>, ()> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["create"]).start_timer();
let mut runner = Runner::new();
HTTP_COUNTER.inc();
let name = match &body.name {
Some(name) => string!(name),
None => string!(body.script.split_whitespace().next().unwrap_or_default()),
};
runner.start(&name, &body.script, body.path.clone(), &body.watch).save();
timer.observe_duration();
Ok(Json(attempt(true, "create")))
}
#[post("/process/<id>/rename", format = "text", data = "<body>")]
#[utoipa::path(post, tag = "Process", path = "/process/{id}/rename",
security((), ("api_key" = [])),
request_body(content = String, example = json!("example_name")),
params(("id" = usize, Path, description = "Process id to rename", example = 0)),
responses(
(
description = "Rename process successful", body = ActionResponse,
example = json!({"action": "rename", "done": true }), status = 200,
),
(status = NOT_FOUND, description = "Process was not found", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn rename_handler(id: usize, body: String, _t: Token) -> Result<Json<ActionResponse>, NotFound> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["rename"]).start_timer();
let runner = Runner::new();
match runner.clone().info(id) {
Some(process) => {
HTTP_COUNTER.inc();
let mut item = runner.get(id);
item.rename(body.trim().replace("\n", ""));
then!(process.running, item.restart());
timer.observe_duration();
Ok(Json(attempt(true, "rename")))
}
None => {
timer.observe_duration();
Err(not_found("Process was not found"))
}
}
}
#[get("/process/<id>/env")]
#[utoipa::path(get, tag = "Process", path = "/process/{id}/env",
params(("id" = usize, Path, description = "Process id to fetch env from", example = 0)),
responses(
(
description = "Current process env", body = HashMap<String, String>,
example = json!({"ENV_TEST_VALUE": "example_value"}), status = 200
),
(status = NOT_FOUND, description = "Process was not found", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn env_handler(id: usize, _t: Token) -> Result<EnvList, NotFound> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["env"]).start_timer();
HTTP_COUNTER.inc();
match Runner::new().info(id) {
Some(item) => {
timer.observe_duration();
Ok(Json(item.clone().env))
}
None => {
timer.observe_duration();
Err(not_found("Process was not found"))
}
}
}
#[post("/process/<id>/action", format = "json", data = "<body>")]
#[utoipa::path(post, tag = "Process", path = "/process/{id}/action", request_body = ActionBody,
security((), ("api_key" = [])),
params(("id" = usize, Path, description = "Process id to run action on", example = 0)),
responses(
(status = 200, description = "Run action on process successful", body = ActionResponse),
(status = NOT_FOUND, description = "Process/action was not found", body = ErrorMessage),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn action_handler(id: usize, body: Json<ActionBody>, _t: Token) -> Result<Json<ActionResponse>, NotFound> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["action"]).start_timer();
let mut runner = Runner::new();
let method = body.method.as_str();
if runner.exists(id) {
HTTP_COUNTER.inc();
match method {
"start" | "restart" => {
runner.get(id).restart();
timer.observe_duration();
Ok(Json(attempt(true, method)))
}
"stop" | "kill" => {
runner.get(id).stop();
timer.observe_duration();
Ok(Json(attempt(true, method)))
}
"remove" | "delete" => {
runner.remove(id);
timer.observe_duration();
Ok(Json(attempt(true, method)))
}
- "flush" => {
+ "flush" | "clean" => {
runner.flush(id);
+ timer.observe_duration();
Ok(Json(attempt(true, method)))
- },
+ }
_ => {
timer.observe_duration();
- Err(not_found("Process was not found"))
+ Err(not_found("Invalid action attempt"))
}
}
} else {
Err(not_found("Process was not found"))
}
}
#[get("/daemon/metrics")]
#[utoipa::path(get, tag = "Daemon", path = "/daemon/metrics", security((), ("api_key" = [])),
responses(
(status = 200, description = "Get daemon metrics", body = MetricsRoot),
(
status = UNAUTHORIZED, description = "Authentication failed or not provided", body = ErrorMessage,
example = json!({"code": 401, "message": "Unauthorized"})
)
)
)]
pub async fn metrics_handler(_t: Token) -> Json<MetricsRoot> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["metrics"]).start_timer();
let mut pid: Option<i32> = None;
let mut cpu_percent: Option<f32> = None;
let mut uptime: Option<DateTime<Utc>> = None;
let mut memory_usage: Option<MemoryInfo> = None;
let mut runner: Runner = file::read_object(global!("pmc.dump"));
HTTP_COUNTER.inc();
if pid::exists() {
if let Ok(process_id) = pid::read() {
if let Ok(mut process) = Process::new(process_id as u32) {
pid = Some(process_id);
uptime = Some(pid::uptime().unwrap());
memory_usage = process.memory_info().ok();
cpu_percent = process.cpu_percent().ok();
}
}
}
let memory_usage = match memory_usage {
Some(usage) => helpers::format_memory(usage.rss()),
None => string!("0b"),
};
let cpu_percent = match cpu_percent {
Some(percent) => format!("{:.2}%", percent),
None => string!("0%"),
};
let uptime = match uptime {
Some(uptime) => helpers::format_duration(uptime),
None => string!("none"),
};
timer.observe_duration();
Json(MetricsRoot {
version: Version {
target: env!("PROFILE"),
build_date: env!("BUILD_DATE"),
pkg: format!("v{}", env!("CARGO_PKG_VERSION")),
hash: ternary!(env!("GIT_HASH_FULL") == "", None, Some(env!("GIT_HASH_FULL"))),
},
daemon: Daemon {
pid,
uptime,
running: pid::exists(),
process_count: runner.count(),
daemon_type: global!("pmc.daemon.kind"),
stats: Stats { memory_usage, cpu_percent },
},
})
}
diff --git a/src/main.rs b/src/main.rs
index d34660b..7de5302 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,257 +1,256 @@
mod cli;
mod daemon;
mod globals;
mod webui;
use clap::{Parser, Subcommand};
use clap_verbosity_flag::{LogLevel, Verbosity};
use macros_rs::{str, string, then};
use update_informer::{registry, Check};
use crate::{
cli::{internal::Internal, Args, Item},
globals::defaults,
};
#[derive(Copy, Clone, Debug, Default)]
struct NoneLevel;
impl LogLevel for NoneLevel {
fn default() -> Option<log::Level> { None }
}
#[derive(Parser)]
#[command(version = str!(cli::get_version(false)))]
struct Cli {
#[command(subcommand)]
command: Commands,
#[clap(flatten)]
verbose: Verbosity<NoneLevel>,
}
#[derive(Subcommand)]
enum Daemon {
/// Reset process index
- #[command(visible_alias = "clean")]
+ #[command(visible_alias = "reset_position")]
Reset,
/// Stop daemon
#[command(visible_alias = "kill")]
Stop,
/// Restart daemon
#[command(visible_alias = "restart", visible_alias = "start")]
Restore {
/// Daemon api
#[arg(long)]
api: bool,
/// WebUI using api
#[arg(long)]
webui: bool,
},
/// Check daemon health
#[command(visible_alias = "info", visible_alias = "status")]
Health {
/// Format output
#[arg(long, default_value_t = string!("default"))]
format: String,
},
}
#[derive(Subcommand)]
enum Server {
/// Add new server
#[command(visible_alias = "add")]
New,
/// List servers
#[command(visible_alias = "ls")]
List {
/// Format output
#[arg(long, default_value_t = string!("default"))]
format: String,
},
/// Remove server
#[command(visible_alias = "rm", visible_alias = "delete")]
Remove {
/// Server name
name: String,
},
/// Set default server
#[command(visible_alias = "set")]
Default {
/// Server name
name: Option<String>,
},
}
// add pmc restore command
#[derive(Subcommand)]
enum Commands {
/// Start/Restart a process
#[command(visible_alias = "restart")]
Start {
/// Process name
#[arg(long)]
name: Option<String>,
#[clap(value_parser = cli::validate::<Args>)]
args: Args,
/// Watch to reload path
#[arg(long)]
watch: Option<String>,
/// Server
#[arg(short, long)]
server: Option<String>,
},
/// Stop/Kill a process
#[command(visible_alias = "kill")]
Stop {
#[clap(value_parser = cli::validate::<Item>)]
item: Item,
/// Server
#[arg(short, long)]
server: Option<String>,
},
/// Stop then remove a process
#[command(visible_alias = "rm", visible_alias = "delete")]
Remove {
#[clap(value_parser = cli::validate::<Item>)]
item: Item,
/// Server
#[arg(short, long)]
server: Option<String>,
},
/// Get env of a process
#[command(visible_alias = "cmdline")]
Env {
#[clap(value_parser = cli::validate::<Item>)]
item: Item,
/// Server
#[arg(short, long)]
server: Option<String>,
},
/// Get information of a process
#[command(visible_alias = "info")]
Details {
#[clap(value_parser = cli::validate::<Item>)]
item: Item,
/// Format output
#[arg(long, default_value_t = string!("default"))]
format: String,
/// Server
#[arg(short, long)]
server: Option<String>,
},
/// List all processes
#[command(visible_alias = "ls")]
List {
/// Format output
#[arg(long, default_value_t = string!("default"))]
format: String,
/// Server
#[arg(short, long)]
server: Option<String>,
},
/// Restore all processes
#[command(visible_alias = "resurrect")]
Restore {
/// Server
#[arg(short, long)]
server: Option<String>,
},
/// Save all processes to dumpfile
#[command(visible_alias = "store")]
Save {
/// Server
#[arg(short, long)]
server: Option<String>,
},
/// Get logs from a process
Logs {
#[clap(value_parser = cli::validate::<Item>)]
item: Item,
#[arg(long, default_value_t = 15, help = "")]
lines: usize,
/// Server
#[arg(short, long)]
server: Option<String>,
},
/// Flush a process log
- #[command(visible_alias = "fl")]
+ #[command(visible_alias = "clean", visible_alias = "log_rotate")]
Flush {
#[clap(value_parser = cli::validate::<Item>)]
item: Item,
/// Server
#[arg(short, long)]
server: Option<String>,
},
-
/// Daemon management
#[command(visible_alias = "agent", visible_alias = "bgd")]
Daemon {
#[command(subcommand)]
command: Daemon,
},
/// Server management
#[command(visible_alias = "remote", visible_alias = "srv")]
Server {
#[command(subcommand)]
command: Server,
},
}
fn main() {
let cli = Cli::parse();
let mut env = env_logger::Builder::new();
let level = cli.verbose.log_level_filter();
let informer = update_informer::new(registry::Crates, "pmc", env!("CARGO_PKG_VERSION"));
if let Some(version) = informer.check_version().ok().flatten() {
println!("{} New version is available: {version}", *pmc::helpers::WARN);
}
globals::init();
env.filter_level(level).init();
match &cli.command {
Commands::Start { name, args, watch, server } => cli::start(name, args, watch, &defaults(server)),
Commands::Stop { item, server } => cli::stop(item, &defaults(server)),
Commands::Remove { item, server } => cli::remove(item, &defaults(server)),
Commands::Restore { server } => Internal::restore(&defaults(server)),
Commands::Save { server } => Internal::save(&defaults(server)),
Commands::Env { item, server } => cli::env(item, &defaults(server)),
Commands::Details { item, format, server } => cli::info(item, format, &defaults(server)),
Commands::List { format, server } => Internal::list(format, &defaults(server)),
Commands::Logs { item, lines, server } => cli::logs(item, lines, &defaults(server)),
Commands::Flush { item, server } => cli::flush(item, &defaults(server)),
Commands::Daemon { command } => match command {
Daemon::Stop => daemon::stop(),
Daemon::Reset => daemon::reset(),
Daemon::Health { format } => daemon::health(format),
Daemon::Restore { api, webui } => daemon::restart(api, webui, level.as_str() != "OFF"),
},
Commands::Server { command } => match command {
Server::New => cli::server::new(),
Server::Remove { name } => cli::server::remove(name),
Server::Default { name } => cli::server::default(name),
Server::List { format } => cli::server::list(format, cli.verbose.log_level()),
},
};
if !matches!(&cli.command, Commands::Daemon { .. })
&& !matches!(&cli.command, Commands::Server { .. })
&& !matches!(&cli.command, Commands::Save { .. })
&& !matches!(&cli.command, Commands::Env { .. })
{
then!(!daemon::pid::exists(), daemon::restart(&false, &false, false));
}
}
diff --git a/src/process/http.rs b/src/process/http.rs
index cbd8542..ffd04c1 100644
--- a/src/process/http.rs
+++ b/src/process/http.rs
@@ -1,108 +1,109 @@
use crate::process::Remote;
use macros_rs::{fmtstr, string};
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::Client;
use serde::Serialize;
use std::path::PathBuf;
#[derive(Serialize)]
struct ActionBody {
pub method: String,
}
pub struct LogResponse {
pub path: &'static str,
pub lines: Vec<String>,
}
#[derive(Serialize)]
struct CreateBody<'c> {
pub name: &'c String,
pub script: &'c String,
pub path: PathBuf,
pub watch: &'c Option<String>,
}
pub mod sync {
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue};
pub use reqwest::blocking::Response;
pub fn client(token: &Option<String>) -> (Client, HeaderMap) {
let client = Client::new();
let mut headers = HeaderMap::new();
if let Some(token) = token {
headers.insert("token", HeaderValue::from_str(&token).unwrap());
}
return (client, headers);
}
}
pub async fn client(token: &Option<String>) -> (Client, HeaderMap) {
let client = Client::new();
let mut headers = HeaderMap::new();
if let Some(token) = token {
headers.insert("token", HeaderValue::from_str(&token).unwrap());
}
return (client, headers);
}
pub fn info(Remote { address, token, .. }: &Remote, id: usize) -> Result<sync::Response, anyhow::Error> {
let (client, headers) = sync::client(token);
Ok(client.get(fmtstr!("{address}/process/{id}/info")).headers(headers).send()?)
}
pub fn logs(Remote { address, token, .. }: &Remote, id: usize, kind: &str) -> Result<LogResponse, anyhow::Error> {
let (client, headers) = sync::client(token);
let response = client.get(fmtstr!("{address}/process/{id}/logs/{kind}/raw")).headers(headers).send()?;
let log = response.text()?;
Ok(LogResponse {
lines: log.lines().skip(1).map(|line| line.to_string()).collect::<Vec<String>>(),
path: Box::leak(Box::from(log.lines().next().unwrap_or("").split_whitespace().last().unwrap_or(""))),
})
}
pub fn create(Remote { address, token, .. }: &Remote, name: &String, script: &String, path: PathBuf, watch: &Option<String>) -> Result<sync::Response, anyhow::Error> {
let (client, headers) = sync::client(token);
let content = CreateBody { name, script, path, watch };
Ok(client.post(fmtstr!("{address}/process/create")).json(&content).headers(headers).send()?)
}
pub fn restart(Remote { address, token, .. }: &Remote, id: usize) -> Result<sync::Response, anyhow::Error> {
let (client, headers) = sync::client(token);
let content = ActionBody { method: string!("restart") };
Ok(client.post(fmtstr!("{address}/process/{id}/action")).json(&content).headers(headers).send()?)
}
pub fn rename(Remote { address, token, .. }: &Remote, id: usize, name: String) -> Result<sync::Response, anyhow::Error> {
let (client, headers) = sync::client(token);
Ok(client.post(fmtstr!("{address}/process/{id}/rename")).body(name).headers(headers).send()?)
}
+// merge into one function
pub fn stop(Remote { address, token, .. }: &Remote, id: usize) -> Result<sync::Response, anyhow::Error> {
let (client, headers) = sync::client(token);
let content = ActionBody { method: string!("stop") };
Ok(client.post(fmtstr!("{address}/process/{id}/action")).json(&content).headers(headers).send()?)
}
pub fn remove(Remote { address, token, .. }: &Remote, id: usize) -> Result<sync::Response, anyhow::Error> {
let (client, headers) = sync::client(token);
let content = ActionBody { method: string!("remove") };
Ok(client.post(fmtstr!("{address}/process/{id}/action")).json(&content).headers(headers).send()?)
}
-pub fn flush(Remote { address, token, ..}: &Remote, id: usize) -> Result<sync::Response, anyhow::Error> {
+pub fn flush(Remote { address, token, .. }: &Remote, id: usize) -> Result<sync::Response, anyhow::Error> {
let (client, headers) = sync::client(token);
let content = ActionBody { method: string!("flush") };
Ok(client.post(fmtstr!("{address}/process/{id}/action")).json(&content).headers(headers).send()?)
-}
\ No newline at end of file
+}
diff --git a/src/process/mod.rs b/src/process/mod.rs
index 2cf3f3b..cc9dc5d 100644
--- a/src/process/mod.rs
+++ b/src/process/mod.rs
@@ -1,627 +1,627 @@
mod unix;
use crate::{
config,
config::structs::Server,
file, helpers,
service::{run, stop, ProcessMetadata},
};
use std::{
env,
+ fs::File,
path::PathBuf,
sync::{Arc, Mutex},
};
use nix::{
sys::signal::{kill, Signal},
unistd::Pid,
};
use chrono::serde::ts_milliseconds;
use chrono::{DateTime, Utc};
use global_placeholders::global;
use macros_rs::{crashln, string, ternary, then};
use psutil::process;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use utoipa::ToSchema;
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ItemSingle {
pub info: Info,
pub stats: Stats,
pub watch: Watch,
pub log: Log,
pub raw: Raw,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Info {
pub id: usize,
pub pid: i64,
pub name: String,
pub status: String,
#[schema(value_type = String, example = "/path")]
pub path: PathBuf,
pub uptime: String,
pub command: String,
pub children: Vec<i64>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Stats {
pub restarts: u64,
pub start_time: i64,
pub cpu_percent: Option<f32>,
pub memory_usage: Option<MemoryInfo>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct MemoryInfo {
pub rss: u64,
pub vms: u64,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Log {
pub out: String,
pub error: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct Raw {
pub running: bool,
pub crashed: bool,
pub crashes: u64,
}
#[derive(Clone)]
pub struct LogInfo {
pub out: String,
pub error: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct ProcessItem {
pid: i64,
id: usize,
cpu: String,
mem: String,
name: String,
restarts: u64,
status: String,
uptime: String,
#[schema(example = "/path")]
watch_path: String,
#[schema(value_type = String, example = "2000-01-01T01:00:00.000Z")]
start_time: DateTime<Utc>,
}
#[derive(Clone)]
pub struct ProcessWrapper {
pub id: usize,
pub runner: Arc<Mutex<Runner>>,
}
type Env = BTreeMap<String, String>;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Process {
pub id: usize,
pub pid: i64,
pub env: Env,
pub name: String,
pub path: PathBuf,
pub script: String,
pub restarts: u64,
pub running: bool,
pub crash: Crash,
pub watch: Watch,
pub children: Vec<i64>,
#[serde(with = "ts_milliseconds")]
pub started: DateTime<Utc>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Crash {
pub crashed: bool,
pub value: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize, ToSchema)]
pub struct Watch {
pub enabled: bool,
#[schema(example = "/path")]
pub path: String,
pub hash: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Runner {
pub id: id::Id,
#[serde(skip)]
pub remote: Option<Remote>,
pub list: BTreeMap<usize, Process>,
}
#[derive(Clone, Debug)]
pub struct Remote {
address: String,
token: Option<String>,
pub config: RemoteConfig,
}
#[derive(Clone, Debug, Deserialize)]
pub struct RemoteConfig {
pub shell: String,
pub args: Vec<String>,
pub log_path: String,
}
pub enum Status {
Offline,
Running,
}
impl Status {
pub fn to_bool(&self) -> bool {
match self {
Status::Offline => false,
Status::Running => true,
}
}
}
-impl LogInfo {
- pub fn flush(&self) {
- if let Err(err) = std::fs::remove_file(&self.out) {
- crashln!("Failed to remove log {0} file: {err}", self.out);
- }
-
- if let Err(err) = std::fs::remove_file(&self.error) {
- crashln!("Failed to remove log {0} file: {err}", self.error);
- }
- }
-}
-
macro_rules! lock {
($runner:expr) => {{
match $runner.lock() {
Ok(runner) => runner,
Err(err) => crashln!("Unable to lock mutex: {err}"),
}
}};
}
fn kill_children(children: Vec<i64>) {
for pid in children {
if let Err(err) = kill(Pid::from_raw(pid as i32), Signal::SIGTERM) {
log::error!("Failed to stop pid {pid}: {err:?}");
};
}
}
impl Runner {
pub fn new() -> Self { dump::read() }
pub fn connect(name: String, Server { address, token }: Server, verbose: bool) -> Option<Self> {
let remote_config = match config::from(&address, token.as_deref()) {
Ok(config) => config,
Err(err) => {
log::error!("{err}");
return None;
}
};
if let Ok(dump) = dump::from(&address, token.as_deref()) {
then!(verbose, println!("{} Fetched remote (name={name}, address={address})", *helpers::SUCCESS));
Some(Runner {
remote: Some(Remote {
token,
address: string!(address),
config: remote_config,
}),
..dump
})
} else {
None
}
}
pub fn start(&mut self, name: &String, command: &String, path: PathBuf, watch: &Option<String>) -> &mut Self {
if let Some(remote) = &self.remote {
if let Err(err) = http::create(remote, name, command, path, watch) {
crashln!("{} Failed to start create {name}\nError: {:#?}", *helpers::FAIL, err);
};
} else {
let id = self.id.next();
let config = config::read().runner;
let crash = Crash { crashed: false, value: 0 };
let watch = match watch {
Some(watch) => Watch {
enabled: true,
path: string!(watch),
hash: hash::create(file::cwd().join(watch)),
},
None => Watch {
enabled: false,
path: string!(""),
hash: string!(""),
},
};
let pid = run(ProcessMetadata {
args: config.args,
name: name.clone(),
shell: config.shell,
command: command.clone(),
log_path: config.log_path,
env: unix::env(),
});
self.list.insert(
id,
Process {
id,
pid,
path,
watch,
crash,
restarts: 0,
running: true,
children: vec![],
name: name.clone(),
started: Utc::now(),
script: command.clone(),
env: env::vars().collect(),
},
);
}
return self;
}
pub fn restart(&mut self, id: usize, dead: bool) -> &mut Self {
if let Some(remote) = &self.remote {
if let Err(err) = http::restart(remote, id) {
crashln!("{} Failed to start process {id}\nError: {:#?}", *helpers::FAIL, err);
};
} else {
let process = self.process(id);
let config = config::read().runner;
let Process { path, script, name, .. } = process.clone();
kill_children(process.children.clone());
stop(process.pid);
if let Err(err) = std::env::set_current_dir(&path) {
crashln!("{} Failed to set working directory {:?}\nError: {:#?}", *helpers::FAIL, path, err);
};
process.pid = run(ProcessMetadata {
args: config.args,
name: name.clone(),
shell: config.shell,
log_path: config.log_path,
command: script.to_string(),
env: unix::env(),
});
process.running = true;
process.children = vec![];
process.started = Utc::now();
process.crash.crashed = false;
process.env = env::vars().collect();
then!(dead, process.restarts += 1);
then!(dead, process.crash.value += 1);
then!(!dead, process.crash.value = 0);
}
return self;
}
pub fn remove(&mut self, id: usize) {
if let Some(remote) = &self.remote {
if let Err(err) = http::remove(remote, id) {
crashln!("{} Failed to stop remove {id}\nError: {:#?}", *helpers::FAIL, err);
};
} else {
self.stop(id);
self.list.remove(&id);
self.save();
}
}
pub fn set_id(&mut self, id: id::Id) {
self.id = id;
self.id.next();
self.save();
}
pub fn set_status(&mut self, id: usize, status: Status) {
self.process(id).running = status.to_bool();
self.save();
}
pub fn items(&self) -> BTreeMap<usize, Process> { self.list.clone() }
pub fn items_mut(&mut self) -> &mut BTreeMap<usize, Process> { &mut self.list }
pub fn save(&self) { then!(self.remote.is_none(), dump::write(&self)) }
pub fn count(&mut self) -> usize { self.list().count() }
pub fn is_empty(&self) -> bool { self.list.is_empty() }
pub fn exists(&self, id: usize) -> bool { self.list.contains_key(&id) }
pub fn info(&self, id: usize) -> Option<&Process> { self.list.get(&id) }
pub fn size(&self) -> Option<&usize> { self.list.iter().map(|(k, _)| k).max() }
pub fn list<'l>(&'l mut self) -> impl Iterator<Item = (&'l usize, &'l mut Process)> { self.list.iter_mut().map(|(k, v)| (k, v)) }
pub fn process(&mut self, id: usize) -> &mut Process { self.list.get_mut(&id).unwrap_or_else(|| crashln!("{} Process ({id}) not found", *helpers::FAIL)) }
pub fn pid(&self, id: usize) -> i64 { self.list.get(&id).unwrap_or_else(|| crashln!("{} Process ({id}) not found", *helpers::FAIL)).pid }
pub fn get(self, id: usize) -> ProcessWrapper {
ProcessWrapper {
id,
runner: Arc::new(Mutex::new(self)),
}
}
pub fn set_crashed(&mut self, id: usize) -> &mut Self {
self.process(id).crash.crashed = true;
return self;
}
pub fn set_children(&mut self, id: usize, children: Vec<i64>) -> &mut Self {
self.process(id).children = children;
return self;
}
pub fn new_crash(&mut self, id: usize) -> &mut Self {
self.process(id).crash.value += 1;
return self;
}
pub fn stop(&mut self, id: usize) -> &mut Self {
if let Some(remote) = &self.remote {
if let Err(err) = http::stop(remote, id) {
crashln!("{} Failed to stop process {id}\nError: {:#?}", *helpers::FAIL, err);
};
} else {
let process = self.process(id);
kill_children(process.children.clone());
stop(process.pid);
process.running = false;
process.crash.crashed = false;
process.crash.value = 0;
process.children = vec![];
}
return self;
}
+ pub fn flush(&mut self, id: usize) -> &mut Self {
+ if let Some(remote) = &self.remote {
+ if let Err(err) = http::flush(remote, id) {
+ crashln!("{} Failed to flush process {id}\nError: {:#?}", *helpers::FAIL, err);
+ };
+ } else {
+ self.process(id).logs().flush();
+ }
+
+ return self;
+ }
+
pub fn rename(&mut self, id: usize, name: String) -> &mut Self {
if let Some(remote) = &self.remote {
if let Err(err) = http::rename(remote, id, name) {
crashln!("{} Failed to rename process {id}\nError: {:#?}", *helpers::FAIL, err);
};
} else {
self.process(id).name = name;
}
return self;
}
pub fn watch(&mut self, id: usize, path: &str, enabled: bool) -> &mut Self {
let process = self.process(id);
process.watch = Watch {
enabled,
path: string!(path),
hash: ternary!(enabled, hash::create(process.path.join(path)), string!("")),
};
return self;
}
pub fn find(&self, name: &str, server_name: &String) -> Option<usize> {
let mut runner = self.clone();
if !matches!(&**server_name, "internal" | "local") {
let Some(servers) = config::servers().servers else {
crashln!("{} Failed to read servers", *helpers::FAIL)
};
if let Some(server) = servers.get(server_name) {
runner = match Runner::connect(server_name.clone(), server.get(), false) {
Some(remote) => remote,
None => crashln!("{} Failed to connect (name={server_name}, address={})", *helpers::FAIL, server.address),
};
} else {
crashln!("{} Server '{server_name}' does not exist", *helpers::FAIL)
};
}
runner.list.iter().find(|(_, p)| p.name == name).map(|(id, _)| *id)
}
pub fn fetch(&self) -> Vec<ProcessItem> {
let mut processes: Vec<ProcessItem> = Vec::new();
for (id, item) in self.items() {
let mut memory_usage: Option<MemoryInfo> = None;
let mut cpu_percent: Option<f32> = None;
if let Ok(mut process) = process::Process::new(item.pid as u32) {
let mem_info_psutil = process.memory_info().ok();
cpu_percent = process.cpu_percent().ok();
memory_usage = Some(MemoryInfo {
rss: mem_info_psutil.as_ref().unwrap().rss(),
vms: mem_info_psutil.as_ref().unwrap().vms(),
});
}
let cpu_percent = match cpu_percent {
Some(percent) => format!("{:.2}%", percent),
None => string!("0.00%"),
};
let memory_usage = match memory_usage {
Some(usage) => helpers::format_memory(usage.rss),
None => string!("0b"),
};
let status = if item.running {
string!("online")
} else {
match item.crash.crashed {
true => string!("crashed"),
false => string!("stopped"),
}
};
processes.push(ProcessItem {
id,
status,
pid: item.pid,
cpu: cpu_percent,
mem: memory_usage,
restarts: item.restarts,
name: item.name.clone(),
start_time: item.started,
watch_path: item.watch.path.clone(),
uptime: helpers::format_duration(item.started),
});
}
return processes;
}
+}
- pub fn flush(&mut self, id: usize) -> &mut Self {
- if let Some(remote) = &self.remote {
- if let Err(err) = http::flush(remote, id) {
- crashln!("{} Failed to flush process {id}\nError: {:#?}", *helpers::FAIL, err);
- };
- } else {
- match self.info(id) {
- Some(item) => item.logs().flush(),
- None => crashln!("{} Process ({id}) not found", *helpers::FAIL),
- };
+impl LogInfo {
+ pub fn flush(&self) {
+ if let Err(err) = File::create(&self.out) {
+ log::debug!("{err}");
+ crashln!("{} Failed to purge logs (path={})", *helpers::FAIL, self.error);
}
- self
+ if let Err(err) = File::create(&self.error) {
+ log::debug!("{err}");
+ crashln!("{} Failed to purge logs (path={})", *helpers::FAIL, self.error);
+ }
}
}
impl Process {
/// Get a log paths of the process item
pub fn logs(&self) -> LogInfo {
let name = self.name.replace(" ", "_");
LogInfo {
out: global!("pmc.logs.out", name.as_str()),
error: global!("pmc.logs.error", name.as_str()),
}
}
}
impl ProcessWrapper {
/// Stop the process item
pub fn stop(&mut self) { lock!(self.runner).stop(self.id).save(); }
/// Restart the process item
pub fn restart(&mut self) { lock!(self.runner).restart(self.id, false).save(); }
/// Rename the process item
pub fn rename(&mut self, name: String) { lock!(self.runner).rename(self.id, name).save(); }
/// Enable watching a path on the process item
pub fn watch(&mut self, path: &str) { lock!(self.runner).watch(self.id, path, true).save(); }
/// Disable watching on the process item
pub fn disable_watch(&mut self) { lock!(self.runner).watch(self.id, "", false).save(); }
/// Set the process item as crashed
pub fn crashed(&mut self) { lock!(self.runner).restart(self.id, true).save(); }
/// Get the borrowed runner reference (lives till program end)
pub fn get_runner(&mut self) -> &Runner { Box::leak(Box::new(lock!(self.runner))) }
/// Get a json dump of the process item
pub fn fetch(&self) -> ItemSingle {
let mut runner = lock!(self.runner);
let item = runner.process(self.id);
let config = config::read().runner;
let mut memory_usage: Option<MemoryInfo> = None;
let mut cpu_percent: Option<f32> = None;
if let Ok(mut process) = process::Process::new(item.pid as u32) {
let mem_info_psutil = process.memory_info().ok();
cpu_percent = process.cpu_percent().ok();
memory_usage = Some(MemoryInfo {
rss: mem_info_psutil.as_ref().unwrap().rss(),
vms: mem_info_psutil.as_ref().unwrap().vms(),
});
}
let status = if item.running {
string!("online")
} else {
match item.crash.crashed {
true => string!("crashed"),
false => string!("stopped"),
}
};
ItemSingle {
info: Info {
status,
id: item.id,
pid: item.pid,
name: item.name.clone(),
path: item.path.clone(),
children: item.children.clone(),
uptime: helpers::format_duration(item.started),
command: format!("{} {} '{}'", config.shell, config.args.join(" "), item.script.clone()),
},
stats: Stats {
cpu_percent,
memory_usage,
restarts: item.restarts,
start_time: item.started.timestamp_millis(),
},
watch: Watch {
enabled: item.watch.enabled,
hash: item.watch.hash.clone(),
path: item.watch.path.clone(),
},
log: Log {
out: item.logs().out,
error: item.logs().error,
},
raw: Raw {
running: item.running,
crashed: item.crash.crashed,
crashes: item.crash.value,
},
}
}
}
pub mod dump;
pub mod hash;
pub mod http;
pub mod id;
diff --git a/src/webui/src/components/react/index.tsx b/src/webui/src/components/react/index.tsx
index 65ed5b9..417ee21 100644
--- a/src/webui/src/components/react/index.tsx
+++ b/src/webui/src/components/react/index.tsx
@@ -1,142 +1,151 @@
import { api } from '@/api';
import Rename from '@/components/react/rename';
import { useEffect, useState, Fragment } from 'react';
-import { Menu, MenuItem, MenuItems, MenuButton, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/20/solid';
+import { Menu, MenuItem, MenuItems, MenuButton, Transition } from '@headlessui/react';
const Index = (props: { base: string }) => {
const [items, setItems] = useState([]);
const badge = {
online: 'bg-emerald-400',
stopped: 'bg-red-500',
crashed: 'bg-amber-400'
};
async function fetch() {
const items = await api.get(props.base + '/list').json();
setItems(items.map((s) => ({ ...s, server: 'Internal' })));
try {
const servers = await api.get(props.base + '/daemon/servers').json();
await servers.forEach(async (name) => {
const remote = await api.get(props.base + `/remote/${name}/list`).json();
setItems((s) => [...s, ...remote.map((i) => ({ ...i, server: name }))]);
});
} catch {}
}
const classNames = (...classes: Array<any>) => classes.filter(Boolean).join(' ');
const isRemote = (item: any): bool => (item.server == 'Internal' ? false : true);
const isRunning = (status: string): bool => (status == 'stopped' ? false : status == 'crashed' ? false : true);
const action = (id: number, name: string) => api.post(`${props.base}/process/${id}/action`, { json: { method: name } }).then(() => fetch());
useEffect(() => {
fetch();
}, []);
return (
<ul role="list" className="grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-4 xl:gap-x-8">
{items.map((item) => (
<li key={item.id + item.name} className="rounded-lg border border-zinc-700/50 bg-zinc-900/10 hover:bg-zinc-900/40 hover:border-zinc-700">
<div className="flex items-center gap-x-4 border-b border-zinc-800/80 bg-zinc-900/20 px-4 py-3">
<span className="text-md font-bold text-zinc-200 truncate">
{item.name}
<div className="text-xs font-medium text-zinc-400">{item.server}</div>
</span>
<span className="relative flex h-2 w-2 -mt-3.5 -ml-2">
<span className={`${badge[item.status]} relative inline-flex rounded-full h-2 w-2`}></span>
</span>
<Menu as="div" className="relative ml-auto">
<MenuButton className="transition border focus:outline-none focus:ring-0 focus:ring-offset-0 z-50 shrink-0 border-zinc-700/50 bg-transparent hover:bg-zinc-800 p-2 text-sm font-semibold rounded-lg ml-3">
<EllipsisVerticalIcon className="h-5 w-5 text-zinc-50" aria-hidden="true" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
- <MenuItems className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-lg bg-zinc-900 border border-zinc-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none text-base divide-y divide-zinc-800/50">
+ <MenuItems
+ anchor={{ to: 'bottom end', gap: '8px', padding: '16px' }}
+ className="z-10 w-48 origin-top-right rounded-lg bg-zinc-900 border border-zinc-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none text-base divide-y divide-zinc-800/50">
<div className="p-1.5">
<MenuItem>
{({ focus }) => (
<a
onClick={() => action(item.id, 'restart')}
className={classNames(
focus ? 'bg-blue-700/10 text-blue-500' : 'text-zinc-200',
'rounded-md block px-2 py-2 w-full text-left cursor-pointer'
)}>
Reload
</a>
)}
</MenuItem>
<MenuItem>
{({ focus }) => (
<a
onClick={() => action(item.id, 'stop')}
className={classNames(
focus ? 'bg-yellow-400/10 text-amber-500' : 'text-zinc-200',
'rounded-md block p-2 w-full text-left cursor-pointer'
)}>
Terminate
</a>
)}
</MenuItem>
</div>
<div className="p-1.5">
+ <MenuItem>{({ _ }) => <Rename base={props.base} process={item.id} callback={fetch} old={item.name} />}</MenuItem>
<MenuItem>
- {({ focus }) => <Rename base={props.base} process={item.id} callback={fetch} active={focus} old={item.name} />}
+ {({ _ }) => (
+ <a
+ onClick={() => action(props.id, 'flush')}
+ className="text-zinc-200 rounded-md block p-2 w-full text-left cursor-pointer hover:bg-zinc-800/80 hover:text-zinc-50">
+ Clean Logs
+ </a>
+ )}
</MenuItem>
</div>
<div className="p-1.5">
<MenuItem>
{({ focus }) => (
<a
onClick={() => action(item.id, 'delete')}
className={classNames(
focus ? 'bg-red-700/10 text-red-500' : 'text-red-400',
'rounded-md block p-2 w-full text-left cursor-pointer'
)}>
Delete
</a>
)}
</MenuItem>
</div>
</MenuItems>
</Transition>
</Menu>
</div>
<a href={isRemote(item) ? `./view/${item.id}?server=${item.server}` : `./view/${item.id}`}>
<dl className="-my-3 divide-y divide-zinc-800/30 px-6 py-4 text-sm leading-6">
<div className="flex justify-between gap-x-1 py-1">
<dt className="text-zinc-700">cpu usage</dt>
<dd className="text-zinc-500">{isRunning(item.status) ? item.cpu : 'offline'}</dd>
</div>
<div className="flex justify-between gap-x-1 py-1">
<dt className="text-zinc-700">memory</dt>
<dd className="text-zinc-500">{isRunning(item.status) ? item.mem : 'offline'}</dd>
</div>
<div className="flex justify-between gap-x-1 py-1">
<dt className="text-zinc-700">pid</dt>
<dd className="text-zinc-500">{isRunning(item.status) ? item.pid : 'none'}</dd>
</div>
<div className="flex justify-between gap-x-1 py-1">
<dt className="text-zinc-700">uptime</dt>
<dd className="text-zinc-500">{isRunning(item.status) ? item.uptime : 'none'}</dd>
</div>
<div className="flex justify-between gap-x-1 py-1">
<dt className="text-zinc-700">restarts</dt>
<dd className="text-zinc-500">{item.restarts == 0 ? 'none' : item.restarts}</dd>
</div>
</dl>
</a>
</li>
))}
</ul>
);
};
export default Index;
diff --git a/src/webui/src/components/react/rename.tsx b/src/webui/src/components/react/rename.tsx
index aa4f22c..43cc6a1 100644
--- a/src/webui/src/components/react/rename.tsx
+++ b/src/webui/src/components/react/rename.tsx
@@ -1,62 +1,62 @@
import { api } from '@/api';
import Modal from '@/components/react/modal';
import { useEffect, useState, Fragment } from 'react';
const Rename = (props: { base: string; process: number; callback: any; old: string }) => {
const [open, setOpen] = useState(false);
const [formData, setFormData] = useState('');
const handleChange = (event: any) => setFormData(event.target.value);
const handleSubmit = (event: any) => {
event.preventDefault();
api.post(`${props.base}/process/${props.process}/rename`, { body: formData }).then(() => {
setOpen(false);
props.callback();
});
};
useEffect(() => {
setFormData(props.old);
}, []);
return (
<Fragment>
<a
onClick={() => setOpen(true)}
className="text-zinc-200 rounded-md block p-2 w-full text-left cursor-pointer hover:bg-zinc-800/80 hover:text-zinc-50">
Rename
</a>
<Modal show={open} title="Rename this process" callback={(close: boolean) => setOpen(close)}>
<form onSubmit={handleSubmit}>
- <div className="relative border border-zinc-700 rounded-lg px-3 py-3 focus-within:ring-1 focus-within:ring-zinc-300 focus-within:border-zinc-300 w-[29rem] focus-within:shadow-sm transition bg-zinc-900">
+ <div className="relative border border-zinc-700 rounded-lg px-3 py-3 focus-within:ring-1 focus-within:ring-zinc-300 focus-within:border-zinc-300 sm:w-[29rem] focus-within:shadow-sm transition bg-zinc-900">
<input
type="text"
name="name"
id="name"
value={formData}
onChange={handleChange}
className="bg-zinc-900 block w-full border-0 p-0 text-zinc-100 placeholder-zinc-500 focus:ring-0 sm:text-sm transition"
placeholder={props.old}
/>
</div>
- <div className="bg-zinc-900 border-t border-zinc-800 px-3 py-2.5 sm:px-6 sm:flex sm:flex-row-reverse -mb-4 mt-4 -ml-6 -mr-6">
+ <div className="bg-zinc-950 border-t border-zinc-800 px-3 py-2.5 px-6 sm:flex sm:flex-row-reverse -mb-4 mt-4 -ml-6 -mr-6">
<button
type="submit"
- className="w-full inline-flex justify-center rounded-lg border shadow-sm px-2.5 py-[0.18rem] bg-sky-600 text-base font-medium text-white hover:bg-sky-500 border-sky-500 hover:border-sky-400 sm:ml-3 sm:w-auto sm:text-sm focus:outline-none transition">
+ className="mt-1 sm:mt-0 w-full inline-flex justify-center rounded-lg border shadow-sm px-2.5 py-2 sm:py-1 bg-sky-600 text-base font-medium text-white hover:bg-sky-500 border-sky-500 hover:border-sky-400 sm:ml-3 sm:w-auto sm:text-sm focus:outline-none transition">
Rename
</button>
<button
type="button"
- className="mt-3 w-full inline-flex justify-center rounded-lg border shadow-sm px-2.5 py-1 bg-zinc-950 text-base font-medium border-zinc-800 hover:border-zinc-700 text-zinc-50 hover:bg-zinc-800 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm focus:outline-none transition"
+ className="mt-3 w-full inline-flex justify-center rounded-lg sm:border shadow-sm px-2.5 py-1.5 sm:py-1 bg-zinc-950 text-base font-medium border-zinc-800 hover:border-zinc-700 text-zinc-50 hover:bg-zinc-800 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm focus:outline-none transition"
onClick={() => setOpen(false)}>
Cancel
</button>
</div>
</form>
</Modal>
</Fragment>
);
};
export default Rename;
diff --git a/src/webui/src/components/react/view.tsx b/src/webui/src/components/react/view.tsx
index 1cfb78f..7839112 100644
--- a/src/webui/src/components/react/view.tsx
+++ b/src/webui/src/components/react/view.tsx
@@ -1,396 +1,401 @@
import { api } from '@/api';
import { matchSorter } from 'match-sorter';
import Rename from '@/components/react/rename';
import { useEffect, useState, useRef, Fragment } from 'react';
import { EllipsisVerticalIcon, CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
import { Menu, MenuItem, MenuItems, MenuButton, Transition, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
const classNames = (...classes: Array<any>) => classes.filter(Boolean).join(' ');
const formatMemory = (bytes: number): [number, string] => {
const units = ['b', 'kb', 'mb', 'gb'];
let size = bytes;
let unitIndex = 0;
while (size > 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return [+size.toFixed(1), units[unitIndex]];
};
const startDuration = (input: string): [number, string] => {
const matches = input.match(/(\d+)([dhms])/);
if (matches) {
const value = parseInt(matches[1], 10);
const unit = matches[2];
return [value, unit];
}
return null;
};
const Loader = () => (
<div
style={{
position: 'fixed',
top: '60%',
left: '50%',
transform: 'translate(-50%, -60%)',
pointerEvents: 'none'
}}>
<div className="h-1 w-96 bg-zinc-800 overflow-hidden rounded-full">
<div className="animate-progress w-full h-full bg-zinc-50 origin-left-right"></div>
</div>
</div>
);
const LogRow = ({ match, children }: any) => {
const _match = match.toLowerCase();
const chunks = match.length ? children.split(new RegExp('(' + match + ')', 'ig')) : [children];
return (
<div>
{chunks.map((chunk: any, index: number) =>
chunk.toLowerCase() === _match ? (
<span key={index} className="bg-yellow-400 text-black">
{chunk}
</span>
) : (
<span key={index} className=" text-zinc-200">
{chunk}
</span>
)
)}
</div>
);
};
const LogViewer = (props: { server: string | null; base: string; id: number }) => {
const logTypes = [
{ id: 1, name: 'stdout' },
{ id: 2, name: 'stderr' }
];
const [logs, setLogs] = useState<string[]>([]);
const [loaded, setLoaded] = useState(false);
const [logType, setLogType] = useState(logTypes[0]);
const lastRow = useRef<HTMLDivElement | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const [componentHeight, setComponentHeight] = useState(0);
const filtered = (!searchQuery && logs) || matchSorter(logs, searchQuery);
useEffect(() => {
const updateComponentHeight = () => {
const windowHeight = window.innerHeight;
const newHeight = (windowHeight * 4) / 6;
setComponentHeight(newHeight);
};
updateComponentHeight();
window.addEventListener('resize', updateComponentHeight);
return () => {
window.removeEventListener('resize', updateComponentHeight);
};
}, []);
const componentStyle = {
height: componentHeight + 'px'
};
useEffect(() => {
const handleKeydown = (event: any) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
setSearchOpen(true);
event.preventDefault();
}
};
const handleKeyup = (event: any) => {
if (event.key === 'Escape') {
setSearchQuery('');
setSearchOpen(false);
}
};
const handleClick = () => {
setSearchQuery('');
setSearchOpen(false);
};
window.addEventListener('click', handleClick);
window.addEventListener('keydown', handleKeydown);
window.addEventListener('keyup', handleKeyup);
return () => {
window.removeEventListener('click', handleClick);
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('keyup', handleKeyup);
};
}, [searchOpen]);
const loadLogs = (type: string) => {
setLoaded(false);
api
.get(`${props.base}/process/${props.id}/logs/${type}`)
.json()
.then((data) => setLogs(data.logs))
.finally(() => setLoaded(true));
};
const loadLogsRemote = (type: string) => {
setLoaded(false);
api
.get(`${props.base}/remote/${props.server}/logs/${props.id}/${type}`)
.json()
.then((data) => setLogs(data.logs))
.finally(() => setLoaded(true));
};
useEffect(() => {
props.server != null ? loadLogsRemote(logType.name) : loadLogs(logType.name);
}, [logType]);
useEffect(() => {
lastRow.current?.scrollIntoView();
}, [loaded]);
if (!loaded) {
return <Loader />;
} else {
return (
<div>
{searchOpen && (
<div className="z-50 fixed top-[16.5rem] right-5 w-96 flex bg-zinc-800/50 backdrop-blur-md px-3 py-1 rounded-lg border border-zinc-700 shadow">
<input
className="grow bg-transparent p-2 border-0 text-white focus:ring-0 sm:text-sm placeholder-zinc-accent-fuchsia-500"
autoFocus
placeholder="Filter logs..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<span className="grow-0 text-zinc-400 font-medium mt-1.5">{searchQuery && filtered.length + ' matches'}</span>
</div>
)}
<div className="p-5 pb-0 break-words overflow-y-scroll font-mono" style={componentStyle}>
{filtered.map((log, index) => (
<LogRow key={index} match={searchQuery}>
{log}
</LogRow>
))}
<div ref={lastRow} />
</div>
<Listbox className="absolute bottom-3 right-3" value={logType} onChange={setLogType}>
{() => (
<div>
<ListboxButton className="relative w-full cursor-pointer rounded-lg py-1.5 pl-3 pr-10 text-left saturate-[50%] border border-zinc-700/50 hover:border-zinc-600/50 bg-zinc-800/50 text-zinc-50 hover:bg-zinc-700/50 shadow-sm focus:outline-none sm:text-sm sm:leading-6">
<span className="block truncate">{logType.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-zinc-500" aria-hidden="true" />
</span>
</ListboxButton>
<ListboxOptions
transition
className="absolute z-10 -mt-2 max-h-60 w-full overflow-auto rounded-lg bg-zinc-900/80 backdrop-blur-md border border-zinc-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none text-base p-1 text-base shadow-lg focus:outline-none data-[closed]:data-[leave]:opacity-0 data-[leave]:transition data-[leave]:duration-100 data-[leave]:ease-in sm:text-sm -translate-y-full transform">
{logTypes.map((item) => (
<ListboxOption
key={item.id}
className={({ focus }) =>
classNames(
focus ? 'bg-zinc-800/80 text-zinc-50' : '',
!focus ? 'text-zinc-200' : '',
'relative rounded-md block p-2 w-full text-left cursor-pointer select-none'
)
}
value={item}>
{({ selected, focus }) => (
<>
<span className={classNames(selected ? 'font-semibold' : 'font-normal', 'block truncate')}>{item.name}</span>
{selected ? (
<span className="text-emerald-500 absolute inset-y-0 right-0 flex items-center pr-1.5">
<CheckIcon className="h-4 w-4" aria-hidden="true" />
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</div>
)}
</Listbox>
</div>
);
}
};
const View = (props: { id: string; base: string }) => {
const [item, setItem] = useState<any>();
const [loaded, setLoaded] = useState(false);
const server = new URLSearchParams(window.location.search).get('server');
const badge = {
online: 'bg-emerald-400/10 text-emerald-400',
stopped: 'bg-red-500/10 text-red-500',
crashed: 'bg-amber-400/10 text-amber-400'
};
const fetch = () => {
api
.get(`${props.base}/process/${props.id}/info`)
.json()
.then((res) => setItem(res))
.finally(() => setLoaded(true));
};
const fetchRemote = () => {
api
.get(`${props.base}/remote/${server}/info/${props.id}`)
.json()
.then((res) => setItem(res))
.finally(() => setLoaded(true));
};
const isRunning = (status: string): bool => (status == 'stopped' ? false : status == 'crashed' ? false : true);
const action = (id: number, name: string) => api.post(`${props.base}/process/${id}/action`, { json: { method: name } }).then(() => fetch());
useEffect(() => {
server != null ? fetchRemote() : fetch();
}, []);
if (!loaded) {
return <Loader />;
} else {
const online = isRunning(item.info.status);
const [uptime, upunit] = startDuration(item.info.uptime);
const [memory, memunit] = formatMemory(online ? item.stats.memory_usage.rss : 0);
const stats = [
{ name: 'Status', value: item.info.status },
{ name: 'Uptime', value: online ? uptime : 'none', unit: online ? upunit : '' },
{ name: 'Memory', value: online ? memory : 'offline', unit: online ? memunit : '' },
{ name: 'CPU', value: online ? item.stats.cpu_percent : 'offline', unit: online ? '%' : '' }
];
return (
<Fragment>
- <div className="flex flex-col items-start justify-between gap-x-8 gap-y-4 bg-zinc-700/10 px-4 py-4 sm:flex-row sm:items-center sm:px-6 lg:px-8">
+ <div className="flex items-start justify-between gap-x-8 gap-y-4 bg-zinc-700/10 px-4 py-4 flex-row items-center sm:px-6 lg:px-8">
<div>
<div className="flex items-center gap-x-3">
<h1 className="flex gap-x-1 text-base leading-7">
<span className="font-semibold text-white cursor-default">{server != null ? `${server}/${item.info.name}` : item.info.name}</span>
</h1>
<div className={`flex-none rounded-full p-1 ${badge[item.info.status]}`}>
<div className="h-2 w-2 rounded-full bg-current" />
</div>
{online && (
<div className="order-first flex-none rounded-full bg-sky-400/10 px-2 py-0.5 text-xs font-medium text-sky-400 ring-1 ring-inset ring-sky-400/30 sm:order-none">
{item.info.pid}
</div>
)}
</div>
<p className="text-xs leading-6 text-zinc-400">{item.info.command}</p>
</div>
- <div className="mt-5 flex lg:ml-4 lg:mt-0">
+ <div className="flex lg:ml-4 mt-0">
<span>
<button
type="button"
onClick={() => action(props.id, 'restart')}
className="disabled:opacity-50 transition inline-flex items-center justify-center space-x-1.5 border focus:outline-none focus:ring-0 focus:ring-offset-0 focus:z-10 shrink-0 saturate-[110%] border-zinc-700 hover:border-zinc-600 bg-zinc-800 text-zinc-50 hover:bg-zinc-700 px-4 py-2 text-sm font-semibold rounded-lg">
{online ? 'Restart' : 'Start'}
</button>
</span>
<span className="ml-3">
<Menu as="div" className="relative inline-block text-left">
<div>
<MenuButton className="transition inline-flex items-center justify-center space-x-1.5 border focus:outline-none focus:ring-0 focus:ring-offset-0 focus:z-10 shrink-0 border-zinc-700 bg-transparent hover:bg-zinc-800 p-2 text-sm font-semibold rounded-lg">
<EllipsisVerticalIcon className="h-5 w-5 text-zinc-50" aria-hidden="true" />
</MenuButton>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95">
- <MenuItems className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-lg bg-zinc-900/80 backdrop-blur-md border border-zinc-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none text-base divide-y divide-zinc-800/50">
+ <MenuItems
+ anchor={{ to: 'bottom end', gap: '8px', padding: '16px' }}
+ className="z-10 w-48 origin-top-right rounded-lg bg-zinc-900/80 backdrop-blur-md border border-zinc-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none text-base divide-y divide-zinc-800/50">
<div className="p-1.5">
<MenuItem>
{({ focus }) => (
<a
onClick={() => action(props.id, 'stop')}
className={classNames(
focus ? 'bg-yellow-400/10 text-amber-500' : 'text-zinc-200',
'rounded-md block p-2 w-full text-left cursor-pointer'
)}>
Terminate
</a>
)}
</MenuItem>
<MenuItem>
{({ focus }) => <Rename base={props.base} process={props.id} active={focus} callback={fetch} old={item.info.name} />}
</MenuItem>
<MenuItem>
{({ _ }) => (
<a
- onClick={() => action(props.id, 'flush')}
+ onClick={() => {
+ action(props.id, 'flush');
+ window.location.reload();
+ }}
className="text-zinc-200 rounded-md block p-2 w-full text-left cursor-pointer hover:bg-zinc-800/80 hover:text-zinc-50">
- Flush
+ Clean Logs
</a>
)}
</MenuItem>
</div>
<div className="p-1.5">
<MenuItem>
{({ focus }) => (
<a
onClick={() => action(props.id, 'delete')}
className={classNames(
focus ? 'bg-red-700/10 text-red-500' : 'text-red-400',
'rounded-md block p-2 w-full text-left cursor-pointer'
)}>
Delete
</a>
)}
</MenuItem>
</div>
</MenuItems>
</Transition>
</Menu>
</span>
</div>
</div>
- <div className="grid grid-cols-1 bg-zinc-700/10 sm:grid-cols-2 lg:grid-cols-4">
+ <div className="grid bg-zinc-700/10 grid-cols-4">
{stats.map((stat: any, index: number) => (
<div
key={stat.name}
className={classNames(
- index % 2 === 1 ? 'sm:border-l' : index === 2 ? 'lg:border-l' : '',
+ index % 2 === 1 ? 'border-l' : index === 2 ? 'border-l' : '',
'border-t border-white/5 py-6 px-4 sm:px-6 lg:px-8'
)}>
<p className="text-sm font-medium leading-6 text-zinc-400">{stat.name}</p>
<p className="mt-2 flex items-baseline gap-x-2">
- <span className="text-4xl font-semibold tracking-tight text-white">{stat.value}</span>
+ <span className="text-xl sm:text-3xl lg:text-4xl font-semibold tracking-tight text-white">{stat.value}</span>
{stat.unit ? <span className="text-sm text-zinc-400">{stat.unit}</span> : null}
</p>
</div>
))}
</div>
<LogViewer server={server} id={parseInt(props.id)} base={props.base} />
</Fragment>
);
}
};
export default View;
diff --git a/src/webui/src/pages/view.astro b/src/webui/src/pages/view.astro
index 52e9b52..9462514 100644
--- a/src/webui/src/pages/view.astro
+++ b/src/webui/src/pages/view.astro
@@ -1,14 +1,13 @@
---
import Layout from '@/components/base.astro';
import Navbar from '@/components/navbar.astro';
import ViewPage from '@/components/react/view';
import { SITE_TITLE, SITE_DESCRIPTION } from '@/consts';
---
-
<Layout title={SITE_TITLE + " - View process"} description={SITE_DESCRIPTION}>
<Navbar active={false} />
<main>
<ViewPage id={"{{process_id | safe}}"} base={"{{base_path | safe}}"} client:only />
</main>
</Layout>

File Metadata

Mime Type
text/x-diff
Expires
Sun, Feb 1, 9:01 PM (1 d, 18 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
494924
Default Alt Text
(131 KB)

Event Timeline