Page MenuHomePhorge

No OneTemporary

Size
23 KB
Referenced Files
None
Subscribers
None
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

Mime Type
text/x-diff
Expires
Sun, Feb 1, 2:30 PM (1 d, 4 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
494790
Default Alt Text
(23 KB)

Event Timeline