Compare commits

..
Author SHA1 Message Date
Voice Typer User 6919582f69 fix: prevent custom registry org input from deselecting
The ImageRegistryConfig component's key included registry.organization,
which caused React to remount the component on every keystroke when
typing a custom organization name. This destroyed the OrganizationSelector's
internal customMode state, causing the input to deselect after each character.

Using a stable index-based key preserves component identity during edits.

Closes #1094
2026-02-06 16:04:35 -08:00
Maxwell BeckerandGitHub 34a9f8eb9e 1.19.5 (#846)
* start 1.19.5

* deploy 1.19.5-dev-1

* avoid execute_and_poll error when update is already complete or has no id

* improve image tagging customization

* 1.19.5 release
2025-09-27 13:29:16 -07:00
mbecker20 494d01aeed RequireAuth redirect when no jwt 2025-09-14 14:05:43 -07:00
mbecker20 084e2fec23 1.19.4 2025-09-14 12:32:58 -07:00
98d72fc908 1.19.4 (#812)
* start 1.19.4

* deploy 1.19.4-dev-1

* try smaller binaries with cargo strip

* deploy 1.19.4-dev-2

* smaller binaries with cargo strip

* Fix Submit Dialog Button Behavior with 500 Errors on Duplicate Names (#819)

* Implement enhanced error handling and messaging for resource creation

* Implement improved error handling for resource creation across alerter, build, and sync

* Implement error handling improvements for resource copying and validation feedback

* Adjust error handling for resource creation to distinguish validation errors from unexpected system errors

* Refactor resource creation error handling by removing redundant match statements and simplifying the error propagation in multiple API modules.

* fmt

* bump indexmap

* fix account selector showing empty when account no longer found

* clean up theme logic, ensure monaco and others get up to date current theme

* enforce disable_non_admin_create for tags. Clean up status code responses

* update server cache concurrency controller

* deploy 1.19.4-dev-3

* Allow signing in by pressing enter (#830)

* Improve dialog overflow handling to prevent clipping of content (#828)

* Add Email notification entry to community.md (#824)

* Add clickable file path to show/hide file contents in StackInfo (#827)

* add clickable file path to show/hide file contents in StackInfo

Also added CopyButton due to the new functionality making the file path not selectable.

* Move clicking interaction to CardHeader

* Avoid sync edge cases of having toggle show function capturing showContents from outside

Co-authored-by: Maxwell Becker <49575486+mbecker20@users.noreply.github.com>

* Format previous change

* Add `default_show_contents` to `handleToggleShow`

---------

Co-authored-by: Maxwell Becker <49575486+mbecker20@users.noreply.github.com>

* deploy 1.19.4-dev-4

* avoid stake info ShowHideButton double toggle

* Allow multiple simultaneous Action runs for use with Args

* deploy 1.19.4-dev-5

* feat: persist all table sorting states including unsorted (#832)

- Always save sorting state to localStorage, even when empty/unsorted
- Fixes issue where 'unsorted' state was not persisted across page reloads
- Ensures consistent and predictable sorting behavior for all DataTable components

* autofocus on login username field (#837)

* Fix unnecessary auth queries flooding console on login page (#842)

* Refactor authentication error handling to use serror::Result and status codes

* Enable user query only when JWT is present

* Enable query execution in useRead only if JWT is present

* Revert backend auth changes - keep PR focused on frontend only

* Fix unnecessary API queries to unreachable servers flooding console (#843)

* Implement server availability checks in various components

* Refactor server availability check to ensure only healthy servers are identified

* cargo fmt

* fmt

* Auth error handling with status codes (#841)

* Refactor authentication error handling to use serror::Result and status codes

* Refactor error messages

* Refactor authentication error handling to include status codes and improve error messages

* clean up

* clean

* fmt

* invalid user id also UNAUTHORIZED

* deploy 1.19.4-dev-6

* deploy 1.19.4-dev-7

---------

Co-authored-by: Marcel Pfennig <82059270+MP-Tool@users.noreply.github.com>
Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com>
Co-authored-by: Guten <ywzhaifei@gmail.com>
Co-authored-by: Paulo Roberto Albuquerque <paulora2405@gmail.com>
Co-authored-by: Lorenzo Farnararo <2814802+baldarn@users.noreply.github.com>
2025-09-14 12:32:06 -07:00
azrikaharandGitHub 20ac04fae5 fix navigation link to users page via omnibar (#838) 2025-09-11 10:04:45 -07:00
76 changed files with 1245 additions and 923 deletions
Generated
+396 -342
View File
File diff suppressed because it is too large Load Diff
+16 -13
View File
@@ -8,13 +8,16 @@ members = [
]
[workspace.package]
version = "1.19.3"
version = "1.19.5"
edition = "2024"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
repository = "https://github.com/moghtech/komodo"
homepage = "https://komo.do"
[profile.release]
strip = "debuginfo"
[workspace.dependencies]
# LOCAL
komodo_client = { path = "client/core/rs" }
@@ -33,7 +36,7 @@ git = { path = "lib/git" }
# MOGH
run_command = { version = "0.0.6", features = ["async_tokio"] }
serror = { version = "0.5.0", default-features = false }
serror = { version = "0.5.1", default-features = false }
slack = { version = "0.4.0", package = "slack_client_rs", default-features = false, features = ["rustls"] }
derive_default_builder = "0.1.8"
derive_empty_traits = "0.1.0"
@@ -65,12 +68,12 @@ axum = { version = "0.8.4", features = ["ws", "json", "macros"] }
# SER/DE
ipnetwork = { version = "0.21.1", features = ["serde"] }
indexmap = { version = "2.11.0", features = ["serde"] }
indexmap = { version = "2.11.1", features = ["serde"] }
serde = { version = "1.0.219", features = ["derive"] }
strum = { version = "0.27.2", features = ["derive"] }
bson = { version = "2.15.0" } # must keep in sync with mongodb version
serde_yaml_ng = "0.10.0"
serde_json = "1.0.143"
serde_json = "1.0.145"
serde_qs = "0.15.0"
toml = "0.9.5"
@@ -81,19 +84,19 @@ thiserror = "2.0.16"
# LOGGING
opentelemetry-otlp = { version = "0.30.0", features = ["tls-roots", "reqwest-rustls"] }
opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio"] }
tracing-subscriber = { version = "0.3.19", features = ["json"] }
tracing-subscriber = { version = "0.3.20", features = ["json"] }
opentelemetry-semantic-conventions = "0.30.0"
tracing-opentelemetry = "0.31.0"
opentelemetry = "0.30.0"
tracing = "0.1.41"
# CONFIG
clap = { version = "4.5.45", features = ["derive"] }
clap = { version = "4.5.47", features = ["derive"] }
dotenvy = "0.15.7"
envy = "0.4.2"
# CRYPTO / AUTH
uuid = { version = "1.18.0", features = ["v4", "fast-rng", "serde"] }
uuid = { version = "1.18.1", features = ["v4", "fast-rng", "serde"] }
jsonwebtoken = { version = "9.3.1", default-features = false }
openidconnect = "4.0.1"
urlencoding = "2.1.3"
@@ -112,20 +115,20 @@ bollard = "0.19.2"
sysinfo = "0.37.0"
# CLOUD
aws-config = "1.8.5"
aws-sdk-ec2 = "1.161.0"
aws-credential-types = "1.2.5"
aws-config = "1.8.6"
aws-sdk-ec2 = "1.167.0"
aws-credential-types = "1.2.6"
## CRON
english-to-cron = "0.1.6"
chrono-tz = "0.10.4"
chrono = "0.4.41"
chrono = "0.4.42"
croner = "3.0.0"
# MISC
async-compression = { version = "0.4.28", features = ["tokio", "gzip"] }
async-compression = { version = "0.4.30", features = ["tokio", "gzip"] }
derive_builder = "0.20.2"
comfy-table = "7.1.4"
comfy-table = "7.2.1"
typeshare = "1.0.4"
octorust = "0.10.0"
dashmap = "6.1.0"
+3 -1
View File
@@ -2,6 +2,7 @@
## for a specific architecture.
FROM rust:1.89.0-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -16,7 +17,8 @@ COPY ./bin/cli ./bin/cli
RUN \
cargo build -p komodo_core --release && \
cargo build -p komodo_periphery --release && \
cargo build -p komodo_cli --release
cargo build -p komodo_cli --release && \
cargo strip
# Copy just the binaries to scratch image
FROM scratch
+3 -1
View File
@@ -12,6 +12,7 @@ COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
RUN cargo install cargo-strip
COPY --from=planner /builder/recipe.json recipe.json
# Build JUST dependencies - cached layer
RUN cargo chef cook --release --recipe-path recipe.json
@@ -20,7 +21,8 @@ COPY . .
RUN \
cargo build --release --bin core && \
cargo build --release --bin periphery && \
cargo build --release --bin km
cargo build --release --bin km && \
cargo strip
# Copy just the binaries to scratch image
FROM scratch
+2 -1
View File
@@ -1,4 +1,5 @@
FROM rust:1.89.0-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -8,7 +9,7 @@ COPY ./client/periphery ./client/periphery
COPY ./bin/cli ./bin/cli
# Compile bin
RUN cargo build -p komodo_cli --release
RUN cargo build -p komodo_cli --release && cargo strip
# Copy binaries to distroless base
FROM gcr.io/distroless/cc
+3 -1
View File
@@ -2,6 +2,7 @@
# Build Core
FROM rust:1.89.0-bullseye AS core-builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -13,7 +14,8 @@ COPY ./bin/cli ./bin/cli
# Compile app
RUN cargo build -p komodo_core --release && \
cargo build -p komodo_cli --release
cargo build -p komodo_cli --release && \
cargo strip
# Build Frontend
FROM node:20.12-alpine AS frontend-builder
+8 -3
View File
@@ -3,11 +3,12 @@ use std::{sync::OnceLock, time::Instant};
use axum::{Router, extract::Path, http::HeaderMap, routing::post};
use derive_variants::{EnumVariants, ExtractVariant};
use komodo_client::{api::auth::*, entities::user::User};
use reqwest::StatusCode;
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::Json;
use serror::{AddStatusCode, Json};
use typeshare::typeshare;
use uuid::Uuid;
@@ -152,7 +153,11 @@ impl Resolve<AuthArgs> for GetUser {
self,
AuthArgs { headers }: &AuthArgs,
) -> serror::Result<User> {
let user_id = get_user_id_from_headers(headers).await?;
Ok(get_user(&user_id).await?)
let user_id = get_user_id_from_headers(headers)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
get_user(&user_id)
.await
.status_code(StatusCode::UNAUTHORIZED)
}
}
+5 -2
View File
@@ -92,8 +92,11 @@ impl Resolve<ExecuteArgs> for RunAction {
// This will set action state back to default when dropped.
// Will also check to ensure action not already busy before updating.
let _action_guard =
action_state.update(|state| state.running = true)?;
let _action_guard = action_state.update_custom(
|state| state.running += 1,
|state| state.running -= 1,
false,
)?;
let mut update = update.clone();
+5 -7
View File
@@ -27,7 +27,7 @@ use komodo_client::{
build::{Build, BuildConfig},
builder::{Builder, BuilderConfig},
deployment::DeploymentState,
komodo_timestamp,
komodo_timestamp, optional_string,
permission::PermissionLevel,
repo::Repo,
update::{Log, Update},
@@ -290,12 +290,10 @@ impl Resolve<ExecuteArgs> for RunBuild {
repo,
registry_tokens,
replacers: secret_replacers.into_iter().collect(),
// Push a commit hash tagged image
additional_tags: if update.commit_hash.is_empty() {
Default::default()
} else {
vec![update.commit_hash.clone()]
},
// To push a commit hash tagged image
commit_hash: optional_string(&update.commit_hash),
// Unused for now
additional_tags: Default::default(),
}) => res.context("failed at call to periphery to build"),
_ = cancel.cancelled() => {
info!("build cancelled during build, cleaning up builder");
+11 -11
View File
@@ -12,7 +12,7 @@ use komodo_client::{
deployment::{
Deployment, DeploymentImage, extract_registry_domain,
},
get_image_names, komodo_timestamp, optional_string,
komodo_timestamp, optional_string,
permission::PermissionLevel,
server::Server,
update::{Log, Update},
@@ -115,7 +115,7 @@ impl Resolve<ExecuteArgs> for Deploy {
let (version, registry_token) = match &deployment.config.image {
DeploymentImage::Build { build_id, version } => {
let build = resource::get::<Build>(build_id).await?;
let image_names = get_image_names(&build);
let image_names = build.get_image_names();
let image_name = image_names
.first()
.context("No image name could be created")
@@ -222,7 +222,7 @@ impl Resolve<ExecuteArgs> for Deploy {
}
};
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -249,7 +249,7 @@ pub async fn pull_deployment_inner(
let (image, account, token) = match deployment.config.image {
DeploymentImage::Build { build_id, version } => {
let build = resource::get::<Build>(&build_id).await?;
let image_names = get_image_names(&build);
let image_names = build.get_image_names();
let image_name = image_names
.first()
.context("No image name could be created")
@@ -343,7 +343,7 @@ pub async fn pull_deployment_inner(
Err(e) => Log::error("Pull image", format_serror(&e.into())),
};
update_cache_for_server(server).await;
update_cache_for_server(server, true).await;
anyhow::Ok(log)
}
.await;
@@ -428,7 +428,7 @@ impl Resolve<ExecuteArgs> for StartDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -477,7 +477,7 @@ impl Resolve<ExecuteArgs> for RestartDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -524,7 +524,7 @@ impl Resolve<ExecuteArgs> for PauseDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -573,7 +573,7 @@ impl Resolve<ExecuteArgs> for UnpauseDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -628,7 +628,7 @@ impl Resolve<ExecuteArgs> for StopDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -711,7 +711,7 @@ impl Resolve<ExecuteArgs> for DestroyDeployment {
update.logs.push(log);
update.finalize();
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update_update(update.clone()).await?;
Ok(update)
+3 -3
View File
@@ -49,7 +49,7 @@ impl Resolve<ExecuteArgs> for ClearRepoCache {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
@@ -124,7 +124,7 @@ impl Resolve<ExecuteArgs> for BackupCoreDatabase {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
@@ -173,7 +173,7 @@ impl Resolve<ExecuteArgs> for GlobalAutoUpdate {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
+21 -21
View File
@@ -66,7 +66,7 @@ impl Resolve<ExecuteArgs> for StartContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -122,7 +122,7 @@ impl Resolve<ExecuteArgs> for RestartContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -176,7 +176,7 @@ impl Resolve<ExecuteArgs> for PauseContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -232,7 +232,7 @@ impl Resolve<ExecuteArgs> for UnpauseContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -288,7 +288,7 @@ impl Resolve<ExecuteArgs> for StopContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -350,7 +350,7 @@ impl Resolve<ExecuteArgs> for DestroyContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -401,7 +401,7 @@ impl Resolve<ExecuteArgs> for StartAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -453,7 +453,7 @@ impl Resolve<ExecuteArgs> for RestartAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -503,7 +503,7 @@ impl Resolve<ExecuteArgs> for PauseAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -555,7 +555,7 @@ impl Resolve<ExecuteArgs> for UnpauseAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -605,7 +605,7 @@ impl Resolve<ExecuteArgs> for StopAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -660,7 +660,7 @@ impl Resolve<ExecuteArgs> for PruneContainers {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -711,7 +711,7 @@ impl Resolve<ExecuteArgs> for DeleteNetwork {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -765,7 +765,7 @@ impl Resolve<ExecuteArgs> for PruneNetworks {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -813,7 +813,7 @@ impl Resolve<ExecuteArgs> for DeleteImage {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -865,7 +865,7 @@ impl Resolve<ExecuteArgs> for PruneImages {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -916,7 +916,7 @@ impl Resolve<ExecuteArgs> for DeleteVolume {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -968,7 +968,7 @@ impl Resolve<ExecuteArgs> for PruneVolumes {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -1020,7 +1020,7 @@ impl Resolve<ExecuteArgs> for PruneDockerBuilders {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -1072,7 +1072,7 @@ impl Resolve<ExecuteArgs> for PruneBuildx {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -1123,7 +1123,7 @@ impl Resolve<ExecuteArgs> for PruneSystem {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
+2 -2
View File
@@ -260,7 +260,7 @@ impl Resolve<ExecuteArgs> for DeployStack {
}
// Ensure cached stack state up to date by updating server cache
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -761,7 +761,7 @@ pub async fn pull_stack_inner(
.await?;
// Ensure cached stack state up to date by updating server cache
update_cache_for_server(server).await;
update_cache_for_server(server, true).await;
Ok(res)
}
+2 -4
View File
@@ -77,10 +77,8 @@ impl Resolve<ExecuteArgs> for RunSync {
};
// get the action state for the sync (or insert default).
let action_state = action_states()
.resource_sync
.get_or_insert_default(&sync.id)
.await;
let action_state =
action_states().sync.get_or_insert_default(&sync.id).await;
// This will set action state back to default when dropped.
// Will also check to ensure sync not already busy before updating.
+2 -2
View File
@@ -131,8 +131,8 @@ impl Resolve<ReadArgs> for GetActionsSummary {
.unwrap_or_default()
.get()?,
) {
(_, action_states) if action_states.running => {
res.running += 1;
(_, action_states) if action_states.running > 0 => {
res.running += action_states.running;
}
(ActionState::Ok, _) => res.ok += 1,
(ActionState::Failed, _) => res.failed += 1,
+2 -2
View File
@@ -93,7 +93,7 @@ impl Resolve<ReadArgs> for GetResourceSyncActionState {
)
.await?;
let action_state = action_states()
.resource_sync
.sync
.get(&sync.id)
.await
.unwrap_or_default()
@@ -138,7 +138,7 @@ impl Resolve<ReadArgs> for GetResourceSyncsSummary {
continue;
}
if action_states
.resource_sync
.sync
.get(&resource_sync.id)
.await
.unwrap_or_default()
+2 -8
View File
@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateAction {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Action> {
Ok(
resource::create::<Action>(&self.name, self.config, user)
.await?,
)
resource::create::<Action>(&self.name, self.config, user).await
}
}
@@ -35,10 +32,7 @@ impl Resolve<WriteArgs> for CopyAction {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Action>(&self.name, config.into(), user)
.await?,
)
resource::create::<Action>(&self.name, config.into(), user).await
}
}
+2 -8
View File
@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateAlerter {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Alerter> {
Ok(
resource::create::<Alerter>(&self.name, self.config, user)
.await?,
)
resource::create::<Alerter>(&self.name, self.config, user).await
}
}
@@ -35,10 +32,7 @@ impl Resolve<WriteArgs> for CopyAlerter {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Alerter>(&self.name, config.into(), user)
.await?,
)
resource::create::<Alerter>(&self.name, config.into(), user).await
}
}
+2 -8
View File
@@ -50,10 +50,7 @@ impl Resolve<WriteArgs> for CreateBuild {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Build> {
Ok(
resource::create::<Build>(&self.name, self.config, user)
.await?,
)
resource::create::<Build>(&self.name, self.config, user).await
}
}
@@ -71,10 +68,7 @@ impl Resolve<WriteArgs> for CopyBuild {
.await?;
// reset version to 0.0.0
config.version = Default::default();
Ok(
resource::create::<Build>(&self.name, config.into(), user)
.await?,
)
resource::create::<Build>(&self.name, config.into(), user).await
}
}
+2 -8
View File
@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateBuilder {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Builder> {
Ok(
resource::create::<Builder>(&self.name, self.config, user)
.await?,
)
resource::create::<Builder>(&self.name, self.config, user).await
}
}
@@ -35,10 +32,7 @@ impl Resolve<WriteArgs> for CopyBuilder {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Builder>(&self.name, config.into(), user)
.await?,
)
resource::create::<Builder>(&self.name, config.into(), user).await
}
}
+5 -12
View File
@@ -38,10 +38,8 @@ impl Resolve<WriteArgs> for CreateDeployment {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Deployment> {
Ok(
resource::create::<Deployment>(&self.name, self.config, user)
.await?,
)
resource::create::<Deployment>(&self.name, self.config, user)
.await
}
}
@@ -58,10 +56,8 @@ impl Resolve<WriteArgs> for CopyDeployment {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Deployment>(&self.name, config.into(), user)
.await?,
)
resource::create::<Deployment>(&self.name, config.into(), user)
.await
}
}
@@ -153,10 +149,7 @@ impl Resolve<WriteArgs> for CreateDeploymentFromContainer {
});
}
Ok(
resource::create::<Deployment>(&self.name, config, user)
.await?,
)
resource::create::<Deployment>(&self.name, config, user).await
}
}
+3 -8
View File
@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateProcedure {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<CreateProcedureResponse> {
Ok(
resource::create::<Procedure>(&self.name, self.config, user)
.await?,
)
resource::create::<Procedure>(&self.name, self.config, user).await
}
}
@@ -36,10 +33,8 @@ impl Resolve<WriteArgs> for CopyProcedure {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Procedure>(&self.name, config.into(), user)
.await?,
)
resource::create::<Procedure>(&self.name, config.into(), user)
.await
}
}
+2 -5
View File
@@ -42,7 +42,7 @@ impl Resolve<WriteArgs> for CreateRepo {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Repo> {
Ok(resource::create::<Repo>(&self.name, self.config, user).await?)
resource::create::<Repo>(&self.name, self.config, user).await
}
}
@@ -58,10 +58,7 @@ impl Resolve<WriteArgs> for CopyRepo {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Repo>(&self.name, config.into(), user)
.await?,
)
resource::create::<Repo>(&self.name, config.into(), user).await
}
}
+3 -8
View File
@@ -30,10 +30,7 @@ impl Resolve<WriteArgs> for CreateServer {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Server> {
Ok(
resource::create::<Server>(&self.name, self.config, user)
.await?,
)
resource::create::<Server>(&self.name, self.config, user).await
}
}
@@ -49,10 +46,8 @@ impl Resolve<WriteArgs> for CopyServer {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Server>(&self.name, config.into(), user)
.await?,
)
resource::create::<Server>(&self.name, config.into(), user).await
}
}
+3 -8
View File
@@ -51,10 +51,7 @@ impl Resolve<WriteArgs> for CreateStack {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Stack> {
Ok(
resource::create::<Stack>(&self.name, self.config, user)
.await?,
)
resource::create::<Stack>(&self.name, self.config, user).await
}
}
@@ -70,10 +67,8 @@ impl Resolve<WriteArgs> for CopyStack {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Stack>(&self.name, config.into(), user)
.await?,
)
resource::create::<Stack>(&self.name, config.into(), user).await
}
}
+4 -12
View File
@@ -68,10 +68,8 @@ impl Resolve<WriteArgs> for CreateResourceSync {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<ResourceSync> {
Ok(
resource::create::<ResourceSync>(&self.name, self.config, user)
.await?,
)
resource::create::<ResourceSync>(&self.name, self.config, user)
.await
}
}
@@ -88,14 +86,8 @@ impl Resolve<WriteArgs> for CopyResourceSync {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<ResourceSync>(
&self.name,
config.into(),
user,
)
.await?,
)
resource::create::<ResourceSync>(&self.name, config.into(), user)
.await
}
}
+14 -1
View File
@@ -13,9 +13,12 @@ use komodo_client::{
server::Server, stack::Stack, sync::ResourceSync, tag::Tag,
},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::{
config::core_config,
helpers::query::{get_tag, get_tag_check_owner},
resource,
state::db_client,
@@ -29,8 +32,18 @@ impl Resolve<WriteArgs> for CreateTag {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Tag> {
if core_config().disable_non_admin_create && !user.admin {
return Err(
anyhow!("Non admins cannot create tags")
.status_code(StatusCode::FORBIDDEN),
);
}
if ObjectId::from_str(&self.name).is_ok() {
return Err(anyhow!("tag name cannot be ObjectId").into());
return Err(
anyhow!("Tag name cannot be ObjectId")
.status_code(StatusCode::BAD_REQUEST),
);
}
let mut tag = Tag {
+2 -2
View File
@@ -32,7 +32,7 @@ impl Resolve<WriteArgs> for CreateLocalUser {
if !admin.admin {
return Err(
anyhow!("This method is admin-only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
@@ -183,7 +183,7 @@ impl Resolve<WriteArgs> for DeleteUser {
if !admin.admin {
return Err(
anyhow!("This method is admin-only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
if admin.username == self.user || admin.id == self.user {
+30 -7
View File
@@ -10,7 +10,9 @@ use komodo_client::{
api::write::*,
entities::{komodo_timestamp, user_group::UserGroup},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::state::db_client;
@@ -23,7 +25,10 @@ impl Resolve<WriteArgs> for CreateUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let user_group = UserGroup {
name: self.name,
@@ -58,7 +63,10 @@ impl Resolve<WriteArgs> for RenameUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
update_one_by_id(
@@ -84,7 +92,10 @@ impl Resolve<WriteArgs> for DeleteUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -117,7 +128,10 @@ impl Resolve<WriteArgs> for AddUserToUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -161,7 +175,10 @@ impl Resolve<WriteArgs> for RemoveUserFromUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -205,7 +222,10 @@ impl Resolve<WriteArgs> for SetUsersInUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -252,7 +272,10 @@ impl Resolve<WriteArgs> for SetEveryoneUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
+32 -15
View File
@@ -4,7 +4,9 @@ use komodo_client::{
api::write::*,
entities::{Operation, ResourceTarget, variable::Variable},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::{
helpers::{
@@ -22,6 +24,13 @@ impl Resolve<WriteArgs> for CreateVariable {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<CreateVariableResponse> {
if !user.admin {
return Err(
anyhow!("Only admins can create variables")
.status_code(StatusCode::FORBIDDEN),
);
}
let CreateVariable {
name,
value,
@@ -29,10 +38,6 @@ impl Resolve<WriteArgs> for CreateVariable {
is_secret,
} = self;
if !user.admin {
return Err(anyhow!("only admins can create variables").into());
}
let variable = Variable {
name,
value,
@@ -44,7 +49,7 @@ impl Resolve<WriteArgs> for CreateVariable {
.variables
.insert_one(&variable)
.await
.context("failed to create variable on db")?;
.context("Failed to create variable on db")?;
let mut update = make_update(
ResourceTarget::system(),
@@ -69,7 +74,10 @@ impl Resolve<WriteArgs> for UpdateVariableValue {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateVariableValueResponse> {
if !user.admin {
return Err(anyhow!("only admins can update variables").into());
return Err(
anyhow!("Only admins can update variables")
.status_code(StatusCode::FORBIDDEN),
);
}
let UpdateVariableValue { name, value } = self;
@@ -87,7 +95,7 @@ impl Resolve<WriteArgs> for UpdateVariableValue {
doc! { "$set": { "value": &value } },
)
.await
.context("failed to update variable value on db")?;
.context("Failed to update variable value on db")?;
let mut update = make_update(
ResourceTarget::system(),
@@ -107,7 +115,7 @@ impl Resolve<WriteArgs> for UpdateVariableValue {
)
};
update.push_simple_log("update variable value", log);
update.push_simple_log("Update Variable Value", log);
update.finalize();
add_update(update).await?;
@@ -123,7 +131,10 @@ impl Resolve<WriteArgs> for UpdateVariableDescription {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateVariableDescriptionResponse> {
if !user.admin {
return Err(anyhow!("only admins can update variables").into());
return Err(
anyhow!("Only admins can update variables")
.status_code(StatusCode::FORBIDDEN),
);
}
db_client()
.variables
@@ -132,7 +143,7 @@ impl Resolve<WriteArgs> for UpdateVariableDescription {
doc! { "$set": { "description": &self.description } },
)
.await
.context("failed to update variable description on db")?;
.context("Failed to update variable description on db")?;
Ok(get_variable(&self.name).await?)
}
}
@@ -144,7 +155,10 @@ impl Resolve<WriteArgs> for UpdateVariableIsSecret {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateVariableIsSecretResponse> {
if !user.admin {
return Err(anyhow!("only admins can update variables").into());
return Err(
anyhow!("Only admins can update variables")
.status_code(StatusCode::FORBIDDEN),
);
}
db_client()
.variables
@@ -153,7 +167,7 @@ impl Resolve<WriteArgs> for UpdateVariableIsSecret {
doc! { "$set": { "is_secret": self.is_secret } },
)
.await
.context("failed to update variable is secret on db")?;
.context("Failed to update variable is secret on db")?;
Ok(get_variable(&self.name).await?)
}
}
@@ -164,14 +178,17 @@ impl Resolve<WriteArgs> for DeleteVariable {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<DeleteVariableResponse> {
if !user.admin {
return Err(anyhow!("only admins can delete variables").into());
return Err(
anyhow!("Only admins can delete variables")
.status_code(StatusCode::FORBIDDEN),
);
}
let variable = get_variable(&self.name).await?;
db_client()
.variables
.delete_one(doc! { "name": &self.name })
.await
.context("failed to delete variable on db")?;
.context("Failed to delete variable on db")?;
let mut update = make_update(
ResourceTarget::system(),
@@ -180,7 +197,7 @@ impl Resolve<WriteArgs> for DeleteVariable {
);
update
.push_simple_log("delete variable", format!("{variable:#?}"));
.push_simple_log("Delete Variable", format!("{variable:#?}"));
update.finalize();
add_update(update).await?;
+28 -12
View File
@@ -16,17 +16,16 @@ use super::cache::Cache;
#[derive(Default)]
pub struct ActionStates {
pub build: Cache<String, Arc<ActionState<BuildActionState>>>,
pub server: Cache<String, Arc<ActionState<ServerActionState>>>,
pub stack: Cache<String, Arc<ActionState<StackActionState>>>,
pub deployment:
Cache<String, Arc<ActionState<DeploymentActionState>>>,
pub server: Cache<String, Arc<ActionState<ServerActionState>>>,
pub build: Cache<String, Arc<ActionState<BuildActionState>>>,
pub repo: Cache<String, Arc<ActionState<RepoActionState>>>,
pub procedure:
Cache<String, Arc<ActionState<ProcedureActionState>>>,
pub action: Cache<String, Arc<ActionState<ActionActionState>>>,
pub resource_sync:
Cache<String, Arc<ActionState<ResourceSyncActionState>>>,
pub stack: Cache<String, Arc<ActionState<StackActionState>>>,
pub sync: Cache<String, Arc<ActionState<ResourceSyncActionState>>>,
}
/// Need to be able to check "busy" with write lock acquired.
@@ -62,17 +61,33 @@ impl<States: Default + Busy + Copy + Send + 'static>
/// Returns a guard that returns the states to default (not busy) when dropped.
pub fn update(
&self,
handler: impl Fn(&mut States),
update_fn: impl Fn(&mut States),
) -> anyhow::Result<UpdateGuard<'_, States>> {
self.update_custom(
update_fn,
|states| *states = Default::default(),
true,
)
}
/// Will acquire lock, optionally check busy, and if not will
/// run the provided update function on the states.
/// Returns a guard that calls the provided return_fn when dropped.
pub fn update_custom(
&self,
update_fn: impl Fn(&mut States),
return_fn: impl Fn(&mut States) + Send + 'static,
busy_check: bool,
) -> anyhow::Result<UpdateGuard<'_, States>> {
let mut lock = self
.0
.lock()
.map_err(|e| anyhow!("action state lock poisoned | {e:?}"))?;
if lock.busy() {
return Err(anyhow!("resource is busy"));
.map_err(|e| anyhow!("Action state lock poisoned | {e:?}"))?;
if busy_check && lock.busy() {
return Err(anyhow!("Resource is busy"));
}
handler(&mut *lock);
Ok(UpdateGuard(&self.0))
update_fn(&mut *lock);
Ok(UpdateGuard(&self.0, Box::new(return_fn)))
}
}
@@ -82,6 +97,7 @@ impl<States: Default + Busy + Copy + Send + 'static>
/// user could drop UpdateGuard.
pub struct UpdateGuard<'a, States: Default + Send + 'static>(
&'a Mutex<States>,
Box<dyn Fn(&mut States) + Send>,
);
impl<States: Default + Send + 'static> Drop
@@ -95,6 +111,6 @@ impl<States: Default + Send + 'static> Drop
return;
}
};
*lock = States::default();
self.1(&mut *lock);
}
}
+27 -22
View File
@@ -13,26 +13,31 @@ use database::mungos::{
options::FindOneOptions,
},
};
use komodo_client::entities::{
Operation, ResourceTarget, ResourceTargetVariant,
action::{Action, ActionState},
alerter::Alerter,
build::Build,
builder::Builder,
deployment::{Deployment, DeploymentState},
docker::container::{ContainerListItem, ContainerStateStatusEnum},
permission::{PermissionLevel, PermissionLevelAndSpecifics},
procedure::{Procedure, ProcedureState},
repo::Repo,
server::{Server, ServerState},
stack::{Stack, StackServiceNames, StackState},
stats::SystemInformation,
sync::ResourceSync,
tag::Tag,
update::Update,
user::{User, admin_service_user},
user_group::UserGroup,
variable::Variable,
use komodo_client::{
busy::Busy,
entities::{
Operation, ResourceTarget, ResourceTargetVariant,
action::{Action, ActionState},
alerter::Alerter,
build::Build,
builder::Builder,
deployment::{Deployment, DeploymentState},
docker::container::{
ContainerListItem, ContainerStateStatusEnum,
},
permission::{PermissionLevel, PermissionLevelAndSpecifics},
procedure::{Procedure, ProcedureState},
repo::Repo,
server::{Server, ServerState},
stack::{Stack, StackServiceNames, StackState},
stats::SystemInformation,
sync::ResourceSync,
tag::Tag,
update::Update,
user::{User, admin_service_user},
user_group::UserGroup,
variable::Variable,
},
};
use periphery_client::api::stats;
use tokio::sync::Mutex;
@@ -467,7 +472,7 @@ pub async fn get_action_state(id: &String) -> ActionState {
.action
.get(id)
.await
.map(|s| s.get().map(|s| s.running))
.map(|s| s.get().map(|s| s.busy()))
.transpose()
.ok()
.flatten()
@@ -483,7 +488,7 @@ pub async fn get_procedure_state(id: &String) -> ProcedureState {
.procedure
.get(id)
.await
.map(|s| s.get().map(|s| s.running))
.map(|s| s.get().map(|s| s.busy()))
.transpose()
.ok()
.flatten()
+39 -3
View File
@@ -1,3 +1,5 @@
use std::sync::{Arc, OnceLock};
use async_timing_util::wait_until_timelength;
use database::mungos::{find::find_collect, mongodb::bson::doc};
use futures::future::join_all;
@@ -15,10 +17,11 @@ use komodo_client::entities::{
};
use periphery_client::api::{self, git::GetLatestCommit};
use serror::Serror;
use tokio::sync::Mutex;
use crate::{
config::core_config,
helpers::periphery_client,
helpers::{cache::Cache, periphery_client},
monitor::{alert::check_alerts, record::record_server_stats},
state::{db_client, deployment_status_cache, repo_status_cache},
};
@@ -110,14 +113,47 @@ async fn refresh_server_cache(ts: i64) {
}
};
let futures = servers.into_iter().map(|server| async move {
update_cache_for_server(&server).await;
update_cache_for_server(&server, false).await;
});
join_all(futures).await;
tokio::join!(check_alerts(ts), record_server_stats(ts));
}
/// Makes sure cache for server doesn't update too frequently / simultaneously.
/// If forced, will still block against simultaneous update.
fn update_cache_for_server_controller()
-> &'static Cache<String, Arc<Mutex<i64>>> {
static CACHE: OnceLock<Cache<String, Arc<Mutex<i64>>>> =
OnceLock::new();
CACHE.get_or_init(Default::default)
}
/// The background loop will call this with force: false,
/// which exits early if the lock is busy or it was completed too recently.
/// If force is true, it will wait on simultaneous calls, and will
/// ignore the restriction on being completed too recently.
#[instrument(level = "debug")]
pub async fn update_cache_for_server(server: &Server) {
pub async fn update_cache_for_server(server: &Server, force: bool) {
// Concurrency controller to ensure it isn't done too often
// when it happens in other contexts.
let controller = update_cache_for_server_controller()
.get_or_insert_default(&server.id)
.await;
let mut lock = match controller.try_lock() {
Ok(lock) => lock,
Err(_) if force => controller.lock().await,
Err(_) => return,
};
let now = komodo_timestamp();
// early return if called again sooner than 1s.
if !force && *lock > now - 1_000 {
return;
}
*lock = now;
let (deployments, builds, repos, stacks) = tokio::join!(
find_collect(
&db_client().deployments,
+1 -1
View File
@@ -188,7 +188,7 @@ impl super::KomodoResource for Deployment {
else {
return Ok(());
};
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
Ok(())
}
+22 -8
View File
@@ -34,8 +34,10 @@ use komodo_client::{
parsers::parse_string_list,
};
use partial_derive2::{Diff, MaybeNone, PartialDiff};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serde::{Serialize, de::DeserializeOwned};
use serror::AddStatusCodeError;
use crate::{
api::{read::ReadArgs, write::WriteArgs},
@@ -458,22 +460,31 @@ pub async fn create<T: KomodoResource>(
name: &str,
mut config: T::PartialConfig,
user: &User,
) -> anyhow::Result<Resource<T::Config, T::Info>> {
) -> serror::Result<Resource<T::Config, T::Info>> {
if !T::user_can_create(user) {
return Err(anyhow!(
"User does not have permissions to create {}.",
T::resource_type()
));
return Err(
anyhow!(
"User does not have permissions to create {}.",
T::resource_type()
)
.status_code(StatusCode::FORBIDDEN),
);
}
if name.is_empty() {
return Err(anyhow!("Must provide non-empty name for resource."));
return Err(
anyhow!("Must provide non-empty name for resource")
.status_code(StatusCode::BAD_REQUEST),
);
}
let name = T::validated_name(name);
if ObjectId::from_str(&name).is_ok() {
return Err(anyhow!("valid ObjectIds cannot be used as names."));
return Err(
anyhow!("Valid ObjectIds cannot be used as names")
.status_code(StatusCode::BAD_REQUEST),
);
}
// Ensure an existing resource with same name doesn't already exist
@@ -489,7 +500,10 @@ pub async fn create<T: KomodoResource>(
.into_iter()
.any(|r| r.name == name)
{
return Err(anyhow!("Must provide unique name for resource."));
return Err(
anyhow!("Resource with name '{}' already exists", name)
.status_code(StatusCode::CONFLICT),
);
}
let start_ts = komodo_timestamp();
+2 -2
View File
@@ -123,7 +123,7 @@ impl super::KomodoResource for Server {
created: &Resource<Self::Config, Self::Info>,
_update: &mut Update,
) -> anyhow::Result<()> {
update_cache_for_server(created).await;
update_cache_for_server(created, true).await;
Ok(())
}
@@ -145,7 +145,7 @@ impl super::KomodoResource for Server {
updated: &Self,
_update: &mut Update,
) -> anyhow::Result<()> {
update_cache_for_server(updated).await;
update_cache_for_server(updated, true).await;
Ok(())
}
+1 -1
View File
@@ -252,7 +252,7 @@ impl super::KomodoResource for Stack {
else {
return Ok(());
};
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
Ok(())
}
+2 -2
View File
@@ -113,7 +113,7 @@ impl super::KomodoResource for ResourceSync {
async fn busy(id: &String) -> anyhow::Result<bool> {
action_states()
.resource_sync
.sync
.get(id)
.await
.unwrap_or_default()
@@ -242,7 +242,7 @@ async fn get_resource_sync_state(
data: &ResourceSyncInfo,
) -> ResourceSyncState {
if let Some(state) = action_states()
.resource_sync
.sync
.get(id)
.await
.and_then(|s| {
+1 -1
View File
@@ -72,7 +72,7 @@ pub async fn execute_compose<T: ExecuteCompose>(
.push(T::execute(periphery, stack, services, extras).await?);
// Ensure cached stack state up to date by updating server cache
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
+1
View File
@@ -40,6 +40,7 @@ pub fn db_client() -> &'static Client {
.expect("db_client accessed before initialized")
}
/// Must be called in app startup sequence.
pub async fn init_db_client() {
let client = Client::new(&core_config().database)
.await
+1
View File
@@ -147,6 +147,7 @@ pub trait ExecuteResourceSync: ResourceSyncTrait {
sync_user(),
)
.await
.map_err(|e| e.error)
{
Ok(resource) => resource.id,
Err(e) => {
+1
View File
@@ -825,6 +825,7 @@ impl ExecuteResourceSync for Procedure {
sync_user(),
)
.await
.map_err(|e| e.error)
{
Ok(resource) => resource.id,
Err(e) => {
+2 -1
View File
@@ -1,6 +1,7 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
FROM rust:1.89.0-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -10,7 +11,7 @@ COPY ./client/periphery ./client/periphery
COPY ./bin/periphery ./bin/periphery
# Compile app
RUN cargo build -p komodo_periphery --release
RUN cargo build -p komodo_periphery --release && cargo strip
# Final Image
FROM debian:bullseye-slim
+7 -12
View File
@@ -12,7 +12,7 @@ use interpolate::Interpolator;
use komodo_client::entities::{
EnvironmentVar, all_logs_success,
build::{Build, BuildConfig},
environment_vars_from_str, get_image_names, optional_string,
environment_vars_from_str, optional_string,
to_path_compatible_name,
update::Log,
};
@@ -25,9 +25,7 @@ use resolver_api::Resolve;
use tokio::fs;
use crate::{
build::{
image_tags, parse_build_args, parse_secret_args, write_dockerfile,
},
build::{parse_build_args, parse_secret_args, write_dockerfile},
config::periphery_config,
docker::docker_login,
helpers::{parse_extra_args, parse_labels},
@@ -126,8 +124,9 @@ impl Resolve<super::Args> for build::Build {
mut build,
repo: linked_repo,
registry_tokens,
additional_tags,
mut replacers,
commit_hash,
additional_tags,
} = self;
let mut logs = Vec::new();
@@ -145,8 +144,6 @@ impl Resolve<super::Args> for build::Build {
name,
config:
BuildConfig {
version,
image_tag,
build_path,
dockerfile_path,
build_args,
@@ -265,8 +262,6 @@ impl Resolve<super::Args> for build::Build {
// Get command parts
let image_names = get_image_names(&build);
// Add VERSION to build args (if not already there)
let mut build_args = environment_vars_from_str(build_args)
.context("Invalid build_args")?;
@@ -291,9 +286,9 @@ impl Resolve<super::Args> for build::Build {
let buildx = if *use_buildx { " buildx" } else { "" };
let image_tags =
image_tags(&image_names, image_tag, version, &additional_tags)
.context("Failed to parse image tags into command")?;
let image_tags = build
.get_image_tags_as_arg(commit_hash.as_deref(), &additional_tags)
.context("Failed to parse image tags into command")?;
let maybe_push = if should_push { " --push" } else { "" };
+1 -29
View File
@@ -6,7 +6,7 @@ use std::{
use anyhow::{Context, anyhow};
use formatting::format_serror;
use komodo_client::{
entities::{EnvironmentVar, Version, update::Log},
entities::{EnvironmentVar, update::Log},
parsers::QUOTE_PATTERN,
};
@@ -52,34 +52,6 @@ pub async fn write_dockerfile(
}
}
pub fn image_tags(
image_names: &[String],
custom_tag: &str,
version: &Version,
additional: &[String],
) -> anyhow::Result<String> {
let Version { major, minor, .. } = version;
let custom_tag = if custom_tag.is_empty() {
String::new()
} else {
format!("-{custom_tag}")
};
let mut res = String::new();
for image_name in image_names {
write!(
&mut res,
" -t {image_name}:latest{custom_tag} -t {image_name}:{version}{custom_tag} -t {image_name}:{major}.{minor}{custom_tag} -t {image_name}:{major}{custom_tag}"
)?;
for tag in additional {
write!(&mut res, " -t {image_name}:{tag}{custom_tag}")?;
}
}
Ok(res)
}
pub fn parse_build_args(build_args: &[EnvironmentVar]) -> String {
build_args
.iter()
+1 -1
View File
@@ -68,7 +68,7 @@ impl Busy for ProcedureActionState {
impl Busy for ActionActionState {
fn busy(&self) -> bool {
self.running
self.running > 0
}
}
@@ -1,5 +1,4 @@
use serde::{
__private::de::{Content, ContentDeserializer},
Deserialize, Deserializer,
de::{IntoDeserializer, Visitor},
};
@@ -69,17 +68,15 @@ impl<'de, T: Deserialize<'de>> Visitor<'de>
let mut res =
Vec::with_capacity(seq.size_hint().unwrap_or_default());
loop {
match seq.next_element::<Content>() {
Ok(Some(content)) => {
match T::deserialize::<ContentDeserializer<'_, S::Error>>(
content.clone().into_deserializer(),
) {
match seq.next_element::<serde_json::Value>() {
Ok(Some(value)) => {
match T::deserialize(value.clone().into_deserializer()) {
Ok(item) => res.push(item),
Err(e) => {
// Since this is used to parse startup config (including logging config),
// the tracing logging is not initialized. Need to use eprintln.
eprintln!(
"WARN: failed to parse item in list | {content:?} | {e:?}",
"WARN: failed to parse item in list | {value:?} | {e:?}",
)
}
}
+2 -2
View File
@@ -222,8 +222,8 @@ impl Default for ActionConfig {
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)]
pub struct ActionActionState {
/// Whether the action is currently running.
pub running: bool,
/// Number of instances of the Action currently running
pub running: u32,
}
#[typeshare]
+147 -1
View File
@@ -1,4 +1,4 @@
use std::sync::OnceLock;
use std::{fmt::Write, sync::OnceLock};
use bson::{Document, doc};
use derive_builder::Builder;
@@ -26,6 +26,127 @@ use super::{
#[typeshare]
pub type Build = Resource<BuildConfig, BuildInfo>;
impl Build {
pub fn get_image_names(&self) -> Vec<String> {
let Build {
name,
config:
BuildConfig {
image_name,
image_registry,
..
},
..
} = self;
let name = if image_name.is_empty() {
name
} else {
image_name
};
// Local only
if image_registry.is_empty() {
return vec![name.to_string()];
}
image_registry
.iter()
.map(
|ImageRegistryConfig {
domain,
account,
organization,
}| {
match (
!domain.is_empty(),
!organization.is_empty(),
!account.is_empty(),
) {
// If organization and account provided, name under organization.
(true, true, true) => {
format!("{domain}/{organization}/{name}")
}
// Just domain / account provided
(true, false, true) => {
format!("{domain}/{account}/{name}")
}
// Otherwise, just use name (local only)
_ => name.to_string(),
}
},
)
.collect()
}
pub fn get_image_tags(
&self,
image_names: &[String],
commit_hash: Option<&str>,
additional: &[String],
) -> Vec<String> {
let BuildConfig {
version,
image_tag,
include_latest_tag,
include_version_tags: include_version_tag,
include_commit_tag,
..
} = &self.config;
let Version { major, minor, .. } = version;
let image_tag_postfix = if image_tag.is_empty() {
String::new()
} else {
format!("-{image_tag}")
};
let mut tags = Vec::new();
for image_name in image_names {
// Pure image tag passthrough when provided
if !image_tag.is_empty() {
tags.push(format!("{image_name}:{image_tag}"));
}
// `:latest` / `:latest-tag`
if *include_latest_tag {
tags.push(format!("{image_name}:latest{image_tag_postfix}"));
}
// `:1.19.5` + `:1.19` etc. / `1.19.5-tag`
if *include_version_tag {
tags
.push(format!("{image_name}:{version}{image_tag_postfix}"));
tags.push(format!(
"{image_name}:{major}.{minor}{image_tag_postfix}"
));
tags.push(format!("{image_name}:{major}{image_tag_postfix}"));
}
if *include_commit_tag && let Some(hash) = commit_hash {
tags.push(format!("{image_name}:{hash}{image_tag_postfix}"));
}
for tag in additional {
tags.push(format!("{image_name}:{tag}"))
}
}
tags
}
pub fn get_image_tags_as_arg(
&self,
commit_hash: Option<&str>,
additional: &[String],
) -> anyhow::Result<String> {
let mut res = String::new();
for image_tag in self.get_image_tags(
&self.get_image_names(),
commit_hash,
additional,
) {
write!(&mut res, " -t {image_tag}")?;
}
Ok(res)
}
}
#[typeshare]
pub type BuildListItem = ResourceListItem<BuildListItemInfo>;
@@ -169,6 +290,24 @@ pub struct BuildConfig {
#[builder(default)]
pub image_tag: String,
/// Push `:latest` / `:latest-image_tag` tags.
#[serde(default = "default_include_tag")]
#[builder(default = "default_include_tag()")]
#[partial_default(default_include_tag())]
pub include_latest_tag: bool,
/// Push build version semver `:1.19.5` + `1.19` / `:1.19.5-image_tag` tags.
#[serde(default = "default_include_tag")]
#[builder(default = "default_include_tag()")]
#[partial_default(default_include_tag())]
pub include_version_tags: bool,
/// Push commit hash `:a6v8h83` / `:a6v8h83-image_tag` tags.
#[serde(default = "default_include_tag")]
#[builder(default = "default_include_tag()")]
#[partial_default(default_include_tag())]
pub include_commit_tag: bool,
/// Configure quick links that are displayed in the resource header
#[serde(default, deserialize_with = "string_list_deserializer")]
#[partial_attr(serde(
@@ -344,6 +483,10 @@ fn default_auto_increment_version() -> bool {
true
}
fn default_include_tag() -> bool {
true
}
fn default_git_provider() -> String {
String::from("github.com")
}
@@ -377,6 +520,9 @@ impl Default for BuildConfig {
auto_increment_version: default_auto_increment_version(),
image_name: Default::default(),
image_tag: Default::default(),
include_latest_tag: default_include_tag(),
include_version_tags: default_include_tag(),
include_commit_tag: default_include_tag(),
links: Default::default(),
linked_repo: Default::default(),
git_provider: default_git_provider(),
-49
View File
@@ -5,7 +5,6 @@ use std::{
use anyhow::Context;
use async_timing_util::unix_timestamp_ms;
use build::ImageRegistryConfig;
use clap::Parser;
use derive_empty_traits::EmptyTraits;
use derive_variants::{EnumVariants, ExtractVariant};
@@ -129,54 +128,6 @@ pub fn optional_string(string: impl Into<String>) -> Option<String> {
}
}
pub fn get_image_names(
build::Build {
name,
config:
build::BuildConfig {
image_name,
image_registry,
..
},
..
}: &build::Build,
) -> Vec<String> {
let name = if image_name.is_empty() {
name
} else {
image_name
};
// Local only
if image_registry.is_empty() {
return vec![name.to_string()];
}
image_registry
.iter()
.map(
|ImageRegistryConfig {
domain,
account,
organization,
}| {
match (
!domain.is_empty(),
!organization.is_empty(),
!account.is_empty(),
) {
// If organization and account provided, name under organization.
(true, true, true) => {
format!("{domain}/{organization}/{name}")
}
// Just domain / account provided
(true, false, true) => format!("{domain}/{account}/{name}"),
// Otherwise, just use name (local only)
_ => name.to_string(),
}
},
)
.collect()
}
pub fn to_general_name(name: &str) -> String {
name.trim().replace('\n', "_").to_string()
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "komodo_client",
"version": "1.19.3",
"version": "1.19.5",
"description": "Komodo client package",
"homepage": "https://komo.do",
"main": "dist/lib.js",
+5
View File
@@ -199,6 +199,11 @@ export function KomodoClient(url: string, options: InitOptions) {
} else {
// it is a single update
const update = res as any as Update;
if (update.status === UpdateStatus.Complete || !update._id?.$oid) {
return update;
}
return await poll_update_until_complete(update._id?.$oid!);
}
};
+8 -2
View File
@@ -598,6 +598,12 @@ export interface BuildConfig {
* independantly versioned tags.
*/
image_tag?: string;
/** Push `:latest` / `:latest-image_tag` tags. */
include_latest_tag: boolean;
/** Push build version semver `:1.19.5` + `1.19` / `:1.19.5-image_tag` tags. */
include_version_tags: boolean;
/** Push commit hash `:a6v8h83` / `:a6v8h83-image_tag` tags. */
include_commit_tag: boolean;
/** Configure quick links that are displayed in the resource header */
links?: string[];
/** Choose a Komodo Repo (Resource) to source the build files. */
@@ -1351,8 +1357,8 @@ export type ExportResourcesToTomlResponse = TomlResponse;
export type FindUserResponse = User;
export interface ActionActionState {
/** Whether the action is currently running. */
running: boolean;
/** Number of instances of the Action currently running */
running: number;
}
export type GetActionActionStateResponse = ActionActionState;
+2
View File
@@ -18,6 +18,8 @@ pub struct Build {
/// Propogate any secret replacers from core interpolation.
#[serde(default)]
pub replacers: Vec<(String, String)>,
/// Pass the commit hash to use with tagging
pub commit_hash: Option<String>,
/// Add more tags for this build in addition to the version tags.
#[serde(default)]
pub additional_tags: Vec<String>,
+2 -1
View File
@@ -15,4 +15,5 @@ These provide alerting implementations which can be used with the `Custom` Alert
- [Telegram](https://github.com/mattsmallman/komodo-alert-to-telgram) by [mattsmallman](https://github.com/mattsmallman)
- [Ntfy](https://github.com/FoxxMD/deploy-ntfy-alerter) by [FoxxMD](https://github.com/FoxxMD)
- [Gotify](https://github.com/FoxxMD/deploy-gotify-alerter) by [FoxxMD](https://github.com/FoxxMD)
- [Apprise](https://github.com/FoxxMD/deploy-apprise-alerter) by [FoxxMD](https://github.com/FoxxMD)
- [Apprise](https://github.com/FoxxMD/deploy-apprise-alerter) by [FoxxMD](https://github.com/FoxxMD)
- [Email](https://github.com/gutenye/email-notification/blob/main/src/templates/Komodo/Komodo.md) by [Guten Ye](https://github.com/gutenye)
+3
View File
@@ -88,6 +88,9 @@ export function KomodoClient(url, options) {
else {
// it is a single update
const update = res;
if (update.status === UpdateStatus.Complete || !update._id?.$oid) {
return update;
}
return await poll_update_until_complete(update._id?.$oid);
}
};
+8 -2
View File
@@ -595,6 +595,12 @@ export interface BuildConfig {
* independantly versioned tags.
*/
image_tag?: string;
/** Push `:latest` / `:latest-image_tag` tags. */
include_latest_tag: boolean;
/** Push build version semver `:1.19.5` + `1.19` / `:1.19.5-image_tag` tags. */
include_version_tags: boolean;
/** Push commit hash `:a6v8h83` / `:a6v8h83-image_tag` tags. */
include_commit_tag: boolean;
/** Configure quick links that are displayed in the resource header */
links?: string[];
/** Choose a Komodo Repo (Resource) to source the build files. */
@@ -1457,8 +1463,8 @@ export type ExportAllResourcesToTomlResponse = TomlResponse;
export type ExportResourcesToTomlResponse = TomlResponse;
export type FindUserResponse = User;
export interface ActionActionState {
/** Whether the action is currently running. */
running: boolean;
/** Number of instances of the Action currently running */
running: number;
}
export type GetActionActionStateResponse = ActionActionState;
export type GetActionResponse = Action;
+2 -2
View File
@@ -136,12 +136,12 @@ const GroupActionDialog = ({
return (
<Dialog open={!!action} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Group Execute - {formatted}</DialogTitle>
</DialogHeader>
<div className="py-8 flex flex-col gap-4">
<ul className="p-4 bg-accent text-sm list-disc list-inside">
<ul className="p-4 bg-accent text-sm list-disc list-inside max-h-[300px] overflow-y-auto">
{selected.map((resource) => (
<li key={resource}>{resource}</li>
))}
+5 -2
View File
@@ -254,8 +254,11 @@ export const NewLayout = ({
try {
await onConfirm();
set(false);
} catch (error) {
console.error("Error creating resource:", error);
} catch (error: any) {
const status = error?.status || error?.response?.status;
if (status !== 409 && status !== 400) {
set(false);
}
} finally {
setLoading(false);
}
+4 -16
View File
@@ -141,13 +141,7 @@ export const MonacoEditor = ({
)}px`;
}, [editor, line_count]);
const { theme: _theme } = useTheme();
const theme =
_theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: _theme;
const { currentTheme } = useTheme();
const options: monaco.editor.IStandaloneEditorConstructionOptions = {
minimap: { enabled: false },
@@ -171,7 +165,7 @@ export const MonacoEditor = ({
<Editor
language={language}
value={value}
theme={theme}
theme={currentTheme}
defaultPath={defaultPath(filename)}
options={options}
onChange={(v) => onValueChange?.(v ?? "")}
@@ -233,13 +227,7 @@ export const MonacoDiffEditor = ({
)}px`;
}, [editor, line_count]);
const { theme: _theme } = useTheme();
const theme =
_theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: _theme;
const { currentTheme } = useTheme();
const options: monaco.editor.IStandaloneDiffEditorConstructionOptions = {
minimap: { enabled: true },
@@ -262,7 +250,7 @@ export const MonacoDiffEditor = ({
language={language}
original={original}
modified={modified}
theme={theme}
theme={currentTheme}
options={options}
onMount={(editor) => {
const modifiedEditor = editor.getModifiedEditor();
+6 -2
View File
@@ -1,4 +1,4 @@
import { useAllResources, useLocalStorage, useRead, useUser } from "@lib/hooks";
import { useAllResources, useLocalStorage, useRead, useSettingsView, useUser } from "@lib/hooks";
import { Button } from "@ui/button";
import {
CommandDialog,
@@ -126,6 +126,7 @@ const useOmniItems = (
): Record<string, OmniItem[]> => {
const user = useUser().data;
const resources = useAllResources();
const [_, setSettingsView] = useSettingsView();
return useMemo(() => {
const searchTerms = search
.toLowerCase()
@@ -166,7 +167,10 @@ const useOmniItems = (
type: "Server" as UsableResource,
label: "Users",
icon: <User className="w-4 h-4" />,
onSelect: () => nav("/users"),
onSelect: () => {
setSettingsView("Users");
nav("/settings");
},
template: false,
}) as OmniItem,
]
@@ -119,11 +119,12 @@ export const ActionComponents: RequiredResourceComponents = {
Actions: {
RunAction: ({ id }) => {
const running = useRead(
"GetActionActionState",
{ action: id },
{ refetchInterval: 5000 }
).data?.running;
const running =
(useRead(
"GetActionActionState",
{ action: id },
{ refetchInterval: 5000 }
).data?.running ?? 0) > 0;
const { mutate, isPending } = useExecute("RunAction");
const action = useAction(id);
if (!action) return null;
@@ -163,6 +163,7 @@ export const BuildConfig = ({
const version_component: ConfigComponent<Types.BuildConfig> = {
label: "Version",
labelHidden: true,
components: {
version: (_version, set) => {
const version =
@@ -173,6 +174,7 @@ export const BuildConfig = ({
<ConfigInput
className="text-lg w-[200px]"
label="Version"
boldLabel
description="Version the image with major.minor.patch. It can be interpolated using [[$VERSION]]."
placeholder="0.0.0"
value={version}
@@ -228,6 +230,8 @@ export const BuildConfig = ({
};
const imageName = (update.image_name ?? config.image_name) || name;
const customTag = update.image_tag ?? config.image_tag;
const customTagPostfix = customTag ? `-${customTag}` : "";
const general_common: ConfigComponent<Types.BuildConfig>[] = [
{
@@ -262,12 +266,7 @@ export const BuildConfig = ({
{image_registries?.map((registry, index) => (
<ImageRegistryConfig
key={
(registry.domain ?? "") +
(registry.organization ?? "") +
(registry.account ?? "") +
index
}
key={index}
registry={registry}
imageName={imageName}
setRegistry={(registry) =>
@@ -292,6 +291,29 @@ export const BuildConfig = ({
),
},
},
{
label: "Tagging",
labelHidden: true,
components: {
image_name: {
description: "Push the image under a different name",
placeholder: "Custom image name",
},
image_tag: {
description: `Push a custom tag, plus postfix the other tags (eg ':latest-${customTag ? customTag : "<TAG>"}').`,
placeholder: "Custom image tag",
},
include_latest_tag: {
description: `:latest${customTagPostfix}`,
},
include_version_tags: {
description: `:X.Y.Z${customTagPostfix} + :X.Y${customTagPostfix} + :X${customTagPostfix}`,
},
include_commit_tag: {
description: `:ae8f8ff${customTagPostfix}`,
},
},
},
{
label: "Links",
labelHidden: true,
@@ -314,19 +336,6 @@ export const BuildConfig = ({
];
const advanced: ConfigComponent<Types.BuildConfig>[] = [
{
label: "Tagging",
components: {
image_name: {
description: "Push the image under a different name",
placeholder: "Custom image name",
},
image_tag: {
description: "Postfix the image version with a custom tag.",
placeholder: "Custom image tag",
},
},
},
{
label: "Pre Build",
description:
+25 -12
View File
@@ -439,12 +439,23 @@ export const CopyResource = ({
const nav = useNavigate();
const inv = useInvalidate();
const { mutate } = useWrite(`Copy${type}`, {
onSuccess: (res) => {
const { mutateAsync: copy } = useWrite(`Copy${type}`);
const onConfirm = async () => {
if (!name) return;
try {
const res = await copy({ id, name });
inv([`List${type}s`]);
nav(`/${usableResourcePath(type)}/${res._id?.$oid}`);
},
});
setOpen(false);
} catch (error: any) {
// Keep dialog open for validation errors (409/400), close for system errors
const status = error?.status || error?.response?.status;
if (status !== 409 && status !== 400) {
setOpen(false);
}
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -472,9 +483,8 @@ export const CopyResource = ({
title="Copy"
icon={<Check className="w-4 h-4" />}
disabled={!name}
onClick={() => {
mutate({ id, name });
setOpen(false);
onClick={async () => {
await onConfirm();
}}
/>
</DialogFooter>
@@ -526,10 +536,13 @@ export const NewResource = ({
: {};
const onConfirm = async () => {
if (!name) toast({ title: "Name cannot be empty" });
const id = templateId
? (await copy({ name, id: templateId }))._id?.$oid!
: (await create({ name, config }))._id?.$oid!;
nav(`/${usableResourcePath(type)}/${id}`);
const result = templateId
? await copy({ name, id: templateId })
: await create({ name, config });
const resourceId = result._id?.$oid;
if (resourceId) {
nav(`/${usableResourcePath(type)}/${resourceId}`);
}
};
return (
<NewLayout
@@ -547,7 +560,7 @@ export const NewResource = ({
onKeyDown={(e) => {
if (!name) return;
if (e.key === "Enter") {
onConfirm();
onConfirm().catch(() => {});
}
}}
/>
@@ -44,6 +44,12 @@ export const useServer = (id?: string) =>
(d) => d.id === id
);
// Helper function to check if server is available for API calls
export const useIsServerAvailable = (serverId?: string) => {
const server = useServer(serverId);
return server?.info.state === Types.ServerState.Ok;
};
export const useFullServer = (id: string) =>
useRead("GetServer", { server: id }, { refetchInterval: 10_000 }).data;
@@ -51,24 +57,26 @@ export const useFullServer = (id: string) =>
export const useVersionMismatch = (serverId?: string) => {
const core_version = useRead("GetVersion", {}).data?.version;
const server_version = useServer(serverId)?.info.version;
const unknown = !server_version || server_version === "Unknown";
const mismatch = !!server_version && !!core_version && server_version !== core_version;
const mismatch =
!!server_version && !!core_version && server_version !== core_version;
return { unknown, mismatch, hasVersionMismatch: mismatch && !unknown };
};
const Icon = ({ id, size }: { id?: string; size: number }) => {
const state = useServer(id)?.info.state;
const { hasVersionMismatch } = useVersionMismatch(id);
return (
<Server
className={cn(
`w-${size} h-${size}`,
state && stroke_color_class_by_intention(
server_state_intention(state, hasVersionMismatch)
)
state &&
stroke_color_class_by_intention(
server_state_intention(state, hasVersionMismatch)
)
)}
/>
);
@@ -137,10 +145,7 @@ const ConfigTabs = ({ id }: { id: string }) => {
</TabsList>
);
return (
<Tabs
value={currentView}
onValueChange={setView as any}
>
<Tabs value={currentView} onValueChange={setView as any}>
<TabsContent value="Config">
<ServerConfig id={id} titleOther={tabsList} />
</TabsContent>
@@ -221,10 +226,10 @@ export const ServerVersion = ({ id }: { id: string }) => {
const core_version = useRead("GetVersion", {}).data?.version;
const version = useServer(id)?.info.version;
const server_state = useServer(id)?.info.state;
const unknown = !version || version === "Unknown";
const mismatch = !!version && !!core_version && version !== core_version;
// Don't show version for disabled servers
if (server_state === Types.ServerState.Disabled) {
return (
@@ -242,13 +247,14 @@ export const ServerVersion = ({ id }: { id: string }) => {
</TooltipTrigger>
<TooltipContent>
<div>
Server is <span className="font-bold">disabled</span> - version unknown.
Server is <span className="font-bold">disabled</span> - version
unknown.
</div>
</TooltipContent>
</Tooltip>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -306,7 +312,11 @@ export const ServerComponents: RequiredResourceComponents = {
),
Dashboard: () => {
const summary = useRead("GetServersSummary", {}, { refetchInterval: 15_000 }).data;
const summary = useRead(
"GetServersSummary",
{},
{ refetchInterval: 15_000 }
).data;
return (
<DashboardPieChart
data={[
@@ -363,18 +373,19 @@ export const ServerComponents: RequiredResourceComponents = {
State: ({ id }) => {
const state = useServer(id)?.info.state;
const { hasVersionMismatch } = useVersionMismatch(id);
// Show full version mismatch text
const displayState = state === Types.ServerState.Ok && hasVersionMismatch
? "Version Mismatch"
: state === Types.ServerState.NotOk
? "Not Ok"
: state;
const displayState =
state === Types.ServerState.Ok && hasVersionMismatch
? "Version Mismatch"
: state === Types.ServerState.NotOk
? "Not Ok"
: state;
return (
<StatusBadge
text={displayState}
intent={server_state_intention(state, hasVersionMismatch)}
<StatusBadge
text={displayState}
intent={server_state_intention(state, hasVersionMismatch)}
/>
);
},
@@ -384,13 +395,13 @@ export const ServerComponents: RequiredResourceComponents = {
Info: {
Version: ServerVersion,
Cpu: ({ id }) => {
const server = useServer(id);
const isServerAvailable = useIsServerAvailable(id);
const core_count =
useRead(
"GetSystemInformation",
{ server: id },
{
enabled: server ? server.info.state !== "Disabled" : false,
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data?.core_count ?? 0;
@@ -402,19 +413,19 @@ export const ServerComponents: RequiredResourceComponents = {
);
},
LoadAvg: ({ id }) => {
const server = useServer(id);
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: server ? server.info.state !== "Disabled" : false,
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
if (!stats?.load_average) return null;
const one = stats.load_average?.one;
return (
<div className="flex gap-2 items-center">
<Cpu className="w-4 h-4" />
@@ -423,12 +434,12 @@ export const ServerComponents: RequiredResourceComponents = {
);
},
Mem: ({ id }) => {
const server = useServer(id);
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: server ? server.info.state !== "Disabled" : false,
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
@@ -440,12 +451,12 @@ export const ServerComponents: RequiredResourceComponents = {
);
},
Disk: ({ id }) => {
const server = useServer(id);
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: server ? server.info.state !== "Disabled" : false,
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
@@ -616,11 +627,12 @@ export const ServerComponents: RequiredResourceComponents = {
const { hasVersionMismatch } = useVersionMismatch(id);
// Determine display state for header (longer text is okay in header)
const displayState = server?.info.state === Types.ServerState.Ok && hasVersionMismatch
? "Version Mismatch"
: server?.info.state === Types.ServerState.NotOk
? "Not Ok"
: server?.info.state;
const displayState =
server?.info.state === Types.ServerState.Ok && hasVersionMismatch
? "Version Mismatch"
: server?.info.state === Types.ServerState.NotOk
? "Not Ok"
: server?.info.state;
return (
<ResourcePageHeader
@@ -3,6 +3,7 @@ import { ServerComponents } from "@components/resources/server";
import { DataTable, SortableHeader } from "@ui/data-table";
import { useRead } from "@lib/hooks";
import { useMemo } from "react";
import { useIsServerAvailable } from ".";
export const ServerMonitoringTable = ({ search = "" }: { search?: string }) => {
const servers = useRead("ListServers", {}).data;
@@ -73,11 +74,19 @@ export const ServerMonitoringTable = ({ search = "" }: { search?: string }) => {
);
};
const useStats = (id: string) =>
useRead("GetSystemStats", { server: id }, { refetchInterval: 10_000 }).data;
const useStats = (id: string) => {
const isServerAvailable = useIsServerAvailable(id);
return useRead("GetSystemStats", { server: id }, {
enabled: isServerAvailable,
refetchInterval: 10_000
}).data;
};
const useServerThresholds = (id: string) => {
const config = useRead("GetServer", { server: id }).data?.config as any;
const isServerAvailable = useIsServerAvailable(id);
const config = useRead("GetServer", { server: id }, {
enabled: isServerAvailable
}).data?.config as any;
return {
cpuWarning: config?.cpu_warning ?? 75,
cpuCritical: config?.cpu_critical ?? 90,
@@ -95,13 +95,7 @@ export const InnerStatChart = ({
stats: StatDatapoint[] | undefined;
seriesData?: { label: string; data: StatDatapoint[] }[];
}) => {
const { theme: _theme } = useTheme();
const theme =
_theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: _theme;
const { currentTheme } = useTheme();
const min = stats?.[0]?.date ?? 0;
const max = stats?.[stats.length - 1]?.date ?? 0;
@@ -200,7 +194,7 @@ export const InnerStatChart = ({
hex_color_by_intention("Unknown"),
]
: [getColor(type)],
dark: theme === "dark",
dark: currentTheme === "dark",
padding: {
left: 10,
right: 10,
@@ -18,6 +18,7 @@ import {
} from "@ui/select";
import { DockerResourceLink, ShowHideButton } from "@components/util";
import { filterBySplit } from "@lib/utils";
import { useIsServerAvailable } from ".";
export const ServerStats = ({
id,
@@ -29,17 +30,23 @@ export const ServerStats = ({
const [interval, setInterval] = useStatsGranularity();
const { specific } = usePermissions({ type: "Server", id });
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{ refetchInterval: 10_000 }
{
enabled: isServerAvailable,
refetchInterval: 10_000
}
).data;
const info = useRead("GetSystemInformation", { server: id }).data;
const info = useRead("GetSystemInformation", { server: id }, { enabled: isServerAvailable }).data;
// Get all the containers with stats
const containers = useRead("ListDockerContainers", {
server: id,
}, {
enabled: isServerAvailable
}).data?.filter((c) => c.stats);
const [showContainers, setShowContainers] = useLocalStorage(
"stats-show-container-table-v1",
@@ -504,8 +511,8 @@ const LOAD_AVERAGE = ({
}) => {
if (!stats?.load_average) return null;
const { one = 0, five = 0, fifteen = 0 } = stats.load_average || {};
const cores = useRead("GetSystemInformation", { server: id }).data
?.core_count;
const isServerAvailable = useIsServerAvailable(id);
const cores = useRead("GetSystemInformation", { server: id }, { enabled: isServerAvailable }).data?.core_count;
const pct = (load: number) =>
cores && cores > 0 ? Math.min((load / cores) * 100, 100) : undefined;
@@ -16,7 +16,7 @@ import { useLocalStorage, useWrite } from "@lib/hooks";
import { Button } from "@ui/button";
import { FilePlus, History } from "lucide-react";
import { useToast } from "@ui/use-toast";
import { ConfirmButton, ShowHideButton } from "@components/util";
import { ConfirmButton, ShowHideButton, CopyButton } from "@components/util";
import { DEFAULT_STACK_FILE_CONTENTS } from "./config";
import { Types } from "komodo_client";
@@ -205,55 +205,85 @@ export const StackInfo = ({
latest_contents.length > 0 &&
latest_contents.map((content) => {
const showContents = show[content.path] ?? default_show_contents;
const handleToggleShow = () => {
setShow((show) => ({
...show,
[content.path]: !(show[content.path] ?? default_show_contents),
}));
};
return (
<Card key={content.path} className="flex flex-col gap-4">
<CardHeader
className={cn(
"flex flex-row justify-between items-center",
"flex flex-row justify-between items-center group cursor-pointer",
showContents && "pb-0"
)}
onClick={handleToggleShow}
tabIndex={0}
role="button"
aria-pressed={showContents}
onKeyDown={(e) => {
if (
(e.key === "Enter" || e.key === " ") &&
e.target === e.currentTarget
) {
if (e.key === " ") e.preventDefault();
handleToggleShow();
}
}}
>
<CardTitle className="font-mono flex gap-2">
<div className="text-muted-foreground">File:</div>
{content.path}
<CardTitle className="font-mono flex gap-2 items-center">
<div className="flex gap-2 items-center">
<span className="text-muted-foreground">File:</span>
<span>{content.path}</span>
<span onClick={(e) => e.stopPropagation()} data-copy-button>
<CopyButton content={content.path} label="file path" />
</span>
</div>
</CardTitle>
<div className="flex items-center gap-2">
{canEdit && (
<>
<Button
variant="outline"
onClick={() =>
setEdits({ ...edits, [content.path]: undefined })
}
onClick={(e) => {
e.stopPropagation();
setEdits({ ...edits, [content.path]: undefined });
}}
className="flex items-center gap-2"
disabled={!edits[content.path]}
>
<History className="w-4 h-4" />
Reset
</Button>
<ConfirmUpdate
previous={{ contents: content.contents }}
content={{ contents: edits[content.path] }}
onConfirm={async () => {
if (stack) {
return await mutateAsync({
stack: stack.name,
file_path: content.path,
contents: edits[content.path]!,
}).then(() =>
setEdits({ ...edits, [content.path]: undefined })
);
}
}}
disabled={!edits[content.path]}
language="yaml"
loading={isPending}
/>
<span onClick={(e) => e.stopPropagation()}>
<ConfirmUpdate
previous={{ contents: content.contents }}
content={{ contents: edits[content.path] }}
onConfirm={async () => {
if (stack) {
return await mutateAsync({
stack: stack.name,
file_path: content.path,
contents: edits[content.path]!,
}).then(() =>
setEdits({
...edits,
[content.path]: undefined,
})
);
}
}}
disabled={!edits[content.path]}
language="yaml"
loading={isPending}
/>
</span>
</>
)}
<ShowHideButton
show={showContents}
setShow={(val) => setShow({ ...show, [content.path]: val })}
setShow={() => {}}
/>
</div>
</CardHeader>
+2 -8
View File
@@ -31,14 +31,8 @@ export const Terminal = ({
_reconnect: boolean;
_clear?: boolean;
}) => {
const { theme: __theme } = useTheme();
const _theme =
__theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: __theme;
const theme = _theme === "dark" ? DARK_THEME : LIGHT_THEME;
const { currentTheme } = useTheme();
const theme = currentTheme === "dark" ? DARK_THEME : LIGHT_THEME;
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon>(new FitAddon());
+69 -47
View File
@@ -239,50 +239,15 @@ export const UserDropdown = () => {
</Button>
</div>
{accounts.map((login) => {
const selected = login.user_id === user?._id?.$oid;
return (
<div className="flex gap-2 items-center w-full">
<Button
variant={selected ? "secondary" : "ghost"}
className="flex gap-2 items-center justify-between w-full"
onClick={() => {
if (selected) {
// Noop
setOpen(false);
return;
}
LOGIN_TOKENS.change(login.user_id);
location.reload();
}}
>
<div className="flex items-center gap-2">
<Username user_id={login.user_id} />
</div>
{selected && (
<Circle className="w-3 h-3 stroke-none transition-colors fill-green-500" />
)}
</Button>
{viewLogout && (
<Button
variant="destructive"
className="px-2 py-0"
onClick={() => {
LOGIN_TOKENS.remove(login.user_id);
if (selected) {
location.reload();
} else {
rerender();
}
}}
>
<LogOut className="w-4" />
</Button>
)}
</div>
);
})}
{accounts.map((login) => (
<Account
login={login}
current_id={user?._id?.$oid}
setOpen={setOpen}
rerender={rerender}
viewLogout={viewLogout}
/>
))}
<Separator />
@@ -317,9 +282,66 @@ export const UserDropdown = () => {
);
};
const Username = ({ user_id }: { user_id: string }) => {
const res = useRead("GetUsername", { user_id }).data;
return <UsernameView username={res?.username} avatar={res?.avatar} full />;
const Account = ({
login,
current_id,
setOpen,
rerender,
viewLogout,
}: {
login: Types.JwtResponse;
current_id?: string;
setOpen: (open: boolean) => void;
rerender: () => void;
viewLogout: boolean;
}) => {
const res = useRead("GetUsername", { user_id: login.user_id });
if (!res.data) return;
const selected = login.user_id === current_id;
return (
<div className="flex gap-2 items-center w-full">
<Button
variant={selected ? "secondary" : "ghost"}
className="flex gap-2 items-center justify-between w-full"
onClick={() => {
if (selected) {
// Noop
setOpen(false);
return;
}
LOGIN_TOKENS.change(login.user_id);
location.reload();
}}
>
<div className="flex items-center gap-2">
<UsernameView
username={res.data?.username}
avatar={res.data?.avatar}
/>
</div>
{selected && (
<Circle className="w-3 h-3 stroke-none transition-colors fill-green-500" />
)}
</Button>
{viewLogout && (
<Button
variant="destructive"
className="px-2 py-0"
onClick={() => {
LOGIN_TOKENS.remove(login.user_id);
if (selected) {
location.reload();
} else {
rerender();
}
}}
>
<LogOut className="w-4" />
</Button>
)}
</div>
);
};
const UsernameView = ({
+7
View File
@@ -128,16 +128,21 @@ export const useLoginOptions = () => {
export const useUser = () => {
const userReset = useUserReset();
const hasJwt = !!LOGIN_TOKENS.jwt();
const query = useQuery({
queryKey: ["GetUser"],
queryFn: () => komodo_client().auth("GetUser", {}),
refetchInterval: 30_000,
enabled: hasJwt,
});
useEffect(() => {
if (query.data && query.error) {
userReset();
}
}, [query.data, query.error]);
return query;
};
@@ -173,9 +178,11 @@ export const useRead = <
params: P,
config?: C
) => {
const hasJwt = !!LOGIN_TOKENS.jwt();
return useQuery({
queryKey: [type, params],
queryFn: () => komodo_client().read<T, R>(type, params),
enabled: hasJwt && (config?.enabled !== false),
...config,
});
};
+8 -2
View File
@@ -112,6 +112,11 @@ export default function Login() {
login(creds);
};
const handleSubmit = (e: any) => {
e.preventDefault();
handleLogin();
};
const handleSignUp = () => {
const creds = getFormCredentials();
if (!creds) return;
@@ -185,6 +190,7 @@ export default function Login() {
{options?.local && (
<form
ref={formRef}
onSubmit={handleSubmit}
autoComplete="on"
>
<CardContent className="flex flex-col justify-center w-full gap-4">
@@ -196,6 +202,7 @@ export default function Login() {
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
autoFocus
/>
</div>
<div className="flex flex-col gap-2">
@@ -222,9 +229,8 @@ export default function Login() {
)}
<Button
variant="default"
type="button"
type="submit"
value="login"
onClick={handleLogin}
disabled={loginPending}
>
Log In
+9 -4
View File
@@ -3,7 +3,14 @@ import { LOGIN_TOKENS, useAuth, useUser } from "@lib/hooks";
import UpdatePage from "@pages/update";
import { Loader2 } from "lucide-react";
import { lazy, Suspense } from "react";
import { BrowserRouter, Navigate, Outlet, Route, Routes, useLocation } from "react-router-dom";
import {
BrowserRouter,
Navigate,
Outlet,
Route,
Routes,
useLocation,
} from "react-router-dom";
// Lazy import pages
const Resources = lazy(() => import("@pages/resources"));
@@ -128,7 +135,7 @@ const RequireAuth = () => {
const { data: user, error } = useUser();
const location = useLocation();
if (error) {
if (!LOGIN_TOKENS.jwt() || error) {
if (location.pathname === "/") {
return <Navigate to="/login" replace />;
}
@@ -148,5 +155,3 @@ const RequireAuth = () => {
return <Outlet />;
};
+1 -3
View File
@@ -80,9 +80,7 @@ export function DataTable<TData, TValue>({
}, [tableKey]);
useEffect(() => {
if (sorting.length) {
localStorage.setItem("data-table-" + tableKey, JSON.stringify(sorting));
}
localStorage.setItem("data-table-" + tableKey, JSON.stringify(sorting));
}, [tableKey, sorting]);
useEffect(() => {
+29 -32
View File
@@ -18,16 +18,21 @@ type ThemeProviderProps = {
type ThemeProviderState = {
theme: Theme;
currentTheme: Exclude<Theme, "system">;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
currentTheme: "dark",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
const systemTheme = () =>
window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
export function ThemeProvider({
children,
defaultTheme = "system",
@@ -37,44 +42,36 @@ export function ThemeProvider({
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
// Tracks the current theme
// - if theme is light or dark, equal to theme.
// - if theme is system, tracks current theme with pool loop
const [currentTheme, setCurrentTheme] = useState<Exclude<Theme, "system">>(
theme === "system" ? systemTheme() : theme
);
useEffect(() => {
if (theme === "system") {
setCurrentTheme(systemTheme());
// For 'system' theme, need to poll
// matchMedia for update to theme.
const interval = setInterval(() => {
setCurrentTheme(systemTheme());
}, 5_000);
return () => clearInterval(interval);
} else {
setCurrentTheme(theme);
}
}, [theme]);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
// For 'system' theme, need to poll
// matchMedia for update to theme.
useEffect(() => {
if (theme === "system") {
const interval = setInterval(() => {
const [systemTheme, other] = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches
? ["dark", "light"]
: ["light", "dark"];
window.document.documentElement.classList.add(systemTheme);
window.document.documentElement.classList.remove(other);
}, 5_000);
return () => clearInterval(interval);
}
}, [theme]);
root.classList.add(currentTheme);
return () => root.classList.remove(currentTheme);
}, [currentTheme]);
const value = {
theme,
currentTheme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);