Compare commits

...

25 Commits

Author SHA1 Message Date
mbecker20
392e691f92 add repo build webhook 2024-08-11 18:26:51 -07:00
mbecker20
495e208ccd add building in repo busy check 2024-08-11 17:47:10 -07:00
mbecker20
14474adb90 add building state to repo 2024-08-11 17:46:26 -07:00
mbecker20
896784e2e3 fix repo action UI responsiveness 2024-08-11 17:38:15 -07:00
mbecker20
2e690bce24 repo table just show repo and branch 2024-08-11 17:36:00 -07:00
mbecker20
7172d24512 add message if fail to remove_dir_all after compose deploy 2024-08-11 17:21:19 -07:00
mbecker20
b754c89118 validate config makes sure ids not empty 2024-08-11 17:09:53 -07:00
Maxwell Becker
31a23dfe2d v1.13.1 improve stack edge cases, and UI action responsiveness (#26)
* get stack state from project

* move custom image name / tag below image setting for build config

* services also trigger stack action state

* add status to stack page

* 1.13.1 patch
2024-08-11 17:01:09 -07:00
mbecker20
b0f80cafc3 improve action responsiveness by improving when update is sent out rel to action state set 2024-08-11 14:59:34 -07:00
mbecker20
85a16f6c6f ensure run directory is normalized before create dir all 2024-08-11 14:14:17 -07:00
mbecker20
29a7e4c27b add link to builder in build info 2024-08-11 13:24:29 -07:00
mbecker20
a73b572725 improve dashboard responsiveness 2024-08-11 12:07:14 -07:00
mbecker20
aa44bf04e8 validate repo builder id in diff (new field) 2024-08-11 05:06:17 -07:00
mbecker20
93348621c5 replace repo builder_id with name for toml export 2024-08-11 05:00:34 -07:00
mbecker20
4b2139ede2 docker compose ls --all 2024-08-11 04:56:28 -07:00
mbecker20
3251216be7 update server address config placeholder 2024-08-11 03:35:34 -07:00
mbecker20
1f980a45e8 fix compose example file to reference monitor-mongo 2024-08-11 03:34:19 -07:00
mbecker20
94da1dce99 fill out Procedure execute types 2024-08-11 02:38:15 -07:00
mbecker20
d4fc015494 cli don't panic of no HOME env var 2024-08-11 02:26:18 -07:00
mbecker20
5800fc91d2 repo don't show build button if builder not attached 2024-08-11 02:12:39 -07:00
mbecker20
91785e1e8f monitor_cli install instructions 2024-08-11 01:56:56 -07:00
mbecker20
41fccdb16e demo link in readme 2024-08-10 23:58:42 -07:00
mbecker20
78cf93da8a improve dashboard recents responsiveness 2024-08-10 23:52:24 -07:00
mbecker20
ea36549dbe fix stack service routing page - works with updates 2024-08-10 23:01:31 -07:00
mbecker20
a319095869 improve readme 2024-08-10 16:02:29 -07:00
60 changed files with 701 additions and 359 deletions

26
Cargo.lock generated
View File

@@ -41,7 +41,7 @@ dependencies = [
[[package]]
name = "alerter"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"anyhow",
"axum 0.7.5",
@@ -967,7 +967,7 @@ dependencies = [
[[package]]
name = "command"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"monitor_client",
"run_command",
@@ -1351,7 +1351,7 @@ dependencies = [
[[package]]
name = "formatting"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"serror",
]
@@ -1482,7 +1482,7 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
[[package]]
name = "git"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"anyhow",
"command",
@@ -2085,7 +2085,7 @@ dependencies = [
[[package]]
name = "logger"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"anyhow",
"monitor_client",
@@ -2154,7 +2154,7 @@ dependencies = [
[[package]]
name = "migrator"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"anyhow",
"chrono",
@@ -2289,7 +2289,7 @@ dependencies = [
[[package]]
name = "monitor_cli"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"anyhow",
"clap",
@@ -2309,7 +2309,7 @@ dependencies = [
[[package]]
name = "monitor_client"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2341,7 +2341,7 @@ dependencies = [
[[package]]
name = "monitor_core"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2399,7 +2399,7 @@ dependencies = [
[[package]]
name = "monitor_periphery"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"anyhow",
"async_timing_util",
@@ -2835,7 +2835,7 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
[[package]]
name = "periphery_client"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"anyhow",
"monitor_client",
@@ -3987,7 +3987,7 @@ dependencies = [
[[package]]
name = "tests"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"anyhow",
"dotenvy",
@@ -4594,7 +4594,7 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "update_logger"
version = "1.13.0"
version = "1.13.1"
dependencies = [
"anyhow",
"logger",

View File

@@ -3,7 +3,7 @@ resolver = "2"
members = ["bin/*", "lib/*", "client/core/rs", "client/periphery/rs"]
[workspace.package]
version = "1.13.0"
version = "1.13.1"
edition = "2021"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
@@ -15,7 +15,7 @@ monitor_client = { path = "client/core/rs" }
[workspace.dependencies]
# LOCAL
monitor_client = "1.13.0"
monitor_client = "1.13.1"
periphery_client = { path = "client/periphery/rs" }
formatting = { path = "lib/formatting" }
command = { path = "lib/command" }

View File

@@ -8,6 +8,8 @@ Monitor CLI is a tool to sync monitor resources and execute operations.
cargo install monitor_cli
```
Note: On Ubuntu, also requires `apt install build-essential pkg-config libssl-dev`.
## Usage
### Credentials

View File

@@ -32,8 +32,8 @@ pub struct CliArgs {
}
fn default_creds() -> String {
let home = std::env::var("HOME")
.expect("no HOME env var. cannot get default config path.");
let home =
std::env::var("HOME").unwrap_or_else(|_| String::from("/root"));
format!("{home}/.config/monitor/creds.toml")
}

View File

@@ -70,6 +70,19 @@ impl Resolve<RunBuild, (User, Update)> for State {
return Err(anyhow!("Must attach builder to RunBuild"));
}
// get the action state for the build (or insert default).
let action_state =
action_states().build.get_or_insert_default(&build.id).await;
// This will set action state back to default when dropped.
// Will also check to ensure build not already busy before updating.
let _action_guard =
action_state.update(|state| state.building = true)?;
build.config.version.increment();
update.version = build.config.version;
update_update(update.clone()).await?;
let git_token = git_token(
&build.config.git_provider,
&build.config.git_account,
@@ -80,22 +93,9 @@ impl Resolve<RunBuild, (User, Update)> for State {
|| format!("Failed to get git token in call to db. This is a database error, not a token exisitence error. Stopping run. | {} | {}", build.config.git_provider, build.config.git_account),
)?;
// get the action state for the build (or insert default).
let action_state =
action_states().build.get_or_insert_default(&build.id).await;
// This will set action state back to default when dropped.
// Will also check to ensure build not already busy before updating.
let _action_guard =
action_state.update(|state| state.building = true)?;
let (registry_token, aws_ecr) =
validate_account_extract_registry_token_aws_ecr(&build).await?;
build.config.version.increment();
update.version = build.config.version;
update_update(update.clone()).await?;
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let mut cancel_recv =

View File

@@ -6,8 +6,7 @@ use monitor_client::{
build::{Build, ImageRegistry},
config::core::AwsEcrConfig,
deployment::{
extract_registry_domain, Deployment, DeploymentActionState,
DeploymentImage,
extract_registry_domain, Deployment, DeploymentImage,
},
get_image_name,
permission::PermissionLevel,
@@ -36,7 +35,6 @@ use crate::{
async fn setup_deployment_execution(
deployment: &str,
user: &User,
set_in_progress: impl Fn(&mut DeploymentActionState),
) -> anyhow::Result<(Deployment, Server)> {
let deployment = resource::get_check_permissions::<Deployment>(
deployment,
@@ -49,16 +47,6 @@ async fn setup_deployment_execution(
return Err(anyhow!("deployment has no server configured"));
}
// get the action state for the deployment (or insert default).
let action_state = action_states()
.deployment
.get_or_insert_default(&deployment.id)
.await;
// Will check to ensure deployment not already busy before updating, and return Err if so.
// The returned guard will set the action state back to default when dropped.
let _action_guard = action_state.update(set_in_progress)?;
let (server, status) =
get_server_with_status(&deployment.config.server_id).await?;
if status != ServerState::Ok {
@@ -82,10 +70,21 @@ impl Resolve<Deploy, (User, Update)> for State {
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let (mut deployment, server) =
setup_deployment_execution(&deployment, &user, |state| {
state.deploying = true
})
.await?;
setup_deployment_execution(&deployment, &user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
.deployment
.get_or_insert_default(&deployment.id)
.await;
// Will check to ensure deployment not already busy before updating, and return Err if so.
// The returned guard will set the action state back to default when dropped.
let _action_guard =
action_state.update(|state| state.deploying = true)?;
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
@@ -234,10 +233,21 @@ impl Resolve<StartContainer, (User, Update)> for State {
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user, |state| {
state.starting = true
})
.await?;
setup_deployment_execution(&deployment, &user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
.deployment
.get_or_insert_default(&deployment.id)
.await;
// Will check to ensure deployment not already busy before updating, and return Err if so.
// The returned guard will set the action state back to default when dropped.
let _action_guard =
action_state.update(|state| state.starting = true)?;
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
@@ -271,10 +281,21 @@ impl Resolve<RestartContainer, (User, Update)> for State {
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user, |state| {
state.restarting = true
})
.await?;
setup_deployment_execution(&deployment, &user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
.deployment
.get_or_insert_default(&deployment.id)
.await;
// Will check to ensure deployment not already busy before updating, and return Err if so.
// The returned guard will set the action state back to default when dropped.
let _action_guard =
action_state.update(|state| state.restarting = true)?;
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
@@ -310,10 +331,21 @@ impl Resolve<PauseContainer, (User, Update)> for State {
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user, |state| {
state.pausing = true
})
.await?;
setup_deployment_execution(&deployment, &user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
.deployment
.get_or_insert_default(&deployment.id)
.await;
// Will check to ensure deployment not already busy before updating, and return Err if so.
// The returned guard will set the action state back to default when dropped.
let _action_guard =
action_state.update(|state| state.pausing = true)?;
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
@@ -347,10 +379,21 @@ impl Resolve<UnpauseContainer, (User, Update)> for State {
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user, |state| {
state.unpausing = true
})
.await?;
setup_deployment_execution(&deployment, &user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
.deployment
.get_or_insert_default(&deployment.id)
.await;
// Will check to ensure deployment not already busy before updating, and return Err if so.
// The returned guard will set the action state back to default when dropped.
let _action_guard =
action_state.update(|state| state.unpausing = true)?;
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
@@ -390,10 +433,21 @@ impl Resolve<StopContainer, (User, Update)> for State {
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user, |state| {
state.stopping = true
})
.await?;
setup_deployment_execution(&deployment, &user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
.deployment
.get_or_insert_default(&deployment.id)
.await;
// Will check to ensure deployment not already busy before updating, and return Err if so.
// The returned guard will set the action state back to default when dropped.
let _action_guard =
action_state.update(|state| state.stopping = true)?;
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
@@ -437,10 +491,21 @@ impl Resolve<RemoveContainer, (User, Update)> for State {
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user, |state| {
state.removing = true
})
.await?;
setup_deployment_execution(&deployment, &user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
.deployment
.get_or_insert_default(&deployment.id)
.await;
// Will check to ensure deployment not already busy before updating, and return Err if so.
// The returned guard will set the action state back to default when dropped.
let _action_guard =
action_state.update(|state| state.removing = true)?;
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;

View File

@@ -69,6 +69,8 @@ fn resolve_inner(
let _action_guard =
action_state.update(|state| state.running = true)?;
update_update(update.clone()).await?;
let update = Mutex::new(update);
let res = execute_procedure(&procedure, &update).await;

View File

@@ -54,6 +54,17 @@ impl Resolve<CloneRepo, (User, Update)> for State {
)
.await?;
// get the action state for the repo (or insert default).
let action_state =
action_states().repo.get_or_insert_default(&repo.id).await;
// This will set action state back to default when dropped.
// Will also check to ensure repo not already busy before updating.
let _action_guard =
action_state.update(|state| state.cloning = true)?;
update_update(update.clone()).await?;
let git_token = git_token(
&repo.config.git_provider,
&repo.config.git_account,
@@ -64,15 +75,6 @@ impl Resolve<CloneRepo, (User, Update)> for State {
|| format!("Failed to get git token in call to db. This is a database error, not a token exisitence error. Stopping run. | {} | {}", repo.config.git_provider, repo.config.git_account),
)?;
// get the action state for the repo (or insert default).
let action_state =
action_states().repo.get_or_insert_default(&repo.id).await;
// This will set action state back to default when dropped.
// Will also check to ensure repo not already busy before updating.
let _action_guard =
action_state.update(|state| state.cloning = true)?;
if repo.config.server_id.is_empty() {
return Err(anyhow!("repo has no server attached"));
}
@@ -138,6 +140,8 @@ impl Resolve<PullRepo, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.pulling = true)?;
update_update(update.clone()).await?;
if repo.config.server_id.is_empty() {
return Err(anyhow!("repo has no server attached"));
}
@@ -241,6 +245,17 @@ impl Resolve<BuildRepo, (User, Update)> for State {
return Err(anyhow!("Must attach builder to BuildRepo"));
}
// get the action state for the repo (or insert default).
let action_state =
action_states().repo.get_or_insert_default(&repo.id).await;
// This will set action state back to default when dropped.
// Will also check to ensure repo not already busy before updating.
let _action_guard =
action_state.update(|state| state.building = true)?;
update_update(update.clone()).await?;
let git_token = git_token(
&repo.config.git_provider,
&repo.config.git_account,
@@ -251,15 +266,6 @@ impl Resolve<BuildRepo, (User, Update)> for State {
|| format!("Failed to get git token in call to db. This is a database error, not a token exisitence error. Stopping run. | {} | {}", repo.config.git_provider, repo.config.git_account),
)?;
// get the action state for the repo (or insert default).
let action_state =
action_states().repo.get_or_insert_default(&repo.id).await;
// This will set action state back to default when dropped.
// Will also check to ensure repo not already busy before updating.
let _action_guard =
action_state.update(|state| state.building = true)?;
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let mut cancel_recv =

View File

@@ -47,6 +47,8 @@ impl Resolve<StopAllContainers, (User, Update)> for State {
let _action_guard = action_state
.update(|state| state.stopping_containers = true)?;
update_update(update.clone()).await?;
let logs = periphery_client(&server)?
.request(api::container::StopAllContainers {})
.await
@@ -90,6 +92,8 @@ impl Resolve<PruneContainers, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.pruning_containers = true)?;
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
let log = match periphery
@@ -144,6 +148,8 @@ impl Resolve<PruneNetworks, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.pruning_networks = true)?;
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
let log = match periphery
@@ -196,6 +202,8 @@ impl Resolve<PruneImages, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.pruning_images = true)?;
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
let log =

View File

@@ -48,6 +48,8 @@ impl Resolve<DeployStack, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.deploying = true)?;
update_update(update.clone()).await?;
let git_token = crate::helpers::git_token(
&stack.config.git_provider,
&stack.config.git_account,
@@ -198,16 +200,11 @@ impl Resolve<StartStack, (User, Update)> for State {
StartStack { stack, service }: StartStack,
(user, update): (User, Update),
) -> anyhow::Result<Update> {
let no_service = service.is_none();
execute_compose::<StartStack>(
&stack,
service,
&user,
|state| {
if no_service {
state.starting = true
}
},
|state| state.starting = true,
update,
(),
)
@@ -222,15 +219,12 @@ impl Resolve<RestartStack, (User, Update)> for State {
RestartStack { stack, service }: RestartStack,
(user, update): (User, Update),
) -> anyhow::Result<Update> {
let no_service = service.is_none();
execute_compose::<RestartStack>(
&stack,
service,
&user,
|state| {
if no_service {
state.restarting = true;
}
state.restarting = true;
},
update,
(),
@@ -246,16 +240,11 @@ impl Resolve<PauseStack, (User, Update)> for State {
PauseStack { stack, service }: PauseStack,
(user, update): (User, Update),
) -> anyhow::Result<Update> {
let no_service = service.is_none();
execute_compose::<PauseStack>(
&stack,
service,
&user,
|state| {
if no_service {
state.pausing = true
}
},
|state| state.pausing = true,
update,
(),
)
@@ -270,16 +259,11 @@ impl Resolve<UnpauseStack, (User, Update)> for State {
UnpauseStack { stack, service }: UnpauseStack,
(user, update): (User, Update),
) -> anyhow::Result<Update> {
let no_service = service.is_none();
execute_compose::<UnpauseStack>(
&stack,
service,
&user,
|state| {
if no_service {
state.unpausing = true
}
},
|state| state.unpausing = true,
update,
(),
)
@@ -298,16 +282,11 @@ impl Resolve<StopStack, (User, Update)> for State {
}: StopStack,
(user, update): (User, Update),
) -> anyhow::Result<Update> {
let no_service = service.is_none();
execute_compose::<StopStack>(
&stack,
service,
&user,
|state| {
if no_service {
state.stopping = true
}
},
|state| state.stopping = true,
update,
stop_time,
)

View File

@@ -58,6 +58,9 @@ impl Resolve<RunSync, (User, Update)> for State {
return Err(anyhow!("resource sync repo not configured"));
}
// Send update here for FE to recheck action state
update_update(update.clone()).await?;
let (res, logs, hash, message) =
crate::helpers::sync::remote::get_remote_resources(&sync)
.await

View File

@@ -107,11 +107,16 @@ impl Resolve<GetReposSummary, User> for State {
(_, action_states) if action_states.pulling => {
res.pulling += 1;
}
(_, action_states) if action_states.building => {
res.building += 1;
}
(RepoState::Ok, _) => res.ok += 1,
(RepoState::Failed, _) => res.failed += 1,
(RepoState::Unknown, _) => res.unknown += 1,
// will never come off the cache in the building state, since that comes from action states
(RepoState::Cloning, _) | (RepoState::Pulling, _) => {
(RepoState::Cloning, _)
| (RepoState::Pulling, _)
| (RepoState::Building, _) => {
unreachable!()
}
}
@@ -132,6 +137,7 @@ impl Resolve<GetRepoWebhooksEnabled, User> for State {
managed: false,
clone_enabled: false,
pull_enabled: false,
build_enabled: false,
});
};
@@ -149,6 +155,7 @@ impl Resolve<GetRepoWebhooksEnabled, User> for State {
managed: false,
clone_enabled: false,
pull_enabled: false,
build_enabled: false,
});
}
@@ -160,6 +167,7 @@ impl Resolve<GetRepoWebhooksEnabled, User> for State {
managed: false,
clone_enabled: false,
pull_enabled: false,
build_enabled: false,
});
};
@@ -185,23 +193,33 @@ impl Resolve<GetRepoWebhooksEnabled, User> for State {
format!("{host}/listener/github/repo/{}/clone", repo.id);
let pull_url =
format!("{host}/listener/github/repo/{}/pull", repo.id);
let build_url =
format!("{host}/listener/github/repo/{}/build", repo.id);
let mut clone_enabled = false;
let mut pull_enabled = false;
let mut build_enabled = false;
for webhook in webhooks {
if webhook.active && webhook.config.url == clone_url {
if !webhook.active {
continue;
}
if webhook.config.url == clone_url {
clone_enabled = true
}
if webhook.active && webhook.config.url == pull_url {
if webhook.config.url == pull_url {
pull_enabled = true
}
if webhook.config.url == build_url {
build_enabled = true
}
}
Ok(GetRepoWebhooksEnabledResponse {
managed: true,
clone_enabled,
pull_enabled,
build_enabled,
})
}
}

View File

@@ -214,6 +214,8 @@ impl Resolve<GetStacksSummary, User> for State {
StackState::Paused => res.paused += 1,
StackState::Stopped => res.stopped += 1,
StackState::Restarting => res.restarting += 1,
StackState::Created => res.created += 1,
StackState::Removing => res.removing += 1,
StackState::Dead => res.dead += 1,
StackState::Unhealthy => res.unhealthy += 1,
StackState::Down => res.down += 1,

View File

@@ -309,6 +309,13 @@ impl Resolve<ExportResourcesToToml, User> for State {
.get(&repo.config.server_id)
.unwrap_or(&String::new()),
);
// replace repo builder with name
repo.config.builder_id.clone_from(
names
.builders
.get(&repo.config.builder_id)
.unwrap_or(&String::new()),
);
res.repos.push(convert_resource::<Repo>(repo, &names.tags))
}
ResourceTarget::Stack(id) => {

View File

@@ -225,6 +225,9 @@ impl Resolve<CreateRepoWebhook, User> for State {
RepoWebhookAction::Pull => {
format!("{host}/listener/github/repo/{}/pull", repo.id)
}
RepoWebhookAction::Build => {
format!("{host}/listener/github/repo/{}/build", repo.id)
}
};
for webhook in webhooks {
@@ -339,6 +342,9 @@ impl Resolve<DeleteRepoWebhook, User> for State {
RepoWebhookAction::Pull => {
format!("{host}/listener/github/repo/{}/pull", repo.id)
}
RepoWebhookAction::Build => {
format!("{host}/listener/github/repo/{}/build", repo.id)
}
};
for webhook in webhooks {

View File

@@ -5,13 +5,13 @@ use monitor_client::entities::{
alerter::Alerter,
build::Build,
builder::Builder,
deployment::{ContainerSummary, Deployment, DeploymentState},
deployment::{Deployment, DeploymentState},
permission::PermissionLevel,
procedure::Procedure,
repo::Repo,
server::{Server, ServerState},
server_template::ServerTemplate,
stack::{Stack, StackServiceNames, StackState},
stack::{ComposeProject, Stack, StackState},
sync::ResourceSync,
tag::Tag,
update::{ResourceTarget, ResourceTargetVariant, Update},
@@ -33,11 +33,6 @@ use crate::{
state::db_client,
};
use super::stack::{
compose_container_match_regex,
services::extract_services_from_stack,
};
#[instrument(level = "debug")]
// user: Id or username
pub async fn get_user(user: &str) -> anyhow::Result<User> {
@@ -98,63 +93,51 @@ pub async fn get_deployment_state(
}
/// Can pass all the containers from the same server
pub fn get_stack_state_from_containers(
services: &[StackServiceNames],
containers: &[ContainerSummary],
pub fn get_stack_state_from_projects(
stack: &Stack,
projects: &[ComposeProject],
) -> StackState {
// first filter the containers to only ones which match the service
let containers = containers.iter().filter(|container| {
services.iter().any(|StackServiceNames { service_name, container_name }| {
match compose_container_match_regex(container_name)
.with_context(|| format!("failed to construct container name matching regex for service {service_name}"))
{
Ok(regex) => regex,
Err(e) => {
warn!("{e:#}");
return false
}
}.is_match(&container.name)
let project_name = stack.project_name(false);
let Some(status) = projects
.iter()
.find(|project| project.name == project_name)
.and_then(|project| project.status.as_deref())
else {
return StackState::Down;
};
let Ok(states) = status
.split(", ")
.filter_map(|state| state.split('(').next())
.map(|state| {
state.parse::<DeploymentState>().with_context(|| {
format!("failed to parse stack state entry: {state}")
})
})
}).collect::<Vec<_>>();
if containers.is_empty() {
.collect::<anyhow::Result<Vec<_>>>()
.inspect_err(|e| warn!("{e:#}"))
else {
return StackState::Unknown;
};
if states.is_empty() {
return StackState::Down;
}
if services.len() != containers.len() {
if states.len() > 1 {
return StackState::Unhealthy;
}
let running = containers
.iter()
.all(|container| container.state == DeploymentState::Running);
if running {
return StackState::Running;
match states[0] {
DeploymentState::Unknown => StackState::Unknown,
DeploymentState::NotDeployed => StackState::Down,
DeploymentState::Created => StackState::Created,
DeploymentState::Restarting => StackState::Restarting,
DeploymentState::Running => StackState::Running,
DeploymentState::Removing => StackState::Removing,
DeploymentState::Paused => StackState::Paused,
DeploymentState::Exited => StackState::Stopped,
DeploymentState::Dead => StackState::Dead,
}
let paused = containers
.iter()
.all(|container| container.state == DeploymentState::Paused);
if paused {
return StackState::Paused;
}
let stopped = containers
.iter()
.all(|container| container.state == DeploymentState::Exited);
if stopped {
return StackState::Stopped;
}
let restarting = containers
.iter()
.all(|container| container.state == DeploymentState::Restarting);
if restarting {
return StackState::Restarting;
}
let dead = containers
.iter()
.all(|container| container.state == DeploymentState::Dead);
if dead {
return StackState::Dead;
}
StackState::Unhealthy
}
/// Gets stack state fresh from periphery
#[instrument(level = "debug")]
pub async fn get_stack_state(
stack: &Stack,
@@ -167,13 +150,11 @@ pub async fn get_stack_state(
if status != ServerState::Ok {
return Ok(StackState::Unknown);
}
let containers = super::periphery_client(&server)?
.request(periphery_client::api::container::GetContainerList {})
let projects = super::periphery_client(&server)?
.request(periphery_client::api::compose::ListComposeProjects {})
.await?;
let services = extract_services_from_stack(stack, false).await?;
Ok(get_stack_state_from_containers(&services, &containers))
Ok(get_stack_state_from_projects(stack, &projects))
}
#[instrument(level = "debug")]

View File

@@ -48,6 +48,9 @@ pub async fn execute_compose<T: ExecuteCompose>(
// The returned guard will set the action state back to default when dropped.
let _action_guard = action_state.update(set_in_progress)?;
// Send update here for frontend to recheck action state
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
if let Some(service) = &service {

View File

@@ -1,4 +1,4 @@
use std::{fs, path::Path};
use std::{fs, path::{Path, PathBuf}};
use anyhow::{anyhow, Context};
use formatting::format_serror;
@@ -38,6 +38,8 @@ pub async fn get_remote_compose_contents(
.context("failed to clone stack repo")?;
let run_directory = repo_path.join(&stack.config.run_directory);
// This will remove any intermediate '/./' which can be a problem for some OS.
let run_directory = run_directory.components().collect::<PathBuf>();
let mut oks = Vec::new();
let mut errs = Vec::new();

View File

@@ -148,6 +148,13 @@ impl ResourceSync for Repo {
.map(|s| s.name.clone())
.unwrap_or_default();
// Need to replace builder id with name
original.builder_id = resources
.builders
.get(&original.builder_id)
.map(|s| s.name.clone())
.unwrap_or_default();
Ok(original.partial_diff(update))
}
}

View File

@@ -59,6 +59,23 @@ pub async fn add_update(
Ok(id)
}
#[instrument(level = "debug")]
pub async fn add_update_without_send(
update: &Update,
) -> anyhow::Result<String> {
let id = db_client()
.await
.updates
.insert_one(update)
.await
.context("failed to insert update into db")?
.inserted_id
.as_object_id()
.context("inserted_id is not object id")?
.to_string();
Ok(id)
}
#[instrument(level = "debug")]
pub async fn update_update(update: Update) -> anyhow::Result<()> {
update_one_by_id(&db_client().await.updates, &update.id, mungos::update::Update::Set(to_document(&update)?), None)
@@ -312,6 +329,7 @@ pub async fn init_execution_update(
};
let mut update = make_update(target, operation, user);
update.in_progress();
update.id = add_update(update.clone()).await?;
// Don't actually send it here, let the handlers send it after they can set action state.
update.id = add_update_without_send(&update).await?;
Ok(update)
}

View File

@@ -89,6 +89,24 @@ pub fn router() -> Router {
},
)
)
.route(
"/repo/:id/build",
post(
|Path(Id { id }), headers: HeaderMap, body: String| async move {
tokio::spawn(async move {
let span = info_span!("repo_build_webhook", id);
async {
let res = repo::handle_repo_build_webhook(id.clone(), headers, body).await;
if let Err(e) = res {
warn!("failed to run repo build webook for repo {id} | {e:#}");
}
}
.instrument(span)
.await
});
},
)
)
.route(
"/stack/:id/refresh",
post(

View File

@@ -3,7 +3,7 @@ use std::sync::OnceLock;
use anyhow::anyhow;
use axum::http::HeaderMap;
use monitor_client::{
api::execute::{CloneRepo, PullRepo},
api::execute::{BuildRepo, CloneRepo, PullRepo},
entities::{repo::Repo, user::git_webhook_user},
};
use resolver_api::Resolve;
@@ -84,3 +84,35 @@ pub async fn handle_repo_pull_webhook(
State.resolve(req, (user, update)).await?;
Ok(())
}
pub async fn handle_repo_build_webhook(
repo_id: String,
headers: HeaderMap,
body: String,
) -> anyhow::Result<()> {
// Acquire and hold lock to make a task queue for
// subsequent listener calls on same resource.
// It would fail if we let it go through from action state busy.
let lock = repo_locks().get_or_insert_default(&repo_id).await;
let _lock = lock.lock().await;
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"));
}
let user = git_webhook_user().to_owned();
let req = crate::api::execute::ExecuteRequest::BuildRepo(BuildRepo {
repo: repo_id,
});
let update = init_execution_update(&req, &user).await?;
let crate::api::execute::ExecuteRequest::BuildRepo(req) = req else {
unreachable!()
};
State.resolve(req, (user, update)).await?;
Ok(())
}

View File

@@ -214,7 +214,7 @@ pub async fn update_cache_for_server(server: &Server) {
Ok((containers, networks, images, projects)) => {
tokio::join!(
resources::update_deployment_cache(deployments, &containers),
resources::update_stack_cache(stacks, &containers),
resources::update_stack_cache(stacks, &containers, &projects),
);
insert_server_status(
server,

View File

@@ -1,12 +1,12 @@
use anyhow::Context;
use monitor_client::entities::{
deployment::{ContainerSummary, Deployment, DeploymentState},
stack::{Stack, StackService, StackServiceNames},
stack::{ComposeProject, Stack, StackService, StackServiceNames},
};
use crate::{
helpers::{
query::get_stack_state_from_containers,
query::get_stack_state_from_projects,
stack::{
compose_container_match_regex,
services::extract_services_from_stack,
@@ -55,6 +55,7 @@ pub async fn update_deployment_cache(
pub async fn update_stack_cache(
stacks: Vec<Stack>,
containers: &[ContainerSummary],
projects: &[ComposeProject],
) {
let stack_status_cache = stack_status_cache();
for stack in stacks {
@@ -92,7 +93,7 @@ pub async fn update_stack_cache(
.map(|s| s.curr.state);
let status = CachedStackStatus {
id: stack.id.clone(),
state: get_stack_state_from_containers(&services, containers),
state: get_stack_state_from_projects(&stack, projects),
services: services_with_containers,
};
stack_status_cache

View File

@@ -174,10 +174,12 @@ async fn validate_config(
user: &User,
) -> anyhow::Result<()> {
if let Some(builder_id) = &config.builder_id {
let builder = super::get_check_permissions::<Builder>(builder_id, user, PermissionLevel::Read)
.await
.context("cannot create build using this builder. user must have at least read permissions on the builder.")?;
config.builder_id = Some(builder.id)
if !builder_id.is_empty() {
let builder = super::get_check_permissions::<Builder>(builder_id, user, PermissionLevel::Read)
.await
.context("cannot create build using this builder. user must have at least read permissions on the builder.")?;
config.builder_id = Some(builder.id)
}
}
if let Some(build_args) = &mut config.build_args {
build_args.retain(|v| {

View File

@@ -3,6 +3,7 @@ use std::time::Duration;
use anyhow::Context;
use formatting::format_serror;
use monitor_client::entities::{
builder::Builder,
permission::PermissionLevel,
repo::{
PartialRepoConfig, Repo, RepoConfig, RepoConfigDiff, RepoInfo,
@@ -203,18 +204,25 @@ async fn validate_config(
config: &mut PartialRepoConfig,
user: &User,
) -> anyhow::Result<()> {
match &config.server_id {
Some(server_id) if !server_id.is_empty() => {
if let Some(server_id) = &config.server_id {
if !server_id.is_empty() {
let server = get_check_permissions::<Server>(
server_id,
user,
PermissionLevel::Write,
)
.await
.context("cannot create repo on this server. user must have update permissions on the server.")?;
server_id,
user,
PermissionLevel::Write,
)
.await
.context("Cannot attach repo to this server. User must have write permissions on the server.")?;
config.server_id = Some(server.id);
}
_ => {}
}
if let Some(builder_id) = &config.builder_id {
if !builder_id.is_empty() {
let builder = super::get_check_permissions::<Builder>(builder_id, user, PermissionLevel::Read)
.await
.context("Cannot attach repo to this builder. User must have at least read permissions on the builder.")?;
config.builder_id = Some(builder.id);
}
}
Ok(())
}
@@ -231,6 +239,8 @@ async fn get_repo_state(id: &String) -> RepoState {
Some(RepoState::Cloning)
} else if s.pulling {
Some(RepoState::Pulling)
} else if s.building {
Some(RepoState::Building)
} else {
None
}

View File

@@ -67,28 +67,32 @@ impl super::MonitorResource for Stack {
.map(|service| service.service_name)
.collect();
// This is only true if it is KNOWN to be true. so other cases are false.
let (project_missing, status) = if stack.config.server_id.is_empty()
|| matches!(state, StackState::Down | StackState::Unknown)
{
(false, None)
} else if let Some(status) = server_status_cache()
.get(&stack.config.server_id)
.await
.as_ref()
{
if let Some(projects) = &status.projects {
if let Some(project) = projects.iter().find(|project| project.name == project_name) {
(false, project.status.clone())
let (project_missing, status) =
if stack.config.server_id.is_empty()
|| matches!(state, StackState::Down | StackState::Unknown)
{
(false, None)
} else if let Some(status) = server_status_cache()
.get(&stack.config.server_id)
.await
.as_ref()
{
if let Some(projects) = &status.projects {
if let Some(project) = projects
.iter()
.find(|project| project.name == project_name)
{
(false, project.status.clone())
} else {
// The project doesn't exist
(true, None)
}
} else {
// The project doesn't exist
(true, None)
(false, None)
}
} else {
(false, None)
}
} else {
(false, None)
};
};
StackListItem {
id: stack.id,
name: stack.name,

View File

@@ -22,7 +22,7 @@ impl Resolve<ListComposeProjects, ()> for State {
let docker_compose = docker_compose();
let res = run_monitor_command(
"list projects",
format!("{docker_compose} ls --format json"),
format!("{docker_compose} ls --all --format json"),
)
.await;

View File

@@ -173,7 +173,7 @@ pub async fn compose_up(
res.logs.push(log);
if let Err(e) = fs::remove_dir_all(&root).await.with_context(|| {
format!("failed to clean up files after deploy | path: {root:?}")
format!("failed to clean up files after deploy | path: {root:?} | ensure all volumes are mounted outside the repo directory (preferably use absolute path for mounts)")
}) {
res
.logs
@@ -194,6 +194,9 @@ async fn write_stack(
.stack_dir
.join(to_monitor_name(&stack.name));
let run_directory = root.join(&stack.config.run_directory);
// This will remove any intermediate '/./' in the path, which is a problem for some OS.
// Cannot use canonicalize yet as directory may not exist.
let run_directory = run_directory.components().collect::<PathBuf>();
if stack.config.file_contents.is_empty() {
// Clone the repo
@@ -283,7 +286,9 @@ async fn write_stack(
} else {
// Ensure run directory exists
fs::create_dir_all(&run_directory).await.with_context(|| {
format!("failed to create stack run directory at {root:?}")
format!(
"failed to create stack run directory at {run_directory:?}"
)
})?;
let env_file_path = match write_environment_file(
&stack.config.environment,

View File

@@ -105,6 +105,8 @@ pub struct GetReposSummaryResponse {
pub cloning: u32,
/// The number of repos currently pulling.
pub pulling: u32,
/// The number of repos currently building.
pub building: u32,
/// The number of repos with failed state.
pub failed: u32,
/// The number of repos with unknown state.
@@ -137,4 +139,6 @@ pub struct GetRepoWebhooksEnabledResponse {
pub clone_enabled: bool,
/// Whether pushes to branch trigger pull. Will always be false if managed is false.
pub pull_enabled: bool,
/// Whether pushes to branch trigger build. Will always be false if managed is false.
pub build_enabled: bool,
}

View File

@@ -213,6 +213,10 @@ pub struct GetStacksSummaryResponse {
pub restarting: u32,
/// The number of stacks with Dead state.
pub dead: u32,
/// The number of stacks with Created state.
pub created: u32,
/// The number of stacks with Removing state.
pub removing: u32,
/// The number of stacks with Unhealthy state.
pub unhealthy: u32,
/// The number of stacks with Down state.

View File

@@ -105,6 +105,7 @@ pub struct RefreshRepoCache {
pub enum RepoWebhookAction {
Clone,
Pull,
Build,
}
/// Create a webhook on the github repo attached to the (monitor) repo

View File

@@ -48,7 +48,7 @@ impl Busy for BuildActionState {
impl Busy for RepoActionState {
fn busy(&self) -> bool {
self.cloning || self.pulling
self.cloning || self.pulling || self.building
}
}

View File

@@ -59,8 +59,10 @@ pub enum RepoState {
Failed,
/// Currently cloning
Cloning,
/// Currently pullling
/// Currently pulling
Pulling,
/// Currently building
Building,
}
#[typeshare]

View File

@@ -107,10 +107,14 @@ pub enum StackState {
Paused,
/// All contianers are stopped
Stopped,
/// All containers are created
Created,
/// All containers are restarting
Restarting,
/// All containers are dead
Dead,
/// All containers are removing
Removing,
/// The containers are in a mix of states
Unhealthy,
/// The stack is not deployed

View File

@@ -1060,8 +1060,10 @@ export enum RepoState {
Failed = "Failed",
/** Currently cloning */
Cloning = "Cloning",
/** Currently pullling */
/** Currently pulling */
Pulling = "Pulling",
/** Currently building */
Building = "Building",
}
export interface RepoListItemInfo {
@@ -1617,10 +1619,14 @@ export enum StackState {
Paused = "paused",
/** All contianers are stopped */
Stopped = "stopped",
/** All containers are created */
Created = "created",
/** All containers are restarting */
Restarting = "restarting",
/** All containers are dead */
Dead = "dead",
/** All containers are removing */
Removing = "removing",
/** The containers are in a mix of states */
Unhealthy = "unhealthy",
/** The stack is not deployed */
@@ -3231,6 +3237,8 @@ export interface GetReposSummaryResponse {
cloning: number;
/** The number of repos currently pulling. */
pulling: number;
/** The number of repos currently building. */
building: number;
/** The number of repos with failed state. */
failed: number;
/** The number of repos with unknown state. */
@@ -3254,6 +3262,8 @@ export interface GetRepoWebhooksEnabledResponse {
clone_enabled: boolean;
/** Whether pushes to branch trigger pull. Will always be false if managed is false. */
pull_enabled: boolean;
/** Whether pushes to branch trigger build. Will always be false if managed is false. */
build_enabled: boolean;
}
/** Find resources matching a common query. Response: [FindResourcesResponse]. */
@@ -3586,6 +3596,10 @@ export interface GetStacksSummaryResponse {
restarting: number;
/** The number of stacks with Dead state. */
dead: number;
/** The number of stacks with Created state. */
created: number;
/** The number of stacks with Removing state. */
removing: number;
/** The number of stacks with Unhealthy state. */
unhealthy: number;
/** The number of stacks with Down state. */
@@ -4377,6 +4391,7 @@ export interface RefreshRepoCache {
export enum RepoWebhookAction {
Clone = "Clone",
Pull = "Pull",
Build = "Build",
}
/**

View File

@@ -15,7 +15,7 @@ services:
environment: # https://github.com/mbecker20/monitor/blob/main/config_example/core.config.example.toml
MONITOR_HOST: https://demo.monitor.dev
## MONGO
MONITOR_MONGO_ADDRESS: demo-mongo:27017
MONITOR_MONGO_ADDRESS: monitor-mongo:27017
MONITOR_MONGO_USERNAME: admin # match ones below
MONITOR_MONGO_PASSWORD: admin
## KEYS

View File

@@ -35,7 +35,7 @@ export const Layout = () => {
<div className="h-screen overflow-y-auto">
<div className="container">
<Sidebar />
<div className="md:ml-64 md:pl-8 py-24">
<div className="lg:ml-64 lg:pl-8 py-24">
<Outlet />
</div>
</div>
@@ -53,7 +53,6 @@ interface PageProps {
subtitle?: ReactNode;
actions?: ReactNode;
superHeader?: ReactNode;
wrapSize?: "md" | "lg" | "xl" | "2xl";
}
export const Page = ({
@@ -72,7 +71,7 @@ export const Page = ({
{superHeader}
{(title || icon || subtitle || actions) && (
<div
className={`flex flex-col gap-6 lg:flex-row lg:gap-0 lg:justify-between`}
className={`flex flex-col gap-6 md:flex-row md:gap-0 md:justify-between`}
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4 items-center">
@@ -89,7 +88,7 @@ export const Page = ({
) : (
(title || icon || subtitle || actions) && (
<div
className={`flex flex-col gap-6 lg:flex-row lg:gap-0 lg:justify-between`}
className={`flex flex-col gap-6 md:flex-row md:gap-0 md:justify-between`}
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4 items-center">

View File

@@ -113,20 +113,6 @@ export const BuildConfig = ({
},
},
},
{
label: "Custom Name / Tag",
components: {
image_name: {
description: "Optional. Push the image under a different name",
placeholder: "Custom image name",
},
image_tag: {
description:
"Optional. Postfix the image version with a custom tag.",
placeholder: "Custom image tag",
},
},
},
{
label: "Git",
components: {
@@ -194,6 +180,20 @@ export const BuildConfig = ({
),
},
},
{
label: "Custom Name / Tag",
components: {
image_name: {
description: "Optional. Push the image under a different name",
placeholder: "Custom image name",
},
image_tag: {
description:
"Optional. Postfix the image version with a custom tag.",
placeholder: "Custom image tag",
},
},
},
{
label: "Extra Args",
description: (

View File

@@ -1,10 +1,10 @@
import { Section } from "@components/layouts";
import { useInvalidate, useRead, useWrite } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { FolderGit, Hammer, Loader2, RefreshCcw } from "lucide-react";
import { Factory, FolderGit, Hammer, Loader2, RefreshCcw } from "lucide-react";
import { BuildConfig } from "./config";
import { BuildTable } from "./table";
import { DeleteResource, NewResource } from "../common";
import { DeleteResource, NewResource, ResourceLink } from "../common";
import { DeploymentTable } from "../deployment/table";
import { RunBuild } from "./actions";
import {
@@ -23,6 +23,7 @@ import { Card } from "@ui/card";
import { Badge } from "@ui/badge";
import { useToast } from "@ui/use-toast";
import { Button } from "@ui/button";
import { useBuilder } from "../builder";
export const useBuild = (id?: string) =>
useRead("ListBuilds", {}, { refetchInterval: 5000 }).data?.find(
@@ -209,6 +210,18 @@ export const BuildComponents: RequiredResourceComponents = {
},
Info: {
Builder: ({ id }) => {
const info = useBuild(id)?.info;
const builder = useBuilder(info?.builder_id);
return builder?.id ? (
<ResourceLink type="Builder" id={builder?.id} />
) : (
<div className="flex gap-2 items-center text-sm">
<Factory className="w-4 h-4" />
<div>Unknown Builder</div>
</div>
);
},
Repo: ({ id }) => {
const repo = useBuild(id)?.info.repo;
return (

View File

@@ -491,14 +491,28 @@ const default_enabled_execution: () => Types.EnabledExecution = () => ({
const EXECUTION_TYPES: Types.Execution["type"][] = [
"RunBuild",
"Deploy",
"RestartContainer",
"StartContainer",
"PauseContainer",
"UnpauseContainer",
"StopContainer",
"StopAllContainers",
"RemoveContainer",
"DeployStack",
"StartStack",
"RestartStack",
"PauseStack",
"UnpauseStack",
"StopStack",
"DestroyStack",
"CloneRepo",
"PullRepo",
"BuildRepo",
"RunProcedure",
"RunSync",
"PruneContainers",
"PruneImages",
"PruneNetworks",
"Sleep",
];

View File

@@ -12,14 +12,16 @@ import { Types } from "@monitor/client";
import { useBuilder } from "../builder";
export const CloneRepo = ({ id }: { id: string }) => {
const hash = useRepo(id)?.info.latest_hash;
const isCloned = (hash?.length || 0) > 0;
const { mutate, isPending } = useExecute("CloneRepo");
const cloning = useRead(
"GetRepoActionState",
{ repo: id },
{ refetchInterval: 5000 }
).data?.cloning;
const info = useRepo(id)?.info;
if (!info?.server_id) return null;
const hash = info?.latest_hash;
const isCloned = (hash?.length || 0) > 0;
const pending = isPending || cloning;
return (
<ConfirmButton
@@ -45,7 +47,9 @@ export const PullRepo = ({ id }: { id: string }) => {
{ repo: id },
{ refetchInterval: 5000 }
).data?.pulling;
const hash = useRepo(id)?.info.latest_hash;
const info = useRepo(id)?.info;
if (!info?.server_id) return null;
const hash = info?.latest_hash;
const isCloned = (hash?.length || 0) > 0;
if (!isCloned) return null;
const pending = isPending || pulling;
@@ -89,6 +93,9 @@ export const BuildRepo = ({ id }: { id: string }) => {
const builder = useBuilder(repo?.info.builder_id);
const canCancel = builder?.info.builder_type !== "Server";
// Don't show if builder not attached
if (!builder) return null;
// make sure hidden without perms.
// not usually necessary, but this button also used in deployment actions.
if (

View File

@@ -164,6 +164,11 @@ export const RepoConfig = ({ id }: { id: string }) => {
<CopyGithubWebhook path={`/repo/${id}/clone`} />
</ConfigItem>
),
["build" as any]: () => (
<ConfigItem label="Build">
<CopyGithubWebhook path={`/repo/${id}/build`} />
</ConfigItem>
),
webhook_enabled: webhooks !== undefined && !webhooks.managed,
["managed" as any]: () => {
const inv = useInvalidate();
@@ -247,44 +252,95 @@ export const RepoConfig = ({ id }: { id: string }) => {
/>
</div>
)}
{!webhooks.clone_enabled && !webhooks.pull_enabled && (
{webhooks.build_enabled && (
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
Incoming webhook is{" "}
<div
className={text_color_class_by_intention(
"Critical"
)}
className={text_color_class_by_intention("Good")}
>
DISABLED
ENABLED
</div>
and will trigger
<div
className={text_color_class_by_intention("Neutral")}
>
BUILD
</div>
</div>
<ConfirmButton
title="Enable Clone"
icon={<CirclePlus className="w-4 h-4" />}
title="Disable"
icon={<Ban className="w-4 h-4" />}
variant="destructive"
onClick={() =>
createWebhook({
deleteWebhook({
repo: id,
action: Types.RepoWebhookAction.Clone,
action: Types.RepoWebhookAction.Build,
})
}
loading={createPending}
disabled={disabled || createPending}
/>
<ConfirmButton
title="Enable Pull"
icon={<CirclePlus className="w-4 h-4" />}
onClick={() =>
createWebhook({
repo: id,
action: Types.RepoWebhookAction.Pull,
})
}
loading={createPending}
disabled={disabled || createPending}
loading={deletePending}
disabled={disabled || deletePending}
/>
</div>
)}
{!webhooks.clone_enabled &&
!webhooks.pull_enabled &&
!webhooks.build_enabled && (
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
Incoming webhook is{" "}
<div
className={text_color_class_by_intention(
"Critical"
)}
>
DISABLED
</div>
</div>
{(update.server_id ?? config.server_id) && (
<ConfirmButton
title="Enable Clone"
icon={<CirclePlus className="w-4 h-4" />}
onClick={() =>
createWebhook({
repo: id,
action: Types.RepoWebhookAction.Clone,
})
}
loading={createPending}
disabled={disabled || createPending}
/>
)}
{(update.server_id ?? config.server_id) && (
<ConfirmButton
title="Enable Pull"
icon={<CirclePlus className="w-4 h-4" />}
onClick={() =>
createWebhook({
repo: id,
action: Types.RepoWebhookAction.Pull,
})
}
loading={createPending}
disabled={disabled || createPending}
/>
)}
{(update.builder_id ?? config.builder_id) && (
<ConfirmButton
title="Enable Build"
icon={<CirclePlus className="w-4 h-4" />}
onClick={() =>
createWebhook({
repo: id,
action: Types.RepoWebhookAction.Build,
})
}
loading={createPending}
disabled={disabled || createPending}
/>
)}
</div>
)}
</ConfigItem>
);
},

View File

@@ -2,6 +2,7 @@ import { useInvalidate, useRead, useWrite } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { Card } from "@ui/card";
import {
Factory,
FolderGit,
GitBranch,
Loader2,
@@ -25,6 +26,7 @@ import { StatusBadge } from "@components/util";
import { Badge } from "@ui/badge";
import { useToast } from "@ui/use-toast";
import { Button } from "@ui/button";
import { useBuilder } from "../builder";
export const useRepo = (id?: string) =>
useRead("ListRepos", {}, { refetchInterval: 5000 }).data?.find(
@@ -195,6 +197,30 @@ export const RepoComponents: RequiredResourceComponents = {
},
Info: {
Server: ({ id }) => {
const info = useRepo(id)?.info;
const server = useServer(info?.server_id);
return server?.id ? (
<ResourceLink type="Server" id={server?.id} />
) : (
<div className="flex gap-2 items-center">
<Server className="w-4 h-4" />
<div>No Server</div>
</div>
);
},
Builder: ({ id }) => {
const info = useRepo(id)?.info;
const builder = useBuilder(info?.builder_id);
return builder?.id ? (
<ResourceLink type="Builder" id={builder?.id} />
) : (
<div className="flex gap-2 items-center">
<Factory className="w-4 h-4" />
<div>No Builder</div>
</div>
);
},
Repo: ({ id }) => {
const repo = useRepo(id)?.info.repo;
return (
@@ -204,24 +230,12 @@ export const RepoComponents: RequiredResourceComponents = {
</div>
);
},
// Branch: ({ id }) => {
// const branch = useRepo(id)?.info.branch;
// return (
// <div className="flex items-center gap-2">
// <FolderGit className="w-4 h-4" />
// {branch}
// </div>
// );
// },
Server: ({ id }) => {
const info = useRepo(id)?.info;
const server = useServer(info?.server_id);
return server?.id ? (
<ResourceLink type="Server" id={server?.id} />
) : (
<div className="flex gap-2 items-center">
<Server className="w-4 h-4" />
<div>Unknown Server</div>
Branch: ({ id }) => {
const branch = useRepo(id)?.info.branch;
return (
<div className="flex items-center gap-2">
<GitBranch className="w-4 h-4" />
{branch}
</div>
);
},

View File

@@ -1,17 +1,10 @@
import { useRead } from "@lib/hooks";
import { DataTable, SortableHeader } from "@ui/data-table";
import { ResourceLink } from "../common";
import { TableTags } from "@components/tags";
import { RepoComponents } from ".";
import { Types } from "@monitor/client";
import { useCallback } from "react";
export const RepoTable = ({ repos }: { repos: Types.RepoListItem[] }) => {
const servers = useRead("ListServers", {}).data;
const serverName = useCallback(
(id: string) => servers?.find((server) => server.id === id)?.name,
[servers]
);
return (
<DataTable
tableKey="repos"
@@ -33,24 +26,9 @@ export const RepoTable = ({ repos }: { repos: Types.RepoListItem[] }) => {
size: 200,
},
{
accessorKey: "info.server_id",
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;
},
accessorKey: "info.branch",
header: ({ column }) => (
<SortableHeader column={column} title="Server" />
),
cell: ({ row }) => (
<ResourceLink type="Server" id={row.original.info.server_id} />
<SortableHeader column={column} title="Branch" />
),
size: 200,
},

View File

@@ -44,7 +44,7 @@ export const ServerConfig = ({
label: "General",
components: {
address: {
placeholder: "http://localhost:8120",
placeholder: "http://12.34.56.78:8120",
description:
"The http/s address of periphery in your network, eg. http://12.34.56.78:8120",
},

View File

@@ -1,6 +1,14 @@
import { ActionWithDialog, ConfirmButton } from "@components/util";
import { useExecute, useInvalidate, useRead, useWrite } from "@lib/hooks";
import { Pause, Pen, Play, RefreshCcw, Rocket, Square, Trash2 } from "lucide-react";
import {
Pause,
Pen,
Play,
RefreshCcw,
Rocket,
Square,
Trash2,
} from "lucide-react";
import { useStack } from ".";
import { Types } from "@monitor/client";
import { useToast } from "@ui/use-toast";
@@ -20,8 +28,6 @@ export const DeployStack = ({ id }: { id: string }) => {
if (!stack || state === Types.StackState.Unknown) {
return null;
}
const pending = isPending || deploying;
const deployed =
state !== undefined &&
[
@@ -39,8 +45,8 @@ export const DeployStack = ({ id }: { id: string }) => {
title="Redeploy"
icon={<Rocket className="h-4 w-4" />}
onClick={() => deploy({ stack: id })}
disabled={pending}
loading={pending}
disabled={isPending}
loading={isPending || deploying}
/>
);
}
@@ -50,8 +56,8 @@ export const DeployStack = ({ id }: { id: string }) => {
title="Deploy"
icon={<Rocket className="w-4 h-4" />}
onClick={() => deploy({ stack: id })}
disabled={pending}
loading={pending}
disabled={isPending}
loading={isPending || deploying}
/>
);
};
@@ -74,8 +80,6 @@ export const DestroyStack = ({ id }: { id: string }) => {
return null;
}
const pending = isPending || destroying;
if (!stack) {
return null;
}
@@ -86,8 +90,8 @@ export const DestroyStack = ({ id }: { id: string }) => {
title="Destroy"
icon={<Trash2 className="h-4 w-4" />}
onClick={() => destroy({ stack: id })}
disabled={pending}
loading={pending}
disabled={isPending}
loading={isPending || destroying}
/>
);
};

View File

@@ -1,7 +1,14 @@
import { useInvalidate, useLocalStorage, useRead, useWrite } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { Card } from "@ui/card";
import { FolderGit, Layers, Loader2, RefreshCcw, Server } from "lucide-react";
import {
FolderGit,
Layers,
Loader2,
NotepadText,
RefreshCcw,
Server,
} from "lucide-react";
import { StackConfig } from "./config";
import { DeleteResource, NewResource, ResourceLink } from "../common";
import { StackTable } from "./table";
@@ -132,6 +139,15 @@ export const StackComponents: RequiredResourceComponents = {
}
return <StatusBadge text={state} intent={stack_state_intention(state)} />;
},
Status: ({ id }) => {
const info = useStack(id)?.info;
if (info?.state !== Types.StackState.Unhealthy) return null;
return (
info?.status && (
<p className="text-sm text-muted-foreground">{info.status}</p>
)
);
},
NoConfig: ({ id }) => {
const config = useFullStack(id)?.config;
if (config?.file_contents || config?.repo) {
@@ -148,9 +164,9 @@ export const StackComponents: RequiredResourceComponents = {
</HoverCardTrigger>
<HoverCardContent align="start">
<div className="grid gap-2">
No configuration provided for stack. Cannot get stack state. Either
paste the compose file contents into the UI, or configure a git repo
containing your files.
No configuration provided for stack. Cannot get stack state.
Either paste the compose file contents into the UI, or configure a
git repo containing your files.
</div>
</HoverCardContent>
</HoverCard>
@@ -286,21 +302,32 @@ export const StackComponents: RequiredResourceComponents = {
},
Info: {
Repo: ({ id }) => {
const repo = useStack(id)?.info.repo;
Contents: ({ id }) => {
const config = useFullStack(id)?.config;
const file_contents = config?.file_contents;
if (file_contents) {
return (
<div className="flex items-center gap-2">
<NotepadText className="w-4 h-4" />
Local
</div>
);
}
return (
<div className="flex items-center gap-2">
<FolderGit className="w-4 h-4" />
{repo}
{config?.repo}
</div>
);
},
// Branch: ({ id }) => {
// const branch = useStack(id)?.info.branch;
// const config = useFullStack(id)?.config;
// const file_contents = config?.file_contents;
// if (file_contents || !config?.branch) return null
// return (
// <div className="flex items-center gap-2">
// <FolderGit className="w-4 h-4" />
// {branch}
// <GitBranch className="w-4 h-4" />
// {config.branch}
// </div>
// );
// },

View File

@@ -22,8 +22,8 @@ export const StackInfo = ({
<Card>
<CardHeader>
deployed contents:{" "}
{stack?.info?.deployed_contents?.map((content) => (
<pre className="flex flex-col gap-2">
{stack?.info?.deployed_contents?.map((content, i) => (
<pre key={i} className="flex flex-col gap-2">
path: {content.path}
<pre>{content.contents}</pre>
</pre>
@@ -48,8 +48,8 @@ export const StackInfo = ({
<Card>
<CardHeader>
latest contents:{" "}
{stack?.info?.remote_contents?.map((content) => (
<pre className="flex flex-col gap-2">
{stack?.info?.remote_contents?.map((content, i) => (
<pre key={i} className="flex flex-col gap-2">
path: {content.path}
<pre>{content.contents}</pre>
</pre>
@@ -62,8 +62,8 @@ export const StackInfo = ({
<Card>
<CardHeader>
remote errors:{" "}
{stack?.info?.remote_errors?.map((content) => (
<pre className="flex flex-col gap-2">
{stack?.info?.remote_errors?.map((content, i) => (
<pre key={i} className="flex flex-col gap-2">
path: {content.path}
<pre>{content.contents}</pre>
</pre>

View File

@@ -50,7 +50,7 @@ export const StackServices = ({
);
return (
<Link
to={`/stacks/${id}/${row.original.service}`}
to={`/stacks/${id}/service/${row.original.service}`}
onClick={(e) => e.stopPropagation()}
>
<Button

View File

@@ -17,7 +17,7 @@ import { homeViewAtom } from "@main";
export const Sidebar = () => {
const [view, setView] = useAtom(homeViewAtom);
return (
<div className="fixed top-24 w-64 border-r hidden md:block pr-8 pb-4 h-[85vh] overflow-y-auto">
<div className="fixed top-24 w-64 border-r hidden lg:block pr-8 pb-4 h-[85vh] overflow-y-auto">
<div className="flex flex-col gap-1">
<p className="pl-4 pb-1 text-xs text-muted-foreground">Overviews</p>
<SidebarLink

View File

@@ -47,14 +47,14 @@ export const Topbar = () => {
return (
<div className="fixed top-0 w-full bg-background z-50 border-b shadow-sm">
<div className="container h-16 flex items-center justify-between md:grid md:grid-cols-2 lg:grid-cols-3">
<div className="container h-16 flex items-center justify-between md:grid md:grid-cols-[auto_1fr] lg:grid-cols-3">
{/* Logo */}
<Link
to="/"
className="flex gap-3 items-center text-2xl tracking-widest md:mx-2"
>
<img src="/monitor-circle.png" className="w-[28px] dark:invert" />
<div className="hidden md:block">MONITOR</div>
<div className="hidden lg:block">MONITOR</div>
</Link>
{/* Searchbar */}
@@ -131,7 +131,7 @@ const MobileDropdown = () => {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild className="md:hidden justify-self-end">
<DropdownMenuTrigger asChild className="lg:hidden justify-self-end">
<Button
variant="ghost"
className="flex justify-start items-center gap-2 w-36 px-3"

View File

@@ -156,6 +156,8 @@ export const repo_state_intention = (state?: Types.RepoState) => {
return "Warning";
case Types.RepoState.Pulling:
return "Warning";
case Types.RepoState.Building:
return "Warning";
case Types.RepoState.Failed:
return "Critical";
default:

View File

@@ -266,7 +266,7 @@ export const WsStatusIndicator = () => {
variant="ghost"
onClick={onclick}
size="icon"
className="inline-flex"
className="hidden lg:inline-flex"
>
<Circle
className={cn(

View File

@@ -84,14 +84,18 @@ const ResourceRow = ({ type }: { type: UsableResource }) => {
<History className="w-4" />
Recently Viewed
</p>
<div className="h-52 grid md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="h-52 grid sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-4">
{ids.map((id, i) => (
<RecentCard
key={type + id}
type={type}
id={id}
className={
i > 3 ? "hidden lg:flex" : i > 1 ? "hidden md:flex" : undefined
i > 4
? "hidden 2xl:flex"
: i > 1
? "hidden sm:flex lg:hidden xl:flex"
: undefined
}
/>
))}
@@ -126,7 +130,7 @@ const RecentCard = ({
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2 text-sm text-nowrap">
<Components.Icon id={id} />
<ResourceName type={type} id={id} />
</div>

View File

@@ -139,7 +139,7 @@ const ResourceHeader = ({ type, id }: { type: UsableResource; id: string }) => {
{infoEntries.map(([key, Info]) => (
<div
key={key}
className="pr-4 border-r last:pr-0 last:border-none"
className="pr-4 text-sm border-r last:pr-0 last:border-none"
>
<Info id={id} />
</div>

View File

@@ -36,7 +36,6 @@ export const Resources = () => {
return (
<Page
wrapSize="lg"
title={`${name}s`}
subtitle={
<div className="text-muted-foreground">

View File

@@ -34,10 +34,24 @@ const Actions: { [action: string]: IdServiceComponent } = {
};
export const StackServicePage = () => {
const { id: stack_id, service } = useParams() as {
const { type, id, service } = useParams() as {
type: string;
id: string;
service: string;
};
if (type !== "stacks") {
return <div>This resource type does not have any services.</div>;
}
return <StackServicePageInner stack_id={id} service={service} />;
};
const StackServicePageInner = ({
stack_id,
service,
}: {
stack_id: string;
service: string;
}) => {
const stack = useStack(stack_id);
useSetTitle(`${stack?.name} | ${service}`);
const nav = useNavigate();

View File

@@ -38,10 +38,6 @@ const ROUTER = createBrowserRouter([
{ path: ":id", element: <UserPage /> },
],
},
{
path: "stacks/:id/:service",
element: <StackServicePage />,
},
{
path: ":type",
children: [
@@ -50,6 +46,10 @@ const ROUTER = createBrowserRouter([
{ path: ":id/stats", element: <ResourceStats /> },
{ path: ":id/updates", element: <Updates /> },
{ path: ":id/alerts", element: <Alerts /> },
{
path: ":id/service/:service",
element: <StackServicePage />,
},
],
},
],

View File

@@ -1,12 +1,12 @@
# Monitor 🦎
A tool to build and deploy software across many servers. [See the docs](https://docs.monitor.dev)
A tool to build and deploy software across many servers. [See the docs](https://docs.monitor.dev). [Try the Demo](https://demo.monitor.dev).
Docs for periphery setup script can be found in [scripts/readme.md](https://github.com/mbecker20/monitor/blob/main/scripts/readme.md).
## Disclaimer
Warning. This is open source software (GPL-V3), and while the Monitor team makes a best effort to ensure main is stable,
Warning. This is open source software (GPL-V3), and while the Monitor team (mainly me) makes a best effort to ensure releases are stable and bug-free,
there are no warranties. Use at your own risk.
## Links