Compare commits

..

45 Commits

Author SHA1 Message Date
mbecker20
addb35aa69 give placeholders 2024-05-11 23:38:48 -07:00
mbecker20
16bf78f9ad enable commit config 2024-05-11 23:11:41 -07:00
mbecker20
3ed4f91d82 periphery cli args name to periphery 2024-05-11 23:06:04 -07:00
mbecker20
653fb894a2 delete file 2024-05-11 22:44:13 -07:00
mbecker20
0f9798a5f2 1.2.0 2024-05-11 22:34:35 -07:00
mbecker20
6776a20ec5 build repo state cache 2024-05-11 22:30:33 -07:00
mbecker20
fb21e8586f clone commit hash 2024-05-11 21:38:29 -07:00
mbecker20
8b2c4d604a periphery get repo status 2024-05-11 18:42:21 -07:00
mbecker20
c0b010d5ce rename DockerContainerState -> DeploymentState 2024-05-11 18:29:40 -07:00
mbecker20
97de34a088 align state / status distinction 2024-05-11 18:22:24 -07:00
mbecker20
6c0b76a270 add big icons 2024-05-11 18:04:30 -07:00
mbecker20
eebd44ab9b common resource filter 2024-05-11 17:42:38 -07:00
mbecker20
783250c5ce sort build / repo status update query by most recent 2024-05-11 16:12:11 -07:00
mbecker20
70ff93050f choose between config / log for deployment 2024-05-11 16:06:36 -07:00
mbecker20
1cc1813185 fix dev fe build 2024-05-11 14:50:05 -07:00
mbecker20
b4f9b87d06 update resources, cli 2024-05-11 14:48:26 -07:00
mbecker20
26b09a767e unlink 2024-05-11 13:44:51 -07:00
mbecker20
bba6c4d8b6 add status to build / repo 2024-05-10 22:59:20 -07:00
mbecker20
ea440235c4 configure enable / disable action on webhook recieve 2024-05-10 21:54:41 -07:00
mbecker20
f9949bf988 readOnly webhhok copy 2024-05-10 20:40:54 -07:00
mbecker20
b978db012e slight style 2024-05-10 20:37:56 -07:00
mbecker20
bc2fbdd657 remove rws and code faster reconnect 2024-05-10 18:04:35 -07:00
mbecker20
a5571bcf4d 2xl show 8 recents 2024-05-10 17:30:20 -07:00
mbecker20
683a528dd9 mx-8 2024-05-10 17:23:28 -07:00
mbecker20
4a283b6052 remove container on topbar 2024-05-10 17:21:11 -07:00
mbecker20
37224ee1ad dont req for username of built in users 2024-05-10 17:14:55 -07:00
mbecker20
5e7445b10d add a god damn sidebar 2024-05-10 17:07:51 -07:00
mbecker20
1829a7da34 update diff looking good 2024-05-10 03:59:51 -07:00
mbecker20
4a1a653bd9 rust 1.78.0 2024-05-10 03:40:30 -07:00
mbecker20
840c1a87d0 try putting the html in th diff directly 2024-05-10 03:19:18 -07:00
mbecker20
c90368e2af add periphery build repo 2024-05-10 02:29:47 -07:00
mbecker20
1f9d74fadb fix default creds path 2024-05-10 02:28:06 -07:00
mbecker20
5b261058fe add updates to dashboard 2024-05-10 02:04:23 -07:00
mbecker20
cf6632ba02 partial_derive2 0.4.2 better diffs 2024-05-10 01:50:08 -07:00
mbecker20
c7124bd63c colored diff 2024-05-10 01:03:23 -07:00
mbecker20
ba19e45607 imporve config styling 2024-05-10 00:50:29 -07:00
mbecker20
20282ffcbb update frontend deps 2024-05-10 00:10:40 -07:00
mbecker20
cb8ad90838 cli readme and default creds path 2024-05-09 22:29:40 -07:00
mbecker20
caac3fdcc4 v{version} 2024-05-09 15:51:54 -07:00
mbecker20
44da282060 improve server version 2024-05-09 15:47:58 -07:00
mbecker20
a2e27b09fc show version 2024-05-09 15:39:30 -07:00
mbecker20
c1b1f397fd comments 2024-05-09 15:36:15 -07:00
mbecker20
1d0f239594 delete binary before recurl 2024-05-09 15:34:03 -07:00
mbecker20
549bc78799 log version 2024-05-09 15:28:32 -07:00
mbecker20
9eb9b57e36 install load latest version automatically if its not passed 2024-05-09 15:25:57 -07:00
104 changed files with 2351 additions and 1090 deletions

43
Cargo.lock generated
View File

