Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2707979
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
105 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/build.rs b/build.rs
index 3e253f8..09fabe7 100644
--- a/build.rs
+++ b/build.rs
@@ -1,165 +1,176 @@
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.10.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");
#[allow(unused_must_use)]
for name in vec!["assets", "dist"] {
fs::remove_dir_all(format!("src/webui/{name}"));
}
/* pre-build */
let path = download_node();
download_then_build(path);
/* move assets */
fs::create_dir_all("src/webui/assets/").expect("Failed to move assets");
fs::rename("src/webui/dist/static", "src/webui/assets/static").expect("Failed to move assets");
/* 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"),
}
- let watched = vec!["lib", "src/lib.rs", "lib/include", "src/webui/src", "src/webui/*.mjs", "src/webui/*.json", "src/webui/*.yaml"];
+ 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/errors.todo b/errors.todo
new file mode 100644
index 0000000..cf43dc6
--- /dev/null
+++ b/errors.todo
@@ -0,0 +1,2 @@
+@fix pmc daemon restart errors
+ - it infinite restarts
\ No newline at end of file
diff --git a/src/cli.rs b/src/cli.rs
index 70c5197..a1f33bf 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -1,593 +1,594 @@
use colored::Colorize;
use global_placeholders::global;
use macros_rs::{crashln, string, ternary};
use psutil::process::{MemoryInfo, Process};
use regex::Regex;
use serde::Serialize;
use serde_json::json;
use std::env;
use pmc::{
config,
file::{self, Exists},
helpers::{self, ColoredString},
log,
process::{
http::{self, LogResponse},
ItemSingle, Runner,
},
};
use tabled::{
settings::{
object::{Columns, Rows},
style::{BorderColor, Style},
themes::Colorization,
Color, Modify, Rotate, Width,
},
Table, Tabled,
};
#[derive(Clone, Debug)]
pub enum Args {
Id(usize),
Script(String),
}
fn format(server_name: &String) -> (String, String) {
let kind = ternary!(server_name == "internal", "", "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 => format!("{} ({} {}) [{}]", env!("CARGO_PKG_VERSION"), env!("GIT_HASH"), env!("BUILD_DATE"), env!("PROFILE")),
};
}
pub fn start(name: &Option<String>, args: &Option<Args>, watch: &Option<String>, server_name: &String) {
let mut runner = Runner::new();
let config = config::read();
let (kind, list_name) = format(server_name);
match args {
Some(Args::Id(id)) => {
let runner: Runner = Runner::new();
println!("{} Applying {kind}action restartProcess on ({id})", *helpers::SUCCESS);
if *server_name == "internal" {
let mut item = runner.get(*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();
log!("process started (id={id})");
} else {
let Some(servers) = config::servers().servers else {
crashln!("{} Server '{server_name}' does not exist", *helpers::FAIL)
};
if let Some(server) = servers.get(server_name) {
match Runner::connect(server_name.clone(), server.clone(), false) {
Some(remote) => {
let mut item = remote.get(*id);
name.as_ref().map(|n| item.rename(n.trim().replace("\n", "")));
item.restart();
}
None => crashln!("{} Failed to connect (name={server_name}, address={})", *helpers::FAIL, server.address),
};
}
}
println!("{} restarted {kind}({id}) ✓", *helpers::SUCCESS);
list(&string!("default"), &list_name);
}
Some(Args::Script(script)) => {
let name = match name {
Some(name) => string!(name),
None => string!(script.split_whitespace().next().unwrap_or_default()),
};
if *server_name == "internal" {
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);
runner.start(&name, &script, file::cwd(), watch).save();
} else {
runner.start(&name, script, file::cwd(), watch).save();
}
log!("process created (name={name})");
} else {
let Some(servers) = config::servers().servers else {
crashln!("{} Server '{server_name}' does not exist", *helpers::FAIL)
};
if let Some(server) = servers.get(server_name) {
match Runner::connect(server_name.clone(), server.clone(), false) {
Some(mut remote) => remote.start(&name, script, file::cwd(), watch),
None => crashln!("{} Failed to connect (name={server_name}, address={})", *helpers::FAIL, server.address),
};
}
}
println!("{} Creating {kind}process with ({name})", *helpers::SUCCESS);
println!("{} {kind}created ({name}) ✓", *helpers::SUCCESS);
list(&string!("default"), &list_name);
}
None => {}
}
}
pub fn stop(id: &usize, server_name: &String) {
let mut runner: Runner = Runner::new();
let (kind, list_name) = format(server_name);
println!("{} Applying {kind}action stopProcess on ({id})", *helpers::SUCCESS);
if *server_name != "internal" {
let Some(servers) = config::servers().servers else {
crashln!("{} Server '{server_name}' does not exist", *helpers::FAIL)
};
if let Some(server) = servers.get(server_name) {
runner = match Runner::connect(server_name.clone(), server.clone(), false) {
Some(remote) => remote,
None => crashln!("{} Failed to connect (name={server_name}, address={})", *helpers::FAIL, server.address),
};
}
}
runner.get(*id).stop();
println!("{} stopped {kind}({id}) ✓", *helpers::SUCCESS);
log!("process stopped {kind}(id={id})");
list(&string!("default"), &list_name);
}
pub fn remove(id: &usize, server_name: &String) {
let mut runner: Runner = Runner::new();
let (kind, _) = format(server_name);
println!("{} Applying {kind}action removeProcess on ({id})", *helpers::SUCCESS);
if *server_name != "internal" {
let Some(servers) = config::servers().servers else {
crashln!("{} Server '{server_name}' does not exist", *helpers::FAIL)
};
if let Some(server) = servers.get(server_name) {
runner = match Runner::connect(server_name.clone(), server.clone(), false) {
Some(remote) => remote,
None => crashln!("{} Failed to remove (name={server_name}, address={})", *helpers::FAIL, server.address),
};
}
}
runner.remove(*id);
println!("{} removed {kind}({id}) ✓", *helpers::SUCCESS);
log!("process removed (id={id})");
}
pub fn info(id: &usize, format: &String, server_name: &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,
#[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,
"watch": &self.watch.trim(),
"watch": &self.hash.trim(),
"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 ({id})").on_bright_white().black());
println!(" {}", format!("Use `pmc logs {id} [--lines <num>]` to display logs").white());
println!(" {}", format!("Use `pmc env {id}` to display environment variables").white());
}
};
};
};
if *server_name == "internal" {
if let Some(home) = home::home_dir() {
let config = config::read().runner;
let mut runner = Runner::new();
let item = runner.process(*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();
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 {
cpu_percent,
memory_usage,
id: string!(id),
restarts: item.restarts,
name: item.name.clone(),
path: format!("{} ", path),
status: ColoredString(status),
log_out: global!("pmc.logs.out", item.name.as_str()),
log_error: global!("pmc.logs.error", item.name.as_str()),
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 mut item: Option<(pmc::process::Process, Runner)> = None;
let Some(servers) = config::servers().servers else {
crashln!("{} Server '{server_name}' does not exist", *helpers::FAIL)
};
if let Some(server) = servers.get(server_name) {
item = match Runner::connect(server_name.clone(), server.clone(), false) {
Some(mut remote) => Some((remote.process(*id).clone(), remote)),
None => crashln!("{} Failed to connect (name={server_name}, address={})", *helpers::FAIL, server.address),
};
}
if let Some((item, remote)) = item {
- let info = http::info(&remote.remote.unwrap(), *id);
+ let remote = remote.remote.unwrap();
+ let info = http::info(&remote, *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 cpu_percent = match stats.cpu_percent {
Some(percent) => format!("{:.2}%", percent),
None => string!("0%"),
};
let memory_usage = match stats.memory_usage {
Some(usage) => helpers::format_memory(usage.rss),
None => string!("0b"),
};
let data = vec![Info {
cpu_percent,
memory_usage,
id: string!(id),
path: path.clone(),
- command: item.script,
restarts: item.restarts,
name: item.name.clone(),
status: ColoredString(status),
- log_out: global!("pmc.logs.out", item.name.as_str()),
- log_error: global!("pmc.logs.error", item.name.as_str()),
+ log_out: format!("{}/{{}}-out.log", remote.config.log_path),
+ log_error: format!("{}/{{}}-error.log", remote.config.log_path),
pid: ternary!(item.running, format!("{}", item.pid), string!("n/a")),
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(id: &usize, lines: &usize, server_name: &String) {
let mut runner: Runner = Runner::new();
if *server_name != "internal" {
let Some(servers) = config::servers().servers else {
crashln!("{} Server '{server_name}' does not exist", *helpers::FAIL)
};
if let Some(server) = servers.get(server_name) {
runner = match Runner::connect(server_name.clone(), server.clone(), false) {
Some(remote) => remote,
None => crashln!("{} Failed to connect (name={server_name}, address={})", *helpers::FAIL, server.address),
};
}
let item = runner.clone().process(*id).clone();
let log_out = http::logs(&runner.remote.as_ref().unwrap(), *id, "out");
let log_error = http::logs(&runner.remote.as_ref().unwrap(), *id, "error");
println!("{}", format!("Showing last {lines} lines for process [{id}] (change the value with --lines option)").yellow());
if let Ok(logs) = log_error {
let logs = logs.json::<LogResponse>().unwrap().logs;
file::logs_internal(logs, *lines, &item.name, *id, "error", &item.name)
}
if let Ok(logs) = log_out {
let logs = logs.json::<LogResponse>().unwrap().logs;
file::logs_internal(logs, *lines, &item.name, *id, "out", &item.name)
}
} else {
let item = runner.process(*id);
let log_out = global!("pmc.logs.out", item.name.as_str());
let log_error = global!("pmc.logs.error", item.name.as_str());
- if Exists::file(log_error.clone()).unwrap() && Exists::file(log_out.clone()).unwrap() {
+ if Exists::check(&log_error).file() && Exists::check(&log_out).file() {
println!("{}", format!("Showing last {lines} lines for process [{id}] (change the value with --lines option)").yellow());
file::logs(*lines, &log_error, *id, "error", &item.name);
file::logs(*lines, &log_out, *id, "out", &item.name);
} else {
crashln!("{} Logs for process ({id}) not found", *helpers::FAIL);
}
}
}
pub fn env(id: &usize, server_name: &String) {
let mut runner: Runner = Runner::new();
if *server_name != "internal" {
let Some(servers) = config::servers().servers else {
crashln!("{} Server '{server_name}' does not exist", *helpers::FAIL)
};
if let Some(server) = servers.get(server_name) {
runner = match Runner::connect(server_name.clone(), server.clone(), false) {
Some(remote) => remote,
None => crashln!("{} Failed to connect (name={server_name}, address={})", *helpers::FAIL, server.address),
};
}
}
let item = runner.process(*id);
item.env.iter().for_each(|(key, value)| println!("{}: {}", key, value.green()));
}
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: ColoredString(status),
cpu: format!("{cpu_percent} "),
mem: format!("{memory_usage} "),
restarts: format!("{} ", item.restarts),
name: format!("{} ", item.name.clone()),
id: ColoredString(id.to_string().cyan().bold()),
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.clone(), 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") {
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" {
for (name, server) in servers {
match Runner::connect(name.clone(), server.clone(), 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/config/mod.rs b/src/config/mod.rs
index 04efa19..1d045d9 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -1,92 +1,111 @@
pub mod structs;
-use crate::file::{self, Exists};
-use crate::helpers;
+use crate::{
+ file::{self, Exists},
+ helpers,
+ process::RemoteConfig,
+};
use colored::Colorize;
-use macros_rs::{crashln, string};
-use std::fs;
+use macros_rs::{crashln, fmtstr, string};
+use reqwest::blocking::Client;
+use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
+use std::fs::write;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use structs::{Config, Daemon, Runner, Secure, Servers, Web};
+pub fn from(address: &str, token: Option<&str>) -> Result<RemoteConfig, anyhow::Error> {
+ let client = Client::new();
+ let mut headers = HeaderMap::new();
+
+ if let Some(token) = token {
+ headers.insert(AUTHORIZATION, HeaderValue::from_static(fmtstr!("token {token}")));
+ }
+
+ let response = client.get(fmtstr!("{address}/daemon/config")).headers(headers).send()?;
+ let json = response.json::<RemoteConfig>()?;
+
+ Ok(json)
+}
+
pub fn read() -> Config {
match home::home_dir() {
Some(path) => {
let path = path.display();
let config_path = format!("{path}/.pmc/config.toml");
- if !Exists::file(config_path.clone()).unwrap() {
+ if !Exists::check(&config_path).file() {
let config = Config {
runner: Runner {
shell: string!("bash"),
args: vec![string!("-c")],
node: string!("node"),
log_path: format!("{path}/.pmc/logs"),
},
daemon: Daemon {
restarts: 10,
interval: 1000,
kind: string!("default"),
web: Web {
ui: false,
api: false,
address: string!("0.0.0.0"),
path: None,
port: 5630,
secure: Secure { enabled: false, token: string!("") },
},
},
};
let contents = match toml::to_string(&config) {
Ok(contents) => contents,
Err(err) => crashln!("{} Cannot parse config.\n{}", *helpers::FAIL, string!(err).white()),
};
- if let Err(err) = fs::write(&config_path, contents) {
+ if let Err(err) = write(&config_path, contents) {
crashln!("{} Error writing config.\n{}", *helpers::FAIL, string!(err).white())
}
log::info!("created config file");
}
file::read(config_path)
}
None => crashln!("{} Impossible to get your home directory", *helpers::FAIL),
}
}
pub fn servers() -> Servers {
match home::home_dir() {
Some(path) => {
let path = path.display();
let config_path = format!("{path}/.pmc/servers.toml");
- if !Exists::file(config_path.clone()).unwrap() {
- if let Err(err) = fs::write(&config_path, "") {
+ if !Exists::check(&config_path).file() {
+ if let Err(err) = write(&config_path, "") {
crashln!("{} Error writing servers.\n{}", *helpers::FAIL, string!(err).white())
}
}
file::read(config_path)
}
None => crashln!("{} Impossible to get your home directory", *helpers::FAIL),
}
}
impl Config {
pub fn get_address(&self) -> SocketAddr {
let config_split: Vec<u8> = match self.daemon.web.address.as_str() {
"localhost" => vec![127, 0, 0, 1],
_ => self.daemon.web.address.split('.').map(|part| part.parse().expect("Failed to parse address part")).collect(),
};
let ipv4_address: Ipv4Addr = Ipv4Addr::from([config_split[0], config_split[1], config_split[2], config_split[3]]);
let ip_address: IpAddr = IpAddr::from(ipv4_address);
let port = self.daemon.web.port as u16;
(ip_address, port).into()
}
pub fn get_path(&self) -> String { self.daemon.web.path.clone().unwrap_or(string!("/")) }
}
diff --git a/src/daemon/api/docs/index.html b/src/daemon/api/docs/index.html
index 3af248e..28e43f3 100644
--- a/src/daemon/api/docs/index.html
+++ b/src/daemon/api/docs/index.html
@@ -1,25 +1,25 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
<script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
</head>
<body>
<rapi-doc
spec-url="$specUrl"
allow-search="true"
show-header="false"
render-style="focused"
show-curl-before-try="true"
allow-advanced-search="true"
- allow-authentication="false"
+ allow-authentication="true"
allow-server-selection="false"
theme="dark"
bg-color="#18181b"
primary-color="#0ea5e9"
regular-font="Inter var"
mono-font="Menlo"
/>
</body>
</html>
diff --git a/src/daemon/api/mod.rs b/src/daemon/api/mod.rs
index 1cb5737..229e07f 100644
--- a/src/daemon/api/mod.rs
+++ b/src/daemon/api/mod.rs
@@ -1,219 +1,224 @@
mod routes;
use crate::webui;
use bytes::Bytes;
use lazy_static::lazy_static;
use macros_rs::{crashln, fmtstr};
use pmc::{config, process};
use prometheus::{opts, register_counter, register_gauge, register_histogram, register_histogram_vec};
use prometheus::{Counter, Gauge, Histogram, HistogramVec};
use serde::Serialize;
use serde_json::json;
use static_dir::static_dir;
use std::{convert::Infallible, str::FromStr};
use utoipa::{OpenApi, ToSchema};
use utoipa_rapidoc::RapiDoc;
use warp::{
body, filters, get, header,
http::{StatusCode, Uri},
path, post, redirect, reject,
reply::{self, html, json},
serve, Filter, Rejection, Reply,
};
#[derive(Serialize, ToSchema)]
struct ErrorMessage {
#[schema(example = 404)]
code: u16,
#[schema(example = "NOT_FOUND")]
message: String,
}
#[inline]
async fn convert_to_string(bytes: Bytes) -> Result<String, Rejection> { String::from_utf8(bytes.to_vec()).map_err(|_| reject()) }
#[inline]
fn string_filter(limit: u64) -> impl Filter<Extract = (String,), Error = Rejection> + Clone { body::content_length_limit(limit).and(body::bytes()).and_then(convert_to_string) }
lazy_static! {
pub static ref HTTP_COUNTER: Counter = register_counter!(opts!("http_requests_total", "Number of HTTP requests made.")).unwrap();
pub static ref DAEMON_START_TIME: Gauge = register_gauge!(opts!("process_start_time_seconds", "The uptime of the daemon.")).unwrap();
pub static ref DAEMON_MEM_USAGE: Histogram = register_histogram!("daemon_memory_usage", "The memory usage graph of the daemon.").unwrap();
pub static ref DAEMON_CPU_PERCENTAGE: Histogram = register_histogram!("daemon_cpu_percentage", "The cpu usage graph of the daemon.").unwrap();
pub static ref HTTP_REQ_HISTOGRAM: HistogramVec = register_histogram_vec!("http_request_duration_seconds", "The HTTP request latencies in seconds.", &["route"]).unwrap();
}
pub async fn start(webui: bool) {
const DOCS: &str = include_str!("docs/index.html");
let config = config::read().daemon.web;
let s_path = config::read().get_path();
let docs_path = fmtstr!("{}/docs.json", s_path.trim_end_matches('/').to_string());
let auth = header::exact("authorization", fmtstr!("token {}", config.secure.token));
- let tmpl =
- match webui::create_template_filter() {
- Ok(template) => template,
- Err(err) => crashln!("{err}"),
- };
+ let tmpl = match webui::create_template_filter() {
+ Ok(template) => template,
+ Err(err) => crashln!("{err}"),
+ };
#[derive(OpenApi)]
#[openapi(
+ security((), ("token" = [])),
paths(
routes::action_handler,
routes::env_handler,
routes::info_handler,
routes::dump_handler,
+ routes::config_handler,
routes::list_handler,
routes::log_handler,
routes::log_handler_raw,
routes::metrics_handler,
routes::prometheus_handler,
routes::create_handler,
routes::rename_handler
),
components(schemas(
ErrorMessage,
process::Log,
process::Raw,
process::Info,
process::Stats,
process::Watch,
process::ItemSingle,
process::ProcessItem,
routes::Stats,
routes::Daemon,
routes::Version,
routes::ActionBody,
+ routes::ConfigBody,
routes::CreateBody,
routes::MetricsRoot,
routes::LogResponse,
routes::DocMemoryInfo,
routes::ActionResponse,
))
)]
struct ApiDoc;
- let app_dump = path!("dump").and(get()).and_then(routes::dump_handler);
- let app_metrics = path!("metrics").and(get()).and_then(routes::metrics_handler);
- let app_prometheus = path!("prometheus").and(get()).and_then(routes::prometheus_handler);
- let app_docs_json = path!("docs.json").and(get()).map(|| json(&ApiDoc::openapi()));
- let app_docs = path!("docs").and(get()).map(|| html(RapiDoc::new(docs_path).custom_html(DOCS).to_html()));
+ let daemon_dump = path!("daemon" / "dump").and(get()).and_then(routes::dump_handler);
+ let daemon_config = path!("daemon" / "config").and(get()).and_then(routes::config_handler);
+ let daemon_metrics = path!("daemon" / "metrics").and(get()).and_then(routes::metrics_handler);
+ let daemon_prometheus = path!("daemon" / "prometheus").and(get()).and_then(routes::prometheus_handler);
+
+ let docs_json = path!("docs.json").and(get()).map(|| json(&ApiDoc::openapi()));
+ let docs_view = path!("docs").and(get()).map(|| html(RapiDoc::new(docs_path).custom_html(DOCS).to_html()));
let process_list = path!("list").and(get()).and_then(routes::list_handler);
let process_env = path!("process" / usize / "env").and(get()).and_then(routes::env_handler);
let process_info = path!("process" / usize / "info").and(get()).and_then(routes::info_handler);
let process_logs = path!("process" / usize / "logs" / String).and(get()).and_then(routes::log_handler);
let process_raw_logs = path!("process" / usize / "logs" / String / "raw").and(get()).and_then(routes::log_handler_raw);
let process_create = path!("process" / "create").and(post()).and(body::json()).and_then(routes::create_handler);
let process_action = path!("process" / usize / "action").and(post()).and(body::json()).and_then(routes::action_handler);
let process_rename = path!("process" / usize / "rename").and(post()).and(string_filter(1024 * 16)).and_then(routes::rename_handler);
let web_login = warp::get().and(path!("login")).and(tmpl.clone()).and_then(routes::login);
let web_dashboard = warp::get().and(path::end()).and(tmpl.clone()).and_then(routes::dashboard);
let web_view_process = warp::get().and(path!("view" / usize)).and(tmpl.clone()).and_then(routes::view_process);
let log = warp::log::custom(|info| {
log!(
"[api] {} (method={}, status={}, ms={:?}, ver={:?})",
info.path(),
info.method(),
info.status().as_u16(),
info.elapsed(),
info.version()
)
});
let base = s_path
.split('/')
.enumerate()
.filter(|(_, p)| !p.is_empty() || *p == s_path)
.fold(warp::any().boxed(), |f, (_, path)| f.and(warp::path(path.to_owned())).boxed());
let routes = process_list
.or(process_env)
.or(process_info)
.or(process_logs)
.or(process_raw_logs)
.or(process_create)
.or(process_action)
.or(process_rename)
- .or(app_metrics)
- .or(app_prometheus)
- .or(app_dump);
+ .or(daemon_dump)
+ .or(daemon_config)
+ .or(daemon_metrics)
+ .or(daemon_prometheus);
let use_routes_basic = || async {
let base_route = path::end().map(|| json(&json!({"healthy": true})).into_response());
let internal = match config.secure.enabled {
- true => routes.clone().and(auth).or(root_redirect()).or(base_route).or(app_docs_json).or(app_docs).boxed(),
- false => routes.clone().or(root_redirect()).or(base_route).or(app_docs_json).or(app_docs).boxed(),
+ true => routes.clone().and(auth).or(root_redirect()).or(base_route).or(docs_json).or(docs_view).boxed(),
+ false => routes.clone().or(root_redirect()).or(base_route).or(docs_json).or(docs_view).boxed(),
};
serve(base.clone().and(internal).recover(handle_rejection).with(log)).run(config::read().get_address()).await
};
let use_routes_web = || async {
let web_routes = web_login.or(web_dashboard).or(web_view_process).or(static_dir!("src/webui/assets"));
let internal = match config.secure.enabled {
- true => routes.clone().and(auth).or(root_redirect()).or(web_routes).or(app_docs_json).or(app_docs).boxed(),
- false => routes.clone().or(root_redirect()).or(web_routes).or(app_docs_json).or(app_docs).boxed(),
+ true => routes.clone().and(auth).or(root_redirect()).or(web_routes).or(docs_json).or(docs_view).boxed(),
+ false => routes.clone().or(root_redirect()).or(web_routes).or(docs_json).or(docs_view).boxed(),
};
serve(base.clone().and(internal).recover(handle_rejection).with(log)).run(config::read().get_address()).await
};
match webui {
true => use_routes_web().await,
false => use_routes_basic().await,
}
}
async fn handle_rejection(err: Rejection) -> Result<impl Reply, Infallible> {
let code;
let message;
HTTP_COUNTER.inc();
if err.is_not_found() {
code = StatusCode::NOT_FOUND;
message = "NOT_FOUND";
} else if let Some(_) = err.find::<reject::MissingHeader>() {
code = StatusCode::UNAUTHORIZED;
message = "UNAUTHORIZED";
} else if let Some(_) = err.find::<reject::InvalidHeader>() {
code = StatusCode::UNAUTHORIZED;
message = "UNAUTHORIZED";
} else if let Some(_) = err.find::<reject::MethodNotAllowed>() {
code = StatusCode::METHOD_NOT_ALLOWED;
message = "METHOD_NOT_ALLOWED";
} else {
log!("[api] unhandled rejection (err={:?})", err);
code = StatusCode::INTERNAL_SERVER_ERROR;
message = "INTERNAL_SERVER_ERROR";
}
let json = json(&ErrorMessage {
code: code.as_u16(),
message: message.into(),
});
Ok(reply::with_status(json, code))
}
fn root_redirect() -> filters::BoxedFilter<(impl Reply,)> {
warp::path::full()
.and_then(move |path: path::FullPath| async move {
let path = path.as_str();
if path.ends_with("/") || path.contains(".") {
return Err(warp::reject());
}
Ok(redirect::redirect(Uri::from_str(&[path, "/"].concat()).unwrap()))
})
.boxed()
}
diff --git a/src/daemon/api/routes.rs b/src/daemon/api/routes.rs
index 8cca5c5..06634bd 100644
--- a/src/daemon/api/routes.rs
+++ b/src/daemon/api/routes.rs
@@ -1,465 +1,502 @@
use chrono::{DateTime, Utc};
use global_placeholders::global;
use macros_rs::{string, ternary, then};
use prometheus::{Encoder, TextEncoder};
use psutil::process::{MemoryInfo, Process};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::convert::Infallible;
use tera::{Context, Tera};
use utoipa::ToSchema;
use pmc::{
- file, helpers,
+ config, file, helpers,
process::{dump, Runner},
};
use crate::daemon::{
api::{HTTP_COUNTER, HTTP_REQ_HISTOGRAM},
pid,
};
use warp::{
hyper::body::Body,
reject,
reply::{self, json, Response},
Rejection, Reply,
};
use std::{
env,
fs::{self, File},
io::{self, BufRead, BufReader},
path::PathBuf,
};
#[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(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(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, ToSchema)]
pub(crate) struct ActionResponse<'a> {
#[schema(example = true)]
done: bool,
#[schema(example = "name")]
action: &'a str,
}
#[derive(Serialize, ToSchema)]
pub(crate) struct LogResponse {
logs: Vec<String>,
}
#[derive(Serialize, ToSchema)]
pub struct MetricsRoot<'a> {
pub version: Version<'a>,
pub daemon: Daemon,
}
#[derive(Serialize, ToSchema)]
pub struct Version<'a> {
#[schema(example = "v1.0.0")]
pub pkg: String,
pub hash: &'a str,
#[schema(example = "2000-01-01")]
pub build_date: &'a str,
#[schema(example = "release")]
pub target: &'a 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,
}
#[inline]
fn attempt(done: bool, method: &str) -> reply::Json {
let data = json!(ActionResponse {
done,
action: ternary!(done, method, "DOES_NOT_EXIST")
});
json(&data)
}
#[inline]
fn render(name: &str, tmpl: &Tera, ctx: &Context) -> Result<String, Rejection> { tmpl.render(name, &ctx).or(Err(reject::not_found())) }
#[inline]
pub async fn login(store: (Tera, String)) -> Result<Box<dyn Reply>, Rejection> {
let mut ctx = Context::new();
let (tmpl, path) = store;
ctx.insert("base_path", &path);
let payload = render("login", &tmpl, &ctx)?;
Ok(Box::new(reply::html(payload)))
}
#[inline]
pub async fn dashboard(store: (Tera, String)) -> Result<Box<dyn Reply>, Rejection> {
let mut ctx = Context::new();
let (tmpl, path) = store;
ctx.insert("base_path", &path);
let payload = render("dashboard", &tmpl, &ctx)?;
Ok(Box::new(reply::html(payload)))
}
#[inline]
pub async fn view_process(id: usize, store: (Tera, String)) -> Result<Box<dyn Reply>, Rejection> {
let mut ctx = Context::new();
let (tmpl, path) = store;
ctx.insert("base_path", &path);
ctx.insert("process_id", &id);
let payload = render("view", &tmpl, &ctx)?;
Ok(Box::new(reply::html(payload)))
}
#[inline]
-#[utoipa::path(get, tag = "Daemon", path = "/prometheus", responses((status = 200, description = "Get prometheus metrics", body = String)))]
+#[utoipa::path(get, tag = "Daemon", path = "/daemon/prometheus",
+ responses((status = 200, description = "Get prometheus metrics", body = String))
+)]
pub async fn prometheus_handler() -> Result<impl Reply, Infallible> {
let encoder = TextEncoder::new();
let mut buffer = Vec::<u8>::new();
let metric_families = prometheus::gather();
encoder.encode(&metric_families, &mut buffer).unwrap();
Ok(format!("{}", String::from_utf8(buffer.clone()).unwrap()))
}
#[inline]
-#[utoipa::path(get, path = "/dump", tag = "Process", responses((status = 200, description = "Dump processes successfully", body = [u8])))]
+#[utoipa::path(get, tag = "Daemon", path = "/daemon/dump", security(()),
+ responses((status = 200, description = "Dump processes successfully", body = [u8]))
+)]
pub async fn dump_handler() -> Result<impl Reply, Infallible> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["dump"]).start_timer();
HTTP_COUNTER.inc();
timer.observe_duration();
Ok(dump::raw())
}
#[inline]
-#[utoipa::path(get, path = "/list", tag = "Process", responses((status = 200, description = "List processes successfully", body = [ProcessItem])))]
+#[utoipa::path(get, tag = "Daemon", path = "/daemon/config",
+ responses((status = 200, description = "Get daemon config successfully", body = ConfigBody))
+)]
+pub async fn config_handler() -> Result<impl Reply, Infallible> {
+ let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["dump"]).start_timer();
+ let config = config::read().runner;
+
+ HTTP_COUNTER.inc();
+
+ let response = json!(ConfigBody {
+ shell: config.shell,
+ args: config.args,
+ log_path: config.log_path,
+ });
+
+ timer.observe_duration();
+ Ok(json(&response))
+}
+
+#[inline]
+#[utoipa::path(get, path = "/list", tag = "Process",
+ responses((status = 200, description = "List processes successfully", body = [ProcessItem]))
+)]
pub async fn list_handler() -> Result<impl Reply, Infallible> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["list"]).start_timer();
let data = Runner::new().json();
HTTP_COUNTER.inc();
timer.observe_duration();
Ok(json(&data))
}
#[inline]
#[utoipa::path(get, tag = "Process", path = "/process/{id}/logs/{kind}",
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)
)
)]
pub async fn log_handler(id: usize, kind: String) -> Result<impl Reply, Rejection> {
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" => global!("pmc.logs.out", item.name.as_str()),
"error" | "stderr" => global!("pmc.logs.error", item.name.as_str()),
_ => global!("pmc.logs.out", item.name.as_str()),
};
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(&json!(LogResponse { logs })))
}
Err(_) => Ok(json(&json!(LogResponse { logs: vec![] }))),
}
}
None => {
timer.observe_duration();
Err(reject::not_found())
}
}
}
#[inline]
#[utoipa::path(get, tag = "Process", path = "/process/{id}/logs/{kind}/raw",
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 raw", body = String),
(status = NOT_FOUND, description = "Process was not found", body = ErrorMessage)
)
)]
pub async fn log_handler_raw(id: usize, kind: String) -> Result<impl Reply, Rejection> {
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" => global!("pmc.logs.out", item.name.as_str()),
"error" | "stderr" => global!("pmc.logs.error", item.name.as_str()),
_ => global!("pmc.logs.out", item.name.as_str()),
};
let data = match fs::read_to_string(log_file) {
Ok(data) => data,
Err(err) => err.to_string(),
};
timer.observe_duration();
Ok(Response::new(Body::from(data)))
}
None => {
timer.observe_duration();
Err(reject::not_found())
}
}
}
#[inline]
#[utoipa::path(get, tag = "Process", path = "/process/{id}/info",
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)
)
)]
pub async fn info_handler(id: usize) -> Result<impl Reply, Rejection> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["info"]).start_timer();
let runner = Runner::new();
let mut item = runner.get(id);
HTTP_COUNTER.inc();
timer.observe_duration();
Ok(json(&item.json()))
}
#[inline]
#[utoipa::path(post, tag = "Process", path = "/process/create", request_body(content = CreateBody),
responses(
(status = 200, description = "Create process successful", body = ActionResponse),
(status = INTERNAL_SERVER_ERROR, description = "Failed to create process", body = ErrorMessage)
)
)]
pub async fn create_handler(body: CreateBody) -> Result<impl Reply, Rejection> {
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, &body.watch).save();
timer.observe_duration();
Ok(attempt(true, "create"))
}
#[inline]
#[utoipa::path(post, tag = "Process", path = "/process/{id}/rename", request_body(content = String),
params(("id" = usize, Path, description = "Process id to rename", example = 0)),
responses(
(status = 200, description = "Rename process successful", body = ActionResponse),
(status = NOT_FOUND, description = "Process was not found", body = ErrorMessage)
)
)]
pub async fn rename_handler(id: usize, body: String) -> Result<impl Reply, Rejection> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["rename"]).start_timer();
let mut runner = Runner::new();
let process = runner.clone().process(id).clone();
HTTP_COUNTER.inc();
if runner.exists(id) {
let mut item = runner.get(id);
item.rename(body.trim().replace("\n", ""));
then!(process.running, item.restart());
timer.observe_duration();
Ok(attempt(true, "rename"))
} else {
timer.observe_duration();
Err(reject::not_found())
}
}
#[inline]
#[utoipa::path(get, tag = "Process", path = "/process/{id}/env",
params(("id" = usize, Path, description = "Process id to fetch env from", example = 0)),
responses(
(status = 200, description = "Current process env", body = HashMap<String, String>),
(status = NOT_FOUND, description = "Process was not found", body = ErrorMessage)
)
)]
pub async fn env_handler(id: usize) -> Result<impl Reply, Rejection> {
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(reject::not_found())
}
}
}
#[inline]
#[utoipa::path(post, tag = "Process", path = "/process/{id}/action", request_body = ActionBody,
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)
)
)]
pub async fn action_handler(id: usize, body: ActionBody) -> Result<impl Reply, Rejection> {
let timer = HTTP_REQ_HISTOGRAM.with_label_values(&["action"]).start_timer();
let mut runner = Runner::new();
let method = body.method.as_str();
HTTP_COUNTER.inc();
match method {
"start" | "restart" => {
runner.get(id).restart();
timer.observe_duration();
Ok(attempt(true, method))
}
"stop" | "kill" => {
runner.get(id).stop();
timer.observe_duration();
Ok(attempt(true, method))
}
"remove" | "delete" => {
runner.remove(id);
timer.observe_duration();
Ok(attempt(true, method))
}
_ => {
timer.observe_duration();
Err(reject::not_found())
}
}
}
#[inline]
-#[utoipa::path(get, tag = "Daemon", path = "/metrics", responses((status = 200, description = "Get daemon metrics", body = MetricsRoot)))]
+#[utoipa::path(get, tag = "Daemon", path = "/daemon/metrics",
+ responses((status = 200, description = "Get daemon metrics", body = MetricsRoot))
+)]
pub async fn metrics_handler() -> Result<impl Reply, Infallible> {
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_rmp(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 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"),
};
let response = json!(MetricsRoot {
version: Version {
pkg: format!("v{}", env!("CARGO_PKG_VERSION")),
hash: env!("GIT_HASH_FULL"),
build_date: env!("BUILD_DATE"),
target: env!("PROFILE"),
},
daemon: Daemon {
pid: pid,
running: pid::exists(),
uptime: uptime,
process_count: runner.count(),
daemon_type: global!("pmc.daemon.kind"),
stats: Stats { memory_usage, cpu_percent }
}
});
timer.observe_duration();
Ok(json(&response))
}
diff --git a/src/daemon/log.rs b/src/daemon/log.rs
index 8429643..783c793 100644
--- a/src/daemon/log.rs
+++ b/src/daemon/log.rs
@@ -1,23 +1,24 @@
use chrono::Local;
use global_placeholders::global;
use std::fs::{File, OpenOptions};
use std::io::{self, Write};
pub struct Logger {
file: File,
}
impl Logger {
pub fn new() -> io::Result<Self> {
let file = OpenOptions::new().create(true).append(true).open(global!("pmc.daemon.log"))?;
Ok(Logger { file })
}
pub fn write(&mut self, message: &str) {
+ log::info!("{message}");
writeln!(&mut self.file, "[{}] {}", Local::now().format("%Y-%m-%d %H:%M:%S%.3f"), message).unwrap()
}
}
#[macro_export]
macro_rules! log { ($message:expr $(, $arg:expr)*) =>
{ crate::daemon::log::Logger::new().unwrap().write(format!($message $(, $arg)*).as_str()) }}
diff --git a/src/daemon/pid.rs b/src/daemon/pid.rs
index 7ce4671..7bed24c 100644
--- a/src/daemon/pid.rs
+++ b/src/daemon/pid.rs
@@ -1,65 +1,69 @@
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use global_placeholders::global;
use macros_rs::crashln;
-use pmc::helpers;
+use pmc::{file::Exists, helpers};
use std::{fs, io};
pub fn exists() -> bool { fs::metadata(global!("pmc.pid")).is_ok() }
pub fn running(pid: i32) -> bool { unsafe { libc::kill(pid, 0) == 0 } }
pub fn uptime() -> io::Result<DateTime<Utc>> {
let metadata = fs::metadata(global!("pmc.pid"))?;
let creation_time = metadata.created()?;
let creation_time = DateTime::from(creation_time);
Ok(creation_time)
}
pub fn read() -> Result<i32> {
let pid = fs::read_to_string(global!("pmc.pid")).map_err(|err| anyhow!(err))?;
let trimmed_pid = pid.trim();
let parsed_pid = trimmed_pid.parse::<i32>().map_err(|err| anyhow!(err))?;
Ok(parsed_pid)
}
pub fn write(pid: u32) {
if let Err(err) = fs::write(global!("pmc.pid"), pid.to_string()) {
crashln!("{} Failed to write PID to file: {}", *helpers::FAIL, err);
}
}
pub fn remove() {
- log::debug!("Stale PID file detected. Removing the PID file.");
- if let Err(err) = fs::remove_file(global!("pmc.pid")) {
- crashln!("{} Failed to remove PID file: {}", *helpers::FAIL, err);
+ if Exists::check(&global!("pmc.pid")).file() {
+ log::warn!("Stale PID file detected. Removing the PID file.");
+ if let Err(err) = fs::remove_file(global!("pmc.pid")) {
+ crashln!("{} Failed to remove PID file: {}", *helpers::FAIL, err);
+ }
+ } else {
+ log::info!("No Stale PID file detected.");
}
}
#[cfg(target_os = "linux")]
pub fn name(new_name: &str) {
use std::ffi::CString;
let pid = std::process::id() as libc::pid_t;
let name_cstr = CString::new(new_name).expect("Failed to convert name to CString");
unsafe {
libc::setpgid(pid, 0);
libc::prctl(libc::PR_SET_NAME, name_cstr.as_ptr() as libc::c_ulong, 0, 0, 0);
}
}
#[cfg(target_os = "macos")]
pub fn name(new_name: &str) {
use std::ffi::CString;
let pid = std::process::id() as libc::pid_t;
let name_cstr = CString::new(new_name).expect("Failed to convert name to CString");
unsafe {
libc::setpgid(pid, 0);
libc::pthread_setname_np(name_cstr.as_ptr());
}
}
diff --git a/src/file.rs b/src/file.rs
index 7d855f2..ea27250 100644
--- a/src/file.rs
+++ b/src/file.rs
@@ -1,114 +1,117 @@
use crate::{helpers, log};
-use anyhow::Error;
use colored::Colorize;
-use macros_rs::{crashln, str, string, ternary};
+use macros_rs::{crashln, string, ternary};
use std::{
env,
fs::{self, File},
io::{self, BufRead, BufReader},
path::{Path, PathBuf},
thread::sleep,
time::Duration,
};
pub fn logs(lines_to_tail: usize, log_file: &str, id: usize, log_type: &str, item_name: &str) {
let file = File::open(log_file).unwrap();
let reader = BufReader::new(file);
let lines: Vec<String> = reader.lines().collect::<io::Result<_>>().unwrap();
logs_internal(lines, lines_to_tail, log_file, id, log_type, item_name)
}
pub fn logs_internal(lines: Vec<String>, lines_to_tail: usize, log_file: &str, id: usize, log_type: &str, item_name: &str) {
let color = ternary!(log_type == "out", "green", "red");
println!("{}", format!("\n{log_file} last {lines_to_tail} lines:").bright_black());
let start_index = if lines.len() > lines_to_tail { lines.len() - lines_to_tail } else { 0 };
for (_, line) in lines.iter().skip(start_index).enumerate() {
println!("{} {}", format!("{}|{} |", id, item_name).color(color), line);
}
}
pub fn cwd() -> PathBuf {
match env::current_dir() {
Ok(path) => path,
Err(_) => crashln!("{} Unable to find current working directory", *helpers::FAIL),
}
}
pub fn make_relative(current: &Path, home: &Path) -> PathBuf {
match current.strip_prefix(home) {
Err(_) => Path::new(home).join(current),
Ok(relative_path) => Path::new("~").join(relative_path),
}
}
-pub struct Exists;
-impl Exists {
- pub fn folder(dir_name: String) -> Result<bool, Error> { Ok(Path::new(str!(dir_name)).is_dir()) }
- pub fn file(file_name: String) -> Result<bool, Error> { Ok(Path::new(str!(file_name)).exists()) }
+pub struct Exists<'p> {
+ path: &'p str,
+}
+
+impl<'p> Exists<'p> {
+ pub fn check(path: &'p str) -> Self { Self { path } }
+ pub fn folder(&self) -> bool { Path::new(self.path).is_dir() }
+ pub fn file(&self) -> bool { Path::new(self.path).exists() }
}
pub fn raw(path: String) -> Vec<u8> {
match fs::read(&path) {
Ok(contents) => contents,
Err(err) => crashln!("{} Cannot find dumpfile.\n{}", *helpers::FAIL, string!(err).white()),
}
}
pub fn read<T: serde::de::DeserializeOwned>(path: String) -> T {
let contents = match fs::read_to_string(&path) {
Ok(contents) => contents,
Err(err) => crashln!("{} Cannot find dumpfile.\n{}", *helpers::FAIL, string!(err).white()),
};
match toml::from_str(&contents).map_err(|err| string!(err)) {
Ok(parsed) => parsed,
Err(err) => crashln!("{} Cannot parse dumpfile.\n{}", *helpers::FAIL, err.white()),
}
}
pub fn from_rmp<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> T {
match rmp_serde::from_slice(&bytes) {
Ok(parsed) => parsed,
Err(err) => crashln!("{} Cannot parse file.\n{}", *helpers::FAIL, string!(err).white()),
}
}
pub fn read_rmp<T: serde::de::DeserializeOwned>(path: String) -> T {
let mut retry_count = 0;
let max_retries = 5;
let bytes = loop {
match fs::read(&path) {
Ok(contents) => break contents,
Err(err) => {
retry_count += 1;
if retry_count >= max_retries {
log!("{} Cannot find file.\n{}", *helpers::FAIL, string!(err).white());
} else {
log!("{} Error reading file. Retrying... (Attempt {}/{})", *helpers::FAIL, retry_count, max_retries);
}
}
}
sleep(Duration::from_secs(1));
};
retry_count = 0;
loop {
match rmp_serde::from_slice(&bytes) {
Ok(parsed) => break parsed,
Err(err) => {
retry_count += 1;
if retry_count >= max_retries {
log!("{} Cannot parse file.\n{}", *helpers::FAIL, string!(err).white());
} else {
log!("{} Error parsing file. Retrying... (Attempt {}/{})", *helpers::FAIL, retry_count, max_retries);
}
}
}
sleep(Duration::from_secs(1));
}
}
diff --git a/src/globals.rs b/src/globals.rs
index 55bd4ca..84a2844 100644
--- a/src/globals.rs
+++ b/src/globals.rs
@@ -1,39 +1,37 @@
use global_placeholders::init;
use macros_rs::crashln;
-use pmc::config;
-use pmc::file::Exists;
-use pmc::helpers;
+use pmc::{config, file::Exists, helpers};
use std::fs;
pub fn init() {
match home::home_dir() {
Some(path) => {
let path = path.display();
- if !Exists::folder(format!("{path}/.pmc/")).unwrap() {
+ if !Exists::check(&format!("{path}/.pmc/")).folder() {
fs::create_dir_all(format!("{path}/.pmc/")).unwrap();
log::info!("created pmc base dir");
}
let config = config::read();
- if !Exists::folder(config.runner.log_path.clone()).unwrap() {
+ if !Exists::check(&config.runner.log_path).folder() {
fs::create_dir_all(&config.runner.log_path).unwrap();
log::info!("created pmc log dir");
}
init!("pmc.base", format!("{path}/.pmc/"));
init!("pmc.log", format!("{path}/.pmc/pmc.log"));
init!("pmc.pid", format!("{path}/.pmc/daemon.pid"));
init!("pmc.dump", format!("{path}/.pmc/process.dump"));
init!("pmc.daemon.kind", config.daemon.kind);
init!("pmc.daemon.log", format!("{path}/.pmc/daemon.log"));
let out = format!("{}/{{}}-out.log", config.runner.log_path);
let error = format!("{}/{{}}-error.log", config.runner.log_path);
init!("pmc.logs.out", out);
init!("pmc.logs.error", error);
}
None => crashln!("{} Impossible to get your home directory", *helpers::FAIL),
}
}
diff --git a/src/log.rs b/src/log.rs
index 2aed2be..42f2773 100644
--- a/src/log.rs
+++ b/src/log.rs
@@ -1,21 +1,24 @@
use chrono::Local;
use global_placeholders::global;
use std::fs::{File, OpenOptions};
use std::io::{self, Write};
pub struct Logger {
file: File,
}
impl Logger {
pub fn new() -> io::Result<Self> {
let file = OpenOptions::new().create(true).append(true).open(global!("pmc.log"))?;
Ok(Logger { file })
}
- pub fn write(&mut self, message: &str) { writeln!(&mut self.file, "[{}] {}", Local::now().format("%Y-%m-%d %H:%M:%S%.3f"), message).unwrap() }
+ pub fn write(&mut self, message: &str) {
+ log::info!("{message}");
+ writeln!(&mut self.file, "[{}] {}", Local::now().format("%Y-%m-%d %H:%M:%S%.3f"), message).unwrap()
+ }
}
#[macro_export]
macro_rules! log { ($message:expr $(, $arg:expr)*) =>
{ log::Logger::new().unwrap().write(format!($message $(, $arg)*).as_str()) }}
diff --git a/src/main.rs b/src/main.rs
index 980d553..72ab771 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,169 +1,175 @@
mod cli;
mod daemon;
mod globals;
mod webui;
use crate::cli::Args;
use clap::{Parser, Subcommand};
-use clap_verbosity_flag::Verbosity;
+use clap_verbosity_flag::{LogLevel, Verbosity};
use macros_rs::{str, string, then};
fn validate_id_script(s: &str) -> Result<Args, String> {
if let Ok(id) = s.parse::<usize>() {
Ok(Args::Id(id))
} else {
Ok(Args::Script(s.to_owned()))
}
}
+#[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,
+ verbose: Verbosity<NoneLevel>,
}
#[derive(Subcommand)]
enum Daemon {
/// Reset process index
#[command(alias = "clean")]
Reset,
/// Stop daemon
#[command(alias = "kill")]
Stop,
/// Restart daemon
#[command(alias = "restart", alias = "start")]
Restore {
/// Daemon api
#[arg(long)]
api: bool,
/// WebUI using api
#[arg(long)]
webui: bool,
},
/// Check daemon
#[command(alias = "info", alias = "status")]
Health {
/// Format output
#[arg(long, default_value_t = string!("default"))]
format: String,
},
}
// add pmc restore command
#[derive(Subcommand)]
enum Commands {
/// Start/Restart a process
#[command(alias = "restart")]
Start {
/// Process name
#[arg(long)]
name: Option<String>,
#[clap(value_parser = validate_id_script)]
args: Option<Args>,
/// Watch to reload path
#[arg(long)]
watch: Option<String>,
/// Server
#[arg(short, long, default_value_t = string!("internal"))]
server: String,
},
/// Stop/Kill a process
#[command(alias = "kill")]
Stop {
id: usize,
/// Server
#[arg(short, long, default_value_t = string!("internal"))]
server: String,
},
/// Stop then remove a process
#[command(alias = "rm")]
Remove {
id: usize,
/// Server
#[arg(short, long, default_value_t = string!("internal"))]
server: String,
},
/// Get env of a process
#[command(alias = "cmdline")]
Env {
id: usize,
/// Server
#[arg(short, long, default_value_t = string!("internal"))]
server: String,
},
/// Get information of a process
#[command(alias = "info")]
Details {
id: usize,
/// Format output
#[arg(long, default_value_t = string!("default"))]
format: String,
/// Server
#[arg(short, long, default_value_t = string!("internal"))]
server: String,
},
/// List all processes
#[command(alias = "ls")]
List {
/// Format output
#[arg(long, default_value_t = string!("default"))]
format: String,
/// Server
#[arg(short, long, default_value_t = string!("all"))]
server: String,
},
/// Get logs from a process
Logs {
id: usize,
#[arg(long, default_value_t = 15, help = "")]
lines: usize,
/// Server
#[arg(short, long, default_value_t = string!("internal"))]
server: String,
},
/// Daemon management
Daemon {
#[command(subcommand)]
command: Daemon,
},
}
fn main() {
let cli = Cli::parse();
let mut env = env_logger::Builder::new();
let level = cli.verbose.log_level_filter();
globals::init();
env.filter_level(level).init();
match &cli.command {
Commands::Start { name, args, watch, server } => cli::start(name, args, watch, server),
Commands::Stop { id, server } => cli::stop(id, server),
Commands::Remove { id, server } => cli::remove(id, server),
Commands::Env { id, server } => cli::env(id, server),
Commands::Details { id, format, server } => cli::info(id, format, server),
Commands::List { format, server } => cli::list(format, server),
Commands::Logs { id, lines, server } => cli::logs(id, lines, 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() != "ERROR"),
},
};
if !matches!(&cli.command, Commands::Daemon { .. }) {
then!(!daemon::pid::exists(), daemon::start(false));
}
}
diff --git a/src/process/dump.rs b/src/process/dump.rs
index efa87f7..27982cc 100644
--- a/src/process/dump.rs
+++ b/src/process/dump.rs
@@ -1,67 +1,67 @@
use crate::{
file::{self, Exists},
helpers, log,
process::{id::Id, Runner},
};
use colored::Colorize;
use global_placeholders::global;
use macros_rs::{crashln, fmtstr, string};
use reqwest::blocking::Client;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use std::{collections::BTreeMap, fs};
-pub fn from<'r>(address: &str, token: Option<&str>) -> Result<Runner, anyhow::Error> {
+pub fn from(address: &str, token: Option<&str>) -> Result<Runner, anyhow::Error> {
let client = Client::new();
let mut headers = HeaderMap::new();
if let Some(token) = token {
headers.insert(AUTHORIZATION, HeaderValue::from_static(fmtstr!("token {token}")));
}
- let response = client.get(fmtstr!("{address}/dump")).headers(headers).send()?;
+ let response = client.get(fmtstr!("{address}/daemon/dump")).headers(headers).send()?;
let bytes = response.bytes()?;
Ok(file::from_rmp(&bytes))
}
pub fn read() -> Runner {
- if !Exists::file(global!("pmc.dump")).unwrap() {
+ if !Exists::check(&global!("pmc.dump")).file() {
let runner = Runner {
id: Id::new(0),
list: BTreeMap::new(),
remote: None,
};
write(&runner);
log!("created dump file");
}
file::read_rmp(global!("pmc.dump"))
}
pub fn raw() -> Vec<u8> {
- if !Exists::file(global!("pmc.dump")).unwrap() {
+ if !Exists::check(&global!("pmc.dump")).file() {
let runner = Runner {
id: Id::new(0),
list: BTreeMap::new(),
remote: None,
};
write(&runner);
log!("created dump file");
}
file::raw(global!("pmc.dump"))
}
pub fn write(dump: &Runner) {
let encoded: Vec<u8> = match rmp_serde::to_vec(&dump) {
Ok(contents) => contents,
Err(err) => crashln!("{} Cannot encode dump.\n{}", *helpers::FAIL, string!(err).white()),
};
if let Err(err) = fs::write(global!("pmc.dump"), encoded) {
crashln!("{} Error writing dumpfile.\n{}", *helpers::FAIL, string!(err).white())
}
}
diff --git a/src/process/hash.rs b/src/process/hash.rs
index a7dfa4e..e6dfb22 100644
--- a/src/process/hash.rs
+++ b/src/process/hash.rs
@@ -1,14 +1,14 @@
use macros_rs::crashln;
use merkle_hash::{bytes_to_hex, Algorithm, MerkleTree};
use std::path::PathBuf;
pub fn create(path: PathBuf) -> String {
- log::debug!("creating hash for {:?}", path);
+ log::info!("creating hash for {:?}", path);
let tree = match MerkleTree::builder(&path.to_str().unwrap()).algorithm(Algorithm::Blake3).hash_names(false).build() {
Ok(v) => v,
Err(e) => crashln!("Invalid UTF-8 sequence: {}", e),
};
log::trace!("hash {:?}", tree.root.item.hash);
bytes_to_hex(tree.root.item.hash)
}
diff --git a/src/process/http.rs b/src/process/http.rs
index e96756c..1e63271 100644
--- a/src/process/http.rs
+++ b/src/process/http.rs
@@ -1,78 +1,78 @@
use crate::process::Remote;
use macros_rs::{fmtstr, string};
use reqwest::blocking::{Client, Response};
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Serialize)]
struct ActionBody {
pub method: String,
}
#[derive(Deserialize)]
pub struct LogResponse {
pub logs: Vec<String>,
}
#[derive(Serialize)]
struct CreateBody<'c> {
pub name: &'c String,
pub script: &'c String,
pub path: PathBuf,
pub watch: &'c Option<String>,
}
fn client(token: &Option<String>) -> (Client, HeaderMap) {
let client = Client::new();
let mut headers = HeaderMap::new();
if let Some(token) = token {
headers.insert(AUTHORIZATION, HeaderValue::from_static(fmtstr!("token {token}")));
}
return (client, headers);
}
-pub fn info(Remote { address, token }: &Remote, id: usize) -> Result<Response, anyhow::Error> {
+pub fn info(Remote { address, token, .. }: &Remote, id: usize) -> Result<Response, anyhow::Error> {
let (client, headers) = 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<Response, anyhow::Error> {
+pub fn logs(Remote { address, token, .. }: &Remote, id: usize, kind: &str) -> Result<Response, anyhow::Error> {
let (client, headers) = client(token);
Ok(client.get(fmtstr!("{address}/process/{id}/logs/{kind}")).headers(headers).send()?)
}
-pub fn create(Remote { address, token }: &Remote, name: &String, script: &String, path: PathBuf, watch: &Option<String>) -> Result<Response, anyhow::Error> {
+pub fn create(Remote { address, token, .. }: &Remote, name: &String, script: &String, path: PathBuf, watch: &Option<String>) -> Result<Response, anyhow::Error> {
let (client, headers) = 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<Response, anyhow::Error> {
+pub fn restart(Remote { address, token, .. }: &Remote, id: usize) -> Result<Response, anyhow::Error> {
let (client, headers) = 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<Response, anyhow::Error> {
+pub fn rename(Remote { address, token, .. }: &Remote, id: usize, name: String) -> Result<Response, anyhow::Error> {
let (client, headers) = client(token);
Ok(client.post(fmtstr!("{address}/process/{id}/rename")).body(name).headers(headers).send()?)
}
-pub fn stop(Remote { address, token }: &Remote, id: usize) -> Result<Response, anyhow::Error> {
+pub fn stop(Remote { address, token, .. }: &Remote, id: usize) -> Result<Response, anyhow::Error> {
let (client, headers) = 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<Response, anyhow::Error> {
+pub fn remove(Remote { address, token, .. }: &Remote, id: usize) -> Result<Response, anyhow::Error> {
let (client, headers) = client(token);
let content = ActionBody { method: string!("remove") };
Ok(client.post(fmtstr!("{address}/process/{id}/action")).json(&content).headers(headers).send()?)
}
diff --git a/src/process/mod.rs b/src/process/mod.rs
index 7f2615b..4b50cd3 100644
--- a/src/process/mod.rs
+++ b/src/process/mod.rs
@@ -1,507 +1,523 @@
use crate::{
config,
config::structs::Server,
file, helpers,
service::{run, stop, ProcessMetadata},
};
use std::{
env,
path::PathBuf,
sync::{Arc, Mutex},
};
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 serde_json::{json, Value};
use std::collections::{BTreeMap, HashMap};
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,
}
#[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(Serialize, 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>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Process {
pub id: usize,
pub pid: i64,
pub name: String,
pub path: PathBuf,
pub script: String,
pub env: HashMap<String, String>,
#[serde(with = "ts_milliseconds")]
pub started: DateTime<Utc>,
pub restarts: u64,
pub running: bool,
pub crash: Crash,
pub watch: Watch,
}
#[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 Runner {
pub fn new() -> Self { dump::read() }
pub fn connect(name: String, Server { address, token }: Server, verbose: bool) -> Option<Self> {
- match dump::from(&address, token.as_deref()) {
- Ok(dump) => {
- then!(verbose, println!("{} Fetched remote (name={name}, address={address})", *helpers::SUCCESS));
- return Some(Runner {
- remote: Some(Remote { token, address: string!(address) }),
- ..dump
- });
- }
+ let remote_config = match config::from(&address, token.as_deref()) {
+ Ok(config) => config,
Err(err) => {
- log::debug!("{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,
});
self.list.insert(
id,
Process {
id,
pid,
path,
watch,
crash,
restarts: 0,
running: true,
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();
if let Err(err) = std::env::set_current_dir(&process.path) {
crashln!("{} Failed to set working directory {:?}\nError: {:#?}", *helpers::FAIL, path, err);
};
stop(process.pid);
process.running = false;
process.crash.crashed = false;
- process.crash.value = 0;
process.pid = run(ProcessMetadata {
args: config.args,
name: name.clone(),
shell: config.shell,
log_path: config.log_path,
command: script.to_string(),
});
process.running = true;
process.started = Utc::now();
+
+ then!(!dead, process.crash.value = 0);
then!(dead, process.restarts += 1);
}
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);
dump::write(&self);
}
}
pub fn set_id(&mut self, id: id::Id) {
self.id = id;
self.id.next();
dump::write(&self);
}
pub fn set_status(&mut self, id: usize, status: Status) {
self.process(id).running = status.to_bool();
dump::write(&self);
}
pub fn items(&mut 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(&mut self, id: usize) -> bool { self.list.contains_key(&id) }
pub fn info(&mut self, id: usize) -> Option<&Process> { self.list.get(&id) }
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 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 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);
stop(process.pid);
process.running = false;
process.crash.crashed = false;
process.crash.value = 0;
}
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 json(&mut self) -> Value {
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"),
- }
- };
+ 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),
});
}
json!(processes)
}
}
impl ProcessWrapper {
pub fn stop(&mut self) {
let runner_arc = Arc::clone(&self.runner);
let mut runner = runner_arc.lock().unwrap();
runner.stop(self.id).save();
}
pub fn watch(&mut self, path: &str) {
let runner_arc = Arc::clone(&self.runner);
let mut runner = runner_arc.lock().unwrap();
runner.watch(self.id, path, true).save();
}
pub fn disable_watch(&mut self) {
let runner_arc = Arc::clone(&self.runner);
let mut runner = runner_arc.lock().unwrap();
runner.watch(self.id, "", false).save();
}
pub fn rename(&mut self, name: String) {
let runner_arc = Arc::clone(&self.runner);
let mut runner = runner_arc.lock().unwrap();
runner.rename(self.id, name).save();
}
pub fn restart(&mut self) {
let runner_arc = Arc::clone(&self.runner);
let mut runner = runner_arc.lock().unwrap();
runner.restart(self.id, false).save();
}
pub fn crashed(&mut self) {
let runner_arc = Arc::clone(&self.runner);
let mut runner = runner_arc.lock().unwrap();
runner.new_crash(self.id).save();
runner.restart(self.id, true).save();
}
pub fn json(&mut self) -> Value {
let runner_arc = Arc::clone(&self.runner);
let mut runner = runner_arc.lock().unwrap();
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"),
}
};
json!(ItemSingle {
info: Info {
status,
id: item.id,
pid: item.pid,
name: item.name.clone(),
path: item.path.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: global!("pmc.logs.out", item.name.as_str()),
error: global!("pmc.logs.error", item.name.as_str()),
},
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/base.astro b/src/webui/src/components/base.astro
index 51188c6..8d64f75 100644
--- a/src/webui/src/components/base.astro
+++ b/src/webui/src/components/base.astro
@@ -1,60 +1,60 @@
---
import '@/styles.css'
import favicon from '@/public/favicon.svg?url'
import banner from '@/public/banner.png?url'
interface Props {
title: string;
description: string;
image?: string;
}
const { title, description } = Astro.props;
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<link rel="icon" type="image/svg+xml" href={favicon} />
<link rel="stylesheet" href="https://rsms.me/inter/inter.css">
<app-redirect data-base={"{{base_path | safe}}"} />
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={banner} />
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={banner} />
<script>
import { $settings } from '@/store';
class AppRedirect extends HTMLElement {
constructor() {
super();
const base = this.dataset.base;
- fetch(base + '/metrics', {
+ fetch(base + '/daemon/metrics', {
headers: { Authorization: 'token ' + $settings.get().token },
}).then(response => {
if (window.location.pathname.includes("login")) {
if (response.status === 200) {
window.location.href = base + "/";
}
} else {
if (response.status !== 200) {
window.location.href = base + '/login';
}
}
});
}
}
customElements.define('app-redirect', AppRedirect);
</script>
diff --git a/src/webui/src/components/react/login.tsx b/src/webui/src/components/react/login.tsx
index 06cf537..c98597e 100644
--- a/src/webui/src/components/react/login.tsx
+++ b/src/webui/src/components/react/login.tsx
@@ -1,78 +1,78 @@
import { $settings } from '@/store';
import { useState, Fragment } from 'react';
import favicon from '@/public/favicon.svg?url';
const Login = (props: { base: string }) => {
const [token, setToken] = useState('');
const [loginFailed, setLoginFailed] = useState(false);
const handleChange = (event: any) => setToken(event.target.value);
const handleSubmit = (event: any) => {
event.preventDefault();
$settings.setKey('token', token);
- fetch(props.base + '/metrics', {
+ fetch(props.base + '/daemon/metrics', {
headers: { Authorization: 'token ' + token },
}).then((response) => {
if (response.status === 200) {
window.location.href = props.base;
} else {
setLoginFailed(true);
setTimeout(() => {
setLoginFailed(false);
}, 3000);
}
});
};
return (
<Fragment>
{loginFailed && (
<div className="-mb-[92px] sm:mx-auto sm:w-full sm:max-w-[480px] sm:rounded-lg bg-red-600 sm:border border-red-400/50 p-4 sm:mt-4 sm:-mb-[110px]">
<h3 className="text-sm font-medium text-white">Failed to login. Is the token correct?</h3>
</div>
)}
<div className="h-screen flex items-center -mt-10">
<div className="flex min-h-full flex-1 flex-col justify-center px-0 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<img className="mx-auto h-10 w-auto" src={`${base}/${favicon}`} alt="PMC" />
<h2 className="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-zinc-200">Provide token to continue</h2>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px]">
<div className="px-5 py-6 rounded-lg bg-none sm:border border-zinc-700/30 sm:bg-zinc-900/10">
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<div>
<input
required
id="password"
name="password"
type="password"
value={token}
onChange={handleChange}
placeholder="••••••••••••••••"
autoComplete="current-password"
className="block w-full rounded-md border-0 bg-white/5 py-2 text-white shadow-sm ring-1 ring-inset ring-white/10 focus:ring-2 focus:ring-inset focus:ring-sky-500 sm:text-sm sm:leading-6 placeholder:text-zinc-600"
/>
</div>
</div>
<div>
<button
type="submit"
className="-mb-1 transition flex w-full justify-center rounded-md px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 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">
Continue
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</Fragment>
);
};
export default Login;
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Feb 1, 6:47 PM (1 d, 19 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
494887
Default Alt Text
(105 KB)
Attached To
Mode
rPMC Process Management Controller
Attached
Detach File
Event Timeline
Log In to Comment