Compare commits

..
50 Commits
Author SHA1 Message Date
mbecker20 9c0be07ae1 refactor caching to use custom Cache struct 2023-05-28 08:31:18 +00:00
mbecker20 ab945aadde fix set termination timeout 0 2023-05-25 20:18:39 +00:00
mbecker20 c1c461c273 add command crud and run api 2023-05-24 07:45:52 +00:00
mbecker20 336742ee69 update author and clap args 2023-05-24 05:28:46 +00:00
beckerinj 405dacce1c sanitize container logs for any script tags 2023-05-13 02:23:05 -04:00
beckerinj 9acd45aa93 fix log whitespace non preservation issue 2023-05-13 02:14:14 -04:00
beckerinj c889c2cc03 clean up log component imports 2023-05-13 01:49:06 -04:00
mbecker20 7ac91ef416 view images on server 2023-05-12 07:22:56 +00:00
mbecker20 8e28669aa1 potentially fix deployment update getting crossed with another deployment 2023-05-09 21:17:10 +00:00
mbecker20 6cdb91f8b8 more readable container state in header 2023-05-04 01:12:55 +00:00
mbecker20 e892474713 modify create deployment initializer 2023-05-03 21:03:57 +00:00
mbecker20 abdae98816 core handle term signal 2023-05-03 20:02:37 +00:00
mbecker20 ab4fe49f33 deployment / build config reset 2023-05-03 19:31:59 +00:00
mbecker20 1ace35103b fix ansi-to-html install 2023-05-03 07:21:38 +00:00
beckerinj dbee729eee show ansi colors in the logs correctly 2023-05-03 03:13:42 -04:00
mbecker20 792576ce59 add auto redeploy user 2023-05-02 17:21:25 +00:00
mbecker20 a07624e9b9 0.3.4 fix docker stop --signal on older docker versions 2023-05-01 21:03:25 +00:00
mbecker20 bb8054af8a log version first 2023-05-01 08:43:00 +00:00
mbecker20 7738f3e066 core logs version on startup 2023-05-01 08:34:20 +00:00
mbecker20 5dee16a100 0.3.3 add default term signal and timeout to deployment 2023-05-01 08:28:12 +00:00
mbecker20 35f3bcdf2f update core version 2023-05-01 03:05:51 +00:00
mbecker20 130ca8e1f1 bump versions to 0.3.2 2023-05-01 01:53:58 +00:00
mbecker20 ced4c21688 update monitor client to 0.3.1 2023-05-01 01:41:23 +00:00
mbecker20 6ec7078024 custom termination signals 2023-04-30 06:52:27 +00:00
mbecker20 b28d8f2506 update frontend types 2023-04-30 03:26:01 +00:00
mbecker20 c88a9291a0 support auto redeploy and custom stop signals 2023-04-30 00:10:59 +00:00
mbecker20 1e82d19306 build summary defaults to time view 2023-04-21 16:34:03 +00:00
mbecker20 dd87e50cb2 build stats summary 2023-04-21 08:52:17 +00:00
mbecker20 4c8f96a30f build stats card 2023-04-21 08:08:15 +00:00
mbecker20 c4f45e05f1 finish build stats api 2023-04-21 08:08:01 +00:00
mbecker20 6aa382c7c1 finish build stats api 2023-04-20 16:34:14 +00:00
mbecker20 ccb9f059e6 get build stats api 2023-04-20 07:34:49 +00:00
mbecker20 1cdcea0771 start on route to get daily build stats (time, count) 2023-04-19 07:02:21 +00:00
mbecker20 88dda0de80 update rename deployment to check whether deployment has repo attached, and if so, reclone it to account for name change. 2023-04-19 06:44:58 +00:00
mbecker20 30ed99e2b0 publish monitor client 0.3.1 with Readme 2023-04-18 07:56:30 +00:00
mbecker20 e5953b7541 monitor client readme 2023-04-18 07:55:05 +00:00
mbecker20 1f9d01c59f new home servers png 2023-04-18 06:31:23 +00:00
mbecker20 cc5210a3d8 fix server children add new button 2023-04-18 06:17:42 +00:00
mbecker20 26559e2d3b delete builds screenshots 2023-04-18 03:20:20 +00:00
mbecker20 7eeddb300f add link to screenshots docsite 2023-04-18 02:59:37 +00:00
mbecker20 1e01bae16b add screenshots to monitor readme 2023-04-18 02:47:46 +00:00
mbecker20 87c03924e5 remove second universal search 2023-04-18 02:46:46 +00:00
mbecker20 f0998b1d43 add universal search screenshot 2023-04-18 02:42:21 +00:00
mbecker20 1995a04244 add screenshots 2023-04-18 02:38:07 +00:00
mbecker20 420fe6bcd5 add build time to version selector 2023-04-17 08:26:34 +00:00
mbecker20 d4e26c0553 fix docker repo reference 2023-04-16 19:33:28 +00:00
mbecker20 5f5e7cb45e add note about oauth 2023-04-16 07:44:34 +00:00
beckerinj 8aa0304738 core setup doc 2023-04-16 03:32:06 -04:00
beckerinj 8ec98c33a4 first user is auto enabled and made admin 2023-04-16 03:23:30 -04:00
beckerinj 2667182ca3 update core example config 2023-04-16 02:49:00 -04:00
113 changed files with 3904 additions and 933 deletions
Generated
+301 -171
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "monitor_cli"
version = "0.3.0"
version = "0.3.4"
edition = "2021"
authors = ["MoghTech"]
description = "monitor cli | tools to setup monitor system"
+27 -25
View File
@@ -10,19 +10,16 @@ port = 9000
# daily utc offset in hours to send daily update. eg 8:00 eastern time is 13:00 UTC, so offset should be 13. default of 0 runs at UTC midnight.
daily_offset_hours = 13
# number of days to keep stats around, or 0 to disable pruning. stats older than this number of days are deleted daily
keep_stats_for_days = 120
# secret used to generate the jwt. should be some randomly generated hash.
jwt_secret = "your_jwt_secret"
# can be 1-hr, 12-hr, 1-day, 3-day, 1-wk, 2-wk, 30-day
jwt_valid_for = "1-wk"
# webhook url given by slack app
# webhook url given by slack app that monitor will send alerts and a daily update to
slack_url = "your_slack_app_webhook_url"
# token that has to be given to github during webhook config as the secret
# token that has to be given to github during repo webhook config as the secret
github_webhook_secret = "your_random_webhook_secret"
# optional. an alternate base url that is used to recieve github webhook requests. if not provided, will use 'host' address as base
@@ -31,30 +28,20 @@ github_webhook_base_url = "https://monitor-github-webhook.mogh.tech"
# token used to authenticate core requests to periphery
passkey = "your_random_passkey"
# can be 30-sec, 1-min, 2-min, 5-min
# controls the granularity of the system stats collection by monitor core
# can be 15-sec, 30-sec, 1-min, 2-min, 5-min
monitoring_interval = "1-min"
# number of days to keep stats around, or 0 to disable pruning. stats older than this number of days are deleted daily
keep_stats_for_days = 14
# these will be used by the GUI to attach to builds. New build docker orgs will default to first org (or none if empty).
# when attached to build, image will be pushed to repo under the specified organization
docker_organizations = ["your_docker_org1", "your_docker_org_2"]
# allow or deny user login with username / password
local_auth = true
# these will be given in the GUI to attach to builds. New build docker orgs will default to first org (or none if empty).
docker_organizations = ["your_docker_org1", "your_docker_org_2"]
[aws]
access_key_id = "your_aws_key_id"
secret_access_key = "your_aws_secret_key"
default_region = "us-east-1"
default_ami_id = "your_periphery_ami"
default_key_pair_name = "your_default_key_pair_name"
default_instance_type = "m5.2xlarge"
default_volume_gb = 8
default_subnet_id = "your_default_subnet_id"
default_security_group_ids = ["sg_id_1", "sg_id_2"]
default_assign_public_ip = false
[aws.available_ami_accounts]
your_periphery_ami = { name = "default ami", github = ["github_username"], docker = ["docker_username"] }
[github_oauth]
enabled = true
id = "your_github_client_id"
@@ -68,4 +55,19 @@ secret = "your_google_client_secret"
[mongo]
uri = "your_mongo_uri"
app_name = "monitor_core"
db_name = "monitor"
db_name = "monitor" # this is the name of the mongo database that monitor will create its collections in.
[aws]
access_key_id = "your_aws_key_id"
secret_access_key = "your_aws_secret_key"
default_region = "us-east-1"
default_ami_name = "your_ami_name" # must be defined below in [aws.available_ami_accounts]
default_instance_type = "m5.2xlarge"
default_volume_gb = 8
default_subnet_id = "your_default_subnet_id"
default_security_group_ids = ["sg_id_1", "sg_id_2"]
default_key_pair_name = "your_default_key_pair_name"
default_assign_public_ip = false
[aws.available_ami_accounts]
your_ami_name = { ami_id = "ami-1234567890", github = ["github_username"], docker = ["docker_username"] }
+13 -14
View File
@@ -1,6 +1,6 @@
[package]
name = "core"
version = "0.3.0"
version = "0.3.4"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -11,17 +11,16 @@ types = { package = "monitor_types", path = "../lib/types" }
db = { package = "db_client", path = "../lib/db_client" }
periphery = { package = "periphery_client", path = "../lib/periphery_client" }
axum_oauth2 = { path = "../lib/axum_oauth2" }
tokio = { version = "1.26", features = ["full"] }
tokio-tungstenite = { version = "0.18", features=["native-tls"] }
tokio-util = "0.7"
tokio = { version = "1.28", features = ["full"] }
tokio-tungstenite = { version = "0.19", features=["native-tls"] }
tokio-util = { version = "0.7"}
axum = { version = "0.6", features = ["ws", "json"] }
axum-extra = { version = "0.5.0", features = ["spa"] }
tower = { version = "0.4", features = ["full"] }
tower-http = { version = "0.4.0", features = ["cors"] }
tower = { version = "0.4", features = ["timeout"] }
tower-http = { version = "0.4", features = ["fs", "cors"] }
slack = { package = "slack_client_rs", version = "0.0.8" }
mungos = "0.3.14"
futures-util = "0.3"
mungos = "0.3.19"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
dotenv = "0.15"
envy = "0.4"
@@ -31,10 +30,10 @@ jwt = "0.16"
hmac = "0.12"
sha2 = "0.10"
async_timing_util = "0.1.14"
futures-util = "0.3"
diff-struct = "0.5"
typeshare = "1.0.0"
typeshare = "1.0.1"
hex = "0.4"
aws-config = "0.55"
aws-sdk-ec2 = "0.26"
merge_config_files = "0.1.3"
aws-config = "0.55.2"
aws-sdk-ec2 = "0.27.0"
merge_config_files = "0.1.3"
termination_signal = "0.1.2"
+110 -35
View File
@@ -3,13 +3,12 @@ use std::time::Duration;
use anyhow::{anyhow, Context};
use aws_sdk_ec2::Client;
use diff::Diff;
use futures_util::future::join_all;
use helpers::{all_logs_success, to_monitor_name};
use mungos::{doc, to_bson};
use mungos::mongodb::bson::{doc, to_bson};
use types::{
monitor_timestamp,
traits::{Busy, Permissioned},
AwsBuilderBuildConfig, Build, Log, Operation, PermissionLevel, Update, UpdateStatus,
UpdateTarget, Version,
monitor_timestamp, traits::Permissioned, AwsBuilderBuildConfig, Build, DockerContainerState,
Log, Operation, PermissionLevel, Update, UpdateStatus, UpdateTarget, Version,
};
use crate::{
@@ -42,13 +41,6 @@ impl State {
}
}
pub async fn build_busy(&self, id: &str) -> bool {
match self.build_action_states.lock().await.get(id) {
Some(a) => a.busy(),
None => false,
}
}
pub async fn create_build(&self, name: &str, user: &RequestUser) -> anyhow::Result<Build> {
if !user.is_admin && !user.create_build_permissions {
return Err(anyhow!("user does not have permission to create builds"));
@@ -116,7 +108,7 @@ impl State {
}
pub async fn delete_build(&self, build_id: &str, user: &RequestUser) -> anyhow::Result<Build> {
if self.build_busy(build_id).await {
if self.build_action_states.busy(build_id).await {
return Err(anyhow!("build busy"));
}
let build = self
@@ -146,21 +138,23 @@ impl State {
new_build: Build,
user: &RequestUser,
) -> anyhow::Result<Build> {
if self.build_busy(&new_build.id).await {
if self.build_action_states.busy(&new_build.id).await {
return Err(anyhow!("build busy"));
}
let id = new_build.id.clone();
{
let mut lock = self.build_action_states.lock().await;
let entry = lock.entry(id.clone()).or_default();
entry.updating = true;
}
self.build_action_states
.update_entry(id.clone(), |entry| {
entry.updating = true;
})
.await;
let res = self.update_build_inner(new_build, user).await;
{
let mut lock = self.build_action_states.lock().await;
let entry = lock.entry(id).or_default();
entry.updating = false;
}
self.build_action_states
.update_entry(id.clone(), |entry| {
entry.updating = false;
})
.await;
res
}
@@ -251,20 +245,20 @@ impl State {
}
pub async fn build(&self, build_id: &str, user: &RequestUser) -> anyhow::Result<Update> {
if self.build_busy(build_id).await {
if self.build_action_states.busy(build_id).await {
return Err(anyhow!("build busy"));
}
{
let mut lock = self.build_action_states.lock().await;
let entry = lock.entry(build_id.to_string()).or_default();
entry.building = true;
}
self.build_action_states
.update_entry(build_id.to_string(), |entry| {
entry.building = true;
})
.await;
let res = self.build_inner(build_id, user).await;
{
let mut lock = self.build_action_states.lock().await;
let entry = lock.entry(build_id.to_string()).or_default();
entry.building = false;
}
self.build_action_states
.update_entry(build_id.to_string(), |entry| {
entry.building = false;
})
.await;
res
}
@@ -415,6 +409,8 @@ impl State {
.await;
}
self.handle_post_build_redeploy(build_id, &mut update).await;
update.success = all_logs_success(&update.logs);
update.status = UpdateStatus::Complete;
update.end_ts = Some(monitor_timestamp());
@@ -424,6 +420,85 @@ impl State {
Ok(update)
}
async fn handle_post_build_redeploy(&self, build_id: &str, update: &mut Update) {
let redeploy_deployments = self
.db
.deployments
.get_some(
doc! { "build_id": build_id, "redeploy_on_build": true },
None,
)
.await;
if let Ok(deployments) = redeploy_deployments {
let futures = deployments.into_iter().map(|d| async move {
let request_user = RequestUser {
id: "auto redeploy".to_string(),
is_admin: true,
..Default::default()
};
let state = self
.get_deployment_with_container_state(&request_user, &d.id)
.await
.map(|r| r.state)
.unwrap_or_default();
if state == DockerContainerState::Running {
Some((
d.id.clone(),
self.deploy_container(
&d.id,
&RequestUser {
id: "auto redeploy".to_string(),
is_admin: true,
..Default::default()
},
None,
None,
)
.await,
))
} else {
None
}
});
let redeploy_results = join_all(futures).await;
let mut redeploys = Vec::<String>::new();
let mut redeploy_failures = Vec::<String>::new();
for res in redeploy_results {
if res.is_none() {
continue;
}
let (id, res) = res.unwrap();
match res {
Ok(_) => redeploys.push(id),
Err(e) => redeploy_failures.push(format!("{id}: {e:#?}")),
}
}
if redeploys.len() > 0 {
update.logs.push(Log::simple(
"redeploy",
format!("redeployed deployments: {}", redeploys.join(", ")),
))
}
if redeploy_failures.len() > 0 {
update.logs.push(Log::simple(
"redeploy failures",
redeploy_failures.join("\n"),
))
}
} else if let Err(e) = redeploy_deployments {
update.logs.push(Log::simple(
"redeploys failed",
format!("failed to get deployments to redeploy: {e:#?}"),
))
}
}
async fn create_ec2_instance_for_build(
&self,
build: &Build,
+238
View File
@@ -0,0 +1,238 @@
use anyhow::{anyhow, Context};
use diff::Diff;
use helpers::all_logs_success;
use types::{
monitor_timestamp, traits::Permissioned, Log, Operation, PeripheryCommand,
PeripheryCommandBuilder, PermissionLevel, Update, UpdateStatus, UpdateTarget,
};
use crate::{auth::RequestUser, state::State};
impl State {
pub async fn get_command_check_permissions(
&self,
command_id: &str,
user: &RequestUser,
permission_level: PermissionLevel,
) -> anyhow::Result<PeripheryCommand> {
let command = self.db.get_command(command_id).await?;
let permissions = command.get_user_permissions(&user.id);
if user.is_admin || permissions >= permission_level {
Ok(command)
} else {
Err(anyhow!(
"user does not have required permissions on this command"
))
}
}
pub async fn create_command(
&self,
name: &str,
server_id: String,
user: &RequestUser,
) -> anyhow::Result<PeripheryCommand> {
self.get_server_check_permissions(&server_id, user, PermissionLevel::Update)
.await?;
let start_ts = monitor_timestamp();
let command = PeripheryCommandBuilder::default()
.name(name.to_string())
.server_id(server_id)
.build()
.context("failed to build command")?;
let command_id = self
.db
.commands
.create_one(command)
.await
.context("failed at adding command to db")?;
let command = self.db.get_command(&command_id).await?;
let update = Update {
target: UpdateTarget::Command(command_id),
operation: Operation::CreateCommand,
start_ts,
end_ts: Some(monitor_timestamp()),
operator: user.id.clone(),
success: true,
..Default::default()
};
self.add_update(update).await?;
Ok(command)
}
pub async fn create_full_command(
&self,
mut command: PeripheryCommand,
user: &RequestUser,
) -> anyhow::Result<PeripheryCommand> {
command.id = self
.create_command(&command.name, command.server_id.clone(), user)
.await?
.id;
let command = self.update_command(command, user).await?;
Ok(command)
}
pub async fn copy_command(
&self,
target_id: &str,
new_name: String,
new_server_id: String,
user: &RequestUser,
) -> anyhow::Result<PeripheryCommand> {
let mut command = self
.get_command_check_permissions(target_id, user, PermissionLevel::Update)
.await?;
command.name = new_name;
command.server_id = new_server_id;
let command = self.create_full_command(command, user).await?;
Ok(command)
}
pub async fn delete_command(
&self,
command_id: &str,
user: &RequestUser,
) -> anyhow::Result<PeripheryCommand> {
if self.command_action_states.busy(command_id).await {
return Err(anyhow!("command busy"));
}
let command = self
.get_command_check_permissions(command_id, user, PermissionLevel::Update)
.await?;
let start_ts = monitor_timestamp();
self.db.commands.delete_one(command_id).await?;
let update = Update {
target: UpdateTarget::Command(command_id.to_string()),
operation: Operation::DeleteCommand,
start_ts,
end_ts: Some(monitor_timestamp()),
operator: user.id.clone(),
logs: vec![Log::simple(
"delete command",
format!("deleted command {}", command.name),
)],
success: true,
..Default::default()
};
self.add_update(update).await?;
Ok(command)
}
pub async fn update_command(
&self,
mut new_command: PeripheryCommand,
user: &RequestUser,
) -> anyhow::Result<PeripheryCommand> {
let current_command = self
.get_command_check_permissions(&new_command.id, user, PermissionLevel::Update)
.await?;
let start_ts = monitor_timestamp();
// none of these should be changed through this method
new_command.permissions = current_command.permissions.clone();
new_command.server_id = current_command.server_id.clone();
new_command.created_at = current_command.created_at.clone();
new_command.updated_at = start_ts.clone();
self.db
.commands
.update_one(
&new_command.id,
mungos::Update::Regular(new_command.clone()),
)
.await
.context("failed at update one command")?;
let diff = current_command.diff(&new_command);
let update = Update {
operation: Operation::UpdateCommand,
target: UpdateTarget::Command(new_command.id.clone()),
start_ts,
status: UpdateStatus::Complete,
logs: vec![Log::simple(
"command update",
serde_json::to_string_pretty(&diff).unwrap(),
)],
operator: user.id.clone(),
success: true,
..Default::default()
};
self.add_update(update.clone()).await?;
self.update_update(update).await?;
Ok(new_command)
}
pub async fn run_command(
&self,
command_id: &str,
user: &RequestUser,
) -> anyhow::Result<Update> {
if self.command_action_states.busy(command_id).await {
return Err(anyhow!("command busy"));
}
self.command_action_states
.update_entry(command_id.to_string(), |entry| {
entry.running = true;
})
.await;
let res = self.run_command_inner(command_id, user).await;
self.command_action_states
.update_entry(command_id.to_string(), |entry| {
entry.running = false;
})
.await;
res
}
async fn run_command_inner(
&self,
command_id: &str,
user: &RequestUser,
) -> anyhow::Result<Update> {
let start_ts = monitor_timestamp();
let command = self
.get_command_check_permissions(command_id, user, PermissionLevel::Execute)
.await?;
if command.command.path.is_empty() || command.command.command.is_empty() {
return Err(anyhow!("command or path is empty, aborting"));
}
let server = self.db.get_server(&command.server_id).await?;
let mut update = Update {
target: UpdateTarget::Command(command_id.to_string()),
operation: Operation::RunCommand,
start_ts,
status: UpdateStatus::InProgress,
success: true,
operator: user.id.clone(),
..Default::default()
};
update.id = self.add_update(update.clone()).await?;
match self.periphery.run_command(&server, &command.command).await {
Ok(log) => {
update.logs.push(log);
}
Err(e) => {
update
.logs
.push(Log::error("clone repo", format!("{e:#?}")));
}
}
update.success = all_logs_success(&update.logs);
update.status = UpdateStatus::Complete;
update.end_ts = Some(monitor_timestamp());
self.update_update(update.clone()).await?;
Ok(update)
}
}
+185 -123
View File
@@ -1,12 +1,11 @@
use anyhow::{anyhow, Context};
use diff::Diff;
use helpers::{all_logs_success, to_monitor_name};
use mungos::doc;
use mungos::mongodb::bson::doc;
use types::{
monitor_timestamp,
traits::{Busy, Permissioned},
Deployment, DeploymentWithContainerState, DockerContainerState, Log, Operation,
PermissionLevel, ServerStatus, ServerWithStatus, Update, UpdateStatus, UpdateTarget,
monitor_timestamp, traits::Permissioned, Deployment, DeploymentBuilder,
DeploymentWithContainerState, DockerContainerState, Log, Operation, PermissionLevel,
ServerStatus, ServerWithStatus, TerminationSignal, Update, UpdateStatus, UpdateTarget,
};
use crate::{
@@ -33,13 +32,6 @@ impl State {
}
}
pub async fn deployment_busy(&self, id: &str) -> bool {
match self.deployment_action_states.lock().await.get(id) {
Some(a) => a.busy(),
None => false,
}
}
pub async fn create_deployment(
&self,
name: &str,
@@ -49,16 +41,18 @@ impl State {
self.get_server_check_permissions(&server_id, user, PermissionLevel::Update)
.await?;
let start_ts = monitor_timestamp();
let deployment = Deployment {
name: to_monitor_name(name),
server_id,
permissions: [(user.id.clone(), PermissionLevel::Update)]
.into_iter()
.collect(),
created_at: start_ts.clone(),
updated_at: start_ts.clone(),
..Default::default()
};
let mut deployment = DeploymentBuilder::default()
.name(to_monitor_name(name))
.server_id(server_id)
.build()
.context("failed to build deployment")?;
deployment.permissions = [(user.id.clone(), PermissionLevel::Update)]
.into_iter()
.collect();
deployment.created_at = start_ts.clone();
deployment.updated_at = start_ts.clone();
let deployment_id = self
.db
.deployments
@@ -112,8 +106,10 @@ impl State {
&self,
deployment_id: &str,
user: &RequestUser,
stop_signal: Option<TerminationSignal>,
stop_time: Option<i32>,
) -> anyhow::Result<Deployment> {
if self.deployment_busy(deployment_id).await {
if self.deployment_action_states.busy(deployment_id).await {
return Err(anyhow!("deployment busy"));
}
let deployment = self
@@ -123,7 +119,7 @@ impl State {
let server = self.db.get_server(&deployment.server_id).await?;
let log = match self
.periphery
.container_remove(&server, &deployment.name)
.container_remove(&server, &deployment.name, stop_signal, stop_time)
.await
{
Ok(log) => log,
@@ -164,21 +160,25 @@ impl State {
new_deployment: Deployment,
user: &RequestUser,
) -> anyhow::Result<Deployment> {
if self.deployment_busy(&new_deployment.id).await {
if self.deployment_action_states.busy(&new_deployment.id).await {
return Err(anyhow!("deployment busy"));
}
let id = new_deployment.id.clone();
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(id.clone()).or_default();
entry.updating = true;
}
self.deployment_action_states
.update_entry(id.clone(), |entry| {
entry.updating = true;
})
.await;
let res = self.update_deployment_inner(new_deployment, user).await;
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(id).or_default();
entry.updating = false;
}
self.deployment_action_states
.update_entry(id.clone(), |entry| {
entry.updating = false;
})
.await;
res
}
@@ -282,22 +282,25 @@ impl State {
new_name: &str,
user: &RequestUser,
) -> anyhow::Result<Update> {
if self.deployment_busy(&deployment_id).await {
if self.deployment_action_states.busy(deployment_id).await {
return Err(anyhow!("deployment busy"));
}
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.renaming = true;
}
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.renaming = true;
})
.await;
let res = self
.rename_deployment_inner(deployment_id, new_name, user)
.await;
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.renaming = false;
}
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.renaming = false;
})
.await;
res
}
@@ -367,7 +370,9 @@ impl State {
self.update_update(update).await?;
return Err(deployment_state.err().unwrap());
}
let DeploymentWithContainerState { state, .. } = deployment_state.unwrap();
let DeploymentWithContainerState {
deployment, state, ..
} = deployment_state.unwrap();
if state != DockerContainerState::NotDeployed {
let log = self
.periphery
@@ -406,7 +411,6 @@ impl State {
)
.await
.context("failed to update deployment name on mongo");
if let Err(e) = res {
update
.logs
@@ -418,6 +422,20 @@ impl State {
))
}
if deployment.repo.is_some() {
let res = self.reclone_deployment(deployment_id, user, false).await;
if let Err(e) = res {
update
.logs
.push(Log::error("reclone repo", format!("{e:?}")));
} else {
update.logs.push(Log::simple(
"reclone repo",
"deployment repo cloned with new name".to_string(),
));
}
}
update.end_ts = monitor_timestamp().into();
update.status = UpdateStatus::Complete;
update.success = all_logs_success(&update.logs);
@@ -431,21 +449,25 @@ impl State {
&self,
deployment_id: &str,
user: &RequestUser,
check_deployment_busy: bool,
) -> anyhow::Result<Update> {
if self.deployment_busy(deployment_id).await {
if check_deployment_busy && self.deployment_action_states.busy(deployment_id).await {
return Err(anyhow!("deployment busy"));
}
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.recloning = true;
}
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.recloning = true;
})
.await;
let res = self.reclone_deployment_inner(deployment_id, user).await;
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.recloning = false;
}
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.recloning = false;
})
.await;
res
}
@@ -469,19 +491,18 @@ impl State {
};
update.id = self.add_update(update.clone()).await?;
update.success = match self.periphery.clone_repo(&server, &deployment).await {
match self.periphery.clone_repo(&server, &deployment).await {
Ok(clone_logs) => {
update.logs.extend(clone_logs);
true
}
Err(e) => {
update
.logs
.push(Log::error("clone repo", format!("{e:#?}")));
false
}
};
update.success = all_logs_success(&update.logs);
update.status = UpdateStatus::Complete;
update.end_ts = Some(monitor_timestamp());
@@ -494,21 +515,27 @@ impl State {
&self,
deployment_id: &str,
user: &RequestUser,
stop_signal: Option<TerminationSignal>,
stop_time: Option<i32>,
) -> anyhow::Result<Update> {
if self.deployment_busy(deployment_id).await {
if self.deployment_action_states.busy(deployment_id).await {
return Err(anyhow!("deployment busy"));
}
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.deploying = true;
}
let res = self.deploy_container_inner(deployment_id, user).await;
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.deploying = false;
}
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.deploying = true;
})
.await;
let res = self
.deploy_container_inner(deployment_id, user, stop_signal, stop_time)
.await;
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.deploying = false;
})
.await;
res
}
@@ -516,6 +543,8 @@ impl State {
&self,
deployment_id: &str,
user: &RequestUser,
stop_signal: Option<TerminationSignal>,
stop_time: Option<i32>,
) -> anyhow::Result<Update> {
let start_ts = monitor_timestamp();
let mut deployment = self
@@ -553,7 +582,14 @@ impl State {
update.id = self.add_update(update.clone()).await?;
let deploy_log = match self.periphery.deploy(&server, &deployment).await {
let stop_signal = stop_signal.unwrap_or(deployment.termination_signal).into();
let stop_time = stop_time.unwrap_or(deployment.termination_timeout).into();
let deploy_log = match self
.periphery
.deploy(&server, &deployment, stop_signal, stop_time)
.await
{
Ok(log) => log,
Err(e) => Log::error("deploy container", format!("{e:#?}")),
};
@@ -573,20 +609,22 @@ impl State {
deployment_id: &str,
user: &RequestUser,
) -> anyhow::Result<Update> {
if self.deployment_busy(deployment_id).await {
if self.deployment_action_states.busy(deployment_id).await {
return Err(anyhow!("deployment busy"));
}
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.starting = true;
}
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.starting = true;
})
.await;
let res = self.start_container_inner(deployment_id, user).await;
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.starting = false;
}
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.starting = false;
})
.await;
res
}
@@ -642,21 +680,27 @@ impl State {
&self,
deployment_id: &str,
user: &RequestUser,
stop_signal: Option<TerminationSignal>,
stop_time: Option<i32>,
) -> anyhow::Result<Update> {
if self.deployment_busy(deployment_id).await {
if self.deployment_action_states.busy(deployment_id).await {
return Err(anyhow!("deployment busy"));
}
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.stopping = true;
}
let res = self.stop_container_inner(deployment_id, user).await;
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.stopping = false;
}
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.stopping = true;
})
.await;
let res = self
.stop_container_inner(deployment_id, user, stop_signal, stop_time)
.await;
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.stopping = false;
})
.await;
res
}
@@ -664,6 +708,8 @@ impl State {
&self,
deployment_id: &str,
user: &RequestUser,
stop_signal: Option<TerminationSignal>,
stop_time: Option<i32>,
) -> anyhow::Result<Update> {
let start_ts = monitor_timestamp();
let deployment = self
@@ -681,9 +727,12 @@ impl State {
};
update.id = self.add_update(update.clone()).await?;
let stop_signal = stop_signal.unwrap_or(deployment.termination_signal).into();
let stop_time = stop_time.unwrap_or(deployment.termination_timeout).into();
let log = self
.periphery
.container_stop(&server, &deployment.name)
.container_stop(&server, &deployment.name, stop_signal, stop_time)
.await;
update.success = match log {
@@ -712,21 +761,27 @@ impl State {
&self,
deployment_id: &str,
user: &RequestUser,
stop_signal: Option<TerminationSignal>,
stop_time: Option<i32>,
) -> anyhow::Result<Update> {
if self.deployment_busy(deployment_id).await {
if self.deployment_action_states.busy(deployment_id).await {
return Err(anyhow!("deployment busy"));
}
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.removing = true;
}
let res = self.remove_container_inner(deployment_id, user).await;
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.removing = false;
}
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.removing = true;
})
.await;
let res = self
.remove_container_inner(deployment_id, user, stop_signal, stop_time)
.await;
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.removing = false;
})
.await;
res
}
@@ -734,6 +789,8 @@ impl State {
&self,
deployment_id: &str,
user: &RequestUser,
stop_signal: Option<TerminationSignal>,
stop_time: Option<i32>,
) -> anyhow::Result<Update> {
let start_ts = monitor_timestamp();
let deployment = self
@@ -751,9 +808,12 @@ impl State {
};
update.id = self.add_update(update.clone()).await?;
let stop_signal = stop_signal.unwrap_or(deployment.termination_signal).into();
let stop_time = stop_time.unwrap_or(deployment.termination_timeout).into();
let log = self
.periphery
.container_remove(&server, &deployment.name)
.container_remove(&server, &deployment.name, stop_signal, stop_time)
.await;
update.success = match log {
@@ -783,20 +843,22 @@ impl State {
deployment_id: &str,
user: &RequestUser,
) -> anyhow::Result<Update> {
if self.deployment_busy(deployment_id).await {
if self.deployment_action_states.busy(deployment_id).await {
return Err(anyhow!("deployment busy"));
}
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.pulling = true;
}
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.pulling = true;
})
.await;
let res = self.pull_deployment_repo_inner(deployment_id, user).await;
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.pulling = false;
}
self.deployment_action_states
.update_entry(deployment_id.to_string(), |entry| {
entry.pulling = false;
})
.await;
res
}
+1
View File
@@ -4,6 +4,7 @@ use types::Update;
use crate::state::State;
mod build;
mod command;
mod deployment;
mod group;
mod procedure;
+9 -5
View File
@@ -209,7 +209,7 @@ impl State {
}
StopContainer => {
let update = self
.stop_container(&target_id, user)
.stop_container(&target_id, user, Option::None, Option::None)
.await
.context(format!(
"failed at stop container for deployment (id: {target_id})"
@@ -218,7 +218,7 @@ impl State {
}
RemoveContainer => {
let update = self
.remove_container(&target_id, user)
.remove_container(&target_id, user, Option::None, Option::None)
.await
.context(format!(
"failed at remove container for deployment (id: {target_id})"
@@ -227,7 +227,7 @@ impl State {
}
DeployContainer => {
let update = self
.deploy_container(&target_id, user)
.deploy_container(&target_id, user, Option::None, Option::None)
.await
.context(format!(
"failed at deploy container for deployment (id: {target_id})"
@@ -236,14 +236,18 @@ impl State {
}
RecloneDeployment => {
let update = self
.reclone_deployment(&target_id, user)
.reclone_deployment(&target_id, user, true)
.await
.context(format!("failed at reclone deployment (id: {target_id})"))?;
updates.push(update);
}
PullDeployment => {
// implement this one
// let update = self.pull
let update = self
.pull_deployment_repo(&target_id, user)
.await
.context(format!("failed at pull deployment (id: {target_id})"))?;
updates.push(update);
}
// build
BuildBuild => {
+46 -48
View File
@@ -1,11 +1,10 @@
use anyhow::{anyhow, Context};
use diff::Diff;
use futures_util::future::join_all;
use mungos::doc;
use mungos::mongodb::bson::doc;
use types::{
monitor_timestamp,
traits::{Busy, Permissioned},
Log, Operation, PermissionLevel, Server, Update, UpdateStatus, UpdateTarget,
monitor_timestamp, traits::Permissioned, Log, Operation, PermissionLevel, Server, Update,
UpdateStatus, UpdateTarget,
};
use crate::{auth::RequestUser, state::State};
@@ -28,13 +27,6 @@ impl State {
}
}
pub async fn server_busy(&self, id: &str) -> bool {
match self.server_action_states.lock().await.get(id) {
Some(a) => a.busy(),
None => false,
}
}
pub async fn create_server(
&self,
name: &str,
@@ -96,7 +88,7 @@ impl State {
server_id: &str,
user: &RequestUser,
) -> anyhow::Result<Server> {
if self.server_busy(server_id).await {
if self.server_action_states.busy(server_id).await {
return Err(anyhow!("server busy"));
}
let server = self
@@ -121,14 +113,14 @@ impl State {
.get_some(doc! { "server_id": server_id }, None)
.await?
.into_iter()
.map(|d| async move { self.delete_deployment(&d.id, user).await });
.map(|d| async move { self.delete_deployment(&d.id, user, None, None).await });
let delete_builds = self
.db
.builds
.get_some(doc! { "server_id": server_id }, None)
.await?
.into_iter()
.map(|d| async move { self.delete_deployment(&d.id, user).await });
.map(|d| async move { self.delete_deployment(&d.id, user, None, None).await });
let update_groups = self
.db
.groups
@@ -164,7 +156,7 @@ impl State {
mut new_server: Server,
user: &RequestUser,
) -> anyhow::Result<Server> {
if self.server_busy(&new_server.id).await {
if self.server_action_states.busy(&new_server.id).await {
return Err(anyhow!("server busy"));
}
let current_server = self
@@ -208,20 +200,22 @@ impl State {
server_id: &str,
user: &RequestUser,
) -> anyhow::Result<Update> {
if self.server_busy(server_id).await {
if self.server_action_states.busy(server_id).await {
return Err(anyhow!("server busy"));
}
{
let mut lock = self.server_action_states.lock().await;
let entry = lock.entry(server_id.to_string()).or_default();
entry.pruning_networks = true;
}
self.server_action_states
.update_entry(server_id.to_string(), |entry| {
entry.pruning_networks = true;
})
.await;
let res = self.prune_networks_inner(server_id, user).await;
{
let mut lock = self.server_action_states.lock().await;
let entry = lock.entry(server_id.to_string()).or_default();
entry.pruning_networks = false;
}
self.server_action_states
.update_entry(server_id.to_string(), |entry| {
entry.pruning_networks = false;
})
.await;
res
}
@@ -269,20 +263,22 @@ impl State {
server_id: &str,
user: &RequestUser,
) -> anyhow::Result<Update> {
if self.server_busy(server_id).await {
if self.server_action_states.busy(server_id).await {
return Err(anyhow!("server busy"));
}
{
let mut lock = self.server_action_states.lock().await;
let entry = lock.entry(server_id.to_string()).or_default();
entry.pruning_images = true;
}
self.server_action_states
.update_entry(server_id.to_string(), |entry| {
entry.pruning_images = true;
})
.await;
let res = self.prune_images_inner(server_id, user).await;
{
let mut lock = self.server_action_states.lock().await;
let entry = lock.entry(server_id.to_string()).or_default();
entry.pruning_images = false;
}
self.server_action_states
.update_entry(server_id.to_string(), |entry| {
entry.pruning_images = false;
})
.await;
res
}
@@ -331,20 +327,22 @@ impl State {
server_id: &str,
user: &RequestUser,
) -> anyhow::Result<Update> {
if self.server_busy(server_id).await {
if self.server_action_states.busy(server_id).await {
return Err(anyhow!("server busy"));
}
{
let mut lock = self.server_action_states.lock().await;
let entry = lock.entry(server_id.to_string()).or_default();
entry.pruning_containers = true;
}
self.server_action_states
.update_entry(server_id.to_string(), |entry| {
entry.pruning_containers = true;
})
.await;
let res = self.prune_containers_inner(server_id, user).await;
{
let mut lock = self.server_action_states.lock().await;
let entry = lock.entry(server_id.to_string()).or_default();
entry.pruning_containers = false;
}
self.server_action_states
.update_entry(server_id.to_string(), |entry| {
entry.pruning_containers = false;
})
.await;
res
}
+138 -23
View File
@@ -1,18 +1,27 @@
use std::{cmp::Ordering, collections::HashMap};
use anyhow::Context;
use async_timing_util::unix_timestamp_ms;
use axum::{
extract::{Path, Query},
routing::{delete, get, patch, post},
Extension, Json, Router,
};
use futures_util::TryStreamExt;
use helpers::handle_anyhow_error;
use mungos::{doc, Deserialize, Document, FindOptions, Serialize};
use mungos::mongodb::{
bson::{doc, Document},
options::FindOptions,
};
use serde::{Deserialize, Serialize};
use types::{
traits::Permissioned, AwsBuilderConfig, Build, BuildActionState, BuildVersionsReponse,
Operation, PermissionLevel, UpdateStatus,
monitor_ts_from_unix, traits::Permissioned, unix_from_monitor_ts, AwsBuilderConfig, Build,
BuildActionState, BuildVersionsReponse, Operation, PermissionLevel, UpdateStatus,
};
use typeshare::typeshare;
const NUM_VERSIONS_PER_PAGE: u64 = 10;
const ONE_DAY_MS: i64 = 86400000;
use crate::{
auth::{RequestUser, RequestUserExtension},
@@ -49,12 +58,35 @@ pub struct BuildVersionsQuery {
patch: Option<i32>,
}
#[typeshare]
#[derive(Serialize, Deserialize)]
pub struct BuildStatsQuery {
#[serde(default)]
page: u32,
}
#[typeshare]
#[derive(Serialize, Deserialize)]
pub struct BuildStatsResponse {
pub total_time: f64, // in hours
pub total_count: f64, // number of builds
pub days: Vec<BuildStatsDay>,
}
#[typeshare]
#[derive(Serialize, Deserialize, Default)]
pub struct BuildStatsDay {
pub time: f64,
pub count: f64,
pub ts: f64,
}
pub fn router() -> Router {
Router::new()
.route(
"/:id",
get(
|Extension(state): StateExtension,
|state: StateExtension,
Extension(user): RequestUserExtension,
Path(build_id): Path<BuildId>| async move {
let build = state
@@ -68,7 +100,7 @@ pub fn router() -> Router {
.route(
"/list",
get(
|Extension(state): StateExtension,
|state: StateExtension,
Extension(user): RequestUserExtension,
Query(query): Query<Document>| async move {
let builds = state
@@ -82,7 +114,7 @@ pub fn router() -> Router {
.route(
"/create",
post(
|Extension(state): StateExtension,
|state: StateExtension,
Extension(user): RequestUserExtension,
Json(build): Json<CreateBuildBody>| async move {
let build = state
@@ -96,7 +128,7 @@ pub fn router() -> Router {
.route(
"/create_full",
post(
|Extension(state): StateExtension,
|state: StateExtension,
Extension(user): RequestUserExtension,
Json(build): Json<Build>| async move {
let build = spawn_request_action(async move {
@@ -113,7 +145,7 @@ pub fn router() -> Router {
.route(
"/:id/copy",
post(
|Extension(state): StateExtension,
|state: StateExtension,
Extension(user): RequestUserExtension,
Path(BuildId { id }): Path<BuildId>,
Json(build): Json<CopyBuildBody>| async move {
@@ -131,7 +163,7 @@ pub fn router() -> Router {
.route(
"/:id/delete",
delete(
|Extension(state): StateExtension,
|state: StateExtension,
Extension(user): RequestUserExtension,
Path(build_id): Path<BuildId>| async move {
let build = spawn_request_action(async move {
@@ -148,7 +180,7 @@ pub fn router() -> Router {
.route(
"/update",
patch(
|Extension(state): StateExtension,
|state: StateExtension,
Extension(user): RequestUserExtension,
Json(build): Json<Build>| async move {
let build = spawn_request_action(async move {
@@ -165,7 +197,7 @@ pub fn router() -> Router {
.route(
"/:id/build",
post(
|Extension(state): StateExtension,
|state: StateExtension,
Extension(user): RequestUserExtension,
Path(build_id): Path<BuildId>| async move {
let update = spawn_request_action(async move {
@@ -182,7 +214,7 @@ pub fn router() -> Router {
.route(
"/:id/action_state",
get(
|Extension(state): StateExtension,
|state: StateExtension,
Extension(user): RequestUserExtension,
Path(BuildId { id }): Path<BuildId>| async move {
let action_state = state
@@ -196,7 +228,7 @@ pub fn router() -> Router {
.route(
"/:id/versions",
get(
|Extension(state): StateExtension,
|state: StateExtension,
Extension(user): RequestUserExtension,
Path(BuildId { id }),
Query(query): Query<BuildVersionsQuery>| async move {
@@ -210,7 +242,7 @@ pub fn router() -> Router {
)
.route(
"/aws_builder_defaults",
get(|Extension(state): StateExtension| async move {
get(|state: StateExtension| async move {
Json(AwsBuilderConfig {
access_key_id: String::new(),
secret_access_key: String::new(),
@@ -220,10 +252,17 @@ pub fn router() -> Router {
)
.route(
"/docker_organizations",
get(|Extension(state): StateExtension| async move {
get(|state: StateExtension| async move {
Json(state.config.docker_organizations.clone())
}),
)
.route(
"/stats",
get(|state: StateExtension, query: Query<BuildStatsQuery>| async move {
let stats = state.get_build_stats(query.page).await.map_err(handle_anyhow_error)?;
response!(Json(stats))
}),
)
}
impl State {
@@ -258,17 +297,11 @@ impl State {
) -> anyhow::Result<BuildActionState> {
self.get_build_check_permissions(&id, &user, PermissionLevel::Read)
.await?;
let action_state = self
.build_action_states
.lock()
.await
.entry(id)
.or_default()
.clone();
let action_state = self.build_action_states.get_or_default(id).await;
Ok(action_state)
}
pub async fn get_build_versions(
async fn get_build_versions(
&self,
id: &str,
user: &RequestUser,
@@ -317,4 +350,86 @@ impl State {
.collect();
Ok(versions)
}
async fn get_build_stats(&self, page: u32) -> anyhow::Result<BuildStatsResponse> {
let curr_ts = unix_timestamp_ms() as i64;
let next_day = curr_ts - curr_ts % ONE_DAY_MS + ONE_DAY_MS;
let close_ts = next_day - page as i64 * 30 * ONE_DAY_MS;
let open_ts = close_ts - 30 * ONE_DAY_MS;
let mut build_updates = self
.db
.updates
.collection
.find(
doc! {
"start_ts": {
"$gte": monitor_ts_from_unix(open_ts)
.context("open_ts out of bounds")?,
"$lt": monitor_ts_from_unix(close_ts)
.context("close_ts out of bounds")?
},
"operation": Operation::BuildBuild.to_string(),
},
None,
)
.await?;
let mut days = HashMap::<i64, BuildStatsDay>::with_capacity(32);
let mut curr = open_ts;
while curr < close_ts {
let stats = BuildStatsDay {
ts: curr as f64,
..Default::default()
};
days.insert(curr, stats);
curr += ONE_DAY_MS;
}
while let Some(update) = build_updates.try_next().await? {
if let Some(end_ts) = update.end_ts {
let start_ts = unix_from_monitor_ts(&update.start_ts)
.context("failed to parse update start_ts")?;
let end_ts =
unix_from_monitor_ts(&end_ts).context("failed to parse update end_ts")?;
let day = start_ts - start_ts % ONE_DAY_MS;
let mut entry = days.entry(day).or_default();
entry.count += 1.0;
entry.time += ms_to_hour(end_ts - start_ts);
}
}
Ok(BuildStatsResponse::new(days.into_values().collect()))
}
}
impl BuildStatsResponse {
fn new(mut days: Vec<BuildStatsDay>) -> BuildStatsResponse {
days.sort_by(|a, b| {
if a.ts < b.ts {
Ordering::Less
} else {
Ordering::Greater
}
});
let mut total_time = 0.0;
let mut total_count = 0.0;
for day in &days {
total_time += day.time;
total_count += day.count;
}
BuildStatsResponse {
total_time,
total_count,
days,
}
}
}
const MS_TO_HOUR_DIVISOR: f64 = 1000.0 * 60.0 * 60.0;
fn ms_to_hour(duration: i64) -> f64 {
duration as f64 / MS_TO_HOUR_DIVISOR
}
+220
View File
@@ -0,0 +1,220 @@
use anyhow::Context;
use axum::{
extract::{Path, Query},
routing::{delete, get, patch, post},
Json, Router,
};
use helpers::handle_anyhow_error;
use mungos::mongodb::bson::Document;
use serde::{Deserialize, Serialize};
use types::{traits::Permissioned, CommandActionState, PeripheryCommand, PermissionLevel};
use typeshare::typeshare;
use crate::{
api::spawn_request_action,
auth::{RequestUser, RequestUserExtension},
response,
state::{State, StateExtension},
};
#[derive(Serialize, Deserialize)]
pub struct CommandId {
id: String,
}
#[typeshare]
#[derive(Serialize, Deserialize)]
pub struct CreateCommandBody {
name: String,
server_id: String,
}
#[typeshare]
#[derive(Serialize, Deserialize)]
pub struct CopyCommandBody {
name: String,
server_id: String,
}
pub fn router() -> Router {
Router::new()
.route(
"/:id",
get(
|state: StateExtension,
user: RequestUserExtension,
Path(CommandId { id })| async move {
let command = state
.get_command_check_permissions(&id, &user, PermissionLevel::Read)
.await
.map_err(handle_anyhow_error)?;
response!(Json(command))
},
),
)
.route(
"/list",
get(
|state: StateExtension,
user: RequestUserExtension,
Query(query): Query<Document>| async move {
let commands = state
.list_commands(&user, query)
.await
.map_err(handle_anyhow_error)?;
response!(Json(commands))
},
),
)
.route(
"/create",
post(
|state: StateExtension,
user: RequestUserExtension,
Json(command): Json<CreateCommandBody>| async move {
let command = state
.create_command(&command.name, command.server_id, &user)
.await
.map_err(handle_anyhow_error)?;
response!(Json(command))
},
),
)
.route(
"/create_full",
post(
|state: StateExtension,
user: RequestUserExtension,
Json(command): Json<PeripheryCommand>| async move {
let command = spawn_request_action(async move {
state
.create_full_command(command, &user)
.await
.map_err(handle_anyhow_error)
})
.await??;
response!(Json(command))
},
),
)
.route(
"/:id/copy",
post(
|state: StateExtension,
user: RequestUserExtension,
Path(CommandId { id }),
Json(command): Json<CopyCommandBody>| async move {
let command = spawn_request_action(async move {
state
.copy_command(&id, command.name, command.server_id, &user)
.await
.map_err(handle_anyhow_error)
})
.await??;
response!(Json(command))
},
),
)
.route(
"/:id/delete",
delete(
|state: StateExtension,
user: RequestUserExtension,
Path(CommandId { id })| async move {
let build = spawn_request_action(async move {
state
.delete_command(&id, &user)
.await
.map_err(handle_anyhow_error)
})
.await??;
response!(Json(build))
},
),
)
.route(
"/update",
patch(
|state: StateExtension,
user: RequestUserExtension,
Json(command): Json<PeripheryCommand>| async move {
let command = spawn_request_action(async move {
state
.update_command(command, &user)
.await
.map_err(handle_anyhow_error)
})
.await??;
response!(Json(command))
},
),
)
.route(
"/:id/action_state",
get(
|state: StateExtension,
user: RequestUserExtension,
Path(CommandId { id })| async move {
let action_state = state
.get_command_action_states(id, &user)
.await
.map_err(handle_anyhow_error)?;
response!(Json(action_state))
},
),
)
.route(
"/:id/run",
post(
|state: StateExtension,
user: RequestUserExtension,
Path(CommandId { id })| async move {
let update = spawn_request_action(async move {
state
.run_command(&id, &user)
.await
.map_err(handle_anyhow_error)
})
.await??;
response!(Json(update))
},
),
)
}
impl State {
async fn list_commands(
&self,
user: &RequestUser,
query: impl Into<Option<Document>>,
) -> anyhow::Result<Vec<PeripheryCommand>> {
let commands: Vec<PeripheryCommand> = self
.db
.commands
.get_some(query, None)
.await
.context("failed at get all commands query")?
.into_iter()
.filter(|s| {
if user.is_admin {
true
} else {
let permissions = s.get_user_permissions(&user.id);
permissions != PermissionLevel::None
}
})
.collect();
Ok(commands)
}
async fn get_command_action_states(
&self,
id: String,
user: &RequestUser,
) -> anyhow::Result<CommandActionState> {
self.get_command_check_permissions(&id, &user, PermissionLevel::Read)
.await?;
let action_state = self.command_action_states.get_or_default(id).await;
Ok(action_state)
}
}
+72 -63
View File
@@ -4,15 +4,19 @@ use anyhow::Context;
use axum::{
extract::{Path, Query},
routing::{delete, get, patch, post},
Extension, Json, Router,
Json, Router,
};
use futures_util::future::join_all;
use helpers::handle_anyhow_error;
use mungos::{doc, options::FindOneOptions, Deserialize, Document, Serialize};
use mungos::mongodb::{
bson::{doc, Document},
options::FindOneOptions,
};
use serde::{Deserialize, Serialize};
use types::{
traits::Permissioned, Deployment, DeploymentActionState, DeploymentWithContainerState,
DockerContainerState, DockerContainerStats, Log, Operation, PermissionLevel, Server,
UpdateStatus,
TerminationSignal, UpdateStatus,
};
use typeshare::typeshare;
@@ -55,16 +59,23 @@ pub struct GetContainerLogQuery {
tail: Option<u32>,
}
#[typeshare]
#[derive(Deserialize)]
pub struct StopContainerQuery {
stop_signal: Option<TerminationSignal>,
stop_time: Option<i32>,
}
pub fn router() -> Router {
Router::new()
.route(
"/:id",
get(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(deployment_id): Path<DeploymentId>| async move {
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id })| async move {
let res = state
.get_deployment_with_container_state(&user, &deployment_id.id)
.get_deployment_with_container_state(&user, &id)
.await
.map_err(handle_anyhow_error)?;
response!(Json(res))
@@ -74,8 +85,8 @@ pub fn router() -> Router {
.route(
"/list",
get(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
|state: StateExtension,
user: RequestUserExtension,
Query(query): Query<Document>| async move {
let deployments = state
.list_deployments_with_container_state(&user, query)
@@ -88,8 +99,8 @@ pub fn router() -> Router {
.route(
"/create",
post(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
|state: StateExtension,
user: RequestUserExtension,
Json(deployment): Json<CreateDeploymentBody>| async move {
let deployment = state
.create_deployment(&deployment.name, deployment.server_id, &user)
@@ -102,8 +113,8 @@ pub fn router() -> Router {
.route(
"/create_full",
post(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
|state: StateExtension,
user: RequestUserExtension,
Json(full_deployment): Json<Deployment>| async move {
let deployment = spawn_request_action(async move {
state
@@ -119,9 +130,9 @@ pub fn router() -> Router {
.route(
"/:id/copy",
post(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(DeploymentId { id }): Path<DeploymentId>,
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id }),
Json(deployment): Json<CopyDeploymentBody>| async move {
let deployment = spawn_request_action(async move {
state
@@ -137,12 +148,13 @@ pub fn router() -> Router {
.route(
"/:id/delete",
delete(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(deployment_id): Path<DeploymentId>| async move {
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId{ id }),
Query(StopContainerQuery { stop_signal, stop_time })| async move {
let deployment = spawn_request_action(async move {
state
.delete_deployment(&deployment_id.id, &user)
.delete_deployment(&id, &user, stop_signal, stop_time)
.await
.map_err(handle_anyhow_error)
})
@@ -154,8 +166,8 @@ pub fn router() -> Router {
.route(
"/update",
patch(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
|state: StateExtension,
user: RequestUserExtension,
Json(deployment): Json<Deployment>| async move {
let deployment = spawn_request_action(async move {
state
@@ -189,12 +201,12 @@ pub fn router() -> Router {
.route(
"/:id/reclone",
post(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(deployment_id): Path<DeploymentId>| async move {
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id })| async move {
let update = spawn_request_action(async move {
state
.reclone_deployment(&deployment_id.id, &user)
.reclone_deployment(&id, &user, true)
.await
.map_err(handle_anyhow_error)
})
@@ -206,12 +218,13 @@ pub fn router() -> Router {
.route(
"/:id/deploy",
post(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(deployment_id): Path<DeploymentId>| async move {
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id }),
Query(StopContainerQuery { stop_signal, stop_time })| async move {
let update = spawn_request_action(async move {
state
.deploy_container(&deployment_id.id, &user)
.deploy_container(&id, &user, stop_signal, stop_time)
.await
.map_err(handle_anyhow_error)
})
@@ -223,12 +236,12 @@ pub fn router() -> Router {
.route(
"/:id/start_container",
post(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(deployment_id): Path<DeploymentId>| async move {
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id })| async move {
let update = spawn_request_action(async move {
state
.start_container(&deployment_id.id, &user)
.start_container(&id, &user)
.await
.map_err(handle_anyhow_error)
})
@@ -240,12 +253,13 @@ pub fn router() -> Router {
.route(
"/:id/stop_container",
post(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(deployment_id): Path<DeploymentId>| async move {
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id }),
Query(StopContainerQuery { stop_signal, stop_time })| async move {
let update = spawn_request_action(async move {
state
.stop_container(&deployment_id.id, &user)
.stop_container(&id, &user, stop_signal, stop_time)
.await
.map_err(handle_anyhow_error)
})
@@ -257,12 +271,13 @@ pub fn router() -> Router {
.route(
"/:id/remove_container",
post(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(deployment_id): Path<DeploymentId>| async move {
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id }),
Query(StopContainerQuery { stop_signal, stop_time })| async move {
let update = spawn_request_action(async move {
state
.remove_container(&deployment_id.id, &user)
.remove_container(&id, &user, stop_signal, stop_time)
.await
.map_err(handle_anyhow_error)
})
@@ -274,12 +289,12 @@ pub fn router() -> Router {
.route(
"/:id/pull",
post(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(deployment_id): Path<DeploymentId>| async move {
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id })| async move {
let update = spawn_request_action(async move {
state
.pull_deployment_repo(&deployment_id.id, &user)
.pull_deployment_repo(&id, &user)
.await
.map_err(handle_anyhow_error)
})
@@ -291,8 +306,8 @@ pub fn router() -> Router {
.route(
"/:id/action_state",
get(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id }): Path<DeploymentId>| async move {
let action_state = state
.get_deployment_action_states(id, &user)
@@ -305,12 +320,12 @@ pub fn router() -> Router {
.route(
"/:id/log",
get(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(deployment_id): Path<DeploymentId>,
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id }),
Query(query): Query<GetContainerLogQuery>| async move {
let log = state
.get_deployment_container_log(&deployment_id.id, &user, query.tail)
.get_deployment_container_log(&id, &user, query.tail)
.await
.map_err(handle_anyhow_error)?;
response!(Json(log))
@@ -320,8 +335,8 @@ pub fn router() -> Router {
.route(
"/:id/stats",
get(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id })| async move {
let stats = state
.get_deployment_container_stats(&id, &user)
@@ -334,8 +349,8 @@ pub fn router() -> Router {
.route(
"/:id/deployed_version",
get(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
|state: StateExtension,
user: RequestUserExtension,
Path(DeploymentId { id })| async move {
let version = state
.get_deployment_deployed_version(&id, &user)
@@ -440,13 +455,7 @@ impl State {
) -> anyhow::Result<DeploymentActionState> {
self.get_deployment_check_permissions(&id, &user, PermissionLevel::Read)
.await?;
let action_state = self
.deployment_action_states
.lock()
.await
.entry(id)
.or_default()
.clone();
let action_state = self.deployment_action_states.get_or_default(id).await;
Ok(action_state)
}
+1 -1
View File
@@ -4,7 +4,7 @@ use axum_oauth2::random_duration;
use helpers::handle_anyhow_error;
use hex::ToHex;
use hmac::{Hmac, Mac};
use mungos::Deserialize;
use serde::Deserialize;
use sha2::Sha256;
use types::GITHUB_WEBHOOK_USER_ID;
+2 -1
View File
@@ -5,7 +5,8 @@ use axum::{
Extension, Json, Router,
};
use helpers::handle_anyhow_error;
use mungos::{Deserialize, Document, Serialize};
use mungos::mongodb::bson::Document;
use serde::{Deserialize, Serialize};
use types::{traits::Permissioned, Group, PermissionLevel};
use typeshare::typeshare;
+12 -9
View File
@@ -9,7 +9,8 @@ use axum::{
};
use futures_util::Future;
use helpers::handle_anyhow_error;
use mungos::{doc, Deserialize};
use mungos::mongodb::bson::doc;
use serde::Deserialize;
use types::{PermissionLevel, UpdateTarget, User};
use typeshare::typeshare;
@@ -20,15 +21,16 @@ use crate::{
ResponseResult,
};
pub mod build;
pub mod deployment;
mod build;
mod command;
mod deployment;
mod github_listener;
pub mod group;
pub mod permissions;
pub mod procedure;
pub mod secret;
pub mod server;
pub mod update;
mod group;
mod permissions;
mod procedure;
mod secret;
mod server;
mod update;
#[typeshare]
#[derive(Deserialize)]
@@ -94,6 +96,7 @@ pub fn router() -> Router {
.nest("/build", build::router())
.nest("/deployment", deployment::router())
.nest("/server", server::router())
.nest("/command", command::router())
.nest("/procedure", procedure::router())
.nest("/group", group::router())
.nest("/update", update::router())
+2 -1
View File
@@ -1,7 +1,8 @@
use anyhow::{anyhow, Context};
use axum::{routing::post, Extension, Json, Router};
use helpers::handle_anyhow_error;
use mungos::{doc, Deserialize, Document, Serialize};
use mungos::mongodb::bson::{doc, Document};
use serde::{Deserialize, Serialize};
use types::{
monitor_timestamp, Build, Deployment, Group, Log, Operation, PermissionLevel,
PermissionsTarget, Procedure, Server, Update, UpdateStatus, UpdateTarget,
+2 -1
View File
@@ -5,7 +5,8 @@ use axum::{
Extension, Json, Router,
};
use helpers::handle_anyhow_error;
use mungos::{Deserialize, Document, Serialize};
use mungos::mongodb::bson::Document;
use serde::{Deserialize, Serialize};
use types::{traits::Permissioned, PermissionLevel, Procedure};
use typeshare::typeshare;
+5 -1
View File
@@ -5,7 +5,11 @@ use axum::{
Extension, Json, Router,
};
use helpers::{generate_secret, handle_anyhow_error};
use mungos::{doc, to_bson, Deserialize, Document, Serialize, Update};
use mungos::{
mongodb::bson::{doc, to_bson, Document},
Update,
};
use serde::{Deserialize, Serialize};
use types::{monitor_timestamp, ApiSecret};
use typeshare::typeshare;
+6 -8
View File
@@ -8,7 +8,11 @@ use axum::{
};
use futures_util::{future::join_all, SinkExt, StreamExt};
use helpers::handle_anyhow_error;
use mungos::{doc, Deserialize, Document, FindOptions};
use mungos::mongodb::{
bson::{doc, Document},
options::FindOptions,
};
use serde::Deserialize;
use tokio::select;
use tokio_tungstenite::tungstenite::Message;
use tokio_util::sync::CancellationToken;
@@ -676,13 +680,7 @@ impl State {
) -> anyhow::Result<ServerActionState> {
self.get_server_check_permissions(&id, &user, PermissionLevel::Read)
.await?;
let action_state = self
.server_action_states
.lock()
.await
.entry(id)
.or_default()
.clone();
let action_state = self.server_action_states.get_or_default(id).await;
Ok(action_state)
}
}
+5 -1
View File
@@ -1,7 +1,7 @@
use anyhow::{anyhow, Context};
use axum::{extract::Query, routing::get, Extension, Json, Router};
use helpers::handle_anyhow_error;
use mungos::{doc, to_bson};
use mungos::mongodb::bson::{doc, to_bson};
use serde_json::Value;
use types::{PermissionLevel, Update, UpdateTarget};
@@ -92,6 +92,10 @@ impl State {
.get_group_check_permissions(id, user, PermissionLevel::Read)
.await
.map(|_| ()),
UpdateTarget::Command(id) => self
.get_command_check_permissions(id, user, PermissionLevel::Read)
.await
.map(|_| ()),
}
}
}
+7 -1
View File
@@ -4,7 +4,8 @@ use anyhow::{anyhow, Context};
use axum::{extract::Query, response::Redirect, routing::get, Extension, Router};
use axum_oauth2::github::{GithubOauthClient, GithubOauthExtension};
use helpers::handle_anyhow_error;
use mungos::{doc, Deserialize};
use mungos::mongodb::bson::doc;
use serde::Deserialize;
use types::{monitor_timestamp, CoreConfig, User};
use crate::{response, state::StateExtension};
@@ -68,10 +69,15 @@ async fn callback(
.context("failed to generate jwt")?,
None => {
let ts = monitor_timestamp();
let no_users_exist = state.db.users.find_one(None, None).await?.is_none();
let user = User {
username: github_user.login,
avatar: github_user.avatar_url.into(),
github_id: github_id.into(),
enabled: no_users_exist,
admin: no_users_exist,
create_server_permissions: no_users_exist,
create_build_permissions: no_users_exist,
created_at: ts.clone(),
updated_at: ts,
..Default::default()
+7 -1
View File
@@ -4,7 +4,8 @@ use anyhow::{anyhow, Context};
use axum::{extract::Query, response::Redirect, routing::get, Extension, Router};
use axum_oauth2::google::{GoogleOauthClient, GoogleOauthExtension};
use helpers::handle_anyhow_error;
use mungos::{doc, Deserialize};
use mungos::mongodb::bson::doc;
use serde::Deserialize;
use types::{monitor_timestamp, CoreConfig, User};
use crate::{response, state::StateExtension};
@@ -85,6 +86,7 @@ async fn callback(
.context("failed to generate jwt")?,
None => {
let ts = monitor_timestamp();
let no_users_exist = state.db.users.find_one(None, None).await?.is_none();
let user = User {
username: google_user
.email
@@ -95,6 +97,10 @@ async fn callback(
.to_string(),
avatar: google_user.picture.into(),
google_id: google_id.into(),
enabled: no_users_exist,
admin: no_users_exist,
create_server_permissions: no_users_exist,
create_build_permissions: no_users_exist,
created_at: ts.clone(),
updated_at: ts,
..Default::default()
+2 -1
View File
@@ -9,7 +9,7 @@ use axum::{body::Body, http::Request, Extension};
use axum_oauth2::random_string;
use hmac::{Hmac, Mac};
use jwt::{SignWithKey, VerifyWithKey};
use mungos::{Deserialize, Serialize};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use types::{CoreConfig, User};
@@ -20,6 +20,7 @@ pub type RequestUserExtension = Extension<Arc<RequestUser>>;
type ExchangeTokenMap = Mutex<HashMap<String, (String, u128)>>;
#[derive(Default)]
pub struct RequestUser {
pub id: String,
pub is_admin: bool,
+2 -1
View File
@@ -1,7 +1,7 @@
use anyhow::{anyhow, Context};
use axum::{extract::Json, routing::post, Extension, Router};
use helpers::handle_anyhow_error;
use mungos::doc;
use mungos::mongodb::bson::doc;
use types::{monitor_timestamp, User, UserCredentials};
use crate::state::StateExtension;
@@ -47,6 +47,7 @@ async fn create_user_handler(
enabled: no_users_exist,
admin: no_users_exist,
create_server_permissions: no_users_exist,
create_build_permissions: no_users_exist,
created_at: ts.clone(),
updated_at: ts,
..Default::default()
+1 -1
View File
@@ -9,7 +9,7 @@ use axum::{
Extension, Json, Router,
};
use helpers::handle_anyhow_error;
use mungos::{Deserialize, Serialize};
use serde::{Deserialize, Serialize};
use types::CoreConfig;
use typeshare::typeshare;
+5 -1
View File
@@ -2,7 +2,11 @@ use anyhow::{anyhow, Context};
use async_timing_util::unix_timestamp_ms;
use axum::{routing::post, Extension, Json, Router};
use helpers::handle_anyhow_error;
use mungos::{doc, Deserialize, Document, Update};
use mungos::{
mongodb::bson::{doc, Document},
Update,
};
use serde::Deserialize;
use types::unix_from_monitor_ts;
use crate::state::StateExtension;
+13 -5
View File
@@ -1,9 +1,12 @@
use axum_extra::routing::SpaRouter;
use axum::Router;
use dotenv::dotenv;
use merge_config_files::parse_config_file;
use mungos::Deserialize;
use serde::Deserialize;
use tower_http::services::{ServeDir, ServeFile};
use types::CoreConfig;
type SpaRouter = Router;
#[derive(Deserialize, Debug)]
struct Env {
#[serde(default = "default_config_path")]
@@ -12,12 +15,17 @@ struct Env {
pub frontend_path: String,
}
pub fn load() -> (CoreConfig, SpaRouter) {
pub fn load() -> (CoreConfig, SpaRouter, ServeFile) {
dotenv().ok();
let env: Env = envy::from_env().expect("failed to parse environment variables");
let config = parse_config_file(env.config_path).expect("failed to parse config");
let spa_router = SpaRouter::new("/assets", env.frontend_path);
(config, spa_router)
let spa_router = Router::new().nest_service(
"/assets",
ServeDir::new(&env.frontend_path)
.not_found_service(ServeFile::new(format!("{}/index.html", env.frontend_path))),
);
let index_html_service = ServeFile::new(format!("{}/index.html", env.frontend_path));
(config, spa_router, index_html_service)
}
pub fn default_config_path() -> String {
+37 -2
View File
@@ -1,9 +1,10 @@
use std::str::FromStr;
use std::{collections::HashMap, str::FromStr};
use anyhow::anyhow;
use diff::{Diff, OptionDiff};
use helpers::to_monitor_name;
use types::Build;
use tokio::sync::RwLock;
use types::{traits::Busy, Build};
#[macro_export]
macro_rules! response {
@@ -66,3 +67,37 @@ pub fn empty_or_only_spaces(word: &str) -> bool {
}
return true;
}
#[derive(Default)]
pub struct Cache<T: Clone + Default> {
cache: RwLock<HashMap<String, T>>,
}
impl<T: Clone + Default> Cache<T> {
pub async fn get(&self, key: &str) -> Option<T> {
self.cache.read().await.get(key).map(|e| e.clone())
}
pub async fn get_or_default(&self, key: String) -> T {
let mut cache = self.cache.write().await;
cache.entry(key).or_default().clone()
}
pub async fn update_entry(&self, key: String, handler: impl Fn(&mut T) -> ()) {
let mut cache = self.cache.write().await;
handler(cache.entry(key).or_default());
}
pub async fn clear(&self) {
self.cache.write().await.clear();
}
}
impl<T: Clone + Default + Busy> Cache<T> {
pub async fn busy(&self, id: &str) -> bool {
match self.get(id).await {
Some(state) => state.busy(),
None => false,
}
}
}
+34 -19
View File
@@ -4,6 +4,7 @@ use ::helpers::get_socket_addr;
use auth::JwtClient;
use axum::{http::StatusCode, Router};
use state::State;
use termination_signal::tokio::immediate_term_handle;
use tower_http::cors::{Any, CorsLayer};
mod actions;
@@ -20,29 +21,43 @@ type ResponseResult<T> = Result<T, (StatusCode, String)>;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (config, spa_router) = config::load();
println!("version: v{}", env!("CARGO_PKG_VERSION"));
println!("starting monitor core on port {}...", config.port);
let term_signal = immediate_term_handle()?;
let app = Router::new()
.merge(spa_router)
.nest("/api", api::router())
.nest("/auth", auth::router(&config))
.nest("/ws", ws::router())
.layer(JwtClient::extension(&config))
.layer(State::extension(config.clone()).await)
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
);
let app = tokio::spawn(async move {
let (config, spa_router, index_html_service) = config::load();
println!("started monitor core on port {}", config.port);
println!("starting monitor core on port {}...", config.port);
axum::Server::bind(&get_socket_addr(config.port))
.serve(app.into_make_service())
.await?;
let app = Router::new()
.nest("/api", api::router())
.nest("/auth", auth::router(&config))
.nest("/ws", ws::router())
.layer(JwtClient::extension(&config))
.layer(State::extension(config.clone()).await)
.merge(spa_router)
.fallback_service(index_html_service)
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any),
);
println!("started monitor core on port {}", config.port);
axum::Server::bind(&get_socket_addr(config.port))
.serve(app.into_make_service())
.await?;
anyhow::Ok(())
});
tokio::select! {
res = app => return res?,
_ = term_signal => {},
}
Ok(())
}
+35 -27
View File
@@ -4,13 +4,13 @@ use async_timing_util::{
unix_timestamp_ms, wait_until_timelength, Timelength, ONE_DAY_MS, ONE_HOUR_MS,
};
use futures_util::future::join_all;
use mungos::doc;
use mungos::mongodb::bson::doc;
use slack::types::Block;
use types::{Server, SystemStats, SystemStatsQuery, SystemStatsRecord};
use crate::state::State;
#[derive(Default)]
#[derive(Default, Clone)]
pub struct AlertStatus {
cpu_alert: bool,
mem_alert: bool,
@@ -100,16 +100,16 @@ impl State {
}
async fn check_cpu(&self, server: &Server, stats: &SystemStats) {
let server_alert_status = self.server_alert_status.lock().await;
if self.slack.is_none()
|| server_alert_status
|| self
.server_alert_status
.get(&server.id)
.await
.map(|s| s.cpu_alert)
.unwrap_or(false)
{
return;
}
drop(server_alert_status);
if stats.cpu_perc > server.cpu_alert {
let region = if let Some(region) = &server.region {
format!(" ({region})")
@@ -171,24 +171,26 @@ impl State {
server.name, stats.cpu_perc
)
} else {
let mut lock = self.server_alert_status.lock().await;
let entry = lock.entry(server.id.clone()).or_default();
entry.cpu_alert = true;
self.server_alert_status
.update_entry(server.id.clone(), |entry| {
entry.cpu_alert = true;
})
.await;
}
}
}
async fn check_mem(&self, server: &Server, stats: &SystemStats) {
let server_alert_status = self.server_alert_status.lock().await;
if self.slack.is_none()
|| server_alert_status
|| self
.server_alert_status
.get(&server.id)
.await
.map(|s| s.mem_alert)
.unwrap_or(false)
{
return;
}
drop(server_alert_status);
let usage_perc = (stats.mem_used_gb / stats.mem_total_gb) * 100.0;
if usage_perc > server.mem_alert {
let region = if let Some(region) = &server.region {
@@ -254,25 +256,27 @@ impl State {
server.name, stats.mem_used_gb, stats.mem_total_gb,
)
} else {
let mut lock = self.server_alert_status.lock().await;
let entry = lock.entry(server.id.clone()).or_default();
entry.mem_alert = true;
self.server_alert_status
.update_entry(server.id.clone(), |entry| {
entry.mem_alert = true;
})
.await;
}
}
}
async fn check_disk(&self, server: &Server, stats: &SystemStats) {
for disk in &stats.disk.disks {
let server_alert_status = self.server_alert_status.lock().await;
if self.slack.is_none()
|| server_alert_status
|| self
.server_alert_status
.get(&server.id)
.await
.map(|s| *s.disk_alert.get(&disk.mount).unwrap_or(&false))
.unwrap_or(false)
{
return;
}
drop(server_alert_status);
let usage_perc = (disk.used_gb / disk.total_gb) * 100.0;
if usage_perc > server.disk_alert {
let region = if let Some(region) = &server.region {
@@ -315,25 +319,27 @@ impl State {
server.name, stats.disk.used_gb, stats.disk.total_gb,
)
} else {
let mut lock = self.server_alert_status.lock().await;
let entry = lock.entry(server.id.clone()).or_default();
entry.disk_alert.insert(disk.mount.clone(), true);
self.server_alert_status
.update_entry(server.id.clone(), |entry| {
entry.disk_alert.insert(disk.mount.clone(), true);
})
.await;
}
}
}
}
async fn check_components(&self, server: &Server, stats: &SystemStats) {
let lock = self.server_alert_status.lock().await;
if self.slack.is_none()
|| lock
|| self
.server_alert_status
.get(&server.id)
.await
.map(|s| s.component_alert)
.unwrap_or(false)
{
return;
}
drop(lock);
let info = stats
.components
.iter()
@@ -393,9 +399,11 @@ impl State {
info.join(" | "),
)
} else {
let mut lock = self.server_alert_status.lock().await;
let entry = lock.entry(server.id.clone()).or_default();
entry.component_alert = true;
self.server_alert_status
.update_entry(server.id.clone(), |entry| {
entry.component_alert = true;
})
.await;
}
}
}
@@ -487,7 +495,7 @@ impl State {
);
}
{
self.server_alert_status.lock().await.clear();
self.server_alert_status.clear().await;
}
}
}
+13 -11
View File
@@ -1,30 +1,31 @@
use std::{collections::HashMap, sync::Arc};
use std::sync::Arc;
use async_timing_util::{unix_timestamp_ms, wait_until_timelength, Timelength, ONE_HOUR_MS};
use axum::Extension;
use db::DbClient;
use futures_util::future::join_all;
use mungos::doc;
use mungos::mongodb::bson::doc;
use periphery::PeripheryClient;
use tokio::sync::Mutex;
use types::{BuildActionState, CoreConfig, DeploymentActionState, ServerActionState};
use types::{
BuildActionState, CommandActionState, CoreConfig, DeploymentActionState, ServerActionState,
};
use crate::{monitoring::AlertStatus, ws::update::UpdateWsChannel};
use crate::{helpers::Cache, monitoring::AlertStatus, ws::update::UpdateWsChannel};
pub type StateExtension = Extension<Arc<State>>;
pub type ActionStateMap<T> = Mutex<HashMap<String, T>>;
// pub type Cache<T> = RwLock<HashMap<String, T>>;
pub struct State {
pub config: CoreConfig,
pub db: DbClient,
pub update: UpdateWsChannel,
pub periphery: PeripheryClient,
pub slack: Option<slack::Client>,
pub build_action_states: ActionStateMap<BuildActionState>,
pub deployment_action_states: ActionStateMap<DeploymentActionState>,
pub server_action_states: ActionStateMap<ServerActionState>,
pub server_alert_status: Mutex<HashMap<String, AlertStatus>>, // (server_id, AlertStatus)
pub build_action_states: Cache<BuildActionState>,
pub deployment_action_states: Cache<DeploymentActionState>,
pub server_action_states: Cache<ServerActionState>,
pub command_action_states: Cache<CommandActionState>,
pub server_alert_status: Cache<AlertStatus>, // (server_id, AlertStatus)
}
impl State {
@@ -38,6 +39,7 @@ impl State {
build_action_states: Default::default(),
deployment_action_states: Default::default(),
server_action_states: Default::default(),
command_action_states: Default::default(),
server_alert_status: Default::default(),
};
let state = Arc::new(state);
+3 -3
View File
@@ -6,9 +6,9 @@ use axum::{
};
use futures_util::{SinkExt, StreamExt};
use helpers::handle_anyhow_error;
use mungos::Deserialize;
use serde::Deserialize;
use tokio::select;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
use tokio_util::sync::CancellationToken;
use types::{traits::Permissioned, PermissionLevel, SystemStatsQuery};
@@ -67,7 +67,7 @@ pub async fn ws_handler(
},
stats = stats_recv.next() => stats,
};
if let Some(Ok(Message::Text(msg))) = stats {
if let Some(Ok(TungsteniteMessage::Text(msg))) = stats {
let _ = ws_sender.send(AxumMessage::Text(msg)).await;
} else {
let _ = stats_recv.close(None).await;
+6
View File
@@ -140,6 +140,12 @@ async fn user_can_see_update(
.await?;
(permissions, "group")
}
UpdateTarget::Command(command_id) => {
let permissions = db_client
.get_user_permission_on_command(user_id, command_id)
.await?;
(permissions, "command")
}
UpdateTarget::System => {
return Err(anyhow!("user not admin, can't recieve system updates"))
}
+40
View File
@@ -0,0 +1,40 @@
# core setup
setting up monitor core is fairly simple. there are some requirements to run monitor core:
- a valid configuration file
- an instance of MongoDB to which monitor core can connect
- docker must be installed on the host
## 1. create the configuration file
create a configuration file on the system, for example at `~/.monitor/core.config.toml`, and copy the [example config](https://github.com/mbecker20/monitor/blob/main/config_example/core.config.example.toml). fill in all the necessary information before continuing.
:::note
to enable OAuth2 login, you must create a client on the respective OAuth provider,
for example [google](https://developers.google.com/identity/protocols/oauth2)
or [github](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps).
monitor uses the `web application` login flow.
the redirect uri is `<base_url>/auth/google/callback` for google and `<base_url>/auth/github/callback` for github.
:::
## 2. start monitor core
monitor core is distributed via dockerhub under the public repo [mbecker2020/monitor_core](https://hub.docker.com/r/mbecker2020/monitor_core).
```sh
docker run -d --name monitor-core \
-v $HOME/.monitor/core.config.toml:/config/config.toml \
-p 9000:9000 \
mbecker2020/monitor_core
```
## first login
monitor core should now be accessible on the specified port, so navigating to `http://<address>:<port>` will display the login page.
the first user to log in will be auto enabled and made admin. any additional users to create accounts will be disabled by default.
## https
monitor core itself only supports http, so a reverse proxy like [caddy](https://caddyserver.com/) should be used for https
View File
+1
View File
@@ -19,6 +19,7 @@ const sidebars = {
// But you can create a sidebar manually
docs: [
"intro",
"core-setup",
{
type: "category",
label: "connecting servers",
+3 -3
View File
@@ -17,8 +17,8 @@
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
display: grid;
gap: 1rem;
grid-template-columns: 1fr 1fr;
width: fit-content;
}
+33 -11
View File
@@ -17,22 +17,44 @@ function HomepageHeader() {
<div style={{ display: "flex", gap: "1rem", justifyContent: "center" }}>
<div style={{ position: "relative" }}>
<MonitorLogo width="600px" />
<h1 className="hero__title" style={{ margin: 0, position: "absolute", top: "40%", left: "50%", transform: "translate(-50%, -50%)" }}>
<h1
className="hero__title"
style={{
margin: 0,
position: "absolute",
top: "40%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
>
monitor
</h1>
</div>
</div>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<Link className="button button--secondary button--lg" to="/intro">
docs
</Link>
<Link
className="button button--secondary button--lg"
to="https://github.com/mbecker20/monitor"
>
github
</Link>
<div style={{ display: "flex", justifyContent: "center" }}>
<div className={styles.buttons}>
<Link className="button button--secondary button--lg" to="/intro">
docs
</Link>
<Link
className="button button--secondary button--lg"
to="https://github.com/mbecker20/monitor"
>
github
</Link>
<Link
className="button button--secondary button--lg"
to="https://github.com/mbecker20/monitor#readme"
style={{
width: "100%",
boxSizing: "border-box",
gridColumn: "span 2",
}}
>
screenshots
</Link>
</div>
</div>
</div>
</header>
+3
View File
@@ -11,6 +11,7 @@
},
"license": "GPL v3.0",
"devDependencies": {
"@types/sanitize-html": "^2.9.0",
"sass": "^1.57.1",
"typescript": "^4.9.4",
"vite": "^4.0.3",
@@ -19,10 +20,12 @@
"dependencies": {
"@solidjs/router": "^0.6.0",
"@tanstack/solid-query": "^4.26.0",
"ansi-to-html": "^0.7.2",
"axios": "^1.2.1",
"js-file-download": "^0.4.12",
"lightweight-charts": "^3.8.0",
"reconnecting-websocket": "^4.4.0",
"sanitize-html": "^2.10.0",
"solid-js": "^1.6.6"
}
}
+19 -7
View File
@@ -14,7 +14,11 @@ export const NewGroup: Component<{}> = (p) => {
<Show
when={showNew()}
fallback={
<button class="green" onClick={toggleShowNew} style={{ height: "100%" }}>
<button
class="green"
onClick={toggleShowNew}
style={{ height: "100%" }}
>
<Icon type="plus" />
</button>
}
@@ -33,8 +37,12 @@ export const NewDeployment: Component<{ serverID: string }> = (p) => {
<Show
when={showNew()}
fallback={
<button class="green" onClick={toggleShowNew} style={{ width: "100%" }}>
<Icon type="plus" />
<button
class="green"
onClick={toggleShowNew}
style={{ width: "100%", height: "fit-content" }}
>
<Icon type="plus" width="1.2rem" />
</button>
}
>
@@ -56,8 +64,12 @@ export const NewBuild: Component<{}> = (p) => {
<Show
when={showNew()}
fallback={
<button class="green" onClick={toggleShowNew} style={{ width: "100%" }}>
<Icon type="plus" />
<button
class="green"
onClick={toggleShowNew}
style={{ width: "100%", height: "fit-content" }}
>
<Icon type="plus" width="1.2rem" />
</button>
}
>
@@ -87,14 +99,14 @@ const New: Component<{
}
};
return (
<Flex justifyContent="space-between">
<Flex justifyContent="space-between" style={{ height: "fit-content", width: "100%" }}>
<Input
ref={inputRef}
placeholder={p.placeholder}
value={name()}
onEdit={setName}
onEnter={create}
style={{ width: "20rem" }}
style={{ width: "100%", "min-width": "20rem" }}
/>
<Flex gap="0.4rem">
<button class="green" onClick={create}>
+1 -1
View File
@@ -33,7 +33,7 @@ const Build: Component<{}> = (p) => {
}
});
});
onCleanup(() => unsub);
onCleanup(() => unsub());
const userCanUpdate = () =>
user().admin ||
build()?.permissions![user_id()] === PermissionLevel.Update;
@@ -7,7 +7,7 @@ import {
useContext,
} from "solid-js";
import { createStore, SetStoreFunction } from "solid-js/store";
import { client } from "../../..";
import { client, pushNotification } from "../../..";
import { useAppState } from "../../../state/StateProvider";
import { useUser } from "../../../state/UserProvider";
import { Build, Operation, PermissionLevel, ServerWithStatus } from "../../../types";
@@ -55,10 +55,17 @@ export const ConfigProvider: ParentComponent<{}> = (p) => {
client.get_build(params.id).then((build) => {
set({
...build,
_id: { $oid: params.id } as any,
repo: build.repo,
branch: build.branch,
pre_build: build.pre_build,
docker_build_args: build.docker_build_args,
docker_build_args: {
build_path: build.docker_build_args?.build_path!,
dockerfile_path: build.docker_build_args?.dockerfile_path,
build_args: build.docker_build_args?.build_args,
extra_args: build.docker_build_args?.extra_args,
use_buildx: build.docker_build_args?.use_buildx,
},
docker_account: build.docker_account,
github_account: build.github_account,
aws_config: build.aws_config,
@@ -72,7 +79,13 @@ export const ConfigProvider: ParentComponent<{}> = (p) => {
const save = () => {
setBuild("saving", true);
client.update_build(build)
client
.update_build(build)
.catch((e) => {
console.error(e);
pushNotification("bad", "update build failed");
setBuild("saving", false);
});
};
let update_unsub = () => {};
+114 -7
View File
@@ -1,4 +1,12 @@
import { Component, Match, Show, Switch } from "solid-js";
import {
Component,
Match,
Setter,
Show,
Signal,
Switch,
createSignal,
} from "solid-js";
import { client } from "../..";
import { useAppState } from "../../state/StateProvider";
import { useUser } from "../../state/UserProvider";
@@ -13,10 +21,15 @@ import { combineClasses } from "../../util/helpers";
import { A, useParams } from "@solidjs/router";
import {
DockerContainerState,
Operation,
PermissionLevel,
ServerStatus,
TerminationSignal,
TerminationSignalLabel,
UpdateStatus,
} from "../../types";
import ConfirmMenuButton from "../shared/ConfirmMenuButton";
import Selector from "../shared/menu/Selector";
const Actions: Component<{}> = (p) => {
const { deployments, builds, servers, getPermissionOnDeployment } =
@@ -161,12 +174,12 @@ const Build: Component = () => {
};
const Deploy: Component<{ redeploy?: boolean }> = (p) => {
// const { deployments } = useAppState();
const params = useParams();
// const deployment = () => deployments.get(params.id)!;
const actions = useActionStates();
const { deployments } = useAppState();
const name = () => deployments.get(params.id)?.deployment.name;
const deployment = () => deployments.get(params.id);
const name = () => deployment()?.deployment.name;
const [termSignalLabel, setTermSignalLabel] = useTermSignalLabel();
return (
<Show
when={!actions.deploying}
@@ -194,9 +207,19 @@ const Deploy: Component<{ redeploy?: boolean }> = (p) => {
<ConfirmMenuButton
class="green"
onConfirm={() => {
client.deploy_container(params.id);
client.deploy_container(params.id, {
stop_signal: ((termSignalLabel().signal as any) === "default"
? undefined
: termSignalLabel().signal) as TerminationSignal,
});
}}
title="redeploy container"
configs={
<TermSignalSelector
termSignalLabel={termSignalLabel()}
setTermSignalLabel={setTermSignalLabel}
/>
}
match={name()!}
>
<Icon type={"reset"} />
@@ -216,6 +239,7 @@ const RemoveContainer = () => {
const actions = useActionStates();
const { deployments } = useAppState();
const name = () => deployments.get(params.id)?.deployment.name;
const [termSignalLabel, setTermSignalLabel] = useTermSignalLabel();
return (
<Show
when={!actions.removing}
@@ -230,9 +254,19 @@ const RemoveContainer = () => {
<ConfirmMenuButton
class="red"
onConfirm={() => {
client.remove_container(params.id);
client.remove_container(params.id, {
stop_signal: ((termSignalLabel().signal as any) === "default"
? undefined
: termSignalLabel().signal) as TerminationSignal,
});
}}
title="destroy container"
configs={
<TermSignalSelector
termSignalLabel={termSignalLabel()}
setTermSignalLabel={setTermSignalLabel}
/>
}
match={name()!}
>
<Icon type="trash" />
@@ -282,6 +316,7 @@ const Stop = () => {
const actions = useActionStates();
const { deployments } = useAppState();
const name = () => deployments.get(params.id)?.deployment.name;
const [termSignalLabel, setTermSignalLabel] = useTermSignalLabel();
return (
<Show
when={!actions.stopping}
@@ -296,9 +331,17 @@ const Stop = () => {
<ConfirmMenuButton
class="orange"
onConfirm={() => {
client.stop_container(params.id);
client.stop_container(params.id, {
stop_signal: termSignalLabel().signal,
});
}}
title="stop container"
configs={
<TermSignalSelector
termSignalLabel={termSignalLabel()}
setTermSignalLabel={setTermSignalLabel}
/>
}
match={name()!}
>
<Icon type="pause" />
@@ -374,4 +417,68 @@ const Reclone = () => {
);
};
const TermSignalSelector: Component<{
termSignalLabel: TerminationSignalLabel;
setTermSignalLabel: Setter<TerminationSignalLabel>;
}> = (p) => {
const params = useParams();
const { deployments } = useAppState();
const deployment = () => deployments.get(params.id);
return (
<Show
when={
deployment()?.state === DockerContainerState.Running &&
(deployment()?.deployment.term_signal_labels?.length || 0) > 1
}
>
<Flex
class="full-width wrap"
justifyContent="space-between"
alignItems="center"
>
<div class="dimmed">termination signal: </div>
<Selector
targetClass="blue"
selected={p.termSignalLabel}
items={deployment()?.deployment.term_signal_labels || []}
itemMap={({ signal, label }) => (
<Flex gap="0.5rem" alignItems="center">
<div>{signal}</div>
<Show when={label.length > 0}>
<div class="dimmed">{label}</div>
</Show>
</Flex>
)}
onSelect={(signal) => p.setTermSignalLabel(signal)}
position="bottom right"
/>
</Flex>
</Show>
);
};
function useTermSignalLabel(): Signal<TerminationSignalLabel> {
const params = useParams();
const { deployments, ws } = useAppState();
const deployment = () => deployments.get(params.id)?.deployment;
const term_signal = () =>
deployment()?.termination_signal || TerminationSignal.SigTerm;
const default_term_signal_label = () => ({
signal: term_signal(),
label:
deployment()?.term_signal_labels?.find(
({ signal }) => signal === term_signal()
)?.label || "",
});
const [label, setLabel] = createSignal<TerminationSignalLabel>(
default_term_signal_label()
);
ws.subscribe([Operation.UpdateDeployment], (update) => {
if (update.status === UpdateStatus.Complete) {
setTimeout(() => setLabel(default_term_signal_label()), 100);
}
});
return [label, setLabel];
}
export default Actions;
@@ -3,7 +3,7 @@ import { Component, Show } from "solid-js";
import { useAppDimensions } from "../../state/DimensionProvider";
import { useAppState } from "../../state/StateProvider";
import { useUser } from "../../state/UserProvider";
import { PermissionLevel } from "../../types";
import { PermissionLevel, TerminationSignal } from "../../types";
import Description from "../Description";
import NotFound from "../NotFound";
import Grid from "../shared/layout/Grid";
@@ -16,6 +16,13 @@ import Updates from "./Updates";
const POLLING_RATE = 10000;
// let interval = -1;
export const TERM_SIGNALS = [
TerminationSignal.SigTerm,
TerminationSignal.SigInt,
TerminationSignal.SigQuit,
TerminationSignal.SigHup,
];
const Deployment: Component<{}> = (p) => {
const { user, user_id } = useUser();
const { servers, deployments } = useAppState();
@@ -2,7 +2,6 @@ import { Component, createResource, createSignal, Show } from "solid-js";
import { useAppState } from "../../state/StateProvider";
import { useUser } from "../../state/UserProvider";
import {
combineClasses,
deploymentHeaderStateClass,
getId,
readableVersion,
@@ -79,7 +78,7 @@ const Header: Component<{}> = (p) => {
<>
<Grid
gap="0.5rem"
class={combineClasses("card shadow")}
class="card shadow"
style={{
position: "relative",
cursor: isSemiMobile() ? "pointer" : undefined,
@@ -173,7 +172,7 @@ const Header: Component<{}> = (p) => {
{server()?.server.name || "unknown"}
</A>
<div class={deploymentHeaderStateClass(deployment().state)}>
{deployment().state}
{deployment().state.replaceAll("_", " ")}
</div>
</Flex>
<Show when={status()}>
@@ -21,6 +21,8 @@ import { useAppDimensions } from "../../../../state/DimensionProvider";
import SimpleTabs from "../../../shared/tabs/SimpleTabs";
import ExtraArgs from "./container/ExtraArgs";
import WebhookUrl from "./container/WebhookUrl";
import RedeployOnBuild from "./container/RedeployOnBuild";
import TerminationSignals, { DefaultTerminationSignal, DefaultTerminationTimeout } from "./termination/TerminationSignals";
const Config: Component<{}> = () => {
const { deployment, reset, save, userCanUpdate } = useConfig();
@@ -47,6 +49,20 @@ const Config: Component<{}> = () => {
<Mounts />
<ExtraArgs />
<PostImage />
<RedeployOnBuild />
<Show when={isMobile()}>
<div style={{ height: "1rem" }} />
</Show>
</Grid>
),
},
{
title: "termination",
element: () => (
<Grid class="config-items" placeItems="start center" style={{ "margin-bottom": "" }}>
<TerminationSignals />
<DefaultTerminationSignal />
<DefaultTerminationTimeout />
<Show when={isMobile()}>
<div style={{ height: "1rem" }} />
</Show>
@@ -62,6 +62,8 @@ export const ConfigProvider: ParentComponent<{}> = (p) => {
// console.log("loading deployment");
client.get_deployment(params.id).then((deployment) =>
set({
...deployment,
_id: { $oid: params.id } as any,
name: deployment.deployment.name,
server_id: deployment.deployment.server_id,
permissions: deployment.deployment.permissions,
@@ -87,6 +89,10 @@ export const ConfigProvider: ParentComponent<{}> = (p) => {
repo_mount: deployment.deployment.repo_mount,
created_at: deployment.deployment.created_at,
updated_at: deployment.deployment.updated_at,
redeploy_on_build: deployment.deployment.redeploy_on_build,
term_signal_labels: deployment.deployment.term_signal_labels,
termination_signal: deployment.deployment.termination_signal,
termination_timeout: deployment.deployment.termination_timeout,
loaded: true,
updated: false,
updating: false,
@@ -108,11 +114,13 @@ export const ConfigProvider: ParentComponent<{}> = (p) => {
const save = () => {
setDeployment("updating", true);
client.update_deployment(deployment).catch((e) => {
console.error(e);
pushNotification("bad", "update deployment failed");
setDeployment("updating", false);
});
client
.update_deployment(deployment)
.catch((e) => {
console.error(e);
pushNotification("bad", "update deployment failed");
setDeployment("updating", false);
});
};
let update_unsub = () => {};
@@ -3,8 +3,8 @@ import { client } from "../../../../..";
import { useAppState } from "../../../../../state/StateProvider";
import {
combineClasses,
string_to_version,
version_to_string,
readableVersion,
readableMonitorTimestamp,
} from "../../../../../util/helpers";
import Input from "../../../../shared/Input";
import Flex from "../../../../shared/layout/Flex";
@@ -14,11 +14,6 @@ import { useConfig } from "../Provider";
const Image: Component<{}> = (p) => {
const { deployment, setDeployment, userCanUpdate } = useConfig();
const { builds } = useAppState();
const [versions] = createResource(() => {
if (deployment.build_id) {
return client.get_build_versions(deployment.build_id);
}
});
return (
<Flex
class={combineClasses("config-item shadow")}
@@ -41,7 +36,8 @@ const Image: Component<{}> = (p) => {
<Selector
targetClass="blue"
selected={
(deployment.build_id && (builds.get(deployment.build_id)?.name || "unknown")) ||
(deployment.build_id &&
(builds.get(deployment.build_id)?.name || "unknown")) ||
"custom image"
}
items={[
@@ -65,33 +61,7 @@ const Image: Component<{}> = (p) => {
useSearch
/>
<Show when={deployment.build_id}>
<Selector
targetClass="blue"
selected={
deployment.build_version
? `v${version_to_string(deployment.build_version)}`
: "latest"
}
items={[
"latest",
...(versions()?.map(
(v) => `v${version_to_string(v.version)}`
) || []),
]}
onSelect={(version) => {
if (version === "latest") {
setDeployment("build_version", undefined);
} else {
setDeployment(
"build_version",
string_to_version(version.replace("v", ""))
);
}
}}
position="bottom right"
disabled={!userCanUpdate()}
useSearch
/>
<VersionSelector />
</Show>
</Show>
</Flex>
@@ -100,3 +70,54 @@ const Image: Component<{}> = (p) => {
};
export default Image;
const VersionSelector: Component<{}> = (p) => {
const { deployment, setDeployment, userCanUpdate } = useConfig();
const [versions] = createResource(() => {
if (deployment.build_id) {
return client.get_build_versions(deployment.build_id);
}
});
const selected = () => ({
version: deployment.build_version || {
major: 0,
minor: 0,
patch: 0,
},
ts: "",
});
return (
<Selector
targetClass="blue"
selected={selected()}
items={[
{ version: { major: 0, minor: 0, patch: 0 }, ts: "" },
...(versions() || []),
]}
itemMap={({ version, ts }) => (
<>
<div>
{version.major === 0 && version.minor === 0 && version.patch === 0
? "latest"
: readableVersion(version)}
</div>
<Show when={ts.length > 0}>
<div class="dimmed">{readableMonitorTimestamp(ts)}</div>
</Show>
</>
)}
searchItemMap={({ version }) => readableVersion(version)}
onSelect={({ version, ts }) => {
if (ts.length === 0) {
setDeployment("build_version", undefined);
} else {
setDeployment("build_version", version);
}
}}
position="bottom right"
disabled={!userCanUpdate()}
useSearch
/>
);
}
@@ -0,0 +1,31 @@
import { Component, Show } from "solid-js";
import { useConfig } from "../Provider";
import Flex from "../../../../shared/layout/Flex";
const RedeployOnBuild: Component<{}> = (p) => {
const { deployment, setDeployment, userCanUpdate } = useConfig();
return (
<Show when={deployment.build_id}>
<Flex
class="config-item shadow"
justifyContent="space-between"
alignItems="center"
>
<h1>redeploy on build</h1>
<Show
when={userCanUpdate()}
fallback={<h2>{deployment.redeploy_on_build ? "yes" : "no"}</h2>}
>
<button
class={deployment.redeploy_on_build ? "green" : "red"}
onClick={() => setDeployment("redeploy_on_build", (v) => !v)}
>
{deployment.redeploy_on_build ? "yes" : "no"}
</button>
</Show>
</Flex>
</Show>
);
};
export default RedeployOnBuild;
@@ -0,0 +1,156 @@
import { Component, For, Show, createSignal } from "solid-js";
import { useConfig } from "../Provider";
import Grid from "../../../../shared/layout/Grid";
import Flex from "../../../../shared/layout/Flex";
import Icon from "../../../../shared/Icon";
import { TERM_SIGNALS } from "../../../Deployment";
import { TerminationSignal } from "../../../../../types";
import Input from "../../../../shared/Input";
import Menu from "../../../../shared/menu/Menu";
import Selector from "../../../../shared/menu/Selector";
import { pushNotification } from "../../../../..";
const TerminationSignals: Component<{}> = (p) => {
const { deployment, setDeployment, userCanUpdate } = useConfig();
const signals_to_add = () =>
TERM_SIGNALS.filter(
(sig) =>
!deployment.term_signal_labels
?.map(({ signal }) => signal)
.includes(sig)
);
const onAdd = (signal: TerminationSignal) => {
setDeployment("term_signal_labels", (term_signals: any) => [
...term_signals,
{ signal, label: "" },
]);
};
const onRemove = (index: number) => {
setDeployment("term_signal_labels", (term_signals) =>
term_signals?.filter((_, i) => i !== index)
);
};
const [menuOpen, setMenuOpen] = createSignal(false);
return (
<Grid class="config-item shadow">
<Flex alignItems="center" justifyContent="space-between">
<h1>termination signals</h1>
<Show when={userCanUpdate() && signals_to_add().length > 0}>
<Menu
show={menuOpen()}
close={() => setMenuOpen(false)}
target={
<button class="green" onClick={() => setMenuOpen(true)}>
<Icon type="plus" />
</button>
}
content={
<For each={signals_to_add()}>
{(signal) => (
<button
class="grey"
onClick={() => {
onAdd(signal);
setMenuOpen(false);
}}
>
<h2>{signal}</h2>
</button>
)}
</For>
}
/>
</Show>
</Flex>
<Show when={(deployment.term_signal_labels?.length || 0) > 0}>
<Grid gridTemplateColumns="auto 1fr auto" placeItems="center start">
<For each={deployment.term_signal_labels}>
{({ signal, label }, index) => (
<>
<h2>{signal}</h2>
<Input
class="full-width"
placeholder="label this termination signal"
value={label}
onConfirm={(value) =>
setDeployment("term_signal_labels", index(), "label", value)
}
disabled={!userCanUpdate()}
/>
<Show when={userCanUpdate()}>
<button class="red" onClick={() => onRemove(index())}>
<Icon type="minus" />
</button>
</Show>
</>
)}
</For>
</Grid>
</Show>
</Grid>
);
};
export default TerminationSignals;
export const DefaultTerminationSignal: Component<{}> = () => {
const { deployment, setDeployment, userCanUpdate } = useConfig();
const term_signal = () =>
deployment.termination_signal || TerminationSignal.SigTerm;
const selected = () => ({
signal: term_signal(),
label:
deployment.term_signal_labels?.find(
({ signal }) => signal === term_signal()
)?.label || "",
});
return (
<Show when={deployment.term_signal_labels?.length || 0 > 0}>
<Flex
class="config-item shadow"
alignItems="center"
justifyContent="space-between"
>
<h1>default termination signal</h1>
<Selector
disabled={!userCanUpdate()}
targetClass="blue"
selected={selected()}
items={deployment.term_signal_labels || []}
onSelect={({ signal }) => setDeployment("termination_signal", signal)}
itemMap={({ signal }) => signal}
/>
</Flex>
</Show>
);
};
export const DefaultTerminationTimeout: Component<{}> = () => {
const { deployment, setDeployment, userCanUpdate } = useConfig();
return (
<Flex
class="config-item shadow"
alignItems="center"
justifyContent="space-between"
>
<h1>termination timeout</h1>
<div style={{ position: "relative" }}>
<Input
disabled={!userCanUpdate()}
style={{ width: "10rem" }}
placeholder="10"
value={deployment.termination_timeout}
onConfirm={(value) => {
const val = Number(value);
if (!isNaN(val)) {
setDeployment("termination_timeout", val);
} else {
pushNotification("bad", "timeout must be number");
}
}}
/>
<div class="dimmed" style={{ position: "absolute", right: "1rem", top: "50%", transform: "translateY(-50%)" }}>seconds</div>
</div>
</Flex>
);
};
@@ -6,22 +6,24 @@ import {
onCleanup,
Show,
} from "solid-js";
import Convert from "ansi-to-html";
import { pushNotification } from "../../../..";
import { useAppState } from "../../../../state/StateProvider";
import { DockerContainerState, Log as LogType } from "../../../../types";
import { combineClasses } from "../../../../util/helpers";
import { combineClasses, sanitizeLog } from "../../../../util/helpers";
import { useBuffer, useLocalStorageToggle } from "../../../../util/hooks";
import Icon from "../../../shared/Icon";
import Flex from "../../../shared/layout/Flex";
import Grid from "../../../shared/layout/Grid";
import Selector from "../../../shared/menu/Selector";
import { useConfig } from "../config/Provider";
import s from "./log.module.scss";
const POLLING_RATE = 5000;
let interval = -1;
const convert = new Convert();
const Log: Component<{
log?: LogType;
logTail: number;
@@ -31,7 +33,6 @@ const Log: Component<{
}> = (p) => {
const { deployments } = useAppState();
const params = useParams();
const { userCanUpdate } = useConfig();
const deployment = () => deployments.get(params.id);
let ref: HTMLDivElement | undefined;
let ignore = false;
@@ -56,14 +57,15 @@ const Log: Component<{
}, 1500);
}
});
/// the return of this closure is used as the innerHTML of the <pre /> element
/// need to make sure this can't be used to inject some script into the page
const log = () => {
if (deployment()?.state === DockerContainerState.NotDeployed) {
return "not deployed";
} else {
return (
(p.error ? p.log?.stderr : p.log?.stdout) ||
`no${p.error ? " error" : ""} log`
);
const log = p.error ? p.log?.stderr : p.log?.stdout;
const sanitized = log && sanitizeLog(log);
return sanitized ? convert.toHtml(sanitized) : `no${p.error ? " error" : ""} log`;
}
};
const buffer = useBuffer(scrolled, 250);
@@ -145,7 +147,7 @@ const Log: Component<{
}
}}
>
<pre class={s.Log}>{log()}</pre>
<pre class={s.Log} innerHTML={log()} />
</div>
<Show when={buffer()}>
<button
@@ -10,9 +10,6 @@
}
.Log {
white-space: pre-line;
overflow-wrap: anywhere;
tab-size: 2;
padding: 1rem;
margin-bottom: 40vh;
}
@@ -0,0 +1,131 @@
import { Component, Show, createMemo } from "solid-js";
import { useAppState } from "../../state/StateProvider";
import Flex from "../shared/layout/Flex";
import Grid from "../shared/layout/Grid";
import LightweightChart from "../shared/LightweightChart";
import Loading from "../shared/loading/Loading";
import { COLORS } from "../../style/colors";
import { BuildStatsResponse } from "../../util/client_types";
import { useLocalStorage } from "../../util/hooks";
const BuildSummary: Component<{}> = (p) => {
const { build_stats } = useAppState();
return (
<Grid class="full-size card" gridTemplateRows="auto 1fr" style={{ "padding-bottom": "0.6rem" }}>
<Show
when={build_stats.get()}
fallback={
<Grid class="full-size" placeItems="center">
<Loading type="three-dot" />
</Grid>
}
>
<Flex
justifyContent="space-between"
alignItems="center"
style={{ height: "fit-content" }}
>
<h2>last 30 days</h2>
<Flex>
<Flex alignItems="center" gap="0.5rem">
<div class="dimmed">build time: </div>
<h2>{build_stats.get()?.total_time.toFixed(1)} hrs</h2>
</Flex>
<Flex alignItems="center" gap="0.5rem">
<div class="dimmed">build count: </div>
<h2>{build_stats.get()?.total_count.toFixed()}</h2>
</Flex>
</Flex>
</Flex>
<BuildStatsChart build_stats={build_stats.get()!} />
</Show>
</Grid>
);
};
export default BuildSummary;
const BuildStatsChart: Component<{ build_stats: BuildStatsResponse }> = (p) => {
const [mode, setMode] = useLocalStorage<"time" | "count">(
"time",
"build-stats-chart-mode-v2"
);
const max = createMemo(() => {
return p.build_stats.days.reduce((max, day) => {
if (mode() === "count") {
if (day.count > max) {
return day.count;
} else return max;
} else {
if (day.time > max) {
return day.time;
} else return max;
}
}, 0);
});
return (
<div class="full-size" style={{ position: "relative" }}>
<LightweightChart
class="full-size"
style={{ "min-height": "200px" }}
histograms={[
{
line: p.build_stats.days.map((day) => ({
value: mode() === "count" ? day.count : (day.time * 60),
time: day.ts / 1000,
color:
mode() === "count"
? day.count > max() * 0.7
? COLORS.red
: day.count > max() * 0.35
? COLORS.orange
: COLORS.green
: day.time > max() * 0.7
? COLORS.red
: day.time > max() * 0.35
? COLORS.orange
: COLORS.green,
})),
priceLineVisible: false,
// priceFormat:
// mode() === "count"
// ? {
// minMove: 1,
// }
// : undefined,
priceFormat: {
minMove: 1,
}
},
]}
timeVisible={false}
options={{
grid: {
horzLines: { visible: false },
vertLines: { visible: false },
},
}}
disableScroll
/>
<button
class="blue opaque"
style={{
position: "absolute",
top: 0,
right: 0,
"z-index": 20,
padding: "0.3rem",
}}
onClick={() => setMode(mode => {
if (mode === "count") {
return "time"
} else {
return "count"
}
})}
>
{mode()}{mode() === "time" ? " (min)" : ""}
</button>
</div>
);
};
+21 -7
View File
@@ -1,30 +1,44 @@
import {
Component,
Component, Match, Show, Switch,
} from "solid-js";
import { useAppDimensions } from "../../state/DimensionProvider";
import Grid from "../shared/layout/Grid";
import SimpleTabs from "../shared/tabs/SimpleTabs";
import { ControlledSimpleTabs } from "../shared/tabs/SimpleTabs";
import Summary from "./Summary";
import Builds from "./Tree/Builds";
import Groups from "./Tree/Groups";
import { TreeProvider } from "./Tree/Provider";
import Updates from "./Updates/Updates";
import { useLocalStorage } from "../../util/hooks";
import BuildSummary from "./BuildSummary";
const Home: Component<{}> = (p) => {
const { isSemiMobile } = useAppDimensions();
const [selectedTab, setTab] = useLocalStorage<"servers" | "builds">(
"servers",
"home-groups-servers-tab-v2"
);
return (
<>
<Grid
style={{ width: "100%" }}
gridTemplateColumns={isSemiMobile() ? "1fr" : "1fr 1fr"}
>
<Summary />
<Switch>
<Match when={selectedTab() === "servers"}>
<Summary />
</Match>
<Match when={selectedTab() === "builds"}>
<BuildSummary />
</Match>
</Switch>
<Updates />
</Grid>
<TreeProvider>
<SimpleTabs
<ControlledSimpleTabs
selected={selectedTab}
set={setTab as any}
containerStyle={{ width: "100%" }}
localStorageKey="home-groups-servers-tab-v1"
tabs={[
{
title: "servers",
@@ -32,8 +46,8 @@ const Home: Component<{}> = (p) => {
},
{
title: "builds",
element: () => <Builds />
}
element: () => <Builds />,
},
]}
/>
</TreeProvider>
+5 -1
View File
@@ -74,7 +74,11 @@ const Groups: Component<{}> = (p) => {
/>
<Flex alignItems="center" style={{ width: "fit-content" }}>
<Selector
label={<div class="dimmed">sort by:</div>}
label={
<div class="dimmed" style={{ "white-space": "nowrap" }}>
sort by:
</div>
}
selected={sort()}
items={TREE_SORTS as any as string[]}
onSelect={(mode) => setSort(mode as TreeSortType)}
@@ -0,0 +1,58 @@
import { Component, For, Show, createEffect, createResource, onCleanup } from "solid-js";
import Grid from "../../shared/layout/Grid";
import { useParams } from "@solidjs/router";
import { client } from "../../..";
import Flex from "../../shared/layout/Flex";
import { readableBytes, readableImageNameTag, readableTimestamp } from "../../../util/helpers";
import { useAppState } from "../../../state/StateProvider";
import { Operation } from "../../../types";
const Images: Component<{}> = (p) => {
const params = useParams();
const { ws } = useAppState();
const [images, { refetch }] = createResource(() => client.get_docker_images(params.id));
let unsub = () => {};
createEffect(() => {
unsub();
unsub = ws.subscribe([Operation.PruneImagesServer], (update) => {
if (update.target.id === params.id) {
refetch()
}
});
});
onCleanup(() => unsub());
createEffect(() => console.log(images()));
return (
<Grid class="config">
<Grid class="config-items">
<Show when={images()}>
<For each={images()}>
{(image) => {
const [name, tag] = readableImageNameTag(image.RepoTags, image.RepoDigests);
return (
<Flex
class="card light hover shadow"
alignItems="center"
justifyContent="space-between"
>
<Flex alignItems="center">
<h2>{name}</h2>
<h2 class="dimmed">{tag}</h2>
</Flex>
<Flex alignItems="center">
<div>{readableBytes(image.Size)}</div>
<div class="dimmed">
{readableTimestamp(image.Created * 1000)}
</div>
</Flex>
</Flex>
);
}}
</For>
</Show>
</Grid>
</Grid>
);
}
export default Images;
@@ -9,6 +9,7 @@ import Config from "./config/Config";
import { ConfigProvider } from "./config/Provider";
import Info from "./Info";
import Permissions from "./Permissions";
import Images from "./Images";
const ServerTabs: Component<{}> = (p) => {
const { servers } = useAppState();
@@ -31,6 +32,10 @@ const ServerTabs: Component<{}> = (p) => {
title: "info",
element: () => <Info />
},
server()?.status === ServerStatus.Ok && {
title: "images",
element: () => <Images />
},
user().admin && {
title: "permissions",
element: () => <Permissions />,
@@ -9,10 +9,15 @@ import {
useContext,
} from "solid-js";
import { createStore, SetStoreFunction } from "solid-js/store";
import { client } from "../../../..";
import { client, pushNotification } from "../../../..";
import { useAppState } from "../../../../state/StateProvider";
import { useUser } from "../../../../state/UserProvider";
import { Server, Operation, PermissionLevel, ServerStatus } from "../../../../types";
import {
Server,
Operation,
PermissionLevel,
ServerStatus,
} from "../../../../types";
import { getId } from "../../../../util/helpers";
type ConfigServer = Server & { loaded: boolean; updated: boolean };
@@ -49,6 +54,7 @@ export const ConfigProvider: ParentComponent<{}> = (p) => {
client.get_server(params.id).then((server) => {
set({
...server.server,
_id: { $oid: params.id } as any,
loaded: true,
updated: false,
});
@@ -66,10 +72,15 @@ export const ConfigProvider: ParentComponent<{}> = (p) => {
createEffect(loadNetworks);
const save = () => {
client.update_server(server);
client
.update_server(server)
.catch((e) => {
console.error(e);
pushNotification("bad", "update server failed");
});
};
let unsub = () => {}
let unsub = () => {};
createEffect(() => {
unsub();
unsub = ws.subscribe([Operation.UpdateServer], (update) => {
@@ -94,7 +105,8 @@ export const ConfigProvider: ParentComponent<{}> = (p) => {
const userCanUpdate = () =>
user().admin ||
servers.get(params.id)!.server.permissions![getId(user())] === PermissionLevel.Update;
servers.get(params.id)!.server.permissions![getId(user())] ===
PermissionLevel.Update;
const state = {
server,
@@ -7,6 +7,7 @@ import { PermissionLevel } from "../../types";
import { NewDeployment } from "../New";
import Deployment from "./Deployment";
import { useAppState } from "../../state/StateProvider";
import Flex from "../shared/layout/Flex";
const ServerChildren: Component<{ id: string }> = (p) => {
const { user } = useUser();
@@ -35,7 +36,9 @@ const ServerChildren: Component<{ id: string }> = (p) => {
PermissionLevel.Update
}
>
<NewDeployment serverID={p.id} />
<Flex class="full-width" alignItems="center">
<NewDeployment serverID={p.id} />
</Flex>
</Show>
</Grid>
</div>
@@ -15,6 +15,7 @@ const ConfirmMenuButton: Component<{
title: string;
match: string;
info?: JSX.Element;
configs?: JSX.Element;
children: JSX.Element;
}> = (p) => {
const [show, toggleShow] = useToggle();
@@ -38,6 +39,7 @@ const ConfirmMenuButton: Component<{
title={p.title}
match={p.match}
info={p.info}
configs={p.configs}
onConfirm={p.onConfirm}
/>
)}
@@ -52,6 +54,7 @@ const ConfirmMenuContent: Component<{
match: string;
onConfirm?: () => void;
info?: JSX.Element;
configs?: JSX.Element;
}> = (p) => {
const [input, setInput] = createSignal("");
return (
@@ -71,6 +74,7 @@ const ConfirmMenuContent: Component<{
value={input()}
autofocus
/>
{p.configs}
<ConfirmButton
class={p.class}
style={{ width: "100%" }}
@@ -1,7 +1,11 @@
import {
AreaSeriesPartialOptions,
BarSeriesPartialOptions,
ChartOptions,
ColorType,
createChart,
DeepPartial,
HistogramSeriesPartialOptions,
IChartApi,
ISeriesApi,
LineSeriesPartialOptions,
@@ -18,6 +22,7 @@ import {
export type LightweightValue = {
time: number | string;
value: number;
color?: string;
};
export type LightweightLine = {
@@ -28,9 +33,14 @@ export type LightweightArea = {
line: LightweightValue[];
} & AreaSeriesPartialOptions;
export type LightweightHistogram = {
line: LightweightValue[];
} & HistogramSeriesPartialOptions;
const LightweightChart: Component<{
lines?: LightweightLine[];
areas?: LightweightArea[];
histograms?: LightweightHistogram[];
class?: string;
style?: JSX.CSSProperties;
width?: string;
@@ -38,11 +48,15 @@ const LightweightChart: Component<{
disableScroll?: boolean;
onCreateLineSeries?: (series: ISeriesApi<"Line">) => void;
onCreateAreaSeries?: (series: ISeriesApi<"Area">) => void;
onCreateHistogramSeries?: (series: ISeriesApi<"Histogram">) => void;
timeVisible?: boolean;
options?: DeepPartial<ChartOptions>;
}> = (p) => {
let el: HTMLDivElement;
const [chart, setChart] = createSignal<IChartApi>();
let lineSeries: ISeriesApi<"Line">[] = [];
let areaSeries: ISeriesApi<"Area">[] = [];
let histogramSeries: ISeriesApi<"Histogram">[] = [];
const [loaded, setLoaded] = createSignal(false);
onMount(() => {
if (loaded()) return;
@@ -58,9 +72,10 @@ const LightweightChart: Component<{
horzLines: { color: "#3f454d" },
vertLines: { color: "#3f454d" },
},
timeScale: { timeVisible: true },
timeScale: { timeVisible: p.timeVisible ?? true },
handleScroll: p.disableScroll ? false : true,
handleScale: p.disableScroll ? false : true,
...p.options
});
chart.timeScale().fitContent();
setChart(chart);
@@ -95,6 +110,20 @@ const LightweightChart: Component<{
});
areaSeries = series;
}
for (const series of histogramSeries) {
chart()!.removeSeries(series);
}
if (p.histograms) {
const series = p.histograms.map((line) => {
const series = chart()!.addHistogramSeries(line);
series.setData(line.line as any);
if (p.onCreateHistogramSeries) {
p.onCreateHistogramSeries(series);
}
return series;
});
histogramSeries = series;
}
chart()!.timeScale().fitContent();
}
});
@@ -15,10 +15,10 @@ import { Position } from "./helpers";
import Menu from "./Menu";
import s from "./menu.module.scss";
const Selector: Component<{
selected: string;
items: string[];
onSelect?: (item: string, index: number) => void;
const Selector = <T,>(p: {
selected: T;
items: T[];
onSelect?: (item: T, index: number) => void;
position?: Position;
targetClass?: string;
targetStyle?: JSX.CSSProperties;
@@ -33,12 +33,13 @@ const Selector: Component<{
itemClass?: string;
itemStyle?: JSX.CSSProperties;
label?: JSXElement;
itemMap?: (item: string) => string;
}> = (p) => {
itemMap?: (item: T) => JSXElement;
searchItemMap?: (item: T) => string;
}) => {
const [show, toggle] = useToggle();
const [search, setSearch] = createSignal("");
let search_ref: HTMLInputElement | undefined;
const current = () => (p.itemMap ? p.itemMap(p.selected) : p.selected);
const current = () => (p.itemMap ? p.itemMap(p.selected) : p.selected as JSXElement);
createEffect(() => {
if (show()) setTimeout(() => search_ref?.focus(), 200);
});
@@ -87,9 +88,11 @@ const Selector: Component<{
each={
p.useSearch
? p.items.filter((item) =>
p.itemMap
? p.itemMap(item).includes(search())
: item.includes(search())
p.searchItemMap
? p.searchItemMap(item).includes(search())
: p.itemMap && typeof p.itemMap(item) === "string"
? (p.itemMap(item) as string).includes(search())
: (item as string).includes(search())
)
: p.items
}
@@ -107,7 +110,7 @@ const Selector: Component<{
}}
class={combineClasses(p.itemClass, s.SelectorItem)}
>
{p.itemMap ? p.itemMap(item) : item}
{p.itemMap ? p.itemMap(item) : item as string}
</button>
)}
</For>
@@ -9,7 +9,7 @@ export type Position =
| "bottom right"
| "bottom center";
export function getPositionClass(position: Position = "bottom") {
export function getPositionClass(position: Position = "bottom right") {
switch (position) {
case "left":
return s.Left;
@@ -28,19 +28,19 @@ const SimpleTabs: Component<{
containerClass?: string;
containerStyle?: JSX.CSSProperties;
}> = (p) => {
const def = p.defaultSelected ? p.defaultSelected : p.tabs[0].title;
const defaultSelected = p.defaultSelected ? p.defaultSelected : p.tabs[0].title;
const [selected, set] = p.localStorageKey
? useLocalStorage(def, p.localStorageKey)
: createSignal(def);
? useLocalStorage(defaultSelected, p.localStorageKey)
: createSignal(defaultSelected);
createEffect(() => {
if (p.tabs.filter((tab) => tab.title === selected())[0] === undefined) {
set(p.tabs[0].title);
}
});
return <ControlledTabs selected={selected} set={set} {...p} />;
return <ControlledSimpleTabs selected={selected} set={set} {...p} />;
};
export const ControlledTabs: Component<{
export const ControlledSimpleTabs: Component<{
tabs: Tab[];
selected: Accessor<string>;
set: LocalStorageSetter<string>;
+1 -1
View File
@@ -12,7 +12,7 @@ import {
convertTsMsToLocalUnixTsInSecs,
get_to_one_sec_divisor,
} from "../../util/helpers";
import { useLocalStorage, useLocalStorageToggle } from "../../util/hooks";
import { useLocalStorageToggle } from "../../util/hooks";
import Flex from "../shared/layout/Flex";
import Grid from "../shared/layout/Grid";
import LightweightChart, { LightweightValue } from "../shared/LightweightChart";
+5 -1
View File
@@ -3,6 +3,7 @@ import { createContext, createResource, ParentComponent, Resource, useContext }
import { useWindowKeyDown } from "../util/hooks";
import {
useBuilds,
useBuildStats,
useDeployments,
useGroups,
useProcedures,
@@ -19,6 +20,7 @@ import connectToWs from "./ws";
import { useUser } from "./UserProvider";
import { AwsBuilderConfig, PermissionLevel, UpdateTarget } from "../types";
import { client } from "..";
import { BuildStatsResponse } from "../util/client_types";
export type State = {
usernames: ReturnType<typeof useUsernames>;
@@ -43,6 +45,7 @@ export type State = {
docker_organizations: Resource<string[]>;
github_webhook_base_url: Resource<string>;
name_from_update_target: (target: UpdateTarget) => string;
build_stats: ReturnType<typeof useBuildStats>;
};
const context = createContext<
@@ -93,6 +96,7 @@ export const AppStateProvider: ParentComponent = (p) => {
});
},
builds,
build_stats: useBuildStats(),
getPermissionOnBuild: (id: string) => {
const build = builds.get(id)!;
const permissions = build.permissions![userId] as
@@ -161,7 +165,7 @@ export const AppStateProvider: ParentComponent = (p) => {
} else {
return "unknown"
}
}
},
};
// createEffect(() => {
+23
View File
@@ -13,6 +13,7 @@ import {
intoCollection,
keepOnlyInObj,
} from "../util/helpers";
import { BuildStatsResponse } from "../util/client_types";
type Collection<T> = Record<string, T>;
@@ -217,6 +218,8 @@ export function useUsernames() {
const load = async (userID: string) => {
if (userID === "github") {
set((s) => ({ ...s, [userID]: "github" }));
} else if (userID === "auto redeploy") {
set((s) => ({ ...s, [userID]: "auto redeploy" }));
} else {
const username = await client.get_username(userID);
set((s) => ({ ...s, [userID]: username }));
@@ -245,6 +248,26 @@ export function useBuilds() {
);
}
let build_stats_loading = false;
export function useBuildStats() {
const [stats, set] = createSignal<BuildStatsResponse>();
const reload = () => {
client.get_build_stats().then(set);
};
const get = () => {
if (stats()) {
return stats();
} else if (!build_stats_loading) {
build_stats_loading = true;
reload()
}
}
return {
get,
reload,
};
}
const deploymentIdPath = ["deployment", "_id", "$oid"];
export function useDeployments() {
+15 -4
View File
@@ -63,7 +63,15 @@ function connectToWs(state: State) {
}
async function handleMessage(
{ deployments, builds, servers, groups, procedures, updates }: State,
{
deployments,
builds,
servers,
groups,
procedures,
updates,
build_stats,
}: State,
update: Update
) {
updates.addOrUpdate(update);
@@ -135,13 +143,16 @@ async function handleMessage(
if (update.status === UpdateStatus.Complete) {
builds.delete(update.target.id!);
}
} else if (
[Operation.UpdateBuild, Operation.BuildBuild].includes(update.operation)
) {
} else if (update.operation === Operation.UpdateBuild) {
if (update.status === UpdateStatus.Complete) {
const build = await client.get_build(update.target.id!);
builds.update(build);
}
} else if (update.operation === Operation.BuildBuild) {
if (update.status === UpdateStatus.Complete) {
build_stats.reload();
client.get_build(update.target.id!).then((build) => builds.update(build));
}
}
// server
+7
View File
@@ -26,6 +26,9 @@ code {
pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
tab-size: 2;
}
h1 {
@@ -180,6 +183,10 @@ svg {
background-color: rgba(c.$blue, 0.8);
}
.blue.opaque {
background-color: c.$blue;
}
.blue:hover {
background-color: c.$lightblue;
}
+16
View File
@@ -105,7 +105,11 @@ export interface Deployment {
permissions?: PermissionsMap;
skip_secret_interp?: boolean;
docker_run_args: DockerRunArgs;
term_signal_labels?: TerminationSignalLabel[];
termination_signal?: TerminationSignal;
termination_timeout?: number;
build_id?: string;
redeploy_on_build?: boolean;
build_version?: Version;
repo?: string;
branch?: string;
@@ -134,6 +138,11 @@ export interface DeploymentActionState {
renaming: boolean;
}
export interface TerminationSignalLabel {
signal: TerminationSignal;
label: string;
}
export interface DockerRunArgs {
image: string;
ports?: Conversion[];
@@ -412,6 +421,13 @@ export enum RestartMode {
UnlessStopped = "unless-stopped",
}
export enum TerminationSignal {
SigHup = "SIGHUP",
SigInt = "SIGINT",
SigQuit = "SIGQUIT",
SigTerm = "SIGTERM",
}
export enum AccountType {
Github = "github",
Docker = "docker",
+41 -8
View File
@@ -29,6 +29,8 @@ import {
UserCredentials,
} from "../types";
import {
BuildStatsQuery,
BuildStatsResponse,
BuildVersionsQuery,
CopyBuildBody,
CopyDeploymentBody,
@@ -43,7 +45,7 @@ import {
ModifyUserCreateServerBody,
ModifyUserEnabledBody,
PermissionsUpdateBody,
RenameDeploymentBody,
StopContainerQuery,
UpdateDescriptionBody,
} from "./client_types";
import { generateQuery, QueryObject } from "./helpers";
@@ -217,20 +219,39 @@ export class Client {
return this.post(`/api/deployment/${deployment_id}/pull`);
}
deploy_container(deployment_id: string): Promise<Update> {
return this.post(`/api/deployment/${deployment_id}/deploy`);
deploy_container(
deployment_id: string,
query?: StopContainerQuery
): Promise<Update> {
return this.post(
`/api/deployment/${deployment_id}/deploy${generateQuery(query as any)}`
);
}
start_container(deployment_id: string): Promise<Update> {
return this.post(`/api/deployment/${deployment_id}/start_container`);
}
stop_container(deployment_id: string): Promise<Update> {
return this.post(`/api/deployment/${deployment_id}/stop_container`);
stop_container(
deployment_id: string,
query?: StopContainerQuery
): Promise<Update> {
return this.post(
`/api/deployment/${deployment_id}/stop_container${generateQuery(
query as any
)}`
);
}
remove_container(deployment_id: string): Promise<Update> {
return this.post(`/api/deployment/${deployment_id}/remove_container`);
remove_container(
deployment_id: string,
query?: StopContainerQuery
): Promise<Update> {
return this.post(
`/api/deployment/${deployment_id}/remove_container${generateQuery(
query as any
)}`
);
}
async download_container_log(
@@ -364,7 +385,15 @@ export class Client {
return this.post(`/api/server/${server_id}/networks/prune`);
}
get_docker_images(server_id: string): Promise<any[]> {
get_docker_images(server_id: string): Promise<
{
RepoTags: string[];
RepoDigests: string[];
Size: number;
Created: number;
Id: string;
}[]
> {
return this.get(`/api/server/${server_id}/images`);
}
@@ -401,6 +430,10 @@ export class Client {
return this.get(`/api/build/${id}/versions${generateQuery(query as any)}`);
}
get_build_stats(query?: BuildStatsQuery): Promise<BuildStatsResponse> {
return this.get(`/api/build/stats${generateQuery(query as any)}`);
}
create_build(body: CreateBuildBody): Promise<Build> {
return this.post("/api/build/create", body);
}
+622
View File
@@ -0,0 +1,622 @@
import axios from "axios";
import {
BuildStatsQuery,
BuildStatsResponse,
BuildVersionsQuery,
CopyBuildBody,
CopyDeploymentBody,
CreateBuildBody,
CreateDeploymentBody,
CreateGroupBody,
CreateProcedureBody,
CreateSecretBody,
CreateServerBody,
LoginOptions,
ModifyUserCreateBuildBody,
ModifyUserCreateServerBody,
ModifyUserEnabledBody,
PermissionsUpdateBody,
StopContainerQuery,
UpdateDescriptionBody,
} from "./client_types";
import { MONITOR_BASE_URL } from "..";
import {
AwsBuilderConfig,
BasicContainerInfo,
Build,
BuildActionState,
BuildVersionsReponse,
Deployment,
DeploymentActionState,
DeploymentWithContainerState,
DockerContainerStats,
Group,
HistoricalStatsQuery,
Log,
Operation,
Procedure,
Server,
ServerActionState,
ServerWithStatus,
SystemInformation,
SystemStats,
SystemStatsQuery,
SystemStatsRecord,
Update,
UpdateTarget,
User,
UserCredentials,
} from "../types";
import fileDownload from "js-file-download";
import { QueryObject, generateQuery } from "./helpers";
export function monitor_client(base_url: string, token: string | null) {
const state = {
base_url,
token: token as string | null,
login_options: undefined as LoginOptions | undefined,
monitor_title: undefined as string | undefined,
secrets_cache: {} as Record<string, string[]>,
github_accounts_cache: {} as Record<string, string[]>,
docker_accounts_cache: {} as Record<string, string[]>,
server_version_cache: {} as Record<string, string>,
};
const request = request_client(state);
const client = {
// ===========
// AUTH / USER
// ===========
async initialize() {
const [loginOptions, monitorTitle] = await Promise.all([
client.get_login_options(),
client.get_monitor_title(),
]);
state.login_options = loginOptions;
state.monitor_title = monitorTitle;
document.title = monitorTitle;
const params = new URLSearchParams(location.search);
const exchange_token = params.get("token");
if (exchange_token) {
history.replaceState({}, "", MONITOR_BASE_URL);
try {
const jwt = await client.exchange_for_jwt(exchange_token);
token = jwt;
localStorage.setItem("access_token", jwt);
} catch (error) {
console.warn(error);
}
}
},
get_login_options(): Promise<LoginOptions> {
return request.get("/auth/options");
},
async login(credentials: UserCredentials) {
const jwt: string = await request.post("/auth/local/login", credentials);
state.token = jwt;
localStorage.setItem("access_token", state.token);
return await client.get_user();
},
async signup(credentials: UserCredentials) {
const jwt: string = await request.post(
"/auth/local/create_user",
credentials
);
state.token = jwt;
localStorage.setItem("access_token", state.token);
return await client.get_user();
},
logout() {
localStorage.removeItem("access_token");
state.token = null;
},
async get_user(): Promise<User | false> {
if (state.token) {
try {
return await request.get("/api/user");
} catch (error: any) {
client.logout();
return false;
}
} else {
return false;
}
},
get_username(user_id: string): Promise<string> {
return request.get(`/api/username/${user_id}`);
},
// admin only
list_users(): Promise<User[]> {
return request.get("/api/users");
},
// admin only
get_user_by_id(user_id: string): Promise<User> {
return request.get(`/api/user/${user_id}`);
},
exchange_for_jwt(exchange_token: string): Promise<string> {
return request.post("/auth/exchange", { token: exchange_token });
},
create_api_secret(body: CreateSecretBody): Promise<string> {
return request.post("/api/secret/create", body);
},
delete_api_secret(name: string): Promise<undefined> {
return request.delete(`/api/secret/delete/${name}`);
},
// ====
// MISC
// ====
get_github_webhook_base_url(): Promise<string> {
return request.get("/api/github_webhook_base_url");
},
update_description(body: UpdateDescriptionBody): Promise<undefined> {
return request.post("/api/update_description", body);
},
get_monitor_title(): Promise<string> {
return request.get("/api/title");
},
list_updates(
offset: number,
target?: UpdateTarget,
show_builds?: boolean,
operations?: Operation[]
): Promise<Update[]> {
return request.get(
`/api/update/list${generateQuery({
offset,
type: target && target.type,
id: target && target.id,
show_builds,
operations: operations?.join(","),
})}`
);
},
// ==========
// DEPLOYMENT
// ==========
list_deployments(
query?: QueryObject
): Promise<DeploymentWithContainerState[]> {
return request.get("/api/deployment/list" + generateQuery(query));
},
get_deployment(id: string): Promise<DeploymentWithContainerState> {
return request.get(`/api/deployment/${id}`);
},
get_deployment_action_state(id: string): Promise<DeploymentActionState> {
return request.get(`/api/deployment/${id}/action_state`);
},
get_deployment_container_log(id: string, tail?: number): Promise<Log> {
return request.get(`/api/deployment/${id}/log${generateQuery({ tail })}`);
},
get_deployment_container_stats(id: string): Promise<DockerContainerStats> {
return request.get(`/api/deployment/${id}/stats`);
},
get_deployment_deployed_version(id: string): Promise<string> {
return request.get(`/api/deployment/${id}/deployed_version`);
},
create_deployment(body: CreateDeploymentBody): Promise<Deployment> {
return request.post("/api/deployment/create", body);
},
create_full_deployment(deployment: Deployment): Promise<Deployment> {
return request.post("/api/deployment/create_full", deployment);
},
copy_deployment(
target_id: string,
body: CopyDeploymentBody
): Promise<Deployment> {
return request.post(`/api/deployment/${target_id}/copy`, body);
},
delete_deployment(id: string): Promise<Deployment> {
return request.delete(`/api/deployment/${id}/delete`);
},
update_deployment(deployment: Deployment): Promise<Deployment> {
return request.patch("/api/deployment/update", deployment);
},
rename_deployment(deployment_id: string, new_name: string) {
return request.patch(`/api/deployment/${deployment_id}/rename`, {
new_name,
});
},
reclone_deployment(deployment_id: string): Promise<Update> {
return request.post(`/api/deployment/${deployment_id}/reclone`);
},
pull_deployment(deployment_id: string): Promise<Update> {
return request.post(`/api/deployment/${deployment_id}/pull`);
},
deploy_container(
deployment_id: string,
query?: StopContainerQuery
): Promise<Update> {
return request.post(
`/api/deployment/${deployment_id}/deploy${generateQuery(query as any)}`
);
},
start_container(deployment_id: string): Promise<Update> {
return request.post(`/api/deployment/${deployment_id}/start_container`);
},
stop_container(
deployment_id: string,
query?: StopContainerQuery
): Promise<Update> {
return request.post(
`/api/deployment/${deployment_id}/stop_container${generateQuery(
query as any
)}`
);
},
remove_container(
deployment_id: string,
query?: StopContainerQuery
): Promise<Update> {
return request.post(
`/api/deployment/${deployment_id}/remove_container${generateQuery(
query as any
)}`
);
},
async download_container_log(
id: string,
name: string,
error?: boolean | undefined
) {
const log = await client.get_deployment_container_log(id, 5000);
const date = new Date();
fileDownload(
(error ? log.stderr : log.stdout) || "no log",
`${name}-${error ? "error-" : ""}log-${date
.toLocaleDateString()
.replaceAll("/", "-")}.txt`
);
},
// ======
// SERVER
// ======
list_servers(query?: QueryObject): Promise<ServerWithStatus[]> {
return request.get("/api/server/list" + generateQuery(query));
},
get_server(server_id: string): Promise<ServerWithStatus> {
return request.get(`/api/server/${server_id}`);
},
get_server_action_state(id: string): Promise<ServerActionState> {
return request.get(`/api/server/${id}/action_state`);
},
get_server_github_accounts(id: string): Promise<string[]> {
return request.get(`/api/server/${id}/github_accounts`);
},
get_server_docker_accounts(id: string): Promise<string[]> {
return request.get(`/api/server/${id}/docker_accounts`);
},
get_server_available_secrets(id: string): Promise<string[]> {
return request.get(`/api/server/${id}/secrets`);
},
get_server_version(id: string): Promise<string> {
return request.get(`/api/server/${id}/version`);
},
get_server_system_info(id: string): Promise<SystemInformation> {
return request.get(`/api/server/${id}/system_information`);
},
create_server(body: CreateServerBody): Promise<Server> {
return request.post("/api/server/create", body);
},
create_full_server(server: Server): Promise<Server> {
return request.post("/api/server/create_full", server);
},
delete_server(id: string): Promise<Server> {
return request.delete(`/api/server/${id}/delete`);
},
update_server(server: Server): Promise<Server> {
return request.patch("/api/server/update", server);
},
get_server_stats(
server_id: string,
query?: SystemStatsQuery
): Promise<SystemStats> {
return request.get(
`/api/server/${server_id}/stats${generateQuery(query as any)}`
);
},
get_server_stats_history(
server_id: string,
query?: HistoricalStatsQuery
): Promise<SystemStatsRecord[]> {
return request.get(
`/api/server/${server_id}/stats/history${generateQuery(query as any)}`
);
},
get_server_stats_at_ts(
server_id: string,
ts: number
): Promise<SystemStatsRecord> {
return request.get(`/api/server/${server_id}/stats/at_ts?ts=${ts}`);
},
get_docker_networks(server_id: string): Promise<any[]> {
return request.get(`/api/server/${server_id}/networks`);
},
prune_docker_networks(server_id: string): Promise<Log> {
return request.post(`/api/server/${server_id}/networks/prune`);
},
get_docker_images(server_id: string): Promise<
{
RepoTags: string[];
RepoDigests: string[];
Size: number;
Created: number;
Id: string;
}[]
> {
return request.get(`/api/server/${server_id}/images`);
},
prune_docker_images(server_id: string): Promise<Log> {
return request.post(`/api/server/${server_id}/images/prune`);
},
get_docker_containers(server_id: string): Promise<BasicContainerInfo[]> {
return request.get(`/api/server/${server_id}/containers`);
},
prune_docker_containers(server_id: string): Promise<Log> {
return request.post(`/api/server/${server_id}/containers/prune`);
},
// =====
// BUILD
// =====
list_builds(query?: QueryObject): Promise<Build[]> {
return request.get("/api/build/list" + generateQuery(query));
},
get_build(build_id: string): Promise<Build> {
return request.get(`/api/build/${build_id}`);
},
get_build_action_state(id: string): Promise<BuildActionState> {
return request.get(`/api/build/${id}/action_state`);
},
get_build_versions(
id: string,
query?: BuildVersionsQuery
): Promise<BuildVersionsReponse[]> {
return request.get(
`/api/build/${id}/versions${generateQuery(query as any)}`
);
},
get_build_stats(query?: BuildStatsQuery): Promise<BuildStatsResponse> {
return request.get(`/api/build/stats${generateQuery(query as any)}`);
},
create_build(body: CreateBuildBody): Promise<Build> {
return request.post("/api/build/create", body);
},
create_full_build(build: Build): Promise<Build> {
return request.post("/api/build/create_full", build);
},
copy_build(target_id: string, body: CopyBuildBody): Promise<Build> {
return request.post(`/api/build/${target_id}/copy`, body);
},
delete_build(id: string): Promise<Build> {
return request.delete(`/api/build/${id}/delete`);
},
update_build(build: Build): Promise<Build> {
return request.patch("/api/build/update", build);
},
build(build_id: string): Promise<Update> {
return request.post(`/api/build/${build_id}/build`);
},
reclone_build(id: string): Promise<Update> {
return request.post(`/api/build/${id}/reclone`);
},
get_aws_builder_defaults(): Promise<AwsBuilderConfig> {
return request.get("/api/build/aws_builder_defaults");
},
get_docker_organizations(): Promise<string[]> {
return request.get("/api/build/docker_organizations");
},
// =========
// PROCEDURE
// =========
list_procedures(query?: QueryObject): Promise<Procedure[]> {
return request.get("/api/procedure/list" + generateQuery(query));
},
get_procedure(procedure_id: string): Promise<Procedure> {
return request.get(`/api/procedure/${procedure_id}`);
},
create_procedure(body: CreateProcedureBody): Promise<Procedure> {
return request.post("/api/procedure/create", body);
},
create_full_procedure(procedure: Procedure): Promise<Procedure> {
return request.post("/api/procedure/create_full", procedure);
},
delete_procedure(id: string): Promise<Procedure> {
return request.delete(`/api/procedure/${id}/delete`);
},
update_procedure(procedure: Procedure): Promise<Procedure> {
return request.patch("/api/procedure/update", procedure);
},
run_procedure(id: string): Promise<Update> {
return request.post(`/api/procedure/${id}/run`);
},
// =====
// GROUP
// =====
list_groups(query?: QueryObject): Promise<Group[]> {
return request.get("/api/group/list" + generateQuery(query));
},
get_group(group_id: string): Promise<Group> {
return request.get(`/api/group/${group_id}`);
},
create_group(body: CreateGroupBody): Promise<Group> {
return request.post("/api/group/create", body);
},
create_full_group(group: Group): Promise<Group> {
return request.post("/api/group/create_full", group);
},
delete_group(id: string): Promise<Group> {
return request.delete(`/api/group/${id}/delete`);
},
update_group(group: Group): Promise<Group> {
return request.patch("/api/group/update", group);
},
// ===========
// PERMISSIONS
// ===========
update_user_permissions_on_target(
body: PermissionsUpdateBody
): Promise<Update> {
return request.post("/api/permissions/update", body);
},
modify_user_enabled(body: ModifyUserEnabledBody): Promise<Update> {
return request.post("/api/permissions/modify_enabled", body);
},
modify_user_create_server_permissions(
body: ModifyUserCreateServerBody
): Promise<Update> {
return request.post("/api/permissions/modify_create_server", body);
},
modify_user_create_build_permissions(
body: ModifyUserCreateBuildBody
): Promise<Update> {
return request.post("/api/permissions/modify_create_build", body);
},
};
return client;
}
function request_client(state: { base_url: string; token: string | null }) {
return {
async get<R = any>(url: string): Promise<R> {
return await axios({
method: "get",
url: state.base_url + url,
headers: {
authorization: state.token ? `Bearer ${state.token}` : undefined,
},
}).then(({ data }) => data);
},
async post<B = any, R = any>(url: string, body?: B): Promise<R> {
return await axios({
method: "post",
url: state.base_url + url,
headers: {
authorization: `Bearer ${state.token}`,
},
data: body,
}).then(({ data }) => data);
},
async patch<B = any, R = any>(url: string, body: B): Promise<R> {
return await axios({
method: "patch",
url: state.base_url + url,
headers: {
authorization: `Bearer ${state.token}`,
},
data: body,
}).then(({ data }) => data);
},
async delete<R = any>(url: string): Promise<R> {
return await axios({
method: "delete",
url: state.base_url + url,
headers: {
authorization: `Bearer ${state.token}`,
},
}).then(({ data }) => data);
},
};
}
export function login_with_github() {
location.replace(`${MONITOR_BASE_URL}/auth/github/login`);
}
export function login_with_google() {
location.replace(`${MONITOR_BASE_URL}/auth/google/login`);
}
+22 -1
View File
@@ -2,7 +2,7 @@
Generated by typeshare 1.0.0
*/
import { PermissionLevel, PermissionsTarget, UpdateTarget } from "../types";
import { PermissionLevel, PermissionsTarget, TerminationSignal, UpdateTarget } from "../types";
export interface CreateBuildBody {
name: string;
@@ -19,6 +19,22 @@ export interface BuildVersionsQuery {
patch?: number;
}
export interface BuildStatsQuery {
page?: number;
}
export interface BuildStatsResponse {
total_time: number;
total_count: number;
days: BuildStatsDay[];
}
export interface BuildStatsDay {
time: number;
count: number;
ts: number;
}
export interface CreateDeploymentBody {
name: string;
server_id: string;
@@ -37,6 +53,11 @@ export interface GetContainerLogQuery {
tail?: number;
}
export interface StopContainerQuery {
stop_signal?: TerminationSignal;
stop_time?: number;
}
export interface CreateGroupBody {
name: string;
}
+40 -6
View File
@@ -1,17 +1,12 @@
import {
Build,
Deployment,
DeploymentWithContainerState,
DockerContainerState,
EnvironmentVar,
Server,
ServerStatus,
ServerWithStatus,
Timelength,
UpdateTarget,
User,
Version,
} from "../types";
import sanitizeHtml from "sanitize-html";
export function combineClasses(...classes: (string | false | undefined)[]) {
return classes.filter((c) => (c ? true : false)).join(" ");
@@ -23,6 +18,15 @@ export function inPx(num: number) {
export type QueryObject = Record<string, string | number | boolean | undefined>;
export function sanitizeLog(log: string) {
return sanitizeHtml(log, {
allowedTags: sanitizeHtml.defaults.allowedTags.filter(
(tag) => tag !== "script"
),
allowedAttributes: sanitizeHtml.defaults.allowedAttributes,
});
}
export function generateQuery(query?: QueryObject) {
if (query) {
const q = Object.keys(query)
@@ -251,3 +255,33 @@ export function readableUserType(user: User) {
return "local";
}
}
const KB = 1024;
const MB = 1024 ** 2;
const GB = 1024 ** 3;
export function readableBytes(bytes: number) {
if (bytes > GB) {
return `${(bytes / GB).toFixed(1)} GiB`;
} else if (bytes > MB) {
return `${(bytes / MB).toFixed(1)} MiB`;
} else {
return `${(bytes / KB).toFixed(1)} KiB`;
}
}
export function readableImageId(id: string) {
return id.replaceAll("sha256:", "").slice(0, 12);
}
export function readableImageNameTag(repoTags?: string[], repoDigests?: string[]): [string, string] {
if (repoTags && repoTags.length > 0) {
const [name, tag] = repoTags[0].split(":");
return [name, tag];
} else if (repoDigests && repoDigests.length > 0) {
const [name] = repoDigests[0].split("@");
return [name, "none"];
} else {
return ["none", "none"]
}
}
+110
View File
@@ -447,6 +447,13 @@
dependencies:
"@tanstack/query-core" "4.26.0"
"@types/sanitize-html@^2.9.0":
version "2.9.0"
resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.9.0.tgz#5b609f7592de22ef80a0930c39670329753dca1b"
integrity sha512-4fP/kEcKNj2u39IzrxWYuf/FnCCwwQCpif6wwY6ROUS1EPRIfWJjGkY3HIowY1EX/VbX5e86yq8AAE7UPMgATg==
dependencies:
htmlparser2 "^8.0.0"
ansi-styles@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@@ -454,6 +461,13 @@ ansi-styles@^3.2.1:
dependencies:
color-convert "^1.9.0"
ansi-to-html@^0.7.2:
version "0.7.2"
resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.7.2.tgz#a92c149e4184b571eb29a0135ca001a8e2d710cb"
integrity sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==
dependencies:
entities "^2.2.0"
anymatch@~3.1.2:
version "3.1.3"
resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
@@ -580,16 +594,61 @@ debug@^4.1.0:
dependencies:
ms "2.1.2"
deepmerge@^4.2.2:
version "4.3.1"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
dom-serializer@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.2"
entities "^4.2.0"
domelementtype@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d"
integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
domhandler@^5.0.2, domhandler@^5.0.3:
version "5.0.3"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
dependencies:
domelementtype "^2.3.0"
domutils@^3.0.1:
version "3.1.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e"
integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==
dependencies:
dom-serializer "^2.0.0"
domelementtype "^2.3.0"
domhandler "^5.0.3"
electron-to-chromium@^1.4.251:
version "1.4.284"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.284.tgz#61046d1e4cab3a25238f6bf7413795270f125592"
integrity sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==
entities@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
entities@^4.2.0, entities@^4.4.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48"
integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
esbuild@^0.16.3:
version "0.16.10"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.16.10.tgz#d485c28f1626a3f9c1796c952e4cd0561f0031bb"
@@ -628,6 +687,11 @@ escape-string-regexp@^1.0.5:
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==
escape-string-regexp@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
fancy-canvas@0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/fancy-canvas/-/fancy-canvas-0.2.2.tgz#33fd4976724169a1eda5015f515a2a1302d1ec91"
@@ -698,6 +762,16 @@ html-entities@2.3.2:
resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.2.tgz#760b404685cb1d794e4f4b744332e3b00dcfe488"
integrity sha512-c3Ab/url5ksaT0WyleslpBEthOzWhrjQbg75y7XUsfSzi3Dgzt0l8w5e7DylRn15MTlMMD58dTfzddNS2kcAjQ==
htmlparser2@^8.0.0:
version "8.0.2"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21"
integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==
dependencies:
domelementtype "^2.3.0"
domhandler "^5.0.3"
domutils "^3.0.1"
entities "^4.4.0"
immutable@^4.0.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.2.0.tgz#c91f09108ed7504c2f0faec7222f40178ff97b11"
@@ -734,6 +808,11 @@ is-number@^7.0.0:
resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
is-plain-object@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
is-what@^4.1.8:
version "4.1.8"
resolved "https://registry.yarnpkg.com/is-what/-/is-what-4.1.8.tgz#0e2a8807fda30980ddb2571c79db3d209b14cbe4"
@@ -802,6 +881,11 @@ nanoid@^3.3.4:
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab"
integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==
nanoid@^3.3.6:
version "3.3.6"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.6.tgz#443380c856d6e9f9824267d960b4236ad583ea4c"
integrity sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==
node-releases@^2.0.6:
version "2.0.8"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.8.tgz#0f349cdc8fcfa39a92ac0be9bc48b7706292b9ae"
@@ -812,6 +896,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0:
resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
parse-srcset@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1"
integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==
path-parse@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
@@ -827,6 +916,15 @@ picomatch@^2.0.4, picomatch@^2.2.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
postcss@^8.3.11:
version "8.4.23"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.23.tgz#df0aee9ac7c5e53e1075c24a3613496f9e6552ab"
integrity sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==
dependencies:
nanoid "^3.3.6"
picocolors "^1.0.0"
source-map-js "^1.0.2"
postcss@^8.4.20:
version "8.4.20"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.20.tgz#64c52f509644cecad8567e949f4081d98349dc56"
@@ -869,6 +967,18 @@ rollup@^3.7.0:
optionalDependencies:
fsevents "~2.3.2"
sanitize-html@^2.10.0:
version "2.10.0"
resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.10.0.tgz#74d28848dfcf72c39693139131895c78900ab452"
integrity sha512-JqdovUd81dG4k87vZt6uA6YhDfWkUGruUu/aPmXLxXi45gZExnt9Bnw/qeQU8oGf82vPyaE0vO4aH0PbobB9JQ==
dependencies:
deepmerge "^4.2.2"
escape-string-regexp "^4.0.0"
htmlparser2 "^8.0.0"
is-plain-object "^5.0.0"
parse-srcset "^1.0.2"
postcss "^8.3.11"
sass@^1.57.1:
version "1.57.1"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.57.1.tgz#dfafd46eb3ab94817145e8825208ecf7281119b5"
+2 -2
View File
@@ -1,11 +1,11 @@
[package]
name = "db_client"
version = "0.3.0"
version = "0.3.4"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
types = { package = "monitor_types", path = "../types" }
mungos = "0.3.8"
mungos = "0.3.19"
anyhow = "1.0"
+18 -1
View File
@@ -1,6 +1,9 @@
use anyhow::Context;
use mungos::{Collection, Mungos};
use types::{Action, Build, Deployment, Group, Procedure, Server, SystemStatsRecord, Update, User};
use types::{
Action, Build, Deployment, Group, PeripheryCommand, Procedure, Server, SystemStatsRecord,
Update, User,
};
pub async fn users_collection(mungos: &Mungos, db_name: &str) -> anyhow::Result<Collection<User>> {
let coll = mungos.collection(db_name, "users");
@@ -133,3 +136,17 @@ pub async fn server_stats_collection(
.context("failed at creating ts index")?;
Ok(coll)
}
pub async fn commands_collection(
mungos: &Mungos,
db_name: &str,
) -> anyhow::Result<Collection<PeripheryCommand>> {
let coll = mungos.collection(db_name, "commands");
coll.create_unique_index("name")
.await
.context("failed at creating name index")?;
coll.create_index("server_id")
.await
.context("failed at creating server_id index")?;
Ok(coll)
}
+33 -5
View File
@@ -1,13 +1,13 @@
use anyhow::{anyhow, Context};
use collections::{
actions_collection, builds_collection, deployments_collection, groups_collection,
procedures_collection, server_stats_collection, servers_collection, updates_collection,
users_collection,
actions_collection, builds_collection, commands_collection, deployments_collection,
groups_collection, procedures_collection, server_stats_collection, servers_collection,
updates_collection, users_collection,
};
use mungos::{Collection, Mungos};
use types::{
Action, Build, Deployment, Group, MongoConfig, PermissionLevel, Procedure, Server,
SystemStatsRecord, Update, User,
Action, Build, Deployment, Group, MongoConfig, PeripheryCommand, PermissionLevel, Procedure,
Server, SystemStatsRecord, Update, User,
};
mod collections;
@@ -20,6 +20,7 @@ pub struct DbClient {
pub procedures: Collection<Procedure>,
pub actions: Collection<Action>,
pub groups: Collection<Group>,
pub commands: Collection<PeripheryCommand>,
pub updates: Collection<Update>,
pub stats: Collection<SystemStatsRecord>,
}
@@ -61,6 +62,9 @@ impl DbClient {
stats: server_stats_collection(&mungos, db_name)
.await
.expect("failed to make stats collection"),
commands: commands_collection(&mungos, db_name)
.await
.expect("failed to make commands collection"),
}
}
@@ -197,4 +201,28 @@ impl DbClient {
.unwrap_or_default();
Ok(permissions)
}
pub async fn get_command(&self, command_id: &str) -> anyhow::Result<PeripheryCommand> {
let command = self
.commands
.find_one_by_id(command_id)
.await
.context(format!("failed at mongo query for command {command_id}"))?
.ok_or(anyhow!("command at {command_id} doesn't exist"))?;
Ok(command)
}
pub async fn get_user_permission_on_command(
&self,
user_id: &str,
command_id: &str,
) -> anyhow::Result<PermissionLevel> {
let permissions = *self
.get_command(command_id)
.await?
.permissions
.get(user_id)
.unwrap_or_default();
Ok(permissions)
}
}
+1 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "monitor_helpers"
version = "0.3.0"
version = "0.3.4"
edition = "2021"
authors = ["MoghTech"]
description = "helpers used as dependency for mogh tech monitor"
@@ -12,7 +12,4 @@ license = "GPL-3.0-or-later"
types = { package = "monitor_types", path = "../types" }
anyhow = "1.0"
axum = "0.6"
serde = "1.0"
serde_json = "1.0"
toml = "0.7"
rand = "0.8"
+1 -18
View File
@@ -1,26 +1,9 @@
use std::{borrow::Borrow, net::SocketAddr, str::FromStr};
use std::{net::SocketAddr, str::FromStr};
use anyhow::anyhow;
use axum::http::StatusCode;
use rand::{distributions::Alphanumeric, Rng};
use types::Log;
pub fn parse_comma_seperated_list<T: FromStr>(
comma_sep_list: impl Borrow<str>,
) -> anyhow::Result<Vec<T>> {
comma_sep_list
.borrow()
.split(",")
.filter(|item| item.len() > 0)
.map(|item| {
let item = item
.parse()
.map_err(|_| anyhow!("error parsing string {item} into type T"))?;
Ok::<T, anyhow::Error>(item)
})
.collect()
}
pub fn get_socket_addr(port: u16) -> SocketAddr {
SocketAddr::from_str(&format!("0.0.0.0:{}", port)).expect("failed to parse socket addr")
}
+4 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "monitor_client"
version = "0.3.0"
version = "0.3.4"
edition = "2021"
authors = ["MoghTech"]
description = "a client to interact with the monitor system"
@@ -9,9 +9,10 @@ license = "GPL-3.0-or-later"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
monitor_types = "0.3.0"
monitor_types = "0.3.4"
# monitor_types = { path = "../types" }
reqwest = { version = "0.11", features = ["json"] }
tokio-tungstenite = { version = "0.18", features=["native-tls"] }
tokio-tungstenite = { version = "0.19", features=["native-tls"] }
tokio = { version = "1.25", features = ["full"] }
tokio-util = "0.7"
anyhow = "1.0"
+110
View File
@@ -0,0 +1,110 @@
# monitor client
## *interact with the monitor system programatically*
with this crate you can leverage all the functionality of monitor through rust code. for example, you can...
- create and manage complex deployments
- execute builds on specific schedules
- monitor server stats programatically
- program response actions to monitor updates sent over websocket
## initialize the client
you can initialize the client by directly passing args to the various initializers:
```rust
use monitor_client::MonitorClient;
let MONITOR_URL: &str = "https://monitor.mogh.tech";
let monitor = MonitorClient::new_with_token(MONITOR_URL, jwt_token).await?; // pass a valid jwt
let monitor = MonitorClient::new_with_password(MONITOR_URL, username, password).await?; // pass local user credentials
let monitor = MonitorClient::new_with_secret(MONITOR_URL, username, secret).await?; // pass api secret
```
or from the application environment / dotenv:
```sh
MONITOR_URL=https://monitor.mogh.tech # required
MONITOR_TOKEN=<jwt> # optional. pass the jwt directly.
MONITOR_USERNAME=<username> # required for password / secret login
MONITOR_PASSWORD=<password> # the users password
MONITOR_SECRET=<secret> # the api secret
```
to log in, you must pass either
1. MONITOR_TOKEN
2. MONITOR_USERNAME and MONITOR_PASSWORD
3. MONITOR_USERNAME and MONITOR_SECRET
you can then initialize the client using this method:
```rust
let monitor = MonitorClient::new_from_env().await?;
```
## use the client
the following will select a server, build monitor core on it, and deploy it.
```rust
let server = monitor
.list_servers(None)
.await?
.pop()
.ok_or(anyhow!("no servers"))?;
let build = BuildBuilder::default()
.name("monitor_core".into())
.server_id(server.server.id.clone().into())
.repo("mbecker20/monitor".to_string().into())
.branch("main".to_string().into())
.docker_build_args(
DockerBuildArgs {
build_path: ".".into(),
dockerfile_path: "core/Dockerfile".to_string().into(),
..Default::default()
}
.into(),
)
.pre_build(
Command {
path: "frontend".into(),
command: "yarn && yarn build".into(),
}
.into(),
)
.build()?;
let build = monitor.create_full_build(&build).await?;
println!("{build:#?}");
let build_update = monitor.build(&build.id).await?;
println!("{build_update:#?}");
let deployment = DeploymentBuilder::default()
.name("monitor_core_1".into())
.server_id(server.server.id.clone())
.build_id(build.id.clone().into())
.docker_run_args(
DockerRunArgsBuilder::default()
.volumes(vec![Conversion {
local: "/home/max/.monitor/core.config.toml".into(),
container: "/config/config.toml".into(),
}])
.build()?,
)
.build()?;
let deployment = monitor.create_full_deployment(&deployment).await?;
println!("{deployment:#?}");
let deploy_update = monitor.deploy_container(&deployment.id).await?;
println!("{deploy_update:#?}");
```
note. this crate re-exports the [monitor types](https://crates.io/crates/monitor_types) crate under the module "types"
-12
View File
@@ -1,12 +0,0 @@
# monitor client
## *interact with the monitor system programatically*
with this crate you can leverage all the functionality of monitor through rust code. for example, you can...
- create and manage complex deployment cycles
- execute builds on specific schedules
- monitor server stats programatically
- program response actions to monitor updates sent over websocket
this crate re-exports the [monitor types](https://crates.io/crates/monitor_types) crate under the module "types"
+2 -2
View File
@@ -1,13 +1,13 @@
[package]
name = "periphery_client"
version = "0.3.0"
version = "0.3.4"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
types = { package = "monitor_types", path = "../types" }
tokio-tungstenite = { version = "0.18", features=["native-tls"] }
tokio-tungstenite = { version = "0.19", features=["native-tls"] }
tokio = "1.25"
reqwest = { version = "0.11", features = ["json"] }
serde = "1.0"
+1 -1
View File
@@ -6,7 +6,7 @@ use crate::PeripheryClient;
impl PeripheryClient {
pub async fn build(&self, server: &Server, build: &Build) -> anyhow::Result<Option<Vec<Log>>> {
let res = self
.post_json::<_, Vec<Log>>(server, "/build", build)
.post_json(server, "/build", build, ())
.await
.context("failed to build image on periphery");
match res {
+1 -1
View File
@@ -5,7 +5,7 @@ use crate::PeripheryClient;
impl PeripheryClient {
pub async fn run_command(&self, server: &Server, command: &Command) -> anyhow::Result<Log> {
self.post_json(server, &format!("/command"), command)
self.post_json(server, &format!("/command"), command, ())
.await
.context("failed to run command on periphery")
}
+25 -6
View File
@@ -1,6 +1,6 @@
use anyhow::Context;
use serde_json::json;
use types::{BasicContainerInfo, Deployment, DockerContainerStats, Log, Server};
use types::{BasicContainerInfo, Deployment, DockerContainerStats, Log, Server, TerminationSignal};
use crate::PeripheryClient;
@@ -37,6 +37,7 @@ impl PeripheryClient {
server,
"/container/start",
&json!({ "name": container_name }),
(),
)
.await
.context("failed to start container on periphery")
@@ -46,11 +47,14 @@ impl PeripheryClient {
&self,
server: &Server,
container_name: &str,
stop_signal: Option<TerminationSignal>,
stop_time: Option<i32>,
) -> anyhow::Result<Log> {
self.post_json(
server,
"/container/stop",
&json!({ "name": container_name }),
(("stop_signal", stop_signal), ("stop_time", stop_time)),
)
.await
.context("failed to stop container on periphery")
@@ -60,11 +64,14 @@ impl PeripheryClient {
&self,
server: &Server,
container_name: &str,
stop_signal: Option<TerminationSignal>,
stop_time: Option<i32>,
) -> anyhow::Result<Log> {
self.post_json(
server,
"/container/remove",
&json!({ "name": container_name }),
(("stop_signal", stop_signal), ("stop_time", stop_time)),
)
.await
.context("failed to remove container on periphery")
@@ -80,19 +87,31 @@ impl PeripheryClient {
server,
"/container/rename",
&json!({ "curr_name": curr_name, "new_name": new_name }),
(),
)
.await
.context("failed to rename container on periphery")
}
pub async fn deploy(&self, server: &Server, deployment: &Deployment) -> anyhow::Result<Log> {
self.post_json(server, "/container/deploy", deployment)
.await
.context("failed to deploy container on periphery")
pub async fn deploy(
&self,
server: &Server,
deployment: &Deployment,
stop_signal: Option<TerminationSignal>,
stop_time: Option<i32>,
) -> anyhow::Result<Log> {
self.post_json(
server,
"/container/deploy",
deployment,
(("stop_signal", stop_signal), ("stop_time", stop_time)),
)
.await
.context("failed to deploy container on periphery")
}
pub async fn container_prune(&self, server: &Server) -> anyhow::Result<Log> {
self.post_json(server, "/container/prune", &json!({}))
self.post_json(server, "/container/prune", &json!({}), ())
.await
.context("failed to prune containers on periphery")
}
+3 -2
View File
@@ -11,7 +11,7 @@ impl PeripheryClient {
clone_args: impl Into<CloneArgs>,
) -> anyhow::Result<Vec<Log>> {
let clone_args: CloneArgs = clone_args.into();
self.post_json(server, "/git/clone", &clone_args)
self.post_json(server, "/git/clone", &clone_args, ())
.await
.context("failed to clone repo on periphery")
}
@@ -27,13 +27,14 @@ impl PeripheryClient {
server,
"/git/pull",
&json!({ "name": name, "branch": branch, "on_pull": on_pull }),
(),
)
.await
.context("failed to pull repo on periphery")
}
pub async fn delete_repo(&self, server: &Server, build_name: &str) -> anyhow::Result<Log> {
self.post_json(server, "/git/delete", &json!({ "name": build_name }))
self.post_json(server, "/git/delete", &json!({ "name": build_name }), ())
.await
.context("failed to delete repo on periphery")
}
+1 -1
View File
@@ -11,7 +11,7 @@ impl PeripheryClient {
}
pub async fn image_prune(&self, server: &Server) -> anyhow::Result<Log> {
self.post_json(server, &format!("/image/prune"), &())
self.post_json(server, &format!("/image/prune"), &(), ())
.await
.context("failed to prune images on periphery")
}
+3 -1
View File
@@ -170,17 +170,19 @@ impl PeripheryClient {
}
}
async fn post_json<B: Serialize, R: DeserializeOwned>(
async fn post_json<B: Serialize, R: DeserializeOwned, Q: Serialize>(
&self,
server: &Server,
endpoint: &str,
body: &B,
query: impl Into<Option<Q>>,
) -> anyhow::Result<R> {
self.health_check(server).await?;
let res = self
.http_client
.post(format!("{}{endpoint}", server.address))
.header("authorization", &self.passkey)
.query(&query.into())
.json(body)
.send()
.await
+3 -2
View File
@@ -24,19 +24,20 @@ impl PeripheryClient {
"name": name,
"driver": driver
}),
(),
)
.await
.context("failed to create network on periphery")
}
pub async fn network_delete(&self, server: &Server, name: &str) -> anyhow::Result<Log> {
self.post_json(server, "/network/delete", &json!({ "name": name }))
self.post_json(server, "/network/delete", &json!({ "name": name }), ())
.await
.context("failed to delete network on periphery")
}
pub async fn network_prune(&self, server: &Server) -> anyhow::Result<Log> {
self.post_json(server, "/network/prune", &json!({}))
self.post_json(server, "/network/prune", &json!({}), ())
.await
.context("failed to prune networks on periphery")
}
+3 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "monitor_types"
version = "0.3.0"
version = "0.3.4"
edition = "2021"
authors = ["MoghTech"]
description = "types for the mogh tech monitor"
@@ -10,13 +10,12 @@ license = "GPL-3.0-or-later"
[dependencies]
serde = "1.0"
serde_derive = "1.0"
bson = "2.4"
bson = "2.6"
strum = "0.24"
strum_macros = "0.24"
diff-struct = "0.5"
bollard = "0.14.0"
derive_builder = "0.12"
typeshare = "1.0.0"
typeshare = "1.0.1"
chrono = "0.4"
anyhow = "1.0"
+81 -5
View File
@@ -46,10 +46,30 @@ pub struct Deployment {
#[diff(attr(#[serde(skip_serializing_if = "docker_run_args_diff_no_change")]))]
pub docker_run_args: DockerRunArgs,
#[serde(default = "default_term_signal_labels")]
#[builder(default = "vec![TerminationSignalLabel::default()]")]
#[diff(attr(#[serde(skip_serializing_if = "vec_diff_no_change")]))]
pub term_signal_labels: Vec<TerminationSignalLabel>,
#[serde(default)]
#[builder(default)]
#[diff(attr(#[serde(skip_serializing_if = "termination_signal_diff_no_change")]))]
pub termination_signal: TerminationSignal,
#[serde(default = "default_termination_timeout")]
#[builder(default = "10")]
#[diff(attr(#[serde(skip_serializing_if = "i32_diff_no_change")]))]
pub termination_timeout: i32,
#[builder(default)]
#[diff(attr(#[serde(skip_serializing_if = "option_diff_no_change")]))]
pub build_id: Option<String>,
#[serde(default)]
#[builder(default)]
#[diff(attr(#[serde(skip_serializing_if = "Option::is_none")]))]
pub redeploy_on_build: bool,
#[builder(default)]
#[diff(attr(#[serde(skip_serializing_if = "option_diff_no_change")]))]
pub build_version: Option<Version>,
@@ -89,6 +109,14 @@ pub struct Deployment {
pub updated_at: String,
}
fn default_termination_timeout() -> i32 {
10
}
fn default_term_signal_labels() -> Vec<TerminationSignalLabel> {
vec![TerminationSignalLabel::default()]
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct DeploymentWithContainerState {
@@ -110,6 +138,18 @@ pub struct DeploymentActionState {
pub renaming: bool,
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Diff, Builder)]
#[diff(attr(#[derive(Debug, PartialEq, Serialize)]))]
pub struct TerminationSignalLabel {
#[builder(default)]
#[diff(attr(#[serde(skip_serializing_if = "termination_signal_diff_no_change")]))]
pub signal: TerminationSignal,
#[builder(default)]
#[diff(attr(#[serde(skip_serializing_if = "Option::is_none")]))]
pub label: String,
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Diff, Builder)]
#[diff(attr(#[derive(Debug, PartialEq, Serialize)]))]
@@ -243,10 +283,22 @@ impl Default for DockerContainerState {
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Display, EnumString, PartialEq, Hash, Eq, Clone, Copy, Diff,
Serialize,
Deserialize,
Debug,
Display,
EnumString,
PartialEq,
Hash,
Eq,
Clone,
Copy,
Diff,
Default,
)]
#[diff(attr(#[derive(Debug, PartialEq, Serialize)]))]
pub enum RestartMode {
#[default]
#[serde(rename = "no")]
#[strum(serialize = "no")]
NoRestart,
@@ -261,8 +313,32 @@ pub enum RestartMode {
UnlessStopped,
}
impl Default for RestartMode {
fn default() -> RestartMode {
RestartMode::NoRestart
}
#[typeshare]
#[derive(
Serialize,
Deserialize,
Debug,
Display,
EnumString,
PartialEq,
Hash,
Eq,
Clone,
Copy,
Diff,
Default,
)]
#[serde(rename_all = "UPPERCASE")]
#[strum(serialize_all = "UPPERCASE")]
#[diff(attr(#[derive(Debug, PartialEq, Serialize)]))]
pub enum TerminationSignal {
#[serde(alias = "1")]
SigHup,
#[serde(alias = "2")]
SigInt,
#[serde(alias = "3")]
SigQuit,
#[default]
#[serde(alias = "15")]
SigTerm,
}
+15 -3
View File
@@ -1,8 +1,8 @@
use diff::{Diff, OptionDiff, VecDiff};
use crate::{
deployment::{DockerRunArgsDiff, RestartModeDiff},
TimelengthDiff,
deployment::{DockerRunArgsDiff, RestartModeDiff, TerminationSignalDiff},
CommandDiff, TimelengthDiff,
};
pub fn f64_diff_no_change(f64_diff: &f64) -> bool {
@@ -13,6 +13,10 @@ pub fn f32_diff_no_change(f32_diff: &f32) -> bool {
*f32_diff == 0.0
}
pub fn i32_diff_no_change(i32_diff: &i32) -> bool {
*i32_diff == 0
}
pub fn option_diff_no_change<T: Diff>(option_diff: &OptionDiff<T>) -> bool
where
<T as Diff>::Repr: PartialEq,
@@ -24,7 +28,7 @@ pub fn vec_diff_no_change<T: Diff>(vec_diff: &VecDiff<T>) -> bool {
vec_diff.0.is_empty()
}
// pub fn hashmap_diff_no_change<T: Diff>(hashmap_diff: &HashMapDiff<String, T>) -> bool {
// pub fn hashmap_diff_no_change<K: Hash + Eq, T: Diff>(hashmap_diff: &HashMapDiff<K, T>) -> bool {
// hashmap_diff.altered.is_empty() && hashmap_diff.removed.is_empty()
// }
@@ -48,3 +52,11 @@ pub fn restart_mode_diff_no_change(restart_mode: &RestartModeDiff) -> bool {
pub fn timelength_diff_no_change(timelength: &TimelengthDiff) -> bool {
timelength == &TimelengthDiff::NoChange
}
pub fn termination_signal_diff_no_change(term_signal: &TerminationSignalDiff) -> bool {
term_signal == &TerminationSignalDiff::NoChange
}
pub fn command_diff_no_change(command: &CommandDiff) -> bool {
command.command.is_none() && command.path.is_none()
}
+32 -10
View File
@@ -1,8 +1,8 @@
use std::collections::HashMap;
use ::diff::Diff;
use anyhow::Context;
use chrono::{DateTime, SecondsFormat, Utc};
use anyhow::{anyhow, Context};
use chrono::{DateTime, LocalResult, SecondsFormat, TimeZone, Utc};
use serde::{Deserialize, Serialize};
use strum_macros::{Display, EnumString};
use typeshare::typeshare;
@@ -17,6 +17,7 @@ mod config;
mod deployment;
mod diff;
mod group;
mod periphery_command;
mod procedure;
mod server;
mod update;
@@ -27,6 +28,7 @@ pub use build::*;
pub use config::*;
pub use deployment::*;
pub use group::*;
pub use periphery_command::*;
pub use procedure::*;
pub use server::*;
pub use update::*;
@@ -49,7 +51,7 @@ pub struct CloneArgs {
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Diff)]
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, Diff)]
#[diff(attr(#[derive(Debug, PartialEq, Serialize)]))]
pub struct Command {
#[serde(default)]
@@ -125,6 +127,12 @@ pub enum Operation {
UpdateProcedure,
DeleteProcedure,
// command
CreateCommand,
UpdateCommand,
DeleteCommand,
RunCommand,
// group
CreateGroup,
UpdateGroup,
@@ -199,7 +207,18 @@ pub enum PermissionsTarget {
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Display, EnumString, PartialEq, Hash, Eq, Clone, Copy, Diff,
Serialize,
Deserialize,
Debug,
Display,
EnumString,
PartialEq,
Hash,
Eq,
Clone,
Copy,
Diff,
Default,
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
@@ -220,6 +239,7 @@ pub enum Timelength {
#[serde(rename = "30-sec")]
#[strum(serialize = "30-sec")]
ThirtySeconds,
#[default]
#[serde(rename = "1-min")]
#[strum(serialize = "1-min")]
OneMinute,
@@ -270,12 +290,6 @@ pub enum Timelength {
ThirtyDays,
}
impl Default for Timelength {
fn default() -> Timelength {
Timelength::OneMinute
}
}
pub fn monitor_timestamp() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Millis, false)
}
@@ -285,3 +299,11 @@ pub fn unix_from_monitor_ts(ts: &str) -> anyhow::Result<i64> {
.context("failed to parse rfc3339 timestamp")?
.timestamp_millis())
}
pub fn monitor_ts_from_unix(ts: i64) -> anyhow::Result<String> {
match Utc.timestamp_millis_opt(ts) {
LocalResult::Single(dt) => Ok(dt.to_rfc3339_opts(SecondsFormat::Millis, false)),
LocalResult::None => Err(anyhow!("out of bounds timestamp passed")),
_ => unreachable!(),
}
}
+58
View File
@@ -0,0 +1,58 @@
use bson::serde_helpers::hex_string_as_object_id;
use derive_builder::Builder;
use diff::Diff;
use serde::{Deserialize, Serialize};
use typeshare::typeshare;
use crate::{diff::command_diff_no_change, Command, PermissionsMap};
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Default, Diff, Builder)]
#[diff(attr(#[derive(Debug, Serialize)]))]
pub struct PeripheryCommand {
#[serde(
default,
rename = "_id",
skip_serializing_if = "String::is_empty",
with = "hex_string_as_object_id"
)]
#[diff(attr(#[serde(skip_serializing_if = "Option::is_none")]))]
#[builder(setter(skip))]
pub id: String,
#[diff(attr(#[serde(skip_serializing_if = "Option::is_none")]))]
pub name: String, // must be formatted to be compat with docker
#[serde(default)]
#[builder(default)]
#[diff(attr(#[serde(skip_serializing_if = "Option::is_none")]))]
pub description: String,
#[diff(attr(#[serde(skip_serializing_if = "Option::is_none")]))]
pub server_id: String,
#[serde(default)]
#[diff(attr(#[serde(skip_serializing)]))]
#[builder(setter(skip))]
pub permissions: PermissionsMap,
#[serde(default)]
#[builder(default)]
#[diff(attr(#[serde(skip_serializing_if = "command_diff_no_change")]))]
pub command: Command,
#[serde(default, skip_serializing_if = "String::is_empty")]
#[diff(attr(#[serde(skip)]))]
#[builder(setter(skip))]
pub created_at: String,
#[serde(default)]
#[diff(attr(#[serde(skip)]))]
#[builder(setter(skip))]
pub updated_at: String,
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct CommandActionState {
pub running: bool,
}
+14 -2
View File
@@ -1,6 +1,6 @@
use crate::{
Build, BuildActionState, CloneArgs, Deployment, DeploymentActionState, Group, PermissionLevel,
PermissionsMap, Procedure, Server, ServerActionState,
Build, BuildActionState, CloneArgs, CommandActionState, Deployment, DeploymentActionState,
Group, PeripheryCommand, PermissionLevel, PermissionsMap, Procedure, Server, ServerActionState,
};
pub trait Permissioned {
@@ -41,6 +41,12 @@ impl Permissioned for Group {
}
}
impl Permissioned for PeripheryCommand {
fn permissions_map(&self) -> &PermissionsMap {
&self.permissions
}
}
pub trait Busy {
fn busy(&self) -> bool;
}
@@ -70,6 +76,12 @@ impl Busy for BuildActionState {
}
}
impl Busy for CommandActionState {
fn busy(&self) -> bool {
self.running
}
}
impl From<&Deployment> for CloneArgs {
fn from(d: &Deployment) -> Self {
CloneArgs {
+1
View File
@@ -75,6 +75,7 @@ pub enum UpdateTarget {
Server(String),
Procedure(String),
Group(String),
Command(String),
}
impl Default for UpdateTarget {

Some files were not shown because too many files have changed in this diff Show More