@@ -32,7 +32,7 @@ dependencies = [
[[package]]
name = "alert_logger"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"axum 0.7.5",
@@ -115,17 +115,6 @@ version = "1.0.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25bdb32cbbdce2b519a9cd7df3a678443100e265d5e25ca763b7572a5104f5f3"
[[package]]
name = "async-recursion"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.53",
]
[[package]]
name = "async-stream"
version = "0.3.5"
@@ -2017,7 +2006,7 @@ checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
[[package]]
name = "logger"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"monitor_client",
@@ -2094,7 +2083,7 @@ dependencies = [
[[package]]
name = "migrator"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"chrono",
@@ -2222,7 +2211,7 @@ dependencies = [
[[package]]
name = "monitor_cli"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"clap",
@@ -2231,6 +2220,7 @@ dependencies = [
"monitor_client",
"partial_derive2",
"serde",
"serde_json",
"strum 0.26.2",
"tokio",
"toml",
@@ -2240,7 +2230,7 @@ dependencies = [
[[package]]
name = "monitor_client"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2272,10 +2262,9 @@ dependencies = [
[[package]]
name = "monitor_core"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"async-recursion",
"async-trait",
"async_timing_util",
"aws-config",
@@ -2319,7 +2308,7 @@ dependencies = [
[[package]]
name = "monitor_periphery"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"async-trait",
@@ -2350,7 +2339,7 @@ dependencies = [
[[package]]
name = "monitor_sync"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"clap",
@@ -2626,18 +2615,18 @@ checksum = "ffa94c2e5674923c67d7f3dfce1279507b191e10eb064881b46ed3e1256e5ca6"
[[package]]
name = "partial_derive2"
version = "0.4.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57fd88f09814d56f73ea68256ebdd2cdd75a4a3b48d41c63bbc27c46c6733588"
checksum = "8a7b915bd76bc306b7ae6f1b5d99b0434e498ab979ecbd5df119db8a00dab972"
dependencies = [
"partial_derive2_derive",
]
[[package]]
name = "partial_derive2_derive"
version = "0.4.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdc7cb36e2cd4e6e7a53e3d35fd3321e1bd83382e0207901bff7ffb779cf7619"
checksum = "ddaac49c0e65bcb207999d2514b10b43d5f2ec2d0fb47b9d875c9a10536a294e"
dependencies = [
"proc-macro2",
"quote",
@@ -2661,7 +2650,7 @@ checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
[[package]]
name = "periphery_client"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"monitor_client",
@@ -3660,7 +3649,7 @@ dependencies = [
[[package]]
name = "tests"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"dotenv",
@@ -4216,7 +4205,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "update_logger"
version = "1.1.0"
version = "1.2.0"
dependencies = [
"anyhow",
"logger",

View File

@@ -3,7 +3,7 @@ resolver = "2"
members = ["bin/*", "lib/*", "client/core/rs", "client/periphery/rs"]
[workspace.package]
version = "1.1.0"
version = "1.2.0"
edition = "2021"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
@@ -23,7 +23,7 @@ derive_empty_traits = "0.1.0"
merge_config_files = "0.1.5"
termination_signal = "0.1.3"
async_timing_util = "0.1.14"
partial_derive2 = "0.4.1"
partial_derive2 = "0.4.2"
derive_variants = "0.1.3"
mongo_indexed = "0.2.2"
resolver_api = "0.1.9"
@@ -38,7 +38,6 @@ tokio-util = "0.7.11"
futures = "0.3.30"
futures-util = "0.3.30"
async-trait = "0.1.80"
async-recursion = "1.1.1"
# SERVER
axum = { version = "0.7.5", features = ["ws", "json"] }
@@ -90,5 +89,5 @@ aws-sdk-ec2 = "1.40.0"
# MISC
derive_builder = "0.20.0"
colored = "2.1.0"
typeshare = "1.0.3"
colored = "2.1.0"

View File

@@ -18,6 +18,10 @@ monitor_client.workspace = true
# mogh
partial_derive2.workspace = true
# external
tracing-subscriber.workspace = true
serde_json.workspace = true
futures.workspace = true
tracing.workspace = true
colored.workspace = true
anyhow.workspace = true
tokio.workspace = true
@@ -25,6 +29,3 @@ serde.workspace = true
strum.workspace = true
toml.workspace = true
clap.workspace = true
futures.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

View File

@@ -1,8 +1,19 @@
# monitor CLI
# Monitor CLI
Monitor CLI is a command line tool to sync monitor resources and execute file defined procedures.
## Examples
## Usage
Configure a file `~/.config/monitor/creds.toml` file with contents:
```toml
url = "https://your.monitor.address"
key = "YOUR-API-KEY"
secret = "YOUR-API-SECRET"
```
Note. You can specify a different creds file by using `--creds ./other/path.toml`.
With your creds in place, you can run syncs:
```sh
## Sync resources in a single file
@@ -15,6 +26,8 @@ monitor sync ./resources
monitor sync
```
And executions:
```sh
## Execute a TOML defined procedure
monitor exec ./execution/execution.toml

View File

@@ -24,6 +24,40 @@ docker_account = "mbecker2020"
build_path = "."
dockerfile_path = "bin/core/Dockerfile"
[[build]]
name = "monitor_frontend"
description = "standalone hosted frontend for monitor.mogh.tech"
tags = ["monitor", "frontend"]
[build.config]
builder_id = "mogh-builder"
repo = "mbecker20/monitor"
branch = "main"
docker_account = "mbecker2020"
build_path = "."
dockerfile_path = "frontend/Dockerfile"
[[build.config.build_args]]
variable = "VITE_MONITOR_HOST"
value = "https://monitor.api.mogh.tech"
[[build]]
name = "monitor_frontend_dev"
description = "standalone hosted frontend for monitor-dev.mogh.tech"
tags = ["monitor", "frontend"]
[build.config]
builder_id = "mogh-builder"
repo = "mbecker20/monitor"
branch = "main"
docker_account = "mbecker2020"
build_path = "."
dockerfile_path = "frontend/Dockerfile"
[[build.config.build_args]]
variable = "VITE_MONITOR_HOST"
value = "https://monitor-dev.api.mogh.tech"
## BUILDER
[[builder]]

View File

@@ -173,9 +173,41 @@ local = "/home/ubuntu/.config/monitor/dev.core.config.toml"
container = "/config/config.toml"
[[deployment.config.volumes]]
local = "/etc/monitor/repos/monitor-dev-frontend/frontend/dist"
local = "/data/repos/monitor-dev-frontend/frontend/dist"
container = "/frontend"
[[deployment.config.labels]]
variable = "vector"
value = "rust"
value = "rust"
## MONITOR FRONTEND
[[deployment]]
name = "monitor-frontend"
description = ""
tags = ["monitor", "frontend"]
[deployment.config]
server_id = "monitor-01"
redeploy_on_build = true
network = "host"
restart = "unless-stopped"
image.type = "Build"
image.params.build = "monitor_frontend"
## MONITOR DEV FRONTEND
[[deployment]]
name = "monitor-dev-frontend"
description = ""
tags = ["monitor", "dev", "frontend"]
[deployment.config]
server_id = "monitor-01"
redeploy_on_build = true
network = "host"
restart = "unless-stopped"
image.type = "Build"
image.params.build = "monitor_frontend_dev"
[[deployment.config.environment]]
variable = "PORT"
value = "4175"

View File

@@ -1,7 +1,26 @@
# [[repo]]
# name = "monitor-dev-frontend"
# description = "Used as frontend for monitor-core-dev"
# tags = ["monitor", "dev"]
# [repo.config]
# server_id = "monitor-01"
# repo = "mbecker20/monitor"
# branch = "main"
# github_account = ""
# [repo.config.on_clone]
# path = ""
# command = ""
# [repo.config.on_pull]
# path = "frontend"
# command = "sh on_pull.sh"
[[repo]]
name = "monitor-dev-frontend"
description = "Used as frontend for monitor-core-dev"
tags = ["monitor", "dev"]
name = "monitor-periphery"
description = ""
tags = ["monitor"]
[repo.config]
server_id = "monitor-01"
@@ -14,5 +33,5 @@ path = ""
command = ""
[repo.config.on_pull]
path = "frontend"
command = "sh on_pull.sh"
path = "."
command = "/root/.cargo/bin/cargo build -p monitor_periphery --release"

View File

@@ -25,13 +25,19 @@ struct CliArgs {
#[command(subcommand)]
command: Command,
/// The path to a creds file.
#[arg(long, default_value_t = String::from("./creds.toml"))]
#[arg(long, default_value_t = default_creds())]
creds: String,
/// Log less (just resource names).
#[arg(long, default_value_t = false)]
quiet: bool,
}
fn default_creds() -> String {
let home = std::env::var("HOME")
.expect("no HOME env var. cannot get default config path.");
format!("{home}/.config/monitor/creds.toml")
}
#[derive(Debug, Clone, Subcommand)]
enum Command {
/// Runs syncs on resource files
@@ -94,7 +100,11 @@ fn parse_toml_file<T: DeserializeOwned>(
}
fn wait_for_enter(press_enter_to: &str) -> anyhow::Result<()> {
println!("\nPress {} to {}\n", "ENTER".green(), press_enter_to.bold());
println!(
"\nPress {} to {}\n",
"ENTER".green(),
press_enter_to.bold()
);
let buffer = &mut [0u8];
std::io::stdin()
.read_exact(buffer)

View File

@@ -10,6 +10,7 @@ use monitor_client::{
},
};
use partial_derive2::{Diff, FieldDiff, MaybeNone, PartialDiff};
use serde::Serialize;
use crate::{cli_args, maps::id_to_tag, monitor_client};
@@ -43,6 +44,7 @@ pub trait ResourceSync {
+ Clone
+ Send
+ From<Self::ConfigDiff>
+ Serialize
+ 'static;
type ConfigDiff: Diff + MaybeNone;
type ListItemInfo: 'static;
@@ -171,10 +173,16 @@ pub trait ResourceSync {
None => {
if !quiet {
println!(
"{}: {}: {}: {resource:#?}",
"\n{}: {}: {}\n{}: {}\n{}: {:?}\n{}: {}",
"CREATE".green(),
Self::display(),
resource.name.bold().green(),
"description".dimmed(),
resource.description,
"tags".dimmed(),
resource.tags,
"config".dimmed(),
serde_json::to_string_pretty(&resource.config)?
)
}
to_create.push(resource);

View File

@@ -28,31 +28,30 @@ mungos.workspace = true
serror.workspace = true
slack.workspace = true
# external
tokio.workspace = true
urlencoding.workspace = true
async-trait.workspace = true
aws-sdk-ec2.workspace = true
aws-config.workspace = true
tokio-util.workspace = true
axum.workspace = true
axum-extra.workspace = true
tower.workspace = true
tower-http.workspace = true
serde.workspace = true
serde_json.workspace = true
typeshare.workspace = true
tracing.workspace = true
reqwest.workspace = true
futures.workspace = true
anyhow.workspace = true
dotenv.workspace = true
bcrypt.workspace = true
tokio.workspace = true
tower.workspace = true
serde.workspace = true
axum.workspace = true
toml.workspace = true
uuid.workspace = true
anyhow.workspace = true
tracing.workspace = true
dotenv.workspace = true
envy.workspace = true
reqwest.workspace = true
urlencoding.workspace = true
rand.workspace = true
jwt.workspace = true
hmac.workspace = true
sha2.workspace = true
bcrypt.workspace = true
jwt.workspace = true
hex.workspace = true
async-trait.workspace = true
async-recursion.workspace = true
futures.workspace = true
aws-config.workspace = true
aws-sdk-ec2.workspace = true
typeshare.workspace = true

View File

@@ -1,5 +1,5 @@
# Build Core
FROM rust:1.77.2-bullseye as core-builder
FROM rust:1.78.0-bullseye as core-builder
WORKDIR /builder
COPY . .
RUN cargo build -p monitor_core --release

View File

@@ -1,4 +1,4 @@
use std::{str::FromStr, time::Duration};
use std::time::Duration;
use anyhow::{anyhow, Context};
use async_trait::async_trait;
@@ -11,7 +11,7 @@ use monitor_client::{
all_logs_success,
build::Build,
builder::{AwsBuilderConfig, Builder, BuilderConfig},
deployment::DockerContainerState,
deployment::DeploymentState,
monitor_timestamp,
permission::PermissionLevel,
server::Server,
@@ -22,8 +22,9 @@ use monitor_client::{
},
};
use mungos::{
by_id::update_one_by_id,
find::find_collect,
mongodb::bson::{doc, oid::ObjectId, to_bson},
mongodb::bson::{doc, to_bson, to_document},
};
use periphery_client::{
api::{self, GetVersionResponse},
@@ -48,7 +49,7 @@ use crate::{
query::get_deployment_state,
update::{add_update, make_update, update_update},
},
resource,
resource::{self, refresh_build_state_cache},
state::{action_states, db_client, State},
};
@@ -135,9 +136,7 @@ impl Resolve<RunBuild, User> for State {
"get builder",
serialize_error_pretty(&e),
));
update.finalize();
update_update(update.clone()).await?;
return Ok(update);
return handle_early_return(update).await;
}
};
@@ -162,9 +161,7 @@ impl Resolve<RunBuild, User> for State {
cleanup_builder_instance(periphery, cleanup_data, &mut update)
.await;
info!("builder cleaned up");
update.finalize();
update_update(update.clone()).await?;
return Ok(update)
return handle_early_return(update).await
},
};
@@ -198,9 +195,7 @@ impl Resolve<RunBuild, User> for State {
update.push_error_log("build cancelled", String::from("user cancelled build during docker build"));
cleanup_builder_instance(periphery, cleanup_data, &mut update)
.await;
update.finalize();
update_update(update.clone()).await?;
return Ok(update)
return handle_early_return(update).await
},
};
@@ -218,12 +213,13 @@ impl Resolve<RunBuild, User> for State {
update.finalize();
let db = db_client().await;
if update.success {
let _ = db_client()
.await
let _ = db
.builds
.update_one(
doc! { "_id": ObjectId::from_str(&build.id)? },
doc! { "name": &build.name },
doc! {
"$set": {
"config.version": to_bson(&build.config.version)
@@ -241,19 +237,57 @@ impl Resolve<RunBuild, User> for State {
cleanup_builder_instance(periphery, cleanup_data, &mut update)
.await;
info!("builder instance cleaned up");
// Need to manually update the update before cache refresh,
// and before broadcast with add_update.
// The Err case of to_document should be unreachable,
// but will fail to update cache in that case.
if let Ok(update_doc) = to_document(&update) {
let _ = update_one_by_id(
&db.updates,
&update.id,
mungos::update::Update::Set(update_doc),
None,
)
.await;
refresh_build_state_cache().await;
}
update_update(update.clone()).await?;
if update.success {
handle_post_build_redeploy(&build.id).await;
info!("post build redeploy handled");
// don't hold response up for user
tokio::spawn(async move {
handle_post_build_redeploy(&build.id).await;
info!("post build redeploy handled");
});
}
Ok(update)
}
}
async fn handle_early_return(
mut update: Update,
) -> anyhow::Result<Update> {
update.finalize();
// Need to manually update the update before cache refresh,
// and before broadcast with add_update.
// The Err case of to_document should be unreachable,
// but will fail to update cache in that case.
if let Ok(update_doc) = to_document(&update) {
let _ = update_one_by_id(
&db_client().await.updates,
&update.id,
mungos::update::Update::Set(update_doc),
None,
)
.await;
refresh_build_state_cache().await;
}
update_update(update.clone()).await?;
Ok(update)
}
#[async_trait]
impl Resolve<CancelBuild, User> for State {
#[instrument(name = "CancelBuild", skip(self, user))]
@@ -352,11 +386,8 @@ async fn get_aws_builder(
) -> anyhow::Result<(PeripheryClient, BuildCleanupData)> {
let start_create_ts = monitor_timestamp();
let instance_name = format!(
"BUILDER-{}-v{}",
build.name,
build.config.version
);
let instance_name =
format!("BUILDER-{}-v{}", build.name, build.config.version);
let Ec2Instance { instance_id, ip } = launch_ec2_instance(
&instance_name,
AwsServerTemplateConfig::from_builder_config(&config),
@@ -478,7 +509,7 @@ async fn handle_post_build_redeploy(build_id: &str) {
.map(|deployment| async move {
let state =
get_deployment_state(&deployment).await.unwrap_or_default();
if state == DockerContainerState::Running {
if state == DeploymentState::Running {
let res = State
.resolve(
Deploy {

View File

@@ -8,7 +8,7 @@ use monitor_client::{
deployment::{Deployment, DeploymentImage},
get_image_name, monitor_timestamp,
permission::PermissionLevel,
server::ServerStatus,
server::ServerState,
update::{Log, ResourceTarget, Update, UpdateStatus},
user::User,
Operation, Version,
@@ -68,7 +68,7 @@ impl Resolve<Deploy, User> for State {
let (server, status) =
get_server_with_status(&deployment.config.server_id).await?;
if status != ServerStatus::Ok {
if status != ServerState::Ok {
return Err(anyhow!(
"cannot send action when server is unreachable or disabled"
));
@@ -168,7 +168,7 @@ impl Resolve<StartContainer, User> for State {
let (server, status) =
get_server_with_status(&deployment.config.server_id).await?;
if status != ServerStatus::Ok {
if status != ServerState::Ok {
return Err(anyhow!(
"cannot send action when server is unreachable or disabled"
));
@@ -247,7 +247,7 @@ impl Resolve<StopContainer, User> for State {
let (server, status) =
get_server_with_status(&deployment.config.server_id).await?;
if status != ServerStatus::Ok {
if status != ServerState::Ok {
return Err(anyhow!(
"cannot send action when server is unreachable or disabled"
));
@@ -296,7 +296,7 @@ impl Resolve<StopAllContainers, User> for State {
user: User,
) -> anyhow::Result<Update> {
let (server, status) = get_server_with_status(&server).await?;
if status != ServerStatus::Ok {
if status != ServerState::Ok {
return Err(anyhow!(
"cannot send action when server is unreachable or disabled"
));
@@ -406,7 +406,7 @@ impl Resolve<RemoveContainer, User> for State {
let (server, status) =
get_server_with_status(&deployment.config.server_id).await?;
if status != ServerStatus::Ok {
if status != ServerState::Ok {
return Err(anyhow!(
"cannot send action when server is unreachable or disabled"
));

View File

@@ -1,5 +1,3 @@
use std::str::FromStr;
use anyhow::anyhow;
use async_trait::async_trait;
use monitor_client::{
@@ -14,7 +12,10 @@ use monitor_client::{
Operation,
},
};
use mungos::mongodb::bson::{doc, oid::ObjectId};
use mungos::{
by_id::update_one_by_id,
mongodb::bson::{doc, to_document},
};
use periphery_client::api;
use resolver_api::Resolve;
use serror::serialize_error_pretty;
@@ -25,7 +26,7 @@ use crate::{
periphery_client,
update::{add_update, update_update},
},
resource,
resource::{self, refresh_repo_state_cache},
state::{action_states, db_client, State},
};
@@ -98,24 +99,10 @@ impl Resolve<CloneRepo, User> for State {
update.finalize();
if update.success {
let res = db_client().await
.repos
.update_one(
doc! { "_id": ObjectId::from_str(&repo.id)? },
doc! { "$set": { "info.last_pulled_at": monitor_timestamp() } },
None,
)
.await;
if let Err(e) = res {
warn!(
"failed to update repo last_pulled_at | repo id: {} | {e:#}",
repo.id
);
}
update_last_pulled(&repo.name).await;
}
update_update(update.clone()).await?;
Ok(update)
handle_update_return(update).await
}
}
@@ -168,8 +155,9 @@ impl Resolve<PullRepo, User> for State {
let logs = match periphery
.request(api::git::PullRepo {
name: repo.name,
name: repo.name.clone(),
branch: optional_string(&repo.config.branch),
commit: optional_string(&repo.config.commit),
on_pull: repo.config.on_pull.into_option(),
})
.await
@@ -185,23 +173,47 @@ impl Resolve<PullRepo, User> for State {
update.finalize();
if update.success {
let res = db_client().await
.repos
.update_one(
doc! { "_id": ObjectId::from_str(&repo.id)? },
doc! { "$set": { "info.last_pulled_at": monitor_timestamp() } },
None,
)
.await;
if let Err(e) = res {
warn!(
"failed to update repo last_pulled_at | repo id: {} | {e:#}",
repo.id
);
}
update_last_pulled(&repo.name).await;
}
update_update(update.clone()).await?;
Ok(update)
handle_update_return(update).await
}
}
async fn handle_update_return(
update: Update,
) -> anyhow::Result<Update> {
// Need to manually update the update before cache refresh,
// and before broadcast with add_update.
// The Err case of to_document should be unreachable,
// but will fail to update cache in that case.
if let Ok(update_doc) = to_document(&update) {
let _ = update_one_by_id(
&db_client().await.updates,
&update.id,
mungos::update::Update::Set(update_doc),
None,
)
.await;
refresh_repo_state_cache().await;
}
update_update(update.clone()).await?;
Ok(update)
}
async fn update_last_pulled(repo_name: &str) {
let res = db_client()
.await
.repos
.update_one(
doc! { "name": repo_name },
doc! { "$set": { "info.last_pulled_at": monitor_timestamp() } },
None,
)
.await;
if let Err(e) = res {
warn!(
"failed to update repo last_pulled_at | repo: {repo_name} | {e:#}",
);
}
}

View File

@@ -7,7 +7,7 @@ use monitor_client::{
entities::{
deployment::{
Deployment, DeploymentActionState, DeploymentConfig,
DeploymentListItem, DockerContainerState, DockerContainerStats,
DeploymentListItem, DeploymentState, DockerContainerStats,
},
permission::PermissionLevel,
server::Server,
@@ -237,13 +237,13 @@ impl Resolve<GetDeploymentsSummary, User> for State {
let status =
status_cache.get(&deployment.id).await.unwrap_or_default();
match status.curr.state {
DockerContainerState::Running => {
DeploymentState::Running => {
res.running += 1;
}
DockerContainerState::Unknown => {
DeploymentState::Unknown => {
res.unknown += 1;
}
DockerContainerState::NotDeployed => {
DeploymentState::NotDeployed => {
res.not_deployed += 1;
}
_ => {

View File

@@ -72,7 +72,7 @@ enum ReadRequest {
GetServersSummary(GetServersSummary),
GetServer(GetServer),
ListServers(ListServers),
GetServerStatus(GetServerStatus),
GetServerState(GetServerState),
GetPeripheryVersion(GetPeripheryVersion),
GetDockerContainers(GetDockerContainers),
GetDockerImages(GetDockerImages),

View File

@@ -15,7 +15,7 @@ use monitor_client::{
permission::PermissionLevel,
server::{
docker_image::ImageSummary, docker_network::DockerNetwork,
Server, ServerActionState, ServerListItem, ServerStatus,
Server, ServerActionState, ServerListItem, ServerState,
},
user::User,
},
@@ -48,14 +48,14 @@ impl Resolve<GetServersSummary, User> for State {
let mut res = GetServersSummaryResponse::default();
for server in servers {
res.total += 1;
match server.info.status {
ServerStatus::Ok => {
match server.info.state {
ServerState::Ok => {
res.healthy += 1;
}
ServerStatus::NotOk => {
ServerState::NotOk => {
res.unhealthy += 1;
}
ServerStatus::Disabled => {
ServerState::Disabled => {
res.disabled += 1;
}
}
@@ -114,12 +114,12 @@ impl Resolve<ListServers, User> for State {
}
#[async_trait]
impl Resolve<GetServerStatus, User> for State {
impl Resolve<GetServerState, User> for State {
async fn resolve(
&self,
GetServerStatus { server }: GetServerStatus,
GetServerState { server }: GetServerState,
user: User,
) -> anyhow::Result<GetServerStatusResponse> {
) -> anyhow::Result<GetServerStateResponse> {
let server = resource::get_check_permissions::<Server>(
&server,
&user,
@@ -130,8 +130,8 @@ impl Resolve<GetServerStatus, User> for State {
.get(&server.id)
.await
.ok_or(anyhow!("did not find cached status for server"))?;
let response = GetServerStatusResponse {
status: status.status,
let response = GetServerStateResponse {
status: status.state,
};
Ok(response)
}

View File

@@ -3,7 +3,7 @@ use async_trait::async_trait;
use monitor_client::{
api::write::*,
entities::{
deployment::{Deployment, DockerContainerState},
deployment::{Deployment, DeploymentState},
monitor_timestamp,
permission::PermissionLevel,
server::Server,
@@ -112,7 +112,7 @@ impl Resolve<RenameDeployment, User> for State {
let container_state = get_deployment_state(&deployment).await?;
if container_state == DockerContainerState::Unknown {
if container_state == DeploymentState::Unknown {
return Err(anyhow!(
"cannot rename deployment when container status is unknown"
));
@@ -132,7 +132,7 @@ impl Resolve<RenameDeployment, User> for State {
.await
.context("failed to update deployment name on db")?;
if container_state != DockerContainerState::NotDeployed {
if container_state != DeploymentState::NotDeployed {
let server =
resource::get::<Server>(&deployment.config.server_id).await?;
let log = periphery_client(&server)?

View File

@@ -3,7 +3,7 @@ use futures::future::join_all;
use monitor_client::entities::{
alert::{Alert, AlertData},
alerter::*,
deployment::DockerContainerState,
deployment::DeploymentState,
server::stats::SeverityLevel,
};
use mungos::{find::find_collect, mongodb::bson::doc};
@@ -236,14 +236,12 @@ fn fmt_region(region: &Option<String>) -> String {
}
}
fn fmt_docker_container_state(
state: &DockerContainerState,
) -> String {
fn fmt_docker_container_state(state: &DeploymentState) -> String {
match state {
DockerContainerState::Running => String::from("Running ▶️"),
DockerContainerState::Exited => String::from("Exited 🛑"),
DockerContainerState::Restarting => String::from("Restarting 🔄"),
DockerContainerState::NotDeployed => String::from("Not Deployed"),
DeploymentState::Running => String::from("Running ▶️"),
DeploymentState::Exited => String::from("Exited 🛑"),
DeploymentState::Restarting => String::from("Restarting 🔄"),
DeploymentState::NotDeployed => String::from("Not Deployed"),
_ => state.to_string(),
}
}

View File

@@ -2,9 +2,9 @@ use std::{collections::HashSet, str::FromStr};
use anyhow::{anyhow, Context};
use monitor_client::entities::{
deployment::{Deployment, DockerContainerState},
deployment::{Deployment, DeploymentState},
permission::PermissionLevel,
server::{Server, ServerStatus},
server::{Server, ServerState},
tag::Tag,
update::ResourceTargetVariant,
user::{admin_service_user, User},
@@ -31,17 +31,17 @@ pub async fn get_user(user_id: &str) -> anyhow::Result<User> {
#[instrument(level = "debug")]
pub async fn get_server_with_status(
server_id_or_name: &str,
) -> anyhow::Result<(Server, ServerStatus)> {
) -> anyhow::Result<(Server, ServerState)> {
let server = resource::get::<Server>(server_id_or_name).await?;
if !server.config.enabled {
return Ok((server, ServerStatus::Disabled));
return Ok((server, ServerState::Disabled));
}
let status = match super::periphery_client(&server)?
.request(periphery_client::api::GetHealth {})
.await
{
Ok(_) => ServerStatus::Ok,
Err(_) => ServerStatus::NotOk,
Ok(_) => ServerState::Ok,
Err(_) => ServerState::NotOk,
};
Ok((server, status))
}
@@ -49,14 +49,14 @@ pub async fn get_server_with_status(
#[instrument(level = "debug")]
pub async fn get_deployment_state(
deployment: &Deployment,
) -> anyhow::Result<DockerContainerState> {
) -> anyhow::Result<DeploymentState> {
if deployment.config.server_id.is_empty() {
return Ok(DockerContainerState::NotDeployed);
return Ok(DeploymentState::NotDeployed);
}
let (server, status) =
get_server_with_status(&deployment.config.server_id).await?;
if status != ServerStatus::Ok {
return Ok(DockerContainerState::Unknown);
if status != ServerState::Ok {
return Ok(DeploymentState::Unknown);
}
let container = super::periphery_client(&server)?
.request(periphery_client::api::container::GetContainerList {})
@@ -66,7 +66,7 @@ pub async fn get_deployment_state(
let state = match container {
Some(container) => container.state,
None => DockerContainerState::NotDeployed,
None => DeploymentState::NotDeployed,
};
Ok(state)

View File

@@ -6,7 +6,9 @@ use hex::ToHex;
use hmac::{Hmac, Mac};
use monitor_client::{
api::execute,
entities::{build::Build, repo::Repo, user::github_user},
entities::{
build::Build, procedure::Procedure, repo::Repo, user::github_user,
},
};
use resolver_api::Resolve;
use serde::Deserialize;
@@ -129,6 +131,9 @@ async fn handle_build_webhook(
verify_gh_signature(headers, &body).await?;
let request_branch = extract_branch(&body)?;
let build = resource::get::<Build>(&build_id).await?;
if !build.config.webhook_enabled {
return Err(anyhow!("build does not have webhook enabled"));
}
if request_branch != build.config.branch {
return Err(anyhow!("request branch does not match expected"));
}
@@ -155,6 +160,9 @@ async fn handle_repo_clone_webhook(
verify_gh_signature(headers, &body).await?;
let request_branch = extract_branch(&body)?;
let repo = resource::get::<Repo>(&repo_id).await?;
if !repo.config.webhook_enabled {
return Err(anyhow!("repo does not have webhook enabled"));
}
if request_branch != repo.config.branch {
return Err(anyhow!("request branch does not match expected"));
}
@@ -181,6 +189,9 @@ async fn handle_repo_pull_webhook(
verify_gh_signature(headers, &body).await?;
let request_branch = extract_branch(&body)?;
let repo = resource::get::<Repo>(&repo_id).await?;
if !repo.config.webhook_enabled {
return Err(anyhow!("repo does not have webhook enabled"));
}
if request_branch != repo.config.branch {
return Err(anyhow!("request branch does not match expected"));
}
@@ -211,6 +222,10 @@ async fn handle_procedure_webhook(
if request_branch != target_branch {
return Err(anyhow!("request branch does not match expected"));
}
let procedure = resource::get::<Procedure>(&procedure_id).await?;
if !procedure.config.webhook_enabled {
return Err(anyhow!("procedure does not have webhook enabled"));
}
State
.resolve(
execute::RunProcedure {

View File

@@ -33,8 +33,10 @@ async fn app() -> anyhow::Result<()> {
info!("config: {:?}", config.sanitized());
// Spawn monitoring loops
monitor::spawn_monitor_loop();
monitor::spawn_monitor_loop()?;
helpers::prune::spawn_prune_loop();
resource::spawn_build_state_refresh_loop();
resource::spawn_repo_state_refresh_loop();
// Setup static frontend services
let frontend_path = frontend_path();

View File

@@ -5,7 +5,7 @@ use mongo_indexed::Indexed;
use monitor_client::entities::{
alert::{Alert, AlertData, AlertDataVariant},
monitor_timestamp, optional_string,
server::{stats::SeverityLevel, ServerListItem, ServerStatus},
server::{stats::SeverityLevel, ServerListItem, ServerState},
update::ResourceTarget,
};
use mungos::{
@@ -56,8 +56,8 @@ pub async fn alert_servers(
let health_alert = server_alerts.as_ref().and_then(|alerts| {
alerts.get(&AlertDataVariant::ServerUnreachable)
});
match (server_status.status, health_alert) {
(ServerStatus::NotOk, None) => {
match (server_status.state, health_alert) {
(ServerState::NotOk, None) => {
// open unreachable alert
let alert = Alert {
id: Default::default(),
@@ -77,7 +77,7 @@ pub async fn alert_servers(
alerts_to_open
.push((alert, server.info.send_unreachable_alerts))
}
(ServerStatus::NotOk, Some(alert)) => {
(ServerState::NotOk, Some(alert)) => {
// update alert err
let mut alert = alert.clone();
let (id, name, region) = match alert.data {
@@ -102,7 +102,7 @@ pub async fn alert_servers(
// Close an open alert
(
ServerStatus::Ok | ServerStatus::Disabled,
ServerState::Ok | ServerState::Disabled,
Some(health_alert),
) => alert_ids_to_close.push((
health_alert.id.clone(),

View File

@@ -1,17 +1,23 @@
use monitor_client::entities::{
deployment::{Deployment, DockerContainerState},
deployment::{Deployment, DeploymentState},
repo::Repo,
server::{
stats::{
ServerHealth, SeverityLevel, SingleDiskUsage, SystemStats,
},
Server, ServerConfig, ServerStatus,
Server, ServerConfig, ServerState,
},
};
use serror::Serror;
use crate::state::{deployment_status_cache, server_status_cache};
use crate::state::{
deployment_status_cache, repo_status_cache, server_status_cache,
};
use super::{CachedDeploymentStatus, CachedServerStatus, History};
use super::{
CachedDeploymentStatus, CachedRepoStatus, CachedServerStatus,
History,
};
#[instrument(level = "debug", skip_all)]
pub async fn insert_deployments_status_unknown(
@@ -27,7 +33,7 @@ pub async fn insert_deployments_status_unknown(
History {
curr: CachedDeploymentStatus {
id: deployment.id,
state: DockerContainerState::Unknown,
state: DeploymentState::Unknown,
container: None,
},
prev,
@@ -38,10 +44,28 @@ pub async fn insert_deployments_status_unknown(
}
}
#[instrument(level = "debug", skip_all)]
pub async fn insert_repos_status_unknown(repos: Vec<Repo>) {
let status_cache = repo_status_cache();
for repo in repos {
status_cache
.insert(
repo.id.clone(),
CachedRepoStatus {
id: repo.id,
latest_hash: None,
latest_message: None,
}
.into(),
)
.await;
}
}
#[instrument(level = "debug", skip_all)]
pub async fn insert_server_status(
server: &Server,
status: ServerStatus,
state: ServerState,
version: String,
stats: Option<SystemStats>,
err: impl Into<Option<Serror>>,
@@ -52,7 +76,7 @@ pub async fn insert_server_status(
server.id.clone(),
CachedServerStatus {
id: server.id.clone(),
status,
state,
version,
stats,
health,

View File

@@ -1,24 +1,26 @@
use async_timing_util::{wait_until_timelength, Timelength};
use async_timing_util::wait_until_timelength;
use futures::future::join_all;
use monitor_client::entities::{
deployment::{ContainerSummary, DockerContainerState},
deployment::{ContainerSummary, DeploymentState},
server::{
stats::{ServerHealth, SystemStats},
Server, ServerStatus,
Server, ServerState,
},
};
use mungos::{find::find_collect, mongodb::bson::doc};
use periphery_client::api;
use periphery_client::api::{self, git::GetLatestCommit};
use serror::Serror;
use crate::{
config::core_config,
helpers::periphery_client,
monitor::{alert::check_alerts, record::record_server_stats},
state::{db_client, deployment_status_cache},
state::{db_client, deployment_status_cache, repo_status_cache},
};
use self::helpers::{
insert_deployments_status_unknown, insert_server_status,
insert_deployments_status_unknown, insert_repos_status_unknown,
insert_server_status,
};
mod alert;
@@ -34,7 +36,7 @@ pub struct History<Curr: Default, Prev> {
#[derive(Default, Clone, Debug)]
pub struct CachedServerStatus {
pub id: String,
pub status: ServerStatus,
pub state: ServerState,
pub version: String,
pub stats: Option<SystemStats>,
pub health: Option<ServerHealth>,
@@ -44,16 +46,24 @@ pub struct CachedServerStatus {
#[derive(Default, Clone, Debug)]
pub struct CachedDeploymentStatus {
pub id: String,
pub state: DockerContainerState,
pub state: DeploymentState,
pub container: Option<ContainerSummary>,
}
pub fn spawn_monitor_loop() {
#[derive(Default, Clone, Debug)]
pub struct CachedRepoStatus {
pub id: String,
pub latest_hash: Option<String>,
pub latest_message: Option<String>,
}
pub fn spawn_monitor_loop() -> anyhow::Result<()> {
let interval: async_timing_util::Timelength =
core_config().monitoring_interval.try_into()?;
tokio::spawn(async move {
loop {
let ts = (wait_until_timelength(Timelength::FiveSeconds, 500)
.await
- 500) as i64;
let ts =
(wait_until_timelength(interval, 500).await - 500) as i64;
let servers =
match find_collect(&db_client().await.servers, None, None)
.await
@@ -73,6 +83,7 @@ pub fn spawn_monitor_loop() {
tokio::join!(check_alerts(ts), record_server_stats(ts));
}
});
Ok(())
}
#[instrument(level = "debug")]
@@ -87,15 +98,31 @@ pub async fn update_cache_for_server(server: &Server) {
Ok(deployments) => deployments,
Err(e) => {
error!("failed to get deployments list from mongo (update status cache) | server id: {} | {e:#}", server.id);
return;
Vec::new()
}
};
let repos = match find_collect(
&db_client().await.repos,
doc! { "config.server_id": &server.id },
None,
)
.await
{
Ok(repos) => repos,
Err(e) => {
error!("failed to get repos list from mongo (update status cache) | server id: {} | {e:#}", server.id);
Vec::new()
}
};
// Handle server disabled
if !server.config.enabled {
insert_deployments_status_unknown(deployments).await;
insert_repos_status_unknown(repos).await;
insert_server_status(
server,
ServerStatus::Disabled,
ServerState::Disabled,
String::from("unknown"),
None,
None,
@@ -103,16 +130,22 @@ pub async fn update_cache_for_server(server: &Server) {
.await;
return;
}
// already handle server disabled case above, so using unwrap here
let periphery = periphery_client(server).unwrap();
let Ok(periphery) = periphery_client(server) else {
error!(
"somehow periphery not ok to create. should not be reached."
);
return;
};
let version = match periphery.request(api::GetVersion {}).await {
Ok(version) => version.version,
Err(e) => {
insert_deployments_status_unknown(deployments).await;
insert_repos_status_unknown(repos).await;
insert_server_status(
server,
ServerStatus::NotOk,
ServerState::NotOk,
String::from("unknown"),
None,
Serror::from(&e),
@@ -127,9 +160,10 @@ pub async fn update_cache_for_server(server: &Server) {
Ok(stats) => Some(stats),
Err(e) => {
insert_deployments_status_unknown(deployments).await;
insert_repos_status_unknown(repos).await;
insert_server_status(
server,
ServerStatus::NotOk,
ServerState::NotOk,
String::from("unknown"),
None,
Serror::from(&e),
@@ -142,45 +176,64 @@ pub async fn update_cache_for_server(server: &Server) {
None
};
insert_server_status(
server,
ServerStatus::Ok,
version,
stats,
None,
)
.await;
insert_server_status(server, ServerState::Ok, version, stats, None)
.await;
let containers =
periphery.request(api::container::GetContainerList {}).await;
if containers.is_err() {
insert_deployments_status_unknown(deployments).await;
return;
}
match periphery.request(api::container::GetContainerList {}).await {
Ok(containers) => {
let status_cache = deployment_status_cache();
for deployment in deployments {
let container = containers
.iter()
.find(|c| c.name == deployment.name)
.cloned();
let prev = status_cache
.get(&deployment.id)
.await
.map(|s| s.curr.state);
let state = container
.as_ref()
.map(|c| c.state)
.unwrap_or(DeploymentState::NotDeployed);
status_cache
.insert(
deployment.id.clone(),
History {
curr: CachedDeploymentStatus {
id: deployment.id,
state,
container,
},
prev,
}
.into(),
)
.await;
}
}
Err(e) => {
warn!("could not get containers list | {e:#}");
insert_deployments_status_unknown(deployments).await;
}
};
let containers = containers.unwrap();
let status_cache = deployment_status_cache();
for deployment in deployments {
let container = containers
.iter()
.find(|c| c.name == deployment.name)
.cloned();
let prev =
status_cache.get(&deployment.id).await.map(|s| s.curr.state);
let state = container
.as_ref()
.map(|c| c.state)
.unwrap_or(DockerContainerState::NotDeployed);
let status_cache = repo_status_cache();
for repo in repos {
let (latest_hash, latest_message) = periphery
.request(GetLatestCommit {
name: repo.name.clone(),
})
.await
.map(|r| (r.hash, r.message))
.ok()
.unzip();
status_cache
.insert(
deployment.id.clone(),
History {
curr: CachedDeploymentStatus {
id: deployment.id,
state,
container,
},
prev,
repo.id.clone(),
CachedRepoStatus {
id: repo.id,
latest_hash,
latest_message,
}
.into(),
)

View File

@@ -1,8 +1,11 @@
use std::time::Duration;
use anyhow::Context;
use monitor_client::entities::{
build::{
Build, BuildConfig, BuildConfigDiff, BuildInfo, BuildListItem,
BuildListItemInfo, BuildQuerySpecifics, PartialBuildConfig,
BuildListItemInfo, BuildQuerySpecifics, BuildState,
PartialBuildConfig,
},
builder::Builder,
permission::PermissionLevel,
@@ -11,11 +14,14 @@ use monitor_client::entities::{
user::User,
Operation,
};
use mungos::mongodb::Collection;
use mungos::{
find::find_collect,
mongodb::{bson::doc, options::FindOneOptions, Collection},
};
use crate::{
helpers::empty_or_only_spaces,
state::{action_states, db_client},
state::{action_states, build_state_cache, db_client},
};
impl super::MonitorResource for Build {
@@ -38,6 +44,7 @@ impl super::MonitorResource for Build {
async fn to_list_item(
build: Resource<Self::Config, Self::Info>,
) -> anyhow::Result<Self::ListItem> {
let state = get_build_state(&build.id).await;
Ok(BuildListItem {
name: build.name,
id: build.id,
@@ -48,6 +55,7 @@ impl super::MonitorResource for Build {
version: build.config.version,
repo: build.config.repo,
branch: build.config.branch,
state,
},
})
}
@@ -127,6 +135,33 @@ impl super::MonitorResource for Build {
}
}
pub fn spawn_build_state_refresh_loop() {
tokio::spawn(async move {
loop {
refresh_build_state_cache().await;
tokio::time::sleep(Duration::from_secs(60)).await;
}
});
}
pub async fn refresh_build_state_cache() {
let _ = async {
let builds = find_collect(&db_client().await.builds, None, None)
.await
.context("failed to get builds from db")?;
let cache = build_state_cache();
for build in builds {
let state = get_build_state_from_db(&build.id).await;
cache.insert(build.id, state).await;
}
anyhow::Ok(())
}
.await
.inspect_err(|e| {
error!("failed to refresh build state cache | {e:#}")
});
}
#[instrument(skip(user))]
async fn validate_config(
config: &mut PartialBuildConfig,
@@ -149,3 +184,52 @@ async fn validate_config(
}
Ok(())
}
async fn get_build_state(id: &String) -> BuildState {
if action_states()
.build
.get(id)
.await
.map(|s| s.get().map(|s| s.building))
.transpose()
.ok()
.flatten()
.unwrap_or_default()
{
return BuildState::Building;
}
build_state_cache().get(id).await.unwrap_or_default()
}
async fn get_build_state_from_db(id: &str) -> BuildState {
async {
let state = db_client()
.await
.updates
.find_one(
doc! {
"target.type": "Build",
"target.id": id,
"operation": "RunBuild"
},
FindOneOptions::builder()
.sort(doc! { "start_ts": -1 })
.build(),
)
.await?
.map(|u| {
if u.success {
BuildState::Ok
} else {
BuildState::Failed
}
})
.unwrap_or(BuildState::Ok);
anyhow::Ok(state)
}
.await
.inspect_err(|e| {
warn!("failed to get build state for {id} | {e:#}")
})
.unwrap_or(BuildState::Unknown)
}

View File

@@ -4,7 +4,7 @@ use monitor_client::entities::{
deployment::{
Deployment, DeploymentConfig, DeploymentConfigDiff,
DeploymentImage, DeploymentListItem, DeploymentListItemInfo,
DeploymentQuerySpecifics, DockerContainerState,
DeploymentQuerySpecifics, DeploymentState,
PartialDeploymentConfig,
},
permission::PermissionLevel,
@@ -149,8 +149,8 @@ impl super::MonitorResource for Deployment {
.context("failed to get container state")?;
if !matches!(
state,
DockerContainerState::NotDeployed
| DockerContainerState::Unknown
DeploymentState::NotDeployed
| DeploymentState::Unknown
) {
// container needs to be destroyed
let server =

View File

@@ -48,6 +48,13 @@ mod repo;
mod server;
mod server_template;
pub use build::{
refresh_build_state_cache, spawn_build_state_refresh_loop,
};
pub use repo::{
refresh_repo_state_cache, spawn_repo_state_refresh_loop,
};
/// Implement on each monitor resource for common methods
pub trait MonitorResource {
type ListItem: Serialize + Send;
@@ -377,7 +384,7 @@ pub async fn update<T: MonitorResource>(
for FieldDiff { field, from, to } in diff.iter_field_diffs() {
diff_log.push_str(&format!(
"\n\nfield: '{field}'\nfrom: {from}\nto: {to}"
"\n\n<span class=\"text-muted-foreground\">field</span>: '{field}'\n<span class=\"text-muted-foreground\">from</span>: <span class=\"text-red-500\">{from}</span>\n<span class=\"text-muted-foreground\">to</span>: <span class=\"text-green-500\">{to}</span>",
));
}

View File

@@ -1,9 +1,11 @@
use std::time::Duration;
use anyhow::Context;
use monitor_client::entities::{
permission::PermissionLevel,
repo::{
PartialRepoConfig, Repo, RepoConfig, RepoConfigDiff, RepoInfo,
RepoListItem, RepoListItemInfo, RepoQuerySpecifics,
RepoListItem, RepoListItemInfo, RepoQuerySpecifics, RepoState,
},
resource::Resource,
server::Server,
@@ -11,13 +13,18 @@ use monitor_client::entities::{
user::User,
Operation,
};
use mungos::mongodb::Collection;
use mungos::{
find::find_collect,
mongodb::{bson::doc, options::FindOneOptions, Collection},
};
use periphery_client::api::git::DeleteRepo;
use serror::serialize_error_pretty;
use crate::{
helpers::periphery_client,
state::{action_states, db_client},
state::{
action_states, db_client, repo_state_cache, repo_status_cache,
},
};
use super::get_check_permissions;
@@ -42,6 +49,9 @@ impl super::MonitorResource for Repo {
async fn to_list_item(
repo: Resource<Self::Config, Self::Info>,
) -> anyhow::Result<Self::ListItem> {
let state = get_repo_state(&repo.id).await;
let status =
repo_status_cache().get(&repo.id).await.unwrap_or_default();
Ok(RepoListItem {
name: repo.name,
id: repo.id,
@@ -51,6 +61,9 @@ impl super::MonitorResource for Repo {
last_pulled_at: repo.info.last_pulled_at,
repo: repo.config.repo,
branch: repo.config.branch,
state,
latest_hash: status.latest_hash.clone(),
latest_message: status.latest_message.clone(),
},
})
}
@@ -150,6 +163,33 @@ impl super::MonitorResource for Repo {
}
}
pub fn spawn_repo_state_refresh_loop() {
tokio::spawn(async move {
loop {
refresh_repo_state_cache().await;
tokio::time::sleep(Duration::from_secs(60)).await;
}
});
}
pub async fn refresh_repo_state_cache() {
let _ = async {
let repos = find_collect(&db_client().await.repos, None, None)
.await
.context("failed to get repos from db")?;
let cache = repo_state_cache();
for repo in repos {
let state = get_repo_state_from_db(&repo.id).await;
cache.insert(repo.id, state).await;
}
anyhow::Ok(())
}
.await
.inspect_err(|e| {
error!("failed to refresh repo state cache | {e:#}")
});
}
#[instrument(skip(user))]
async fn validate_config(
config: &mut PartialRepoConfig,
@@ -170,3 +210,62 @@ async fn validate_config(
}
Ok(())
}
async fn get_repo_state(id: &String) -> RepoState {
if let Some(state) = action_states()
.repo
.get(id)
.await
.and_then(|s| {
s.get()
.map(|s| {
if s.cloning {
Some(RepoState::Cloning)
} else if s.pulling {
Some(RepoState::Pulling)
} else {
None
}
})
.ok()
})
.flatten()
{
return state;
}
repo_state_cache().get(id).await.unwrap_or_default()
}
async fn get_repo_state_from_db(id: &str) -> RepoState {
async {
let state = db_client()
.await
.updates
.find_one(
doc! {
"target.type": "Repo",
"target.id": id,
"$or": [
{ "operation": "CloneRepo" },
{ "operation": "PullRepo" },
],
},
FindOneOptions::builder()
.sort(doc! { "start_ts": -1 })
.build(),
)
.await?
.map(|u| {
if u.success {
RepoState::Ok
} else {
RepoState::Failed
}
})
.unwrap_or(RepoState::Ok);
anyhow::Ok(state)
}
.await
.inspect_err(|e| warn!("failed to get repo state for {id} | {e:#}"))
.unwrap_or(RepoState::Unknown)
}

View File

@@ -44,7 +44,7 @@ impl super::MonitorResource for Server {
tags: server.tags,
resource_type: ResourceTargetVariant::Server,
info: ServerListItemInfo {
status: status.map(|s| s.status).unwrap_or_default(),
state: status.map(|s| s.state).unwrap_or_default(),
region: server.config.region,
send_unreachable_alerts: server
.config

View File

@@ -1,6 +1,8 @@
use std::sync::{Arc, OnceLock};
use monitor_client::entities::deployment::DockerContainerState;
use monitor_client::entities::{
build::BuildState, deployment::DeploymentState, repo::RepoState,
};
use tokio::sync::OnceCell;
use crate::{
@@ -8,7 +10,10 @@ use crate::{
config::core_config,
db::DbClient,
helpers::{action_state::ActionStates, cache::Cache},
monitor::{CachedDeploymentStatus, CachedServerStatus, History},
monitor::{
CachedDeploymentStatus, CachedRepoStatus, CachedServerStatus,
History,
},
};
pub struct State;
@@ -36,7 +41,7 @@ pub fn action_states() -> &'static ActionStates {
pub type DeploymentStatusCache = Cache<
String,
Arc<History<CachedDeploymentStatus, DockerContainerState>>,
Arc<History<CachedDeploymentStatus, DeploymentState>>,
>;
pub fn deployment_status_cache() -> &'static DeploymentStatusCache {
@@ -52,3 +57,26 @@ pub fn server_status_cache() -> &'static ServerStatusCache {
OnceLock::new();
SERVER_STATUS_CACHE.get_or_init(Default::default)
}
pub type RepoStatusCache = Cache<String, Arc<CachedRepoStatus>>;
pub fn repo_status_cache() -> &'static RepoStatusCache {
static REPO_STATUS_CACHE: OnceLock<RepoStatusCache> =
OnceLock::new();
REPO_STATUS_CACHE.get_or_init(Default::default)
}
pub type BuildStateCache = Cache<String, BuildState>;
pub fn build_state_cache() -> &'static BuildStateCache {
static BUILD_STATE_CACHE: OnceLock<BuildStateCache> =
OnceLock::new();
BUILD_STATE_CACHE.get_or_init(Default::default)
}
pub type RepoStateCache = Cache<String, RepoState>;
pub fn repo_state_cache() -> &'static RepoStateCache {
static REPO_STATE_CACHE: OnceLock<RepoStateCache> = OnceLock::new();
REPO_STATE_CACHE.get_or_init(Default::default)
}

View File

@@ -230,6 +230,8 @@ impl TryFrom<Build> for monitor_client::entities::build::Build {
extra_args,
use_buildx,
labels: Default::default(),
webhook_enabled: true,
commit: Default::default(),
},
};
Ok(build)

View File

@@ -1,9 +1,30 @@
use anyhow::anyhow;
use monitor_client::entities::{to_monitor_name, update::Log};
use periphery_client::api::git::{CloneRepo, DeleteRepo, PullRepo};
use periphery_client::api::git::{
CloneRepo, DeleteRepo, GetLatestCommit, GetLatestCommitResponse,
PullRepo,
};
use resolver_api::Resolve;
use crate::{config::periphery_config, helpers::git, State};
#[async_trait::async_trait]
impl Resolve<GetLatestCommit, ()> for State {
async fn resolve(
&self,
GetLatestCommit { name }: GetLatestCommit,
_: (),
) -> anyhow::Result<GetLatestCommitResponse> {
let repo_path = periphery_config().repo_dir.join(name);
if !repo_path.is_dir() {
return Err(anyhow!(
"repo path is not directory. is it cloned?"
));
}
git::get_commit_hash_info(&repo_path).await
}
}
#[async_trait::async_trait]
impl Resolve<CloneRepo> for State {
#[instrument(name = "CloneRepo", skip(self))]
@@ -26,6 +47,7 @@ impl Resolve<PullRepo> for State {
PullRepo {
name,
branch,
commit,
on_pull,
}: PullRepo,
_: (),
@@ -35,6 +57,7 @@ impl Resolve<PullRepo> for State {
git::pull(
&periphery_config().repo_dir.join(name),
&branch,
&commit,
&on_pull,
)
.await,

View File

@@ -3,9 +3,10 @@ use std::path::Path;
use anyhow::Context;
use async_timing_util::unix_timestamp_ms;
use monitor_client::entities::{
monitor_timestamp, to_monitor_name, update::Log, CloneArgs,
SystemCommand,
all_logs_success, monitor_timestamp, to_monitor_name, update::Log,
CloneArgs, SystemCommand,
};
use periphery_client::api::git::GetLatestCommitResponse;
use run_command::async_run_command;
use crate::config::periphery_config;
@@ -15,6 +16,7 @@ use super::{get_github_token, run_monitor_command};
pub async fn pull(
path: &Path,
branch: &Option<String>,
commit: &Option<String>,
on_pull: &Option<SystemCommand>,
) -> Vec<Log> {
let branch = match branch {
@@ -27,8 +29,19 @@ pub async fn pull(
let pull_log = run_monitor_command("git pull", command).await;
if !pull_log.success {
return vec![pull_log];
let mut logs = vec![pull_log];
if !logs[0].success {
return logs;
}
if let Some(commit) = commit {
let reset_log = run_monitor_command(
"set commit",
format!("cd {} && git reset --hard {commit}", path.display()),
)
.await;
logs.push(reset_log);
}
let commit_hash_log =
@@ -36,8 +49,7 @@ pub async fn pull(
"latest commit",
String::from("failed to get latest commit"),
));
let mut logs = vec![pull_log, commit_hash_log];
logs.push(commit_hash_log);
if let Some(on_pull) = on_pull {
if !on_pull.path.is_empty() && !on_pull.command.is_empty() {
@@ -66,6 +78,7 @@ where
name,
repo,
branch,
commit,
on_clone,
on_pull,
github_account,
@@ -83,19 +96,19 @@ where
let repo_dir = periphery_config().repo_dir.join(name);
let clone_log =
clone_inner(repo, &repo_dir, &branch, access_token).await;
let mut logs =
clone_inner(repo, &repo_dir, &branch, &commit, access_token)
.await;
if !clone_log.success {
if !all_logs_success(&logs) {
warn!("repo at {repo_dir:?} failed to clone");
return Ok(vec![clone_log]);
return Ok(logs);
}
info!("repo at {repo_dir:?} cloned with clone_inner");
let commit_hash_log = get_commit_hash_log(&repo_dir).await?;
let mut logs = vec![clone_log, commit_hash_log];
logs.push(commit_hash_log);
if let Some(command) = on_clone {
if !command.path.is_empty() && !command.command.is_empty() {
@@ -143,8 +156,9 @@ async fn clone_inner(
repo: &str,
destination: &Path,
branch: &Option<String>,
commit: &Option<String>,
access_token: Option<String>,
) -> Log {
) -> Vec<Log> {
let _ = std::fs::remove_dir_all(destination);
let access_token_at = match &access_token {
Some(token) => format!("{token}@"),
@@ -162,6 +176,7 @@ async fn clone_inner(
let output = async_run_command(&command).await;
let success = output.success();
let (command, stderr) = if !access_token_at.is_empty() {
// know that access token can't be none if access token non-empty
let access_token = access_token.unwrap();
(
command.replace(&access_token, "<TOKEN>"),
@@ -170,7 +185,7 @@ async fn clone_inner(
} else {
(command, output.stderr)
};
Log {
let mut logs = vec![Log {
stage: "clone repo".to_string(),
command,
success,
@@ -178,7 +193,45 @@ async fn clone_inner(
stderr,
start_ts,
end_ts: unix_timestamp_ms() as i64,
}];
if !logs[0].success {
return logs;
}
if let Some(commit) = commit {
let reset_log = run_monitor_command(
"set commit",
format!(
"cd {} && git reset --hard {commit}",
destination.display()
),
)
.await;
logs.push(reset_log);
}
logs
}
pub async fn get_commit_hash_info(
repo_dir: &Path,
) -> anyhow::Result<GetLatestCommitResponse> {
let command = format!("cd {} && git rev-parse --short HEAD && git rev-parse HEAD && git log -1 --pretty=%B", repo_dir.display());
let output = async_run_command(&command).await;
let mut split = output.stdout.split('\n');
let (hash, _, message) = (
split
.next()
.context("failed to get short commit hash")?
.to_string(),
split.next().context("failed to get long commit hash")?,
split
.next()
.context("failed to get commit message")?
.to_string(),
);
Ok(GetLatestCommitResponse { hash, message })
}
#[instrument]

View File

@@ -6,7 +6,7 @@ use typeshare::typeshare;
use crate::entities::{
deployment::{
ContainerSummary, Deployment, DeploymentActionState,
DeploymentListItem, DeploymentQuery, DockerContainerState,
DeploymentListItem, DeploymentQuery, DeploymentState,
DockerContainerStats,
},
update::Log,
@@ -76,7 +76,7 @@ pub struct GetDeploymentContainer {
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GetDeploymentContainerResponse {
pub state: DockerContainerState,
pub state: DeploymentState,
pub container: Option<ContainerSummary>,
}

View File

@@ -13,7 +13,7 @@ use crate::entities::{
SystemStatsRecord,
},
Server, ServerActionState, ServerListItem, ServerQuery,
ServerStatus,
ServerState,
},
Timelength, I64,
};
@@ -58,25 +58,25 @@ pub type ListServersResponse = Vec<ServerListItem>;
//
/// Get the status of the target server. Response: [GetServerStatusResponse].
/// Get the state of the target server. Response: [GetServerStateResponse].
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Request, EmptyTraits,
)]
#[empty_traits(MonitorReadRequest)]
#[response(GetServerStatusResponse)]
pub struct GetServerStatus {
#[response(GetServerStateResponse)]
pub struct GetServerState {
/// Id or name
#[serde(alias = "id", alias = "name")]
pub server: String,
}
/// The status for [GetServerStatus].
/// The response for [GetServerState].
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct GetServerStatusResponse {
pub struct GetServerStateResponse {
/// The server status.
pub status: ServerStatus,
pub status: ServerState,
}
//

View File

@@ -11,8 +11,8 @@ use typeshare::typeshare;
use crate::entities::{MongoId, I64};
use super::{
_Serror, deployment::DockerContainerState,
server::stats::SeverityLevel, update::ResourceTarget,
_Serror, deployment::DeploymentState, server::stats::SeverityLevel,
update::ResourceTarget,
};
/// Representation of an alert in the system.
@@ -140,9 +140,9 @@ pub enum AlertData {
/// The server name
server_name: String,
/// The previous container state
from: DockerContainerState,
from: DeploymentState,
/// The current container state
to: DockerContainerState,
to: DeploymentState,
},
/// An AWS builder failed to terminate.

View File

@@ -3,6 +3,7 @@ use derive_default_builder::DefaultBuilder;
use mungos::mongodb::bson::{doc, Document};
use partial_derive2::Partial;
use serde::{Deserialize, Serialize};
use strum::Display;
use typeshare::typeshare;
use crate::entities::I64;
@@ -19,7 +20,7 @@ pub type Build = Resource<BuildConfig, BuildInfo>;
pub type BuildListItem = ResourceListItem<BuildListItemInfo>;
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildListItemInfo {
/// Unix timestamp in milliseconds of last build
pub last_built_at: I64,
@@ -29,6 +30,24 @@ pub struct BuildListItemInfo {
pub repo: String,
/// The branch of the repo
pub branch: String,
/// State of the build. Reflects whether most recent build successful.
pub state: BuildState,
}
#[typeshare]
#[derive(
Debug, Clone, Copy, Default, Serialize, Deserialize, Display,
)]
pub enum BuildState {
/// Last build successful (or never built)
Ok,
/// Last build failed
Failed,
/// Currently building
Building,
/// Other case
#[default]
Unknown,
}
#[typeshare]
@@ -73,6 +92,11 @@ pub struct BuildConfig {
#[partial_default(default_branch())]
pub branch: String,
/// Optionally set a specific commit hash.
#[serde(default)]
#[builder(default)]
pub commit: String,
/// The github account used to clone (used to access private repos).
/// Empty string is public clone (only public repos).
#[serde(default)]
@@ -128,6 +152,12 @@ pub struct BuildConfig {
#[serde(default)]
#[builder(default)]
pub use_buildx: bool,
/// Whether incoming webhooks actually trigger action.
#[serde(default = "default_webhook_enabled")]
#[builder(default = "default_webhook_enabled()")]
#[partial_default(default_webhook_enabled())]
pub webhook_enabled: bool,
}
impl BuildConfig {
@@ -148,6 +178,10 @@ fn default_dockerfile_path() -> String {
String::from("Dockerfile")
}
fn default_webhook_enabled() -> bool {
true
}
#[typeshare]
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct BuildActionState {

View File

@@ -28,7 +28,7 @@ use crate::entities::{
/// configure the periphery agent. A help manual for the periphery binary
/// can be printed using `/path/to/periphery --help`.
#[derive(Parser)]
#[command(author, about, version)]
#[command(name = "periphery", author, about, version)]
pub struct CliArgs {
/// Sets the path of a config file or directory to use.
/// Can use multiple times
@@ -125,53 +125,53 @@ fn default_config_paths() -> Vec<String> {
/// The periphery agent initializes it's configuration by reading the environment,
/// parsing the [PeripheryConfig] schema from the files specified by cli args (and falling back to `env.config_paths`),
/// and then applying any config field overrides specified in the environment.
///
///
/// ## Example TOML
/// ```toml
/// ## optional. 8120 is default
/// port = 8120
///
///
/// ## optional. /repos is default.
/// repo_dir = "/repos"
///
/// ## optional. 5-sec is default.
///
/// ## optional. 5-sec is default.
/// ## can use 1-sec, 5-sec, 10-sec, 30-sec, 1-min.
/// ## controls granularity of system stats recorded
/// stats_polling_rate = "5-sec"
///
///
/// ## optional. default is empty, which will not block any request by ip.
/// allowed_ips = ["127.0.0.1"]
///
///
/// ## optional. default is empty, which will not require any passkey to be passed by core.
/// passkeys = ["abcdefghijk"]
///
///
/// ## specify the log level of the monitor core application
/// ## default: info
/// ## options: off, error, warn, info, debug, trace
/// logging.level = "info"
///
///
/// ## specify the logging format for stdout / stderr.
/// ## default: standard
/// ## options: standard, json, none
/// logging.stdio = "standard"
///
///
/// ## specify an otlp endpoint to send traces to
/// ## optional, default unassigned
/// # logging.otlp_endpoint = "http://localhost:4317"
///
///
/// ## specify the service name to send with otlp traces.
/// ## optional, default 'Monitor'.
/// # logging.opentelemetry_service_name = "Monitor"
///
///
/// ## optional. can inject these values into your deployments configuration.
/// [secrets]
/// secret_variable = "secret_value"
///
///
/// ## optional. can use these accounts with deployments / builds.
/// [github_accounts]
/// github_username1 = "github_token1"
/// github_username2 = "github_token2"
///
///
/// ## optional. can use these accounts with deployments / builds.
/// [docker_accounts]
/// docker_username1 = "docker_token1"

View File

@@ -24,8 +24,8 @@ pub type DeploymentListItem =
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct DeploymentListItemInfo {
/// The state of the docker container.
pub state: DockerContainerState,
/// The state of the deployment / underlying docker container.
pub state: DeploymentState,
/// The status of the docker container (eg. up 12 hours, exited 5 minutes ago.)
pub status: Option<String>,
/// The image attached to the deployment.
@@ -237,7 +237,7 @@ pub struct ContainerSummary {
/// The docker labels on the container.
pub labels: HashMap<String, String>,
/// The state of the container, like `running` or `not_deployed`
pub state: DockerContainerState,
pub state: DeploymentState,
/// The status string of the docker container.
pub status: Option<String>,
}
@@ -261,6 +261,12 @@ pub struct DockerContainerStats {
pub pids: String,
}
/// Variants de/serialized from/to snake_case.
///
/// Eg.
/// - NotDeployed -> not_deployed
/// - Restarting -> restarting
/// - Running -> running.
#[typeshare]
#[derive(
Serialize,
@@ -277,7 +283,7 @@ pub struct DockerContainerStats {
)]
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum DockerContainerState {
pub enum DeploymentState {
#[default]
Unknown,
NotDeployed,

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use anyhow::{anyhow, Context};
use async_timing_util::unix_timestamp_ms;
use serde::{Deserialize, Serialize};
@@ -209,6 +211,7 @@ pub struct CloneArgs {
pub name: String,
pub repo: Option<String>,
pub branch: Option<String>,
pub commit: Option<String>,
pub on_clone: Option<SystemCommand>,
pub on_pull: Option<SystemCommand>,
pub github_account: Option<String>,
@@ -220,6 +223,7 @@ impl From<&self::build::Build> for CloneArgs {
name: build.name.clone(),
repo: optional_string(&build.config.repo),
branch: optional_string(&build.config.branch),
commit: optional_string(&build.config.commit),
on_clone: build.config.pre_build.clone().into_option(),
on_pull: None,
github_account: optional_string(&build.config.github_account),
@@ -233,6 +237,7 @@ impl From<&self::repo::Repo> for CloneArgs {
name: repo.name.clone(),
repo: optional_string(&repo.config.repo),
branch: optional_string(&repo.config.branch),
commit: optional_string(&repo.config.commit),
on_clone: repo.config.on_clone.clone().into_option(),
on_pull: repo.config.on_pull.clone().into_option(),
github_account: optional_string(&repo.config.github_account),
@@ -323,6 +328,16 @@ pub enum Timelength {
ThirtyDays,
}
impl TryInto<async_timing_util::Timelength> for Timelength {
type Error = anyhow::Error;
fn try_into(
self,
) -> Result<async_timing_util::Timelength, Self::Error> {
async_timing_util::Timelength::from_str(&self.to_string())
.context("failed to parse timelength?")
}
}
#[typeshare]
#[derive(
Serialize,

View File

@@ -1,3 +1,4 @@
use derive_builder::Builder;
use derive_default_builder::DefaultBuilder;
use mungos::mongodb::bson::{doc, Document};
use partial_derive2::Partial;
@@ -29,16 +30,28 @@ pub type Procedure = Resource<ProcedureConfig, ()>;
pub type _PartialProcedureConfig = PartialProcedureConfig;
#[typeshare]
#[derive(Debug, Clone, Default, Serialize, Deserialize, Partial)]
#[derive(Debug, Clone, Default, Serialize, Deserialize, Partial, Builder)]
#[partial_derive(Debug, Clone, Default, Serialize, Deserialize)]
#[partial(skip_serializing_none, from, diff)]
pub struct ProcedureConfig {
/// Whether executions in the procedure runs sequentially or in parallel.
#[serde(default)]
#[builder(default)]
pub procedure_type: ProcedureType,
/// The executions to be run by the procedure.
#[serde(default)]
#[builder(default)]
pub executions: Vec<EnabledExecution>,
/// Whether incoming webhooks actually trigger action.
#[serde(default = "default_webhook_enabled")]
#[builder(default = "default_webhook_enabled()")]
#[partial_default(default_webhook_enabled())]
pub webhook_enabled: bool,
}
fn default_webhook_enabled() -> bool {
true
}
#[typeshare]

View File

@@ -3,6 +3,7 @@ use derive_default_builder::DefaultBuilder;
use mungos::mongodb::bson::{doc, Document};
use partial_derive2::Partial;
use serde::{Deserialize, Serialize};
use strum::Display;
use typeshare::typeshare;
use crate::entities::I64;
@@ -18,9 +19,36 @@ pub type RepoListItem = ResourceListItem<RepoListItemInfo>;
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct RepoListItemInfo {
/// Repo last cloned / pulled timestamp in ms.
pub last_pulled_at: I64,
/// The configured github repo
pub repo: String,
/// The configured branch
pub branch: String,
/// The repo state
pub state: RepoState,
/// If the repo is cloned, will be the latest short commit hash.
pub latest_hash: Option<String>,
/// If the repo is cloned, will be the latest commit message.
pub latest_message: Option<String>,
}
#[typeshare]
#[derive(
Debug, Clone, Copy, Default, Serialize, Deserialize, Display,
)]
pub enum RepoState {
/// Unknown case
#[default]
Unknown,
/// Last clone / pull successful (or never cloned)
Ok,
/// Last clone / pull failed
Failed,
/// Currently cloning
Cloning,
/// Currently pullling
Pulling,
}
#[typeshare]
@@ -29,6 +57,7 @@ pub type Repo = Resource<RepoConfig, RepoInfo>;
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct RepoInfo {
/// When repo was last pulled
pub last_pulled_at: I64,
}
@@ -57,6 +86,11 @@ pub struct RepoConfig {
#[partial_default(default_branch())]
pub branch: String,
/// Optionally set a specific commit hash.
#[serde(default)]
#[builder(default)]
pub commit: String,
/// The github account to use to clone.
/// It must be available in the server's periphery config.
#[serde(default)]
@@ -74,6 +108,12 @@ pub struct RepoConfig {
#[serde(default)]
#[builder(default)]
pub on_pull: SystemCommand,
/// Whether incoming webhooks actually trigger action.
#[serde(default = "default_webhook_enabled")]
#[builder(default = "default_webhook_enabled()")]
#[partial_default(default_webhook_enabled())]
pub webhook_enabled: bool,
}
impl RepoConfig {
@@ -86,10 +126,16 @@ fn default_branch() -> String {
String::from("main")
}
fn default_webhook_enabled() -> bool {
true
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)]
pub struct RepoActionState {
/// Whether repo currently cloning
pub cloning: bool,
/// Whether repo currently pulling
pub pulling: bool,
}
@@ -101,6 +147,7 @@ pub type RepoQuery = ResourceQuery<RepoQuerySpecifics>;
Serialize, Deserialize, Debug, Clone, Default, DefaultBuilder,
)]
pub struct RepoQuerySpecifics {
/// Filter builds by their repo.
pub repos: Vec<String>,
}

View File

@@ -20,8 +20,8 @@ pub type ServerListItem = ResourceListItem<ServerListItemInfo>;
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ServerListItemInfo {
/// The server's status.
pub status: ServerStatus,
/// The server's state.
pub state: ServerState,
/// Region of the server.
pub region: String,
/// Whether server is configured to send unreachable alerts.
@@ -208,7 +208,7 @@ pub struct ServerActionState {
Copy,
Default,
)]
pub enum ServerStatus {
pub enum ServerState {
/// Server is unreachable.
#[default]
NotOk,

View File

@@ -11,5 +11,6 @@
},
"devDependencies": {
"typescript": "^5.1.6"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -43,7 +43,7 @@ export type ReadResponses = {
GetServersSummary: Types.GetServersSummaryResponse;
GetServer: Types.GetServerResponse;
ListServers: Types.ListServersResponse;
GetServerStatus: Types.GetServerStatusResponse;
GetServerState: Types.GetServerStateResponse;
GetPeripheryVersion: Types.GetPeripheryVersionResponse;
GetSystemInformation: Types.GetSystemInformationResponse;
GetDockerContainers: Types.GetDockerContainersResponse;

View File

@@ -1,5 +1,5 @@
/*
Generated by typeshare 1.7.0
Generated by typeshare 1.9.2
*/
/** JSON containing an authentication token. */
@@ -155,9 +155,9 @@ export type AlertData =
/** The server name */
server_name: string;
/** The previous container state */
from: DockerContainerState;
from: DeploymentState;
/** The current container state */
to: DockerContainerState;
to: DeploymentState;
}}
/** An AWS builder failed to terminate. */
| { type: "AwsBuilderTerminationFailed", data: {
@@ -285,6 +285,8 @@ export interface BuildConfig {
repo?: string;
/** The branch of the repo. */
branch: string;
/** Optionally set a specific commit hash. */
commit?: string;
/**
* The github account used to clone (used to access private repos).
* Empty string is public clone (only public repos).
@@ -317,6 +319,8 @@ export interface BuildConfig {
extra_args?: string[];
/** Whether to use buildx to build (eg `docker buildx build ...`) */
use_buildx?: boolean;
/** Whether incoming webhooks actually trigger action. */
webhook_enabled: boolean;
}
export interface BuildInfo {
@@ -327,6 +331,17 @@ export type Build = Resource<BuildConfig, BuildInfo>;
export type GetBuildResponse = Build;
export enum BuildState {
/** Last build successful (or never built) */
Ok = "Ok",
/** Last build failed */
Failed = "Failed",
/** Currently building */
Building = "Building",
/** Other case */
Unknown = "Unknown",
}
export interface BuildListItemInfo {
/** Unix timestamp in milliseconds of last build */
last_built_at: I64;
@@ -336,6 +351,8 @@ export interface BuildListItemInfo {
repo: string;
/** The branch of the repo */
branch: string;
/** State of the build. Reflects whether most recent build successful. */
state: BuildState;
}
export type BuildListItem = ResourceListItem<BuildListItemInfo>;
@@ -488,7 +505,15 @@ export type Deployment = Resource<DeploymentConfig, undefined>;
export type GetDeploymentResponse = Deployment;
export enum DockerContainerState {
/**
* Variants de/serialized from/to snake_case.
*
* Eg.
* - NotDeployed -> not_deployed
* - Restarting -> restarting
* - Running -> running.
*/
export enum DeploymentState {
Unknown = "unknown",
NotDeployed = "not_deployed",
Created = "created",
@@ -501,8 +526,8 @@ export enum DockerContainerState {
}
export interface DeploymentListItemInfo {
/** The state of the docker container. */
state: DockerContainerState;
/** The state of the deployment / underlying docker container. */
state: DeploymentState;
/** The status of the docker container (eg. up 12 hours, exited 5 minutes ago.) */
status?: string;
/** The image attached to the deployment. */
@@ -628,6 +653,8 @@ export interface ProcedureConfig {
procedure_type?: ProcedureType;
/** The executions to be run by the procedure. */
executions?: EnabledExecution[];
/** Whether incoming webhooks actually trigger action. */
webhook_enabled: boolean;
}
export type Procedure = Resource<ProcedureConfig, undefined>;
@@ -655,6 +682,8 @@ export interface RepoConfig {
repo?: string;
/** The repo branch. */
branch: string;
/** Optionally set a specific commit hash. */
commit?: string;
/**
* The github account to use to clone.
* It must be available in the server's periphery config.
@@ -670,9 +699,12 @@ export interface RepoConfig {
* The path is relative to the root of the repo.
*/
on_pull?: SystemCommand;
/** Whether incoming webhooks actually trigger action. */
webhook_enabled: boolean;
}
export interface RepoInfo {
/** When repo was last pulled */
last_pulled_at: I64;
}
@@ -680,10 +712,32 @@ export type Repo = Resource<RepoConfig, RepoInfo>;
export type GetRepoResponse = Repo;
export enum RepoState {
/** Unknown case */
Unknown = "Unknown",
/** Last clone / pull successful (or never cloned) */
Ok = "Ok",
/** Last clone / pull failed */
Failed = "Failed",
/** Currently cloning */
Cloning = "Cloning",
/** Currently pullling */
Pulling = "Pulling",
}
export interface RepoListItemInfo {
/** Repo last cloned / pulled timestamp in ms. */
last_pulled_at: I64;
/** The configured github repo */
repo: string;
/** The configured branch */
branch: string;
/** The repo state */
state: RepoState;
/** If the repo is cloned, will be the latest short commit hash. */
latest_hash?: string;
/** If the repo is cloned, will be the latest commit message. */
latest_message?: string;
}
export type RepoListItem = ResourceListItem<RepoListItemInfo>;
@@ -691,7 +745,9 @@ export type RepoListItem = ResourceListItem<RepoListItemInfo>;
export type ListReposResponse = RepoListItem[];
export interface RepoActionState {
/** Whether repo currently cloning */
cloning: boolean;
/** Whether repo currently pulling */
pulling: boolean;
}
@@ -749,7 +805,7 @@ export type Server = Resource<ServerConfig, undefined>;
export type GetServerResponse = Server;
export enum ServerStatus {
export enum ServerState {
/** Server is unreachable. */
NotOk = "NotOk",
/** Server health check passing. */
@@ -759,8 +815,8 @@ export enum ServerStatus {
}
export interface ServerListItemInfo {
/** The server's status. */
status: ServerStatus;
/** The server's state. */
state: ServerState;
/** Region of the server. */
region: string;
/** Whether server is configured to send unreachable alerts. */
@@ -877,7 +933,7 @@ export interface ContainerSummary {
/** The docker labels on the container. */
labels: Record<string, string>;
/** The state of the container, like `running` or `not_deployed` */
state: DockerContainerState;
state: DeploymentState;
/** The status string of the docker container. */
status?: string;
}
@@ -1266,6 +1322,7 @@ export type ProcedureQuery = ResourceQuery<ProcedureQuerySpecifics>;
export type _PartialRepoConfig = Partial<RepoConfig>;
export interface RepoQuerySpecifics {
/** Filter builds by their repo. */
repos: string[];
}
@@ -1750,7 +1807,7 @@ export interface GetDeploymentContainer {
/** Response for [GetDeploymentContainer]. */
export interface GetDeploymentContainerResponse {
state: DockerContainerState;
state: DeploymentState;
container?: ContainerSummary;
}
@@ -1992,16 +2049,16 @@ export interface ListServers {
query?: ServerQuery;
}
/** Get the status of the target server. Response: [GetServerStatusResponse]. */
export interface GetServerStatus {
/** Get the state of the target server. Response: [GetServerStateResponse]. */
export interface GetServerState {
/** Id or name */
server: string;
}
/** The status for [GetServerStatus]. */
export interface GetServerStatusResponse {
/** The response for [GetServerState]. */
export interface GetServerStateResponse {
/** The server status. */
status: ServerStatus;
status: ServerState;
}
/** Get current action state for the servers. Response: [ServerActionState]. */
@@ -3016,6 +3073,7 @@ export interface CloneArgs {
name: string;
repo?: string;
branch?: string;
commit?: string;
on_clone?: SystemCommand;
on_pull?: SystemCommand;
github_account?: string;
@@ -3141,7 +3199,7 @@ export type ReadRequest =
| { type: "GetServersSummary", params: GetServersSummary }
| { type: "GetServer", params: GetServer }
| { type: "ListServers", params: ListServers }
| { type: "GetServerStatus", params: GetServerStatus }
| { type: "GetServerState", params: GetServerState }
| { type: "GetPeripheryVersion", params: GetPeripheryVersion }
| { type: "GetDockerContainers", params: GetDockerContainers }
| { type: "GetDockerImages", params: GetDockerImages }

View File

@@ -4,6 +4,18 @@ use monitor_client::entities::{
use resolver_api::derive::Request;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Request)]
#[response(GetLatestCommitResponse)]
pub struct GetLatestCommit {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GetLatestCommitResponse {
pub hash: String,
pub message: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, Request)]
#[response(Vec<Log>)]
pub struct CloneRepo {
@@ -19,6 +31,7 @@ pub struct CloneRepo {
pub struct PullRepo {
pub name: String,
pub branch: Option<String>,
pub commit: Option<String>,
pub on_pull: Option<SystemCommand>,
}

View File

@@ -13,7 +13,7 @@
<title>Monitor</title>
</head>
<body class="min-h-screen pb-12">
<body class="min-h-screen">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

View File

@@ -1,7 +0,0 @@
#!/bin/sh
## Install, build, and setup yarn link on client.
yarn build-client
## Link, install, build frontend
yarn link @monitor/client && yarn && yarn build

View File

@@ -19,43 +19,46 @@
"@radix-ui/react-popover": "1.0.7",
"@radix-ui/react-progress": "1.0.3",
"@radix-ui/react-select": "2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "1.0.2",
"@radix-ui/react-switch": "1.0.3",
"@radix-ui/react-tabs": "1.0.4",
"@radix-ui/react-toast": "1.1.5",
"@tanstack/react-query": "5.29.2",
"@radix-ui/react-toggle": "^1.0.3",
"@radix-ui/react-toggle-group": "^1.0.4",
"@tanstack/react-query": "5.35.1",
"@tanstack/react-table": "8.16.0",
"ansi-to-html": "0.7.2",
"class-variance-authority": "0.7.0",
"clsx": "2.1.0",
"clsx": "2.1.1",
"cmdk": "1.0.0",
"jotai": "2.8.0",
"lightweight-charts": "4.1.3",
"lucide-react": "0.371.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"lightweight-charts": "4.1.4",
"lucide-react": "0.378.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-minimal-pie-chart": "8.4.0",
"react-router-dom": "6.22.3",
"reconnecting-websocket": "4.4.0",
"react-router-dom": "6.23.0",
"sanitize-html": "2.13.0",
"tailwind-merge": "2.2.2",
"tailwind-merge": "2.3.0",
"tailwindcss-animate": "1.0.7"
},
"devDependencies": {
"@types/react": "18.2.79",
"@types/react-dom": "18.2.25",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.0",
"@types/sanitize-html": "2.11.0",
"@typescript-eslint/eslint-plugin": "7.7.0",
"@typescript-eslint/parser": "7.7.0",
"@typescript-eslint/eslint-plugin": "7.8.0",
"@typescript-eslint/parser": "7.8.0",
"@vitejs/plugin-react": "4.2.1",
"autoprefixer": "10.4.19",
"eslint": "9.0.0",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-react-refresh": "0.4.6",
"eslint": "9.2.0",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.7",
"postcss": "8.4.38",
"tailwindcss": "3.4.3",
"typescript": "5.4.5",
"vite": "5.2.10",
"vite": "5.2.11",
"vite-tsconfig-paths": "4.3.2"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -30,6 +30,7 @@ export const ConfigLayout = <
onConfirm,
onReset,
selector,
titleOther,
}: {
config: Partial<T>;
children: ReactNode;
@@ -37,33 +38,40 @@ export const ConfigLayout = <
onConfirm: () => void;
onReset: () => void;
selector?: ReactNode;
}) => (
<Section
title="Config"
icon={<Settings className="w-4 h-4" />}
actions={
<div className="flex gap-2">
{selector}
<Button
variant="outline"
onClick={onReset}
disabled={disabled || (config ? !Object.keys(config).length : true)}
>
<History className="w-4 h-4" />
</Button>
{Object.keys(config).length ? (
<ConfirmUpdate
content={JSON.stringify(config, null, 2)}
onConfirm={onConfirm}
disabled={disabled}
/>
) : null}
</div>
}
>
{children}
</Section>
);
titleOther?: ReactNode;
}) => {
const titleProps = titleOther
? { titleOther }
: { title: "Config", icon: <Settings className="w-4 h-4" /> };
return (
<Section
{...titleProps}
actions={
<div className="flex gap-2">
{selector}
<Button
variant="outline"
onClick={onReset}
disabled={disabled || (config ? !Object.keys(config).length : true)}
>
<History className="w-4 h-4" />
</Button>
{Object.keys(config).length ? (
<ConfirmUpdate
content={JSON.stringify(config, null, 2)}
onConfirm={onConfirm}
disabled={disabled}
/>
) : null}
</div>
}
>
{children}
</Section>
);
};
type PrimitiveConfigArgs = { placeholder: string };
export const Config = <T,>({
config,
@@ -73,6 +81,7 @@ export const Config = <T,>({
onSave,
components,
selector,
titleOther,
}: {
config: T;
update: Partial<T>;
@@ -80,6 +89,7 @@ export const Config = <T,>({
set: React.Dispatch<SetStateAction<Partial<T>>>;
onSave: () => Promise<void>;
selector?: ReactNode;
titleOther?: ReactNode;
components: Record<
string,
Record<
@@ -87,6 +97,7 @@ export const Config = <T,>({
{
[K in keyof Partial<T>]:
| boolean
| PrimitiveConfigArgs
| ((value: T[K], set: (value: Partial<T>) => void) => ReactNode);
}
>
@@ -96,6 +107,7 @@ export const Config = <T,>({
return (
<ConfigLayout
titleOther={titleOther}
config={update}
disabled={disabled}
onConfirm={async () => {
@@ -144,7 +156,7 @@ export const Config = <T,>({
<CardTitle>{snake_case_to_upper_space_case(k)}</CardTitle>
</CardHeader>
)}
<CardContent className="flex flex-col gap-4 mt-4">
<CardContent className="flex flex-col gap-2 mt-4">
<ConfigAgain
config={config}
update={update}
@@ -176,6 +188,7 @@ export const ConfigAgain = <
components: Partial<{
[K in keyof T extends string ? keyof T : never]:
| boolean
| PrimitiveConfigArgs
| ((value: T[K], set: (value: Partial<T>) => void) => ReactNode);
}>;
set: (value: Partial<T>) => void;
@@ -185,7 +198,12 @@ export const ConfigAgain = <
{keys(components).map((key) => {
const component = components[key];
const value = update[key] ?? config[key];
if (component === true) {
if (typeof component === "function") {
return (
<Fragment key={key.toString()}>{component?.(value, set)}</Fragment>
);
} else if (typeof component === "object" || component === true) {
const args = (typeof component === "object") ? component as PrimitiveConfigArgs : undefined;
switch (typeof value) {
case "string":
return (
@@ -195,6 +213,7 @@ export const ConfigAgain = <
value={value}
onChange={(value) => set({ [key]: value } as Partial<T>)}
disabled={disabled}
placeholder={args?.placeholder}
/>
);
case "number":
@@ -207,6 +226,7 @@ export const ConfigAgain = <
set({ [key]: Number(value) } as Partial<T>)
}
disabled={disabled}
placeholder={args?.placeholder}
/>
);
case "boolean":
@@ -225,9 +245,7 @@ export const ConfigAgain = <
} else if (component === false) {
return <Fragment key={key.toString()} />;
}
return (
<Fragment key={key.toString()}>{component?.(value, set)}</Fragment>
);
})}
</>
);

View File

@@ -23,7 +23,7 @@ import {
DialogTrigger,
} from "@ui/dialog";
import { snake_case_to_upper_space_case } from "@lib/formatting";
import { ConfirmButton } from "@components/util";
import { ConfirmButton, TextUpdateMenu } from "@components/util";
export const ConfigItem = ({
label,
@@ -34,15 +34,18 @@ export const ConfigItem = ({
children: ReactNode;
className?: string;
}) => (
<div
className={cn(
"flex justify-between items-center border-b pb-2 min-h-[60px] last:border-none last:pb-0",
className
)}
>
{label && <div>{snake_case_to_upper_space_case(label)}</div>}
{children}
</div>
<>
<div
className={cn(
"flex justify-between items-center min-h-[60px]",
className
)}
>
{label && <div>{snake_case_to_upper_space_case(label)}</div>}
{children}
</div>
<div className="w-full h-0 border-b last:hidden" />
</>
);
export const ConfigInput = ({
@@ -224,7 +227,10 @@ export const InputList = <T extends { [key: string]: unknown }>({
disabled: boolean;
set: (update: Partial<T>) => void;
}) => (
<ConfigItem label={field as string} className="items-start">
<ConfigItem
label={field as string}
className={values.length > 0 ? "items-start" : undefined}
>
<div className="flex flex-col gap-4 w-full max-w-[400px]">
{values.map((arg, i) => (
<div className="w-full flex gap-4" key={i}>
@@ -239,7 +245,7 @@ export const InputList = <T extends { [key: string]: unknown }>({
/>
{!disabled && (
<Button
variant="outline"
variant="secondary"
onClick={() =>
set({
[field]: [...values.filter((_, idx) => idx !== i)],
@@ -254,7 +260,7 @@ export const InputList = <T extends { [key: string]: unknown }>({
{!disabled && (
<Button
variant="outline"
variant="secondary"
onClick={() => set({ [field]: [...values, ""] } as Partial<T>)}
>
Add {snake_case_to_upper_space_case(field as string).slice(0, -1)}
@@ -319,27 +325,23 @@ export const SystemCommand = ({
}) => {
return (
<ConfigItem label={label} className="items-start">
<div className="grid gap-2">
<div className="flex gap-4 items-center justify-end">
Path:
<Input
placeholder="command working directory"
value={value?.path}
className="w-[300px]"
onChange={(e) => set({ ...(value || {}), path: e.target.value })}
disabled={disabled}
/>
</div>
<div className="flex gap-4 items-center justify-end">
Command:
<Input
placeholder="shell command"
value={value?.command}
className="w-[300px]"
onChange={(e) => set({ ...(value || {}), command: e.target.value })}
disabled={disabled}
/>
</div>
<div className="grid gap-y-4 gap-x-8 grid-cols-[auto_1fr] grid-rows-1 items-center">
Path:
<Input
placeholder="Command working directory"
value={value?.path}
className="w-full"
onChange={(e) => set({ ...(value || {}), path: e.target.value })}
disabled={disabled}
/>
Command:
<TextUpdateMenu
title="Update Command"
placeholder="Set shell command"
value={value?.command}
onUpdate={(command) => set({ ...(value || {}), command })}
triggerClassName="w-[300px]"
/>
</div>
</ConfigItem>
);

View File

@@ -16,19 +16,29 @@ import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@ui/card";
import { ResourceTags } from "./tags";
import { Topbar } from "./topbar";
import { usableResourcePath } from "@lib/utils";
import { Sidebar } from "./sidebar";
export const Layout = () => {
return (
<>
<Topbar />
<Outlet />
<div className="flex gap-2">
<Sidebar />
<div className="w-full h-[calc(100vh-70px)] overflow-y-auto">
<div className="pb-24">
<Outlet />
</div>
</div>
</div>
</>
);
};
interface PageProps {
title?: ReactNode;
icon?: ReactNode;
titleRight?: ReactNode;
titleOther?: ReactNode;
children?: ReactNode;
subtitle?: ReactNode;
actions?: ReactNode;
@@ -36,16 +46,19 @@ interface PageProps {
export const Page = ({
title,
icon,
titleRight,
titleOther,
subtitle,
actions,
children,
}: PageProps) => (
<div className="flex flex-col gap-12 container py-8">
{(title || subtitle || actions) && (
<div className="flex flex-col gap-10 container py-8">
{(title || icon || subtitle || actions) && (
<div className="flex flex-col gap-6 lg:flex-row lg:gap-0 lg:items-start justify-between">
<div className="flex flex-col gap-4">
<div className="flex gap-4 items-center">
{icon}
<h1 className="text-4xl">{title}</h1>
{titleRight}
</div>
@@ -54,26 +67,28 @@ export const Page = ({
{actions}
</div>
)}
{titleOther}
{children}
</div>
);
interface SectionProps {
title?: ReactNode;
children?: ReactNode;
icon?: ReactNode;
titleOther?: ReactNode;
children?: ReactNode;
actions?: ReactNode;
}
export const Section = ({ title, icon, actions, children }: SectionProps) => (
export const Section = ({ title, icon, titleOther, actions, children }: SectionProps) => (
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between">
{(title || icon) && (
{(title || icon) ? (
<div className="flex items-center gap-2 text-muted-foreground">
{icon}
<h2 className="text-xl">{title}</h2>
{title && <h2 className="text-xl">{title}</h2>}
</div>
)}
) : titleOther}
{actions}
</div>
{children}

View File

@@ -94,6 +94,7 @@ export const AlerterComponents: RequiredResourceComponents = {
name: (id) => useAlerter(id)?.name,
Icon: () => <AlarmClock className="w-4 h-4" />,
BigIcon: () => <AlarmClock className="w-8 h-8" />,
Status: {},

View File

@@ -1,24 +1,15 @@
import { useRead, useTagsFilter } from "@lib/hooks";
import { useFilterResources, useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { ResourceLink } from "../common";
import { TagsWithBadge } from "@components/tags";
export const AlerterTable = ({ search }: { search?: string }) => {
const tags = useTagsFilter();
const alerters = useRead("ListAlerters", {}).data;
const searchSplit = search?.split(" ") || [];
const filtered = useFilterResources(alerters, search);
return (
<DataTable
tableKey="alerters"
data={
alerters?.filter(
(resource) =>
tags.every((tag) => resource.tags.includes(tag)) &&
(searchSplit.length > 0
? searchSplit.every((search) => resource.name.includes(search))
: true)
) ?? []
}
data={filtered}
columns={[
{
accessorKey: "name",

View File

@@ -98,8 +98,9 @@ export const BuildConfig = ({ id }: { id: string }) => {
),
},
git: {
repo: true,
branch: true,
repo: { placeholder: "Enter repo" },
branch: { placeholder: "Enter branch" },
commit: { placeholder: "Enter specific commit hash. Optional." },
github_account: (account, set) => (
<AccountSelector
id={update.builder_id ?? config.builder_id ?? undefined}
@@ -161,6 +162,7 @@ export const BuildConfig = ({ id }: { id: string }) => {
<CopyGithubWebhook path={`/build/${id}`} />
</ConfigItem>
),
webhook_enabled: true,
},
},
"Build Args": {
@@ -213,7 +215,10 @@ const ExtraArgs = ({
disabled: boolean;
}) => {
return (
<ConfigItem label="Extra Args" className="items-start">
<ConfigItem
label="Extra Args"
className={args.length > 0 ? "items-start" : undefined}
>
<div className="flex flex-col gap-4 w-full max-w-[400px]">
{args.map((arg, i) => (
<div className="w-full flex gap-4" key={i}>

View File

@@ -1,15 +0,0 @@
import { fill_color_class_by_intention } from "@lib/color";
import { useRead } from "@lib/hooks";
import { Hammer } from "lucide-react";
export const IconStrictId = ({ id }: { id: string }) => {
const building = useRead(
"GetBuildActionState",
{ build: id },
{ refetchInterval: 5000 }
).data?.building;
const className = building
? "w-4 h-4 " + fill_color_class_by_intention("Good")
: "w-4 h-4";
return <Hammer className={className} />;
};

View File

@@ -8,11 +8,23 @@ import { BuildTable } from "./table";
import { DeleteResource, NewResource } from "../common";
import { DeploymentTable } from "../deployment/table";
import { RunBuild } from "./actions";
import { IconStrictId } from "./icon";
import {
bg_color_class_by_intention,
build_state_intention,
fill_color_class_by_intention,
} from "@lib/color";
import { Card, CardHeader } from "@ui/card";
import { cn } from "@lib/utils";
const useBuild = (id?: string) =>
useRead("ListBuilds", {}).data?.find((d) => d.id === id);
const BuildIcon = ({ id, size }: { id?: string; size: number }) => {
const state = useBuild(id)?.info.state;
const color = fill_color_class_by_intention(build_state_intention(state));
return <Hammer className={cn(`w-${size} h-${size}`, state && color)} />;
};
export const BuildComponents: RequiredResourceComponents = {
Dashboard: BuildChart,
@@ -23,12 +35,20 @@ export const BuildComponents: RequiredResourceComponents = {
Name: ({ id }) => <>{useBuild(id)?.name}</>,
name: (id) => useBuild(id)?.name,
Icon: ({ id }) => {
if (id) return <IconStrictId id={id} />;
else return <Hammer className="w-4 h-4" />;
},
Icon: ({ id }) => <BuildIcon id={id} size={4} />,
BigIcon: ({ id }) => <BuildIcon id={id} size={8} />,
Status: {},
Status: {
State: ({ id }) => {
let state = useBuild(id)?.info.state;
const color = bg_color_class_by_intention(build_state_intention(state));
return (
<Card className={cn("w-fit", color)}>
<CardHeader className="py-0 px-2">{state}</CardHeader>
</Card>
);
},
},
Info: {
Repo: ({ id }) => {

View File

@@ -1,25 +1,17 @@
import { TagsWithBadge } from "@components/tags";
import { useRead, useTagsFilter } from "@lib/hooks";
import { useFilterResources, useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { fmt_date_with_minutes, fmt_version } from "@lib/formatting";
import { fmt_version } from "@lib/formatting";
import { ResourceLink } from "../common";
import { BuildComponents } from ".";
export const BuildTable = ({ search }: { search?: string }) => {
const builds = useRead("ListBuilds", {}).data;
const tags = useTagsFilter();
const searchSplit = search?.split(" ") || [];
const filtered = useFilterResources(builds, search)
return (
<DataTable
tableKey="builds"
data={
builds?.filter(
(resource) =>
tags.every((tag) => resource.tags.includes(tag)) &&
(searchSplit.length > 0
? searchSplit.every((search) => resource.name.includes(search))
: true)
) ?? []
}
data={filtered}
columns={[
{
accessorKey: "name",
@@ -39,17 +31,13 @@ export const BuildTable = ({ search }: { search?: string }) => {
),
},
{
accessorKey: "info.last_built_at",
accessorKey: "info.state",
header: ({ column }) => (
<SortableHeader column={column} title="Last Built" />
<SortableHeader column={column} title="State" />
),
cell: ({ row }) => (
<BuildComponents.Status.State id={row.original.id} />
),
accessorFn: ({ info: { last_built_at } }) => {
if (last_built_at > 0) {
return fmt_date_with_minutes(new Date(last_built_at));
} else {
return "never";
}
},
},
{
header: "Tags",

View File

@@ -106,6 +106,7 @@ export const BuilderComponents: RequiredResourceComponents = {
name: (id) => useBuilder(id)?.name,
Icon: () => <Factory className="w-4 h-4" />,
BigIcon: () => <Factory className="w-8 h-8" />,
Status: {},

View File

@@ -1,25 +1,16 @@
import { useRead, useTagsFilter } from "@lib/hooks";
import { useFilterResources, useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { ResourceLink } from "../common";
import { TagsWithBadge } from "@components/tags";
import { BuilderInstanceType } from ".";
export const BuilderTable = ({ search }: { search?: string }) => {
const tags = useTagsFilter();
const builders = useRead("ListBuilders", {}).data;
const searchSplit = search?.split(" ") || [];
const filtered = useFilterResources(builders, search);
return (
<DataTable
tableKey="builders"
data={
builders?.filter(
(resource) =>
tags.every((tag) => resource.tags.includes(tag)) &&
(searchSplit.length > 0
? searchSplit.every((search) => resource.name.includes(search))
: true)
) ?? []
}
data={filtered}
columns={[
{
accessorKey: "name",

View File

@@ -284,7 +284,10 @@ export const LabelsConfig = ({
set: (input: Partial<Types.DeploymentConfig | Types.BuildConfig>) => void;
disabled: boolean;
}) => (
<ConfigItem label="Labels" className="items-start">
<ConfigItem
label="Labels"
className={labels.length > 0 ? "items-start" : undefined}
>
<DoubleInput
disabled={disabled}
values={labels}
@@ -320,8 +323,27 @@ export const CopyGithubWebhook = ({
const url = base_url + "/listener/github" + path;
return (
<div className="flex gap-2 items-center">
<Input className="w-[400px] max-w-[70vw]" value={url} disabled />
<Input className="w-[400px] max-w-[70vw]" value={url} readOnly />
<CopyButton content={url} />
</div>
);
};
export const ServerSelector = ({
selected,
set,
disabled,
}: {
selected: string | undefined;
set: (input: Partial<Types.DeploymentConfig>) => void;
disabled: boolean;
}) => (
<ConfigItem label="Server">
<ResourceSelector
type="Server"
selected={selected}
onSelect={(server_id) => set({ server_id })}
disabled={disabled}
/>
</ConfigItem>
);

View File

@@ -13,7 +13,6 @@ import {
SelectItem,
SelectTrigger,
} from "@ui/select";
import { DockerContainerState } from "@monitor/client/dist/types";
interface DeploymentId {
id: string;
@@ -48,8 +47,8 @@ export const DeployContainer = ({ id }: DeploymentId) => {
if (!deployment) return null;
const deployed =
deployment_item?.info.state !== DockerContainerState.NotDeployed &&
deployment_item?.info.state !== DockerContainerState.Unknown;
deployment_item?.info.state !== Types.DeploymentState.NotDeployed &&
deployment_item?.info.state !== Types.DeploymentState.Unknown;
if (deployed) {
return (
@@ -161,15 +160,15 @@ export const StartOrStopContainer = ({ id }: DeploymentId) => {
const state = deployment?.info.state;
if (
state === Types.DockerContainerState.NotDeployed ||
state === Types.DockerContainerState.Unknown
state === Types.DeploymentState.NotDeployed ||
state === Types.DeploymentState.Unknown
) {
return null;
}
if (
state === Types.DockerContainerState.Running ||
state === Types.DockerContainerState.Restarting
state === Types.DeploymentState.Running ||
state === Types.DeploymentState.Restarting
) {
return <StopContainer id={id} />;
}
@@ -202,7 +201,7 @@ export const RemoveContainer = ({ id }: DeploymentId) => {
const pending = isPending || removing;
if (!deployment) return null;
if (state === Types.DockerContainerState.NotDeployed) return null;
if (state === Types.DeploymentState.NotDeployed) return null;
return (
<ActionWithDialog

View File

@@ -25,7 +25,10 @@ export const ExtraArgs = ({
disabled: boolean;
}) => {
return (
<ConfigItem label="Extra Args" className="items-start">
<ConfigItem
label="Extra Args"
className={args.length > 0 ? "items-start" : undefined}
>
<div className="flex flex-col gap-4 w-full max-w-[400px]">
{args.map((arg, i) => (
<div className="w-full flex gap-4" key={i}>

View File

@@ -10,7 +10,10 @@ export const PortsConfig = ({
set: (input: Partial<Types.DeploymentConfig>) => void;
disabled: boolean;
}) => (
<ConfigItem label="Ports" className="items-start">
<ConfigItem
label="Ports"
className={ports.length > 0 ? "items-start" : undefined}
>
<DoubleInput
disabled={disabled}
values={ports}

View File

@@ -10,7 +10,10 @@ export const VolumesConfig = ({
set: (input: Partial<Types.DeploymentConfig>) => void;
disabled: boolean;
}) => (
<ConfigItem label="Volumes" className="items-start">
<ConfigItem
label="Volumes"
className={volumes.length > 0 ? "items-start" : undefined}
>
<DoubleInput
disabled={disabled}
inputClassName="w-[300px] max-w-full"

View File

@@ -1,6 +1,6 @@
import { useRead, useWrite } from "@lib/hooks";
import { Types } from "@monitor/client";
import { useState } from "react";
import { ReactNode, useState } from "react";
import { AccountSelector, ConfigItem } from "@components/config/util";
import { ImageConfig } from "./components/image";
import { RestartModeSelector } from "./components/restart";
@@ -15,29 +15,10 @@ import {
TermSignalLabels,
TerminationTimeout,
} from "./components/term-signal";
import { LabelsConfig, ResourceSelector } from "@components/resources/common";
import { LabelsConfig, ServerSelector } from "@components/resources/common";
import { TextUpdateMenu } from "@components/util";
export const ServerSelector = ({
selected,
set,
disabled,
}: {
selected: string | undefined;
set: (input: Partial<Types.DeploymentConfig>) => void;
disabled: boolean;
}) => (
<ConfigItem label="Server">
<ResourceSelector
type="Server"
selected={selected}
onSelect={(server_id) => set({ server_id })}
disabled={disabled}
/>
</ConfigItem>
);
export const DeploymentConfig = ({ id }: { id: string }) => {
export const DeploymentConfig = ({ id, titleOther }: { id: string; titleOther: ReactNode }) => {
const perms = useRead("GetPermissionLevel", {
target: { type: "Deployment", id },
}).data;
@@ -57,6 +38,7 @@ export const DeploymentConfig = ({ id }: { id: string }) => {
return (
<Config
titleOther={titleOther}
disabled={disabled}
config={config}
update={update}
@@ -97,17 +79,6 @@ export const DeploymentConfig = ({ id }: { id: string }) => {
disabled={disabled}
/>
),
command: (value, set) => (
<ConfigItem label="Command">
<TextUpdateMenu
title="Update Command"
placeholder="Set Command"
value={value}
onUpdate={(command) => set({ command })}
triggerClassName="min-w-[300px] max-w-[400px]"
/>
</ConfigItem>
),
network: (value, set) => (
<NetworkModeSelector
server_id={update.server_id ?? config.server_id}
@@ -133,6 +104,17 @@ export const DeploymentConfig = ({ id }: { id: string }) => {
extra_args: (value, set) => (
<ExtraArgs args={value ?? []} set={set} disabled={disabled} />
),
command: (value, set) => (
<ConfigItem label="Command">
<TextUpdateMenu
title="Update Command"
placeholder="Set custom command"
value={value}
onUpdate={(command) => set({ command })}
triggerClassName="min-w-[300px] max-w-[400px]"
/>
</ConfigItem>
),
},
settings: {
send_alerts: true,

View File

@@ -4,7 +4,6 @@ import { RequiredResourceComponents } from "@types";
import { HardDrive, Rocket } from "lucide-react";
import { cn } from "@lib/utils";
import { useServer } from "../server";
import { DeploymentConfig } from "./config";
import {
DeployContainer,
StartOrStopContainer,
@@ -24,12 +23,81 @@ import { DeploymentsChart } from "./dashboard";
import { NewResource, ResourceLink } from "../common";
import { Card, CardHeader } from "@ui/card";
import { RunBuild } from "../build/actions";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs";
import { DeploymentConfig } from "./config";
import { atomWithStorage } from "jotai/utils";
import { useAtom } from "jotai";
const configOrLog = atomWithStorage("config-or-log-v1", "Config");
export const useDeployment = (id?: string) =>
useRead("ListDeployments", {}, { refetchInterval: 5000 }).data?.find(
(d) => d.id === id
);
const ConfigOrLog = ({ id }: { id: string }) => {
const [view, setView] = useAtom(configOrLog);
const state = useDeployment(id)?.info.state;
const logsDisabled =
state === undefined ||
state === Types.DeploymentState.Unknown ||
state === Types.DeploymentState.NotDeployed;
return (
<Tabs
value={logsDisabled ? "Config" : view}
onValueChange={setView}
className="grid gap-4"
>
<TabsContent value="Config">
<DeploymentConfig
id={id}
titleOther={
<TabsList className="justify-start w-fit">
<TabsTrigger value="Config" className="w-[70px]">
Config
</TabsTrigger>
<TabsTrigger
value="Log"
className="w-[70px]"
disabled={logsDisabled}
>
Log
</TabsTrigger>
</TabsList>
}
/>
</TabsContent>
<TabsContent value="Log">
<DeploymentLogs
id={id}
titleOther={
<TabsList className="justify-start w-fit">
<TabsTrigger value="Config" className="w-[70px]">
Config
</TabsTrigger>
<TabsTrigger
value="Log"
className="w-[70px]"
disabled={logsDisabled}
>
Log
</TabsTrigger>
</TabsList>
}
/>
</TabsContent>
</Tabs>
);
};
const DeploymentIcon = ({ id, size }: { id?: string; size: number }) => {
const state = useDeployment(id)?.info.state;
const color = fill_color_class_by_intention(
deployment_state_intention(state)
);
return <Rocket className={cn(`w-${size} h-${size}`, state && color)} />;
};
export const DeploymentComponents: RequiredResourceComponents = {
Dashboard: DeploymentsChart,
@@ -43,18 +111,13 @@ export const DeploymentComponents: RequiredResourceComponents = {
Name: ({ id }) => <>{useDeployment(id)?.name}</>,
name: (id) => useDeployment(id)?.name,
Icon: ({ id }) => {
const state = useDeployment(id)?.info.state;
const color = fill_color_class_by_intention(
deployment_state_intention(state)
);
return <Rocket className={cn("w-4 h-4", state && color)} />;
},
Icon: ({ id }) => <DeploymentIcon id={id} size={4} />,
BigIcon: ({ id }) => <DeploymentIcon id={id} size={8} />,
Status: {
State: ({ id }) => {
const state =
useDeployment(id)?.info.state ?? Types.DockerContainerState.Unknown;
useDeployment(id)?.info.state ?? Types.DeploymentState.Unknown;
const color = bg_color_class_by_intention(
deployment_state_intention(state)
);
@@ -106,9 +169,9 @@ export const DeploymentComponents: RequiredResourceComponents = {
RemoveContainer,
},
Page: { DeploymentLogs },
Page: {},
Config: DeploymentConfig,
Config: ConfigOrLog,
DangerZone: ({ id }) => (
<>

View File

@@ -1,16 +1,14 @@
import { Section } from "@components/layouts";
import { useRead } from "@lib/hooks";
import { Types } from "@monitor/client";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@ui/tabs";
import { Button } from "@ui/button";
import {
TerminalSquare,
AlertOctagon,
RefreshCw,
ChevronDown,
X,
AlertOctagon,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ReactNode, useEffect, useRef, useState } from "react";
import { useDeployment } from ".";
import {
Select,
@@ -20,25 +18,26 @@ import {
SelectTrigger,
SelectValue,
} from "@ui/select";
import sanitizeHtml from "sanitize-html";
import Convert from "ansi-to-html";
import { Input } from "@ui/input";
import { useToast } from "@ui/use-toast";
import { logToHtml } from "@lib/utils";
import { ToggleGroup, ToggleGroupItem } from "@ui/toggle-group";
export const DeploymentLogs = ({ id }: { id: string }) => {
export const DeploymentLogs = ({ id, titleOther }: { id: string; titleOther: ReactNode }) => {
const state = useDeployment(id)?.info.state;
if (
state === undefined ||
state === Types.DockerContainerState.Unknown ||
state === Types.DockerContainerState.NotDeployed
state === Types.DeploymentState.Unknown ||
state === Types.DeploymentState.NotDeployed
) {
return null;
}
return <DeploymentLogsInner id={id} />;
return <DeploymentLogsInner id={id} titleOther={titleOther} />;
};
const DeploymentLogsInner = ({ id }: { id: string }) => {
const DeploymentLogsInner = ({ id, titleOther }: { id: string; titleOther: ReactNode }) => {
const { toast } = useToast();
const [stream, setStream] = useState("stdout");
const [tail, set] = useState("100");
const [terms, setTerms] = useState<string[]>([]);
const [search, setSearch] = useState("");
@@ -61,88 +60,77 @@ const DeploymentLogsInner = ({ id }: { id: string }) => {
const { Log, refetch, stderr } = terms.length
? SearchLogs(id, terms)
: NoSearchLogs(id, tail);
: NoSearchLogs(id, tail, stream);
return (
<Tabs defaultValue="stdout">
<Section
title="Logs"
icon={<TerminalSquare className="w-4 h-4" />}
actions={
<div className="flex gap-2">
{terms.map((term, index) => (
<Button
key={term}
variant="destructive"
onClick={() => setTerms(terms.filter((_, i) => i !== index))}
className="flex gap-2 items-center py-0 px-2"
>
{term}
<X className="w-4 h-h" />
</Button>
))}
<div className="relative">
<Input
placeholder="Search Logs"
value={search}
onChange={(e) => setSearch(e.target.value)}
onBlur={addTerm}
onKeyDown={(e) => {
if (e.key === "Enter") addTerm();
}}
className="w-[300px]"
/>
<Button
variant="ghost"
size="icon"
onClick={clearSearch}
className="absolute right-0 top-1/2 -translate-y-1/2"
>
<X className="w-4 h-4" />
</Button>
</div>
<TabsList>
<TabsTrigger value="stdout">stdout</TabsTrigger>
<TabsTrigger value="stderr">
stderr
{stderr && (
<AlertOctagon className="w-4 h-4 ml-2 stroke-red-500" />
)}
</TabsTrigger>
</TabsList>
<Button variant="secondary" size="icon" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4" />
<Section
titleOther={titleOther}
actions={
<div className="flex gap-2">
{terms.map((term, index) => (
<Button
key={term}
variant="destructive"
onClick={() => setTerms(terms.filter((_, i) => i !== index))}
className="flex gap-2 items-center py-0 px-2"
>
{term}
<X className="w-4 h-h" />
</Button>
<TailLengthSelector
selected={tail}
onSelect={set}
disabled={search.length > 0}
))}
<div className="relative">
<Input
placeholder="Search Logs"
value={search}
onChange={(e) => setSearch(e.target.value)}
onBlur={addTerm}
onKeyDown={(e) => {
if (e.key === "Enter") addTerm();
}}
className="w-[300px]"
/>
<Button
variant="ghost"
size="icon"
onClick={clearSearch}
className="absolute right-0 top-1/2 -translate-y-1/2"
>
<X className="w-4 h-4" />
</Button>
</div>
}
>
{Log}
</Section>
</Tabs>
<ToggleGroup type="single" value={stream} onValueChange={setStream}>
<ToggleGroupItem value="stdout">stdout</ToggleGroupItem>
<ToggleGroupItem value="stderr">
stderr
{stderr && (
<AlertOctagon className="w-4 h-4 ml-2 stroke-red-500" />
)}
</ToggleGroupItem>
</ToggleGroup>
<Button variant="secondary" size="icon" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4" />
</Button>
<TailLengthSelector
selected={tail}
onSelect={set}
disabled={search.length > 0}
/>
</div>
}
>
{Log}
</Section>
);
};
const NoSearchLogs = (id: string, tail: string) => {
const NoSearchLogs = (id: string, tail: string, stream: string) => {
const { data: log, refetch } = useRead(
"GetLog",
{ deployment: id, tail: Number(tail) },
{ refetchInterval: 30000 }
);
return {
Log: (
<>
{["stdout", "stderr"].map((stream) => (
<TabsContent key={stream} className="h-full relative" value={stream}>
<Log log={log} stream={stream as "stdout" | "stderr"} />
</TabsContent>
))}
</>
),
Log: <Log log={log} stream={stream as "stdout" | "stderr"} />,
refetch,
stderr: !!log?.stderr,
};
@@ -221,20 +209,3 @@ const TailLengthSelector = ({
</SelectContent>
</Select>
);
const convert = new Convert();
/**
* Converts the ansi colors in log to html.
* sanitizes incoming log first for any eg. script tags.
* @param log incoming log string
*/
const logToHtml = (log: string) => {
if (!log) return "No log.";
const sanitized = sanitizeHtml(log, {
allowedTags: sanitizeHtml.defaults.allowedTags.filter(
(tag) => tag !== "script"
),
allowedAttributes: sanitizeHtml.defaults.allowedAttributes,
});
return convert.toHtml(sanitized);
};

View File

@@ -1,7 +1,7 @@
import { TagsWithBadge } from "@components/tags";
import { Types } from "@monitor/client";
import { DataTable, SortableHeader } from "@ui/data-table";
import { useRead, useTagsFilter } from "@lib/hooks";
import { useFilterResources, useRead } from "@lib/hooks";
import { ResourceLink } from "../common";
import { DeploymentComponents } from ".";
import { HardDrive } from "lucide-react";
@@ -14,25 +14,16 @@ export const DeploymentTable = ({
deployments: Types.DeploymentListItem[] | undefined;
search?: string;
}) => {
const tags = useTagsFilter();
const searchSplit = search?.split(" ") || [];
const servers = useRead("ListServers", {}).data;
const serverName = useCallback(
(id: string) => servers?.find((server) => server.id === id)?.name,
[servers]
);
const filtered = useFilterResources(deployments, search);
return (
<DataTable
tableKey="deployments"
data={
deployments?.filter(
(resource) =>
tags.every((tag) => resource.tags.includes(tag)) &&
(searchSplit.length > 0
? searchSplit.every((search) => resource.name.includes(search))
: true)
) ?? []
}
data={filtered}
columns={[
{
accessorKey: "name",
@@ -61,14 +52,14 @@ export const DeploymentTable = ({
sortingFn: (a, b) => {
const sa = serverName(a.original.info.server_id);
const sb = serverName(b.original.info.server_id);
if (!sa && !sb) return 0;
if (!sa) return -1;
if (!sb) return 1;
if (sa > sb) return 1;
else if (sa < sb) return -1;
else return 0
else return 0;
},
header: ({ column }) => (
<SortableHeader column={column} title="Server" />

View File

@@ -210,6 +210,57 @@ const ProcedureConfigInner = ({
{
header: "Modify",
cell: ({ row }) => {
const on_move_up = () =>
setConfig({
...config,
executions: executions.map((item, i) => {
// Make sure its not the first row
if (i === row.index && row.index !== 0) {
return executions[row.index - 1];
} else if (i === row.index - 1) {
// Reverse the entry, moving this row "Up"
return executions[row.index];
} else {
return item;
}
}),
});
const on_move_down = () =>
setConfig({
...config,
executions: executions.map((item, i) => {
// The index also cannot be the last index, which cannot be moved down
if (
i === row.index &&
row.index !== executions.length - 1
) {
return executions[row.index + 1];
} else if (i === row.index + 1) {
// Move the row "Down"
return executions[row.index];
} else {
return item;
}
}),
});
const on_insert_above = () =>
setConfig({
...config,
executions: [
...executions.slice(0, row.index),
default_enabled_execution(),
...executions.slice(row.index),
],
});
const on_insert_below = () =>
setConfig({
...config,
executions: [
...executions.slice(0, row.index + 1),
default_enabled_execution(),
...executions.slice(row.index + 1),
],
});
return (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled}>
@@ -228,22 +279,7 @@ const ProcedureConfigInner = ({
{row.index ? (
<DropdownMenuItem
className="flex gap-4 justify-between cursor-pointer"
onClick={() =>
setConfig({
...config,
executions: executions.map((item, i) => {
// Make sure its not the first row
if (i === row.index && row.index !== 0) {
return executions[row.index - 1];
} else if (i === row.index - 1) {
// Reverse the entry, moving this row "Up"
return executions[row.index];
} else {
return item;
}
}),
})
}
onClick={on_move_up}
>
Move Up <ArrowUp className="w-4 h-4" />
</DropdownMenuItem>
@@ -251,25 +287,7 @@ const ProcedureConfigInner = ({
{row.index < executions.length - 1 && (
<DropdownMenuItem
className="flex gap-4 justify-between cursor-pointer"
onClick={() =>
setConfig({
...config,
executions: executions.map((item, i) => {
// The index also cannot be the last index, which cannot be moved down
if (
i === row.index &&
row.index !== executions.length - 1
) {
return executions[row.index + 1];
} else if (i === row.index + 1) {
// Move the row "Down"
return executions[row.index];
} else {
return item;
}
}),
})
}
onClick={on_move_down}
>
Move Down <ArrowDown className="w-4 h-4" />
</DropdownMenuItem>
@@ -277,16 +295,7 @@ const ProcedureConfigInner = ({
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex gap-4 justify-between cursor-pointer"
onClick={() =>
setConfig({
...config,
executions: [
...executions.slice(0, row.index),
default_enabled_execution(),
...executions.slice(row.index),
],
})
}
onClick={on_insert_above}
>
Insert Above{" "}
<div className="flex">
@@ -296,16 +305,7 @@ const ProcedureConfigInner = ({
</DropdownMenuItem>
<DropdownMenuItem
className="flex gap-4 justify-between cursor-pointer"
onClick={() =>
setConfig({
...config,
executions: [
...executions.slice(0, row.index + 1),
default_enabled_execution(),
...executions.slice(row.index + 1),
],
})
}
onClick={on_insert_below}
>
Insert Below{" "}
<div className="flex">
@@ -356,6 +356,18 @@ const ProcedureConfigInner = ({
<CopyGithubWebhook
path={`/procedure/${procedure._id?.$oid!}/${branch}`}
/>
<div className="flex items-center justify-end gap-4 w-full">
<div className="text-muted-foreground">
Enabled:
</div>
<Switch
checked={procedure.config.webhook_enabled}
onCheckedChange={(webhook_enabled) =>
setConfig({ ...config, webhook_enabled })
}
disabled={disabled}
/>
</div>
</div>
</ConfigItem>
</CardHeader>

View File

@@ -39,6 +39,7 @@ export const ProcedureComponents: RequiredResourceComponents = {
name: (id) => useProcedure(id)?.name,
Icon: () => <Route className="w-4" />,
BigIcon: () => <Route className="w-8" />,
Status: {},

View File

@@ -1,24 +1,15 @@
import { useRead, useTagsFilter } from "@lib/hooks";
import { useFilterResources, useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { TagsWithBadge } from "@components/tags";
import { ResourceLink } from "../common";
export const ProcedureTable = ({ search }: { search?: string }) => {
const tags = useTagsFilter();
const procedures = useRead("ListProcedures", {}).data;
const searchSplit = search?.split(" ") || [];
const filtered = useFilterResources(procedures, search);
return (
<DataTable
tableKey="procedures"
data={
procedures?.filter(
(resource) =>
tags.every((tag) => resource.tags.includes(tag)) &&
(searchSplit.length > 0
? searchSplit.every((search) => resource.name.includes(search))
: true)
) ?? []
}
data={filtered}
columns={[
{
accessorKey: "name",

View File

@@ -10,7 +10,7 @@ import {
SelectValue,
} from "@ui/select";
import { useState } from "react";
import { CopyGithubWebhook, ResourceSelector } from "../common";
import { CopyGithubWebhook, ServerSelector } from "../common";
export const RepoConfig = ({ id }: { id: string }) => {
const perms = useRead("GetPermissionLevel", {
@@ -34,18 +34,15 @@ export const RepoConfig = ({ id }: { id: string }) => {
}}
components={{
general: {
general: {
server_id: (selected, set) => (
<ConfigItem label="Server">
<ResourceSelector
type="Server"
selected={selected}
onSelect={(server_id) => set({ server_id })}
/>
</ConfigItem>
"": {
server_id: (value, set) => (
<ServerSelector selected={value} set={set} disabled={disabled} />
),
repo: true,
branch: true,
},
general: {
repo: { placeholder: "Enter repo" },
branch: { placeholder: "Enter branch" },
commit: { placeholder: "Enter specific commit hash. Optional." },
github_account: (value, set) => {
const server_id = update.server_id || config.server_id;
if (server_id) {
@@ -87,6 +84,7 @@ export const RepoConfig = ({ id }: { id: string }) => {
<CopyGithubWebhook path={`/repo/${id}/pull`} />
</ConfigItem>
),
webhook_enabled: true,
},
},
}}

View File

@@ -7,10 +7,20 @@ import { RepoConfig } from "./config";
import { CloneRepo, PullRepo } from "./actions";
import { DeleteResource, NewResource } from "../common";
import { RepoTable } from "./table";
import { bg_color_class_by_intention, fill_color_class_by_intention, repo_state_intention } from "@lib/color";
import { cn } from "@lib/utils";
const useRepo = (id?: string) =>
useRead("ListRepos", {}).data?.find((d) => d.id === id);
const RepoIcon = ({ id, size }: { id?: string; size: number }) => {
const state = useRepo(id)?.info.state;
const color = fill_color_class_by_intention(
repo_state_intention(state)
);
return <GitBranch className={cn(`w-${size} h-${size}`, state && color)} />;
};
export const RepoComponents: RequiredResourceComponents = {
Dashboard: () => {
const repo_count = useRead("ListRepos", {}).data?.length;
@@ -38,9 +48,20 @@ export const RepoComponents: RequiredResourceComponents = {
Name: ({ id }) => <>{useRepo(id)?.name}</>,
name: (id) => useRepo(id)?.name,
Icon: () => <GitBranch className="w-4 h-4" />,
Icon: ({ id }) => <RepoIcon id={id} size={4} />,
BigIcon: ({ id }) => <RepoIcon id={id} size={8} />,
Status: {},
Status: {
State: ({ id }) => {
let state = useRepo(id)?.info.state;
const color = bg_color_class_by_intention(repo_state_intention(state));
return (
<Card className={cn("w-fit", color)}>
<CardHeader className="py-0 px-2">{state}</CardHeader>
</Card>
);
},
},
Info: {
Repo: ({ id }) => {

View File

@@ -1,24 +1,16 @@
import { useRead, useTagsFilter } from "@lib/hooks";
import { useFilterResources, useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { ResourceLink } from "../common";
import { TagsWithBadge } from "@components/tags";
import { RepoComponents } from ".";
export const RepoTable = ({ search }: { search?: string }) => {
const tags = useTagsFilter();
const repos = useRead("ListRepos", {}).data;
const searchSplit = search?.split(" ") || [];
const filtered = useFilterResources(repos, search);
return (
<DataTable
tableKey="repos"
data={
repos?.filter(
(resource) =>
tags.every((tag) => resource.tags.includes(tag)) &&
(searchSplit.length > 0
? searchSplit.every((search) => resource.name.includes(search))
: true)
) ?? []
}
data={filtered}
columns={[
{
accessorKey: "name",
@@ -39,6 +31,15 @@ export const RepoTable = ({ search }: { search?: string }) => {
<SortableHeader column={column} title="Branch" />
),
},
{
accessorKey: "info.state",
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
cell: ({ row }) => (
<RepoComponents.Status.State id={row.original.id} />
),
},
{
header: "Tags",
cell: ({ row }) => {

View File

@@ -66,7 +66,10 @@ export const AwsServerTemplateConfig = ({ id }: { id: string }) => {
),
volumes: (volumes, set) => {
return (
<ConfigItem label="EBS Volumes" className="items-start">
<ConfigItem
label="EBS Volumes"
className={volumes.length > 0 ? "items-start" : undefined}
>
<div className="flex flex-col gap-4 w-full max-w-[400px]">
{volumes.map((_, index) => (
<div
@@ -81,7 +84,7 @@ export const AwsServerTemplateConfig = ({ id }: { id: string }) => {
/>
{!disabled && (
<Button
variant="outline"
variant="secondary"
disabled={disabled}
onClick={() =>
set({
@@ -96,7 +99,7 @@ export const AwsServerTemplateConfig = ({ id }: { id: string }) => {
))}
{!disabled && (
<Button
variant="outline"
variant="secondary"
onClick={() =>
set({
volumes: [...volumes, newVolume(volumes.length)],

View File

@@ -94,6 +94,7 @@ export const ServerTemplateComponents: RequiredResourceComponents = {
name: (id) => useServerTemplate(id)?.name,
Icon: () => <ServerCog className="w-4 h-4" />,
BigIcon: () => <ServerCog className="w-8 h-8" />,
Status: {},

View File

@@ -1,24 +1,15 @@
import { useRead, useTagsFilter } from "@lib/hooks";
import { useFilterResources, useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { ResourceLink } from "../common";
import { TagsWithBadge } from "@components/tags";
export const ServerTemplateTable = ({ search }: { search?: string }) => {
const tags = useTagsFilter();
const server_templates = useRead("ListServerTemplates", {}).data;
const searchSplit = search?.split(" ") || [];
const filtered = useFilterResources(server_templates, search);
return (
<DataTable
tableKey="server-templates"
data={
server_templates?.filter(
(resource) =>
tags.every((tag) => resource.tags.includes(tag)) &&
(searchSplit.length > 0
? searchSplit.every((search) => resource.name.includes(search))
: true)
) ?? []
}
data={filtered}
columns={[
{
accessorKey: "name",

View File

@@ -11,13 +11,14 @@ import {
Scissors,
XOctagon,
AreaChart,
Milestone,
} from "lucide-react";
import { Section } from "@components/layouts";
import { RenameServer } from "./actions";
import {
bg_color_class_by_intention,
fill_color_class_by_intention,
server_status_intention,
server_state_intention,
} from "@lib/color";
import { ServerConfig } from "./config";
import { DeploymentTable } from "../deployment/table";
@@ -34,6 +35,18 @@ export const useServer = (id?: string) =>
(d) => d.id === id
);
const _ServerIcon = ({ id, size }: { id?: string; size: number }) => {
const state = useServer(id)?.info.state;
return (
<ServerIcon
className={cn(
`w-${size} h-${size}`,
id && fill_color_class_by_intention(server_state_intention(state))
)}
/>
);
};
export const ServerComponents: RequiredResourceComponents = {
Dashboard: ServersChart,
@@ -44,38 +57,38 @@ export const ServerComponents: RequiredResourceComponents = {
Name: ({ id }: { id: string }) => <>{useServer(id)?.name}</>,
name: (id) => useServer(id)?.name,
Icon: ({ id }) => {
const status = useServer(id)?.info.status;
return (
<ServerIcon
className={cn(
"w-4 h-4",
id && fill_color_class_by_intention(server_status_intention(status))
)}
/>
);
},
Icon: ({ id }) => <_ServerIcon id={id} size={4} />,
BigIcon: ({ id }) => <_ServerIcon id={id} size={8} />,
Status: {
Status: ({ id }) => {
const status = useServer(id)?.info.status;
const color = bg_color_class_by_intention(
server_status_intention(status)
);
State: ({ id }) => {
const state = useServer(id)?.info.state;
const color = bg_color_class_by_intention(server_state_intention(state));
return (
<Card className={cn("w-fit", color)}>
<CardHeader className="py-0 px-2">
{status === Types.ServerStatus.NotOk ? "Not Ok" : status}
{state === Types.ServerState.NotOk ? "Not Ok" : state}
</CardHeader>
</Card>
);
},
Stats: ({id}) => <Link to={`/servers/${id}/stats`}>
<Button variant="link" className="flex gap-2 items-center p-0">
<AreaChart className="w-4 h-4" />
Stats
</Button>
</Link>
Stats: ({ id }) => (
<Link to={`/servers/${id}/stats`}>
<Button variant="link" className="flex gap-2 items-center p-0">
<AreaChart className="w-4 h-4" />
Stats
</Button>
</Link>
),
Version: ({ id }) => {
const version = useRead("GetPeripheryVersion", { server: id }).data
?.version;
return (
<div className="flex items-center gap-2">
<Milestone className="w-4 h-4" />v{version}
</div>
);
},
},
Info: {
@@ -85,7 +98,7 @@ export const ServerComponents: RequiredResourceComponents = {
useRead(
"GetSystemInformation",
{ server: id },
{ enabled: server ? server.info.status !== "Disabled" : false }
{ enabled: server ? server.info.state !== "Disabled" : false }
).data?.core_count ?? 0;
return (
<Link to={`/servers/${id}/stats`} className="flex gap-2 items-center">
@@ -99,7 +112,7 @@ export const ServerComponents: RequiredResourceComponents = {
const stats = useRead(
"GetSystemStats",
{ server: id },
{ enabled: server ? server.info.status !== "Disabled" : false }
{ enabled: server ? server.info.state !== "Disabled" : false }
).data;
return (
<Link to={`/servers/${id}/stats`} className="flex gap-2 items-center">
@@ -113,7 +126,7 @@ export const ServerComponents: RequiredResourceComponents = {
const stats = useRead(
"GetSystemStats",
{ server: id },
{ enabled: server ? server.info.status !== "Disabled" : false }
{ enabled: server ? server.info.state !== "Disabled" : false }
).data;
const disk_total_gb = stats?.disks.reduce(
(acc, curr) => acc + curr.total_gb,

View File

@@ -1,25 +1,16 @@
import { TagsWithBadge } from "@components/tags";
import { useRead, useTagsFilter } from "@lib/hooks";
import { useFilterResources, useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { ServerComponents } from ".";
import { ResourceLink } from "../common";
export const ServerTable = ({ search }: { search?: string }) => {
const servers = useRead("ListServers", {}).data;
const tags = useTagsFilter();
const searchSplit = search?.split(" ") || [];
const filtered = useFilterResources(servers, search);
return (
<DataTable
tableKey="servers"
data={
servers?.filter(
(resource) =>
tags.every((tag) => resource.tags.includes(tag)) &&
(searchSplit.length > 0
? searchSplit.every((search) => resource.name.includes(search))
: true)
) ?? []
}
data={filtered}
columns={[
{
accessorKey: "name",
@@ -56,16 +47,16 @@ export const ServerTable = ({ search }: { search?: string }) => {
),
},
{
accessorKey: "info.status",
accessorKey: "info.state",
header: ({ column }) => (
<SortableHeader column={column} title="Status" />
<SortableHeader column={column} title="State" />
),
cell: ({
row: {
original: { id },
},
}) => {
return <ServerComponents.Status.Status id={id} />;
return <ServerComponents.Status.State id={id} />;
},
},
{

View File

@@ -0,0 +1,93 @@
import { RESOURCE_TARGETS, cn, usableResourcePath } from "@lib/utils";
import { Button } from "@ui/button";
import { Card, CardContent } from "@ui/card";
import { AlertTriangle, Bell, Box, Home, Tag, UserCircle2 } from "lucide-react";
import { Link, useLocation } from "react-router-dom";
import { ResourceComponents } from "./resources";
import { Separator } from "@ui/separator";
import { ReactNode } from "react";
export const Sidebar = () => {
return (
<Card className="h-fit m-4 hidden lg:flex">
<CardContent className="h-fit grid gap-2 px-6 py-4">
<SidebarLink label="Home" to="/" icon={<Home className="w-4 h-4" />} />
<Separator />
{RESOURCE_TARGETS.map((type) => {
const RTIcon = ResourceComponents[type].Icon;
const name = type === "ServerTemplate" ? "Template" : type;
return (
<SidebarLink
key={type}
label={`${name}s`}
to={`/${usableResourcePath(type)}`}
icon={<RTIcon />}
/>
);
})}
<Separator />
<SidebarLink
label="Alerts"
to="/alerts"
icon={<AlertTriangle className="w-4 h-4" />}
/>
<SidebarLink
label="Updates"
to="/updates"
icon={<Bell className="w-4 h-4" />}
/>
<SidebarLink
label="Tags"
to="/tags"
icon={<Tag className="w-4 h-4" />}
/>
<Separator />
<SidebarLink
label="Api Keys"
to="/keys"
icon={<Box className="w-4 h-4" />}
/>
<SidebarLink
label="Users"
to="/users"
icon={<UserCircle2 className="w-4 h-4" />}
/>
</CardContent>
</Card>
);
};
const SidebarLink = ({
to,
icon,
label,
}: {
to: string;
icon: ReactNode;
label: string;
}) => {
const location = useLocation();
return (
<Link to={to} className="w-full">
<Button
variant="link"
className={cn(
"flex justify-start items-center gap-2 w-full hover:bg-accent",
"/" + location.pathname.split("/")[1] === to && "bg-accent"
)}
>
{icon}
{label}
</Button>
</Link>
);
};

View File

@@ -1,7 +1,8 @@
import { useRead, useResourceParamType } from "@lib/hooks";
import { useRead, useResourceParamType, useUser } from "@lib/hooks";
import { ResourceComponents } from "./resources";
import {
AlertTriangle,
Bell,
Box,
Boxes,
FileQuestion,
@@ -33,7 +34,7 @@ import { UsableResource } from "@types";
import { atomWithStorage } from "jotai/utils";
import { useAtom } from "jotai";
import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover";
import { useState } from "react";
import { ReactNode, useState } from "react";
import {
Command,
CommandEmpty,
@@ -46,10 +47,10 @@ import { ResourceLink } from "./resources/common";
export const Topbar = () => {
return (
<div className="sticky top-0 border-b z-50 w-full bg-card text-card-foreground shadow">
<div className="container flex items-center justify-between py-4 gap-8">
<div className="sticky top-0 h-[70px] border-b z-50 w-full bg-card text-card-foreground shadow flex items-center">
<div className="w-full flex items-center justify-between p-4 gap-8">
<div className="flex items-center gap-4">
<Link to={"/"} className="text-2xl tracking-widest">
<Link to={"/"} className="text-2xl tracking-widest mx-8">
MONITOR
</Link>
<div className="flex gap-2">
@@ -73,6 +74,8 @@ export const Topbar = () => {
};
const PrimaryDropdown = () => {
const user = useUser().data;
const type = useResourceParamType();
const Components = type && ResourceComponents[type];
@@ -89,6 +92,8 @@ const PrimaryDropdown = () => {
? [<Tag className="w-4 h-4" />, "Tags"]
: location.pathname === "/alerts"
? [<AlertTriangle className="w-4 h-4" />, "Alerts"]
: location.pathname === "/updates"
? [<Bell className="w-4 h-4" />, "Updates"]
: location.pathname.split("/")[1] === "user-groups"
? [<Users className="w-4 h-4" />, "User Groups"]
: location.pathname === "/users" ||
@@ -99,7 +104,7 @@ const PrimaryDropdown = () => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild className="lg:hidden">
<Button
variant="ghost"
className="flex justify-start items-center gap-2 w-36 px-3"
@@ -110,12 +115,11 @@ const PrimaryDropdown = () => {
</DropdownMenuTrigger>
<DropdownMenuContent className="w-36" side="bottom">
<DropdownMenuGroup>
<Link to="/">
<DropdownMenuItem className="flex items-center gap-2 cursor-pointer">
<Home className="w-4 h-4" />
Home
</DropdownMenuItem>
</Link>
<DropdownLinkItem
label="Home"
icon={<Home className="w-4 h-4" />}
to="/"
/>
<DropdownMenuSeparator />
@@ -123,51 +127,75 @@ const PrimaryDropdown = () => {
const RTIcon = ResourceComponents[type].Icon;
const name = type === "ServerTemplate" ? "Template" : type;
return (
<Link key={type} to={`/${usableResourcePath(type)}`}>
<DropdownMenuItem className="flex items-center gap-2 cursor-pointer">
<RTIcon />
{name}s
</DropdownMenuItem>
</Link>
<DropdownLinkItem
key={type}
label={`${name}s`}
icon={<RTIcon />}
to={`/${usableResourcePath(type)}`}
/>
);
})}
<DropdownMenuSeparator />
<Link to="/alerts">
<DropdownMenuItem className="flex items-center gap-2 cursor-pointer">
<AlertTriangle className="w-4 h-4" />
Alerts
</DropdownMenuItem>
</Link>
<DropdownLinkItem
label="Alerts"
icon={<AlertTriangle className="w-4 h-4" />}
to="/alerts"
/>
<Link to="/tags">
<DropdownMenuItem className="flex items-center gap-2 cursor-pointer">
<Tag className="w-4 h-4" />
Tags
</DropdownMenuItem>
</Link>
<DropdownLinkItem
label="Updates"
icon={<Bell className="w-4 h-4" />}
to="/updates"
/>
<DropdownLinkItem
label="Tags"
icon={<Tag className="w-4 h-4" />}
to="/tags"
/>
<DropdownMenuSeparator />
<Link to="/keys">
<DropdownMenuItem className="flex items-center gap-2 cursor-pointer">
<Box className="w-4 h-4" />
Api Keys
</DropdownMenuItem>
</Link>
<Link to="/users">
<DropdownMenuItem className="flex items-center gap-2 cursor-pointer">
<UserCircle2 className="w-4 h-4" />
Users
</DropdownMenuItem>
</Link>
<DropdownLinkItem
label="Api Keys"
icon={<Box className="w-4 h-4" />}
to="/keys"
/>
{user?.admin && (
<DropdownLinkItem
label="Users"
icon={<UserCircle2 className="w-4 h-4" />}
to="/users"
/>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
};
const DropdownLinkItem = ({
label,
icon,
to,
}: {
label: string;
icon: ReactNode;
to: string;
}) => {
return (
<Link to={to}>
<DropdownMenuItem className="flex items-center gap-2 cursor-pointer">
{icon}
{label}
</DropdownMenuItem>
</Link>
);
};
export type HomeView = "Dashboard" | "Tree" | "Resources";
export const homeViewAtom = atomWithStorage<HomeView>(
@@ -250,7 +278,7 @@ const ResourcesDropdown = ({ type }: { type: UsableResource }) => {
{selected ? selected.name : `All ${type}s`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] max-h-[400px] p-0" sideOffset={12}>
<PopoverContent className="w-[300px] max-h-[400px] p-0" align="start">
<Command>
<CommandInput
placeholder={`Search ${type}s`}

View File

@@ -19,10 +19,17 @@ import { useRead } from "@lib/hooks";
import { ResourceComponents } from "@components/resources";
import { Link } from "react-router-dom";
import { fmt_duration, fmt_version } from "@lib/formatting";
import { usableResourcePath, version_is_none } from "@lib/utils";
import { sanitizeOnlySpan, usableResourcePath, version_is_none } from "@lib/utils";
import { UsableResource } from "@types";
export const UpdateUser = ({ user_id }: { user_id: string }) => {
if (user_id === "Procedure") return <>{user_id}</>;
if (user_id === "Github") return <>{user_id}</>;
if (user_id === "Auto Redeploy") return <>{user_id}</>;
return <RealUpdateUser user_id={user_id} />;
};
const RealUpdateUser = ({ user_id }: { user_id: string }) => {
const username = useRead("GetUsername", { user_id }).data?.username;
return <>{username || user_id}</>;
};
@@ -85,9 +92,9 @@ export const UpdateDetailsInner = ({
</div>
<div className="flex gap-4">
<Link
to={`/${usableResourcePath(update.target.type as UsableResource)}/${
update.target.id
}`}
to={`/${usableResourcePath(
update.target.type as UsableResource
)}/${update.target.id}`}
>
<div
className="flex items-center gap-2"
@@ -146,17 +153,23 @@ export const UpdateDetailsInner = ({
{log.stdout && (
<div>
<CardDescription>stdout</CardDescription>
<pre className="max-h-[500px] overflow-y-auto">
{log.stdout}
</pre>
<pre
dangerouslySetInnerHTML={{
__html: sanitizeOnlySpan(log.stdout),
}}
className="max-h-[500px] overflow-y-auto"
/>
</div>
)}
{log.stderr && (
<div>
<CardDescription>stdout</CardDescription>
<pre className="max-h-[500px] overflow-y-auto">
{log.stderr}
</pre>
<CardDescription>stderr</CardDescription>
<pre
dangerouslySetInnerHTML={{
__html: sanitizeOnlySpan(log.stderr),
}}
className="max-h-[500px] overflow-y-auto"
/>
</div>
)}
</CardContent>

View File

@@ -58,6 +58,33 @@ const UpdateCard = ({ update }: { update: Types.UpdateListItem }) => {
);
};
export const AllUpdates = () => {
const updates = useRead("ListUpdates", {}).data;
return (
<Section
title="Updates"
icon={<Bell className="w-4 h-4" />}
actions={
<Link
to="/updates"
>
<Button variant="secondary" size="icon">
<ExternalLink className="w-4 h-4" />
</Button>
</Link>
}
>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
{updates?.updates.slice(0, 3).map((update) => (
<UpdateCard update={update} key={update.id} />
))}
</div>
</Section>
);
};
export const ResourceUpdates = ({ type, id }: Types.ResourceTarget) => {
const deployments = useRead("ListDeployments", {}).data;

View File

@@ -48,8 +48,8 @@
--secondary: 220.1 40.1% 90.2%;
--secondary-foreground: 220.1 40.1% 5.2%;
--muted: 220.1 40.1% 98.2%;
--muted-foreground: 220.1 20.1% 40.2%;
--muted: 220.1 40.1% 90.2%;
--muted-foreground: 220.1 20.1% 20.2%;
--accent: 220.1 40.1% 90.2%;
--accent-foreground: 220.1 40.1% 11.2%;

View File

@@ -59,7 +59,7 @@ export const bg_color_class_by_intention = (intention: ColorIntention) => {
case "Neutral":
return "bg-blue-400 dark:bg-blue-700";
case "Warning":
return "bg-orange-400 dark:bg-orange-700";
return "bg-orange-400 dark:bg-orange-500";
case "Critical":
return "bg-red-400 dark:bg-red-700";
case "Unknown":
@@ -86,15 +86,15 @@ export const text_color_class_by_intention = (intention: ColorIntention) => {
}
};
export const server_status_intention: (
status?: Types.ServerStatus
export const server_state_intention: (
status?: Types.ServerState
) => ColorIntention = (status) => {
switch (status) {
case Types.ServerStatus.Ok:
case Types.ServerState.Ok:
return "Good";
case Types.ServerStatus.NotOk:
case Types.ServerState.NotOk:
return "Critical";
case Types.ServerStatus.Disabled:
case Types.ServerState.Disabled:
return "Neutral";
case undefined:
return "None";
@@ -102,28 +102,67 @@ export const server_status_intention: (
};
export const deployment_state_intention: (
state?: Types.DockerContainerState
state?: Types.DeploymentState
) => ColorIntention = (state) => {
switch (state) {
case undefined:
return "None";
case Types.DockerContainerState.Running:
case Types.DeploymentState.Running:
return "Good";
case Types.DockerContainerState.NotDeployed:
case Types.DeploymentState.NotDeployed:
return "Neutral";
case Types.DockerContainerState.Unknown:
case Types.DeploymentState.Unknown:
return "Unknown";
default:
return "Critical";
}
};
export const build_state_intention = (status?: Types.BuildState) => {
switch (status) {
case undefined:
return "None";
case Types.BuildState.Unknown:
return "Unknown";
case Types.BuildState.Ok:
return "Good";
case Types.BuildState.Building:
return "Warning";
case Types.BuildState.Failed:
return "Critical";
default:
return "None";
}
};
export const repo_state_intention = (state?: Types.RepoState) => {
switch (state) {
case undefined:
return "None";
case Types.RepoState.Unknown:
return "Unknown";
case Types.RepoState.Ok:
return "Good";
case Types.RepoState.Cloning:
return "Warning";
case Types.RepoState.Pulling:
return "Warning";
case Types.RepoState.Failed:
return "Critical";
default:
return "None";
}
};
export const alert_level_intention: (
level: Types.SeverityLevel
) => ColorIntention = (level) => {
switch (level) {
case Types.SeverityLevel.Ok: return "Good"
case Types.SeverityLevel.Warning: return "Warning"
case Types.SeverityLevel.Critical: return "Critical"
case Types.SeverityLevel.Ok:
return "Good";
case Types.SeverityLevel.Warning:
return "Warning";
case Types.SeverityLevel.Critical:
return "Critical";
}
}
};

View File

@@ -216,3 +216,18 @@ export const useCheckResourceExists = () => {
}
};
};
export const useFilterResources = <Info>(
resources?: Types.ResourceListItem<Info>[],
search?: string,
) => {
const tags = useTagsFilter();
const searchSplit = search?.split(" ") || [];
return resources?.filter(
(resource) =>
tags.every((tag) => resource.tags.includes(tag)) &&
(searchSplit.length > 0
? searchSplit.every((search) => resource.name.includes(search))
: true)
) ?? [];
};

View File

@@ -5,11 +5,10 @@ import { toast } from "@ui/use-toast";
import { atom, useAtom } from "jotai";
import { Circle } from "lucide-react";
import { ReactNode, useCallback, useEffect } from "react";
import Rws from "reconnecting-websocket";
import { cn } from "@lib/utils";
import { AUTH_TOKEN_STORAGE_KEY } from "@main";
const rws_atom = atom<Rws | null>(null);
const rws_atom = atom<WebSocket | null>(null);
const useWebsocket = () => useAtom(rws_atom);
const ws_connected = atom(false);
@@ -46,7 +45,7 @@ const on_message = (
["ListServers"],
["GetServer"],
["GetServerActionState"],
["GetServerStatus"],
["GetServerState"],
["GetHistoricalServerStats"],
["GetServersSummary"]
);
@@ -103,13 +102,6 @@ const on_message = (
}
};
const on_open = (ws: Rws | null) => {
const jwt = localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
if (!ws || !jwt) return;
const msg: Types.WsLoginMessage = { type: "Jwt", params: { jwt } };
if (jwt && ws) ws.send(JSON.stringify(msg));
};
export const WebsocketProvider = ({
url,
children,
@@ -118,38 +110,28 @@ export const WebsocketProvider = ({
children: ReactNode;
}) => {
const invalidate = useInvalidate();
const [ws, set] = useWebsocket();
const setConnected = useWebsocketConnected()[1];
const [_, set] = useWebsocket();
const [connected, setConnected] = useWebsocketConnected();
const on_open_fn = useCallback(() => {
on_open(ws);
setConnected(true);
}, [ws, setConnected]);
const on_message_fn = useCallback(
(e: MessageEvent) => on_message(e, invalidate),
[invalidate]
);
const on_close_fn = useCallback(() => {
setConnected(false);
}, [setConnected]);
useEffect(() => {
if (!ws) set(new Rws(url));
return () => {
ws?.close();
};
}, [set, url, ws]);
useEffect(() => {
ws?.addEventListener("open", on_open_fn);
ws?.addEventListener("message", on_message_fn);
ws?.addEventListener("close", on_close_fn);
return () => {
ws?.removeEventListener("open", on_open_fn);
ws?.removeEventListener("message", on_message_fn);
ws?.removeEventListener("close", on_close_fn);
};
}, [on_message_fn, on_open_fn, on_close_fn, ws]);
if (!connected) {
const ws = make_websocket(
url,
() => setConnected(true),
on_message_fn,
() => {
console.log("ws closed");
setConnected(false);
}
);
set(ws);
}
}, [set, url, connected]);
return <>{children}</>;
};
@@ -177,3 +159,34 @@ export const WsStatusIndicator = () => {
</Button>
);
};
const make_websocket = (
url: string,
on_open: () => void,
on_message: (e: MessageEvent) => void,
on_close: () => void
) => {
console.log("init websocket");
const ws = new WebSocket(url);
ws.addEventListener("open", on_open);
ws.addEventListener("message", on_message);
ws.addEventListener("close", on_close);
const _on_open = () => {
const jwt = localStorage.getItem(AUTH_TOKEN_STORAGE_KEY);
if (!ws || !jwt) return;
const msg: Types.WsLoginMessage = { type: "Jwt", params: { jwt } };
if (jwt && ws) ws.send(JSON.stringify(msg));
on_open();
};
ws?.addEventListener("open", _on_open);
ws?.addEventListener("message", on_message);
ws?.addEventListener("close", on_close);
// force close every 30s to trigger reconnect and keep fresh
setTimeout(() => ws.close(), 30_000);
return ws
};

View File

@@ -1,7 +1,9 @@
import { ResourceComponents } from "@components/resources";
import { Types } from "@monitor/client";
import { UsableResource } from "@types";
import Convert from "ansi-to-html";
import { type ClassValue, clsx } from "clsx";
import sanitizeHtml from "sanitize-html";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
@@ -12,11 +14,11 @@ export const object_keys = <T extends object>(o: T): (keyof T)[] =>
Object.keys(o) as (keyof T)[];
export const RESOURCE_TARGETS: UsableResource[] = [
"Deployment",
"Server",
"Deployment",
"Build",
"Procedure",
"Repo",
"Procedure",
"Builder",
"Alerter",
"ServerTemplate"
@@ -96,3 +98,29 @@ export const usableResourcePath = (resource: UsableResource) => {
if (resource === "ServerTemplate") return "server-templates"
return `${resource.toLowerCase()}s`
}
export const sanitizeOnlySpan = (log: string) => {
return sanitizeHtml(log, {
allowedTags: ["span"],
allowedAttributes: {
"span": ["class"]
},
});
}
const convert = new Convert();
/**
* Converts the ansi colors in log to html.
* sanitizes incoming log first for any eg. script tags.
* @param log incoming log string
*/
export const logToHtml = (log: string) => {
if (!log) return "No log.";
const sanitized = sanitizeHtml(log, {
allowedTags: sanitizeHtml.defaults.allowedTags.filter(
(tag) => tag !== "script"
),
allowedAttributes: sanitizeHtml.defaults.allowedAttributes,
});
return convert.toHtml(sanitized);
};

View File

@@ -13,12 +13,14 @@ import { OpenAlerts } from "@components/alert";
import { useCheckResourceExists, useRead, useUser } from "@lib/hooks";
import { ResourceLink } from "@components/resources/common";
import { Fragment } from "react";
import { usableResourcePath } from "@lib/utils";
import { cn, usableResourcePath } from "@lib/utils";
import { AllUpdates } from "@components/updates/resource";
export const Dashboard = () => {
return (
<Page title="">
<OpenAlerts />
<AllUpdates />
<RecentlyViewed />
<Resources />
</Page>
@@ -65,18 +67,21 @@ const RecentlyViewed = () => {
icon={<History className="w-4 h-4" />}
actions=""
>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid md:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-4">
{recently_viewed
?.filter(checkResourceExists)
.slice(0, 6)
.map(({ type, id }) => (
.slice(0, 8)
.map(({ type, id }, i) => (
<Fragment key={type + id}>
{type !== "System" && (
<Card
onClick={() => nav(`/${usableResourcePath(type)}/${id}`)}
className="px-3 py-2 h-fit hover:bg-accent/50 group-focus:bg-accent/50 transition-colors cursor-pointer"
className={cn(
"px-3 py-2 h-fit hover:bg-accent/50 group-focus:bg-accent/50 transition-colors cursor-pointer",
i > 5 && "hidden 2xl:flex"
)}
>
<CardContent className="flex items-center justify-between gap-4 px-3 py-2 text-sm text-muted-foreground">
<CardContent className="flex items-center justify-between gap-4 px-3 py-2 text-sm text-muted-foreground w-full">
<ResourceLink type={type} id={id} />
{type}
</CardContent>

View File

@@ -40,6 +40,7 @@ export const Resource = () => {
return (
<Page
title={<Components.Name id={id} />}
icon={<Components.BigIcon id={id} />}
titleRight={
<div className="flex gap-4 items-center">
{Object.entries(Components.Status).map(([key, Status]) => (
@@ -50,10 +51,9 @@ export const Resource = () => {
subtitle={
<div className="flex flex-col gap-4">
<div className="flex gap-4 items-center text-muted-foreground">
<Components.Icon id={id} />
{Object.entries(Components.Info).map(([key, Info], i) => (
<Fragment key={key}>
| <Info key={i} id={id} />
{i !== 0 && "| "}<Info key={i} id={id} />
</Fragment>
))}
| <ExportButton target={{ type, id }} />

View File

@@ -1,4 +1,4 @@
import { Page, Section } from "@components/layouts";
import { Page } from "@components/layouts";
import { ResourceComponents } from "@components/resources";
import { TagsFilter } from "@components/tags";
import { useResourceParamType, useSetTitle } from "@lib/hooks";
@@ -15,24 +15,23 @@ export const Resources = () => {
return (
<Page
title={`${name}s`}
icon={<Components.BigIcon />}
actions={
<div className="grid gap-4 justify-items-end">
<div className="flex gap-4">
<Input
value={search}
onChange={(e) => set(e.target.value)}
placeholder="search..."
className="w-96"
/>
<TagsFilter />
<Components.New />
</div>
<TagsFilter />
<Input
value={search}
onChange={(e) => set(e.target.value)}
placeholder="search..."
className="w-96"
/>
</div>
}
>
<Section title="">
<Components.Table />
</Section>
<Components.Table search={search} />
</Page>
);
};

View File

@@ -3,11 +3,36 @@ import { ResourceComponents } from "@components/resources";
import { AddTags, ResourceTags } from "@components/tags";
import { UpdatesTable } from "@components/updates/table";
import { useRead, useResourceParamType, useSetTitle } from "@lib/hooks";
import { UsableResource } from "@types";
import { useParams } from "react-router-dom";
export const ResourceUpdates = () => {
export const Updates = () => {
const type = useResourceParamType()!;
const id = useParams().id as string;
if (type && id) {
return <ResourceUpdates type={type} id={id} />;
} else {
return <AllUpdates />;
}
};
const AllUpdates = () => {
useSetTitle("Updates");
const updates = useRead("ListUpdates", {}).data;
return (
<Page title="Updates">
<UpdatesTable updates={updates?.updates ?? []} />
</Page>
);
};
const ResourceUpdates = ({
type,
id,
}: {
type: UsableResource;
id: string;
}) => {
const name = useRead(`List${type}s`, {}).data?.find((r) => r.id === id)?.name;
useSetTitle(name && `${name} | Updates`);
const updates = useRead("ListUpdates", {

View File

@@ -7,7 +7,7 @@ import { Keys } from "@pages/keys";
import { RouterProvider, createBrowserRouter } from "react-router-dom";
import { Tree } from "@pages/home/tree";
import { Tags } from "@pages/tags";
import { ResourceUpdates } from "@pages/resource_update";
import { Updates } from "@pages/updates";
import { UsersPage } from "@pages/users";
import { AllResources } from "@pages/home/all_resources";
import { UserDisabled } from "@pages/user_disabled";
@@ -28,6 +28,7 @@ const ROUTER = createBrowserRouter([
{ path: "tree", element: <Tree /> },
{ path: "resources", element: <AllResources /> },
{ path: "alerts", element: <Alerts /> },
{ path: "updates", element: <Updates /> },
{
path: "users",
children: [
@@ -42,7 +43,7 @@ const ROUTER = createBrowserRouter([
{ path: "", element: <Resources /> },
{ path: ":id", element: <Resource /> },
{ path: ":id/stats", element: <ResourceStats /> },
{ path: ":id/updates", element: <ResourceUpdates /> },
{ path: ":id/updates", element: <Updates /> },
{ path: ":id/alerts", element: <Alerts /> },
],
},

View File

@@ -21,6 +21,7 @@ export interface RequiredResourceComponents {
/** Icon for the component */
Icon: OptionalIdComponent;
BigIcon: OptionalIdComponent;
/** status metrics, like deployment state / status */
Status: { [status: string]: IdComponent };

View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,59 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { VariantProps } from "class-variance-authority"
import { cn } from "@lib/utils"
import { toggleVariants } from "@//ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn("flex items-center justify-center gap-1", className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
})
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-3",
sm: "h-8 px-2",
lg: "h-10 px-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Toggle = React.forwardRef<
React.ElementRef<typeof TogglePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, ...props }, ref) => (
<TogglePrimitive.Root
ref={ref}
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
))
Toggle.displayName = TogglePrimitive.Root.displayName
export { Toggle, toggleVariants }

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