Compare commits

...

126 Commits

Author SHA1 Message Date
mbecker20
b375708bbd 0.2.13 support config directories 2023-04-01 19:02:43 +00:00
mbecker20
10b6a9482b update aws sdk verison and implement merge_config_files 2023-04-01 07:06:17 +00:00
mbecker20
84d45c5df8 0.2.12 fix docker build command interp 2023-03-31 18:06:18 +00:00
mbecker20
c6559814b1 frontend for docker build extra args and use buildx 2023-03-31 17:31:39 +00:00
mbecker20
c8c080183f remove publish for cli 2023-03-31 17:04:15 +00:00
mbecker20
597b67f799 0.2.11 support buildx and arbitrary extra args 2023-03-31 17:03:38 +00:00
mbecker20
ec52d5f422 support docker buildx build and passing arbitrary extra args 2023-03-31 16:57:02 +00:00
mbecker20
34806304d6 add center menu title bottom border and adjust copy menu 2023-03-31 05:41:35 +00:00
beckerinj
87953d5495 menu padding 2rem 2023-03-31 01:27:17 -04:00
beckerinj
b6c7c80c95 full width input for copy menu 2023-03-31 01:26:19 -04:00
beckerinj
77e568d5c3 small 2023-03-27 12:41:59 -04:00
mbecker20
699fc51cf7 link to build if click on image deployment header 2023-03-27 15:30:11 +00:00
mbecker20
21029c90b7 info page on stats page 2023-03-27 05:13:12 +00:00
mbecker20
6b0530eb7f brush up server stats page 2023-03-26 23:15:58 +00:00
beckerinj
f7061c7225 toggle to show absolutes for mem and disk stat graphs 2023-03-26 18:47:21 -04:00
mbecker20
750f698369 updates page 2023-03-26 02:20:39 +00:00
mbecker20
ec5ef42298 add max height / scrolling to copy menu target selector 2023-03-24 00:45:47 +00:00
beckerinj
46820b0044 increase the tab title padding 2023-03-23 20:36:31 -04:00
beckerinj
425a6648f7 improve summary styling 2023-03-23 03:13:19 -04:00
mbecker20
349fc297ce 0.2.10 add renaming functionality 2023-03-22 20:33:26 +00:00
mbecker20
5ad87c03ed show none when none 2023-03-22 07:16:04 +00:00
mbecker20
d16006f28f improve design 2023-03-22 07:03:28 +00:00
beckerinj
7f0452a5f5 improve pie chart home page 2023-03-22 02:59:29 -04:00
mbecker20
c605b2f6fc implement pie chart summary 2023-03-22 06:41:57 +00:00
beckerinj
6c2d8a8494 unnecessary import 2023-03-21 23:10:01 -07:00
mbecker20
874691f729 add a pie chart component 2023-03-21 09:44:00 +00:00
beckerinj
cdf702e17d orange 2023-03-21 00:52:25 -07:00
mbecker20
25fdb32627 rename deployments 2023-03-19 08:14:54 +00:00
mbecker20
e976ea0a3a improve the behavior 2023-03-17 20:55:37 +00:00
mbecker20
34e6b4fc69 rename server working 2023-03-17 20:40:19 +00:00
mbecker20
a2d77567b3 dont need to 'to_monitor_name' servers 2023-03-15 07:35:54 +00:00
mbecker20
ecb460f9b5 add rename deployment to monitor client 2023-03-14 20:15:27 +00:00
mbecker20
63444b089c rename deployment func 2023-03-12 23:36:20 +00:00
mbecker20
c787984b77 initialize mongo with builder 2023-03-12 22:03:31 +00:00
mbecker20
bf3d03e801 fix problem of repeated query for docker accounts, secrets, etc 2023-03-12 05:07:55 +00:00
mbecker20
bc2e69b975 use resource to load stuff 2023-03-12 03:45:49 +00:00
mbecker20
7b94fcf3da 0.2.9 finish implement secret helpers on frontend 2023-03-12 00:48:18 +00:00
mbecker20
9cf03b8b88 add route to get available secret keys 2023-03-12 00:16:03 +00:00
mbecker20
a288edcf61 0.2.8 implement secret interpolation on builds and deployments 2023-03-11 23:34:17 +00:00
mbecker20
89cc18ad37 update tokio version 2023-03-10 19:27:40 +00:00
mbecker20
ffa3b671e1 change default alerting thresholds 2023-03-09 07:08:38 +00:00
beckerinj
f32eeb413b add label to home sort by 2023-03-08 16:10:23 -05:00
mbecker20
b5a5103cfc move core dockerfile 2023-03-08 18:26:42 +00:00
mbecker20
c5697e59f3 delete sample file 2023-03-08 18:24:15 +00:00
mbecker20
f030667ff4 update image in deployment header as well 2023-03-07 17:41:00 +00:00
mbecker20
e9fef5d97c change get_deployment_deployed_version to 'unknown' if not known 2023-03-07 17:39:59 +00:00
beckerinj
f5818ac7ea actually return image 2023-03-07 12:37:45 -05:00
mbecker20
c85ab4110d show derived image is container.image is sha256: 2023-03-07 16:30:59 +00:00
mbecker20
9690ea35b8 make description text area larger 2023-03-07 08:44:31 +00:00
mbecker20
6300c8011b fix modify global user permissions operator - make operator the admin, instead of the target 2023-03-06 17:09:12 +00:00
mbecker20
97f582b381 customizable page title 2023-03-06 02:07:08 +00:00
mbecker20
5135a9c228 show server name under deployment on admin user manage page 2023-03-06 01:46:40 +00:00
mbecker20
b7d1212a82 make resources links in account page 2023-03-05 21:50:15 +00:00
mbecker20
7d9d0a9fc4 add view of resources you can access on account page 2023-03-05 21:42:11 +00:00
beckerinj
ed9aef4321 add resources to account page 2023-03-05 16:33:40 -05:00
mbecker20
0aa638bdf4 only do daily update if servers not empty 2023-03-05 20:19:06 +00:00
mbecker20
0ec39d793d one page to view all permissions for user 2023-03-05 09:24:59 +00:00
mbecker20
5579ba869c v0.2.7 remove passkeys from periphery startup log 2023-03-03 17:27:42 +00:00
mbecker20
210940038c hide passkeys on periphery startup config log 2023-03-03 17:24:28 +00:00
mbecker20
98a1a60362 /home/ubuntu/example 2023-03-03 08:15:17 +00:00
mbecker20
86cf9116ba update builds and deployments docs with link to file paths doc 2023-03-03 08:09:41 +00:00
mbecker20
8b2defe0d9 add doc about file paths 2023-03-03 07:58:09 +00:00
mbecker20
50b14b3ce5 0.2.6 store ami name instead of ami_id (because the id has to change sometimes) 2023-03-03 07:11:55 +00:00
mbecker20
1bfb17cb5d handle setting default ami id correctly 2023-03-02 21:32:22 +00:00
mbecker20
b90acb66c7 0.2.5 stop leaking github token 2023-03-02 21:25:46 +00:00
mbecker20
7648b0dd10 don't let github access token leak when clone fails 2023-03-02 21:21:07 +00:00
mbecker20
2d69f1791a default builds to use aws config on create 2023-03-02 17:11:23 +00:00
mbecker20
5ba887095a allow select "none" for docker organization 2023-03-02 17:00:40 +00:00
mbecker20
19b7405562 show organization immediately if it exists on build 2023-03-01 23:20:54 +00:00
mbecker20
f5c5f734e1 clean deployment / build config before update 2023-03-01 21:18:40 +00:00
mbecker20
8d1639bcaf fix build permissions 2023-03-01 10:18:58 +00:00
mbecker20
e2446af00e remove print 2023-03-01 10:12:26 +00:00
mbecker20
1b39aaaa38 implement description 2023-03-01 09:46:50 +00:00
mbecker20
5a2a1a3d98 0.2.4: add description and update description 2023-03-01 08:13:07 +00:00
mbecker20
39eceb745b v0.2.2: configure docker organizations for builds 2023-03-01 07:18:49 +00:00
beckerinj
4c1ec5db33 edit permissions.md 2023-02-28 02:26:57 -05:00
beckerinj
8b68b9481e permissions.md 2023-02-28 02:21:46 -05:00
beckerinj
14843f83c6 add core setup link to table of contents 2023-02-28 01:56:05 -05:00
beckerinj
e67d87e885 even 2023-02-28 01:53:54 -05:00
beckerinj
7d4d865d58 elaborate on networks 2023-02-28 01:53:04 -05:00
beckerinj
1e4aaff23c if to is 2023-02-28 01:37:43 -05:00
beckerinj
df3f4a5f4a improve builds.md 2023-02-28 01:36:35 -05:00
beckerinj
1f8557300d fix type 2023-02-28 01:33:55 -05:00
beckerinj
bf17d705f0 fix typo 2023-02-28 01:33:09 -05:00
beckerinj
0d24b792c6 container lifetime management 2023-02-28 01:30:43 -05:00
mbecker20
fb61e36417 remove download log button, its kind of unsafe if the log is long 2023-02-28 06:18:42 +00:00
beckerinj
c39869d2f8 deployments.md 2023-02-28 01:18:11 -05:00
mbecker20
750e0274da #example 2023-02-28 05:54:43 +00:00
beckerinj
a9d37ab667 add placeholders to show to to pass env 2023-02-28 00:52:09 -05:00
mbecker20
eacb549d5e update core config example with github_webhook_base_url 2023-02-28 05:08:05 +00:00
mbecker20
ce7cb8fe45 improve confirm menu with copy button 2023-02-28 04:58:39 +00:00
mbecker20
f9fe4e32b4 restyle builds and deployments 2023-02-28 04:24:15 +00:00
mbecker20
2c9fc2bad4 always show docker account 2023-02-28 03:57:35 +00:00
mbecker20
94949291c2 fix notifications, add dynamic listener url 2023-02-28 03:41:25 +00:00
beckerinj
2944ba6ef9 cli v0.2.3 2023-02-27 22:18:04 -05:00
beckerinj
997e68a31d dynamic github webhook base url 2023-02-27 22:17:37 -05:00
beckerinj
bfb9d9e34d add periphery version in builder connected logs 2023-02-27 21:46:43 -05:00
mbecker20
3b9219b586 fix updates selector style 2023-02-27 05:55:33 +00:00
mbecker20
7bf2a88ab1 finish build args section 2023-02-27 05:53:56 +00:00
mbecker20
d21ed093dc fix build args gap 2023-02-27 05:52:13 +00:00
mbecker20
6e89671e91 switch cli build and build args build config 2023-02-27 05:47:10 +00:00
beckerinj
ee1128a666 Update builds.md 2023-02-27 00:45:16 -05:00
beckerinj
63b5deecd7 Update servers.md 2023-02-27 00:44:01 -05:00
mbecker20
f4f97ce1a7 finish builds / servers 2023-02-27 05:42:22 +00:00
mbecker20
a666df099f use image on deployment container 2023-02-26 06:55:31 +00:00
mbecker20
21dd0ee072 cli should be 0.2.2 2023-02-26 06:48:38 +00:00
mbecker20
bd2a1d4236 v0.2.1 merge multiple config files 2023-02-26 06:25:26 +00:00
mbecker20
7acdbcfd8f improve updates selector - add class 2023-02-25 22:34:37 +00:00
mbecker20
58514c5c93 fix height of builder config when no builder type chosen 2023-02-25 22:07:05 +00:00
mbecker20
580e800923 fix clap args with - 2023-02-23 23:14:02 +00:00
mbecker20
29f6b19f33 cli 0.2.0. fix starting mongo when no existing container present 2023-02-23 22:46:17 +00:00
mbecker20
e090247723 fix error when user doesn't have access to build on deployment 2023-02-23 08:00:55 +00:00
mbecker20
1374c26cd8 0.2.0 cleanup 2023-02-23 07:28:11 +00:00
mbecker20
5467b40b2e fix to git clone <TOKEN> splice 2023-02-23 07:22:32 +00:00
mbecker20
165b9012da improve users responsiveness 2023-02-23 07:17:18 +00:00
mbecker20
22630f665e update the manage users page 2023-02-23 07:09:33 +00:00
mbecker20
3d867084ba log poll default to false 2023-02-23 06:44:55 +00:00
mbecker20
171dd2d9e0 remove menu animation, change builder type to selector 2023-02-23 06:38:36 +00:00
mbecker20
9709239f88 build version h2 2023-02-22 22:45:05 +00:00
mbecker20
60d457b285 improve deployment in tree and display deployed version in header 2023-02-22 22:27:54 +00:00
mbecker20
8b1d4793a7 0.1.17 support building with ec2 instances 2023-02-22 21:05:03 +00:00
mbecker20
f2166c8435 configure aws config on builds 2023-02-22 20:49:56 +00:00
mbecker20
07d723a748 more prog on frontend, some api etc 2023-02-22 06:39:32 +00:00
mbecker20
b36f485287 put server / aws build on build header 2023-02-21 23:05:49 +00:00
mbecker20
a121ae0828 begin frontend refactor for ephemeral build support 2023-02-21 18:11:43 +00:00
mbecker20
e2b5a02008 building works 2023-02-21 05:22:26 +00:00
180 changed files with 5857 additions and 2491 deletions

25
.vscode/tasks.json vendored
View File

@@ -92,15 +92,6 @@
"cwd": "${workspaceFolder}/lib/types"
}
},
{
"type": "cargo",
"command": "publish",
"args": ["--allow-dirty"],
"label": "publish monitor helpers",
"options": {
"cwd": "${workspaceFolder}/lib/helpers"
}
},
{
"type": "cargo",
"command": "publish",
@@ -109,22 +100,6 @@
"cwd": "${workspaceFolder}/lib/monitor_client"
}
},
{
"type": "cargo",
"command": "publish",
"label": "publish monitor periphery",
"options": {
"cwd": "${workspaceFolder}/periphery"
}
},
{
"type": "cargo",
"command": "publish",
"label": "publish monitor cli",
"options": {
"cwd": "${workspaceFolder}/cli"
}
},
{
"type": "shell",
"command": "docker compose up -d",

835
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
FROM rust:latest as builder
WORKDIR /builder
COPY ./periphery ./periphery
COPY ./lib/types ./lib/types
COPY ./lib/helpers ./lib/helpers
RUN cd periphery && cargo build --release
FROM debian:stable-slim
ARG DEPS_INSTALLER
COPY ./${DEPS_INSTALLER}.sh ./
RUN sh ./${DEPS_INSTALLER}.sh
COPY --from=builder /builder/periphery/target/release/periphery /usr/local/bin/periphery
EXPOSE 8000
CMD "periphery"

View File

@@ -1,6 +1,6 @@
[package]
name = "monitor_cli"
version = "0.1.22"
version = "0.2.13"
edition = "2021"
authors = ["MoghTech"]
description = "monitor cli | tools to setup monitor system"
@@ -13,6 +13,7 @@ path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
monitor_types = { path = "../lib/types" }
clap = "4.0"
async_timing_util = "0.1.14"
rand = "0.8"

View File

@@ -7,15 +7,13 @@ use std::{
str::FromStr,
};
use async_timing_util::Timelength;
use clap::ArgMatches;
use colored::Colorize;
use monitor_types::{CoreConfig, MongoConfig, PeripheryConfig, RestartMode, Timelength};
use rand::{distributions::Alphanumeric, Rng};
use run_command::run_command_pipe_to_terminal;
use serde::Serialize;
use crate::types::{CoreConfig, MongoConfig, PeripheryConfig, RestartMode};
const CORE_IMAGE_NAME: &str = "mbecker2020/monitor_core";
const PERIPHERY_IMAGE_NAME: &str = "mbecker2020/monitor_periphery";
const PERIPHERY_CRATE: &str = "monitor_periphery";
@@ -64,6 +62,7 @@ pub fn gen_core_config(sub_matches: &ArgMatches) {
.map(|p| p.to_owned());
let config = CoreConfig {
title: String::from("monitor"),
host,
port,
jwt_valid_for,
@@ -75,6 +74,7 @@ pub fn gen_core_config(sub_matches: &ArgMatches) {
github_oauth: Default::default(),
google_oauth: Default::default(),
aws: Default::default(),
docker_organizations: Default::default(),
mongo: MongoConfig {
uri: mongo_uri,
db_name: mongo_db_name,
@@ -82,6 +82,7 @@ pub fn gen_core_config(sub_matches: &ArgMatches) {
},
jwt_secret: generate_secret(40),
github_webhook_secret: generate_secret(30),
github_webhook_base_url: None,
passkey: generate_secret(30),
};
@@ -178,7 +179,10 @@ pub fn start_mongo(sub_matches: &ArgMatches) {
}
}
let command = format!("docker stop {name} && docker container rm {name} && docker run -d --name {name} -p {port}:27017 --network {network} -v {mount}:/data/db{env} --restart {restart} --log-opt max-size=15m --log-opt max-file=3 mongo --quiet");
let stop =
run_command_pipe_to_terminal(&format!("docker stop {name} && docker container rm {name}"));
let command = format!("docker run -d --name {name} -p {port}:27017 --network {network} -v {mount}:/data/db{env} --restart {restart} --log-opt max-size=15m --log-opt max-file=3 mongo --quiet");
let output = run_command_pipe_to_terminal(&command);
@@ -316,7 +320,9 @@ pub fn gen_periphery_config(sub_matches: &ArgMatches) {
.map(|p| p.as_str())
.unwrap_or("~/.monitor/repos")
.to_string()
.replace("~", env::var("HOME").unwrap().as_str());
.replace("~", env::var("HOME").unwrap().as_str())
.parse()
.expect("failed to parse --repo_dir as path");
let config = PeripheryConfig {
port,

View File

@@ -3,7 +3,6 @@
use clap::{arg, Arg, Command};
mod helpers;
mod types;
use helpers::*;
@@ -36,19 +35,19 @@ fn cli() -> Command {
.required(false)
)
.arg(
arg!(--mongo-uri <URI> "sets the mongo uri to use. default is 'mongodb://monitor-mongo'")
arg!(--"mongo-uri" <URI> "sets the mongo uri to use. default is 'mongodb://monitor-mongo'")
.required(false)
)
.arg(
arg!(--mongo-db-name <NAME> "sets the db name to use. default is 'monitor'")
arg!(--"mongo-db-name" <NAME> "sets the db name to use. default is 'monitor'")
.required(false)
)
.arg(
arg!(--jwt-valid-for <TIMELENGTH> "sets the length of time jwt stays valid for. default is 1-wk (one week)")
arg!(--"jwt-valid-for" <TIMELENGTH> "sets the length of time jwt stays valid for. default is 1-wk (one week)")
.required(false)
)
.arg(
arg!(--slack-url <URL> "sets the slack url to use for slack notifications")
arg!(--"slack-url" <URL> "sets the slack url to use for slack notifications")
.required(false)
),
)
@@ -96,7 +95,7 @@ fn cli() -> Command {
arg!(--name <NAME> "specify the name of the monitor core container. default is monitor-core")
)
.arg(
arg!(--config-path <PATH> "specify the file path to use for config. default is ~/.monitor/core.config.toml")
arg!(--"config-path" <PATH> "specify the file path to use for config. default is ~/.monitor/core.config.toml")
.required(false)
)
.arg(
@@ -111,7 +110,7 @@ fn cli() -> Command {
arg!(--restart <RESTART> "sets docker restart mode of monitor core container. default is unless-stopped")
)
.arg(
arg!(--add-internal-host "adds the docker flag '--add-host=host.docker.internal:host-gateway'. default is true")
arg!(--"add-internal-host" "adds the docker flag '--add-host=host.docker.internal:host-gateway'. default is true")
)
),
)
@@ -133,15 +132,15 @@ fn cli() -> Command {
.required(false)
)
.arg(
arg!(--stats-polling-rate <INTERVAL> "sets stats polling rate to control granularity of system stats returned. default is 5-sec. options: 1-sec, 5-sec, 10-sec, 30-sec, 1-min")
arg!(--"stats-polling-rate" <INTERVAL> "sets stats polling rate to control granularity of system stats returned. default is 5-sec. options: 1-sec, 5-sec, 10-sec, 30-sec, 1-min")
.required(false)
)
.arg(
arg!(--allowed-ips <IPS> "used to only accept requests from known ips. give ips as comma seperated list, like '--allowed_ips 127.0.0.1,10.20.30.43'. default is empty, which will not block any ip.")
arg!(--"allowed-ips" <IPS> "used to only accept requests from known ips. give ips as comma seperated list, like '--allowed_ips 127.0.0.1,10.20.30.43'. default is empty, which will not block any ip.")
.required(false)
)
.arg(
arg!(--repo-dir <PATH> "if running in container, this should be '/repos'. default is ~/.monitor/repos").required(false)
arg!(--"repo-dir" <PATH> "if running in container, this should be '/repos'. default is ~/.monitor/repos").required(false)
)
)
.subcommand(
@@ -157,7 +156,7 @@ fn cli() -> Command {
arg!(--install "specify this to install periphery from crates.io")
)
.arg(
arg!(--config-path <PATH> "specify the file path to use for config. default is ~/.monitor/periphery.config.toml")
arg!(--"config-path" <PATH> "specify the file path to use for config. default is ~/.monitor/periphery.config.toml")
.required(false)
)
)
@@ -171,7 +170,7 @@ fn cli() -> Command {
arg!(--install "specify this to install periphery from crates.io")
)
.arg(
arg!(--config-path <PATH> "specify the file path to use for config. default is ~/.monitor/periphery.config.toml")
arg!(--"config-path" <PATH> "specify the file path to use for config. default is ~/.monitor/periphery.config.toml")
.required(false)
)
.arg(
@@ -183,32 +182,32 @@ fn cli() -> Command {
.required(false)
)
)
.subcommand(
Command::new("container")
.about("start up monitor periphery in docker container")
.arg(
arg!(--yes "used in scripts to skip 'enter to continue' step")
)
.arg(
arg!(--name <NAME> "specify the name of the monitor periphery container. default is monitor-periphery")
)
.arg(
arg!(--config-path <PATH> "specify the file path to use for config. default is ~/.monitor/periphery.config.toml")
.required(false)
)
.arg(arg!(--repo-dir <PATH> "specify the folder on host to clone repos into. default is ~/.monitor/repos").required(false))
.arg(
arg!(--port <PORT> "sets port monitor periphery will run on. default is 8000")
.required(false)
)
.arg(
arg!(--network <NETWORK> "sets docker network of monitor periphery container. default is bridge")
.required(false)
)
.arg(
arg!(--restart <RESTART> "sets docker restart mode of monitor periphery container. default is unless-stopped")
)
)
// .subcommand(
// Command::new("container")
// .about("start up monitor periphery in docker container")
// .arg(
// arg!(--yes "used in scripts to skip 'enter to continue' step")
// )
// .arg(
// arg!(--name <NAME> "specify the name of the monitor periphery container. default is monitor-periphery")
// )
// .arg(
// arg!(--"config-path" <PATH> "specify the file path to use for config. default is ~/.monitor/periphery.config.toml")
// .required(false)
// )
// .arg(arg!(--"repo-dir" <PATH> "specify the folder on host to clone repos into. default is ~/.monitor/repos").required(false))
// .arg(
// arg!(--port <PORT> "sets port monitor periphery will run on. default is 8000")
// .required(false)
// )
// .arg(
// arg!(--network <NETWORK> "sets docker network of monitor periphery container. default is bridge")
// .required(false)
// )
// .arg(
// arg!(--restart <RESTART> "sets docker restart mode of monitor periphery container. default is unless-stopped")
// )
// )
),
)
}
@@ -239,7 +238,7 @@ fn main() {
match periphery_start_command {
("systemd", sub_matches) => start_periphery_systemd(sub_matches),
("daemon", sub_matches) => start_periphery_daemon(sub_matches),
("container", sub_matches) => start_periphery_container(sub_matches),
// ("container", sub_matches) => start_periphery_container(sub_matches),
_ => println!("\n❌ invalid call, should be 'monitor periphery start <daemon, container> <flags>' ❌\n")
}
}

View File

