Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F2707288
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
23 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/build.rs b/build.rs
index 66c23ef..6c6ad36 100644
--- a/build.rs
+++ b/build.rs
@@ -1,169 +1,169 @@
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";
+const NODE_VERSION: &str = "20.11.0";
fn extract_tar_gz(tar: &PathBuf, download_dir: &PathBuf) -> io::Result<()> {
let file = File::open(tar)?;
let decoder = GzDecoder::new(file);
let mut archive = Archive::new(decoder);
archive.unpack(download_dir)?;
Ok(fs::remove_file(tar)?)
}
fn download_file(url: String, destination: &PathBuf, download_dir: &PathBuf) {
if !download_dir.exists() {
fs::create_dir_all(download_dir).unwrap();
}
let mut response = reqwest::blocking::get(url).expect("Failed to send request");
let mut file = File::create(destination).expect("Failed to create file");
copy(&mut response, &mut file).expect("Failed to copy content");
}
fn download_node() -> PathBuf {
#[cfg(target_os = "linux")]
let target_os = "linux";
#[cfg(all(target_os = "macos"))]
let target_os = "darwin";
#[cfg(all(target_arch = "arm"))]
let target_arch = "armv7l";
#[cfg(all(target_arch = "x86_64"))]
let target_arch = "x64";
#[cfg(all(target_arch = "aarch64"))]
let target_arch = "arm64";
let download_url = format!("https://nodejs.org/dist/v{NODE_VERSION}/node-v{NODE_VERSION}-{target_os}-{target_arch}.tar.gz");
/* paths */
let download_dir = Path::new("target").join("downloads");
let node_extract_dir = download_dir.join(format!("node-v{NODE_VERSION}-{target_os}-{target_arch}"));
if node_extract_dir.is_dir() {
return node_extract_dir;
}
/* download node */
let node_archive = download_dir.join(format!("node-v{}-{}.tar.gz", NODE_VERSION, target_os));
download_file(download_url, &node_archive, &download_dir);
/* extract node */
if let Err(err) = extract_tar_gz(&node_archive, &download_dir) {
panic!("Failed to extract Node.js: {:?}", err)
}
println!("cargo:rustc-env=NODE_HOME={}", node_extract_dir.to_str().unwrap());
return node_extract_dir;
}
fn download_then_build(node_extract_dir: PathBuf) {
let base_dir = match fs::canonicalize(node_extract_dir) {
Ok(path) => path,
Err(err) => panic!("{err}"),
};
let bin = &base_dir.join("bin");
let node = &bin.join("node");
let project_dir = &Path::new("src").join("webui");
let npm = &base_dir.join("lib/node_modules/npm/index.js");
/* set path */
let mut paths = match env::var_os("PATH") {
Some(paths) => env::split_paths(&paths).collect::<Vec<PathBuf>>(),
None => vec![],
};
paths.push(bin.clone());
let path = match env::join_paths(paths) {
Ok(joined) => joined,
Err(err) => panic!("{err}"),
};
/* install deps */
Command::new(node)
.args([npm.to_str().unwrap(), "ci"])
.current_dir(project_dir)
.env("PATH", &path)
.status()
.expect("Failed to install dependencies");
/* build frontend */
Command::new(node)
.args(["node_modules/astro/astro.js", "build"])
.current_dir(project_dir)
.env("PATH", &path)
.status()
.expect("Failed to build frontend");
}
fn main() {
#[cfg(target_os = "windows")]
compile_error!("This project is not supported on Windows.");
#[cfg(target_arch = "x86")]
compile_error!("This project is not supported on 32 bit.");
/* version attributes */
let date = chrono::Utc::now();
let profile = env::var("PROFILE").unwrap();
let output = Command::new("git").args(&["rev-parse", "--short=10", "HEAD"]).output().unwrap();
let output_full = Command::new("git").args(&["rev-parse", "HEAD"]).output().unwrap();
println!("cargo:rustc-env=TARGET={}", env::var("TARGET").unwrap());
println!("cargo:rustc-env=GIT_HASH={}", String::from_utf8(output.stdout).unwrap());
println!("cargo:rustc-env=GIT_HASH_FULL={}", String::from_utf8(output_full.stdout).unwrap());
println!("cargo:rustc-env=BUILD_DATE={}-{}-{}", date.year(), date.month(), date.day());
/* profile matching */
match profile.as_str() {
"debug" => println!("cargo:rustc-env=PROFILE=debug"),
"release" => {
/* cleanup */
fs::remove_dir_all(format!("src/webui/dist")).ok();
println!("cargo:rustc-env=PROFILE=release");
/* pre-build */
let path = download_node();
download_then_build(path);
/* cc linking */
cxx_build::bridge("src/lib.rs")
.file("lib/bridge.cc")
.file("lib/process.cc")
.file("lib/fork.cc")
.include("lib/include")
.flag_if_supported("-std=c++17")
.compile("bridge");
}
_ => println!("cargo:rustc-env=PROFILE=none"),
}
let watched = vec![
"lib",
"src/lib.rs",
"lib/include",
"src/webui/src",
"src/webui/links.ts",
"src/webui/package.json",
"src/webui/tsconfig.json",
"src/webui/astro.config.mjs",
"src/webui/tailwind.config.mjs",
];
watched.iter().for_each(|file| println!("cargo:rerun-if-changed={file}"));
}
diff --git a/src/webui/src/components/react/index.tsx b/src/webui/src/components/react/index.tsx
index 6cee879..9a9b11e 100644
--- a/src/webui/src/components/react/index.tsx
+++ b/src/webui/src/components/react/index.tsx
@@ -1,140 +1,139 @@
import { api } from '@/api';
-import { $settings } from '@/store';
import Rename from '@/components/react/rename';
import { useEffect, useState, Fragment } from 'react';
import { Menu, Transition } from '@headlessui/react';
import { EllipsisVerticalIcon } from '@heroicons/react/20/solid';
const Index = (props: { base: string }) => {
- const servers = JSON.parse($settings.get().servers);
const [items, setItems] = useState([]);
const badge = {
online: 'bg-emerald-400',
stopped: 'bg-red-500',
crashed: 'bg-amber-400',
};
- const fetch = () => {
- api
- .get(props.base + '/list')
- .json()
- .then((res) => setItems(res));
+ async function fetch() {
+ const items = await api.get(props.base + '/list').json();
+ const servers = await api.get(props.base + '/daemon/servers').json();
- api
- .get(props.base + '/daemon/servers')
- .json()
- .then((res) => $settings.setKey('servers', JSON.stringify(res.servers)));
+ setItems(items.map((s) => ({ ...s, server: 'Internal' })));
- servers.forEach((s) => {
- api.get(s.address + '/list').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 }))]);
});
- };
+ }
const classNames = (...classes: Array<any>) => classes.filter(Boolean).join(' ');
+ const isRemote = (item: any): bool => (item.server == 'Internal' ? false : true);
const isRunning = (status: string): bool => (status == 'stopped' ? false : status == 'crashed' ? false : true);
const action = (id: number, name: string) => api.post(`${props.base}/process/${id}/action`, { json: { method: name } }).then(() => fetch());
useEffect(() => fetch(), []);
return (
<ul role="list" className="grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-4 xl:gap-x-8">
{items.map((item) => (
- <li key={item.id} className="rounded-lg border border-zinc-700/50 bg-zinc-900/10 hover:bg-zinc-900/40 hover:border-zinc-700">
+ <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">{isRunning(item.status) ? item.pid : 'none'}</div>
+ <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">
<Menu.Button 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" />
</Menu.Button>
<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">
<Menu.Items 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">
<Menu.Item>
{({ active }) => (
<a
onClick={() => action(item.id, 'restart')}
className={classNames(
active ? '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>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
onClick={() => action(item.id, 'stop')}
className={classNames(
active ? 'bg-yellow-400/10 text-amber-500' : 'text-zinc-200',
'rounded-md block p-2 w-full text-left cursor-pointer'
)}>
Terminate
</a>
)}
</Menu.Item>
</div>
<div className="p-1.5">
<Menu.Item>
{({ active }) => <Rename base={props.base} process={item.id} callback={fetch} active={active} old={item.name} />}
</Menu.Item>
</div>
<div className="p-1.5">
<Menu.Item>
{({ active }) => (
<a
onClick={() => action(item.id, 'delete')}
className={classNames(
active ? 'bg-red-700/10 text-red-500' : 'text-red-400',
'rounded-md block p-2 w-full text-left cursor-pointer'
)}>
Delete
</a>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
- <a href={`./view/${item.id}`}>
+ <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">restarts</dt>
- <dd className="text-zinc-500">{item.restarts}</dd>
+ <dt className="text-zinc-700">pid</dt>
+ <dd className="text-zinc-500">{isRunning(item.status) ? item.pid : 'stopped'}</dd>
</div>
<div className="flex justify-between gap-x-1 py-1">
- <dt className="text-zinc-700">cpu</dt>
- <dd className="text-zinc-500">{isRunning(item.status) ? item.cpu : 'offline'}</dd>
+ <dt className="text-zinc-700">cpu usage</dt>
+ <dd className="text-zinc-500">{isRunning(item.status) ? item.cpu : 'stopped'}</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>
+ <dd className="text-zinc-500">{isRunning(item.status) ? item.mem : 'stopped'}</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>
+ <dd className="text-zinc-500">{isRunning(item.status) ? item.uptime : 'stopped'}</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/view.tsx b/src/webui/src/components/react/view.tsx
index 0e2a7ec..0b17e5b 100644
--- a/src/webui/src/components/react/view.tsx
+++ b/src/webui/src/components/react/view.tsx
@@ -1,298 +1,315 @@
import { api } from '@/api';
import { matchSorter } from 'match-sorter';
import Rename from '@/components/react/rename';
import { Menu, Transition } from '@headlessui/react';
import { useEffect, useState, useRef, Fragment } from 'react';
import { EllipsisVerticalIcon } from '@heroicons/react/20/solid';
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 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: { base: string; id: number }) => {
+const LogViewer = (props: { server: string | null; base: string; id: number }) => {
const [logs, setLogs] = useState<string[]>([]);
const [loaded, setLoaded] = useState(false);
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 = () => {
api
.get(`${props.base}/process/${props.id}/logs/out`)
.json()
.then((data) => setLogs(data.logs))
.finally(() => setLoaded(true));
};
- useEffect(() => loadLogs(), []);
+ const loadLogsRemote = () => {
+ api
+ .get(`${props.base}/remote/${props.server}/logs/${props.id}/out`)
+ .json()
+ .then((data) => setLogs(data.logs))
+ .finally(() => setLoaded(true));
+ };
+
+ useEffect(() => (props.server != null ? loadLogsRemote() : loadLogs()), []);
useEffect(() => lastRow.current?.scrollIntoView(), [loaded]);
if (!loaded) {
return <div className="text-lg text-white font-bold">loading...</div>;
} 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"
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>
</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(() => fetch(), []);
+ useEffect(() => (server != null ? fetchRemote() : fetch()), []);
if (!loaded) {
return <div className="text-lg text-white font-bold">loading...</div>;
} 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">{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>
<div className="order-first flex-none rounded-full bg-sky-400/10 px-2 py-1 text-xs font-medium text-sky-400 ring-1 ring-inset ring-sky-400/30 sm:order-none">
{online ? item.info.pid : 'none'}
</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>
<Menu.Button 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" />
</Menu.Button>
</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">
<Menu.Items 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">
<Menu.Item>
{({ active }) => (
<a
onClick={() => action(props.id, 'stop')}
className={classNames(
active ? 'bg-yellow-400/10 text-amber-500' : 'text-zinc-200',
'rounded-md block p-2 w-full text-left cursor-pointer'
)}>
Terminate
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => <Rename base={props.base} process={props.id} active={active} callback={fetch} old={item.info.name} />}
</Menu.Item>
</div>
<div className="p-1.5">
<Menu.Item>
{({ active }) => (
<a
onClick={() => action(props.id, 'delete')}
className={classNames(
active ? 'bg-red-700/10 text-red-500' : 'text-red-400',
'rounded-md block p-2 w-full text-left cursor-pointer'
)}>
Delete
</a>
)}
</Menu.Item>
</div>
</Menu.Items>
</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 id={parseInt(props.id)} base={props.base} />
+ <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, 2:30 PM (1 d, 3 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
494790
Default Alt Text
(23 KB)
Attached To
Mode
rPMC Process Management Controller
Attached
Detach File
Event Timeline
Log In to Comment