Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2706590
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
43 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs
index 819d644..d2b1c21 100644
--- a/src/daemon/mod.rs
+++ b/src/daemon/mod.rs
@@ -1,313 +1,313 @@
#[macro_use]
mod log;
mod api;
mod fork;
use api::{DAEMON_CPU_PERCENTAGE, DAEMON_MEM_USAGE, DAEMON_START_TIME};
use chrono::{DateTime, Utc};
use colored::Colorize;
use fork::{daemon, Fork};
use global_placeholders::global;
use macros_rs::{crashln, str, string, ternary, then};
use psutil::process::{MemoryInfo, Process};
use serde::Serialize;
use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering};
use std::{process, thread::sleep, time::Duration};
use pmc::{
config, file,
helpers::{self, ColoredString},
process::{hash, id::Id, Runner, Status},
};
use tabled::{
settings::{
object::Columns,
style::{BorderColor, Style},
themes::Colorization,
Color, Rotate,
},
Table, Tabled,
};
static ENABLE_API: AtomicBool = AtomicBool::new(false);
static ENABLE_WEBUI: AtomicBool = AtomicBool::new(false);
extern "C" fn handle_termination_signal(_: libc::c_int) {
pid::remove();
log!("[daemon] killed", "pid" => process::id());
unsafe { libc::_exit(0) }
}
fn restart_process() {
for (id, item) in Runner::new().items_mut() {
let mut runner = Runner::new();
let children = pmc::service::find_chidren(item.pid);
if !children.is_empty() && children != item.children {
log!("[daemon] added", "children" => format!("{children:?}"));
runner.set_children(*id, children).save();
}
if item.running && item.watch.enabled {
let path = item.path.join(item.watch.path.clone());
let hash = hash::create(path);
if hash != item.watch.hash {
runner.restart(item.id, false);
log!("[daemon] watch reload", "name" => item.name, "hash" => "hash");
continue;
}
}
if !item.running && pid::running(item.pid as i32) {
Runner::new().set_status(*id, Status::Running);
log!("[daemon] process fix status", "name" => item.name, "id" => id);
continue;
}
then!(!item.running || pid::running(item.pid as i32), continue);
if item.running && item.crash.value == config::read().daemon.restarts {
log!("[daemon] process has crashed", "name" => item.name, "id" => id);
runner.stop(item.id);
runner.set_crashed(*id).save();
continue;
} else {
runner.get(item.id).crashed();
log!("[daemon] restarted", "name" => item.name, "id" => id, "crashes" => item.crash.value);
}
}
}
pub fn health(format: &String) {
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"));
#[derive(Clone, Debug, Tabled)]
struct Info {
#[tabled(rename = "pid file")]
pid_file: String,
#[tabled(rename = "fork path")]
path: String,
#[tabled(rename = "cpu percent")]
cpu_percent: String,
#[tabled(rename = "memory usage")]
memory_usage: String,
#[tabled(rename = "daemon type")]
external: String,
#[tabled(rename = "process count")]
process_count: usize,
uptime: String,
pid: String,
status: ColoredString,
}
impl Serialize for Info {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let trimmed_json = json!({
"pid_file": &self.pid_file.trim(),
"path": &self.path.trim(),
"cpu": &self.cpu_percent.trim(),
"mem": &self.memory_usage.trim(),
"process_count": &self.process_count.to_string(),
"uptime": &self.uptime.trim(),
"pid": &self.pid.trim(),
"status": &self.status.0.trim(),
});
trimmed_json.serialize(serializer)
}
}
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 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 uptime = match uptime {
Some(uptime) => helpers::format_duration(uptime),
None => string!("none"),
};
let pid = match pid {
Some(pid) => string!(pid),
None => string!("n/a"),
};
let data = vec![Info {
pid: pid,
cpu_percent,
memory_usage,
uptime: uptime,
path: global!("pmc.base"),
external: global!("pmc.daemon.kind"),
process_count: runner.count(),
pid_file: format!("{} ", global!("pmc.pid")),
status: ColoredString(ternary!(pid::exists(), "online".green().bold(), "stopped".red().bold())),
}];
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}"),
"default" => {
println!("{}\n{table}\n", format!("PMC daemon information").on_bright_white().black());
println!(" {}", format!("Use `pmc daemon restart` to restart the daemon").white());
println!(" {}", format!("Use `pmc daemon reset` to clean process id values").white());
}
_ => {}
};
};
}
pub fn stop() {
if pid::exists() {
println!("{} Stopping PMC daemon", *helpers::SUCCESS);
match pid::read() {
Ok(pid) => {
pmc::service::stop(pid as i64);
pid::remove();
log!("[daemon] stopped", "pid" => pid);
println!("{} PMC daemon stopped", *helpers::SUCCESS);
}
Err(err) => crashln!("{} Failed to read PID file: {}", *helpers::FAIL, err),
}
} else {
crashln!("{} The daemon is not running", *helpers::FAIL)
}
}
pub fn start(verbose: bool) {
let external = match global!("pmc.daemon.kind").as_str() {
"external" => true,
"default" => false,
"rust" => false,
"cc" => true,
_ => false,
};
println!("{} Spawning PMC daemon (pmc_base={})", *helpers::SUCCESS, global!("pmc.base"));
if ENABLE_API.load(Ordering::Acquire) {
println!(
"{} API server started (address={}, webui={})",
*helpers::SUCCESS,
config::read().fmt_address(),
ENABLE_WEBUI.load(Ordering::Acquire)
);
}
if pid::exists() {
match pid::read() {
Ok(pid) => then!(!pid::running(pid), pid::remove()),
Err(_) => crashln!("{} The daemon is already running", *helpers::FAIL),
}
}
#[inline]
#[tokio::main]
async extern "C" fn init() {
pid::name("PMC Restart Handler Daemon");
let config = config::read().daemon;
let api_enabled = ENABLE_API.load(Ordering::Acquire);
let ui_enabled = ENABLE_WEBUI.load(Ordering::Acquire);
unsafe { libc::signal(libc::SIGTERM, handle_termination_signal as usize) };
DAEMON_START_TIME.set(Utc::now().timestamp_millis() as f64);
pid::write(process::id());
log!("[daemon] new fork", "pid" => process::id());
if api_enabled {
log!("[api] server queued", "address" => config::read().fmt_address());
tokio::spawn(async move { api::start(ui_enabled).await });
}
loop {
if api_enabled {
if let Ok(mut process) = Process::new(process::id()) {
DAEMON_CPU_PERCENTAGE.observe(process.cpu_percent().ok().unwrap() as f64);
DAEMON_MEM_USAGE.observe(process.memory_info().ok().unwrap().rss() as f64);
}
}
then!(!Runner::new().is_empty(), restart_process());
sleep(Duration::from_millis(config.interval));
}
}
println!("{} PMC Successfully daemonized (type={})", *helpers::SUCCESS, global!("pmc.daemon.kind"));
if external {
let callback = pmc::Callback(init);
pmc::service::try_fork(false, verbose, callback);
} else {
match daemon(false, verbose) {
Ok(Fork::Parent(_)) => {}
Ok(Fork::Child) => init(),
Err(err) => crashln!("{} Daemon creation failed with code {err}", *helpers::FAIL),
}
}
}
pub fn restart(api: &bool, webui: &bool, verbose: bool) {
if pid::exists() {
stop();
}
let config = config::read().daemon;
if config.web.ui || *webui {
ENABLE_API.store(true, Ordering::Release);
ENABLE_WEBUI.store(true, Ordering::Release);
} else if config.web.api {
ENABLE_API.store(true, Ordering::Release);
} else {
ENABLE_API.store(*api, Ordering::Release);
}
start(verbose);
}
pub fn reset() {
let mut runner = Runner::new();
let largest = runner.size();
match largest {
Some(id) => runner.set_id(Id::from(str!(id.to_string()))),
- None => println!("{} Cannot reset index, no ID found", *helpers::FAIL),
+ None => runner.set_id(Id::new(0)),
}
println!("{} PMC Successfully reset (index={})", *helpers::SUCCESS, runner.id);
}
pub mod pid;
diff --git a/src/main.rs b/src/main.rs
index f3bdd00..06d1b3a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,245 +1,245 @@
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")]
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")]
+ #[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")]
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>,
},
/// 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::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/webui/src/components/react/index.tsx b/src/webui/src/components/react/index.tsx
index eb0c81b..65ed5b9 100644
--- a/src/webui/src/components/react/index.tsx
+++ b/src/webui/src/components/react/index.tsx
@@ -1,140 +1,142 @@
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';
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(), []);
+ 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">
<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>
{({ focus }) => <Rename base={props.base} process={item.id} callback={fetch} active={focus} old={item.name} />}
</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/modal.tsx b/src/webui/src/components/react/modal.tsx
index d34a637..1d5a787 100644
--- a/src/webui/src/components/react/modal.tsx
+++ b/src/webui/src/components/react/modal.tsx
@@ -1,51 +1,51 @@
import { Fragment } from 'react';
-import { Dialog, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
+import { Dialog, DialogTitle, DialogBackdrop, Transition, TransitionChild } from '@headlessui/react';
const Modal = (props: { show: boolean; callback: any; title: string; children: any }) => {
return (
<Transition show={props.show} as={Fragment}>
<Dialog as="div" className="fixed z-10 inset-0 overflow-y-auto" onClose={() => props.callback(false)}>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0">
- <Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-60 transition-opacity" style={{ backdropFilter: 'blur(5px)' }} />
+ <DialogBackdrop className="fixed inset-0 bg-black bg-opacity-60 transition-opacity" style={{ backdropFilter: 'blur(5px)' }} />
</TransitionChild>
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">
​
</span>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<div className="inline-block align-bottom bg-zinc-950 border border-zinc-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-zinc-950 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:text-left">
<DialogTitle as="h3" className="text-3xl leading-6 font-bold text-zinc-300 mb-[1.5rem]">
{props.title}
</DialogTitle>
<div className="mt-2">
<span className="text-sm text-zinc-400">{props.children}</span>
</div>
</div>
</div>
</div>
</div>
</TransitionChild>
</div>
</Dialog>
</Transition>
);
};
export default Modal;
diff --git a/src/webui/src/components/react/rename.tsx b/src/webui/src/components/react/rename.tsx
index df30ccb..aa4f22c 100644
--- a/src/webui/src/components/react/rename.tsx
+++ b/src/webui/src/components/react/rename.tsx
@@ -1,60 +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), []);
+ 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-300 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 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">
<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">
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"
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 7fe824d..e31ca2a 100644
--- a/src/webui/src/components/react/view.tsx
+++ b/src/webui/src/components/react/view.tsx
@@ -1,380 +1,387 @@
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]);
+ 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()), []);
+ 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>
<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">
<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">
<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>
</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">
{stats.map((stat: any, index: number) => (
<div
key={stat.name}
className={classNames(
index % 2 === 1 ? 'sm:border-l' : index === 2 ? 'lg: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>
{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;
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sun, Feb 1, 11:25 AM (9 h, 36 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
494682
Default Alt Text
(43 KB)
Attached To
Mode
rPMC Process Management Controller
Attached
Detach File
Event Timeline
Log In to Comment