wouldnt it be funny if I didn't commit for 24 hours straight
This commit is contained in:
parent
6e30007b4a
commit
a87d1df9ef
15 changed files with 782 additions and 112 deletions
109
Cargo.lock
generated
109
Cargo.lock
generated
|
@ -377,6 +377,39 @@ version = "0.8.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"memoffset",
|
||||
"scopeguard",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.3.9"
|
||||
|
@ -393,6 +426,12 @@ version = "0.15.7"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.1"
|
||||
|
@ -717,6 +756,15 @@ version = "2.6.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
|
@ -769,6 +817,15 @@ dependencies = [
|
|||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ntapi"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.46.0"
|
||||
|
@ -904,6 +961,26 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c27db03db7734835b3f53954b534c91069375ce6ccaa2e065441e07d9b6cdb1"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ce3fb6ad83f861aac485e76e1985cd109d9a3713802152be56c3b1f0e0658ed"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.4.1"
|
||||
|
@ -1084,12 +1161,14 @@ dependencies = [
|
|||
"futures-util",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sysinfo",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"xshell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -1115,6 +1194,21 @@ version = "0.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.29.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a18d114d420ada3a891e6bc8e96a2023402203296a47cdd65083377dad18ba5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"ntapi",
|
||||
"once_cell",
|
||||
"rayon",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.50"
|
||||
|
@ -1560,3 +1654,18 @@ name = "windows_x86_64_msvc"
|
|||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "xshell"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2107fe03e558353b4c71ad7626d58ed82efaf56c54134228608893c77023ad"
|
||||
dependencies = [
|
||||
"xshell-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xshell-macros"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e2c411759b501fb9501aac2b1b2d287a6e93e5bdcf13c25306b23e1b716dd0e"
|
||||
|
|
|
@ -14,9 +14,11 @@ dotenvy = "0.15"
|
|||
futures-util = "0.3"
|
||||
serde = "1"
|
||||
serde_json = "1"
|
||||
sysinfo = "0.29.10"
|
||||
thiserror = "1"
|
||||
time = { version = "0.3", features = ["serde"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tokio-stream = "0.1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
xshell = "0.2"
|
||||
|
|
|
@ -22,6 +22,9 @@ pub enum AppError {
|
|||
#[error("file error: {0}")]
|
||||
FileError(#[from] std::io::Error),
|
||||
|
||||
#[error("shell error: {0}")]
|
||||
ShellError(#[from] xshell::Error),
|
||||
|
||||
#[error("unexpected internal error: {0}")]
|
||||
Internal(#[from] anyhow::Error),
|
||||
|
||||
|
@ -51,6 +54,11 @@ impl IntoResponse for AppError {
|
|||
code: "file-error".to_string(),
|
||||
message: err.to_string(),
|
||||
},
|
||||
AppError::ShellError(err) => ErrorInfo {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
code: "shell-error".to_string(),
|
||||
message: err.to_string(),
|
||||
},
|
||||
AppError::Template(err) => ErrorInfo {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
code: "template-error".to_string(),
|
||||
|
|
10
src/main.rs
10
src/main.rs
|
@ -21,11 +21,20 @@ struct Args {
|
|||
/// Address:port to bind
|
||||
#[arg(short, long, default_value = "0.0.0.0:3000", env = "STAX_BIND")]
|
||||
bind: SocketAddr,
|
||||
|
||||
/// Path to arion
|
||||
#[arg(
|
||||
long = "arion-bin",
|
||||
env = "STAX_ARION_BIN",
|
||||
default_value = "/run/current-system/sw/bin/arion"
|
||||
)]
|
||||
arion_binary: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub stack_dir: PathBuf,
|
||||
pub arion_bin: PathBuf,
|
||||
pub docker: Docker,
|
||||
}
|
||||
|
||||
|
@ -47,6 +56,7 @@ async fn main() -> Result<()> {
|
|||
|
||||
let state = AppState {
|
||||
stack_dir: args.stack_dir,
|
||||
arion_bin: args.arion_binary,
|
||||
docker,
|
||||
};
|
||||
|
||||
|
|
|
@ -1,6 +1,11 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use crate::http::error::Result;
|
||||
use bollard::{
|
||||
container::InspectContainerOptions,
|
||||
container::{
|
||||
InspectContainerOptions, KillContainerOptions, RemoveContainerOptions,
|
||||
RestartContainerOptions, StartContainerOptions, StopContainerOptions,
|
||||
},
|
||||
service::{ContainerInspectResponse, ContainerSummary, MountPoint},
|
||||
Docker,
|
||||
};
|
||||
|
@ -17,6 +22,15 @@ pub struct ContainerInfo {
|
|||
pub created_at: String,
|
||||
pub volumes: Option<Vec<MountPoint>>,
|
||||
pub env: Option<Vec<String>>,
|
||||
pub labels: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl ContainerInfo {
|
||||
pub fn stack(&self) -> Option<String> {
|
||||
self.labels
|
||||
.clone()
|
||||
.and_then(|lab| lab.get("com.docker.compose.project").cloned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ContainerSummary> for ContainerInfo {
|
||||
|
@ -33,6 +47,7 @@ impl From<ContainerSummary> for ContainerInfo {
|
|||
image_id: value.image_id.unwrap_or_default(),
|
||||
created_at: created.time().to_string(),
|
||||
volumes: value.mounts,
|
||||
labels: value.labels,
|
||||
env: None,
|
||||
}
|
||||
}
|
||||
|
@ -53,6 +68,7 @@ impl From<ContainerInspectResponse> for ContainerInfo {
|
|||
image_id: value.image.unwrap_or_default(),
|
||||
created_at: value.created.unwrap_or_default(),
|
||||
volumes: value.mounts,
|
||||
labels: config.labels,
|
||||
env: config.env,
|
||||
}
|
||||
}
|
||||
|
@ -65,3 +81,40 @@ pub async fn get_info(docker: &Docker, name: &str) -> Result<ContainerInfo> {
|
|||
|
||||
Ok(info.into())
|
||||
}
|
||||
|
||||
pub async fn start(docker: &Docker, name: &str) -> Result<()> {
|
||||
Ok(docker
|
||||
.start_container(&name, None::<StartContainerOptions<String>>)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn restart(docker: &Docker, name: &str) -> Result<()> {
|
||||
Ok(docker
|
||||
.restart_container(&name, Some(RestartContainerOptions { t: 30 }))
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn stop(docker: &Docker, name: &str) -> Result<()> {
|
||||
Ok(docker
|
||||
.stop_container(&name, Some(StopContainerOptions { t: 30 }))
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn kill(docker: &Docker, name: &str) -> Result<()> {
|
||||
Ok(docker
|
||||
.kill_container(&name, None::<KillContainerOptions<String>>)
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn remove(docker: &Docker, name: &str) -> Result<()> {
|
||||
Ok(docker
|
||||
.remove_container(
|
||||
&name,
|
||||
Some(RemoveContainerOptions {
|
||||
v: false,
|
||||
force: true,
|
||||
link: false,
|
||||
}),
|
||||
)
|
||||
.await?)
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
pub mod container;
|
||||
pub mod error;
|
||||
pub mod stack;
|
||||
pub mod system;
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
use super::error::StackError;
|
||||
use super::{container::ContainerInfo, error::StackError};
|
||||
use crate::http::error::Result;
|
||||
use bollard::{container::ListContainersOptions, service::ContainerSummary, Docker};
|
||||
use serde::Serialize;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
use tokio::fs::{read_dir, try_exists};
|
||||
use xshell::Shell;
|
||||
|
||||
const COMPOSE_FILE: &str = "arion-compose.nix";
|
||||
|
||||
|
@ -41,18 +42,26 @@ pub struct StackInfo {
|
|||
pub active: bool,
|
||||
}
|
||||
|
||||
pub async fn list(base_dir: &Path, docker: &Docker) -> Result<Vec<StackInfo>> {
|
||||
let containers = docker
|
||||
.list_containers(Some(ListContainersOptions {
|
||||
all: false,
|
||||
#[derive(Serialize)]
|
||||
pub struct NodeInfo {
|
||||
pub stacks: Vec<StackInfo>,
|
||||
pub containers: Vec<ContainerInfo>,
|
||||
}
|
||||
|
||||
pub async fn list(base_dir: &Path, docker: &Docker) -> Result<NodeInfo> {
|
||||
let containers: Vec<ContainerInfo> = docker
|
||||
.list_containers(Some(ListContainersOptions::<String> {
|
||||
all: true,
|
||||
limit: None,
|
||||
filters: HashMap::from([("status".to_string(), vec!["running".to_string()])]),
|
||||
..Default::default()
|
||||
}))
|
||||
.await?;
|
||||
.await?
|
||||
.iter()
|
||||
.map(|c| c.clone().into())
|
||||
.collect();
|
||||
|
||||
let mut dirs = read_dir(base_dir).await?;
|
||||
let mut stacklist = vec![];
|
||||
let mut stacks = vec![];
|
||||
while let Some(dir) = dirs.next_entry().await? {
|
||||
let meta = dir.metadata().await?;
|
||||
if !meta.is_dir() {
|
||||
|
@ -61,17 +70,48 @@ pub async fn list(base_dir: &Path, docker: &Docker) -> Result<Vec<StackInfo>> {
|
|||
if is_stack(&dir.path()).await? {
|
||||
let name = dir.file_name().to_string_lossy().to_string();
|
||||
// Check status by analyzing containers
|
||||
let active = containers.iter().any(|cont| {
|
||||
let project = cont
|
||||
.clone()
|
||||
.labels
|
||||
.and_then(|lab| lab.get("com.docker.compose.project").cloned())
|
||||
.unwrap_or_default();
|
||||
name == project
|
||||
});
|
||||
stacklist.push(StackInfo { name, active })
|
||||
let active = containers
|
||||
.iter()
|
||||
.any(|cont| cont.state == "running" && cont.stack() == Some(name.clone()));
|
||||
stacks.push(StackInfo { name, active })
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stacklist)
|
||||
Ok(NodeInfo { stacks, containers })
|
||||
}
|
||||
|
||||
pub async fn command(
|
||||
base_dir: &Path,
|
||||
stack_name: &str,
|
||||
arion_bin: &Path,
|
||||
action: StackCommand,
|
||||
) -> Result<()> {
|
||||
let dir = base_dir.join(stack_name);
|
||||
if !is_stack(&dir).await? {
|
||||
return Err(StackError::NotFound.into());
|
||||
}
|
||||
|
||||
let sh = Shell::new()?;
|
||||
sh.change_dir(dir);
|
||||
sh.cmd(arion_bin).args(action.command()).run()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub enum StackCommand {
|
||||
Down,
|
||||
Start,
|
||||
Stop,
|
||||
Restart,
|
||||
}
|
||||
|
||||
impl StackCommand {
|
||||
fn command(&self) -> &[&str] {
|
||||
match self {
|
||||
StackCommand::Down => &["down"],
|
||||
StackCommand::Start => &["up", "-d"],
|
||||
StackCommand::Stop => &["stop"],
|
||||
StackCommand::Restart => &["restart"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
89
src/node/system.rs
Normal file
89
src/node/system.rs
Normal file
|
@ -0,0 +1,89 @@
|
|||
use serde::Serialize;
|
||||
use sysinfo::{CpuExt, DiskExt, NetworkExt, NetworksExt, RefreshKind, System, SystemExt};
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NetworkInterface {
|
||||
pub name: String,
|
||||
pub received: u64,
|
||||
pub transmitted: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CpuInfo {
|
||||
pub name: String,
|
||||
pub usage: f32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DiskInfo {
|
||||
pub device_name: String,
|
||||
pub mount_point: String,
|
||||
pub total_space: u64,
|
||||
pub available_space: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SystemInfo {
|
||||
// Node info
|
||||
pub name: String,
|
||||
pub host_name: String,
|
||||
pub os_version: String,
|
||||
pub kernel_version: String,
|
||||
|
||||
// RAM usage
|
||||
pub total_memory: u64,
|
||||
pub used_memory: u64,
|
||||
pub total_swap: u64,
|
||||
pub used_swap: u64,
|
||||
|
||||
// Multi-elem resources
|
||||
pub networks: Vec<NetworkInterface>,
|
||||
pub disks: Vec<DiskInfo>,
|
||||
|
||||
// CPU
|
||||
pub load_average: (f64, f64, f64),
|
||||
}
|
||||
|
||||
pub fn system_info() -> SystemInfo {
|
||||
let refresh_kind: RefreshKind = RefreshKind::new().with_disks_list().with_memory();
|
||||
let mut sys = System::new_with_specifics(refresh_kind);
|
||||
sys.refresh_specifics(refresh_kind);
|
||||
|
||||
let networks = sys
|
||||
.networks()
|
||||
.iter()
|
||||
.map(|(name, data)| NetworkInterface {
|
||||
name: name.clone(),
|
||||
received: data.received(),
|
||||
transmitted: data.transmitted(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let avg = sys.load_average();
|
||||
|
||||
let disks = sys
|
||||
.disks()
|
||||
.iter()
|
||||
.map(|disk| DiskInfo {
|
||||
device_name: disk.name().to_str().unwrap_or_default().to_string(),
|
||||
mount_point: disk.mount_point().to_str().unwrap_or_default().to_string(),
|
||||
total_space: disk.total_space(),
|
||||
available_space: disk.available_space(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
SystemInfo {
|
||||
name: sys.name().unwrap_or_default(),
|
||||
host_name: sys.host_name().unwrap_or_default(),
|
||||
os_version: sys.os_version().unwrap_or_default(),
|
||||
kernel_version: sys.kernel_version().unwrap_or_default(),
|
||||
total_memory: sys.total_memory(),
|
||||
used_memory: sys.used_memory(),
|
||||
total_swap: sys.total_swap(),
|
||||
used_swap: sys.used_swap(),
|
||||
networks,
|
||||
disks,
|
||||
load_average: (avg.one, avg.five, avg.fifteen),
|
||||
}
|
||||
}
|
|
@ -5,7 +5,8 @@ use crate::{
|
|||
accept::ExtractAccept,
|
||||
response::{reply, HandlerResponse},
|
||||
},
|
||||
node::container::{get_info, ContainerInfo},
|
||||
node::container::{get_info, kill, remove, restart, start, stop, ContainerInfo},
|
||||
route::AppError,
|
||||
AppState,
|
||||
};
|
||||
use askama::Template;
|
||||
|
@ -14,9 +15,9 @@ use axum::{
|
|||
extract::{Path, State},
|
||||
response::{
|
||||
sse::{Event, KeepAlive},
|
||||
Sse,
|
||||
Redirect, Sse,
|
||||
},
|
||||
routing::get,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use bollard::container::LogsOptions;
|
||||
|
@ -108,8 +109,36 @@ async fn get_log_stream(container_name: String, state: AppState) -> impl IntoRes
|
|||
Sse::new(stream).keep_alive(KeepAlive::default())
|
||||
}
|
||||
|
||||
macro_rules! container_command {
|
||||
($cmd: ident) => {
|
||||
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
|
||||
$cmd(&state.docker, &cont_name).await?;
|
||||
Ok(Redirect::to("./")) as Result<Redirect, AppError>
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/:container", get(get_one))
|
||||
.route(
|
||||
"/:container",
|
||||
get(|Path(cont_name): Path<String>| async move {
|
||||
Redirect::permanent(format!("{}/", &cont_name).as_str())
|
||||
}),
|
||||
)
|
||||
.route("/:container/", get(get_one))
|
||||
.route("/:container/log", get(get_log))
|
||||
.route("/:container/start", post(container_command!(start)))
|
||||
.route("/:container/stop", post(container_command!(stop)))
|
||||
.route("/:container/restart", post(container_command!(restart)))
|
||||
.route("/:container/kill", post(container_command!(kill)))
|
||||
.route(
|
||||
"/:container/remove",
|
||||
post(
|
||||
move |Path(cont_name): Path<String>, State(state): State<AppState>| async move {
|
||||
remove(&state.docker, &cont_name).await?;
|
||||
Ok(Redirect::to("/")) as Result<Redirect, AppError>
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
@ -3,11 +3,14 @@ use crate::{
|
|||
error::AppError,
|
||||
response::{reply, HandlerResponse},
|
||||
},
|
||||
node::stack::{list, StackInfo},
|
||||
node::{
|
||||
stack::{list, NodeInfo},
|
||||
system::{system_info, SystemInfo},
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
use askama::Template;
|
||||
use axum::{extract::State, routing::get, Router};
|
||||
use axum::{extract::State, response::IntoResponse, routing::get, Json, Router};
|
||||
use serde_json::json;
|
||||
|
||||
mod container;
|
||||
|
@ -16,19 +19,29 @@ mod stack;
|
|||
#[derive(Template)]
|
||||
#[template(path = "home.html")]
|
||||
struct HomeTemplate {
|
||||
stacks: Vec<StackInfo>,
|
||||
info: NodeInfo,
|
||||
system: SystemInfo,
|
||||
}
|
||||
|
||||
async fn home(State(state): State<AppState>) -> HandlerResponse {
|
||||
let list = list(&state.stack_dir, &state.docker)
|
||||
let info = list(&state.stack_dir, &state.docker)
|
||||
.await
|
||||
.map_err(AppError::from)?;
|
||||
reply(json!({ "stacks": list }), HomeTemplate { stacks: list })
|
||||
let system = system_info();
|
||||
reply(
|
||||
json!({ "info": info, "system": system }),
|
||||
HomeTemplate { info, system },
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_sys_info() -> impl IntoResponse {
|
||||
Json(system_info())
|
||||
}
|
||||
|
||||
pub(crate) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route("/", get(home))
|
||||
.route("/sysinfo", get(get_sys_info))
|
||||
.nest("/stack", stack::router())
|
||||
.nest("/container", container::router())
|
||||
}
|
||||
|
|
|
@ -1,16 +1,21 @@
|
|||
use askama::Template;
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
routing::get,
|
||||
response::Redirect,
|
||||
routing::{get, post},
|
||||
Router,
|
||||
};
|
||||
use futures_util::join;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
http::response::{reply, HandlerResponse},
|
||||
http::{
|
||||
error::AppError,
|
||||
response::{reply, HandlerResponse},
|
||||
},
|
||||
node::{
|
||||
container::ContainerInfo,
|
||||
stack::{get_compose, get_containers},
|
||||
stack::{command, get_compose, get_containers, StackCommand},
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
|
@ -24,8 +29,13 @@ struct GetOneTemplate {
|
|||
}
|
||||
|
||||
async fn get_one(Path(stack_name): Path<String>, State(state): State<AppState>) -> HandlerResponse {
|
||||
let file_contents = get_compose(&state.stack_dir, &stack_name).await?;
|
||||
let containers = get_containers(&state.docker, &stack_name).await?;
|
||||
let (file_contents_res, containers_res) = join!(
|
||||
get_compose(&state.stack_dir, &stack_name),
|
||||
get_containers(&state.docker, &stack_name)
|
||||
);
|
||||
|
||||
let file_contents = file_contents_res?;
|
||||
let containers = containers_res?;
|
||||
|
||||
reply(
|
||||
json!({
|
||||
|
@ -41,6 +51,29 @@ async fn get_one(Path(stack_name): Path<String>, State(state): State<AppState>)
|
|||
)
|
||||
}
|
||||
|
||||
pub(super) fn router() -> Router<AppState> {
|
||||
Router::new().route("/:stack", get(get_one))
|
||||
macro_rules! stack_command {
|
||||
($cmd: expr) => {
|
||||
move |Path(stack_name): Path<String>, State(state): State<AppState>| async move {
|
||||
command(&state.stack_dir, &stack_name, &state.arion_bin, $cmd).await?;
|
||||
Ok(Redirect::to("./")) as Result<Redirect, AppError>
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/:stack",
|
||||
get(|Path(stack_name): Path<String>| async move {
|
||||
Redirect::permanent(format!("{}/", &stack_name).as_str())
|
||||
}),
|
||||
)
|
||||
.route("/:stack/", get(get_one))
|
||||
.route("/:stack/start", post(stack_command!(StackCommand::Start)))
|
||||
.route(
|
||||
"/:stack/restart",
|
||||
post(stack_command!(StackCommand::Restart)),
|
||||
)
|
||||
.route("/:stack/stop", post(stack_command!(StackCommand::Stop)))
|
||||
.route("/:stack/down", post(stack_command!(StackCommand::Down)))
|
||||
}
|
||||
|
|
|
@ -10,8 +10,20 @@
|
|||
<link rel="stylesheet" href="https://iosevka-webfonts.github.io/iosevka/iosevka.css">
|
||||
<style>
|
||||
:root {
|
||||
background-color: #13131E;
|
||||
color: #E0DFFE;
|
||||
--background: #13131E;
|
||||
--bg-raised: #171625;
|
||||
--text: #E0DFFE;
|
||||
|
||||
--link: #FFC53D;
|
||||
--link-hover: #e28d0e;
|
||||
|
||||
--button-color: #B1A9FF;
|
||||
--button-bg: #202248;
|
||||
--button-border: #5B5BD6;
|
||||
--button-bg-hover: #7E451D;
|
||||
|
||||
background-color: var(--background);
|
||||
color: var(--text);
|
||||
font-family: Inter, sans-serif;
|
||||
}
|
||||
|
||||
|
@ -22,10 +34,10 @@
|
|||
|
||||
a[href],
|
||||
a[href]:visited {
|
||||
color: #FFC53D;
|
||||
color: var(--link);
|
||||
|
||||
&:hover {
|
||||
color: #e28d0e;
|
||||
color: var(--link-hover);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,7 +49,7 @@
|
|||
nav {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
background-color: #171625;
|
||||
background-color: var(--bg-raised);
|
||||
|
||||
& a {
|
||||
flex: 1;
|
||||
|
@ -46,18 +58,18 @@
|
|||
padding: 5px 8px;
|
||||
justify-content: center;
|
||||
max-width: 100px;
|
||||
box-shadow: inset 0 -2px #202248;
|
||||
box-shadow: inset 0 -2px var(--button-bg);
|
||||
font-size: 11pt;
|
||||
text-transform: uppercase;
|
||||
|
||||
&[href],
|
||||
&[href]:visited {
|
||||
color: #B1A9FF;
|
||||
color: var(--button-color);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #1E160F;
|
||||
box-shadow: inset 0 -2px #7E451D;
|
||||
box-shadow: inset 0 -2px var(--button-bg-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -72,6 +84,21 @@
|
|||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: var(--button-bg);
|
||||
color: var(--button-color);
|
||||
border: 1px solid var(--button-border);
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--button-bg-hover);
|
||||
color: var(--link-hover);
|
||||
border-color: var(--link-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
|
|
|
@ -4,10 +4,22 @@
|
|||
|
||||
{% block content %}
|
||||
<main>
|
||||
<h1>Container <span class="container-name">{{container_name}}</span></h1>
|
||||
<header>
|
||||
<h1>Container <span class="container-name">{{container_name}}</span> {% match info.stack() %}{% when
|
||||
Some
|
||||
with (stack) %}(<a class="stack-name" href="/stack/{{stack}}/">{{stack}}</a>)
|
||||
{% when None %}{% endmatch %}</h1>
|
||||
<div class="actions">
|
||||
<form action="./start" method="POST"><button type="submit">Start</button></form>
|
||||
<form action="./restart" method="POST"><button type="submit">Restart</button></form>
|
||||
<form action="./stop" method="POST"><button type="submit">Stop</button></form>
|
||||
<form action="./kill" method="POST"><button type="submit">Kill</button></form>
|
||||
<form action="./remove" method="POST"><button type="submit">Delete</button></form>
|
||||
</div>
|
||||
</header>
|
||||
<section class="status-section">
|
||||
<h2>Status</h2>
|
||||
<dl>
|
||||
<dl class="table">
|
||||
<dt>ID</dt>
|
||||
<dd>{{ info.id }}</dd>
|
||||
<dt>Status</dt>
|
||||
|
@ -18,6 +30,8 @@
|
|||
<dd>{{ info.image_id }}</dd>
|
||||
<dt>Created at</dt>
|
||||
<dd>{{ info.created_at }}</dd>
|
||||
</dl>
|
||||
<dl class="list">
|
||||
{% match info.volumes %}
|
||||
{% when Some with (volumes) %}
|
||||
<dt>Volumes</dt>
|
||||
|
@ -37,6 +51,15 @@
|
|||
{% endfor %}
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
{% match info.labels %}
|
||||
{% when Some with (labels) %}
|
||||
<dt>Labels</dt>
|
||||
{% for label in labels %}
|
||||
<dd class="label"><span class="key">{{ label.0 }}</span> = <span class="value">{{ label.1
|
||||
}}</span></dd>
|
||||
{% endfor %}
|
||||
{% when None %}
|
||||
{% endmatch %}
|
||||
</dl>
|
||||
</section>
|
||||
<section class="log-section">
|
||||
|
@ -46,6 +69,10 @@
|
|||
</main>
|
||||
|
||||
<style scoped>
|
||||
a.stack-name {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
@ -68,8 +95,15 @@
|
|||
color: #E796F3;
|
||||
}
|
||||
|
||||
.label {
|
||||
|
||||
& .key {
|
||||
color: #E796F3;
|
||||
}
|
||||
}
|
||||
|
||||
#log {
|
||||
background-color: #171625;
|
||||
background-color: var(--bg-raised);
|
||||
display: flex;
|
||||
|
||||
display: block;
|
||||
|
@ -98,37 +132,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
dl {
|
||||
border: 1px solid #3D3E82;
|
||||
background-color: #202248;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt,
|
||||
dd {
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
dd~dt,
|
||||
dd~dd {
|
||||
border-top: 1px solid #262A65;
|
||||
}
|
||||
|
||||
dt {
|
||||
float: left;
|
||||
width: 25%;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-left: 25%;
|
||||
border-left: 1px dotted #3D3E82;
|
||||
background-color: #171625;
|
||||
padding: 4px 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
dd[data-state] {
|
||||
background-color: #202248;
|
||||
}
|
||||
|
@ -145,14 +153,63 @@
|
|||
color: #FF9592;
|
||||
}
|
||||
|
||||
|
||||
dd:after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
dt,
|
||||
dd {
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
main>h1 {
|
||||
|
||||
dt {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
dd {
|
||||
background-color: #171625;
|
||||
padding: 4px 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
dl.table {
|
||||
border: 1px solid #3D3E82;
|
||||
background-color: #202248;
|
||||
margin: 0;
|
||||
|
||||
& dt {
|
||||
float: left;
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
& dd {
|
||||
margin-left: 25%;
|
||||
border-left: 1px dotted #3D3E82;
|
||||
}
|
||||
|
||||
|
||||
& dd:after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
|
||||
dl.list {
|
||||
border: 1px solid #3D3E82;
|
||||
background-color: #202248;
|
||||
margin: 0;
|
||||
border-top: 0;
|
||||
|
||||
& dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
& dt {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
main>header {
|
||||
grid-area: head;
|
||||
}
|
||||
|
||||
|
@ -172,10 +229,24 @@
|
|||
"info log log"
|
||||
"info log log";
|
||||
|
||||
grid-template-rows: 80px 1fr;
|
||||
grid-template-rows: 120px 1fr;
|
||||
grid-template-columns: 30vw 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
& form {
|
||||
display: flex;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
& button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="module">
|
||||
|
@ -190,7 +261,7 @@
|
|||
});
|
||||
|
||||
const logEl = document.querySelector("#log");
|
||||
const logSource = new EventSource(`${location.origin}${location.pathname}/log`);
|
||||
const logSource = new EventSource(`${location.origin}${location.pathname}log`);
|
||||
logSource.addEventListener("error", (ev) => {
|
||||
logSource.close();
|
||||
const err = document.createElement("div");
|
||||
|
|
|
@ -4,13 +4,107 @@
|
|||
|
||||
{% block content %}
|
||||
<main>
|
||||
<h2>All stacks</h2>
|
||||
<ul>
|
||||
{% for stack in stacks %}
|
||||
<li class="{% if stack.active %}active{% endif %}"><a href="/stack/{{stack.name}}">{{stack.name}}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<section class="system-info">
|
||||
<article>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Hostname</th>
|
||||
<td colspan="2">
|
||||
{{ system.host_name }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>OS info</th>
|
||||
<td colspan="2">
|
||||
{{ system.name }} {{ system.os_version }} (kernel {{
|
||||
system.kernel_version }})
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Load Avg</th>
|
||||
<td colspan="2">
|
||||
{{ system.load_average.0 }},
|
||||
{{ system.load_average.1 }},
|
||||
{{ system.load_average.2 }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Memory</th>
|
||||
<td>
|
||||
{{ system.used_memory|filesizeformat }}
|
||||
/ {{ system.total_memory|filesizeformat }}
|
||||
</td>
|
||||
<td>
|
||||
<progress id="memory" value="{{system.used_memory}}"
|
||||
max="{{system.total_memory}}">
|
||||
{{
|
||||
"{:.2}"|format(system.used_memory/system.total_memory*100)
|
||||
}}%
|
||||
</progress>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
</article>
|
||||
<article>
|
||||
<table>
|
||||
<tbody>
|
||||
|
||||
{% for disk in system.disks %}
|
||||
<tr>
|
||||
<th title="{{disk.device_name}}">{{disk.mount_point}}</th>
|
||||
<td>
|
||||
{{ (disk.total_space-disk.available_space)|filesizeformat }}
|
||||
/ {{ disk.total_space|filesizeformat }}
|
||||
</td>
|
||||
<td>
|
||||
<progress id="disk-{{disk.device_name}}"
|
||||
value="{{disk.total_space-disk.available_space}}"
|
||||
max="{{disk.total_space}}">
|
||||
</progress>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
</section>
|
||||
<section class="stack-list">
|
||||
<h2>Stacks</h2>
|
||||
<ul>
|
||||
{% for stack in info.stacks %}
|
||||
<li class="{% if stack.active %}active{% endif %}"><a
|
||||
href="/stack/{{stack.name}}/">{{stack.name}}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
<section class="container-list">
|
||||
<h2>Containers</h2>
|
||||
<table class="containers">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Stack</th>
|
||||
<th>State</th>
|
||||
<th>Image</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for container in info.containers %}
|
||||
<tr>
|
||||
<td><a href="/container/{{container.name}}/">{{container.name}}</a></td>
|
||||
<td>{{container.stack().unwrap_or_default()}}</td>
|
||||
<td class="status {{container.state}}">{{container.state}}</td>
|
||||
<td>{{container.image}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style scoped>
|
||||
|
@ -31,5 +125,71 @@
|
|||
border-left-color: #33B074;
|
||||
}
|
||||
}
|
||||
|
||||
.containers {
|
||||
background-color: var(--bg-raised);
|
||||
border: 3px solid #5958B1;
|
||||
border-radius: 3px;
|
||||
|
||||
& th,
|
||||
& td {
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
& th {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& thead {
|
||||
background-color: #202248;
|
||||
}
|
||||
|
||||
& .status {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
padding: 0 10px;
|
||||
|
||||
&.exited {
|
||||
background-color: #E5484D;
|
||||
}
|
||||
|
||||
&.running {
|
||||
background-color: #46A758;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container-list {
|
||||
grid-area: con;
|
||||
}
|
||||
|
||||
.stack-list {
|
||||
grid-area: stk;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
grid-area: sys;
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
& th {
|
||||
font-weight: bold;
|
||||
padding-right: 2ch;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
main {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"sys sys"
|
||||
"stk con";
|
||||
|
||||
grid-template-rows: 100px 1fr;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
|
@ -4,28 +4,40 @@
|
|||
|
||||
{% block content %}
|
||||
<main>
|
||||
<h1>Stack details for <span class="stack-name">{{stack_name}}</span></h1>
|
||||
<h2>Containers</h2>
|
||||
<table class="containers">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>State</th>
|
||||
<th>Image</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for container in containers %}
|
||||
<tr>
|
||||
<td><a href="/container/{{container.name}}">{{container.name}}</a></td>
|
||||
<td class="status {{container.state}}">{{container.state}}</td>
|
||||
<td>{{container.image}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<h2>Editor</h2>
|
||||
<textarea id="editor">{{file_contents}}</textarea>
|
||||
<header>
|
||||
<h1>Stack details for <span class="stack-name">{{stack_name}}</span></h1>
|
||||
<div class="actions">
|
||||
<form action="./start" method="POST"><button type="submit">Start</button></form>
|
||||
<form action="./restart" method="POST"><button type="submit">Restart</button></form>
|
||||
<form action="./stop" method="POST"><button type="submit">Stop</button></form>
|
||||
<form action="./down" method="POST"><button type="submit">Down</button></form>
|
||||
</div>
|
||||
</header>
|
||||
<section class="container-list">
|
||||
<h2>Containers</h2>
|
||||
<table class="containers">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>State</th>
|
||||
<th>Image</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for container in containers %}
|
||||
<tr>
|
||||
<td><a href="/container/{{container.name}}/">{{container.name}}</a></td>
|
||||
<td class="status {{container.state}}">{{container.state}}</td>
|
||||
<td>{{container.image}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<section class="editor">
|
||||
<h2>Editor</h2>
|
||||
<textarea id="editor">{{file_contents}}</textarea>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<style scoped>
|
||||
|
@ -37,8 +49,6 @@
|
|||
|
||||
h2 {
|
||||
text-transform: uppercase;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
|
@ -47,7 +57,7 @@
|
|||
}
|
||||
|
||||
.containers {
|
||||
background-color: #171625;
|
||||
background-color: var(--bg-raised);
|
||||
border: 3px solid #5958B1;
|
||||
border-radius: 3px;
|
||||
|
||||
|
@ -68,6 +78,7 @@
|
|||
text-align: center;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
padding: 0 10px;
|
||||
|
||||
&.exited {
|
||||
background-color: #E5484D;
|
||||
|
@ -88,6 +99,20 @@
|
|||
border: 3px solid #5958B1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
& form {
|
||||
display: flex;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
& button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.31.2/ace.min.js"
|
||||
|
|
Reference in a new issue