@@ -1,177 +0,0 @@
use std::{collections::HashMap, net::IpAddr};
use async_timing_util::Timelength;
use serde_derive::{Deserialize, Serialize};
use strum_macros::{Display, EnumString};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct CoreConfig {
// the host to use with oauth redirect url, whatever host the user hits to access monitor. eg 'https://monitor.mogh.tech'
pub host: String,
// port the core web server runs on
#[serde(default = "default_core_port")]
pub port: u16,
// daily utc offset in hours to run daily update. eg 8:00 eastern time is 13:00 UTC, so offset should be 13. default of 0 runs at UTC midnight.
#[serde(default)]
pub daily_offset_hours: u8,
// number of days to keep stats around, or 0 to disable pruning. stats older than this number of days are deleted daily
#[serde(default)]
pub keep_stats_for_days: u64, // 0 means never prune
pub jwt_secret: String,
#[serde(default = "default_jwt_valid_for")]
pub jwt_valid_for: Timelength,
// interval at which to collect server stats and alert for out of bounds
pub monitoring_interval: Timelength,
// used to verify validity from github webhooks
pub github_webhook_secret: String,
// sent in auth header with req to periphery
pub passkey: String,
// integration with slack app
pub slack_url: Option<String>,
// enable login with local auth
pub local_auth: bool,
pub mongo: MongoConfig,
#[serde(default)]
pub github_oauth: OauthCredentials,
#[serde(default)]
pub google_oauth: OauthCredentials,
#[serde(default)]
pub aws: AwsBuilderConfig,
}
fn default_core_port() -> u16 {
9000
}
fn default_jwt_valid_for() -> Timelength {
Timelength::OneWeek
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct OauthCredentials {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub id: String,
#[serde(default)]
pub secret: String,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct MongoConfig {
pub uri: String,
#[serde(default = "default_core_mongo_app_name")]
pub app_name: String,
#[serde(default = "default_core_mongo_db_name")]
pub db_name: String,
}
fn default_core_mongo_app_name() -> String {
"monitor_core".to_string()
}
fn default_core_mongo_db_name() -> String {
"monitor".to_string()
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct AwsBuilderConfig {
pub access_key_id: String,
pub secret_access_key: String,
pub default_ami_id: String,
pub default_subnet_id: String,
pub default_key_pair_name: String,
#[serde(default = "default_aws_region")]
pub default_region: String,
#[serde(default = "default_volume_gb")]
pub default_volume_gb: i32,
#[serde(default = "default_instance_type")]
pub default_instance_type: String,
#[serde(default)]
pub default_security_group_ids: Vec<String>,
#[serde(default)]
pub default_assign_public_ip: bool,
}
fn default_aws_region() -> String {
String::from("us-east-1")
}
fn default_volume_gb() -> i32 {
8
}
fn default_instance_type() -> String {
String::from("m5.2xlarge")
}
pub type GithubUsername = String;
pub type GithubToken = String;
pub type GithubAccounts = HashMap<GithubUsername, GithubToken>;
pub type DockerUsername = String;
pub type DockerToken = String;
pub type DockerAccounts = HashMap<DockerUsername, DockerToken>;
pub type SecretsMap = HashMap<String, String>;
#[derive(Serialize, Deserialize, Debug)]
pub struct PeripheryConfig {
#[serde(default = "default_periphery_port")]
pub port: u16,
#[serde(default = "default_repo_dir")]
pub repo_dir: String,
#[serde(default = "default_stats_refresh_interval")]
pub stats_polling_rate: Timelength,
#[serde(default)]
pub allowed_ips: Vec<IpAddr>,
#[serde(default)]
pub passkeys: Vec<String>,
#[serde(default)]
pub secrets: SecretsMap,
#[serde(default)]
pub github_accounts: GithubAccounts,
#[serde(default)]
pub docker_accounts: DockerAccounts,
}
fn default_periphery_port() -> u16 {
8000
}
fn default_repo_dir() -> String {
"/repos".to_string()
}
fn default_stats_refresh_interval() -> Timelength {
Timelength::FiveSeconds
}
#[derive(Serialize, Deserialize, Debug, Display, EnumString, PartialEq, Hash, Eq, Clone, Copy)]
pub enum RestartMode {
#[serde(rename = "no")]
#[strum(serialize = "no")]
NoRestart,
#[serde(rename = "on-failure")]
#[strum(serialize = "on-failure")]
OnFailure,
#[serde(rename = "always")]
#[strum(serialize = "always")]
Always,
#[serde(rename = "unless-stopped")]
#[strum(serialize = "unless-stopped")]
UnlessStopped,
}

View File

@@ -1,5 +1,8 @@
# optional. this will be the document title on the web page (shows up as text in the browser tab). default is 'monitor'
title = "monitor"
# this should be the url used to access monitor in browser, potentially behind DNS, eg https://monitor.mogh.tech or http://12.34.56.78:9000
host = "http://localhost:9000"
host = "https://monitor.mogh.tech"
# the port the core system will run on. if running core in docker container, leave as this port as 9000 and use port bind eg. -p 9001:9000
port = 9000
@@ -19,9 +22,12 @@ jwt_valid_for = "1-wk"
# webhook url given by slack app
slack_url = "your_slack_app_webhook_url"
# token that has to be given to github during webhook config as the Secret
# token that has to be given to github during webhook config as the secret
github_webhook_secret = "your_random_webhook_secret"
# optional. an alternate base url that is used to recieve github webhook requests. if not provided, will use 'host' address as base
github_webhook_base_url = "https://monitor-github-webhook.mogh.tech"
# token used to authenticate core requests to periphery
passkey = "your_random_passkey"
@@ -31,6 +37,9 @@ monitoring_interval = "1-min"
# allow or deny user login with username / password
local_auth = true
# these will be given in the GUI to attach to builds. New build docker orgs will default to first org (or none if empty).
docker_organizations = ["your_docker_org1", "your_docker_org_2"]
[aws]
access_key_id = "your_aws_key_id"
secret_access_key = "your_aws_secret_key"
@@ -43,6 +52,9 @@ default_subnet_id = "your_default_subnet_id"
default_security_group_ids = ["sg_id_1", "sg_id_2"]
default_assign_public_ip = false
[aws.available_ami_accounts]
your_periphery_ami = { name = "default ami", github = ["github_username"], docker = ["docker_username"] }
[github_oauth]
enabled = true
id = "your_github_client_id"

View File

@@ -1,6 +1,6 @@
[package]
name = "core"
version = "0.1.16"
version = "0.2.13"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -11,15 +11,15 @@ types = { package = "monitor_types", path = "../lib/types" }
db = { package = "db_client", path = "../lib/db_client" }
periphery = { package = "periphery_client", path = "../lib/periphery_client" }
axum_oauth2 = { path = "../lib/axum_oauth2" }
tokio = { version = "1.25", features = ["full"] }
tokio = { version = "1.26", features = ["full"] }
tokio-tungstenite = { version = "0.18", features=["native-tls"] }
tokio-util = "0.7"
axum = { version = "0.6", features = ["ws", "json"] }
axum-extra = { version = "0.5", features = ["spa"] }
axum-extra = { version = "0.5.0", features = ["spa"] }
tower = { version = "0.4", features = ["full"] }
tower-http = { version = "0.3", features = ["cors"] }
tower-http = { version = "0.4.0", features = ["cors"] }
slack = { package = "slack_client_rs", version = "0.0.8" }
mungos = "0.3.3"
mungos = "0.3.14"
serde = "1.0"
serde_derive = "1.0"
serde_json = "1.0"
@@ -34,4 +34,7 @@ async_timing_util = "0.1.14"
futures-util = "0.3"
diff-struct = "0.5"
typeshare = "1.0.0"
hex = "0.4"
hex = "0.4"
aws-config = "0.55"
aws-sdk-ec2 = "0.25"
merge_config_files = "0.1.1"

View File

@@ -3,8 +3,8 @@ WORKDIR /builder
COPY ./core ./core
# COPY ./lib/types ./lib/types
# COPY ./lib/helpers ./lib/helpers
COPY ./lib/types ./lib/types
COPY ./lib/helpers ./lib/helpers
COPY ./lib/db_client ./lib/db_client
COPY ./lib/periphery_client ./lib/periphery_client

View File

@@ -1,20 +1,25 @@
use std::time::Duration;
use anyhow::{anyhow, Context};
use aws_sdk_ec2::Client;
use diff::Diff;
use helpers::{
all_logs_success,
aws::{self, create_ec2_client, create_instance_with_ami, terminate_ec2_instance, Ec2Instance},
to_monitor_name,
};
use helpers::{all_logs_success, to_monitor_name};
use mungos::{doc, to_bson};
use types::{
monitor_timestamp,
traits::{Busy, Permissioned},
Build, Log, Operation, PermissionLevel, Update, UpdateStatus, UpdateTarget, Version,
AwsBuilderBuildConfig, Build, Log, Operation, PermissionLevel, Update, UpdateStatus,
UpdateTarget, Version,
};
use crate::{auth::RequestUser, state::State};
use crate::{
auth::RequestUser,
cloud::aws::{
create_ec2_client, create_instance_with_ami, terminate_ec2_instance, Ec2Instance,
},
helpers::empty_or_only_spaces,
state::State,
};
const BUILDER_POLL_RATE_SECS: u64 = 2;
const BUILDER_POLL_MAX_TRIES: usize = 30;
@@ -51,6 +56,12 @@ impl State {
let start_ts = monitor_timestamp();
let build = Build {
name: to_monitor_name(name),
docker_organization: self
.config
.docker_organizations
.get(0)
.map(|d| d.to_string()),
aws_config: Some(AwsBuilderBuildConfig::default()),
permissions: [(user.id.clone(), PermissionLevel::Update)]
.into_iter()
.collect(),
@@ -158,18 +169,39 @@ impl State {
mut new_build: Build,
user: &RequestUser,
) -> anyhow::Result<Build> {
let start_ts = monitor_timestamp();
let current_build = self
.get_build_check_permissions(&new_build.id, user, PermissionLevel::Update)
.await?;
let start_ts = monitor_timestamp();
if let Some(new_server_id) = &new_build.server_id {
if current_build.server_id.is_none()
|| new_server_id != current_build.server_id.as_ref().unwrap()
{
self.get_server_check_permissions(new_server_id, user, PermissionLevel::Update)
.await
.context("user does not have permission to attach build to this server")?;
}
}
// none of these should be changed through this method
new_build.name = current_build.name.clone();
new_build.permissions = current_build.permissions.clone();
new_build.last_built_at = String::new();
new_build.last_built_at = current_build.last_built_at.clone();
new_build.created_at = current_build.created_at.clone();
new_build.updated_at = start_ts.clone();
// filter out any build args that contain empty strings
// these could only happen by accident
new_build.docker_build_args = new_build.docker_build_args.map(|mut args| {
args.build_args = args
.build_args
.into_iter()
.filter(|a| !empty_or_only_spaces(&a.variable) && !empty_or_only_spaces(&a.value))
.collect();
args
});
self.db
.builds
.update_one(&new_build.id, mungos::Update::Regular(new_build.clone()))
@@ -238,7 +270,7 @@ impl State {
async fn build_inner(&self, build_id: &str, user: &RequestUser) -> anyhow::Result<Update> {
let mut build = self
.get_build_check_permissions(build_id, user, PermissionLevel::Update)
.get_build_check_permissions(build_id, user, PermissionLevel::Execute)
.await?;
build.version.increment();
let mut update = Update {
@@ -288,8 +320,8 @@ impl State {
self.update_update(update).await?;
return Err(e);
}
let (server, aws_client, log) = res.unwrap();
update.logs.push(log);
let (server, aws_client, logs) = res.unwrap();
update.logs.extend(logs);
self.update_update(update.clone()).await?;
(server, aws_client)
} else {
@@ -309,7 +341,7 @@ impl State {
let clone_success = match self.periphery.clone_repo(&server.server, &build).await {
Ok(clone_logs) => {
update.logs.extend(clone_logs);
true
all_logs_success(&update.logs)
}
Err(e) => {
update
@@ -395,11 +427,11 @@ impl State {
async fn create_ec2_instance_for_build(
&self,
build: &Build,
) -> anyhow::Result<(Ec2Instance, Option<aws::Client>, Log)> {
) -> anyhow::Result<(Ec2Instance, Option<Client>, Vec<Log>)> {
if build.aws_config.is_none() {
return Err(anyhow!("build has no aws_config attached"));
}
let start_ts = monitor_timestamp();
let start_instance_ts = monitor_timestamp();
let aws_config = build.aws_config.as_ref().unwrap();
let region = aws_config
.region
@@ -412,10 +444,17 @@ impl State {
self.config.aws.secret_access_key.clone(),
)
.await;
let ami_id = aws_config
.ami_id
let ami_name = aws_config
.ami_name
.as_ref()
.unwrap_or(&self.config.aws.default_ami_id);
.unwrap_or(&self.config.aws.default_ami_name);
let ami_id = &self
.config
.aws
.available_ami_accounts
.get(ami_name)
.ok_or(anyhow!("no ami id associated with ami name {ami_name}"))?
.ami_id;
let instance_type = aws_config
.instance_type
.as_ref()
@@ -454,31 +493,42 @@ impl State {
assign_public_ip,
)
.await?;
let instance_id = &instance.instance_id;
let start_log = Log {
stage: "start build instance".to_string(),
success: true,
stdout: format!("instance id: {instance_id}\nami id: {ami_id}\ninstance type: {instance_type}\nvolume size: {volume_size_gb} GB\nsubnet id: {subnet_id}\nsecurity groups: {readable_sec_group_ids}"),
start_ts: start_instance_ts,
end_ts: monitor_timestamp(),
..Default::default()
};
let start_connect_ts = monitor_timestamp();
let mut res = Ok(String::new());
for _ in 0..BUILDER_POLL_MAX_TRIES {
let status = self.periphery.health_check(&instance.server).await;
if let Ok(_) = status {
let instance_id = &instance.instance_id;
let log = Log {
stage: "start build instance".to_string(),
let version = self.periphery.get_version(&instance.server).await;
if let Ok(version) = version {
let connect_log = Log {
stage: "build instance connected".to_string(),
success: true,
stdout: format!("instance id: {instance_id}\nami id: {ami_id}\ninstance type: {instance_type}\nvolume size: {volume_size_gb} GB\nsubnet id: {subnet_id}\nsecurity groups: {readable_sec_group_ids}"),
start_ts,
stdout: format!("established contact with periphery on builder\nperiphery version: v{version}"),
start_ts: start_connect_ts,
end_ts: monitor_timestamp(),
..Default::default()
};
return Ok((instance, Some(aws_client), log));
return Ok((instance, Some(aws_client), vec![start_log, connect_log]));
}
res = status;
res = version;
tokio::time::sleep(Duration::from_secs(BUILDER_POLL_RATE_SECS)).await;
}
let _ = terminate_ec2_instance(&aws_client, &instance.instance_id).await;
Err(anyhow!("unable to reach periphery agent on build server\n{res:#?}"))
Err(anyhow!(
"unable to reach periphery agent on build server\n{res:#?}"
))
}
async fn terminate_ec2_instance(
&self,
aws_client: aws::Client,
aws_client: Client,
server: &Ec2Instance,
update: &mut Update,
) {

View File

@@ -1,15 +1,17 @@
use anyhow::{anyhow, Context};
use diff::Diff;
use helpers::{all_logs_success, to_monitor_name};
use mungos::doc;
use types::{
monitor_timestamp,
traits::{Busy, Permissioned},
Deployment, Log, Operation, PermissionLevel, Update, UpdateStatus, UpdateTarget,
Deployment, DeploymentWithContainerState, DockerContainerState, Log, Operation,
PermissionLevel, ServerStatus, ServerWithStatus, Update, UpdateStatus, UpdateTarget,
};
use crate::{
auth::RequestUser,
helpers::{any_option_diff_is_some, option_diff_is_some},
helpers::{any_option_diff_is_some, empty_or_only_spaces, get_image_name, option_diff_is_some},
state::State,
};
@@ -197,6 +199,33 @@ impl State {
new_deployment.created_at = current_deployment.created_at.clone();
new_deployment.updated_at = start_ts.clone();
// filter out any volumes, ports, env vars, extra args which are or contain empty strings
// these could only happen by accident
new_deployment.docker_run_args.volumes = new_deployment
.docker_run_args
.volumes
.into_iter()
.filter(|v| !empty_or_only_spaces(&v.local) && !empty_or_only_spaces(&v.container))
.collect();
new_deployment.docker_run_args.ports = new_deployment
.docker_run_args
.ports
.into_iter()
.filter(|p| !empty_or_only_spaces(&p.local) && !empty_or_only_spaces(&p.container))
.collect();
new_deployment.docker_run_args.environment = new_deployment
.docker_run_args
.environment
.into_iter()
.filter(|e| !empty_or_only_spaces(&e.variable) && !empty_or_only_spaces(&e.value))
.collect();
new_deployment.docker_run_args.extra_args = new_deployment
.docker_run_args
.extra_args
.into_iter()
.filter(|a| a.len() != 0)
.collect();
self.db
.deployments
.update_one(
@@ -247,6 +276,157 @@ impl State {
Ok(new_deployment)
}
pub async fn rename_deployment(
&self,
deployment_id: &str,
new_name: &str,
user: &RequestUser,
) -> anyhow::Result<Update> {
if self.deployment_busy(&deployment_id).await {
return Err(anyhow!("deployment busy"));
}
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.renaming = true;
}
let res = self
.rename_deployment_inner(deployment_id, new_name, user)
.await;
{
let mut lock = self.deployment_action_states.lock().await;
let entry = lock.entry(deployment_id.to_string()).or_default();
entry.renaming = false;
}
res
}
async fn rename_deployment_inner(
&self,
deployment_id: &str,
new_name: &str,
user: &RequestUser,
) -> anyhow::Result<Update> {
let start_ts = monitor_timestamp();
let deployment = self
.get_deployment_check_permissions(deployment_id, user, PermissionLevel::Update)
.await?;
let mut update = Update {
target: UpdateTarget::Deployment(deployment_id.to_string()),
operation: Operation::RenameDeployment,
start_ts,
status: UpdateStatus::InProgress,
operator: user.id.to_string(),
success: true,
..Default::default()
};
update.id = self.add_update(update.clone()).await?;
let server_with_status = self.get_server(&deployment.server_id, user).await;
if server_with_status.is_err() {
update.logs.push(Log::error(
"get server",
format!(
"failed to get server info: {:?}",
server_with_status.as_ref().err().unwrap()
),
));
update.status = UpdateStatus::Complete;
update.end_ts = monitor_timestamp().into();
update.success = false;
self.update_update(update).await?;
return Err(server_with_status.err().unwrap());
}
let ServerWithStatus { server, status } = server_with_status.unwrap();
if status != ServerStatus::Ok {
update.logs.push(Log::error(
"check server status",
String::from("cannot rename deployment when periphery is disabled or unreachable"),
));
update.status = UpdateStatus::Complete;
update.end_ts = monitor_timestamp().into();
update.success = false;
self.update_update(update).await?;
return Err(anyhow!(
"cannot rename deployment when periphery is disabled or unreachable"
));
}
let deployment_state = self
.get_deployment_with_container_state(user, deployment_id)
.await;
if deployment_state.is_err() {
update.logs.push(Log::error(
"check deployment status",
format!(
"could not get current state of deployment: {:?}",
deployment_state.as_ref().err().unwrap()
),
));
update.status = UpdateStatus::Complete;
update.end_ts = monitor_timestamp().into();
update.success = false;
self.update_update(update).await?;
return Err(deployment_state.err().unwrap());
}
let DeploymentWithContainerState { state, .. } = deployment_state.unwrap();
if state != DockerContainerState::NotDeployed {
let log = self
.periphery
.container_rename(&server, &deployment.name, new_name)
.await;
if log.is_err() {
update.logs.push(Log::error(
"rename container",
format!("{:?}", log.as_ref().err().unwrap()),
));
update.status = UpdateStatus::Complete;
update.end_ts = monitor_timestamp().into();
update.success = false;
self.update_update(update).await?;
return Err(log.err().unwrap());
}
let log = log.unwrap();
if !log.success {
update.logs.push(log);
update.status = UpdateStatus::Complete;
update.end_ts = monitor_timestamp().into();
update.success = false;
self.update_update(update).await?;
return Err(anyhow!("rename container on periphery not successful"));
}
update.logs.push(log);
}
let res = self
.db
.deployments
.update_one(
deployment_id,
mungos::Update::<()>::Set(
doc! { "name": to_monitor_name(new_name), "updated_at": monitor_timestamp() },
),
)
.await
.context("failed to update deployment name on mongo");
if let Err(e) = res {
update
.logs
.push(Log::error("mongo update", format!("{e:?}")));
} else {
update.logs.push(Log::simple(
"mongo update",
String::from("updated name on mongo"),
))
}
update.end_ts = monitor_timestamp().into();
update.status = UpdateStatus::Complete;
update.success = all_logs_success(&update.logs);
self.update_update(update.clone()).await?;
Ok(update)
}
pub async fn reclone_deployment(
&self,
deployment_id: &str,
@@ -343,14 +523,12 @@ impl State {
.await?;
let version = if let Some(build_id) = &deployment.build_id {
let build = self.db.get_build(build_id).await?;
let image = if let Some(docker_account) = &build.docker_account {
if deployment.docker_run_args.docker_account.is_none() {
let image = get_image_name(&build);
if deployment.docker_run_args.docker_account.is_none() {
if let Some(docker_account) = &build.docker_account {
deployment.docker_run_args.docker_account = Some(docker_account.to_string())
}
format!("{docker_account}/{}", to_monitor_name(&build.name))
} else {
to_monitor_name(&build.name)
};
};
}
let version = if let Some(version) = &deployment.build_version {
version.clone()
} else {

View File

@@ -1,7 +1,6 @@
use anyhow::{anyhow, Context};
use diff::Diff;
use futures_util::future::join_all;
use helpers::to_monitor_name;
use mungos::doc;
use types::{
monitor_timestamp,
@@ -49,7 +48,7 @@ impl State {
}
let start_ts = monitor_timestamp();
let server = Server {
name: to_monitor_name(name),
name: name.to_string(),
address,
permissions: [(user.id.clone(), PermissionLevel::Update)]
.into_iter()

View File

@@ -7,8 +7,8 @@ use axum::{
use helpers::handle_anyhow_error;
use mungos::{doc, Deserialize, Document, FindOptions, Serialize};
use types::{
traits::Permissioned, Build, BuildActionState, BuildVersionsReponse, Operation,
PermissionLevel, UpdateStatus,
traits::Permissioned, AwsBuilderConfig, Build, BuildActionState, BuildVersionsReponse,
Operation, PermissionLevel, UpdateStatus,
};
use typeshare::typeshare;
@@ -208,6 +208,22 @@ pub fn router() -> Router {
},
),
)
.route(
"/aws_builder_defaults",
get(|Extension(state): StateExtension| async move {
Json(AwsBuilderConfig {
access_key_id: String::new(),
secret_access_key: String::new(),
..state.config.aws.clone()
})
}),
)
.route(
"/docker_organizations",
get(|Extension(state): StateExtension| async move {
Json(state.config.docker_organizations.clone())
}),
)
}
impl State {

View File

@@ -8,10 +8,11 @@ use axum::{
};
use futures_util::future::join_all;
use helpers::handle_anyhow_error;
use mungos::{Deserialize, Document, Serialize};
use mungos::{doc, options::FindOneOptions, Deserialize, Document, Serialize};
use types::{
traits::Permissioned, Deployment, DeploymentActionState, DeploymentWithContainerState,
DockerContainerState, DockerContainerStats, Log, PermissionLevel, Server,
DockerContainerState, DockerContainerStats, Log, Operation, PermissionLevel, Server,
UpdateStatus,
};
use typeshare::typeshare;
@@ -42,6 +43,12 @@ pub struct CopyDeploymentBody {
server_id: String,
}
#[typeshare]
#[derive(Serialize, Deserialize)]
pub struct RenameDeploymentBody {
new_name: String,
}
#[typeshare]
#[derive(Deserialize)]
pub struct GetContainerLogQuery {
@@ -161,6 +168,24 @@ pub fn router() -> Router {
},
),
)
.route(
"/:id/rename",
patch(
|state: StateExtension,
user: RequestUserExtension,
deployment: Path<DeploymentId>,
body: Json<RenameDeploymentBody>| async move {
let update = spawn_request_action(async move {
state
.rename_deployment(&deployment.id, &body.new_name, &user)
.await
.map_err(handle_anyhow_error)
})
.await??;
response!(Json(update))
},
),
)
.route(
"/:id/reclone",
post(
@@ -297,19 +322,33 @@ pub fn router() -> Router {
get(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(deployment_id): Path<DeploymentId>| async move {
Path(DeploymentId { id })| async move {
let stats = state
.get_deployment_container_stats(&deployment_id.id, &user)
.get_deployment_container_stats(&id, &user)
.await
.map_err(handle_anyhow_error)?;
response!(Json(stats))
},
),
)
.route(
"/:id/deployed_version",
get(
|Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Path(DeploymentId { id })| async move {
let version = state
.get_deployment_deployed_version(&id, &user)
.await
.map_err(handle_anyhow_error)?;
response!(version)
},
),
)
}
impl State {
async fn get_deployment_with_container_state(
pub async fn get_deployment_with_container_state(
&self,
user: &RequestUser,
id: &str,
@@ -443,4 +482,53 @@ impl State {
.await?;
Ok(stats)
}
async fn get_deployment_deployed_version(
&self,
id: &str,
user: &RequestUser,
) -> anyhow::Result<String> {
let deployment = self
.get_deployment_check_permissions(&id, &user, PermissionLevel::Read)
.await?;
if deployment.build_id.is_some() {
let latest_deploy_update = self
.db
.updates
.find_one(
doc! {
"target": {
"type": "Deployment",
"id": id
},
"operation": Operation::DeployContainer.to_string(),
"status": UpdateStatus::Complete.to_string(),
"success": true,
},
FindOneOptions::builder().sort(doc! { "_id": -1 }).build(),
)
.await
.context("failed at query to get latest deploy update from mongo")?;
if let Some(update) = latest_deploy_update {
if let Some(version) = update.version {
Ok(version.to_string())
} else {
Ok("unknown".to_string())
}
} else {
Ok("unknown".to_string())
}
} else {
let split = deployment
.docker_run_args
.image
.split(':')
.collect::<Vec<&str>>();
if let Some(version) = split.get(1) {
Ok(version.to_string())
} else {
Ok("unknown".to_string())
}
}
}
}

View File

@@ -1,20 +1,23 @@
use anyhow::Context;
use anyhow::{anyhow, Context};
use axum::{
body::Body,
extract::Path,
http::{Request, StatusCode},
middleware,
routing::get,
routing::{get, post},
Extension, Json, Router,
};
use futures_util::Future;
use helpers::handle_anyhow_error;
use mungos::Deserialize;
use types::User;
use mungos::{doc, Deserialize};
use types::{PermissionLevel, UpdateTarget, User};
use typeshare::typeshare;
use crate::{
auth::{auth_request, JwtExtension, RequestUserExtension},
state::StateExtension,
auth::{auth_request, JwtExtension, RequestUser, RequestUserExtension},
response,
state::{State, StateExtension},
ResponseResult,
};
pub mod build;
@@ -27,24 +30,66 @@ pub mod secret;
pub mod server;
pub mod update;
#[typeshare]
#[derive(Deserialize)]
struct UpdateDescriptionBody {
target: UpdateTarget,
description: String,
}
#[derive(Deserialize)]
struct UserId {
id: String,
}
pub fn router() -> Router {
Router::new()
.route(
"/user",
get(|jwt, req| async { get_user(jwt, req).await.map_err(handle_anyhow_error) }),
"/title",
get(|state: StateExtension| async move { state.config.title.clone() }),
)
.route("/user", get(get_request_user))
.nest("/listener", github_listener::router())
.nest(
"/",
Router::new()
.route("/user/:id", get(get_user_at_id))
.route(
"/username/:id",
get(|state, user_id| async {
get_username(state, user_id)
get(|state: StateExtension, Path(UserId { id })| async move {
let user = state
.db
.get_user(&id)
.await
.map_err(handle_anyhow_error)
.context("failed to find user at id")
.map_err(handle_anyhow_error)?;
response!(Json(user.username))
}),
)
.route(
"/github_webhook_base_url",
get(|state: StateExtension| async move {
state
.config
.github_webhook_base_url
.as_ref()
.unwrap_or(&state.config.host)
.to_string()
}),
)
.route(
"/update_description",
post(
|state: StateExtension,
user: RequestUserExtension,
body: Json<UpdateDescriptionBody>| async move {
state
.update_description(&body.target, &body.description, &user)
.await
.map_err(handle_anyhow_error)
},
),
)
.route("/users", get(get_users))
.nest("/build", build::router())
.nest("/deployment", deployment::router())
@@ -58,8 +103,11 @@ pub fn router() -> Router {
)
}
async fn get_user(Extension(jwt): JwtExtension, req: Request<Body>) -> anyhow::Result<Json<User>> {
let mut user = jwt.authenticate(&req).await?;
async fn get_request_user(
Extension(jwt): JwtExtension,
req: Request<Body>,
) -> ResponseResult<Json<User>> {
let mut user = jwt.authenticate(&req).await.map_err(handle_anyhow_error)?;
user.password = None;
for secret in &mut user.secrets {
secret.hash = String::new();
@@ -67,23 +115,10 @@ async fn get_user(Extension(jwt): JwtExtension, req: Request<Body>) -> anyhow::R
Ok(Json(user))
}
#[derive(Deserialize)]
struct UserId {
id: String,
}
async fn get_username(
state: StateExtension,
Path(UserId { id }): Path<UserId>,
) -> anyhow::Result<String> {
let user = state.db.get_user(&id).await?;
Ok(user.username)
}
async fn get_users(
state: StateExtension,
user: RequestUserExtension,
) -> Result<Json<Vec<User>>, (StatusCode, String)> {
) -> ResponseResult<Json<Vec<User>>> {
if user.is_admin {
let users = state
.db
@@ -105,8 +140,33 @@ async fn get_users(
}
}
async fn get_user_at_id(
state: StateExtension,
Path(UserId { id }): Path<UserId>,
user: RequestUserExtension,
) -> ResponseResult<Json<User>> {
if user.is_admin {
let mut user = state
.db
.users
.find_one_by_id(&id)
.await
.context("failed at query to get user from mongo")
.map_err(handle_anyhow_error)?
.ok_or(anyhow!(""))
.map_err(handle_anyhow_error)?;
user.password = None;
for secret in &mut user.secrets {
secret.hash = String::new();
}
Ok(Json(user))
} else {
Err((StatusCode::UNAUTHORIZED, "user is not admin".to_string()))
}
}
// need to run requested actions in here to prevent them being dropped mid action when user disconnects prematurely
pub async fn spawn_request_action<A>(action: A) -> Result<A::Output, (StatusCode, String)>
pub async fn spawn_request_action<A>(action: A) -> ResponseResult<A::Output>
where
A: Future + Send + 'static,
A::Output: Send + 'static,
@@ -117,3 +177,57 @@ where
.map_err(handle_anyhow_error)?;
Ok(res)
}
impl State {
pub async fn update_description(
&self,
target: &UpdateTarget,
description: &str,
user: &RequestUser,
) -> anyhow::Result<()> {
match target {
UpdateTarget::Build(id) => {
self.get_build_check_permissions(id, user, PermissionLevel::Update)
.await?;
self.db
.builds
.update_one::<()>(id, mungos::Update::Set(doc! { "description": description }))
.await?;
}
UpdateTarget::Deployment(id) => {
self.get_deployment_check_permissions(id, user, PermissionLevel::Update)
.await?;
self.db
.deployments
.update_one::<()>(id, mungos::Update::Set(doc! { "description": description }))
.await?;
}
UpdateTarget::Server(id) => {
self.get_server_check_permissions(id, user, PermissionLevel::Update)
.await?;
self.db
.servers
.update_one::<()>(id, mungos::Update::Set(doc! { "description": description }))
.await?;
}
UpdateTarget::Group(id) => {
self.get_group_check_permissions(id, user, PermissionLevel::Update)
.await?;
self.db
.groups
.update_one::<()>(id, mungos::Update::Set(doc! { "description": description }))
.await?;
}
UpdateTarget::Procedure(id) => {
self.get_procedure_check_permissions(id, user, PermissionLevel::Update)
.await?;
self.db
.procedures
.update_one::<()>(id, mungos::Update::Set(doc! { "description": description }))
.await?;
}
_ => return Err(anyhow!("invalid target: {target:?}")),
}
Ok(())
}
}

View File

@@ -33,6 +33,13 @@ struct ModifyUserCreateServerBody {
create_server_permissions: bool,
}
#[typeshare]
#[derive(Serialize, Deserialize)]
struct ModifyUserCreateBuildBody {
user_id: String,
create_build_permissions: bool,
}
pub fn router() -> Router {
Router::new()
.route(
@@ -62,6 +69,15 @@ pub fn router() -> Router {
response!(Json(update))
}),
)
.route(
"/modify_create_build",
post(|state, user, body| async {
let update = modify_user_create_build_permissions(state, user, body)
.await
.map_err(handle_anyhow_error)?;
response!(Json(update))
}),
)
}
async fn update_permissions(
@@ -268,7 +284,7 @@ async fn modify_user_create_server_permissions(
"user does not have permissions for this action (not admin)"
));
}
let user = state
let target_user = state
.db
.users
.find_one_by_id(&user_id)
@@ -296,7 +312,62 @@ async fn modify_user_create_server_permissions(
"modify user create server permissions",
format!(
"{update_type} create server permissions for {} (id: {})",
user.username, user.id
target_user.username, target_user.id
),
)],
start_ts: ts.clone(),
end_ts: Some(ts),
status: UpdateStatus::Complete,
success: true,
operator: user.id.clone(),
..Default::default()
};
update.id = state.add_update(update.clone()).await?;
Ok(update)
}
async fn modify_user_create_build_permissions(
Extension(state): StateExtension,
Extension(user): RequestUserExtension,
Json(ModifyUserCreateBuildBody {
user_id,
create_build_permissions,
}): Json<ModifyUserCreateBuildBody>,
) -> anyhow::Result<Update> {
if !user.is_admin {
return Err(anyhow!(
"user does not have permissions for this action (not admin)"
));
}
let target_user = state
.db
.users
.find_one_by_id(&user_id)
.await
.context("failed at mongo query to find target user")?
.ok_or(anyhow!("did not find any user with user_id {user_id}"))?;
state
.db
.users
.update_one::<Document>(
&user_id,
mungos::Update::Set(doc! { "create_build_permissions": create_build_permissions }),
)
.await?;
let update_type = if create_build_permissions {
"enabled"
} else {
"disabled"
};
let ts = monitor_timestamp();
let mut update = Update {
target: UpdateTarget::System,
operation: Operation::ModifyUserCreateBuildPermissions,
logs: vec![Log::simple(
"modify user create build permissions",
format!(
"{update_type} create build permissions for {} (id: {})",
target_user.username, target_user.id
),
)],
start_ts: ts.clone(),

View File

@@ -339,6 +339,20 @@ pub fn router() -> Router {
},
),
)
.route(
"/:id/secrets",
get(
|state: StateExtension,
user: RequestUserExtension,
Path(ServerId { id })| async move {
let vars = state
.get_available_secrets(&id, &user)
.await
.map_err(handle_anyhow_error)?;
response!(Json(vars))
},
),
)
.route(
"/:id/action_state",
get(
@@ -356,7 +370,11 @@ pub fn router() -> Router {
}
impl State {
async fn get_server(&self, id: &str, user: &RequestUser) -> anyhow::Result<ServerWithStatus> {
pub async fn get_server(
&self,
id: &str,
user: &RequestUser,
) -> anyhow::Result<ServerWithStatus> {
let server = self
.get_server_check_permissions(id, user, PermissionLevel::Read)
.await?;
@@ -628,6 +646,18 @@ impl State {
Ok(docker_accounts)
}
async fn get_available_secrets(
&self,
id: &str,
user: &RequestUser,
) -> anyhow::Result<Vec<String>> {
let server = self
.get_server_check_permissions(id, user, PermissionLevel::Read)
.await?;
let vars = self.periphery.get_available_secrets(&server).await?;
Ok(vars)
}
async fn get_server_action_states(
&self,
id: String,

View File

@@ -1,9 +1,7 @@
use std::str::FromStr;
use anyhow::{anyhow, Context};
use axum::{extract::Query, routing::get, Extension, Json, Router};
use helpers::handle_anyhow_error;
use mungos::{doc, to_bson, ObjectId};
use mungos::{doc, to_bson};
use serde_json::Value;
use types::{PermissionLevel, Update, UpdateTarget};
@@ -14,7 +12,7 @@ use crate::{
state::{State, StateExtension},
};
const NUM_UPDATES_PER_PAGE: usize = 10;
const NUM_UPDATES_PER_PAGE: usize = 20;
pub fn router() -> Router {
Router::new().route(
@@ -159,7 +157,7 @@ impl State {
.await
.context("failed at query to get users builds")?
.into_iter()
.map(|e| ObjectId::from_str(&e.id).unwrap())
.map(|e| e.id)
.collect::<Vec<_>>();
let deployment_ids = self
.db
@@ -168,7 +166,7 @@ impl State {
.await
.context("failed at query to get users deployments")?
.into_iter()
.map(|e| ObjectId::from_str(&e.id).unwrap())
.map(|e| e.id)
.collect::<Vec<_>>();
let server_ids = self
.db
@@ -177,7 +175,7 @@ impl State {
.await
.context("failed at query to get users servers")?
.into_iter()
.map(|e| ObjectId::from_str(&e.id).unwrap())
.map(|e| e.id)
.collect::<Vec<_>>();
let procedure_ids = self
.db
@@ -186,7 +184,7 @@ impl State {
.await
.context("failed at query to get users procedures")?
.into_iter()
.map(|e| ObjectId::from_str(&e.id).unwrap())
.map(|e| e.id)
.collect::<Vec<_>>();
let filter = doc! {
"$or": [

View File

@@ -1,14 +1,14 @@
use std::time::Duration;
use anyhow::{anyhow, Context};
use aws_sdk_ec2::model::{
BlockDeviceMapping, EbsBlockDevice, InstanceNetworkInterfaceSpecification, InstanceStateChange,
InstanceStateName, InstanceStatus, ResourceType, Tag, TagSpecification,
};
pub use aws_sdk_ec2::{
model::InstanceType,
output::{DescribeInstanceStatusOutput, TerminateInstancesOutput},
Client, Region,
use aws_sdk_ec2::{
config::Region,
types::{
BlockDeviceMapping, EbsBlockDevice, InstanceNetworkInterfaceSpecification,
InstanceStateChange, InstanceStateName, InstanceStatus, InstanceType, ResourceType, Tag,
TagSpecification,
},
Client,
};
use types::Server;

1
core/src/cloud/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod aws;

View File

@@ -1,6 +1,6 @@
use axum_extra::routing::SpaRouter;
use dotenv::dotenv;
use helpers::parse_config_file;
use merge_config_files::parse_config_file;
use mungos::Deserialize;
use types::CoreConfig;
@@ -15,7 +15,7 @@ struct Env {
pub fn load() -> (CoreConfig, SpaRouter) {
dotenv().ok();
let env: Env = envy::from_env().expect("failed to parse environment variables");
let config = parse_config_file(&env.config_path).expect("failed to parse config");
let config = parse_config_file(env.config_path).expect("failed to parse config");
let spa_router = SpaRouter::new("/assets", env.frontend_path);
(config, spa_router)
}

View File

@@ -2,6 +2,8 @@ use std::str::FromStr;
use anyhow::anyhow;
use diff::{Diff, OptionDiff};
use helpers::to_monitor_name;
use types::Build;
#[macro_export]
macro_rules! response {
@@ -41,3 +43,26 @@ pub fn parse_comma_seperated_list<T: FromStr>(comma_sep_list: &str) -> anyhow::R
})
.collect()
}
pub fn get_image_name(build: &Build) -> String {
let name = to_monitor_name(&build.name);
match &build.docker_organization {
Some(org) => format!("{org}/{name}"),
None => match &build.docker_account {
Some(acct) => format!("{acct}/{name}"),
None => name,
},
}
}
pub fn empty_or_only_spaces(word: &str) -> bool {
if word.len() == 0 {
return true;
}
for char in word.chars() {
if char != ' ' {
return false;
}
}
return true;
}

View File

@@ -2,21 +2,24 @@
use ::helpers::get_socket_addr;
use auth::JwtClient;
use axum::Router;
use axum::{http::StatusCode, Router};
use state::State;
use tower_http::cors::{Any, CorsLayer};
mod actions;
mod api;
mod auth;
mod cloud;
mod config;
mod helpers;
mod monitoring;
mod state;
mod ws;
type ResponseResult<T> = Result<T, (StatusCode, String)>;
#[tokio::main]
async fn main() {
async fn main() -> anyhow::Result<()> {
let (config, spa_router) = config::load();
println!("starting monitor core on port {}...", config.port);
@@ -39,6 +42,7 @@ async fn main() {
axum::Server::bind(&get_socket_addr(config.port))
.serve(app.into_make_service())
.await
.expect("monitor core axum server crashed");
.await?;
Ok(())
}

View File

@@ -412,8 +412,12 @@ impl State {
);
continue;
}
let servers = servers.unwrap();
if servers.is_empty() {
continue;
}
let mut blocks = vec![Block::header("INFO | daily update"), Block::divider()];
for (server, stats) in servers.unwrap() {
for (server, stats) in servers {
let region = if let Some(region) = &server.region {
format!(" | {region}")
} else {

View File

@@ -78,7 +78,6 @@ impl State {
}
let futures = servers.unwrap().into_iter().map(|server| async move {
let _ = self.periphery.image_prune(&server).await;
let _ = self.periphery.container_prune(&server).await;
});
join_all(futures).await;
}

View File

@@ -2,7 +2,6 @@ use std::sync::Arc;
use axum::{
extract::{ws::Message as AxumMessage, Path, Query, WebSocketUpgrade},
http::StatusCode,
response::IntoResponse,
};
use futures_util::{SinkExt, StreamExt};
@@ -13,7 +12,7 @@ use tokio_tungstenite::tungstenite::Message;
use tokio_util::sync::CancellationToken;
use types::{traits::Permissioned, PermissionLevel, SystemStatsQuery};
use crate::{auth::JwtExtension, state::StateExtension};
use crate::{auth::JwtExtension, state::StateExtension, ResponseResult};
#[derive(Deserialize)]
pub struct ServerId {
@@ -26,7 +25,7 @@ pub async fn ws_handler(
path: Path<ServerId>,
query: Query<SystemStatsQuery>,
ws: WebSocketUpgrade,
) -> Result<impl IntoResponse, (StatusCode, String)> {
) -> ResponseResult<impl IntoResponse> {
let server = state
.db
.get_server(&path.id)

View File

@@ -1,22 +1,50 @@
# building images
Monitor builds docker images by cloning the source repository from Github and running ```docker build``` on the configured Dockerfile, which should be present in the source repository.
Monitor builds docker images by cloning the source repository from Github, running ```docker build```, and pushing the resulting image to docker hub. Any repo containing a 'Dockerfile' is buildable using this method.
Build configuration involves passing file / directory paths, for more details about passing file paths, see the [file paths doc](https://github.com/mbecker20/monitor/blob/main/docs/paths.md).
## repo configuration
Setting related to the github repo are under the *repo* tab on respective build's page.
To specify the github repo to build, just give it the name of the repo and the branch under *github config*. The name is given like ```mbecker20/monitor```, it includes the username / organization that owns the repo.
To specify the github repo to build, just give it the name of the repo and the branch under *repo config*. The name is given like ```mbecker20/monitor```, it includes the username / organization that owns the repo.
Many repos are private, in this case a Github access token is required in the periphery.config.toml of the building server. these are specified in the config like ```username = "access_token"```. An account which has access to the repo and is available on the periphery server can be selected to use via the *github account* dropdown menu.
Sometimes a command needs to be run when the repo is cloned, you can configure this in the *on clone* section.
## docker build configuration
There are two fields to pass for *on clone*. the first is *path*, which changes to working directory. To run the command in the root of the repo, just pass ".". The second field is *command*, this is the shell command to be executed after the repo is cloned.
In order to docker build, monitor just needs to know the build directory and the path of the Dockerfile, you can configure these in the *build config* section.
For example, say your repo had a folder in it called "scripts" with a shell script "on-clone.sh". You would give *path* as "scripts" and command as "sh on-clone.sh". Or you could make *path* just "." and then command would be "sh scripts/on-clone.sh". Either way works fine.
If the build directory is the root of the repository, you pass the build path as ```.```. If the build directory is some folder of the repo, just pass the name of the the folder. Do not pass the preceding "/". for example ```build/directory```
## build configuration
The dockerfile's path is given relative to the build directory. So if your build directory is ```build/directory``` and the dockerfile is in ```build/directory/Dockerfile.example```, you give the dockerfile path simply as ```Dockerfile.example```.
Just as with private repos, you will need to select a docker account to use with ```docker push```.
## running a pre build command
Sometimes a command needs to be run before running ```docker build```, you can configure this in the *pre build* section.
There are two fields to pass for *pre build*. the first is *path*, which changes the working directory. To run the command in the root of the repo, just pass ```.```. The second field is *command*, this is the shell command to be executed after the repo is cloned.
For example, say your repo had a folder in it called ```scripts``` with a shell script ```on-clone.sh```. You would give *path* as ```scripts``` and command as ```sh on-clone.sh```. Or you could make *path* just ```.``` and then the command would be ```sh scripts/on-clone.sh```. Either way works fine.
## adding build args
The Dockerfile may make use of [build args](https://docs.docker.com/engine/reference/builder/#arg). Build args can be passed using the gui by pressing the ```edit``` button. They are passed in the menu just like in the would in a .env file:
```
BUILD_ARG1=some_value
BUILD_ARG2=some_other_value
```
## builder configuration
A builder is a machine running monitor periphery and docker. Any server connected to monitor can be chosen as the builder for a build.
Building on a machine running production software is usually not a great idea, as this process can use a lot of the system resources. It is better to start up a temporary cloud machine dedicated for the build, then shut it down when the build is finished. Right now monitor supports AWS ec2 for this task.
### AWS builder
You can choose to build on AWS on the "builder" tab on the build's page. From here you can configure the AMI to use as a base to build the image. These must be configured in the monitor core configuration along with other information like defaults to use, AWS credentials, etc. This is explained on the [core setup page](https://github.com/mbecker20/monitor/blob/main/docs/setup.md).
## versioning
@@ -25,4 +53,4 @@ Monitor uses a major.minor.patch versioning scheme. Every build will auto increm
[next: deploying](https://github.com/mbecker20/monitor/blob/main/docs/deployments.md)
[back to table of contents](https://github.com/mbecker20/monitor/blob/main/readme.md)
[back to table of contents](https://github.com/mbecker20/monitor/blob/main/readme.md)

View File

@@ -1,3 +1,103 @@
## deploying applications
# deploying applications
Monitor can deploy any docker images that it can access with the configured docker accounts. It works by parsing the deployment configuration into a ```docker run``` command. The configuration is stored on MongoDB, and records of all actions (update config, deploy, stop, etc.) are stored as well.
Deployment configuration involves passing file / directory paths, for more details about passing file paths, see the [file paths doc](https://github.com/mbecker20/monitor/blob/main/docs/paths.md).
## configuring the image
There are two options to configure the deployed image.
### attaching a monitor build
If the software you want to deploy is built by monitor, you can attach the build directly to the deployment.
By default, monitor will deploy the latest available version of the build, or you can specify a specific version using the version dropdown.
Also by default, monitor will use the same docker account that is attached to the build in order to pull the image on the periphery server. If that account is not available on the server, you can specify another available account to use instead, this account just needs to have read access to the docker repository.
### using a custom image
You can also manually specify an image name, like ```mongo``` or ```mbecker2020/random_image:0.1.1```.
If the image repository is private, you can select an available docker account to use to pull the image.
## configuring the network
One feature of docker is that it allows for the creation of [virtual networks between containers](https://docs.docker.com/network/). Monitor allows you to specify a docker virtual network to connect the container to, or to use the host system networking to bypass the docker virtual network.
The default selection is ```host```, which bypasses the docker virtual network layer.
If you do select select a network other than host, you can specify port bindings with the GUI. For example, if you are running mongo (which defaults to port 27017), you could use the mapping:
```
27018 : 27017
```
In this case, you would access mongo from outside of the container on port ```27018```.
Note that this is not the only affect of using a network other than ```host```. For example, containers running on different networks can not communicate, and ones on the same network can not reach other containers on ```localhost``` even when they are running on the same system. This behavior can be a bit confusing if you are not familiar with it, and it can be bypassed entirely by just using ```host``` network.
## configuring restart behavior
Docker, like systemd, has a couple options for handling when a container exits. See [docker restart policies](https://docs.docker.com/config/containers/start-containers-automatically/). Monitor allows you to select the appropriate restart behavior from these options.
## configuring environment variables
Monitor enables you to easily manage environment variables passed to the container. In the GUI, click the 'edit' button on the 'environment' card, this will bring up the environment menu.
You pass environment variables just as you would with a ```.env``` file:
```
ENV_VAR_1=some_value
ENV_VAR_2=some_other_value
```
## configuring volumes
A docker container's filesystem is segregated from that of the host. However, it is still possible for a container to access system files and directories, this is accomplished by using [bind mounts](https://docs.docker.com/storage/bind-mounts/).
Say your container needs to read a config file located on the system at ```/home/ubuntu/config.toml```. You can specify the bind mount to be:
```
/home/ubuntu/config.toml : /config/config.toml
```
The first path is the one on the system, the second is the path in the container. Your application would then read the file at ```/config/config.toml``` in order to load its contents.
These can be configured easily with the GUI in the 'volumes' card. You can configure as many bind mounts as you need.
## extra args
Not all features of docker are mapped directly by monitor, only the most common. You can still specify any custom flags for monitor to include in the ```docker run``` command by utilizing 'extra args'. For example, you can enable log rotation using these two extra args:
```
--log-opt max-size=10M
```
```
--log-opt max-file=3
```
## post image
Sometimes you need to specify some flags to be passed directly to the application. What is put here is inserted into the docker run command after the image. For example, to pass the ```--quiet``` flag to MongoDB, the docker run command would be:
```
docker run -d --name mongo-db mongo:6.0.3 --quiet
```
In order to achieve this with monitor, just pass ```--quiet``` to 'post image'.
## container lifetime management
The lifetime of a docker container is more like a virtual machine. They can be created, started, stopped, and destroyed. The lifetime management actions monitor presents to the user is relative to the containers state. For example, when the container is ```running```, you can either stop it, destroy it, or redeploy it.
### stopping a container
Sometimes you want to stop a running application but preserve its logs and configuration, either to be restarted later or to view the logs at a later time. It is more like *pausing* the application with its current config, as no configuration (like environment variable, volume mounts, etc.) will be changed when the container is started again. In order to restart an application with updated configuration, it must be *redeployed*.
### container redeploy
redeploying is the action of destroying a container and recreating it. If you update deployment config, these changes will not take effect until the container is redeployed. Just note this will destroy the previous containers logs along with the container itself.
[next: permissions](https://github.com/mbecker20/monitor/blob/main/docs/permissions.md)
[back to table of contents](https://github.com/mbecker20/monitor/blob/main/readme.md)

38
docs/paths.md Normal file
View File

@@ -0,0 +1,38 @@
# File Paths
When working with monitor, you might have to configure file or directory paths.
## Relative Paths
Where possible, it is better to use relative file paths. Using relative file paths removes the connection between the process being run and the particular server it runs one, making it easier to move things between servers.
Where you see relative paths:
- setting the build directory and path of the Dockerfile
- setting a pre build command path
- configuring a frontend mount (used for web apps)
For all of the above, the path can be given relative to the root of the configured repo
The one exception is the Dockerfile path, which is given relative to the build directory (This is done by Docker itself, and this pattern matches usage of the Docker CLI).
There are 3 kinds of paths to pass:
1. to specify the root of the repo, use ```.``` as the path
2. to specify a folder in the repo, pass it with **no** preceding ```/```. For example, ```example_folder``` or ```folder1/folder2```
3. to specify an absolute path on the servers filesystem, use a preceding slash, eg. ```/home/ubuntu/example```. This way should only be used if absolutely necessary.
### Implementation
relative file paths are joined with the path of the repo on the system using a Rust [PathBuf](https://doc.rust-lang.org/std/path/struct.PathBuf.html#method.push).
## Docker Volume Paths
These are passed directly to the Docker CLI using ```--volume /path/on/system:/path/in/container```. So for these, the same rules apply as when using Docker on the command line. Paths here should be given as absolute, don't use ```~``` or even ```$HOME```.

View File

@@ -1 +1,32 @@
# permissioning resources
All monitor resources (servers, builds, deployment) have independant permission tables to allow for users to have granular access to these resources. By default, users do not see any resources until they are given at least read permissions.
## permission levels
There are 4 levels of permissions a user can have on a resource:
1. **None**. This is the lowest permission level, and means the user will not have any access to this resource. They will not see it in the GUI, and it will not show up if the user queries the core API directly. All attempts to view or update the resource will be blocked.
2. **Read**. This is the first permission level that grants any access. It will enable the user to see the resource in the GUI, read the configuration, and see any logs. Any attempts to update configuration or trigger any action will be blocked.
3. **Execute**. This level will allow the user to execute actions on the resource, like send a build command or trigger a redeploy. The user will still be blocked from updating configuration on the resource.
4. **Update**. The user has full access to the resource, they can execute any actions, update the configuration, and delete the resource.
## Administration
Users can be given admin priviledges by accessing the monitor MongoDB and setting ```admin: true``` on the intended user document. These users have unrestricted access to all monitor resources, like servers, builds, and deployments. Additionally, only these users can update other (non-admin) user's permissions on resources, an action not available to regular users even with **Update** level permissions.
Monitor admins are responsible for managing user accounts as well. When a user logs into monitor for the first time, they will not immediately be granted access. An admin must first **enable** the user, which can be done from the 'manage users' page (found in the user dropdown menu in the topbar). Users can also be **disabled** by an admin at any time, which blocks all their access to the GUI and API.
Users also have some configurable global permissions, these are:
- create server permission
- create build permission
Only users with these permissions (as well as admins) can add additional servers to monitor, and can create additional builds, respectively.
[next: core setup](https://github.com/mbecker20/monitor/blob/main/docs/setup.md)
[back to table of contents](https://github.com/mbecker20/monitor/blob/main/readme.md)

View File

@@ -11,20 +11,46 @@ The easiest way to do this is to follow the [monitor guide](https://github.com/m
### manual install steps
1. Download the periphery binary from the latest [release](https://github.com/mbecker20/monitor/releases) or install it using [cargo](https://crates.io/crates/monitor_periphery). If the monitor cli.
2. Create and edit ~/.monitor/periphery.config.toml, following the [config example](https://github.com/mbecker20/monitor/blob/main/config_example/periphery.config.example.toml). The file can be anywhere, it can be passed to periphery via the --config-path flag or with the CONFIG_PATH environment variable. The monitor cli can also be used: ```monitor periphery gen-config```
1. Download the periphery binary from the latest [release](https://github.com/mbecker20/monitor/releases).
2. Create and edit your config files, following the [config example](https://github.com/mbecker20/monitor/blob/main/config_example/periphery.config.example.toml). The monitor cli can be used to add the boilerplate: ```monitor periphery gen-config --path /path/to/config.toml```. The files can be anywhere, and can be passed to periphery via the ```--config-path``` flag.
3. Ensure that inbound connectivity is allowed on the port specified in periphery.config.toml (default 8000).
4. Install docker. Make sure whatever user periphery is run as has access to the docker group without sudo.
5. Start the periphery binary with your preferred process manager, like systemd. The config read from the file is printed on startup, ensure that it is as expected.
## example periphery start command
```
periphery \
--config-path /path/to/periphery.config.base.toml \
--config-path /other_path/to/periphery.config.overide.toml \
--merge-nested-config \
--home_dir /home/username
```
## passing config files
when you pass multiple config files, later --config-path given in the command will always overide previous ones.
there are two ways to merge config files. The default behavior is to completely replace any base fields with whatever fields are present in the overide config. So if you pass ```allowed_ips = []``` in your overide config, the final allowed_ips will be an empty list as well.
```--merge-nested-config``` will merge config fields recursively and extend config array fields.
For example, with ```--merge-nested-config``` you can specify an allowed ip in the base config, and another in the overide config, they will both be present in the final config.
Similarly, you can specify a base docker / github account pair, and extend them with additional accounts in the overide config.
## adding the server to monitor
The easiest way to add the server is with the GUI. On the home page, click the + button to the right of the server search bar, configure the name and address of the server. The address is the full http/s url to the periphery server, eg http://12.34.56.78:8000.
Once it is added, you can use access the GUI to modify some config, like the alerting thresholds for cpu, memory and disk usage. A server can also be temporarily disabled, this will prevent alerting if it goes offline.
Since no state is stored on the periphery servers, you can easily redirect all builds / deployments to be hosted on a different server. Just update the address to point to the new server.
Since no state is stored on the periphery servers, you can easily redirect all deployments to be hosted on a different server. Just update the address to point to the new server.
[next: building](https://github.com/mbecker20/monitor/blob/main/docs/builds.md)
[back to table of contents](https://github.com/mbecker20/monitor/blob/main/readme.md)
[back to table of contents](https://github.com/mbecker20/monitor/blob/main/readme.md)

View File

@@ -17,7 +17,7 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="app"></div>
<div id="root" class="app-bounder"></div>
<script src="/src/index.tsx" type="module"></script>
</body>

View File

@@ -18,6 +18,7 @@
},
"dependencies": {
"@solidjs/router": "^0.6.0",
"@tanstack/solid-query": "^4.26.0",
"axios": "^1.2.1",
"js-file-download": "^0.4.12",
"lightweight-charts": "^3.8.0",

View File

@@ -8,16 +8,19 @@ const Deployment = lazy(() => import("./components/deployment/Deployment"));
const Server = lazy(() => import("./components/server/Server"));
const Build = lazy(() => import("./components/build/Build"));
const Users = lazy(() => import("./components/users/Users"));
const User = lazy(() => import("./components/users/User"));
const Stats = lazy(() => import("./components/stats/Stats"));
const Account = lazy(() => import("./components/Account"));
const Account = lazy(() => import("./components/account/Account"));
const Updates = lazy(() => import("./components/Updates"));
const App: Component = () => {
const { user } = useUser();
return (
<>
<div class="app">
<Topbar />
<Routes>
<Route path="/" component={Home} />
<Route path="/updates" component={Updates} />
<Route path="/build/:id" component={Build} />
<Route path="/deployment/:id" component={Deployment} />
<Route path="/server/:id" component={Server} />
@@ -25,9 +28,10 @@ const App: Component = () => {
<Route path="/account" component={Account} />
<Show when={user().admin}>
<Route path="/users" component={Users} />
<Route path="/user/:id" component={User} />
</Show>
</Routes>
</>
</div>
);
};

View File

@@ -1,5 +1,5 @@
import { useNavigate } from "@solidjs/router";
import { Component, createSignal } from "solid-js";
import { Component, createSignal, Show } from "solid-js";
import { client, pushNotification } from "..";
import { useAppState } from "../state/StateProvider";
import { Build, Deployment } from "../types";
@@ -11,6 +11,7 @@ import Input from "./shared/Input";
import Flex from "./shared/layout/Flex";
import Grid from "./shared/layout/Grid";
import CenterMenu from "./shared/menu/CenterMenu";
import HoverMenu from "./shared/menu/HoverMenu";
import Selector from "./shared/menu/Selector";
const CopyMenu: Component<{
@@ -42,12 +43,11 @@ const CopyMenu: Component<{
if (p.type === "build") {
promise = client.copy_build(p.id, {
name: newName(),
server_id: selectedId(),
});
} else {
promise = client.copy_deployment(p.id, {
name: newName(),
server_id: selectedId(),
server_id: selectedId()!,
});
}
toggleShow();
@@ -59,45 +59,55 @@ const CopyMenu: Component<{
}
};
return (
<CenterMenu
show={show}
toggleShow={toggleShow}
title={`copy ${p.type} | ${name()}`}
target={<Icon type="duplicate" />}
targetClass="blue"
content={() => (
<Grid placeItems="center">
<Flex alignItems="center">
<Input
placeholder="copy name"
class="card dark"
style={{ padding: "0.5rem" }}
value={newName()}
onEdit={setNewName}
/>
<Selector
label="target: "
selected={selectedId()}
items={servers.ids()!}
onSelect={setSelected}
itemMap={(id) => servers.get(id)!.server.name}
targetClass="blue"
targetStyle={{ display: "flex", gap: "0.5rem" }}
searchStyle={{ width: "100%" }}
position="bottom right"
useSearch
/>
</Flex>
<ConfirmButton
class="green"
style={{ width: "100%" }}
onConfirm={copy}
>
copy {p.type}
</ConfirmButton>
</Grid>
)}
position="center"
<HoverMenu
target={
<CenterMenu
show={show}
toggleShow={toggleShow}
title={`copy ${p.type} | ${name()}`}
target={<Icon type="duplicate" />}
targetClass="blue"
content={() => (
<Grid placeItems="center">
<Flex class="full-width" alignItems="center">
<Input
placeholder="copy name"
class="card dark full-width"
style={{ padding: "0.5rem" }}
value={newName()}
onEdit={setNewName}
/>
<Show when={p.type === "deployment"}>
<Selector
label="target: "
selected={selectedId()!}
items={servers.ids()!}
onSelect={setSelected}
itemMap={(id) => servers.get(id)!.server.name}
targetClass="blue"
targetStyle={{ display: "flex", gap: "0.5rem" }}
searchStyle={{ width: "100%" }}
menuClass="scroller"
menuStyle={{ "max-height": "40vh" }}
position="bottom right"
useSearch
/>
</Show>
</Flex>
<ConfirmButton
class="green"
style={{ width: "100%" }}
onConfirm={copy}
>
copy {p.type}
</ConfirmButton>
</Grid>
)}
position="center"
/>
}
content={`copy ${p.type}`}
position="bottom center"
/>
);
};

View File

@@ -0,0 +1,126 @@
import { Component, createSignal, onMount, Show } from "solid-js";
import { client, pushNotification } from "..";
import { useAppState } from "../state/StateProvider";
import { UpdateTarget } from "../types";
import { useToggle } from "../util/hooks";
import Grid from "./shared/layout/Grid";
import Loading from "./shared/loading/Loading";
import CenterMenu from "./shared/menu/CenterMenu";
import TextArea from "./shared/TextArea";
const Description: Component<{
name: string;
target: UpdateTarget;
description?: string;
userCanUpdate: boolean;
}> = (p) => {
const [show, toggleShow] = useToggle();
const description = () => {
if (p.description) {
let [description] = p.description.split("\n");
return description;
} else {
return "add a description";
}
};
const [width, setWidth] = createSignal<number>();
onMount(() => {
setWidth(ref!?.clientWidth);
});
let ref: HTMLDivElement;
return (
<CenterMenu
show={show}
toggleShow={toggleShow}
title={`description | ${p.name}`}
targetClass="card grey"
targetStyle={{ width: "100%", "justify-content": "flex-start" }}
target={
<div
ref={ref! as any}
class="ellipsis"
style={{
opacity: 0.7,
width: width() ? `${width()}px` : "100%",
"box-sizing": "border-box",
"text-align": "left"
}}
>
{width() ? description() : ""}
</div>
}
content={() => (
<DescriptionMenu
target={p.target}
description={p.description}
userCanUpdate={p.userCanUpdate}
toggleShow={toggleShow}
/>
)}
/>
);
};
const DescriptionMenu: Component<{
target: UpdateTarget;
description?: string;
userCanUpdate: boolean;
toggleShow: () => void;
}> = (p) => {
const { builds, servers, deployments } = useAppState();
let ref: HTMLTextAreaElement;
onMount(() => {
ref?.focus();
});
const [desc, setDesc] = createSignal(p.description);
const [loading, setLoading] = createSignal(false);
const update_description = () => {
if (!p.userCanUpdate) return;
setLoading(true);
client
.update_description({ target: p.target, description: desc() || "" })
.then(() => {
if (p.target.type === "Build") {
builds.update({ ...builds.get(p.target.id)!, description: desc() });
} else if (p.target.type === "Deployment") {
const deployment = deployments.get(p.target.id)!;
deployments.update({
...deployment,
deployment: { ...deployment.deployment, description: desc() },
});
} else if (p.target.type === "Server") {
const server = servers.get(p.target.id)!;
servers.update({
...server,
server: { ...server.server, description: desc() },
});
}
p.toggleShow();
})
.catch(() => {
pushNotification("bad", "failed to update description");
p.toggleShow();
});
};
return (
<Grid placeItems="center">
<TextArea
ref={ref! as any}
placeholder="add a description"
value={desc()}
onEdit={setDesc}
style={{ width: "900px", "max-width": "90vw", height: "70vh", padding: "1rem" }}
disabled={!p.userCanUpdate}
/>
<Show when={p.userCanUpdate}>
<Show when={!loading()} fallback={<Loading />}>
<button class="green" onClick={update_description}>
update
</button>
</Show>
</Show>
</Grid>
);
};
export default Description;

View File

@@ -47,10 +47,10 @@ export const NewDeployment: Component<{ serverID: string }> = (p) => {
);
};
export const NewBuild: Component<{ serverID: string }> = (p) => {
export const NewBuild: Component<{}> = (p) => {
const [showNew, toggleShowNew] = useToggle();
const create = (name: string) => {
client.create_build({ name, server_id: p.serverID });
client.create_build({ name });
};
return (
<Show

View File

@@ -1,19 +1,24 @@
import { Component } from "solid-js";
import { Component, Show } from "solid-js";
import Grid from "./shared/layout/Grid";
import Loading from "./shared/loading/Loading";
const NotFound: Component<{ type: "deployment" | "server" | "build" }> = (p) => {
return (
<Grid
placeItems="center"
style={{ height: "100%", width: "100%" }}
>
<Grid placeItems="center" style={{ width: "fit-content", height: "fit-content" }}>
<h2>{p.type} at id not found</h2>
const NotFound: Component<{
type: "deployment" | "server" | "build";
loaded: boolean;
}> = (p) => {
return (
<Grid placeItems="center" style={{ height: "100%", width: "100%" }}>
<Grid
placeItems="center"
style={{ width: "fit-content", height: "fit-content" }}
>
<Show when={p.loaded} fallback={<h2>loading {p.type}...</h2>}>
<h2>{p.type} at id not found</h2>
</Show>
<Loading type="sonar" />
</Grid>
</Grid>
);
}
};
export default NotFound;
export default NotFound;

View File

@@ -0,0 +1,157 @@
import { A } from "@solidjs/router";
import {
Component,
createEffect,
createMemo,
createSignal,
For,
Show,
} from "solid-js";
import { OPERATIONS } from "..";
import { useAppDimensions } from "../state/DimensionProvider";
import { useAppState } from "../state/StateProvider";
import { Operation, Update as UpdateType, UpdateStatus } from "../types";
import { readableMonitorTimestamp, readableVersion } from "../util/helpers";
import Icon from "./shared/Icon";
import Input from "./shared/Input";
import Flex from "./shared/layout/Flex";
import Grid from "./shared/layout/Grid";
import Loading from "./shared/loading/Loading";
import Selector from "./shared/menu/Selector";
import UpdateMenu from "./update/UpdateMenu";
const Updates: Component<{}> = (p) => {
const { isMobile } = useAppDimensions();
const { updates, usernames, name_from_update_target } = useAppState();
const [operation, setOperation] = createSignal<Operation>();
createEffect(() => {
if (operation()) {
updates.load([operation()!]);
} else {
updates.load();
}
});
const [search, setSearch] = createSignal("");
const filtered_updates = createMemo(() => {
return updates.collection()?.filter((u) => {
const name = name_from_update_target(u.target);
if (name.includes(search())) return true;
const username = usernames.get(u.operator);
if (username?.includes(search())) return true;
});
});
return (
<Grid class="full-width card shadow">
<Flex alignItems="center" justifyContent="space-between">
<h1>updates</h1>
<Flex alignItems="center">
<Input class="lightgrey" placeholder="search" onEdit={setSearch} />
<Selector
label={isMobile() ? undefined : "operation: "}
selected={operation() ? operation()! : "all"}
items={["all", ...OPERATIONS]}
onSelect={(o) =>
o === "all"
? setOperation(undefined)
: setOperation(o.replaceAll(" ", "_") as Operation)
}
targetClass="blue"
position="bottom right"
searchStyle={{ width: "15rem" }}
menuClass="scroller"
menuStyle={{ "max-height": "50vh" }}
useSearch
/>
</Flex>
</Flex>
<Show
when={updates.loaded()}
fallback={
<Flex justifyContent="center">
<Loading type="three-dot" />
</Flex>
}
>
<For each={filtered_updates()}>
{(update) => <Update update={update} />}
</For>
<Show when={!updates.noMore()}>
<button
class="grey full-width"
onClick={() =>
operation()
? updates.loadMore([operation()!])
: updates.loadMore()
}
>
load more
</button>
</Show>
</Show>
</Grid>
);
};
export default Updates;
const Update: Component<{ update: UpdateType }> = (p) => {
const { isMobile } = useAppDimensions();
const { usernames, name_from_update_target } = useAppState();
const name = () => name_from_update_target(p.update.target);
const operation = () => {
if (p.update.operation === Operation.BuildBuild) {
return `build ${readableVersion(p.update.version!)}`;
}
return `${p.update.operation.replaceAll("_", " ")}${
p.update.version ? " " + readableVersion(p.update.version) : ""
}`;
};
const link_to = () => {
return p.update.target.type === "System"
? "/"
: `/${p.update.target.type.toLowerCase()}/${p.update.target.id}`;
};
return (
<Flex
class="card light shadow wrap"
justifyContent="space-between"
alignItems="center"
>
<Flex
alignItems="center"
justifyContent="space-between"
style={{ width: isMobile() ? "100%" : undefined }}
>
<A style={{ padding: 0 }} href={link_to()}>
<h2 class="text-hover">{name()}</h2>
</A>
<div
style={{
color: !p.update.success ? "rgb(182, 47, 52)" : "inherit",
}}
>
{operation()}
</div>
<Show when={p.update.status === UpdateStatus.InProgress}>
<div style={{ opacity: 0.7 }}>(in progress)</div>
</Show>
</Flex>
<Flex
alignItems="center"
justifyContent="space-between"
style={{ width: isMobile() ? "100%" : undefined }}
>
<Flex gap="0.5rem">
<Icon type="user" />
<div>{usernames.get(p.update.operator)}</div>
</Flex>
<Flex alignItems="center">
<div style={{ "place-self": "center end" }}>
{readableMonitorTimestamp(p.update.start_ts)}
</div>
<UpdateMenu update={p.update} />
</Flex>
</Flex>
</Flex>
);
};

View File

@@ -0,0 +1,41 @@
import { Component, Show } from "solid-js";
import { useUser } from "../../state/UserProvider";
import { readableMonitorTimestamp, readableUserType } from "../../util/helpers";
import Flex from "../shared/layout/Flex";
import Resources from "./Resources";
import Secrets from "./Secrets";
const Account: Component<{}> = (p) => {
const { user, username } = useUser();
return (
<>
<Flex
class="card shadow"
style={{ width: "100%", "box-sizing": "border-box" }}
alignItems="center"
justifyContent="space-between"
>
<h1>{username()}</h1>
<Flex>
<Show when={user().admin}>
<div class="dimmed">admin</div>
</Show>
<Flex gap="0.5rem">
<div class="dimmed">type:</div>
<div>{user() ? readableUserType(user()!) : "unknown"}</div>
</Flex>
<Flex gap="0.5rem">
<div class="dimmed">created:</div>
<div>{readableMonitorTimestamp(user().created_at!)}</div>
</Flex>
</Flex>
</Flex>
<Secrets />
<Show when={!user().admin}>
<Resources />
</Show>
</>
);
};
export default Account;

View File

@@ -0,0 +1,124 @@
import { A } from "@solidjs/router";
import { Component, createMemo, createSignal, For } from "solid-js";
import { useAppDimensions } from "../../state/DimensionProvider";
import { useAppState } from "../../state/StateProvider";
import { useUser } from "../../state/UserProvider";
import { PermissionLevel } from "../../types";
import { getId } from "../../util/helpers";
import Flex from "../shared/layout/Flex";
import Grid from "../shared/layout/Grid";
const Resources: Component<{}> = (p) => {
const { user, user_id } = useUser();
const { isMobile } = useAppDimensions();
const { builds, deployments, servers } = useAppState();
const [search, setSearch] = createSignal("");
const _servers = createMemo(() => {
return servers.filterArray((s) => {
if (!s.server.name.includes(search())) return false;
const p = s.server.permissions?.[user_id()];
return p ? p !== PermissionLevel.None : false;
});
});
const _deployments = createMemo(() => {
return deployments.filterArray((d) => {
if (!d.deployment.name.includes(search())) return false;
const p = d.deployment.permissions?.[user_id()];
return p ? p !== PermissionLevel.None : false;
});
});
const _builds = createMemo(() => {
return builds.filterArray((b) => {
if (!b.name.includes(search())) return false;
const p = b.permissions?.[user_id()];
return p ? p !== PermissionLevel.None : false;
});
});
return (
<>
<Grid
class="card shadow"
style={{ width: "100%", "box-sizing": "border-box" }}
>
<h1>servers</h1>
<Grid gridTemplateColumns={isMobile() ? undefined : "1fr 1fr"}>
<For each={_servers()}>
{(item) => (
<A
class="card light shadow"
href={`/server/${getId(item.server)}`}
style={{
"justify-content": "space-between",
width: "100%",
"box-sizing": "border-box",
}}
>
<Grid gap="0.25rem">
<h2>{item.server.name}</h2>
<div class="dimmed">{item.server.region || "unknown region"}</div>
</Grid>
<div>{item.server.permissions?.[user_id()] || "none"}</div>
</A>
)}
</For>
</Grid>
</Grid>
<Grid
class="card shadow"
style={{ width: "100%", "box-sizing": "border-box" }}
>
<h1>deployments</h1>
<Grid gridTemplateColumns={isMobile() ? undefined : "1fr 1fr"}>
<For each={_deployments()}>
{(item) => (
<A
href={`/deployment/${getId(item.deployment)}`}
class="card light shadow"
style={{
"justify-content": "space-between",
width: "100%",
"box-sizing": "border-box",
}}
>
<Grid gap="0.25rem">
<h2>{item.deployment.name}</h2>
<div class="dimmed">
{servers.get(item.deployment.server_id)?.server.name ||
"unknown"}
</div>
</Grid>
<div>{item.deployment.permissions?.[user_id()] || "none"}</div>
</A>
)}
</For>
</Grid>
</Grid>
<Grid
class="card shadow"
style={{ width: "100%", "box-sizing": "border-box" }}
>
<h1>builds</h1>
<Grid gridTemplateColumns={isMobile() ? undefined : "1fr 1fr"}>
<For each={_builds()}>
{(item) => (
<A
href={`/build/${getId(item)}`}
class="card light shadow"
style={{
"justify-content": "space-between",
width: "100%",
"box-sizing": "border-box",
}}
>
<h2>{item.name}</h2>
<div>{item.permissions?.[user_id()] || "none"}</div>
</A>
)}
</For>
</Grid>
</Grid>
</>
);
};
export default Resources;

View File

@@ -1,79 +1,81 @@
import { Component, For, Match, Show, Switch } from "solid-js";
import { Component, For, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { client, pushNotification } from "..";
import { useUser } from "../state/UserProvider";
import { copyToClipboard, readableMonitorTimestamp } from "../util/helpers";
import { useToggle } from "../util/hooks";
import ConfirmButton from "./shared/ConfirmButton";
import Icon from "./shared/Icon";
import Input from "./shared/Input";
import Flex from "./shared/layout/Flex";
import Grid from "./shared/layout/Grid";
import Loading from "./shared/loading/Loading";
import CenterMenu from "./shared/menu/CenterMenu";
import Selector from "./shared/menu/Selector";
import { client, pushNotification } from "../..";
import { useUser } from "../../state/UserProvider";
import { copyToClipboard, readableMonitorTimestamp } from "../../util/helpers";
import { useToggle } from "../../util/hooks";
import ConfirmButton from "../shared/ConfirmButton";
import Icon from "../shared/Icon";
import Input from "../shared/Input";
import Flex from "../shared/layout/Flex";
import Grid from "../shared/layout/Grid";
import Loading from "../shared/loading/Loading";
import CenterMenu from "../shared/menu/CenterMenu";
import Selector from "../shared/menu/Selector";
const Account: Component<{}> = (p) => {
const { user, reloadUser } = useUser();
const Secrets: Component<{}> = (p) => {
const { user, reloadUser } = useUser();
const [showCreate, toggleShowCreate] = useToggle();
return (
<>
<Grid
class="card shadow"
style={{ width: "100%", "box-sizing": "border-box" }}
>
<Flex justifyContent="space-between">
<h1>api secrets</h1>
<CenterMenu
show={showCreate}
toggleShow={toggleShowCreate}
targetClass="green"
title="create secret"
target={<Icon type="plus" />}
content={() => <CreateNewMenu />}
position="center"
/>
</Flex>
<For each={user().secrets}>
{(secret) => (
<Flex
class="card dark shadow wrap"
justifyContent="space-between"
alignItems="center"
>
<h2>{secret.name}</h2>
<Flex alignItems="center">
<Flex gap="0.25rem">
<div style={{ opacity: 0.7 }}>created:</div>
<div>{readableMonitorTimestamp(secret.created_at)}</div>
</Flex>
<Flex gap="0.25rem">
<div style={{ opacity: 0.7 }}>expires:</div>
<div>{secret.expires ? readableMonitorTimestamp(secret.expires) : "never"}</div>
</Flex>
<ConfirmButton
class="red"
onConfirm={() =>
client.delete_api_secret(secret.name).then(reloadUser)
}
>
<Icon type="trash" />
</ConfirmButton>
return (
<Grid
class="card shadow"
style={{ width: "100%", "box-sizing": "border-box" }}
>
<Flex justifyContent="space-between">
<h1>api secrets</h1>
<CenterMenu
show={showCreate}
toggleShow={toggleShowCreate}
targetClass="green"
title="create secret"
target={<Icon type="plus" />}
content={() => <CreateNewSecretMenu />}
position="center"
/>
</Flex>
<For each={user().secrets}>
{(secret) => (
<Flex
class="card light shadow wrap"
justifyContent="space-between"
alignItems="center"
>
<h2>{secret.name}</h2>
<Flex alignItems="center">
<Flex gap="0.25rem">
<div style={{ opacity: 0.7 }}>created:</div>
<div>{readableMonitorTimestamp(secret.created_at)}</div>
</Flex>
<Flex gap="0.25rem">
<div style={{ opacity: 0.7 }}>expires:</div>
<div>
{secret.expires
? readableMonitorTimestamp(secret.expires)
: "never"}
</div>
</Flex>
<ConfirmButton
class="red"
onConfirm={() =>
client.delete_api_secret(secret.name).then(reloadUser)
}
>
<Icon type="trash" />
</ConfirmButton>
</Flex>
)}
</For>
</Grid>
</>
</Flex>
)}
</For>
</Grid>
);
};
}
export default Account;
export default Secrets;
const EXPIRE_LENGTHS = ["30 days", "90 days", "1 year", "never"] as const;
type ExpireLength = typeof EXPIRE_LENGTHS[number];
const CreateNewMenu = () => {
const CreateNewSecretMenu = () => {
const { reloadUser } = useUser();
const [info, setInfo] = createStore<{
name: string;
@@ -167,4 +169,4 @@ function createExpires(length: ExpireLength) {
const add_days = length === "30 days" ? 30 : length === "90 days" ? 90 : 365;
const add_ms = add_days * 24 * 60 * 60 * 1000;
return new Date(Date.now() + add_ms).toISOString();
}
}

View File

@@ -11,31 +11,29 @@ type State = {
const context = createContext<State>();
export const ActionStateProvider: ParentComponent<{}> = (p) => {
export const ActionStateProvider: ParentComponent<{ build_id: string }> = (p) => {
const { ws } = useAppState();
const params = useParams();
const [actions, setActions] = createStore<BuildActionState>({
building: false,
recloning: false,
updating: false,
});
createEffect(() => {
client.get_build_action_state(params.id).then(setActions);
client.get_build_action_state(p.build_id).then(setActions);
});
onCleanup(
ws.subscribe([Operation.BuildBuild], (update) => {
if (update.target.id === params.id) {
if (update.target.id === p.build_id) {
setActions("building", update.status !== UpdateStatus.Complete);
}
})
);
onCleanup(
ws.subscribe([Operation.RecloneBuild], (update) => {
if (update.target.id === params.id) {
setActions("recloning", update.status !== UpdateStatus.Complete);
}
})
);
// onCleanup(
// ws.subscribe([Operation.RecloneBuild], (update) => {
// if (update.target.id === params.id) {
// setActions("recloning", update.status !== UpdateStatus.Complete);
// }
// })
// );
// onCleanup(
// ws.subscribe([DELETE_BUILD], ({ complete, buildID }) => {
// if (buildID === selected.id()) {

View File

@@ -10,21 +10,21 @@ import { useActionStates } from "./ActionStateProvider";
import { client } from "../..";
import { combineClasses, getId } from "../../util/helpers";
import { useParams } from "@solidjs/router";
import { PermissionLevel, ServerStatus } from "../../types";
import { PermissionLevel, ServerStatus, ServerWithStatus } from "../../types";
const Actions: Component<{}> = (p) => {
const { user } = useUser();
const params = useParams() as { id: string };
const { builds, servers } = useAppState();
const build = () => builds.get(params.id)!;
const server = () => build() && servers.get(build()!.server_id);
const server = () => (build() && build().server_id) ? servers.get(build()!.server_id!) : undefined;
const actions = useActionStates();
const userCanExecute = () =>
user().admin ||
build().permissions![getId(user())] === PermissionLevel.Execute ||
build().permissions![getId(user())] === PermissionLevel.Update;
return (
<Show when={userCanExecute() && server()?.status === ServerStatus.Ok}>
<Show when={userCanExecute() && (server() ? server()?.status === ServerStatus.Ok : true)}>
<Grid class={combineClasses("card shadow")} gridTemplateRows="auto 1fr">
<h1>actions</h1>
<Grid style={{ height: "fit-content" }}>
@@ -48,7 +48,7 @@ const Actions: Component<{}> = (p) => {
</ConfirmButton>
</Show>
</Flex>
<Flex class={combineClasses("action shadow")}>
{/* <Flex class={combineClasses("action shadow")}>
reclone{" "}
<Show
when={!actions.recloning}
@@ -67,7 +67,7 @@ const Actions: Component<{}> = (p) => {
<Icon type="reset" />
</ConfirmButton>
</Show>
</Flex>
</Flex> */}
</Grid>
</Grid>
</Show>

View File

@@ -4,7 +4,7 @@ import { useAppDimensions } from "../../state/DimensionProvider";
import { useAppState } from "../../state/StateProvider";
import { useUser } from "../../state/UserProvider";
import { Operation, PermissionLevel } from "../../types";
import { combineClasses, getId } from "../../util/helpers";
import Description from "../Description";
import NotFound from "../NotFound";
import Grid from "../shared/layout/Grid";
import Actions from "./Actions";
@@ -14,6 +14,7 @@ import BuildTabs from "./tabs/Tabs";
import Updates from "./Updates";
const Build: Component<{}> = (p) => {
const { user, user_id } = useUser();
const { builds, ws } = useAppState();
const navigate = useNavigate();
const params = useParams();
@@ -33,9 +34,12 @@ const Build: Component<{}> = (p) => {
});
});
onCleanup(() => unsub);
const userCanUpdate = () =>
user().admin ||
build()?.permissions![user_id()] === PermissionLevel.Update;
return (
<Show when={build()} fallback={<NotFound type="build" />}>
<ActionStateProvider>
<Show when={build()} fallback={<NotFound type="build" loaded={builds.loaded()} />}>
<ActionStateProvider build_id={params.id}>
<Grid
style={{
width: "100%",
@@ -46,8 +50,14 @@ const Build: Component<{}> = (p) => {
style={{ width: "100%" }}
gridTemplateColumns={isSemiMobile() ? "1fr" : "1fr 1fr"}
>
<Grid style={{ "flex-grow": 1, "grid-auto-rows": "auto 1fr" }}>
<Grid style={{ "flex-grow": 1, "grid-auto-rows": "auto auto 1fr" }}>
<Header />
<Description
target={{ type: "Build", id: params.id }}
name={build()?.name!}
description={build()?.description}
userCanUpdate={userCanUpdate()}
/>
<Actions />
</Grid>
<Show when={!isSemiMobile()}>

View File

@@ -27,7 +27,8 @@ const Header: Component<{}> = (p) => {
const userCanUpdate = () =>
user().admin ||
build().permissions![getId(user())] === PermissionLevel.Update;
const server = () => servers.get(build().server_id);
const server = () =>
build().server_id ? servers.get(build().server_id!) : undefined;
return (
<>
<Grid
@@ -54,7 +55,7 @@ const Header: Component<{}> = (p) => {
client.delete_build(params.id);
}}
class="red"
title={`delete build | ${build().name}`}
title="delete build"
match={build().name}
>
<Icon type="trash" />
@@ -69,13 +70,15 @@ const Header: Component<{}> = (p) => {
</Flex>
<Flex alignItems="center" justifyContent="space-between">
<Flex alignItems="center">
<A
href={`/server/${build().server_id}`}
class="text-hover"
style={{ opacity: 0.7, padding: 0 }}
>
{server()?.server.name}
</A>
<Show when={server()} fallback={<div style={{ opacity: 0.7 }}>{build().aws_config ? "aws build" : ""}</div>}>
<A
href={`/server/${build().server_id}`}
class="text-hover"
style={{ opacity: 0.7, padding: 0 }}
>
{server()?.server.name}
</A>
</Show>
<div style={{ opacity: 0.7 }}>build</div>
</Flex>
<div style={{ opacity: 0.7 }}>

View File

@@ -45,7 +45,10 @@ export const ConfigProvider: ParentComponent<{}> = (p) => {
set(...args);
set("updated", true);
};
const server = () => servers.get(builds.get(params.id)!.server_id);
const server = () =>
builds.get(params.id)?.server_id
? servers.get(builds.get(params.id)!.server_id!)
: undefined;
const load = () => {
// console.log("load build");
@@ -54,11 +57,11 @@ export const ConfigProvider: ParentComponent<{}> = (p) => {
...build,
repo: build.repo,
branch: build.branch,
on_clone: build.on_clone,
pre_build: build.pre_build,
docker_build_args: build.docker_build_args,
docker_account: build.docker_account,
github_account: build.github_account,
aws_config: build.aws_config,
loaded: true,
updated: false,
saving: false,

View File

@@ -2,13 +2,11 @@ import { useParams } from "@solidjs/router";
import { Component, Show } from "solid-js";
import { useAppState } from "../../../state/StateProvider";
import { useUser } from "../../../state/UserProvider";
import { PermissionLevel } from "../../../types";
import { getId } from "../../../util/helpers";
import SimpleTabs from "../../shared/tabs/SimpleTabs";
import { Tab } from "../../shared/tabs/Tabs";
import BuildConfig from "./build-config/BuildConfig";
import GitConfig from "./git-config/GitConfig";
import Owners from "./Permissions";
import BuilderConfig from "./builder/BuilderConfig";
import BuildConfig from "./config/BuildConfig";
import Permissions from "./Permissions";
import { ConfigProvider } from "./Provider";
const BuildTabs: Component<{}> = (p) => {
@@ -24,16 +22,16 @@ const BuildTabs: Component<{}> = (p) => {
tabs={
[
{
title: "repo",
element: () => <GitConfig />,
},
{
title: "build",
title: "config",
element: () => <BuildConfig />,
},
{
title: "builder",
element: () => <BuilderConfig />
},
user().admin && {
title: "collaborators",
element: () => <Owners />,
title: "permissions",
element: () => <Permissions />,
},
].filter((e) => e) as Tab[]
}

View File

@@ -1,89 +0,0 @@
import { Component, createEffect, createSignal, Show } from "solid-js";
import {
combineClasses,
parseDotEnvToEnvVars,
parseEnvVarseToDotEnv,
} from "../../../../util/helpers";
import { useToggle } from "../../../../util/hooks";
import Flex from "../../../shared/layout/Flex";
import Grid from "../../../shared/layout/Grid";
import CenterMenu from "../../../shared/menu/CenterMenu";
import TextArea from "../../../shared/TextArea";
import { useConfig } from "../Provider";
const BuildArgs: Component<{}> = (p) => {
const { build, userCanUpdate } = useConfig();
return (
<Grid class={combineClasses("config-item shadow")}>
<Flex alignItems="center" justifyContent="space-between">
<h1>build args</h1>
<Flex alignItems="center" gap="0.2rem">
<Show
when={
!build.docker_build_args?.build_args ||
build.docker_build_args.build_args.length === 0
}
>
<div>none</div>
</Show>
<Show when={userCanUpdate()}>
<EditBuildArgs />
</Show>
</Flex>
</Flex>
</Grid>
);
};
const EditBuildArgs: Component<{}> = (p) => {
const [show, toggle] = useToggle();
const [buildArgs, setBuildArgs] = createSignal("");
const { build, setBuild } = useConfig();
createEffect(() => {
setBuildArgs(
parseEnvVarseToDotEnv(
build.docker_build_args?.build_args
? build.docker_build_args.build_args
: []
)
);
});
const toggleShow = () => {
if (show()) {
setBuild("docker_build_args", {
build_args: parseDotEnvToEnvVars(buildArgs()),
});
}
toggle();
};
return (
<CenterMenu
show={show}
toggleShow={toggleShow}
title={`${build.name} build args`}
target="edit"
targetClass="blue"
leftOfX={() => (
<button class="green" onClick={toggleShow}>
confirm
</button>
)}
content={() => (
<TextArea
class="scroller"
value={buildArgs()}
onEdit={setBuildArgs}
style={{
width: "1000px",
"max-width": "90vw",
height: "80vh",
padding: "1rem",
}}
spellcheck={false}
/>
)}
/>
);
};
export default BuildArgs;

View File

@@ -0,0 +1,110 @@
import { Component, Show } from "solid-js";
import { useAppState } from "../../../../state/StateProvider";
import Input from "../../../shared/Input";
import Flex from "../../../shared/layout/Flex";
import Selector from "../../../shared/menu/Selector";
import { useConfig } from "../Provider";
const AwsBuilderConfig: Component<{}> = (p) => {
const { build } = useConfig();
return (
<>
<Ami />
<InstanceType />
<VolumeSize />
<Show when={!build.updated}>
<div style={{ height: "4rem" }} />
</Show>
</>
);
};
const Ami: Component = () => {
const { aws_builder_config } = useAppState();
const { build, setBuild, userCanUpdate } = useConfig();
const default_ami_name = () => aws_builder_config()?.default_ami_name;
const get_ami_name = () => build.aws_config?.ami_name || aws_builder_config()?.default_ami_name || "unknown";
const ami_names = () => {
if (aws_builder_config() === undefined) return [];
return Object.keys(aws_builder_config()!.available_ami_accounts!);
};
return (
<Flex
class="config-item shadow"
alignItems="center"
justifyContent="space-between"
>
<h1>ami</h1>
<Selector
targetClass="blue"
selected={get_ami_name()}
items={ami_names()}
onSelect={(ami_name) => {
if (ami_name === default_ami_name()) {
setBuild("aws_config", "ami_name", undefined);
} else {
setBuild("aws_config", "ami_name", ami_name);
}
}}
itemMap={(i) => i.replaceAll("_", " ")}
position="bottom right"
disabled={!userCanUpdate()}
useSearch
/>
</Flex>
);
};
const InstanceType: Component = () => {
const { aws_builder_config } = useAppState();
const { build, setBuild, userCanUpdate } = useConfig();
return (
<Flex
class="config-item shadow"
alignItems="center"
justifyContent="space-between"
>
<h1>instance type</h1>
<Input
placeholder={aws_builder_config()?.default_instance_type}
value={build.aws_config?.instance_type}
onEdit={(instance_type) =>
setBuild("aws_config", "instance_type", instance_type)
}
disabled={!userCanUpdate()}
/>
</Flex>
);
};
const VolumeSize: Component = () => {
const { aws_builder_config } = useAppState();
const { build, setBuild, userCanUpdate } = useConfig();
return (
<Flex
class="config-item shadow"
alignItems="center"
justifyContent="space-between"
>
<h1>volume size</h1>
<Flex gap="0.25rem" alignItems="center">
<Input
style={{ width: "4rem" }}
placeholder={aws_builder_config()?.default_volume_gb?.toString()}
value={
build.aws_config?.volume_gb
? build.aws_config.volume_gb.toString()
: ""
}
onEdit={(volume_size) =>
setBuild("aws_config", "volume_gb", Number(volume_size))
}
disabled={!userCanUpdate()}
/>
GB
</Flex>
</Flex>
);
};
export default AwsBuilderConfig;

View File

@@ -1,43 +1,30 @@
import { Component, Show } from "solid-js";
import { pushNotification, URL } from "../../../..";
import { combineClasses, copyToClipboard, getId } from "../../../../util/helpers";
import ConfirmButton from "../../../shared/ConfirmButton";
import Icon from "../../../shared/Icon";
import Flex from "../../../shared/layout/Flex";
import Grid from "../../../shared/layout/Grid";
import Loading from "../../../shared/loading/Loading";
import { useConfig } from "../Provider";
import Git from "./Git";
import OnClone from "./OnClone";
import Loading from "../../../shared/loading/Loading";
import BuilderType from "./BuilderType";
import BuilderServer from "./BuilderServer";
import AwsBuilderConfig from "./AwsBuilderConfig";
const GitConfig: Component<{}> = (p) => {
const BuilderConfig: Component<{}> = (p) => {
const { build, reset, save, userCanUpdate } = useConfig();
const listenerUrl = () => `${URL}/api/listener/build/${getId(build)}`;
return (
<Show when={build.loaded}>
<Grid class="config">
<Grid class="config-items scroller">
<Git />
<OnClone />
<Show when={userCanUpdate()}>
<Grid class={combineClasses("config-item shadow")}>
<h1>webhook url</h1>
<Flex justifyContent="space-between" alignItems="center">
<div class="ellipsis" style={{ width: "250px" }}>
{listenerUrl()}
</div>
<ConfirmButton
class="blue"
onFirstClick={() => {
copyToClipboard(listenerUrl());
pushNotification("good", "copied url to clipboard");
}}
confirm={<Icon type="check" />}
>
<Icon type="clipboard" />
</ConfirmButton>
</Flex>
</Grid>
<BuilderType />
<Show when={build.server_id}>
<BuilderServer />
<div style={{ height: "12rem" }} />
</Show>
<Show when={build.aws_config}>
<AwsBuilderConfig />
</Show>
<Show when={!build.server_id && !build.aws_config}>
<div style={{ height: "12rem" }} />
</Show>
</Grid>
<Show when={userCanUpdate() && build.updated}>
@@ -66,4 +53,4 @@ const GitConfig: Component<{}> = (p) => {
);
};
export default GitConfig;
export default BuilderConfig;

View File

@@ -0,0 +1,46 @@
import { Component } from "solid-js";
import { useAppState } from "../../../../state/StateProvider";
import { PermissionLevel } from "../../../../types";
import { getId } from "../../../../util/helpers";
import Flex from "../../../shared/layout/Flex";
import Grid from "../../../shared/layout/Grid";
import Selector from "../../../shared/menu/Selector";
import { useConfig } from "../Provider";
const BuilderServer: Component<{}> = (p) => {
const { servers, getPermissionOnServer } = useAppState();
const { setBuild, server, userCanUpdate } = useConfig();
const availableServers = () => {
if (!servers.loaded()) return [];
return servers
.ids()!
.filter((id) => {
return getPermissionOnServer(id) === PermissionLevel.Update;
});
};
return (
<Flex
class="config-item shadow"
alignItems="center"
justifyContent="space-between"
>
<h1>builder server</h1>
<Selector
targetClass="blue"
selected={server()?.server ? getId(server()!.server) : "select server"}
items={availableServers()}
onSelect={(server_id) => setBuild("server_id", server_id)}
itemMap={(server_id) =>
server_id === "select server"
? "select server"
: servers.get(server_id)!.server.name
}
disabled={!userCanUpdate()}
position="bottom right"
useSearch
/>
</Flex>
);
};
export default BuilderServer;

View File

@@ -0,0 +1,52 @@
import { Component, Show } from "solid-js";
import { useAppState } from "../../../../state/StateProvider";
import Flex from "../../../shared/layout/Flex";
import Grid from "../../../shared/layout/Grid";
import Selector from "../../../shared/menu/Selector";
import { useConfig } from "../Provider";
const BuilderType: Component<{}> = (p) => {
const { servers } = useAppState();
const { build, setBuild, userCanUpdate } = useConfig();
const builderType = () => {
if (build.server_id) {
return "server";
} else if (build.aws_config) {
return "aws";
} else {
return undefined;
}
};
return (
<Flex
class="config-item shadow"
alignItems="center"
justifyContent="space-between"
>
<h1>builder type</h1>
<Show when={userCanUpdate()} fallback={<h2>{builderType()}</h2>}>
<Selector
targetClass="blue"
selected={builderType() || "select type"}
items={["aws", "server"]}
position="bottom right"
onSelect={(type) => {
if (type !== builderType()) {
if (type === "server") {
const server_id =
servers.ids()?.length || 0 > 0
? servers.ids()![0]
: undefined;
setBuild({ server_id, aws_config: undefined });
} else if (type === "aws") {
setBuild({ server_id: undefined, aws_config: {} });
}
}
}}
/>
</Show>
</Flex>
);
};
export default BuilderType;

View File

@@ -0,0 +1,146 @@
import { useParams } from "@solidjs/router";
import {
Component,
createEffect,
createResource,
createSignal,
For,
Show,
} from "solid-js";
import { client } from "../../../..";
import { useAppState } from "../../../../state/StateProvider";
import { ServerStatus } from "../../../../types";
import {
parseDotEnvToEnvVars,
parseEnvVarseToDotEnv,
} from "../../../../util/helpers";
import { useToggle } from "../../../../util/hooks";
import Flex from "../../../shared/layout/Flex";
import Grid from "../../../shared/layout/Grid";
import CenterMenu from "../../../shared/menu/CenterMenu";
import TextArea from "../../../shared/TextArea";
import { useConfig } from "../Provider";
const BuildArgs: Component<{}> = (p) => {
const { build, userCanUpdate } = useConfig();
return (
<Flex
class="config-item shadow"
alignItems="center"
justifyContent="space-between"
>
<h1>build args</h1>
<Flex alignItems="center">
<Show
when={
!build.docker_build_args?.build_args ||
build.docker_build_args.build_args.length === 0
}
>
<div>none</div>
</Show>
<Show when={userCanUpdate()}>
<EditBuildArgs />
</Show>
</Flex>
</Flex>
);
};
const EditBuildArgs: Component<{}> = (p) => {
const { aws_builder_config, builds, serverSecrets } = useAppState();
const [show, toggle] = useToggle();
const [buildArgs, setBuildArgs] = createSignal("");
const params = useParams();
const { build, setBuild, server } = useConfig();
createEffect(() => {
setBuildArgs(
parseEnvVarseToDotEnv(
build.docker_build_args?.build_args
? build.docker_build_args.build_args
: []
)
);
});
const toggleShow = () => {
if (show()) {
setBuild("docker_build_args", {
build_args: parseDotEnvToEnvVars(buildArgs()),
});
}
toggle();
};
const secrets = () => {
if (builds.get(params.id)?.server_id) {
return (
serverSecrets.get(
builds.get(params.id)!.server_id!,
server()?.status || ServerStatus.NotOk
) || []
);
} else if (build.aws_config) {
const ami_name =
build.aws_config?.ami_name || aws_builder_config()?.default_ami_name;
return ami_name
? aws_builder_config()?.available_ami_accounts![ami_name].secrets || []
: [];
} else return [];
};
let ref: HTMLTextAreaElement;
return (
<CenterMenu
show={show}
toggleShow={toggleShow}
title={`${build.name} build args`}
target="edit"
targetClass="blue"
leftOfX={() => (
<button class="green" onClick={toggleShow}>
confirm
</button>
)}
content={() => (
<Grid>
<Show when={secrets()?.length || 0 > 0}>
<Flex class="wrap" justifyContent="flex-end" alignItems="center">
<h2 class="dimmed">secrets:</h2>
<For each={secrets()}>
{(secret) => (
<button
class="blue"
onClick={() =>
setBuildArgs(
(args) =>
args.slice(0, ref.selectionStart) +
`[[${secret}]]` +
args.slice(ref.selectionStart, undefined)
)
}
>
{secret}
</button>
)}
</For>
</Flex>
</Show>
<TextArea
ref={ref! as any}
class="scroller"
placeholder="VARIABLE=value #example"
value={buildArgs()}
onEdit={setBuildArgs}
style={{
width: "1000px",
"max-width": "90vw",
height: "80vh",
padding: "1rem",
}}
spellcheck={false}
/>
</Grid>
)}
/>
);
};
export default BuildArgs;

View File

@@ -9,6 +9,10 @@ import { useConfig } from "../Provider";
import Loading from "../../../shared/loading/Loading";
import BuildArgs from "./BuildArgs";
import Version from "./Version";
import Repo from "./Repo";
import WebhookUrl from "./WebhookUrl";
import ExtraArgs from "./ExtraArgs";
import UseBuildx from "./UseBuildx";
const BuildConfig: Component<{}> = (p) => {
const { build, reset, save, userCanUpdate } = useConfig();
@@ -17,9 +21,15 @@ const BuildConfig: Component<{}> = (p) => {
<Grid class="config">
<Grid class="config-items scroller">
<Version />
<Repo />
<Docker />
<BuildArgs />
<CliBuild />
<BuildArgs />
<ExtraArgs />
<UseBuildx />
<Show when={userCanUpdate()}>
<WebhookUrl />
</Show>
</Grid>
<Show when={userCanUpdate() && build.updated}>
<Show

View File

@@ -1,4 +1,8 @@
import { Component, createEffect, createSignal, Show } from "solid-js";
import {
Component,
createResource,
Show,
} from "solid-js";
import { client } from "../../../..";
import { useAppState } from "../../../../state/StateProvider";
import { ServerStatus } from "../../../../types";
@@ -10,15 +14,19 @@ import Selector from "../../../shared/menu/Selector";
import { useConfig } from "../Provider";
const Docker: Component<{}> = (p) => {
const { aws_builder_config, serverDockerAccounts, docker_organizations } = useAppState();
const { build, setBuild, server, userCanUpdate } = useConfig();
const [dockerAccounts, setDockerAccounts] = createSignal<string[]>();
createEffect(() => {
if (server()?.status === ServerStatus.Ok) {
client
.get_server_docker_accounts(build.server_id)
.then(setDockerAccounts);
}
});
const dockerAccounts = () => {
if (build.server_id) {
return serverDockerAccounts.get(build.server_id, server()?.status || ServerStatus.NotOk) || [];
} else if (build.aws_config) {
const ami_name =
build.aws_config?.ami_name || aws_builder_config()?.default_ami_name;
return ami_name
? aws_builder_config()?.available_ami_accounts![ami_name].docker || []
: [];
} else return [];
};
return (
<Grid class={combineClasses("config-item shadow")}>
<h1>docker build</h1> {/* checkbox here? */}
@@ -62,7 +70,7 @@ const Docker: Component<{}> = (p) => {
<Selector
targetClass="blue"
selected={build.docker_account || "none"}
items={["none", ...dockerAccounts()!]}
items={["none", ...dockerAccounts()]}
onSelect={(account) => {
setBuild(
"docker_account",
@@ -73,6 +81,28 @@ const Docker: Component<{}> = (p) => {
disabled={!userCanUpdate()}
/>
</Flex>
<Show when={build.docker_organization || (docker_organizations() || []).length > 0}>
<Flex
justifyContent={userCanUpdate() ? "space-between" : undefined}
alignItems="center"
style={{ "flex-wrap": "wrap" }}
>
<h2>dockerhub organization: </h2>
<Selector
targetClass="blue"
selected={build.docker_organization || "none"}
items={["none", ...(docker_organizations() || [])]}
onSelect={(account) => {
setBuild(
"docker_organization",
account === "none" ? undefined : account
);
}}
position="bottom right"
disabled={!userCanUpdate()}
/>
</Flex>
</Show>
</Grid>
);
};

View File

@@ -0,0 +1,59 @@
import { Component, For, Show } from "solid-js";
import Icon from "../../../shared/Icon";
import Input from "../../../shared/Input";
import Flex from "../../../shared/layout/Flex";
import Grid from "../../../shared/layout/Grid";
import { useConfig } from "../Provider";
const ExtraArgs: Component<{}> = (p) => {
const { build, setBuild, userCanUpdate } = useConfig();
const onAdd = () => {
setBuild("docker_build_args", "extra_args", (extra_args: any) => [
...extra_args,
"",
]);
};
const onRemove = (index: number) => {
setBuild("docker_build_args", "extra_args", (extra_args) =>
extra_args!.filter((_, i) => i !== index)
);
};
return (
<Grid class="config-item shadow">
<Flex justifyContent="space-between" alignItems="center">
<h1>extra args</h1>
<Show when={userCanUpdate()}>
<button class="green" onClick={onAdd}>
<Icon type="plus" />
</button>
</Show>
</Flex>
<For each={[...build.docker_build_args!.extra_args!.keys()]}>
{(_, index) => (
<Flex
justifyContent={userCanUpdate() ? "space-between" : undefined}
alignItems="center"
style={{ "flex-wrap": "wrap" }}
>
<Input
placeholder="--extra-arg=value"
value={build.docker_build_args!.extra_args![index()]}
style={{ width: "80%" }}
onEdit={(value) =>
setBuild("docker_build_args", "extra_args", index(), value)
}
disabled={!userCanUpdate()}
/>
<Show when={userCanUpdate()}>
<button class="red" onClick={() => onRemove(index())}>
<Icon type="minus" />
</button>
</Show>
</Flex>
)}
</For>
</Grid>
);
};
export default ExtraArgs;

View File

@@ -0,0 +1,85 @@
import { Component, Show } from "solid-js";
import Grid from "../../../shared/layout/Grid";
import { useConfig } from "../Provider";
import Flex from "../../../shared/layout/Flex";
import Input from "../../../shared/Input";
import { combineClasses } from "../../../../util/helpers";
import { useAppState } from "../../../../state/StateProvider";
import { ServerStatus } from "../../../../types";
import Selector from "../../../shared/menu/Selector";
const Repo: Component<{}> = (p) => {
const { aws_builder_config, serverGithubAccounts } = useAppState();
const { build, setBuild, server, userCanUpdate } = useConfig();
const githubAccounts = () => {
if (build.server_id) {
return (
serverGithubAccounts.get(
build.server_id,
server()?.status || ServerStatus.NotOk
) || []
);
} else if (build.aws_config) {
const ami_name =
build.aws_config?.ami_name || aws_builder_config()?.default_ami_name;
return ami_name
? aws_builder_config()?.available_ami_accounts![ami_name].github || []
: [];
} else return [];
};
return (
<Grid class={combineClasses("config-item shadow")}>
<h1>repo config</h1>
<Flex
justifyContent={userCanUpdate() ? "space-between" : undefined}
alignItems="center"
style={{ "flex-wrap": "wrap" }}
>
<h2>repo: </h2>
<Input
placeholder="ie. solidjs/solid"
value={build.repo || ""}
onEdit={(value) => setBuild("repo", value)}
disabled={!userCanUpdate()}
/>
</Flex>
<Flex
justifyContent={userCanUpdate() ? "space-between" : undefined}
alignItems="center"
style={{ "flex-wrap": "wrap" }}
>
<h2>branch: </h2>
<Input
placeholder="defaults to main"
value={build.branch || (userCanUpdate() ? "" : "main")}
onEdit={(value) => setBuild("branch", value)}
disabled={!userCanUpdate()}
/>
</Flex>
<Show when={githubAccounts()}>
<Flex
justifyContent={userCanUpdate() ? "space-between" : undefined}
alignItems="center"
style={{ "flex-wrap": "wrap" }}
>
<h2>github account: </h2>
<Selector
targetClass="blue"
selected={build.github_account || "none"}
items={["none", ...githubAccounts()]}
onSelect={(account) => {
setBuild(
"github_account",
account === "none" ? undefined : account
);
}}
position="bottom right"
disabled={!userCanUpdate()}
/>
</Flex>
</Show>
</Grid>
);
};
export default Repo;

View File

@@ -0,0 +1,30 @@
import { Component, Show } from "solid-js";
import Flex from "../../../shared/layout/Flex";
import { useConfig } from "../Provider";
const UseBuildx: Component<{}> = (p) => {
const { build, setBuild, userCanUpdate } = useConfig();
const use_buildx = () => build.docker_build_args?.use_buildx || false;
return (
<Flex
class="config-item shadow"
alignItems="center"
justifyContent="space-between"
>
<h1>use buildx</h1>
<Show
when={userCanUpdate()}
fallback={<div>{use_buildx() ? "enabled" : "disabled"}</div>}
>
<button
class={use_buildx() ? "green" : "red"}
onClick={() => setBuild("docker_build_args", "use_buildx", (c) => !c)}
>
{use_buildx() ? "enabled" : "disabled"}
</button>
</Show>
</Flex>
);
};
export default UseBuildx;

View File

@@ -1,6 +1,5 @@
import { Component, createSignal, Show } from "solid-js";
import { version_to_string } from "../../../../util/helpers";
import Input from "../../../shared/Input";
import Flex from "../../../shared/layout/Flex";
import { useConfig } from "../Provider";

View File

@@ -0,0 +1,38 @@
import { Component, createResource, Show } from "solid-js";
import { client } from "../../../..";
import { useAppState } from "../../../../state/StateProvider";
import { getId } from "../../../../util/helpers";
import CopyClipboard from "../../../shared/CopyClipboard";
import Flex from "../../../shared/layout/Flex";
import Grid from "../../../shared/layout/Grid";
import Loading from "../../../shared/loading/Loading";
import { useConfig } from "../Provider";
const ListenerUrl: Component<{}> = (p) => {
const { github_webhook_base_url } = useAppState();
const { build } = useConfig();
const listenerUrl = () => {
if (github_webhook_base_url()) {
return `${github_webhook_base_url()}/api/listener/build/${getId(build)}`;
}
};
return (
<Grid class="config-item shadow">
<h1>webhook url</h1>
<Flex
justifyContent="space-between"
alignItems="center"
style={{ "flex-wrap": "wrap" }}
>
<Show when={listenerUrl()} fallback={<Loading type="three-dot" />}>
<div class="ellipsis" style={{ width: "250px" }}>
{listenerUrl()}
</div>
</Show>
<CopyClipboard copyText={listenerUrl() || ""} copying="url" />
</Flex>
</Grid>
);
};
export default ListenerUrl;

View File

@@ -1,72 +0,0 @@
import { Component, createEffect, createSignal } from "solid-js";
import Grid from "../../../shared/layout/Grid";
import { useConfig } from "../Provider";
import Flex from "../../../shared/layout/Flex";
import Input from "../../../shared/Input";
import Selector from "../../../shared/menu/Selector";
import { combineClasses } from "../../../../util/helpers";
import { client } from "../../../..";
import { ServerStatus } from "../../../../types";
const Git: Component<{}> = (p) => {
const { build, setBuild, server, userCanUpdate } = useConfig();
const [githubAccounts, setGithubAccounts] = createSignal<string[]>();
createEffect(() => {
if (server()?.status === ServerStatus.Ok) {
client.get_server_github_accounts(build.server_id).then(setGithubAccounts);
}
});
return (
<Grid class={combineClasses("config-item shadow")}>
<h1>github config</h1>
<Flex
justifyContent={userCanUpdate() ? "space-between" : undefined}
alignItems="center"
style={{ "flex-wrap": "wrap" }}
>
<h2>repo: </h2>
<Input
placeholder="ie. solidjs/solid"
value={build.repo || ""}
onEdit={(value) => setBuild("repo", value)}
disabled={!userCanUpdate()}
/>
</Flex>
<Flex
justifyContent={userCanUpdate() ? "space-between" : undefined}
alignItems="center"
style={{ "flex-wrap": "wrap" }}
>
<h2>branch: </h2>
<Input
placeholder="defaults to main"
value={build.branch || (userCanUpdate() ? "" : "main")}
onEdit={(value) => setBuild("branch", value)}
disabled={!userCanUpdate()}
/>
</Flex>
<Flex
justifyContent={userCanUpdate() ? "space-between" : undefined}
alignItems="center"
style={{ "flex-wrap": "wrap" }}
>
<h2>github account: </h2>
<Selector
targetClass="blue"
selected={build.github_account || "none"}
items={["none", ...githubAccounts()!]}
onSelect={(account) => {
setBuild(
"github_account",
account === "none" ? undefined : account
);
}}
position="bottom right"
disabled={!userCanUpdate()}
/>
</Flex>
</Grid>
);
};
export default Git;

View File

@@ -1,63 +0,0 @@
import { Component } from "solid-js";
import { combineClasses } from "../../../../util/helpers";
import Input from "../../../shared/Input";
import Grid from "../../../shared/layout/Grid";
import { useConfig } from "../Provider";
import Flex from "../../../shared/layout/Flex";
const OnClone: Component = () => {
const { build, setBuild, userCanUpdate } = useConfig();
return (
<Grid class={combineClasses("config-item shadow")}>
<h1>on clone</h1>
<Flex
alignItems="center"
justifyContent={userCanUpdate() ? "space-between" : undefined}
style={{ "flex-wrap": "wrap" }}
>
<h2>path:</h2>
<Input
placeholder="relative to repo"
value={build.on_clone?.path || ""}
onEdit={(path) => {
if (
path.length === 0 &&
(!build.on_clone ||
!build.on_clone.command ||
build.on_clone.command.length === 0)
) {
setBuild("on_clone", undefined);
}
setBuild("on_clone", { path });
}}
disabled={!userCanUpdate()}
/>
</Flex>
<Flex
alignItems="center"
justifyContent={userCanUpdate() ? "space-between" : undefined}
style={{ "flex-wrap": "wrap" }}
>
<h2>command:</h2>
<Input
placeholder="command"
value={build.on_clone?.command || ""}
onEdit={(command) => {
if (
command.length === 0 &&
(!build.on_clone ||
!build.on_clone.path ||
build.on_clone.path.length === 0)
) {
setBuild("on_clone", undefined);
}
setBuild("on_clone", { command });
}}
disabled={!userCanUpdate()}
/>
</Flex>
</Grid>
);
};
export default OnClone;

View File

@@ -196,7 +196,7 @@ const Deploy: Component<{ redeploy?: boolean }> = (p) => {
onConfirm={() => {
client.deploy_container(params.id);
}}
title={`redeploy container | ${name()}`}
title="redeploy container"
match={name()!}
>
<Icon type={"reset"} />
@@ -232,7 +232,7 @@ const RemoveContainer = () => {
onConfirm={() => {
client.remove_container(params.id);
}}
title={`destroy container | ${name()}`}
title="destroy container"
match={name()!}
>
<Icon type="trash" />
@@ -298,7 +298,7 @@ const Stop = () => {
onConfirm={() => {
client.stop_container(params.id);
}}
title={`stop container | ${name()}`}
title="stop container"
match={name()!}
>
<Icon type="pause" />

View File

@@ -1,9 +1,10 @@
import { useParams } from "@solidjs/router";
import { Component, onCleanup, Show } from "solid-js";
import { client } from "../..";
import { Component, Show } from "solid-js";
import { useAppDimensions } from "../../state/DimensionProvider";
import { useAppState } from "../../state/StateProvider";
import { ServerStatus } from "../../types";
import { useUser } from "../../state/UserProvider";
import { PermissionLevel } from "../../types";
import Description from "../Description";
import NotFound from "../NotFound";
import Grid from "../shared/layout/Grid";
import Actions from "./Actions";
@@ -16,12 +17,16 @@ const POLLING_RATE = 10000;
// let interval = -1;
const Deployment: Component<{}> = (p) => {
const { user, user_id } = useUser();
const { servers, deployments } = useAppState();
const { isSemiMobile } = useAppDimensions();
const params = useParams();
const deployment = () => deployments.get(params.id);
const server = () =>
deployment() && servers.get(deployment()!.deployment.server_id);
const userCanUpdate = () =>
user().admin ||
deployment()?.deployment.permissions![user_id()] === PermissionLevel.Update;
// clearInterval(interval);
// interval = setInterval(async () => {
// if (server()?.status === ServerStatus.Ok) {
@@ -33,7 +38,7 @@ const Deployment: Component<{}> = (p) => {
return (
<Show
when={deployment() && server()}
fallback={<NotFound type="deployment" />}
fallback={<NotFound type="deployment" loaded={deployments.loaded()} />}
>
<ActionStateProvider>
<Grid
@@ -46,8 +51,14 @@ const Deployment: Component<{}> = (p) => {
style={{ width: "100%" }}
gridTemplateColumns={isSemiMobile() ? "1fr" : "1fr 1fr"}
>
<Grid style={{ "flex-grow": 1, "grid-auto-rows": "auto 1fr" }}>
<Grid style={{ "flex-grow": 1, "grid-auto-rows": "auto auto 1fr" }}>
<Header />
<Description
target={{ type: "Deployment", id: params.id }}
name={deployment()?.deployment.name!}
description={deployment()?.deployment.description}
userCanUpdate={userCanUpdate()}
/>
<Actions />
</Grid>
<Show when={!isSemiMobile()}>

View File

@@ -1,10 +1,11 @@
import { Component, Show } from "solid-js";
import { Component, createResource, createSignal, Show } from "solid-js";
import { useAppState } from "../../state/StateProvider";
import { useUser } from "../../state/UserProvider";
import {
combineClasses,
deploymentHeaderStateClass,
getId,
readableVersion,
} from "../../util/helpers";
import Icon from "../shared/Icon";
import Flex from "../shared/layout/Flex";
@@ -18,9 +19,11 @@ import { A, useParams } from "@solidjs/router";
import { client } from "../..";
import CopyMenu from "../CopyMenu";
import ConfirmMenuButton from "../shared/ConfirmMenuButton";
import Loading from "../shared/loading/Loading";
import { AutofocusInput } from "../shared/Input";
const Header: Component<{}> = (p) => {
const { deployments, servers } = useAppState();
const { deployments, servers, builds } = useAppState();
const params = useParams();
const deployment = () => deployments.get(params.id)!;
const { user } = useUser();
@@ -37,6 +40,41 @@ const Header: Component<{}> = (p) => {
deployment().deployment.permissions![getId(user())] ===
PermissionLevel.Update;
const server = () => servers.get(deployment().deployment.server_id);
const [deployed_version] = createResource(() =>
client.get_deployment_deployed_version(params.id)
);
const derived_image = () => {
if (deployment().deployment.build_id) {
const build = builds.get(deployment().deployment.build_id!);
if (build === undefined) return "unknown";
const version =
deployment().state === DockerContainerState.NotDeployed
? deployment().deployment.build_version
? readableVersion(
deployment().deployment.build_version!
).replaceAll("v", "")
: "latest"
: deployed_version() || "unknown";
return `${build.name}:${version}`;
} else {
return deployment().deployment.docker_run_args.image || "unknown";
}
};
const image = () => {
if (deployment().state === DockerContainerState.NotDeployed) {
return derived_image();
} else if (deployment().container?.image) {
if (deployment().container!.image.includes("sha256:")) {
return derived_image();
}
let [account, image] = deployment().container!.image.split("/");
return image ? image : account;
} else {
return "unknown";
}
};
const [editingName, setEditingName] = createSignal(false);
const [updatingName, setUpdatingName] = createSignal(false);
return (
<>
<Grid
@@ -52,7 +90,48 @@ const Header: Component<{}> = (p) => {
}}
>
<Flex alignItems="center" justifyContent="space-between">
<h1>{deployment()!.deployment.name}</h1>
<Flex alignItems="center">
<Show
when={editingName()}
fallback={
<button
onClick={() => setEditingName(true)}
style={{ padding: 0 }}
>
<h1>{deployment()!.deployment.name}</h1>
</button>
}
>
<Show
when={!updatingName()}
fallback={<Loading type="three-dot" />}
>
<AutofocusInput
value={deployment().deployment.name}
placeholder={deployment().deployment.name}
onEnter={async (new_name) => {
setUpdatingName(true);
await client.rename_deployment(params.id, new_name);
setEditingName(false);
setUpdatingName(false);
}}
onBlur={() => setEditingName(false)}
/>
</Show>
</Show>
<Show
when={deployment().deployment.build_id}
fallback={<div style={{ opacity: 0.7 }}>{image()}</div>}
>
<A
href={`/build/${deployment().deployment.build_id}`}
class="text-hover"
style={{ opacity: 0.7, padding: 0 }}
>
{image()}
</A>
</Show>
</Flex>
<Show when={userCanUpdate()}>
<Flex alignItems="center">
<CopyMenu type="deployment" id={params.id} />
@@ -63,9 +142,7 @@ const Header: Component<{}> = (p) => {
client.delete_deployment(params.id);
}}
class="red"
title={`delete deployment | ${
deployment().deployment.name
}`}
title="delete deployment"
match={deployment().deployment.name}
info={
<Show when={deployment().container}>

View File

@@ -123,7 +123,7 @@ const DeploymentTabs: Component<{}> = () => {
},
],
user().admin && {
title: "collaborators",
title: "permissions",
element: () => <Permissions />,
},
]

View File

@@ -17,19 +17,14 @@ import { Tab } from "../../../shared/tabs/Tabs";
import RepoMount from "./mount-repo/RepoMount";
import { OnClone, OnPull } from "./mount-repo/OnGit";
import Loading from "../../../shared/loading/Loading";
import Permissions from "../Permissions";
import { pushNotification, URL } from "../../../..";
import { combineClasses, copyToClipboard, getId } from "../../../../util/helpers";
import { useAppDimensions } from "../../../../state/DimensionProvider";
import { useUser } from "../../../../state/UserProvider";
import SimpleTabs from "../../../shared/tabs/SimpleTabs";
import ExtraArgs from "./container/ExtraArgs";
import WebhookUrl from "./container/WebhookUrl";
const Config: Component<{}> = () => {
const { deployment, reset, save, userCanUpdate } = useConfig();
const { user } = useUser();
const { isMobile } = useAppDimensions();
const listenerUrl = () => `${URL}/api/listener/deployment/${getId(deployment)}`;
return (
<Show when={deployment.loaded}>
<Grid class="config">
@@ -44,14 +39,12 @@ const Config: Component<{}> = () => {
element: () => (
<Grid class="config-items scroller" placeItems="start center">
<Image />
<Show when={deployment.docker_run_args.image}>
<DockerAccount />
</Show>
<DockerAccount />
<Network />
<Restart />
<Env />
<Ports />
<Mounts />
<Env />
<ExtraArgs />
<PostImage />
<Show when={isMobile()}>
@@ -66,31 +59,7 @@ const Config: Component<{}> = () => {
<Grid class="config-items scroller" placeItems="start center">
<Git />
<Show when={userCanUpdate()}>
<Grid class={combineClasses("config-item shadow")}>
<h1>webhook url</h1>
<Flex
justifyContent="space-between"
alignItems="center"
style={{ "flex-wrap": "wrap" }}
>
<div class="ellipsis" style={{ width: "250px" }}>
{listenerUrl()}
</div>
<ConfirmButton
class="blue"
onFirstClick={() => {
copyToClipboard(listenerUrl());
pushNotification(
"good",
"copied url to clipboard"
);
}}
confirm={<Icon type="check" />}
>
<Icon type="clipboard" />
</ConfirmButton>
</Flex>
</Grid>
<WebhookUrl />
</Show>
<RepoMount />
<OnClone />

View File

@@ -3,16 +3,24 @@ import {
Accessor,
createContext,
createEffect,
createResource,
createSignal,
onCleanup,
ParentComponent,
Resource,
useContext,
} from "solid-js";
import { createStore, SetStoreFunction } from "solid-js/store";
import { client, pushNotification } from "../../../..";
import { useAppState } from "../../../../state/StateProvider";
import { useUser } from "../../../../state/UserProvider";
import { Deployment, Operation, PermissionLevel, ServerStatus, ServerWithStatus } from "../../../../types";
import {
Deployment,
Operation,
PermissionLevel,
ServerStatus,
ServerWithStatus,
} from "../../../../types";
import { getId } from "../../../../util/helpers";
type ConfigDeployment = Deployment & {
@@ -28,7 +36,7 @@ type State = {
server: () => ServerWithStatus | undefined;
reset: () => void;
save: () => void;
networks: Accessor<any[]>;
networks: Resource<any[]>;
userCanUpdate: () => boolean;
};
@@ -87,19 +95,20 @@ export const ConfigProvider: ParentComponent<{}> = (p) => {
};
createEffect(load);
const [networks, setNetworks] = createSignal<any[]>([]);
const server = () => servers.get(deployments.get(params.id)!.deployment.server_id);
createEffect(() => {
const server = () =>
servers.get(deployments.get(params.id)!.deployment.server_id);
const [networks] = createResource(() => {
if (server()?.status === ServerStatus.Ok) {
client
.get_docker_networks(deployments.get(params.id)!.deployment.server_id)
.then(setNetworks);
}
return client.get_docker_networks(
deployments.get(params.id)!.deployment.server_id
);
} else return [];
});
const save = () => {
setDeployment("updating", true);
client.update_deployment(deployment).catch(e => {
client.update_deployment(deployment).catch((e) => {
console.error(e);
pushNotification("bad", "update deployment failed");
setDeployment("updating", false);

View File

@@ -1,5 +1,5 @@
import { Component, createEffect, createSignal, Show } from "solid-js";
import { client } from "../../../../..";
import { Component } from "solid-js";
import { useAppState } from "../../../../../state/StateProvider";
import { ServerStatus } from "../../../../../types";
import { combineClasses } from "../../../../../util/helpers";
import Flex from "../../../../shared/layout/Flex";
@@ -7,15 +7,21 @@ import Selector from "../../../../shared/menu/Selector";
import { useConfig } from "../Provider";
const DockerAccount: Component<{}> = (p) => {
const { serverDockerAccounts } = useAppState();
const { deployment, setDeployment, server, userCanUpdate } = useConfig();
const [dockerAccounts, setDockerAccounts] = createSignal<string[]>();
createEffect(() => {
if (server()?.status === ServerStatus.Ok) {
client
.get_server_docker_accounts(deployment.server_id)
.then(setDockerAccounts);
const dockerAccounts = () =>
serverDockerAccounts.get(
deployment.server_id,
server()?.status || ServerStatus.NotOk
) || [];
const when_none_selected = () => {
if (deployment.build_id) {
return "same as build";
} else {
return "none";
}
});
};
const accounts = () => [when_none_selected(), ...dockerAccounts()];
return (
<Flex
class={combineClasses("config-item shadow")}
@@ -26,11 +32,14 @@ const DockerAccount: Component<{}> = (p) => {
<h1>docker account</h1>
<Selector
targetClass="blue"
items={["none", ...dockerAccounts()!]}
selected={deployment.docker_run_args.docker_account || "none"}
items={accounts()}
selected={
deployment.docker_run_args.docker_account || when_none_selected()
}
onSelect={(account) =>
setDeployment("docker_run_args", {
docker_account: account === "none" ? undefined : account,
docker_account:
account === when_none_selected() ? undefined : account,
})
}
position="bottom right"

View File

@@ -1,4 +1,14 @@
import { Component, createEffect, createSignal, Show } from "solid-js";
import {
Component,
createEffect,
createResource,
createSignal,
For,
Show,
} from "solid-js";
import { client } from "../../../../..";
import { useAppState } from "../../../../../state/StateProvider";
import { ServerStatus } from "../../../../../types";
import {
combineClasses,
parseDotEnvToEnvVars,
@@ -17,7 +27,7 @@ const Env: Component<{}> = (p) => {
<Grid class={combineClasses("config-item shadow")}>
<Flex alignItems="center" justifyContent="space-between">
<h1>environment</h1>
<Flex alignItems="center" gap="0.2rem">
<Flex alignItems="center">
<Show
when={
!deployment.docker_run_args.environment ||
@@ -36,9 +46,10 @@ const Env: Component<{}> = (p) => {
};
const EditDotEnv: Component<{}> = (p) => {
const { serverSecrets } = useAppState();
const [show, toggle] = useToggle();
const [dotenv, setDotEnv] = createSignal("");
const { deployment, setDeployment } = useConfig();
const { deployment, setDeployment, server } = useConfig();
createEffect(() => {
setDotEnv(
parseEnvVarseToDotEnv(
@@ -56,6 +67,12 @@ const EditDotEnv: Component<{}> = (p) => {
}
toggle();
};
const secrets = () =>
serverSecrets.get(
deployment.server_id,
server()?.status || ServerStatus.NotOk
) || [];
let ref: HTMLTextAreaElement;
return (
<CenterMenu
show={show}
@@ -69,18 +86,44 @@ const EditDotEnv: Component<{}> = (p) => {
</button>
)}
content={() => (
<TextArea
class="scroller"
value={dotenv()}
onEdit={setDotEnv}
style={{
width: "1000px",
"max-width": "90vw",
height: "80vh",
padding: "1rem",
}}
spellcheck={false}
/>
<Grid>
<Show when={secrets()?.length || 0 > 0}>
<Flex class="wrap" justifyContent="flex-end" alignItems="center">
<h2 class="dimmed">secrets:</h2>
<For each={secrets()}>
{(secret) => (
<button
class="blue"
onClick={() =>
setDotEnv(
(env) =>
env.slice(0, ref.selectionStart) +
`[[${secret}]]` +
env.slice(ref.selectionStart, undefined)
)
}
>
{secret}
</button>
)}
</For>
</Flex>
</Show>
<TextArea
ref={ref! as any}
class="scroller"
placeholder="VARIABLE=value #example"
value={dotenv()}
onEdit={setDotEnv}
style={{
width: "1000px",
"max-width": "90vw",
height: "80vh",
padding: "1rem",
}}
spellcheck={false}
/>
</Grid>
)}
/>
);

View File

@@ -22,21 +22,11 @@ const ExtraArgs: Component<{}> = (p) => {
<Grid class="config-item shadow">
<Flex justifyContent="space-between" alignItems="center">
<h1>extra args</h1>
<Flex alignItems="center">
<Show
when={
!deployment.docker_run_args.extra_args ||
deployment.docker_run_args.extra_args.length === 0
}
>
<div>none</div>
</Show>
<Show when={userCanUpdate()}>
<button class="green" onClick={onAdd}>
<Icon type="plus" />
</button>
</Show>
</Flex>
<Show when={userCanUpdate()}>
<button class="green" onClick={onAdd}>
<Icon type="plus" />
</button>
</Show>
</Flex>
<For each={[...deployment.docker_run_args.extra_args!.keys()]}>
{(_, index) => (

View File

@@ -1,8 +1,11 @@
import { Component, createEffect, createSignal, Show } from "solid-js";
import { Component, createResource, Show } from "solid-js";
import { client } from "../../../../..";
import { useAppState } from "../../../../../state/StateProvider";
import { BuildVersionsReponse } from "../../../../../types";
import { combineClasses, string_to_version, version_to_string } from "../../../../../util/helpers";
import {
combineClasses,
string_to_version,
version_to_string,
} from "../../../../../util/helpers";
import Input from "../../../../shared/Input";
import Flex from "../../../../shared/layout/Flex";
import Selector from "../../../../shared/menu/Selector";
@@ -11,10 +14,9 @@ import { useConfig } from "../Provider";
const Image: Component<{}> = (p) => {
const { deployment, setDeployment, userCanUpdate } = useConfig();
const { builds } = useAppState();
const [versions, setVersions] = createSignal<BuildVersionsReponse[]>([]);
createEffect(() => {
const [versions] = createResource(() => {
if (deployment.build_id) {
client.get_build_versions(deployment.build_id).then(setVersions);
return client.get_build_versions(deployment.build_id);
}
});
return (
@@ -72,7 +74,9 @@ const Image: Component<{}> = (p) => {
}
items={[
"latest",
...versions().map((v) => `v${version_to_string(v.version)}`),
...(versions()?.map(
(v) => `v${version_to_string(v.version)}`
) || []),
]}
onSelect={(version) => {
if (version === "latest") {

View File

@@ -20,6 +20,7 @@ const Network: Component<{}> = (p) => {
onSelect={(network) => setDeployment("docker_run_args", { network })}
position="bottom right"
disabled={!userCanUpdate()}
searchStyle={{ width: "100%", "min-width": "12rem" }}
useSearch
/>
</Flex>

View File

@@ -23,21 +23,11 @@ const Volumes: Component<{}> = (p) => {
<Grid class={combineClasses("config-item shadow")}>
<Flex justifyContent="space-between" alignItems="center">
<h1>volumes</h1>
<Flex alignItems="center">
<Show
when={
!deployment.docker_run_args.volumes ||
deployment.docker_run_args.volumes.length === 0
}
>
<div>none</div>
</Show>
<Show when={userCanUpdate()}>
<button class="green" onClick={onAdd}>
<Icon type="plus" />
</button>
</Show>
</Flex>
<Show when={userCanUpdate()}>
<button class="green" onClick={onAdd}>
<Icon type="plus" />
</button>
</Show>
</Flex>
<For each={deployment.docker_run_args.volumes}>
{({ local, container }, index) => (

View File

@@ -0,0 +1,39 @@
import { Component, createResource, Show } from "solid-js";
import { useAppState } from "../../../../../state/StateProvider";
import { getId } from "../../../../../util/helpers";
import CopyClipboard from "../../../../shared/CopyClipboard";
import Flex from "../../../../shared/layout/Flex";
import Grid from "../../../../shared/layout/Grid";
import Loading from "../../../../shared/loading/Loading";
import { useConfig } from "../Provider";
const WebhookUrl: Component<{}> = (p) => {
const { github_webhook_base_url } = useAppState();
const { deployment } = useConfig();
const listenerUrl = () => {
if (github_webhook_base_url()) {
return `${github_webhook_base_url()}/api/listener/deployment/${getId(
deployment
)}`;
}
};
return (
<Grid class="config-item shadow">
<h1>webhook url</h1>
<Flex
justifyContent="space-between"
alignItems="center"
style={{ "flex-wrap": "wrap" }}
>
<Show when={listenerUrl()} fallback={<Loading type="three-dot" />}>
<div class="ellipsis" style={{ "max-width": "250px" }}>
{listenerUrl()}
</div>
</Show>
<CopyClipboard copyText={listenerUrl() || ""} copying="url" />
</Flex>
</Grid>
);
};
export default WebhookUrl;

View File

@@ -1,5 +1,5 @@
import { Component, createEffect, createSignal } from "solid-js";
import { client } from "../../../../..";
import { Component, createResource } from "solid-js";
import { useAppState } from "../../../../../state/StateProvider";
import { ServerStatus } from "../../../../../types";
import { combineClasses } from "../../../../../util/helpers";
import Input from "../../../../shared/Input";
@@ -9,15 +9,13 @@ import Selector from "../../../../shared/menu/Selector";
import { useConfig } from "../Provider";
const Git: Component<{}> = (p) => {
const { serverGithubAccounts } = useAppState();
const { deployment, server, setDeployment, userCanUpdate } = useConfig();
const [githubAccounts, setGithubAccounts] = createSignal<string[]>();
createEffect(() => {
if (server()?.status === ServerStatus.Ok) {
client
.get_server_github_accounts(deployment.server_id)
.then(setGithubAccounts);
}
});
const githubAccounts = () =>
serverGithubAccounts.get(
deployment.server_id,
server()?.status || ServerStatus.NotOk
) || [];
return (
<Grid class={combineClasses("config-item shadow")}>
<h1>github config</h1>
@@ -56,7 +54,7 @@ const Git: Component<{}> = (p) => {
<Selector
targetClass="blue"
selected={deployment.github_account || "none"}
items={["none", ...githubAccounts()!]}
items={["none", ...githubAccounts()]}
onSelect={(account) => {
setDeployment(
"github_account",

View File

@@ -2,12 +2,11 @@ import { useParams } from "@solidjs/router";
import {
Component,
createEffect,
createMemo,
createSignal,
onCleanup,
Show,
} from "solid-js";
import { client, pushNotification } from "../../../..";
import { pushNotification } from "../../../..";
import { useAppState } from "../../../../state/StateProvider";
import { DockerContainerState, Log as LogType } from "../../../../types";
import { combineClasses } from "../../../../util/helpers";
@@ -70,7 +69,7 @@ const Log: Component<{
const buffer = useBuffer(scrolled, 250);
const [poll, togglePoll] = useLocalStorageToggle(
"deployment-log-polling",
true
false
);
clearInterval(interval);
interval = setInterval(() => {
@@ -100,7 +99,7 @@ const Log: Component<{
position="bottom right"
itemStyle={{ width: "4rem" }}
/>
<Show when={userCanUpdate()}>
{/* <Show when={userCanUpdate()}>
<button
class="blue"
onClick={() =>
@@ -114,7 +113,7 @@ const Log: Component<{
>
download full log
</button>
</Show>
</Show> */}
<button
class="blue"
onClick={async () => {

View File

@@ -6,6 +6,7 @@ import { useAppState } from "../../state/StateProvider";
import Grid from "../shared/layout/Grid";
import SimpleTabs from "../shared/tabs/SimpleTabs";
import Summary from "./Summary";
import Builds from "./Tree/Builds";
import Groups from "./Tree/Groups";
import { TreeProvider } from "./Tree/Provider";
import Servers from "./Tree/Servers";
@@ -36,6 +37,10 @@ const Home: Component<{}> = (p) => {
title: "servers",
element: () => <Servers serverIDs={servers.ids()!} showAdd />,
},
{
title: "builds",
element: () => <Builds />
}
]}
/>
</TreeProvider>

View File

@@ -1,126 +1,59 @@
import { Component, createMemo, For, Show } from "solid-js";
import { Accessor, Component, createMemo } from "solid-js";
import { useAppState } from "../../state/StateProvider";
import { DockerContainerState, ServerStatus } from "../../types";
import Grid from "../shared/layout/Grid";
import Flex from "../shared/layout/Flex";
import PieChart, { PieChartSection } from "../shared/PieChart";
import { COLORS } from "../../style/colors";
import { useAppDimensions } from "../../state/DimensionProvider";
const PIE_CHART_SIZE = 250;
const Summary: Component<{}> = (p) => {
const { isMobile } = useAppDimensions();
const deployentCount = useDeploymentCount();
const serverCount = useServerCount();
return (
<Grid class="card shadow" gridTemplateRows="auto 1fr 1fr 1fr">
<h1>summary</h1>
<DeploymentsSummary />
<ServersSummary />
<BuildsSummary />
<Grid
class="full-size"
gridTemplateColumns={isMobile() ? "1fr" : "1fr 1fr"}
>
<Grid class="card shadow full-size" placeItems="center">
<div
style={{
width: `${PIE_CHART_SIZE}px`,
height: `${PIE_CHART_SIZE}px`,
}}
>
<PieChart title="deployments" sections={deployentCount()} />
</div>
</Grid>
<Grid class="card shadow full-size" placeItems="center">
<div
style={{
width: `${PIE_CHART_SIZE}px`,
height: `${PIE_CHART_SIZE}px`,
}}
>
<PieChart title="servers" sections={serverCount()} />
</div>
</Grid>
</Grid>
);
};
export default Summary;
const SummaryItem: Component<{
title: string;
metrics: Array<{ title: string; class: string; count?: number }>;
}> = (p) => {
return (
<Flex
class="card light shadow wrap"
justifyContent="space-between"
alignItems="center"
>
<h2>{p.title}</h2>
<Flex class="wrap">
<For each={p.metrics}>
{(metric) => (
<Show when={metric?.count && metric.count > 0}>
<Flex gap="0.4rem" alignItems="center">
<div>{metric.title}</div>
<h2 class={metric.class}>{metric.count}</h2>
</Flex>
</Show>
)}
</For>
</Flex>
</Flex>
);
};
const BuildsSummary = () => {
const { builds } = useAppState();
return (
<SummaryItem
title="builds"
metrics={[
{ title: "total", class: "text-green", count: builds.ids()?.length },
]}
/>
);
};
const DeploymentsSummary = () => {
const deployentCount = useDeploymentCount();
return (
<SummaryItem
title="deployments"
metrics={[
{
title: "total",
class: "text-green",
count: deployentCount().total,
},
{
title: "running",
class: "text-green",
count: deployentCount().running,
},
{
title: "stopped",
class: "text-red",
count: deployentCount().stopped,
},
{
title: "not deployed",
class: "text-blue",
count: deployentCount().notDeployed,
},
{
title: "unknown",
class: "text-blue",
count: deployentCount().unknown,
},
]}
/>
);
};
const ServersSummary = () => {
const serverCount = useServerCount();
return (
<SummaryItem
title="servers"
metrics={[
{ title: "total", class: "text-green", count: serverCount().total },
{ title: "healthy", class: "text-green", count: serverCount().healthy },
{
title: "unhealthy",
class: "text-red",
count: serverCount().unhealthy,
},
{
title: "disabled",
class: "text-blue",
count: serverCount().disabled,
},
]}
/>
);
};
function useDeploymentCount() {
function useDeploymentCount(): Accessor<PieChartSection[]> {
const { deployments } = useAppState();
const count = createMemo(() => {
const ids = deployments.ids();
if (!ids)
return { total: 0, running: 0, stopped: 0, notDeployed: 0, unknown: 0 };
return [
{ title: "running", amount: 0, color: COLORS.textgreen },
{ title: "stopped", amount: 0, color: COLORS.textred },
{ title: "not deployed", amount: 0, color: COLORS.textblue },
{ title: "unknown", amount: 0, color: COLORS.textorange },
];
let running = 0;
let stopped = 0;
let notDeployed = 0;
@@ -137,16 +70,26 @@ function useDeploymentCount() {
unknown++;
}
}
return { total: ids.length, running, stopped, notDeployed, unknown };
return [
{ title: "running", amount: running, color: COLORS.textgreen },
{ title: "stopped", amount: stopped, color: COLORS.textred },
{ title: "not deployed", amount: notDeployed, color: COLORS.textblue },
{ title: "unknown", amount: unknown, color: COLORS.textorange },
];
});
return count;
}
function useServerCount() {
function useServerCount(): Accessor<PieChartSection[]> {
const { servers } = useAppState();
const count = createMemo(() => {
const ids = servers.ids();
if (!ids) return { total: 0, healthy: 0, unhealthy: 0, disabled: 0 };
if (!ids)
return [
{ title: "healthy", amount: 0, color: COLORS.textgreen },
{ title: "unhealthy", amount: 0, color: COLORS.textred },
{ title: "disabled", amount: 0, color: COLORS.textblue },
];
let healthy = 0;
let unhealthy = 0;
let disabled = 0;
@@ -160,7 +103,50 @@ function useServerCount() {
unhealthy++;
}
}
return { total: ids.length, healthy, unhealthy, disabled };
return [
{ title: "healthy", amount: healthy, color: COLORS.textgreen },
{ title: "unhealthy", amount: unhealthy, color: COLORS.textred },
{ title: "disabled", amount: disabled, color: COLORS.textblue },
];
});
return count;
}
// const SummaryItem: Component<{
// title: string;
// metrics: Array<{ title: string; class: string; count?: number }>;
// }> = (p) => {
// return (
// <Flex
// class="card light shadow wrap"
// justifyContent="space-between"
// alignItems="center"
// >
// <h2>{p.title}</h2>
// <Flex class="wrap">
// <For each={p.metrics}>
// {(metric) => (
// <Show when={metric?.count && metric.count > 0}>
// <Flex gap="0.4rem" alignItems="center">
// <div>{metric.title}</div>
// <h2 class={metric.class}>{metric.count}</h2>
// </Flex>
// </Show>
// )}
// </For>
// </Flex>
// </Flex>
// );
// };
// const BuildsSummary = () => {
// const { builds } = useAppState();
// return (
// <SummaryItem
// title="builds"
// metrics={[
// { title: "total", class: "text-green", count: builds.ids()?.length },
// ]}
// />
// );
// };

View File

@@ -0,0 +1,175 @@
import { A } from "@solidjs/router";
import { Component, createMemo, createSignal, For, Show } from "solid-js";
import { client } from "../../..";
import { useAppDimensions } from "../../../state/DimensionProvider";
import { useAppState } from "../../../state/StateProvider";
import { useUser } from "../../../state/UserProvider";
import { PermissionLevel } from "../../../types";
import { getId, readableMonitorTimestamp } from "../../../util/helpers";
import {
ActionStateProvider,
useActionStates,
} from "../../build/ActionStateProvider";
import { NewBuild } from "../../New";
import ConfirmButton from "../../shared/ConfirmButton";
import Icon from "../../shared/Icon";
import Input from "../../shared/Input";
import Flex from "../../shared/layout/Flex";
import Grid from "../../shared/layout/Grid";
import Loading from "../../shared/loading/Loading";
import Selector from "../../shared/menu/Selector";
import { TreeSortType, TREE_SORTS, useTreeState } from "./Provider";
const Builds: Component<{}> = (p) => {
const { isSemiMobile } = useAppDimensions();
const { user } = useUser();
const { builds } = useAppState();
const { sort, setSort, build_sorter } = useTreeState();
const [buildFilter, setBuildFilter] = createSignal("");
const buildIDs = createMemo(() => {
if (builds.loaded()) {
const filters = buildFilter()
.split(" ")
.filter((term) => term.length > 0)
.map((term) => term.toLowerCase());
return builds
.ids()!
.filter((id) => {
const name = builds.get(id)!.name;
for (const term of filters) {
if (!name.includes(term)) {
return false;
}
}
return true;
})
.sort(build_sorter());
} else {
return undefined;
}
});
return (
<Grid>
<Grid gridTemplateColumns="1fr auto auto">
<Input
placeholder="filter builds"
value={buildFilter()}
onEdit={setBuildFilter}
style={{ width: "100%", padding: "0.5rem" }}
/>
<Selector
label={<div class="dimmed">sort by:</div>}
selected={sort()}
items={TREE_SORTS as any as string[]}
onSelect={(mode) => setSort(mode as TreeSortType)}
position="bottom right"
targetClass="blue"
targetStyle={{ height: "100%" }}
containerStyle={{ height: "100%" }}
/>
<Show when={user().admin || user().create_build_permissions}>
<NewBuild />
</Show>
</Grid>
<Grid gridTemplateColumns={isSemiMobile() ? "1fr" : "1fr 1fr"}>
<For each={buildIDs()}>
{(id) => (
<ActionStateProvider build_id={id}>
<Build id={id} />
</ActionStateProvider>
)}
</For>
</Grid>
</Grid>
);
};
const Build: Component<{ id: string }> = (p) => {
const { isMobile } = useAppDimensions();
const { user } = useUser();
const { builds } = useAppState();
const build = () => builds.get(p.id)!;
// const server = () =>
// build().server_id ? servers.get(build().server_id!) : undefined;
const version = () => {
return `v${build().version.major}.${build().version.minor}.${
build().version.patch
}`;
};
const lastBuiltAt = () => {
if (
build().last_built_at === undefined ||
build().last_built_at?.length === 0 ||
build().last_built_at === "never"
) {
return "not built";
} else {
return readableMonitorTimestamp(build().last_built_at!);
}
};
const actions = useActionStates();
const userCanExecute = () =>
user().admin ||
build().permissions![getId(user())] === PermissionLevel.Execute ||
build().permissions![getId(user())] === PermissionLevel.Update;
// const isAwsBuild = () => build().aws_config ? true : false;
return (
<A
href={`/build/${p.id}`}
class="card light shadow hoverable"
style={{
width: "100%",
height: "fit-content",
"box-sizing": "border-box",
"justify-content": "space-between",
padding: "0.5rem",
}}
>
<h1 style={{ "font-size": "1.25rem" }}>{build().name}</h1>
<Flex alignItems="center">
{/* <Show when={server()}>
<A
href={`/server/${build().server_id!}`}
style={{ padding: 0, opacity: 0.7 }}
>
<div class="text-hover">{server()?.server.name}</div>
</A>
</Show>
<Show when={isAwsBuild()}>
<div style={{ opacity: 0.7 }}>aws build</div>
</Show> */}
<h2>{version()}</h2>
<Show when={!isMobile()}>
<div style={{ opacity: 0.7 }}>{lastBuiltAt()}</div>
</Show>
<Show when={userCanExecute()}>
<Show
when={!actions.building}
fallback={
<button
class="green"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
}}
>
<Loading type="spinner" />
</button>
}
>
<ConfirmButton
class="green"
onConfirm={() => {
client.build(p.id);
}}
>
<Icon type="build" width="0.9rem" />
</ConfirmButton>
</Show>
</Show>
</Flex>
</A>
);
};
export default Builds;

View File

@@ -51,6 +51,7 @@ const Groups: Component<{}> = (p) => {
style={{ width: "100%", padding: "0.5rem" }}
/>
<Selector
label={<div class="dimmed">sort by:</div>}
selected={sort()}
items={TREE_SORTS as any as string[]}
onSelect={(mode) => setSort(mode as TreeSortType)}

View File

@@ -2,14 +2,14 @@ import { ParentComponent, createContext, useContext } from "solid-js";
import { useAppState } from "../../../state/StateProvider";
import { useLocalStorage } from "../../../util/hooks";
export const TREE_SORTS = ["name", "created"] as const;
export const TREE_SORTS = ["name", "created at"] as const;
export type TreeSortType = typeof TREE_SORTS[number];
const value = () => {
const { servers, groups } = useAppState();
const { servers, groups, builds } = useAppState();
const [sort, setSort] = useLocalStorage<TreeSortType>(
TREE_SORTS[0],
"home-sort-v1"
"home-sort-v2"
);
const server_sorter = () => {
if (!servers.loaded()) return () => 0;
@@ -29,7 +29,7 @@ const value = () => {
}
};
const group_sorter = () => {
if (!groups.loaded) return () => 0;
if (!groups.loaded()) return () => 0;
if (sort() === "name") {
return (a: string, b: string) => {
const ga = groups.get(a)!;
@@ -44,12 +44,30 @@ const value = () => {
} else {
return () => 0;
}
};
const build_sorter = () => {
if (!builds.loaded()) return () => 0;
if (sort() === "name") {
return (a: string, b: string) => {
const ba = builds.get(a)!;
const bb = builds.get(b)!;
if (ba.name < bb.name) {
return -1;
} else if (ba.name > bb.name) {
return 1;
}
return 0;
};
} else {
return () => 0;
}
};
return {
sort,
setSort,
server_sorter,
group_sorter,
build_sorter
};
}

View File

@@ -42,7 +42,8 @@ const Servers: Component<{ serverIDs: string[]; showAdd?: boolean }> = (p) => {
onEdit={setServerFilter}
style={{ width: "100%", padding: "0.5rem" }}
/>
<Selector
<Selector
label={<div class="dimmed">sort by:</div>}
selected={sort()}
items={TREE_SORTS as any as string[]}
onSelect={(mode) => setSort(mode as TreeSortType)}

View File

@@ -14,18 +14,9 @@ import UpdateMenu from "../../update/UpdateMenu";
import s from "./update.module.scss";
const Update: Component<{ update: UpdateType }> = (p) => {
const { deployments, servers, builds, usernames } = useAppState();
const name = () => {
if (p.update.target.type === "Deployment" && deployments.loaded()) {
return deployments.get(p.update.target.id!)?.deployment.name || "deleted";
} else if (p.update.target.type === "Server" && servers.loaded()) {
return servers.get(p.update.target.id)?.server.name || "deleted";
} else if (p.update.target.type === "Build" && builds.loaded()) {
return builds.get(p.update.target.id)?.name || "deleted";
} else {
return "monitor";
}
};
const { usernames, name_from_update_target } =
useAppState();
const name = () => name_from_update_target(p.update.target);
const operation = () => {
if (p.update.operation === Operation.BuildBuild) {
return `build ${readableVersion(p.update.version!)}`;

View File

@@ -1,4 +1,6 @@
import { A } from "@solidjs/router";
import { Component, createEffect, createSignal, For, Show } from "solid-js";
import { OPERATIONS } from "../../..";
import { useAppState } from "../../../state/StateProvider";
import { Operation } from "../../../types";
import Flex from "../../shared/layout/Flex";
@@ -7,10 +9,6 @@ import Loading from "../../shared/loading/Loading";
import Selector from "../../shared/menu/Selector";
import Update from "./Update";
const OPERATIONS = Object.values(Operation)
.filter((e) => e !== "none" && !e.includes("user"))
.map((e) => e.replaceAll("_", " "));
const Updates: Component<{}> = () => {
const { updates } = useAppState();
const [operation, setOperation] = createSignal<Operation>();
@@ -24,8 +22,11 @@ const Updates: Component<{}> = () => {
return (
<Grid class="card shadow" style={{ "flex-grow": 1 }}>
<Flex alignItems="center" justifyContent="space-between">
<h1>updates</h1>
<A href="/updates" style={{ padding: 0 }}>
<h1>updates</h1>
</A>
<Selector
label="operation: "
selected={operation() ? operation()! : "all"}
items={["all", ...OPERATIONS]}
onSelect={(o) =>
@@ -50,7 +51,7 @@ const Updates: Component<{}> = () => {
}
>
<Grid class="updates-container-small scroller">
<For each={updates.collection()!}>
<For each={updates.collection()}>
{(update) => <Update update={update} />}
</For>
<Show when={!updates.noMore()}>

View File

@@ -34,9 +34,9 @@
width: 100%;
}
.ServerButton:hover {
background-color: rgba(c.$lightblue, 0.5);
}
// .ServerButton:hover {
// background-color: rgba(c.$lightblue, 0.5);
// }
.Deployments {
background-color: c.$lightgrey;

View File

@@ -1,8 +1,7 @@
import { Component, createResource, Show } from "solid-js";
import { Component, createResource, createSignal, Show } from "solid-js";
import { useAppState } from "../../state/StateProvider";
import { useUser } from "../../state/UserProvider";
import { combineClasses, getId, serverStatusClass } from "../../util/helpers";
import ConfirmButton from "../shared/ConfirmButton";
import Icon from "../shared/Icon";
import Flex from "../shared/layout/Flex";
import Grid from "../shared/layout/Grid";
@@ -15,6 +14,7 @@ import { client } from "../..";
import Loading from "../shared/loading/Loading";
import HoverMenu from "../shared/menu/HoverMenu";
import ConfirmMenuButton from "../shared/ConfirmMenuButton";
import Input, { AutofocusInput } from "../shared/Input";
const Header: Component<{}> = (p) => {
const { servers } = useAppState();
@@ -25,6 +25,8 @@ const Header: Component<{}> = (p) => {
const { isMobile, isSemiMobile } = useAppDimensions();
const [showUpdates, toggleShowUpdates] =
useLocalStorageToggle("show-updates");
const [editingName, setEditingName] = createSignal(false);
const [updatingName, setUpdatingName] = createSignal(false);
const userCanUpdate = () =>
user().admin ||
server().server.permissions![getId(user())] === PermissionLevel.Update;
@@ -50,7 +52,38 @@ const Header: Component<{}> = (p) => {
}}
>
<Flex alignItems="center" justifyContent="space-between">
<h1>{server().server.name}</h1>
<Show
when={editingName()}
fallback={
<button
onClick={() => setEditingName(true)}
style={{ padding: 0 }}
>
<h1>{server().server.name}</h1>
</button>
}
>
<Show
when={!updatingName()}
fallback={<Loading type="three-dot" />}
>
<AutofocusInput
value={server().server.name}
placeholder={server().server.name}
onEnter={async (new_name) => {
setUpdatingName(true);
await client.update_server({
...server().server,
name: new_name,
});
setEditingName(false);
setUpdatingName(false);
}}
onBlur={() => setEditingName(false)}
/>
</Show>
</Show>
<Show when={userCanUpdate()}>
<Flex alignItems="center">
<div class={serverStatusClass(server().status)}>{status()}</div>
@@ -75,7 +108,7 @@ const Header: Component<{}> = (p) => {
client.delete_server(params.id);
}}
class="red"
title={`delete server | ${server().server.name}`}
title="delete server"
match={server().server.name}
info={
<div style={{ opacity: 0.7 }}>

View File

@@ -2,6 +2,9 @@ import { useParams } from "@solidjs/router";
import { Component, Show } from "solid-js";
import { useAppDimensions } from "../../state/DimensionProvider";
import { useAppState } from "../../state/StateProvider";
import { useUser } from "../../state/UserProvider";
import { PermissionLevel } from "../../types";
import Description from "../Description";
import NotFound from "../NotFound";
import ServerChildren from "../server_children/ServerChildren";
import Grid from "../shared/layout/Grid";
@@ -12,15 +15,16 @@ import ServerTabs from "./tabs/Tabs";
import Updates from "./Updates";
const Server: Component<{}> = (p) => {
const { user, user_id } = useUser();
const { servers } = useAppState();
const params = useParams();
const server = () => servers.get(params.id)!;
const { isSemiMobile } = useAppDimensions();
// const userCanUpdate = () =>
// user().admin ||
// server()!.server.permissions![getId(user())] === PermissionLevel.Update;
const userCanUpdate = () =>
user().admin ||
server()?.server.permissions![user_id()] === PermissionLevel.Update;
return (
<Show when={server()} fallback={<NotFound type="server" />}>
<Show when={server()} fallback={<NotFound type="server" loaded={servers.loaded()} />}>
<ActionStateProvider>
<Grid
style={{
@@ -32,8 +36,14 @@ const Server: Component<{}> = (p) => {
style={{ width: "100%" }}
gridTemplateColumns={isSemiMobile() ? "1fr" : "1fr 1fr"}
>
<Grid style={{ "flex-grow": 1, "grid-auto-rows": "auto 1fr" }}>
<Grid style={{ "flex-grow": 1, "grid-auto-rows": "auto auto 1fr" }}>
<Header />
<Description
target={{ type: "Server", id: params.id }}
name={server().server.name}
description={server().server.description}
userCanUpdate={userCanUpdate()}
/>
<Actions />
</Grid>
<Show when={!isSemiMobile()}>

View File

@@ -1,6 +1,7 @@
import { useParams } from "@solidjs/router";
import { Component, createResource, For, Show } from "solid-js";
import { client } from "../../..";
import { useAppDimensions } from "../../../state/DimensionProvider";
import { useAppState } from "../../../state/StateProvider";
import { readableStorageAmount } from "../../../util/helpers";
import Flex from "../../shared/layout/Flex";
@@ -8,6 +9,7 @@ import Grid from "../../shared/layout/Grid";
import Loading from "../../shared/loading/Loading";
const Info: Component<{}> = (p) => {
const { isMobile } = useAppDimensions();
const { serverInfo } = useAppState();
const params = useParams();
const [stats] = createResource(() => client.get_server_stats(params.id, { disks: true }));
@@ -74,8 +76,17 @@ const Info: Component<{}> = (p) => {
justifyContent="space-between"
>
<Flex alignItems="center">
<div>mount point:</div>
<h2>{disk.mount}</h2>
<div style={{ "white-space": "nowrap" }}>
mount point:
</div>
<h2
class="ellipsis"
style={{
"max-width": isMobile() ? "50px" : "200px",
}}
>
{disk.mount}
</h2>
</Flex>
<Flex alignItems="center">
<div>{readableStorageAmount(disk.used_gb)} used</div>

View File

@@ -1,23 +1,67 @@
import { A } from "@solidjs/router";
import { Component, Show } from "solid-js";
import { Component, createResource, Show } from "solid-js";
import { client } from "../..";
import { useAppState } from "../../state/StateProvider";
import { combineClasses, deploymentStateClass, getId } from "../../util/helpers";
import { DockerContainerState } from "../../types";
import {
deploymentStateClass,
readableVersion,
} from "../../util/helpers";
import Circle from "../shared/Circle";
import Flex from "../shared/layout/Flex";
import s from "./serverchildren.module.scss";
import Grid from "../shared/layout/Grid";
const Deployment: Component<{ id: string }> = (p) => {
const { deployments } = useAppState();
const { deployments, builds } = useAppState();
const deployment = () => deployments.get(p.id)!;
const [deployed_version] = createResource(() =>
client.get_deployment_deployed_version(p.id)
);
const derived_image = () => {
if (deployment().deployment.build_id) {
const build = builds.get(deployment().deployment.build_id!);
if (build === undefined) return "unknown";
const version =
deployment().state === DockerContainerState.NotDeployed
? deployment().deployment.build_version
? readableVersion(
deployment().deployment.build_version!
).replaceAll("v", "")
: "latest"
: deployed_version() || "unknown";
return `${build.name}:${version}`;
} else {
return deployment().deployment.docker_run_args.image || "unknown";
}
};
const image = () => {
if (deployment().state === DockerContainerState.NotDeployed) {
return derived_image();
} else if (deployment().container?.image) {
if (deployment().container!.image.includes("sha256:")) {
return derived_image();
}
let [account, image] = deployment().container!.image.split("/");
return image ? image : account;
} else {
return "unknown";
}
};
return (
<Show when={deployment()}>
<A
href={`/deployment/${p.id}`}
class={combineClasses(
s.DropdownItem,
)}
class="card hoverable"
style={{
width: "100%",
"justify-content": "space-between",
padding: "0.5rem",
}}
>
<h2>{deployment().deployment.name}</h2>
<Grid gap="0">
<h2>{deployment().deployment.name}</h2>
<div style={{ opacity: 0.7 }}>{image()}</div>
</Grid>
<Flex alignItems="center">
<div style={{ opacity: 0.7 }}>{deployments.status(p.id)}</div>
<Circle

View File

@@ -6,15 +6,14 @@ import SimpleTabs from "../shared/tabs/SimpleTabs";
import s from "./serverchildren.module.scss";
import { useUser } from "../../state/UserProvider";
import { PermissionLevel } from "../../types";
import { NewBuild, NewDeployment } from "../New";
import { NewDeployment } from "../New";
import Deployment from "./Deployment";
import Build from "./Build";
import { useAppState } from "../../state/StateProvider";
const ServerChildren: Component<{ id: string }> = (p) => {
const { user } = useUser();
const { isSemiMobile } = useAppDimensions();
const { servers, deployments, builds } = useAppState();
const { servers, deployments } = useAppState();
const server = () => servers.get(p.id);
const deploymentIDs = createMemo(() => {
return (deployments.loaded() &&
@@ -24,61 +23,78 @@ const ServerChildren: Component<{ id: string }> = (p) => {
(id) => deployments.get(id)?.deployment.server_id === p.id
)) as string[];
});
const buildIDs = createMemo(() => {
return (builds.loaded() &&
builds
.ids()!
.filter((id) => builds.get(id)?.server_id === p.id)) as string[];
});
// const buildIDs = createMemo(() => {
// return (builds.loaded() &&
// builds
// .ids()!
// .filter((id) => builds.get(id)?.server_id === p.id)) as string[];
// });
return (
<SimpleTabs
containerClass="card shadow"
localStorageKey={`${p.id}-home-tab`}
tabs={[
{
title: "deployments",
element: () => (
<Grid
gap=".5rem"
class={combineClasses(s.Deployments)}
gridTemplateColumns={isSemiMobile() ? "1fr" : "1fr 1fr"}
>
<For each={deploymentIDs()}>{(id) => <Deployment id={id} />}</For>
<Show
when={
user().admin ||
server()?.server.permissions![getId(user())] ===
PermissionLevel.Update
}
>
<NewDeployment serverID={p.id} />
</Show>
</Grid>
),
},
{
title: "builds",
element: () => (
<Grid
gap=".5rem"
class={combineClasses(s.Deployments)}
gridTemplateColumns={isSemiMobile() ? "1fr" : "1fr 1fr"}
>
<For each={buildIDs()}>{(id) => <Build id={id} />}</For>
<Show
when={
user().admin ||
server()?.server.permissions![getId(user())] ===
PermissionLevel.Update
}
>
<NewBuild serverID={p.id} />
</Show>
</Grid>
),
},
]}
/>
<div class="card shadow">
<Grid
gap=".5rem"
gridTemplateColumns={isSemiMobile() ? "1fr" : "1fr 1fr"}
>
<For each={deploymentIDs()}>{(id) => <Deployment id={id} />}</For>
<Show
when={
user().admin ||
server()?.server.permissions![getId(user())] ===
PermissionLevel.Update
}
>
<NewDeployment serverID={p.id} />
</Show>
</Grid>
</div>
// <SimpleTabs
// containerClass="card shadow"
// localStorageKey={`${p.id}-home-tab`}
// tabs={[
// {
// title: "deployments",
// element: () => (
// <Grid
// gap=".5rem"
// class={combineClasses(s.Deployments)}
// gridTemplateColumns={isSemiMobile() ? "1fr" : "1fr 1fr"}
// >
// <For each={deploymentIDs()}>{(id) => <Deployment id={id} />}</For>
// <Show
// when={
// user().admin ||
// server()?.server.permissions![getId(user())] ===
// PermissionLevel.Update
// }
// >
// <NewDeployment serverID={p.id} />
// </Show>
// </Grid>
// ),
// },
// // {
// // title: "builds",
// // element: () => (
// // <Grid
// // gap=".5rem"
// // class={combineClasses(s.Deployments)}
// // gridTemplateColumns={isSemiMobile() ? "1fr" : "1fr 1fr"}
// // >
// // <For each={buildIDs()}>{(id) => <Build id={id} />}</For>
// // <Show
// // when={
// // user().admin ||
// // server()?.server.permissions![getId(user())] ===
// // PermissionLevel.Update
// // }
// // >
// // <NewBuild serverID={p.id} />
// // </Show>
// // </Grid>
// // ),
// // },
// ]}
// />
);
};

View File

@@ -1,16 +1,16 @@
@use "../../style/colors.scss" as c;
.Deployments {
background-color: c.$lightgrey;
transform-origin: top;
padding: 0.5rem;
}
// .Deployments {
// // background-color: c.$lightgrey;
// // transform-origin: top;
// // padding: 0.5rem;
// }
.DropdownItem {
padding: 0.5rem 1rem;
// padding: 0.5rem 1rem;
width: 100%;
justify-content: space-between;
transition: background-color 500ms ease;
// transition: background-color 500ms ease;
}
.DropdownItem:hover {

View File

@@ -0,0 +1,32 @@
import { Component, JSX } from "solid-js";
const CheckBox: Component<{
label: JSX.Element;
checked: boolean;
toggle: () => void;
}> = (p) => {
return (
<button
class="blue"
style={{ gap: "0.5rem" }}
onClick={(e) => {
e.stopPropagation();
p.toggle();
}}
>
{p.label}
<input
type="checkbox"
checked={p.checked}
style={{
width: "fit-content",
margin: 0,
appearance: "auto",
"-webkit-appearance": "checkbox",
}}
/>
</button>
);
};
export default CheckBox;

View File

@@ -17,6 +17,7 @@ const ConfirmButton: Component<{
onBlur={() => set(false)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (confirm()) {
p.onConfirm && p.onConfirm();
} else {

View File

@@ -2,7 +2,9 @@ import { Component, createSignal, JSX } from "solid-js";
import { pushNotification } from "../..";
import { useToggle } from "../../util/hooks";
import ConfirmButton from "./ConfirmButton";
import CopyClipboard from "./CopyClipboard";
import Input from "./Input";
import Flex from "./layout/Flex";
import Grid from "./layout/Grid";
import CenterMenu from "./menu/CenterMenu";
@@ -22,6 +24,12 @@ const ConfirmMenuButton: Component<{
show={show}
toggleShow={toggleShow}
title={p.title}
leftOfX={() => (
<Flex alignItems="center" justifyContent="space-between" style={{ width: "100%" }}>
<h1>{p.match}</h1>
<CopyClipboard copyText={p.match} copying="name" />
</Flex>
)}
targetClass={p.class}
target={p.children}
content={() => (

View File

@@ -0,0 +1,22 @@
import { Component } from "solid-js";
import { pushNotification } from "../..";
import { copyToClipboard } from "../../util/helpers";
import ConfirmButton from "./ConfirmButton";
import Icon from "./Icon";
const CopyClipboard: Component<{ copyText: string; copying?: string; }> = (p) => {
return (
<ConfirmButton
class="blue"
onFirstClick={() => {
copyToClipboard(p.copyText);
pushNotification("good", `copied ${p.copying || "text"} to clipboard`);
}}
confirm={<Icon type="check" />}
>
<Icon type="clipboard" />
</ConfirmButton>
);
}
export default CopyClipboard;

View File

@@ -1,10 +1,11 @@
import { Component, JSX, Show } from "solid-js";
import { Component, JSX, onMount, Show } from "solid-js";
const Input: Component<
{
onEdit?: (value: string) => void;
onConfirm?: (value: string) => void;
onEnter?: (value: string) => void;
onEsc?: (value: string) => void;
disabled?: boolean;
} & JSX.InputHTMLAttributes<HTMLInputElement> &
JSX.HTMLAttributes<HTMLDivElement>
@@ -14,17 +15,37 @@ const Input: Component<
<input
{...p}
onInput={(e) => p.onEdit && p.onEdit(e.currentTarget.value)}
onBlur={(e) => p.onConfirm && p.onConfirm(e.currentTarget.value)}
onKeyDown={p.onKeyDown || ((e) => {
if (e.key === "Enter") {
p.onEnter
? p.onEnter(e.currentTarget.value)
: e.currentTarget.blur();
}
})}
onBlur={
p.onBlur || ((e) => p.onConfirm && p.onConfirm(e.currentTarget.value))
}
onKeyDown={
p.onKeyDown ||
((e) => {
if (e.key === "Enter") {
p.onEnter && p.onEnter(e.currentTarget.value);
} else if (e.key === "Escape") {
p.onEsc ? p.onEsc(e.currentTarget.value) : e.currentTarget.blur();
}
})
}
/>
</Show>
);
};
export default Input;
export const AutofocusInput: Component<
{
onEdit?: (value: string) => void;
onConfirm?: (value: string) => void;
onEnter?: (value: string) => void;
onEsc?: (value: string) => void;
disabled?: boolean;
} & JSX.InputHTMLAttributes<HTMLInputElement> &
JSX.HTMLAttributes<HTMLDivElement>
> = (p) => {
let ref: HTMLInputElement;
onMount(() => setTimeout(() => ref?.focus(), 100));
return <Input ref={ref! as any} {...p} />;
};

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