wouldnt it be funny if I didn't commit for 24 hours straight

This commit is contained in:
Hamcha 2023-11-21 01:36:42 +01:00
parent 6e30007b4a
commit a87d1df9ef
15 changed files with 782 additions and 112 deletions

109
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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(),

View file

@ -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,
};

View file

@ -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?)
}

View file

@ -1,3 +1,4 @@
pub mod container;
pub mod error;
pub mod stack;
pub mod system;

View file

@ -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
View 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),
}
}

View file

@ -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>
},
),
)
}

View file

@ -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())
}

View file

@ -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)))
}

View file

@ -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>

View file

@ -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");

View file

@ -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 %}

View file

@ -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"