Compare commits

...
13 Commits
Author SHA1 Message Date
ninjasurge cf3f2e0503 merge upstream 2026-10-02 09:07:18 -05:00
Maxwell BeckerandGitHub 780ac68b99 2.3.3 (#1605)
* 2.4.0-dev-0

* deploy 2.4.0-dev-1

* slightly optimize stack read path

* Add IMAGE_NAME and IMAGE_TAG default build args in addition to VERSION

* deploy 2.4.0-dev-2

* ui use || location.origin for base url

* move to build time versioning

* simplify run action with komodo managed version

* procedure batch executions use  tag filters

* set advanced code splitting options

* improve network module logic / edge cases

* fix startup ordering - apply legacy db fixes before other tasks

* lengthen periphery command timeouts

* update deps

* clients still have explicit versions

* Add page breadcrumbs

* mogh ui 1.2.6

* fix set version – set only specific path

* cargo fmt

* use mogh ui ListPagination

* bump deps, rust 1.98.0

* missing dockerfile rust 1.98.0 bump
2026-09-01 14:34:49 -07:00
ninjasurge 221ad252e1 merge upstream 2026-08-26 21:18:32 -05:00
Maxwell BeckerandGitHub cc1c5aa5da 2.3.2 (#1578)
* UI Tags: remove owner column

* 2.3.2

* reporting error includes api message

* fix server builder config deserialization issue when server_ids not specified (causing UI failure)

* deploy 2.3.2-dev-1

* bump rs / ui dependencies. Breaking fixes: clap (rust), react table (ts), recharts (ts)

* improve ui index file from 4.7mb to 1.4mb with proper code splitting

* mogh ui 1.0.1 lock

* deploy 2.3.2-dev-2

* fix monaco yaml web worker for compose file suggestions

* small fix various console logs

* deploy 2.3.2-dev-3

* docsite configurable DOCSITE_URL and DOCSITE_BASE_URL

* align dockerfile with base url changes

* fix docsite docker caddy

* 2.3.2

* remove currently unsupported configuration (github webhook app)

* bump rust deps again

* bump react table
2026-08-11 13:58:11 -07:00
Maxwell BeckerandGitHub 93cce3fb60 2.3.1 (#1553)
* add static connection user agent

* include server alerting thresholds in list info to reduce query

* fix database registry accounts lost

* dev-1

* deploy 2.3.1-dev-2

* CoreReport -> KomodoReport

* stack update available avoid many GetStack call on tables

* deploy 2.3.1-dev-3

* dashboard tables toml respects tag filter

* add post execution invalidate for better feedback when ws update is missed

* resource selector select first result on settle

* skip GetPermission call for admin users

* fmt

* 2.3.1

* fix clippy lint

* ts 2.3.1
2026-07-31 14:01:30 -07:00
ninjasurge 7406c292a1 merge upstream 2026-04-04 12:49:28 -05:00
NinjaSurge 427475651c Merge branch 'github-starred-main' 2026-03-25 18:51:08 -05:00
NinjaSurge ae0c8bf98a Merge branch 'fix-issue-1022' 2026-02-12 09:39:30 -06:00
NinjaSurge a7592f4561 Merge branch 'fix-issue-1022' of https://github.com/litlmike/komodo into fix-issue-1022 2026-02-12 09:37:28 -06:00
rootandNinjaSurge a199c8fe27 fix: refresh login options when OIDC client is re-initialized
The GetLoginOptions response was cached in a OnceLock, meaning it was
computed once at startup and never updated. When the OIDC client was
re-initialized (e.g. after config changes), the login options endpoint
would still return the stale cached value, causing the UI to show
outdated OIDC button state.

Replace the static OnceLock cache with a function that reads the
current OIDC client state on each request, so the response always
reflects whether OIDC is currently available.

Also add a 30-second polling interval to the frontend's login options
query so the login page picks up server-side changes without requiring
a hard refresh.

Fixes #1022
2026-02-12 09:37:19 -06:00
ninjasurge f7296c73b1 Merge pull request 'fix: prevent custom registry org input from deselecting' (#1) from fix-issue-1094 into main
Reviewed-on: ComputerSurge/komodo#1
2026-02-11 19:32:25 -06:00
root 0eaf8b5f01 fix: refresh login options when OIDC client is re-initialized
The GetLoginOptions response was cached in a OnceLock, meaning it was
computed once at startup and never updated. When the OIDC client was
re-initialized (e.g. after config changes), the login options endpoint
would still return the stale cached value, causing the UI to show
outdated OIDC button state.

Replace the static OnceLock cache with a function that reads the
current OIDC client state on each request, so the response always
reflects whether OIDC is currently available.

Also add a 30-second polling interval to the frontend's login options
query so the login page picks up server-side changes without requiring
a hard refresh.

Fixes #1022
2026-02-07 20:34:53 -08:00
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
115 changed files with 3076 additions and 2263 deletions
Generated
+233 -918
View File
File diff suppressed because it is too large Load Diff
+19 -19
View File
@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
version = "2.3.0"
version = "0.0.0"
edition = "2024"
license = "GPL-3.0-or-later"
repository = "https://github.com/moghtech/komodo"
@@ -34,14 +34,14 @@ mogh_error = { version = "1.0.4", default-features = false }
derive_default_builder = "0.1.8"
async_timing_util = "1.1.0"
mogh_auth_client = "1.7.0"
mogh_auth_server = "1.7.0"
mogh_auth_server = "1.8.0"
mogh_secret_file = "1.0.1"
mogh_validations = "1.0.1"
mogh_rate_limit = "1.0.1"
partial_derive2 = "0.5.0"
mongo_indexed = "2.1.0"
mogh_resolver = "1.0.0"
mogh_config = "1.1.0"
mogh_config = "1.4.2"
mogh_logger = "1.3.4"
mogh_server = "1.5.0"
toml_pretty = "2.0.0"
@@ -56,7 +56,7 @@ tokio = { version = "1.53.1", features = ["full"] }
tokio-util = { version = "0.7.19", features = ["io", "codec"] }
tokio-stream = { version = "0.1.19", features = ["sync"] }
pin-project-lite = "0.2.17"
futures-util = "0.3.33"
futures-util = "0.3.34"
arc-swap = "1.9.2"
# SERVER
@@ -74,15 +74,15 @@ indexmap = { version = "2.14.0", features = ["serde"] }
serde = { version = "1.0.229", features = ["derive"] }
strum = { version = "0.28.0", features = ["derive"] }
bson = { version = "2.15.0" } # must keep in sync with mongodb version
toml = "1.1.3"
toml = "1.1.4"
serde_yaml_ng = "0.10.0"
serde_json = "1.0.151"
serde_qs = "1.1.2"
serde_qs = "1.1.3"
url = "2.5.8"
# ERROR
anyhow = "1.0.104"
thiserror = "2.0.19"
thiserror = "2.0.20"
# SYSTEM
libc = "0.2.189"
@@ -91,16 +91,16 @@ libc = "0.2.189"
tracing = "0.1.44"
# CONFIG
clap = { version = "4.6.4", features = ["derive"] }
clap = { version = "4.6.6", features = ["derive"] }
dotenvy = "0.15.7"
envy = "0.4.2"
# CRYPTO / AUTH
uuid = { version = "1.24.0", features = ["v4", "fast-rng", "serde"] }
rustls = { version = "0.23.42", features = ["aws-lc-rs"] }
data-encoding = "2.11.0"
uuid = { version = "1.25.0", features = ["v4", "fast-rng", "serde"] }
rustls = { version = "0.23.43", features = ["aws-lc-rs"] }
data-encoding = "2.11.1"
urlencoding = "2.1.3"
bcrypt = "0.19.2"
bcrypt = "0.19.3"
hmac = "0.13.0"
sha1 = "0.11.0"
sha2 = "0.11.0"
@@ -112,26 +112,26 @@ hickory-resolver = "0.26.1"
portable-pty = "0.9.0"
shell-escape = "0.1.5"
crossterm = "0.29.0"
bollard = "0.21.0"
bollard = "0.21.1"
sysinfo = "0.39.6"
shlex = "2.0.1"
# CLOUD
aws-config = "1.10.0"
aws-sdk-ec2 = "1.241.0"
aws-config = "1.11.0"
aws-sdk-ec2 = "1.252.0"
aws-credential-types = "1.3.0"
## CRON
english-to-cron = "0.1.7"
chrono-tz = "0.10.4"
chrono = "0.4.45"
croner = "3.0.1"
croner = "4.0.0"
# MISC
async-compression = { version = "0.4.42", features = ["tokio", "gzip"] }
schemars = { version = "1.2.1", features = ["indexmap2"] }
async-compression = { version = "0.4.43", features = ["tokio", "gzip"] }
schemars = { version = "1.2.2", features = ["indexmap2"] }
derive_builder = "0.20.2"
comfy-table = "7.2.2"
comfy-table = "8.0.0"
typeshare = "1.0.5"
wildcard = "0.3.0"
colored = "3.1.1"
+1 -5
View File
@@ -1,5 +1 @@
{
"imports": {
"@std/toml": "jsr:@std/toml"
}
}
{}
+1 -42
View File
@@ -1,5 +1,3 @@
import * as TOML from "@std/toml";
export const run = async (action: string) => {
const branch = await new Deno.Command("bash", {
args: ["-c", "git rev-parse --abbrev-ref HEAD"],
@@ -7,46 +5,7 @@ export const run = async (action: string) => {
.output()
.then((r) => new TextDecoder("utf-8").decode(r.stdout).trim());
const cargo_toml_str = await Deno.readTextFile("Cargo.toml");
const prev_version = (
TOML.parse(cargo_toml_str) as {
workspace: { package: { version: string } };
}
).workspace.package.version;
const [version, tag, count] = prev_version.split("-");
const next_count = Number(count) + 1;
const next_version = `${version}-${tag}-${next_count}`;
await Deno.writeTextFile(
"Cargo.toml",
cargo_toml_str.replace(
`version = "${prev_version}"`,
`version = "${next_version}"`
)
);
// Cargo check first here to make sure lock file is updated before commit.
const cmd = `
cargo check
echo ""
git add --all
git commit --all --message "deploy ${version}-${tag}-${next_count}"
echo ""
git push
echo ""
km run -y action ${action} "KOMODO_BRANCH=${branch}&KOMODO_VERSION=${version}&KOMODO_TAG=${tag}-${next_count}"
`
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("//"))
.join(" && ");
new Deno.Command("bash", {
args: ["-c", cmd],
args: ["-c", `km run -y action ${action} "BRANCH=${branch}"`],
}).spawn();
};
+7 -2
View File
@@ -1,8 +1,8 @@
## Builds the Komodo Core, Periphery, and Util binaries
## for a specific architecture. Requires OpenSSL 3 or later.
FROM rust:1.97.1-bookworm AS builder
RUN cargo install cargo-strip
FROM rust:1.98.0-bookworm AS builder
RUN cargo install cargo-strip cargo-edit
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -14,6 +14,11 @@ COPY ./bin/periphery ./bin/periphery
COPY ./bin/cli ./bin/cli
COPY ./xtask ./xtask
# Set Version
ARG VERSION="0.0.0"
ARG IMAGE_TAG=""
RUN cargo set-version -p komodo_core ${VERSION}${IMAGE_TAG:+-${IMAGE_TAG}}
# Compile bin
RUN \
cargo build -p komodo_core --release && \
+12 -4
View File
@@ -3,7 +3,7 @@
## Uses chef for dependency caching to help speed up back-to-back builds.
FROM lukemathwalker/cargo-chef:latest-rust-1.97.1-bookworm AS chef
FROM lukemathwalker/cargo-chef:latest-rust-1.98.0-bookworm AS chef
WORKDIR /builder
# Plan just the RECIPE to see if things have changed
@@ -12,12 +12,20 @@ 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
RUN cargo install cargo-strip cargo-edit
# Build JUST dependencies - cached layer
COPY --from=planner /builder/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# NOW copy again (this time into builder) and build app
# NOW copy again (this time into builder), set version, and build app
COPY . .
# Set Version
ARG VERSION="0.0.0"
ARG IMAGE_TAG=""
RUN cargo set-version -p komodo_core ${VERSION}${IMAGE_TAG:+-${IMAGE_TAG}}
RUN \
cargo build --release --bin core && \
cargo build --release --bin periphery && \
+7 -2
View File
@@ -1,5 +1,5 @@
FROM rust:1.97.1-trixie AS builder
RUN cargo install cargo-strip
FROM rust:1.98.0-trixie AS builder
RUN cargo install cargo-strip cargo-edit
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -8,6 +8,11 @@ COPY ./client/core/rs ./client/core/rs
COPY ./client/periphery ./client/periphery
COPY ./bin/cli ./bin/cli
# Set Version
ARG VERSION="0.0.0"
ARG IMAGE_TAG=""
RUN cargo set-version -p komodo_cli ${VERSION}${IMAGE_TAG:+-${IMAGE_TAG}}
# Compile bin
RUN cargo build -p komodo_cli --release && cargo strip
+2 -2
View File
@@ -100,7 +100,7 @@ fn print_items<T: PrintTable + Serialize>(
match format {
CliFormat::Table => {
let mut table = Table::new();
let preset = {
let style = {
use comfy_table::presets::*;
match cli_config().table_borders {
None | Some(CliTableBorders::Horizontal) => {
@@ -112,7 +112,7 @@ fn print_items<T: PrintTable + Serialize>(
Some(CliTableBorders::All) => UTF8_FULL,
}
};
table.load_preset(preset).set_header(
table.load_style(style).set_header(
T::header(links)
.iter()
.map(|h| Cell::new(h).add_attribute(Attribute::Bold)),
+7 -2
View File
@@ -1,8 +1,8 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
# Build Core
FROM rust:1.97.1-trixie AS core-builder
RUN cargo install cargo-strip
FROM rust:1.98.0-trixie AS core-builder
RUN cargo install cargo-strip cargo-edit
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -13,6 +13,11 @@ COPY ./bin/core ./bin/core
COPY ./bin/cli ./bin/cli
COPY ./xtask ./xtask
# Set Version
ARG VERSION="0.0.0"
ARG IMAGE_TAG=""
RUN cargo set-version -p komodo_core ${VERSION}${IMAGE_TAG:+-${IMAGE_TAG}}
# Compile app
RUN cargo build -p komodo_core --release && \
cargo build -p komodo_cli --release && \
+158
View File
@@ -0,0 +1,158 @@
use std::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::{AddStatusCode, Json};
use typeshare::typeshare;
use uuid::Uuid;
use crate::{
auth::{
get_user_id_from_headers,
github::{self, client::github_oauth_client},
google::{self, client::google_oauth_client},
oidc::{self, client::oidc_client},
},
config::core_config,
helpers::query::get_user,
state::jwt_client,
};
use super::Variant;
#[derive(Default)]
pub struct AuthArgs {
pub headers: HeaderMap,
}
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants,
)]
#[args(AuthArgs)]
#[response(Response)]
#[error(serror::Error)]
#[variant_derive(Debug)]
#[serde(tag = "type", content = "params")]
#[allow(clippy::enum_variant_names, clippy::large_enum_variant)]
pub enum AuthRequest {
GetLoginOptions(GetLoginOptions),
SignUpLocalUser(SignUpLocalUser),
LoginLocalUser(LoginLocalUser),
ExchangeForJwt(ExchangeForJwt),
GetUser(GetUser),
}
pub fn router() -> Router {
let mut router = Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler));
if core_config().local_auth {
info!("🔑 Local Login Enabled");
}
if github_oauth_client().is_some() {
info!("🔑 Github Login Enabled");
router = router.nest("/github", github::router())
}
if google_oauth_client().is_some() {
info!("🔑 Google Login Enabled");
router = router.nest("/google", google::router())
}
if core_config().oidc_enabled {
info!("🔑 OIDC Login Enabled");
router = router.nest("/oidc", oidc::router())
}
router
}
async fn variant_handler(
headers: HeaderMap,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
let req: AuthRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(headers, Json(req)).await
}
#[instrument(name = "AuthHandler", level = "debug", skip(headers))]
async fn handler(
headers: HeaderMap,
Json(request): Json<AuthRequest>,
) -> serror::Result<axum::response::Response> {
let timer = Instant::now();
let req_id = Uuid::new_v4();
debug!(
"/auth request {req_id} | METHOD: {:?}",
request.extract_variant()
);
let res = request.resolve(&AuthArgs { headers }).await;
if let Err(e) = &res {
debug!("/auth request {req_id} | error: {:#}", e.error);
}
let elapsed = timer.elapsed();
debug!("/auth request {req_id} | resolve time: {elapsed:?}");
res.map(|res| res.0)
}
fn login_options_response() -> GetLoginOptionsResponse {
let config = core_config();
GetLoginOptionsResponse {
local: config.local_auth,
github: github_oauth_client().is_some(),
google: google_oauth_client().is_some(),
oidc: oidc_client().load().is_some(),
registration_disabled: config.disable_user_registration,
}
}
impl Resolve<AuthArgs> for GetLoginOptions {
#[instrument(name = "GetLoginOptions", level = "debug", skip(self))]
async fn resolve(
self,
_: &AuthArgs,
) -> serror::Result<GetLoginOptionsResponse> {
Ok(login_options_response())
}
}
impl Resolve<AuthArgs> for ExchangeForJwt {
#[instrument(name = "ExchangeForJwt", level = "debug", skip(self))]
async fn resolve(
self,
_: &AuthArgs,
) -> serror::Result<ExchangeForJwtResponse> {
jwt_client()
.redeem_exchange_token(&self.token)
.await
.map_err(Into::into)
}
}
impl Resolve<AuthArgs> for GetUser {
#[instrument(name = "GetUser", level = "debug", skip(self))]
async fn resolve(
self,
AuthArgs { headers }: &AuthArgs,
) -> serror::Result<User> {
let user_id = get_user_id_from_headers(headers)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
get_user(&user_id)
.await
.status_code(StatusCode::UNAUTHORIZED)
}
}
+35 -1
View File
@@ -20,7 +20,10 @@ use komodo_client::{
permission::PermissionLevel,
repo::Repo,
resource::ResourceQuery,
stack::{Stack, StackInfo, StackServiceWithUpdate, StackState},
stack::{
Stack, StackInfo, StackServiceNames, StackServiceWithUpdate,
StackState,
},
update::Update,
user::{auto_redeploy_user, stack_user, system_user},
},
@@ -822,6 +825,11 @@ pub async fn check_stack_for_update_inner(
services: extract_services_from_stack(&stack)
.into_iter()
.map(|service| StackServiceWithUpdate {
latest_image: find_latest_image(
&service.service_name,
&service.image,
&stack.info.latest_services,
),
service: service.service_name,
image: service.image,
update_available: false,
@@ -842,6 +850,11 @@ pub async fn check_stack_for_update_inner(
.services
.iter()
.map(|service| StackServiceWithUpdate {
latest_image: find_latest_image(
&service.service,
&service.image,
&stack.info.latest_services,
),
service: service.service.clone(),
image: service.image.clone(),
update_available: false,
@@ -857,6 +870,11 @@ pub async fn check_stack_for_update_inner(
service: service.service.clone(),
image: service.image.clone(),
update_available: false,
latest_image: find_latest_image(
&service.service,
&service.image,
&stack.info.latest_services,
),
};
let Some(current_digests) = &service.image_digests else {
@@ -1032,6 +1050,22 @@ pub async fn check_stack_for_update_inner(
})
}
fn find_latest_image(
service_name: &str,
current_image: &str,
latest_services: &[StackServiceNames],
) -> Option<String> {
latest_services.iter().find_map(|latest| {
if latest.service_name == service_name
&& latest.image != current_image
{
Some(latest.image.clone())
} else {
None
}
})
}
//
impl Resolve<WriteArgs> for BatchCheckStackForUpdate {
+50 -91
View File
@@ -13,6 +13,7 @@ use komodo_client::{
permission::PermissionLevel,
procedure::{Procedure, ProcedureStage},
repo::Repo,
resource::ResourceQuery,
stack::Stack,
update::Update,
user::procedure_user,
@@ -33,7 +34,10 @@ use crate::{
state::{all_resources_cache, db_client},
};
use super::update::{init_execution_update, update_update};
use super::{
query::get_all_tags,
update::{init_execution_update, update_update},
};
pub async fn execute_procedure(
procedure: &Procedure,
@@ -109,95 +113,41 @@ async fn execute_procedure_stage(
update: &Mutex<Update>,
) -> anyhow::Result<()> {
let mut executions = Vec::with_capacity(_executions.capacity());
for execution in _executions {
match execution {
Execution::BatchRunAction(exec) => {
extend_batch_exection::<BatchRunAction>(
&exec.pattern,
&mut executions,
)
.await?;
// Expands the batch executions into their matching
// single executions, and passes through the rest.
macro_rules! expand_batch_executions {
($($Variant:ident),* $(,)?) => {
for execution in _executions {
match execution {
$(
Execution::$Variant(exec) => {
extend_batch_execution::<$Variant>(
&exec.pattern,
&exec.tags,
&mut executions,
)
.await?;
}
)*
execution => executions.push(execution),
}
}
Execution::BatchRunProcedure(exec) => {
extend_batch_exection::<BatchRunProcedure>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchRunBuild(exec) => {
extend_batch_exection::<BatchRunBuild>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchCloneRepo(exec) => {
extend_batch_exection::<BatchCloneRepo>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchPullRepo(exec) => {
extend_batch_exection::<BatchPullRepo>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchBuildRepo(exec) => {
extend_batch_exection::<BatchBuildRepo>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchDeploy(exec) => {
extend_batch_exection::<BatchDeploy>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchDestroyDeployment(exec) => {
extend_batch_exection::<BatchDestroyDeployment>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchDeployStack(exec) => {
extend_batch_exection::<BatchDeployStack>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchDeployStackIfChanged(exec) => {
extend_batch_exection::<BatchDeployStackIfChanged>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchPullStack(exec) => {
extend_batch_exection::<BatchPullStack>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchDestroyStack(exec) => {
extend_batch_exection::<BatchDestroyStack>(
&exec.pattern,
&mut executions,
)
.await?;
}
execution => executions.push(execution),
}
};
}
expand_batch_executions!(
BatchRunAction,
BatchRunProcedure,
BatchRunBuild,
BatchCloneRepo,
BatchPullRepo,
BatchBuildRepo,
BatchDeploy,
BatchDestroyDeployment,
BatchDeployStack,
BatchDeployStackIfChanged,
BatchPullStack,
BatchDestroyStack,
);
let futures = executions.into_iter().map(|execution| async move {
let now = Instant::now();
add_line_to_update(
@@ -550,18 +500,27 @@ async fn add_line_to_update(update: &Mutex<Update>, line: &str) {
};
}
async fn extend_batch_exection<E: ExtendBatch>(
async fn extend_batch_execution<E: ExtendBatch>(
pattern: &str,
tags: &[String],
executions: &mut Vec<Execution>,
) -> anyhow::Result<()> {
let all_tags = if tags.is_empty() {
Vec::new()
} else {
get_all_tags(None).await?
};
let more = list_full_for_user_using_pattern::<E::Resource>(
pattern,
Default::default(),
ResourceQuery {
tags: tags.to_vec(),
..Default::default()
},
None,
None,
procedure_user(),
PermissionLevel::Read.into(),
&[],
&all_tags,
)
.await?
.into_iter()
+2 -2
View File
@@ -19,7 +19,7 @@ mod monitor;
mod network;
mod periphery;
mod permission;
mod reporting;
mod report;
mod resource;
mod schedule;
mod stack;
@@ -72,7 +72,7 @@ async fn app() -> anyhow::Result<()> {
resource::spawn_action_state_refresh_loop();
schedule::spawn_schedule_executor();
helpers::prune::spawn_prune_loop();
reporting::spawn_reporting_loop();
report::spawn_reporting_loop();
}
.instrument(startup_span)
.await;
+116 -119
View File
@@ -9,24 +9,26 @@
//! - Automatic container environment detection
//! - Interface validation (existence and UP state)
//! - Gateway discovery from routing tables or network configuration
//! - Safe default route modification with privilege checking
//! - Atomic default route replacement (never leaves the container without a default route)
//! - Comprehensive error handling and logging
use std::net::Ipv4Addr;
use anyhow::{Context, anyhow};
use tokio::process::Command;
use tracing::{debug, info, trace, warn};
/// Standard gateway addresses to test for Docker networks
const DOCKER_GATEWAY_CANDIDATES: &[&str] = &[".1", ".254"];
/// Container environment detection files
const DOCKERENV_FILE: &str = "/.dockerenv";
const CONTAINERENV_FILE: &str = "/run/.containerenv";
const CGROUP_FILE: &str = "/proc/1/cgroup";
/// Check if running in container environment
fn is_container_environment() -> bool {
// Check for Docker-specific indicators
if std::path::Path::new(DOCKERENV_FILE).exists() {
// Check for Docker / Podman specific indicators
if std::path::Path::new(DOCKERENV_FILE).exists()
|| std::path::Path::new(CONTAINERENV_FILE).exists()
{
return true;
}
@@ -35,7 +37,9 @@ fn is_container_environment() -> bool {
return true;
}
// Check cgroup for container runtime indicators
// Check cgroup for container runtime indicators.
// Only reliable on cgroup v1 - on cgroup v2 the file
// is usually just `0::/`, so the checks above carry detection.
if let Ok(content) = std::fs::read_to_string(CGROUP_FILE)
&& (content.contains("docker") || content.contains("containerd"))
{
@@ -51,23 +55,24 @@ pub async fn configure_internet_gateway() {
let config = core_config();
if config.internet_interface.is_empty() {
debug!("No interface specified, using default routing");
return;
}
if !is_container_environment() {
debug!("Not in container, skipping network configuration");
return;
}
if !config.internet_interface.is_empty() {
debug!(
"Configuring internet interface: {}",
config.internet_interface
);
if let Err(e) =
configure_manual_interface(&config.internet_interface).await
{
warn!("Failed to configure internet gateway: {e:#}");
}
} else {
debug!("No interface specified, using default routing");
debug!(
"Configuring internet interface: {}",
config.internet_interface
);
if let Err(e) =
configure_manual_interface(&config.internet_interface).await
{
warn!("Failed to configure internet gateway: {e:#}");
}
}
@@ -91,7 +96,10 @@ async fn configure_manual_interface(
let interface_info =
String::from_utf8_lossy(&interface_check.stdout);
if !interface_info.contains("state UP") {
// tun / wireguard style interfaces report `state UNKNOWN` while operational
if !interface_info.contains("state UP")
&& !interface_info.contains("state UNKNOWN")
{
return Err(anyhow!(
"Interface '{}' is not UP. Please ensure the interface is enabled and connected",
interface_name
@@ -159,7 +167,7 @@ async fn find_gateway(
.context("Failed to get routes for interface")?;
if route_output.status.success() {
let routes = String::from_utf8(route_output.stdout)?;
let routes = String::from_utf8_lossy(&route_output.stdout);
trace!("Routes for {}: {}", interface_name, routes.trim());
// Look for routes with gateway
@@ -179,60 +187,75 @@ async fn find_gateway(
}
}
// Derive gateway from network configuration (Docker standard: .1)
if let Some(network_base) = ip_cidr.split('/').next() {
let ip_parts: Vec<&str> = network_base.split('.').collect();
if ip_parts.len() == 4 {
let potential_gateways: Vec<String> = DOCKER_GATEWAY_CANDIDATES
.iter()
.map(|suffix| {
format!(
"{}.{}.{}{}",
ip_parts[0], ip_parts[1], ip_parts[2], suffix
)
})
.collect();
// Derive candidates from the interface subnet
let candidates = derive_gateway_candidates(ip_cidr)
.with_context(|| {
format!(
"Could not determine gateway for interface '{interface_name}' in network '{ip_cidr}'"
)
})?;
for gateway in potential_gateways {
trace!(
"Testing potential gateway {} for {}",
gateway, interface_name
);
for gateway in &candidates {
trace!(
"Testing potential gateway {} for {}",
gateway, interface_name
);
// Check if gateway is reachable
let route_test = Command::new("ip")
.args(["route", "get", &gateway, "dev", interface_name])
.output()
.await;
// Note: `ip route get` only confirms the address is on-link
// for this interface, not that a router actually answers there.
let route_test = Command::new("ip")
.args(["route", "get", gateway, "dev", interface_name])
.output()
.await;
if let Ok(output) = route_test
&& output.status.success()
{
trace!(
"Gateway {} is reachable via {}",
gateway, interface_name
);
return Ok(gateway.to_string());
}
// Fallback: assume .1 is gateway (Docker standard)
if gateway.ends_with(".1") {
trace!(
"Assuming Docker gateway {} for {}",
gateway, interface_name
);
return Ok(gateway.to_string());
}
}
if let Ok(output) = route_test
&& output.status.success()
{
trace!("Gateway {} is on-link via {}", gateway, interface_name);
return Ok(gateway.clone());
}
}
Err(anyhow!(
"Could not determine gateway for interface '{}' in network '{}'. \
Ensure the interface is properly configured with a valid gateway",
interface_name,
ip_cidr
))
// Fall back to the first host address (Docker standard)
let gateway = candidates
.into_iter()
.next()
.context("No gateway candidates derived")?;
trace!(
"Assuming Docker gateway {} for {}",
gateway, interface_name
);
Ok(gateway)
}
/// Derive candidate gateway addresses for the interface subnet:
/// the first and last usable host addresses. Docker assigns the
/// gateway the first host address of the subnet regardless of prefix length.
fn derive_gateway_candidates(
ip_cidr: &str,
) -> anyhow::Result<Vec<String>> {
let (ip, prefix) = ip_cidr
.split_once('/')
.context("Address is not in CIDR (ip/prefix) format")?;
let ip: Ipv4Addr =
ip.parse().context("Failed to parse IPv4 address")?;
let prefix: u32 =
prefix.parse().context("Failed to parse CIDR prefix")?;
if !(1..=30).contains(&prefix) {
return Err(anyhow!(
"Subnet /{prefix} cannot contain a distinct gateway address"
));
}
let mask = u32::MAX << (32 - prefix);
let network = u32::from(ip) & mask;
let first_host = network + 1;
let last_host = (network | !mask) - 1;
Ok(vec![
Ipv4Addr::from(first_host).to_string(),
Ipv4Addr::from(last_host).to_string(),
])
}
/// Set default gateway to use specified interface
@@ -245,48 +268,33 @@ async fn set_default_gateway(
gateway, interface_name
);
// Check if we have network privileges
if !check_network_privileges().await {
warn!(
"⚠️ Container lacks network privileges (NET_ADMIN capability required)"
);
warn!(
"Add 'cap_add: [\"NET_ADMIN\"]' to your docker-compose.yaml"
);
return Err(anyhow!(
"Insufficient network privileges to modify routing table. \
Container needs NET_ADMIN capability to configure network interfaces"
));
}
// Remove existing default routes
let remove_default = Command::new("sh")
.args(["-c", "ip route del default 2>/dev/null || true"])
.output()
.await;
if let Ok(output) = remove_default
&& output.status.success()
{
trace!("Removed existing default routes");
}
// Add new default route
let add_default_cmd = format!(
"ip route add default via {gateway} dev {interface_name}"
);
trace!("Adding default route: {}", add_default_cmd);
let add_default = Command::new("sh")
.args(["-c", &add_default_cmd])
// `replace` swaps the default route atomically, so a failure
// never leaves the container without any default route.
let replace = Command::new("ip")
.args([
"route",
"replace",
"default",
"via",
gateway,
"dev",
interface_name,
])
.output()
.await
.context("Failed to add default route")?;
.context("Failed to run ip route replace")?;
if !add_default.status.success() {
let error = String::from_utf8_lossy(&add_default.stderr)
.trim()
.to_string();
if !replace.status.success() {
let error =
String::from_utf8_lossy(&replace.stderr).trim().to_string();
if error.contains("Operation not permitted") {
warn!(
"⚠️ Container lacks network privileges (NET_ADMIN capability required)"
);
warn!(
"Add 'cap_add: [\"NET_ADMIN\"]' to your docker-compose.yaml"
);
}
return Err(anyhow!(
"❌ Failed to set default gateway via '{}': {}. \
Verify interface configuration and network permissions",
@@ -298,14 +306,3 @@ async fn set_default_gateway(
trace!("Default gateway set to {} via {}", gateway, interface_name);
Ok(())
}
/// Check if we have sufficient network privileges
async fn check_network_privileges() -> bool {
// Try to test NET_ADMIN capability with a harmless route operation
let capability_test = Command::new("sh")
.args(["-c", "ip route add 198.51.100.1/32 dev lo 2>/dev/null && ip route del 198.51.100.1/32 dev lo 2>/dev/null"])
.output()
.await;
matches!(capability_test, Ok(output) if output.status.success())
}
@@ -1,9 +1,9 @@
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Context;
use anyhow::{Context, anyhow};
use async_timing_util::{Timelength, wait_until_timelength};
use komodo_client::entities::{
ResourceTargetVariant, core_report::CoreReport,
ResourceTargetVariant, report::KomodoReport,
};
use mogh_pki::{
PkiKind, RotatableKeyPair, SpkiPublicKey,
@@ -60,12 +60,12 @@ async fn report(
.get("https://mogh.tech/report/public_key")
.send()
.await
.context("Failed to query for reporting endpoint public key for signature.")?
.context("Failed to query for reporting endpoint public key for signature")?
.error_for_status()
.context("Failed response for reporting endpoint public key for signature.")?
.context("Failed response for reporting endpoint public key for signature")?
.text()
.await
.context("Failed to get reporting endpoint public key for signature.")?;
.context("Failed to get reporting endpoint public key for signature")?;
let endpoint_public_key =
SpkiPublicKey::maybe_pem_to_raw_bytes(&endpoint_public_key)
@@ -112,8 +112,9 @@ async fn report(
.into_iter()
.collect();
let report = CoreReport {
report_public_key: keys.public().to_string(),
let report = KomodoReport {
public_key: keys.public().to_string(),
version: String::from(env!("CARGO_PKG_VERSION")),
users,
count,
};
@@ -137,7 +138,7 @@ async fn report(
.generate_signature()
.context("Failed to generate report signature")?;
client
let res = client
.post("https://mogh.tech/report/komodo")
.header("x-api-signature", signature)
.header("x-api-timestamp", timestamp)
@@ -145,9 +146,18 @@ async fn report(
.body(serialized)
.send()
.await
.context("Failed to post report.")?
.error_for_status()
.context("Failed response for report post.")?;
.context("Failed to post report")?;
Ok(())
let status = res.status();
if status.is_success() {
return Ok(());
}
let text = res
.text()
.await
.context("Failed report, and failed to get response body")?;
Err(anyhow!("{status}: {text}"))
}
+1
View File
@@ -104,6 +104,7 @@ impl super::KomodoResource for Server {
state: status.as_ref().map(|s| s.state).unwrap_or_default(),
err: status.as_ref().and_then(|s| s.err.clone()),
stats: system_stats.map(Into::into),
alerting_thresholds: (&server.config).into(),
core_count: system_info.and_then(|i| i.core_count),
logical_core_count: system_info
.and_then(|i| i.logical_core_count),
+38 -28
View File
@@ -106,33 +106,38 @@ impl super::KomodoResource for Stack {
.services
.iter()
.map(|current_service| {
let latest_service = stack
.info
.latest_services
.iter()
.find(|latest_service| {
current_service.service == latest_service.service_name
});
let latest_image = if let Some(latest_image) =
latest_service.as_ref().map(|s| &s.image)
&& latest_image != &current_service.image
{
Some(latest_image.to_string())
} else {
None
};
let update_available = current_service
.image_digests
.as_ref()
.map(|current_digests| {
stack
.info
.latest_services
.iter()
.find_map(|latest_service| {
if current_service.service
== latest_service.service_name
{
latest_service
.image_digest
.as_ref()?
.update_available(current_digests)
.into()
} else {
None
}
})
.unwrap_or_default()
.and_then(|current_digests| {
latest_service.as_ref().and_then(|latest_service| {
latest_service
.image_digest
.as_ref()?
.update_available(current_digests)
.into()
})
})
.unwrap_or_default();
StackServiceWithUpdate {
service: current_service.service.clone(),
image: current_service.image.clone(),
latest_image,
update_available,
}
})
@@ -140,16 +145,18 @@ impl super::KomodoResource for Stack {
})
.unwrap_or_default();
let default_git = (
stack.config.git_provider,
stack.config.repo,
stack.config.branch,
stack.config.git_https,
String::new(),
);
let default_git = || {
(
stack.config.git_provider,
stack.config.repo,
stack.config.branch,
stack.config.git_https,
String::new(),
)
};
let (git_provider, repo, branch, git_https, linked_repo_name) =
if stack.config.linked_repo.is_empty() {
default_git
default_git()
} else {
all_resources_cache()
.load()
@@ -164,7 +171,7 @@ impl super::KomodoResource for Stack {
r.name.clone(),
)
})
.unwrap_or(default_git)
.unwrap_or_else(default_git)
};
// This is only true if it is KNOWN to be true. so other cases are false.
@@ -239,6 +246,9 @@ impl super::KomodoResource for Stack {
branch,
latest_hash: stack.info.latest_hash,
deployed_hash: stack.info.deployed_hash,
auto_update_all_services: stack
.config
.auto_update_all_services,
},
}
}
+7 -2
View File
@@ -109,14 +109,19 @@ pub async fn on_startup() {
// Configure manual network interface if specified
network::configure_internet_gateway().await;
// Run legacy database fixes
tokio::join!(
clean_up_server_templates(),
v2_init_missing_resource_info(),
);
// Standard startup tasks
tokio::join!(
in_progress_update_cleanup(),
open_alert_cleanup(),
action_api_key_cleanup(),
ensure_first_server_and_builder(),
ensure_init_user_and_resources(),
clean_up_server_templates(),
v2_init_missing_resource_info(),
);
}
+7 -2
View File
@@ -1,7 +1,7 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
FROM rust:1.97.1-trixie AS builder
RUN cargo install cargo-strip
FROM rust:1.98.0-trixie AS builder
RUN cargo install cargo-strip cargo-edit
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -11,6 +11,11 @@ COPY ./client/periphery ./client/periphery
COPY ./bin/periphery ./bin/periphery
COPY ./xtask ./xtask
# Set Version
ARG VERSION="0.0.0"
ARG IMAGE_TAG=""
RUN cargo set-version -p komodo_periphery ${VERSION}${IMAGE_TAG:+-${IMAGE_TAG}}
# Compile app
RUN cargo build -p komodo_periphery --release && cargo strip
+23 -2
View File
@@ -304,15 +304,36 @@ impl Resolve<crate::api::Args> for build::Build {
// Do this in fallible block to ensure
// cancel token removed from cache on failure.
let command = async {
// Add VERSION to build args (if not already there)
let mut build_args = environment_vars_from_str(build_args)
.context("Invalid build_args")?;
.context("Invalid build_args")?;
// Add IMAGE_NAME to build args
if !build_args.iter().any(|a| a.variable == "IMAGE_NAME") {
build_args.push(EnvironmentVar {
variable: String::from("IMAGE_NAME"),
value: if build.config.image_name.is_empty() {
build.name.clone()
} else {
build.config.image_name.clone()
},
});
}
// Maybe Add IMAGE_TAG to build args
if !build.config.image_tag.is_empty()
&& !build_args.iter().any(|a| a.variable == "IMAGE_TAG") {
build_args.push(EnvironmentVar {
variable: String::from("IMAGE_TAG"),
value: build.config.image_tag.clone(),
});
}
// Add VERSION to build args
if !build_args.iter().any(|a| a.variable == "VERSION") {
build_args.push(EnvironmentVar {
variable: String::from("VERSION"),
value: build.config.version.to_string(),
});
}
let build_args = parse_build_args(&build_args);
let secret_args = environment_vars_from_str(secret_args)
+2 -2
View File
@@ -61,7 +61,7 @@ impl Resolve<crate::api::Args> for GetComposeLog {
run_komodo_standard_command(
"Get Stack Log",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
@@ -101,7 +101,7 @@ impl Resolve<crate::api::Args> for GetComposeLogSearch {
run_komodo_shell_command(
"Search Stack Log",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
+2 -2
View File
@@ -69,7 +69,7 @@ impl Resolve<crate::api::Args> for GetContainerLog {
run_komodo_standard_command(
"Get container log",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
@@ -104,7 +104,7 @@ impl Resolve<crate::api::Args> for GetContainerLogSearch {
run_komodo_shell_command(
"Get container log grep",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
+2 -2
View File
@@ -90,7 +90,7 @@ impl Resolve<crate::api::Args> for GetSwarmServiceLog {
run_komodo_standard_command(
"Get Swarm Service Log",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
@@ -141,7 +141,7 @@ impl Resolve<crate::api::Args> for GetSwarmServiceLogSearch {
run_komodo_shell_command(
"Search Swarm Service Log",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
+1 -1
View File
@@ -21,7 +21,7 @@ pub async fn list_compose_projects()
let res = run_komodo_standard_command(
"List Projects",
format!("{docker_compose} ls --all --format json"),
CommandOptions::default().timeout(Duration::from_secs(1)),
CommandOptions::default().timeout(Duration::from_secs(5)),
)
.await;
+2 -2
View File
@@ -26,7 +26,7 @@ pub async fn list_swarm_configs(
let res = run_komodo_standard_command(
"List Swarm Configs",
"docker config ls --format json",
CommandOptions::default().timeout(Duration::from_secs(1)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await;
@@ -73,7 +73,7 @@ pub async fn inspect_swarm_config(
// `--` so a name beginning with `-` is not parsed as a flag.
// The quotes alone give no protection, as they are stripped when lexed.
format!(r#"docker config inspect -- "{config}""#),
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await;
+3 -3
View File
@@ -74,7 +74,7 @@ pub async fn list_swarm_stacks(
let res = run_komodo_standard_command(
"List Swarm Stacks",
"docker stack ls --format json",
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await;
@@ -133,7 +133,7 @@ pub async fn list_swarm_stack_service_ids(
let res = run_komodo_standard_command(
"List Swarm Stack Services",
format!("docker stack services --format json -- {stack}"),
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await;
@@ -163,7 +163,7 @@ pub async fn list_swarm_stack_tasks(
let res = run_komodo_standard_command(
"List Swarm Stack Tasks",
format!("docker stack ps --format json --no-trunc -- {stack}"),
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await;
-1
View File
@@ -1,6 +1,5 @@
use anyhow::Context;
use bollard::query_parameters::ListTasksOptions;
use komodo_client::entities::docker::task::*;
use super::*;
+1 -1
View File
@@ -85,7 +85,7 @@ impl StatsClient {
let mut network_ingress_bytes: u64 = 0;
let mut network_egress_bytes: u64 = 0;
for (_, network) in self.networks.iter() {
for network in self.networks.values() {
network_ingress_bytes += network.received();
network_egress_bytes += network.transmitted();
}
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "komodo_client"
description = "Client for the Komodo build and deployment system"
version.workspace = true
version = "2.3.3"
edition.workspace = true
license.workspace = true
homepage.workspace = true
+8 -6
View File
@@ -256,14 +256,14 @@ impl MaybeNone for BuilderConfigDiff {
impl From<PartialBuilderConfig> for BuilderConfig {
fn from(value: PartialBuilderConfig) -> BuilderConfig {
match value {
PartialBuilderConfig::Url(server) => {
BuilderConfig::Url(server.into())
PartialBuilderConfig::Url(config) => {
BuilderConfig::Url(config.into())
}
PartialBuilderConfig::Server(server) => {
BuilderConfig::Server(server.into())
PartialBuilderConfig::Server(config) => {
BuilderConfig::Server(config.into())
}
PartialBuilderConfig::Aws(builder) => {
BuilderConfig::Aws(builder.into())
PartialBuilderConfig::Aws(config) => {
BuilderConfig::Aws(config.into())
}
}
}
@@ -469,11 +469,13 @@ pub struct ServerBuilderConfig {
deserialize_with = "string_list_deserializer"
)]
#[partial_attr(serde(
default,
alias = "server_id",
alias = "server",
alias = "servers",
deserialize_with = "option_string_list_deserializer"
))]
#[builder(default)]
#[cfg_attr(
feature = "schemars",
partial_attr(schemars(rename = "servers"))
+2 -2
View File
@@ -35,8 +35,6 @@ pub mod build;
pub mod builder;
/// [core config][config::core] and [periphery config][config::periphery]
pub mod config;
/// Subtypes of [CoreReport][core_report::CoreReport]
pub mod core_report;
/// Subtypes of [Deployment][deployment::Deployment].
pub mod deployment;
/// Networks, Images, Containers.
@@ -53,6 +51,8 @@ pub mod procedure;
pub mod provider;
/// Subtypes of [Repo][repo::Repo].
pub mod repo;
/// Subtypes of [CoreReport][core_report::CoreReport]
pub mod report;
/// Subtypes of [Resource][resource::Resource].
pub mod resource;
/// Subtypes of [Schedule][schedule::Schedule]
@@ -4,14 +4,16 @@ use serde::{Deserialize, Serialize};
use crate::entities::ResourceTargetVariant;
/// Semi-anonymous core data reporting.
/// Reports only include the reporting-specific core public key
/// (not the keys used for server connection).
/// Semi-anonymous Komodo Core reporting.
/// Reports only include the reporting-specific public key
/// (not the key pair used for server connection).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CoreReport {
pub struct KomodoReport {
/// Reporting specific public key.
/// Must match public key obtained through request signature.
pub report_public_key: String,
pub public_key: String,
/// The Komodo Core version string
pub version: String,
/// The total number of users
pub users: u64,
/// Resource counts by type
+45
View File
@@ -44,6 +44,8 @@ pub struct ServerListItemInfo {
pub err: Option<_Serror>,
/// System stats, if available
pub stats: Option<MinimalSystemStats>,
/// The server alerting thresholds
pub alerting_thresholds: ServerAlertingThresholds,
/// The server's number of physical cores.
pub core_count: Option<u32>,
/// The server's number of logical cores.
@@ -363,6 +365,49 @@ impl utoipa::PartialSchema for PartialServerConfig {
#[cfg(feature = "utoipa")]
impl utoipa::ToSchema for PartialServerConfig {}
/// Just the server alerting thresholds
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ServerAlertingThresholds {
/// The percentage threshhold which triggers WARNING state for CPU.
#[serde(default = "default_cpu_warning")]
pub cpu_warning: f32,
/// The percentage threshhold which triggers CRITICAL state for CPU.
#[serde(default = "default_cpu_critical")]
pub cpu_critical: f32,
/// The percentage threshhold which triggers WARNING state for MEM.
#[serde(default = "default_mem_warning")]
pub mem_warning: f64,
/// The percentage threshhold which triggers CRITICAL state for MEM.
#[serde(default = "default_mem_critical")]
pub mem_critical: f64,
/// The percentage threshhold which triggers WARNING state for DISK.
#[serde(default = "default_disk_warning")]
pub disk_warning: f64,
/// The percentage threshhold which triggers CRITICAL state for DISK.
#[serde(default = "default_disk_critical")]
pub disk_critical: f64,
}
impl From<&ServerConfig> for ServerAlertingThresholds {
fn from(config: &ServerConfig) -> Self {
ServerAlertingThresholds {
cpu_warning: config.cpu_warning,
cpu_critical: config.cpu_critical,
mem_warning: config.mem_warning,
mem_critical: config.mem_critical,
disk_warning: config.disk_warning,
disk_critical: config.disk_critical,
}
}
}
/// The health of a part of the server.
#[typeshare]
#[derive(Serialize, Deserialize, Default, Debug, Clone)]
+5 -1
View File
@@ -184,6 +184,8 @@ pub struct StackListItemInfo {
/// If deployed, will be `deployed_services`.
/// Otherwise, its `latest_services`
pub services: Vec<StackServiceWithUpdate>,
/// Whether stack has auto_update_all_services enabled.
pub auto_update_all_services: bool,
/// Whether the compose project is missing on the host.
/// Ie, it does not show up in `docker compose ls`.
/// If true, and the stack is not Down, this is an unhealthy state.
@@ -208,8 +210,10 @@ impl StackListItemInfo {
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct StackServiceWithUpdate {
pub service: String,
/// The service's image
/// The service's (current) image
pub image: String,
/// The latest image (if different than current)
pub latest_image: Option<String>,
/// Whether there is a newer image available for this service
pub update_available: bool,
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "komodo_client",
"version": "2.3.0",
"version": "2.3.3",
"description": "Komodo client package",
"homepage": "https://komo.do",
"main": "dist/lib.js",
@@ -13,10 +13,10 @@
"build": "tsc"
},
"dependencies": {
"mogh_auth_client": "^1.7.0"
"mogh_auth_client": "^1.7.1"
},
"devDependencies": {
"typescript": "^6.0.2"
"typescript": "^7.0.2"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+24 -5
View File
@@ -366,8 +366,10 @@ export type BatchCheckDeploymentForUpdateResponse = CheckDeploymentForUpdateResp
export interface StackServiceWithUpdate {
service: string;
/** The service's image */
/** The service's (current) image */
image: string;
/** The latest image (if different than current) */
latest_image?: string;
/** Whether there is a newer image available for this service */
update_available: boolean;
}
@@ -2858,10 +2860,7 @@ export interface SystemInformation {
kernel?: string;
/** Physical core count */
core_count?: number;
/**
* Logical core count. If available,
* used to interpret system load accurately.
*/
/** Logical core count. */
logical_core_count?: number;
/** System hostname based off DNS */
host_name?: string;
@@ -5531,6 +5530,22 @@ export interface MinimalSystemStats {
refresh_list_ts: I64;
}
/** Just the server alerting thresholds */
export interface ServerAlertingThresholds {
/** The percentage threshhold which triggers WARNING state for CPU. */
cpu_warning: number;
/** The percentage threshhold which triggers CRITICAL state for CPU. */
cpu_critical: number;
/** The percentage threshhold which triggers WARNING state for MEM. */
mem_warning: number;
/** The percentage threshhold which triggers CRITICAL state for MEM. */
mem_critical: number;
/** The percentage threshhold which triggers WARNING state for DISK. */
disk_warning: number;
/** The percentage threshhold which triggers CRITICAL state for DISK. */
disk_critical: number;
}
export interface ServerListItemInfo {
/** The server's state. */
state: ServerState;
@@ -5541,6 +5556,8 @@ export interface ServerListItemInfo {
err?: _Serror;
/** System stats, if available */
stats?: MinimalSystemStats;
/** The server alerting thresholds */
alerting_thresholds: ServerAlertingThresholds;
/** The server's number of physical cores. */
core_count?: number;
/** The server's number of logical cores. */
@@ -5650,6 +5667,8 @@ export interface StackListItemInfo {
* Otherwise, its `latest_services`
*/
services: StackServiceWithUpdate[];
/** Whether stack has auto_update_all_services enabled. */
auto_update_all_services: boolean;
/**
* Whether the compose project is missing on the host.
* Ie, it does not show up in `docker compose ls`.
+129 -8
View File
@@ -2,19 +2,140 @@
# yarn lockfile v1
"@typescript/typescript-aix-ppc64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz#cdc7ce81d60f1e09034960ddfb1fb880d7a776b6"
integrity sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==
"@typescript/typescript-darwin-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz#a55fdfcfa58df58d27db2237cde6a5c1e35a7235"
integrity sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==
"@typescript/typescript-darwin-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz#38d1c9172800a91d707bec64d2a370a016634db4"
integrity sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==
"@typescript/typescript-freebsd-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz#f1ff8810030b35d2b5be0db6a2dc650460ea94fa"
integrity sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==
"@typescript/typescript-freebsd-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz#3d86b03f353c5b1ba95162eb6ce35533bfc294bd"
integrity sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==
"@typescript/typescript-linux-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz#d9334d96d6dac6ff85da9c865588948de939e91f"
integrity sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==
"@typescript/typescript-linux-arm@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz#ad94b41e1aee2a4dcc6a298c7b67c43345fde32e"
integrity sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==
"@typescript/typescript-linux-loong64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz#2965aee4fc873360139d893daafe6397a29138ad"
integrity sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==
"@typescript/typescript-linux-mips64el@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz#1a887a311bed3a833f80bfd4a9ed37c271936cf0"
integrity sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==
"@typescript/typescript-linux-ppc64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz#8b63c9b2f445b393eb4e43ec21da225dade3577d"
integrity sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==
"@typescript/typescript-linux-riscv64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz#b6e8a35c289b3ea97a92a41d461aaeed0d3b36e1"
integrity sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==
"@typescript/typescript-linux-s390x@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz#2ef96693be4861f6d17965427e5b009cbbed1a3e"
integrity sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==
"@typescript/typescript-linux-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz#73269cb0baba50aea0ca060445a6b88e583f1ce2"
integrity sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==
"@typescript/typescript-netbsd-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz#3a3649f97fafa210b4e6e3798c15e06605c8a901"
integrity sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==
"@typescript/typescript-netbsd-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz#47ec59491a40c470d2807dc4d2b825528fd979ab"
integrity sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==
"@typescript/typescript-openbsd-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz#796be8da0bd989d8a3fb96f2801e38a8365b4baf"
integrity sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==
"@typescript/typescript-openbsd-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz#d37fe2a729eb942c076c454ee7f1815faf7d560f"
integrity sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==
"@typescript/typescript-sunos-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz#aba8d3464c3565a7044789baba96916bd4ab2c88"
integrity sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==
"@typescript/typescript-win32-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz#b9de50a17196383f62620b5f9d0a2f34ad3b60d7"
integrity sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==
"@typescript/typescript-win32-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz#cf3b7b0d6ce5635daca4c8e01c189cdcde47ec3c"
integrity sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==
jwt-decode@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b"
integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==
mogh_auth_client@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/mogh_auth_client/-/mogh_auth_client-1.7.0.tgz#a016c8dafd0f73633098d41aa95889c444e481d9"
integrity sha512-v4TJVNIiFYBLd70yGw/eS37E15he4I5w3poj5X7W75D+m6ry9gbhtlePp5elRbqjDZ/WXkOZ/XYCHqGYb/PQ2A==
mogh_auth_client@^1.7.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/mogh_auth_client/-/mogh_auth_client-1.7.1.tgz#98469ee4cff4c47c991235acd0b96c1b27c5a1e4"
integrity sha512-IWT7ET5QmqNGqrGgNy4oyF4LMb1TeLXQhDw6AXzd5S/WfWC9JJx7xjnIdJFqXpl3zoZ+va773RrPB5ufGPkVVQ==
dependencies:
jwt-decode "^4.0.0"
typescript@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.2.tgz#0b1bfb15f68c64b97032f3d78abbf98bdbba501f"
integrity sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==
typescript@^7.0.2:
version "7.0.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-7.0.2.tgz#9ec773d7954a8c182c17cc5bbd575aa28bc51582"
integrity sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==
optionalDependencies:
"@typescript/typescript-aix-ppc64" "7.0.2"
"@typescript/typescript-darwin-arm64" "7.0.2"
"@typescript/typescript-darwin-x64" "7.0.2"
"@typescript/typescript-freebsd-arm64" "7.0.2"
"@typescript/typescript-freebsd-x64" "7.0.2"
"@typescript/typescript-linux-arm" "7.0.2"
"@typescript/typescript-linux-arm64" "7.0.2"
"@typescript/typescript-linux-loong64" "7.0.2"
"@typescript/typescript-linux-mips64el" "7.0.2"
"@typescript/typescript-linux-ppc64" "7.0.2"
"@typescript/typescript-linux-riscv64" "7.0.2"
"@typescript/typescript-linux-s390x" "7.0.2"
"@typescript/typescript-linux-x64" "7.0.2"
"@typescript/typescript-netbsd-arm64" "7.0.2"
"@typescript/typescript-netbsd-x64" "7.0.2"
"@typescript/typescript-openbsd-arm64" "7.0.2"
"@typescript/typescript-openbsd-x64" "7.0.2"
"@typescript/typescript-sunos-x64" "7.0.2"
"@typescript/typescript-win32-arm64" "7.0.2"
"@typescript/typescript-win32-x64" "7.0.2"
-19
View File
@@ -498,25 +498,6 @@ webhook_secret = "a_random_webhook_secret"
## Default: empty (none)
webhook_base_url = ""
## Configure Github webhook app. Enables webhook management apis.
## <INSERT LINK TO GUIDE>
## Env: KOMODO_GITHUB_WEBHOOK_APP_APP_ID or KOMODO_GITHUB_WEBHOOK_APP_APP_ID_FILE
# github_webhook_app.app_id = 1234455 # Find on the app page.
## Env:
## - KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_IDS or KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_IDS_FILE
## - KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_NAMESPACES
# github_webhook_app.installations = [
# ## Find the id after installing the app to user / organization. "namespace" is the username / organization name.
# { id = 1234, namespace = "mbecker20" }
# ]
## The path to Github webhook app private key. <INSERT LINK TO GUIDE>
## This is defaulted to `/github/private-key.pem`, and doesn't need to be changed if running core in Docker.
## Just mount the private key pem file on the host to `/github/private-key.pem` in the container.
## Eg. `/your/path/to/key.pem : /github/private-key.pem`
## Env: KOMODO_GITHUB_WEBHOOK_APP_PK_PATH
# github_webhook_app.pk_path = "/path/to/pk.pem"
###########
# LOGGING #
###########
+14 -6
View File
@@ -1,5 +1,8 @@
FROM node:22.12-alpine AS builder
ARG DOCSITE_URL
ARG DOCSITE_BASE_URL=/
WORKDIR /builder
COPY package.json yarn.lock ./
@@ -13,19 +16,24 @@ COPY ./docusaurus.config.ts ./docusaurus.config.ts
COPY ./sidebars.ts ./sidebars.ts
COPY ./tsconfig.json ./tsconfig.json
RUN yarn build
RUN DOCSITE_URL=${DOCSITE_URL} DOCSITE_BASE_URL=${DOCSITE_BASE_URL} yarn build
FROM caddy:latest
# Copy into caddy image /srv
COPY --from=builder /builder/build /srv
ARG DOCSITE_BASE_URL=/
# Add a basic caddyfile serving http
RUN <<EOF cat > /etc/caddy/Caddyfile
# Copy into caddy image /srv, nested under the base url path
COPY --from=builder /builder/build /srv${DOCSITE_BASE_URL}
# Add a basic caddyfile serving http.
# BASE is the base url without its trailing slash (empty when base url is "/"),
# used to redirect e.g. /komodo -> /komodo/ so relative urls resolve correctly.
RUN BASE="${DOCSITE_BASE_URL%/}" && cat > /etc/caddy/Caddyfile <<EOF
:80 {
root * /srv
${BASE:+redir $BASE $BASE/ 308}
file_server
try_files {path} /index.html
try_files {path} {path}.html ${DOCSITE_BASE_URL}index.html
}
EOF
+2 -3
View File
@@ -11,11 +11,10 @@ const config: Config = {
favicon: "img/favicon.ico",
// Set the production url of your site here
url: "https://komo.do",
url: process.env.DOCSITE_URL || "https://komo.do",
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
// baseUrl: "/komodo/",
baseUrl: "/",
baseUrl: process.env.DOCSITE_BASE_URL || "/",
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.97.1-trixie as builder
FROM rust:1.98.0-trixie as builder
WORKDIR /builder
COPY . .
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.97.1-trixie as builder
FROM rust:1.98.0-trixie as builder
WORKDIR /builder
COPY . .
+836
View File
@@ -0,0 +1,836 @@
import { KOMODO_BASE_URL } from "@main";
import { KomodoClient, Types } from "komodo_client";
import {
AuthResponses,
ExecuteResponses,
ReadResponses,
UserResponses,
WriteResponses,
} from "komodo_client/dist/responses";
import {
UseMutationOptions,
UseQueryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { UsableResource } from "@types";
import { useToast } from "@ui/use-toast";
import { atom, useAtom } from "jotai";
import { atomFamily } from "jotai/utils";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { has_minimum_permissions, RESOURCE_TARGETS } from "./utils";
export const atomWithStorage = <T>(key: string, init: T) => {
const stored = localStorage.getItem(key);
const inner = atom(stored ? JSON.parse(stored) : init);
return atom(
(get) => get(inner),
(_, set, newValue) => {
set(inner, newValue);
localStorage.setItem(key, JSON.stringify(newValue));
}
);
};
type LoginTokens = {
/** Current User ID */
current: string | undefined;
/** Array of logged in user ids / tokens */
tokens: Array<Types.JwtResponse>;
};
const LOGIN_TOKENS_KEY = "komodo-auth-tokens-v1";
export const LOGIN_TOKENS = (() => {
const stored = localStorage.getItem(LOGIN_TOKENS_KEY);
let tokens: LoginTokens = stored
? JSON.parse(stored)
: { current: undefined, tokens: [] };
const update_local_storage = () => {
localStorage.setItem(LOGIN_TOKENS_KEY, JSON.stringify(tokens));
};
const accounts = () => {
const current = tokens.tokens.find((t) => t.user_id === tokens.current);
const filtered = tokens.tokens.filter((t) => t.user_id !== tokens.current);
return current ? [current, ...filtered] : filtered;
};
const add_and_change = (user_id: string, jwt: string) => {
const filtered = tokens.tokens.filter((t) => t.user_id !== user_id);
filtered.push({ user_id, jwt });
filtered.sort();
tokens = {
current: user_id,
tokens: filtered,
};
update_local_storage();
};
const remove = (user_id: string) => {
const filtered = tokens.tokens.filter((t) => t.user_id !== user_id);
tokens = {
current:
tokens.current === user_id ? filtered[0]?.user_id : tokens.current,
tokens: filtered,
};
update_local_storage();
};
const remove_all = () => {
tokens = {
current: undefined,
tokens: [],
};
update_local_storage();
};
const change = (to_id: string) => {
tokens = {
current: to_id,
tokens: tokens.tokens,
};
update_local_storage();
};
return {
jwt: () =>
tokens.current
? (tokens.tokens.find((t) => t.user_id === tokens.current)?.jwt ?? "")
: "",
accounts,
add_and_change,
remove,
remove_all,
change,
};
})();
export const komodo_client = () =>
KomodoClient(KOMODO_BASE_URL, {
type: "jwt",
params: { jwt: LOGIN_TOKENS.jwt() },
});
// ============== RESOLVER ==============
export const useLoginOptions = () => {
return useQuery({
queryKey: ["GetLoginOptions"],
queryFn: () => komodo_client().auth("GetLoginOptions", {}),
refetchInterval: 30_000,
});
};
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;
};
export const useUserInvalidate = () => {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ["GetUser"] });
};
};
export const useUserReset = () => {
const qc = useQueryClient();
return () => {
qc.resetQueries({ queryKey: ["GetUser"] });
};
};
export const useRead = <
T extends Types.ReadRequest["type"],
R extends Extract<Types.ReadRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseQueryOptions<
ReadResponses[R["type"]],
unknown,
ReadResponses[R["type"]],
(T | P)[]
>,
"queryFn" | "queryKey"
>,
>(
type: T,
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,
});
};
export const useInvalidate = () => {
const qc = useQueryClient();
return <
Type extends Types.ReadRequest["type"],
Params extends Extract<Types.ReadRequest, { type: Type }>["params"],
>(
...keys: Array<[Type] | [Type, Params]>
) => keys.forEach((key) => qc.invalidateQueries({ queryKey: key }));
};
export const useManageUser = <
T extends Types.UserRequest["type"],
R extends Extract<Types.UserRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<UserResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>,
>(
type: T,
config?: C
) => {
const { toast } = useToast();
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().user<T, R>(type, params),
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Auth error:", e);
const msg = e.result?.error ?? "Unknown error. See console.";
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Request ${type} Failed`,
description: `${msg_log}See console for details`,
variant: "destructive",
});
config?.onError && config.onError(e, v, c);
},
...config,
});
};
export const useWrite = <
T extends Types.WriteRequest["type"],
R extends Extract<Types.WriteRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<WriteResponses[R["type"]], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>,
>(
type: T,
config?: C
) => {
const { toast } = useToast();
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().write<T, R>(type, params),
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Write error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Write request ${type} failed`,
description: `${msg_log}See console for details`,
variant: "destructive",
});
config?.onError && config.onError(e, v, c);
},
...config,
});
};
export const useExecute = <
T extends Types.ExecuteRequest["type"],
R extends Extract<Types.ExecuteRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<ExecuteResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>,
>(
type: T,
config?: C
) => {
const { toast } = useToast();
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().execute<T, R>(type, params),
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Execute error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Execute request ${type} failed`,
description: `${msg_log}See console for details`,
variant: "destructive",
});
config?.onError && config.onError(e, v, c);
},
...config,
});
};
export const useAuth = <
T extends Types.AuthRequest["type"],
R extends Extract<Types.AuthRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<AuthResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>,
>(
type: T,
config?: C
) => {
const { toast } = useToast();
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().auth<T, R>(type, params),
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Auth error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Auth request ${type} failed`,
description: `${msg_log}See console for details`,
variant: "destructive",
});
config?.onError && config.onError(e, v, c);
},
...config,
});
};
// ============== UTILITY ==============
export const useResourceParamType = () => {
const type = useParams().type;
if (!type) return undefined;
if (type === "resource-syncs") return "ResourceSync";
return (type[0].toUpperCase() + type.slice(1, -1)) as UsableResource;
};
type ResourceMap = {
[Resource in UsableResource]: Types.ResourceListItem<unknown>[] | undefined;
};
export const useAllResources = (): ResourceMap => {
return {
Server: useRead("ListServers", {}).data,
Stack: useRead("ListStacks", {}).data,
Deployment: useRead("ListDeployments", {}).data,
Build: useRead("ListBuilds", {}).data,
Repo: useRead("ListRepos", {}).data,
Procedure: useRead("ListProcedures", {}).data,
Action: useRead("ListActions", {}).data,
Builder: useRead("ListBuilders", {}).data,
Alerter: useRead("ListAlerters", {}).data,
ResourceSync: useRead("ListResourceSyncs", {}).data,
};
};
// Returns true if Komodo has no resources.
export const useNoResources = () => {
const resources = useAllResources();
for (const target of RESOURCE_TARGETS) {
if (resources[target] && resources[target].length) {
return false;
}
}
return true;
};
/** returns function that takes a resource target and checks if it exists */
export const useCheckResourceExists = () => {
const resources = useAllResources();
return (target: Types.ResourceTarget) => {
return (
resources[target.type as UsableResource]?.some(
(resource) => resource.id === target.id
) || false
);
};
};
export const useFilterResources = <Info>(
resources?: Types.ResourceListItem<Info>[],
search?: string
) => {
const tags = useTagsFilter();
const searchSplit = search?.toLowerCase()?.split(" ") || [];
return (
resources?.filter(
(resource) =>
tags.every((tag: string) => resource.tags.includes(tag)) &&
(searchSplit.length > 0
? searchSplit.every((search) =>
resource.name.toLowerCase().includes(search)
)
: true)
) ?? []
);
};
export const usePushRecentlyViewed = ({ type, id }: Types.ResourceTarget) => {
const userInvalidate = useUserInvalidate();
const push = useManageUser("PushRecentlyViewed", {
onSuccess: userInvalidate,
}).mutate;
const exists = useRead(`List${type as UsableResource}s`, {}).data?.find(
(r) => r.id === id
)
? true
: false;
useEffect(() => {
exists && push({ resource: { type, id } });
}, [exists, push]);
return () => push({ resource: { type, id } });
};
export const useSetTitle = (more?: string) => {
const info = useRead("GetCoreInfo", {}).data;
const title = more ? `${more} | ${info?.title}` : info?.title;
useEffect(() => {
if (title) {
document.title = title;
}
}, [title]);
};
const tagsAtom = atomWithStorage<string[]>("tags-v0", []);
export const useTags = () => {
const [tags, setTags] = useAtom<string[]>(tagsAtom);
const add_tag = (tag_id: string) => setTags([...tags, tag_id]);
const remove_tag = (tag_id: string) =>
setTags(tags.filter((id) => id !== tag_id));
const toggle_tag = (tag_id: string) => {
if (tags.includes(tag_id)) {
remove_tag(tag_id);
} else {
add_tag(tag_id);
}
};
const clear_tags = () => setTags([]);
return {
tags,
add_tag,
remove_tag,
toggle_tag,
clear_tags,
};
};
export const useTagsFilter = () => {
const [tags] = useAtom<string[]>(tagsAtom);
return tags;
};
export type LocalStorageSetter<T> = (state: T) => T;
export const useLocalStorage = <T>(
key: string,
init: T
): [T, (state: T | LocalStorageSetter<T>) => void] => {
const stored = localStorage.getItem(key);
const parsed = stored ? (JSON.parse(stored) as T) : undefined;
const [state, inner_set] = useState<T>(parsed ?? init);
const set = (state: T | LocalStorageSetter<T>) => {
inner_set((prev_state) => {
const new_val =
typeof state === "function"
? (state as LocalStorageSetter<T>)(prev_state)
: state;
localStorage.setItem(key, JSON.stringify(new_val));
return new_val;
});
};
return [state, set];
};
export const useKeyListener = (listenKey: string, onPress: () => void) => {
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
// This will ignore Shift + listenKey if it is sent from input / textarea
const target = e.target as any;
if (target.matches("input") || target.matches("textarea")) return;
if (e.key === listenKey) {
e.preventDefault();
onPress();
}
};
document.addEventListener("keydown", keydown);
return () => document.removeEventListener("keydown", keydown);
});
};
export const useShiftKeyListener = (listenKey: string, onPress: () => void) => {
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
// This will ignore Shift + listenKey if it is sent from input / textarea
const target = e.target as any;
if (target.matches("input") || target.matches("textarea")) return;
if (e.shiftKey && e.key === listenKey) {
e.preventDefault();
onPress();
}
};
document.addEventListener("keydown", keydown);
return () => document.removeEventListener("keydown", keydown);
});
};
/** Listens for ctrl (or CMD on mac) + the listenKey */
export const useCtrlKeyListener = (listenKey: string, onPress: () => void) => {
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === listenKey) {
e.preventDefault();
onPress();
}
};
document.addEventListener("keydown", keydown);
return () => document.removeEventListener("keydown", keydown);
});
};
export interface PromptHotkeysConfig {
/** Function to call when Enter is pressed (confirm action) */
onConfirm?: () => void;
/** Function to call when Escape is pressed (cancel/close action) */
onCancel?: () => void;
/** Whether the hotkeys are enabled. Defaults to true */
enabled?: boolean;
/** Whether to ignore hotkeys when inside input/textarea elements. Defaults to true */
ignoreInputs?: boolean;
/** Whether the confirm action is disabled (e.g., form validation failed) */
confirmDisabled?: boolean;
}
/**
* Hook that provides standard prompt/dialog hotkey behavior:
* - Enter: Confirm/submit action
* - Escape: Cancel/close action
*/
export const usePromptHotkeys = ({
enabled = true,
onConfirm,
onCancel,
ignoreInputs = true,
confirmDisabled = false,
}: PromptHotkeysConfig) => {
useEffect(() => {
if (!enabled) return;
const findConfirmButton = (): HTMLButtonElement | null => {
const dialogContainers = document.querySelectorAll('[role="dialog"], [data-state="open"], .dialog-content');
for (const container of dialogContainers) {
const button = container.querySelector('[data-confirm-button]:not([disabled])') as HTMLButtonElement;
if (button) return button;
}
return document.querySelector('[data-confirm-button]:not([disabled])') as HTMLButtonElement;
};
const handleKeyDown = (e: KeyboardEvent) => {
if (ignoreInputs) {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable
) {
return;
}
}
switch (e.key) {
case "Enter":
if (onConfirm && !confirmDisabled) {
e.preventDefault();
const confirmButton = findConfirmButton();
if (confirmButton) {
confirmButton.click();
} else {
onConfirm();
}
}
break;
case "Escape":
if (onCancel) {
e.preventDefault();
onCancel();
}
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [enabled, onConfirm, onCancel, ignoreInputs, confirmDisabled]);
};
export type WebhookIntegration = "Github" | "Gitlab";
export type WebhookIntegrations = {
[key: string]: WebhookIntegration;
};
const WEBHOOK_INTEGRATIONS_ATOM = atomWithStorage<WebhookIntegrations>(
"webhook-integrations-v2",
{}
);
export const useWebhookIntegrations = () => {
const [integrations, setIntegrations] = useAtom<WebhookIntegrations>(
WEBHOOK_INTEGRATIONS_ATOM
);
return {
integrations,
setIntegration: (provider: string, integration: WebhookIntegration) =>
setIntegrations({
...integrations,
[provider]: integration,
}),
};
};
export const getWebhookIntegration = (
integrations: WebhookIntegrations,
git_provider: string
) => {
return integrations[git_provider]
? integrations[git_provider]
: git_provider.includes("gitlab")
? "Gitlab"
: "Github";
};
export type WebhookIdOrName = "Id" | "Name";
const WEBHOOK_ID_OR_NAME_ATOM = atomWithStorage<WebhookIdOrName>(
"webhook-id-or-name-v1",
"Id"
);
export const useWebhookIdOrName = () => {
return useAtom<WebhookIdOrName>(WEBHOOK_ID_OR_NAME_ATOM);
};
export type Dimensions = { width: number; height: number };
export const useWindowDimensions = () => {
const [dimensions, setDimensions] = useState<Dimensions>({
width: 0,
height: 0,
});
useEffect(() => {
const callback = () => {
setDimensions({
width: window.screen.availWidth,
height: window.screen.availHeight,
});
};
callback();
window.addEventListener("resize", callback);
return () => {
window.removeEventListener("resize", callback);
};
}, []);
return dimensions;
};
const selected_resources = atomFamily((_: UsableResource) =>
atom<string[]>([])
);
export const useSelectedResources = (type: UsableResource) =>
useAtom(selected_resources(type));
const filter_by_update_available = atomWithStorage<boolean>(
"update-available-filter-v1",
false
);
export const useFilterByUpdateAvailable: () => [boolean, () => void] = () => {
const [filter, set] = useAtom<boolean>(filter_by_update_available);
return [filter, () => set(!filter)];
};
export const usePermissions = ({ type, id }: Types.ResourceTarget) => {
const user = useUser().data;
const perms = useRead("GetPermission", { target: { type, id } }).data as
| Types.PermissionLevelAndSpecifics
| Types.PermissionLevel
| undefined;
const info = useRead("GetCoreInfo", {}).data;
const ui_write_disabled = info?.ui_write_disabled ?? false;
const disable_non_admin_create = info?.disable_non_admin_create ?? false;
const level =
(perms && typeof perms === "string" ? perms : perms?.level) ??
Types.PermissionLevel.None;
const specific =
(perms && typeof perms === "string" ? [] : perms?.specific) ?? [];
const canWrite = !ui_write_disabled && level === Types.PermissionLevel.Write;
const canExecute = has_minimum_permissions(
{ level, specific },
Types.PermissionLevel.Execute
);
const [
specificLogs,
specificInspect,
specificTerminal,
specificAttach,
specificProcesses,
] = [
specific.includes(Types.SpecificPermission.Logs),
specific.includes(Types.SpecificPermission.Inspect),
specific.includes(Types.SpecificPermission.Terminal),
specific.includes(Types.SpecificPermission.Attach),
specific.includes(Types.SpecificPermission.Processes),
];
const canCreate =
type === "Server"
? user?.admin ||
(!disable_non_admin_create && user?.create_server_permissions)
: type === "Build"
? user?.admin ||
(!disable_non_admin_create && user?.create_build_permissions)
: type === "Alerter" ||
type === "Builder" ||
type === "Procedure" ||
type === "Action"
? user?.admin
: user?.admin || !disable_non_admin_create;
return {
canWrite,
canExecute,
canCreate,
specific,
specificLogs,
specificInspect,
specificTerminal,
specificAttach,
specificProcesses,
};
};
const templatesQueryBehaviorAtom =
atomWithStorage<Types.TemplatesQueryBehavior>(
"templates-query-behavior-v0",
Types.TemplatesQueryBehavior.Exclude
);
export const useTemplatesQueryBehavior = () =>
useAtom<Types.TemplatesQueryBehavior>(templatesQueryBehaviorAtom);
export type SettingsView =
| "Variables"
| "Tags"
| "Providers"
| "Users"
| "Profile";
const viewAtom = atomWithStorage<SettingsView>("settings-view-v2", "Variables");
export const useSettingsView = () => useAtom<SettingsView>(viewAtom);
/**
* Map of unique host ports to array of formatted full port map spec
* Formatted ex: 0.0.0.0:3000:3000/tcp
*/
export type PortsMap = { [host_port: string]: Array<Types.Port> };
export const useContainerPortsMap = (ports: Types.Port[]) => {
return useMemo(() => {
const map: PortsMap = {};
for (const port of ports) {
if (!port.PublicPort || !port.PrivatePort) continue;
if (map[port.PublicPort]) {
map[port.PublicPort].push(port);
} else {
map[port.PublicPort] = [port];
}
}
for (const key in map) {
map[key].sort();
}
return map;
}, [ports]);
};
/**
* A custom React hook that debounces a value, delaying its update until after
* a specified period of inactivity. This is useful for performance optimization
* in scenarios like search inputs, form validation, or API calls.
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
+38 -2
View File
@@ -26,7 +26,7 @@ use komodo_client::entities::{
user_group::UserGroup,
variable::Variable,
};
use mongo_indexed::{create_index, create_unique_index};
use mongo_indexed::{Document, create_index, create_unique_index};
use mungos::{
by_id::update_one_by_id,
init::MongoBuilder,
@@ -90,7 +90,7 @@ impl Client {
tags: mongo_indexed::collection(&db, true).await?,
variables: mongo_indexed::collection(&db, true).await?,
git_accounts: mongo_indexed::collection(&db, true).await?,
registry_accounts: mongo_indexed::collection(&db, true).await?,
registry_accounts: registry_accounts_collection(&db).await?,
updates: mongo_indexed::collection(&db, true).await?,
alerts: mongo_indexed::collection(&db, true).await?,
stats: mongo_indexed::collection(&db, true).await?,
@@ -263,3 +263,39 @@ where
bcrypt::hash(password, BCRYPT_COST)
.context("failed to hash password")
}
/// Prior to v2.3.0, the image registry accounts were stored in
/// DockerRegistryAccount collection instead of default ImageRegistryAccount.
/// This method selects the correct name to use for any Komodo database,
/// including legacy installations, where the old name continues
/// to be used.
async fn registry_accounts_collection(
db: &Database,
) -> anyhow::Result<Collection<ImageRegistryAccount>> {
let name = if db
.collection::<Document>("ImageRegistryAccount")
.count_documents(Document::new())
.await
.unwrap_or_default()
> 0
{
// If ImageRegistryAccount collection is non-empty in any case, prefer it.
"ImageRegistryAccount"
} else if db
.collection::<Document>("DockerRegistryAccount")
.count_documents(Document::new())
.await
.unwrap_or_default()
> 0
{
// If already using DockerRegistryAccount collection, prefer it instead.
"DockerRegistryAccount"
} else {
// If using neither, prefer the new convention
"ImageRegistryAccount"
};
mongo_indexed::collection_with_name(db, name, true)
.await
.map_err(Into::into)
}
+1 -1
View File
@@ -7,7 +7,7 @@ use command::{CommandOptions, run_standard_command};
pub async fn check_installed() -> anyhow::Result<()> {
if run_standard_command(
"which git",
CommandOptions::default().timeout(Duration::from_secs(1)),
CommandOptions::default().timeout(Duration::from_secs(2)),
)
.await
.success()
+3 -3
View File
@@ -31,7 +31,7 @@ pub async fn get_commit_hash_info(
"git rev-parse --short HEAD",
CommandOptions::default()
.path(repo_dir)
.timeout(Duration::from_secs(1)),
.timeout(Duration::from_secs(2)),
)
.await;
let hash = if hash.status.success() {
@@ -46,7 +46,7 @@ pub async fn get_commit_hash_info(
"git log -1 --pretty=%B",
CommandOptions::default()
.path(repo_dir)
.timeout(Duration::from_secs(1)),
.timeout(Duration::from_secs(2)),
)
.await;
let message = if message.status.success() {
@@ -93,7 +93,7 @@ pub async fn get_remote_url(path: &Path) -> anyhow::Result<String> {
"git remote show origin",
CommandOptions::default()
.path(path)
.timeout(Duration::from_secs(1)),
.timeout(Duration::from_secs(2)),
)
.await;
if output.success() {
+19 -4
View File
@@ -1,7 +1,7 @@
use std::sync::Arc;
use anyhow::{Context, anyhow};
use axum::http::HeaderValue;
use axum::http::{self, HeaderValue};
use bytes::Bytes;
use encoding::CastBytes as _;
use futures_util::{
@@ -15,7 +15,8 @@ use tokio::net::TcpStream;
use tokio_tungstenite::{
Connector, MaybeTlsStream, WebSocketStream,
tungstenite::{
self, handshake::client::Response, protocol::CloseFrame,
self, client::IntoClientRequest as _,
handshake::client::Response, protocol::CloseFrame,
},
};
use tokio_util::sync::CancellationToken;
@@ -183,7 +184,8 @@ impl TungsteniteWebsocket {
pub async fn connect(
url: &str,
) -> mogh_error::Result<(Self, HeaderValue)> {
let res = tokio_tungstenite::connect_async(url).await;
let res =
tokio_tungstenite::connect_async(make_request(url)?).await;
Self::handle_connection_result(url, res)
}
@@ -191,7 +193,7 @@ impl TungsteniteWebsocket {
url: &str,
) -> mogh_error::Result<(Self, HeaderValue)> {
let res = tokio_tungstenite::connect_async_tls_with_config(
url,
make_request(url)?,
None,
false,
Some(Connector::Rustls(Arc::new(
@@ -239,6 +241,19 @@ impl TungsteniteWebsocket {
}
}
fn make_request(url: &str) -> mogh_error::Result<http::Request<()>> {
let mut request =
url.into_client_request().context("Invalid websocket URL")?;
request.headers_mut().insert(
"user-agent",
HeaderValue::from_static(concat!(
"komodo/",
env!("CARGO_PKG_VERSION")
)),
);
Ok(request)
}
#[derive(Debug)]
struct InsecureVerifier;
+25 -25
View File
@@ -10,48 +10,48 @@
"build-client": "cd ../client/core/ts && yarn && yarn build && yarn link"
},
"dependencies": {
"@mantine/core": "^9.4.1",
"@mantine/form": "^9.4.1",
"@mantine/hooks": "^9.4.1",
"@mantine/notifications": "^9.4.1",
"@mantine/spotlight": "^9.4.1",
"@mantine/core": "^9.5.2",
"@mantine/form": "^9.5.2",
"@mantine/hooks": "^9.5.2",
"@mantine/notifications": "^9.5.2",
"@mantine/spotlight": "^9.5.2",
"@monaco-editor/react": "^4.7.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-query": "^5.102.4",
"@tanstack/react-table": "^9.1.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"ansi-to-html": "^0.7.2",
"jotai": "^2.20.1",
"jotai-family": "^1.0.2",
"jotai": "^2.20.3",
"jotai-family": "^1.1.0",
"jotai-location": "^0.6.2",
"lucide-react": "^1.22.0",
"mogh_auth_client": "^1.7.0",
"mogh_ui": "^0.7.6",
"lucide-react": "^1.34.0",
"mogh_auth_client": "^1.7.1",
"mogh_ui": "^1.2.7",
"monaco-editor": "^0.56.0",
"monaco-yaml": "^5.5.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-minimal-pie-chart": "^9.1.2",
"react-router-dom": "^7.18.1",
"react-router-dom": "^7.18.2",
"react-xtermjs": "^1.0.12",
"recharts": "^3.9.1",
"sanitize-html": "^2.17.5",
"shell-quote": "^1.9.0"
"recharts": "^3.10.1",
"sanitize-html": "^2.17.7",
"shell-quote": "^1.10.0"
},
"devDependencies": {
"@types/node": "^26.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/node": "^26.3.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@types/sanitize-html": "^2.16.1",
"@types/shell-quote": "^1.7.5",
"@vitejs/plugin-react": "^6.0.3",
"@vitejs/plugin-react": "^6.1.0",
"dotenv": "^17.4.2",
"postcss": "^8.5.16",
"postcss": "^8.5.26",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"sass-embedded": "^1.100.0",
"sass-embedded": "^1.103.1",
"typescript": "^7.0.2",
"vite": "^8.1.2"
"vite": "^8.2.2"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+2 -2
View File
@@ -120,10 +120,10 @@ export declare function KomodoClient(url: string, options: InitOptions): {
*/
execute_and_poll: <T extends ExecuteRequest["type"], Req extends Extract<ExecuteRequest, {
type: T;
}>>(type: T, params: Req["params"]) => Promise<Update | (Update | {
}>>(type: T, params: Req["params"]) => Promise<(Update | {
status: "Err";
data: import("./types.js").BatchExecutionResponseItemErr;
})[]>;
})[] | Update>;
/**
* Poll an Update (returned by the `execute` calls) until the `status` is `Complete`.
* https://docs.rs/komodo_client/latest/komodo_client/entities/update/struct.Update.html#structfield.status.
+23 -5
View File
@@ -369,8 +369,10 @@ export interface CheckDeploymentForUpdateResponse {
export type BatchCheckDeploymentForUpdateResponse = CheckDeploymentForUpdateResponse[];
export interface StackServiceWithUpdate {
service: string;
/** The service's image */
/** The service's (current) image */
image: string;
/** The latest image (if different than current) */
latest_image?: string;
/** Whether there is a newer image available for this service */
update_available: boolean;
}
@@ -2992,10 +2994,7 @@ export interface SystemInformation {
kernel?: string;
/** Physical core count */
core_count?: number;
/**
* Logical core count. If available,
* used to interpret system load accurately.
*/
/** Logical core count. */
logical_core_count?: number;
/** System hostname based off DNS */
host_name?: string;
@@ -5414,6 +5413,21 @@ export interface MinimalSystemStats {
/** Unix timestamp in milliseconds when disk list was last refreshed */
refresh_list_ts: I64;
}
/** Just the server alerting thresholds */
export interface ServerAlertingThresholds {
/** The percentage threshhold which triggers WARNING state for CPU. */
cpu_warning: number;
/** The percentage threshhold which triggers CRITICAL state for CPU. */
cpu_critical: number;
/** The percentage threshhold which triggers WARNING state for MEM. */
mem_warning: number;
/** The percentage threshhold which triggers CRITICAL state for MEM. */
mem_critical: number;
/** The percentage threshhold which triggers WARNING state for DISK. */
disk_warning: number;
/** The percentage threshhold which triggers CRITICAL state for DISK. */
disk_critical: number;
}
export interface ServerListItemInfo {
/** The server's state. */
state: ServerState;
@@ -5424,6 +5438,8 @@ export interface ServerListItemInfo {
err?: _Serror;
/** System stats, if available */
stats?: MinimalSystemStats;
/** The server alerting thresholds */
alerting_thresholds: ServerAlertingThresholds;
/** The server's number of physical cores. */
core_count?: number;
/** The server's number of logical cores. */
@@ -5528,6 +5544,8 @@ export interface StackListItemInfo {
* Otherwise, its `latest_services`
*/
services: StackServiceWithUpdate[];
/** Whether stack has auto_update_all_services enabled. */
auto_update_all_services: boolean;
/**
* Whether the compose project is missing on the host.
* Ie, it does not show up in `docker compose ls`.
@@ -86,6 +86,7 @@ export default function ContainerSelector({
<Group gap="xs" ml="sm" wrap="nowrap">
{clearable && (
<ActionIcon
component="div"
size="sm"
variant="filled"
color="red"
@@ -93,7 +94,12 @@ export default function ContainerSelector({
e.stopPropagation();
onSelect?.("");
}}
disabled={disabled || !selected}
data-disabled={disabled || !selected || undefined}
style={
disabled || !selected
? { pointerEvents: "none" }
: undefined
}
>
<ICONS.Clear size="0.8rem" />
</ActionIcon>
@@ -94,7 +94,6 @@ export default function ContainersSection({
columns={[
{
accessorKey: "name",
size: 260,
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
@@ -108,7 +107,6 @@ export default function ContainersSection({
},
{
accessorKey: "state",
size: 160,
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
@@ -124,7 +122,6 @@ export default function ContainersSection({
},
{
accessorKey: "image",
size: 300,
header: ({ column }) => (
<SortableHeader column={column} title="Image" />
),
@@ -139,7 +136,6 @@ export default function ContainersSection({
},
{
accessorKey: "networks.0",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Networks" />
),
@@ -167,8 +163,7 @@ export default function ContainersSection({
},
{
accessorKey: "ports.0",
size: 200,
sortingFn: (a, b) => {
sortFn: (a, b) => {
const getMinHostPort = (row: typeof a) => {
const ports = row.original.ports ?? [];
if (!ports.length) return Number.POSITIVE_INFINITY;
@@ -197,7 +192,6 @@ export default function ContainersSection({
},
{
accessorKey: "volumes.0",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Volumes" />
),
+8 -25
View File
@@ -1,5 +1,8 @@
import { useRead } from "@/lib/hooks";
import { Group, Pagination } from "@mantine/core";
import {
ListPaginationProps,
ListPagination as MoghListPagination,
} from "mogh_ui";
/**
* The server side default page size on `List<Resource>` calls,
@@ -18,29 +21,9 @@ export function usePageSize() {
* Only renders when needed, ie the results fill the page
* or the user is past the first page.
*/
export default function ListPagination({
page,
setPage,
count,
}: {
page: number;
setPage: (page: number) => void;
count: number;
}) {
export default function ListPagination(
props: Omit<ListPaginationProps, "pageSize">,
) {
const pageSize = usePageSize();
if (count < pageSize && page === 0) return null;
return (
<Pagination.Root
total={count >= pageSize ? page + 2 : page + 1}
value={page + 1}
onChange={(page) => setPage(page - 1)}
>
<Group gap="0.2rem" justify="center">
<Pagination.First />
<Pagination.Previous />
<Pagination.Items />
<Pagination.Next />
</Group>
</Pagination.Root>
);
return <MoghListPagination {...props} pageSize={pageSize} />;
}
@@ -88,7 +88,6 @@ export default function ConfigMaintenanceWindows({
<Text>{row.original.name}</Text>
</Group>
),
size: 200,
},
{
accessorKey: "schedule_type",
@@ -98,7 +97,6 @@ export default function ConfigMaintenanceWindows({
cell: ({ row }) => (
<ScheduleDescription window={row.original} />
),
size: 150,
},
{
accessorKey: "start_time",
@@ -106,7 +104,6 @@ export default function ConfigMaintenanceWindows({
<SortableHeader column={column} title="Start Time" />
),
cell: ({ row }) => fmtMaintenanceWindowTime(row.original),
size: 180,
},
{
accessorKey: "duration_minutes",
@@ -114,7 +111,6 @@ export default function ConfigMaintenanceWindows({
<SortableHeader column={column} title="Duration" />
),
accessorFn: (row) => `${row.duration_minutes} min`,
size: 100,
},
{
accessorKey: "enabled",
@@ -128,7 +124,6 @@ export default function ConfigMaintenanceWindows({
disabled={disabled}
/>
),
size: 120,
},
{
id: "actions",
@@ -152,7 +147,6 @@ export default function ConfigMaintenanceWindows({
</ActionIcon>
</Group>
),
size: 100,
},
]}
/>
@@ -102,7 +102,6 @@ export default function BasePermissionsSection({
columns={[
{
accessorKey: "type",
size: 150,
header: ({ column }) => (
<SortableHeader column={column} title="Resource Type" />
),
@@ -119,8 +118,7 @@ export default function BasePermissionsSection({
},
{
accessorKey: "level",
size: 150,
sortingFn: (a, b) =>
sortFn: (a, b) =>
levelSortingFn(a.original.level, b.original.level),
header: ({ column }) => (
<SortableHeader column={column} title="Level" />
@@ -143,7 +141,6 @@ export default function BasePermissionsSection({
},
{
header: "Specific",
size: 300,
cell: ({ row }) => {
return (
<SpecificPermissionSelector
@@ -53,7 +53,6 @@ export default function SpecificPermissionsSection({
columns={[
{
accessorKey: "resource_target.type",
size: 150,
header: ({ column }) => (
<SortableHeader column={column} title="Resource" />
),
@@ -72,7 +71,6 @@ export default function SpecificPermissionsSection({
},
{
accessorKey: "resource_target",
size: 250,
header: ({ column }) => (
<SortableHeader column={column} title="Target" />
),
@@ -91,8 +89,7 @@ export default function SpecificPermissionsSection({
},
{
accessorKey: "level",
size: 150,
sortingFn: (a, b) =>
sortFn: (a, b) =>
levelSortingFn(a.original.level, b.original.level),
header: ({ column }) => (
<SortableHeader column={column} title="Level" />
@@ -115,7 +112,6 @@ export default function SpecificPermissionsSection({
},
{
header: "Specific",
size: 300,
cell: ({ row: { original: permission } }) => {
return (
<SpecificPermissionSelector
+7 -1
View File
@@ -101,6 +101,7 @@ export default function StackServiceSelector({
<Group gap="xs" ml="sm" wrap="nowrap">
{clearable && (
<ActionIcon
component="div"
size="sm"
variant="filled"
color="red"
@@ -108,7 +109,12 @@ export default function StackServiceSelector({
e.stopPropagation();
onSelect?.("");
}}
disabled={disabled || !selected}
data-disabled={disabled || !selected || undefined}
style={
disabled || !selected
? { pointerEvents: "none" }
: undefined
}
>
<ICONS.Clear size="0.8rem" />
</ActionIcon>
@@ -66,7 +66,6 @@ export default function SwarmServicesSection({
name={row.original.Name}
/>
),
size: 200,
},
{
accessorKey: "ID",
@@ -74,7 +73,6 @@ export default function SwarmServicesSection({
<SortableHeader column={column} title="ID" />
),
cell: ({ row }) => row.original.ID ?? "Unknown",
size: 200,
},
{
accessorKey: "UpdatedAt",
@@ -85,7 +83,6 @@ export default function SwarmServicesSection({
row.original.UpdatedAt
? new Date(row.original.UpdatedAt).toLocaleString()
: "Unknown",
size: 200,
},
{
accessorKey: "CreatedAt",
@@ -96,7 +93,6 @@ export default function SwarmServicesSection({
row.original.CreatedAt
? new Date(row.original.CreatedAt).toLocaleString()
: "Unknown",
size: 200,
},
]}
/>
@@ -86,7 +86,6 @@ export default function SwarmTasksSection({
name={row.original.ID}
/>
),
size: 150,
},
{
accessorKey: "node.Hostname",
@@ -101,7 +100,6 @@ export default function SwarmTasksSection({
name={row.original.node?.Hostname}
/>
),
size: 200,
},
{
accessorKey: "service.Name",
@@ -116,7 +114,6 @@ export default function SwarmTasksSection({
name={row.original.service?.Name}
/>
),
size: 200,
},
{
accessorKey: "State",
@@ -157,7 +154,6 @@ export default function SwarmTasksSection({
row.original.UpdatedAt
? new Date(row.original.UpdatedAt).toLocaleString()
: "Unknown",
size: 200,
},
// {
// accessorKey: "CreatedAt",
+8 -158
View File
@@ -1,169 +1,19 @@
import { Box, useComputedColorScheme } from "@mantine/core";
import { useEffect, useMemo, useRef } from "react";
import { ITheme } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { useXTerm, UseXTermProps } from "react-xtermjs";
import { TerminalCallbacks } from "komodo_client";
import { lazy, Suspense } from "react";
const LIGHT_THEME: ITheme = {
background: "#EDEEEF",
foreground: "#000000",
cursor: "#000000",
selectionBackground: "#C8D9FA",
scrollbarSliderBackground: "#D3D3D3",
};
// The implementation pulls in @xterm / react-xtermjs,
// so it is loaded lazily to keep them out of the entry chunk.
const TerminalInner = lazy(() => import("./xterm"));
const DARK_THEME: ITheme = {
background: "#15171B",
foreground: "#FFFFFF",
cursor: "#FFFFFF",
selectionBackground: "#B4BFD6",
scrollbarSliderBackground: "#232528",
};
export default function Terminal({
makeWs,
selected,
_reconnect,
_clear,
}: {
export default function Terminal(props: {
makeWs: (callbacks: TerminalCallbacks) => WebSocket | undefined;
selected: boolean;
_reconnect: boolean;
_clear?: boolean;
}) {
const currentTheme = useComputedColorScheme();
const theme = currentTheme === "dark" ? DARK_THEME : LIGHT_THEME;
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon>(new FitAddon());
const resize = () => {
fitRef.current.fit();
if (term) {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const json = JSON.stringify({
rows: term.rows,
cols: term.cols,
});
const buf = new Uint8Array(json.length + 1);
for (let i = 0; i < json.length; i++) buf[i] = json.charCodeAt(i);
buf[json.length] = 0xff; // resize postfix
wsRef.current.send(buf);
}
term.focus();
}
};
const onStdin = (data: string) => {
// This is data user writes to stdin
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const buf = new Uint8Array(data.length + 1);
for (let i = 0; i < data.length; i++) buf[i] = data.charCodeAt(i);
buf[data.length] = 0x01; // forward data postfix
wsRef.current.send(buf);
};
useEffect(resize, [selected]);
const params: UseXTermProps = useMemo(
() => ({
options: {
convertEol: false,
cursorBlink: true,
cursorStyle: "block",
fontFamily: "monospace",
scrollback: 5000,
// This is handled in ws on_message handler
scrollOnUserInput: false,
theme,
},
listeners: {
onResize: resize,
onData: onStdin,
},
addons: [fitRef.current],
}),
[theme],
);
const { instance: term, ref: termRef } = useXTerm(params);
const viewport = (term as any)?._core?.viewport?._viewportElement as
| HTMLDivElement
| undefined;
useEffect(() => {
if (!term || !viewport) return;
let delta = 0;
term.attachCustomWheelEventHandler((e) => {
e.preventDefault();
// This is used to make touchpad and mousewheel more similar
delta += Math.sign(e.deltaY) * Math.sqrt(Math.abs(e.deltaY)) * 20;
return false;
});
const int = setInterval(() => {
if (Math.abs(delta) < 1) return;
viewport.scrollTop += delta;
delta = 0;
}, 100);
return () => clearInterval(int);
}, [term, termRef.current]);
useEffect(() => {
if (!selected || !term) return;
term.clear();
let debounce = -1;
const callbacks: TerminalCallbacks = {
on_login: () => {
// console.log("Logged into terminal");
},
on_open: resize,
on_message: (e: MessageEvent<any>) => {
term.write(new Uint8Array(e.data as ArrayBuffer), () => {
if (viewport) {
viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight;
}
clearTimeout(debounce);
debounce = setTimeout(() => {
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight;
}, 500) as any;
});
},
on_close: () => {
term.writeln("\r\n\x1b[33m[connection closed]\x1b[0m");
},
};
const ws = makeWs(callbacks);
if (!ws) return;
wsRef.current = ws;
return () => {
ws.close();
wsRef.current = null;
};
}, [term, viewport, makeWs, selected, _reconnect]);
useEffect(() => term?.clear(), [_clear]);
return (
<Box
p="md"
className="bordered-light"
bdrs="md"
style={{
display: selected ? undefined : "none",
}}
>
<Box ref={termRef} h="max(200px, calc(100vh - 320px))" />
</Box>
<Suspense fallback={null}>
<TerminalInner {...props} />
</Suspense>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { Box, useComputedColorScheme } from "@mantine/core";
import { useEffect, useMemo, useRef } from "react";
import { ITheme } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { useXTerm, UseXTermProps } from "react-xtermjs";
import { TerminalCallbacks } from "komodo_client";
const LIGHT_THEME: ITheme = {
background: "#EDEEEF",
foreground: "#000000",
cursor: "#000000",
selectionBackground: "#C8D9FA",
scrollbarSliderBackground: "#D3D3D3",
};
const DARK_THEME: ITheme = {
background: "#15171B",
foreground: "#FFFFFF",
cursor: "#FFFFFF",
selectionBackground: "#B4BFD6",
scrollbarSliderBackground: "#232528",
};
export default function Terminal({
makeWs,
selected,
_reconnect,
_clear,
}: {
makeWs: (callbacks: TerminalCallbacks) => WebSocket | undefined;
selected: boolean;
_reconnect: boolean;
_clear?: boolean;
}) {
const currentTheme = useComputedColorScheme();
const theme = currentTheme === "dark" ? DARK_THEME : LIGHT_THEME;
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon>(new FitAddon());
const resize = () => {
fitRef.current.fit();
if (term) {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const json = JSON.stringify({
rows: term.rows,
cols: term.cols,
});
const buf = new Uint8Array(json.length + 1);
for (let i = 0; i < json.length; i++) buf[i] = json.charCodeAt(i);
buf[json.length] = 0xff; // resize postfix
wsRef.current.send(buf);
}
term.focus();
}
};
const onStdin = (data: string) => {
// This is data user writes to stdin
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const buf = new Uint8Array(data.length + 1);
for (let i = 0; i < data.length; i++) buf[i] = data.charCodeAt(i);
buf[data.length] = 0x01; // forward data postfix
wsRef.current.send(buf);
};
useEffect(resize, [selected]);
const params: UseXTermProps = useMemo(
() => ({
options: {
convertEol: false,
cursorBlink: true,
cursorStyle: "block",
fontFamily: "monospace",
scrollback: 5000,
// This is handled in ws on_message handler
scrollOnUserInput: false,
theme,
},
listeners: {
onResize: resize,
onData: onStdin,
},
addons: [fitRef.current],
}),
[theme],
);
const { instance: term, ref: termRef } = useXTerm(params);
const viewport = (term as any)?._core?.viewport?._viewportElement as
| HTMLDivElement
| undefined;
useEffect(() => {
if (!term || !viewport) return;
let delta = 0;
term.attachCustomWheelEventHandler((e) => {
e.preventDefault();
// This is used to make touchpad and mousewheel more similar
delta += Math.sign(e.deltaY) * Math.sqrt(Math.abs(e.deltaY)) * 20;
return false;
});
const int = setInterval(() => {
if (Math.abs(delta) < 1) return;
viewport.scrollTop += delta;
delta = 0;
}, 100);
return () => clearInterval(int);
}, [term, termRef.current]);
useEffect(() => {
if (!selected || !term) return;
term.clear();
let debounce = -1;
const callbacks: TerminalCallbacks = {
on_login: () => {
// console.log("Logged into terminal");
},
on_open: resize,
on_message: (e: MessageEvent<any>) => {
term.write(new Uint8Array(e.data as ArrayBuffer), () => {
if (viewport) {
viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight;
}
clearTimeout(debounce);
debounce = setTimeout(() => {
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight;
}, 500) as any;
});
},
on_close: () => {
term.writeln("\r\n\x1b[33m[connection closed]\x1b[0m");
},
};
const ws = makeWs(callbacks);
if (!ws) return;
wsRef.current = ws;
return () => {
ws.close();
wsRef.current = null;
};
}, [term, viewport, makeWs, selected, _reconnect]);
useEffect(() => term?.clear(), [_clear]);
return (
<Box
p="md"
className="bordered-light"
bdrs="md"
style={{
display: selected ? undefined : "none",
}}
>
<Box ref={termRef} h="max(200px, calc(100vh - 320px))" />
</Box>
);
}
+6 -2
View File
@@ -2,7 +2,7 @@ import { useUser } from "@/lib/hooks";
import { ICONS } from "@/lib/icons";
import { ConfirmButton } from "mogh_ui";
import { ConfirmModal } from "mogh_ui";
import { DataTable } from "mogh_ui";
import { DataTable, DataTableFeatures } from "mogh_ui";
import { StatusBadge } from "mogh_ui";
import { Badge, BoxProps } from "@mantine/core";
import { ColumnDef } from "@tanstack/react-table";
@@ -27,7 +27,11 @@ export default function UserTable({
}: UserTableProps) {
const user = useUser().data;
const nav = useNavigate();
const columns: ColumnDef<Types.User, "User" | "Admin" | "Super Admin">[] = [
const columns: ColumnDef<
DataTableFeatures,
Types.User,
"User" | "Admin" | "Super Admin"
>[] = [
{ header: "Username", accessorKey: "username" },
{
header: "Type",
+20 -1
View File
@@ -660,7 +660,12 @@ export function useFilterByUpdateAvailable(): [boolean, () => void] {
export function usePermissions({ type, id }: Types.ResourceTarget) {
const user = useUser().data;
const perms = useRead("GetPermission", { target: { type, id } }).data as
const perms = useRead(
"GetPermission",
{ target: { type, id } },
// skip call for admins
{ enabled: user ? !user?.admin : false },
).data as
| Types.PermissionLevelAndSpecifics
| Types.PermissionLevel
| undefined;
@@ -668,6 +673,20 @@ export function usePermissions({ type, id }: Types.ResourceTarget) {
const ui_write_disabled = info?.ui_write_disabled ?? false;
const disable_non_admin_create = info?.disable_non_admin_create ?? false;
if (user?.admin) {
return {
canWrite: true,
canExecute: true,
canCreate: true,
specific: Object.values(Types.SpecificPermission),
specificLogs: true,
specificInspect: true,
specificTerminal: true,
specificAttach: true,
specificProcesses: true,
};
}
const level =
(perms && typeof perms === "string"
? perms
+9
View File
@@ -3,6 +3,9 @@ import { Types } from "komodo_client";
import sanitizeHtml from "sanitize-html";
import ConvertAnsiToHtml from "ansi-to-html";
import { RowSelectionState } from "@tanstack/react-table";
import { Crumb } from "mogh_ui";
export const EXECUTION_ACTION_STATE_REQUERY_MS = 500;
export function objectKeys<T extends object>(o: T): (keyof T)[] {
return Object.keys(o) as (keyof T)[];
@@ -318,3 +321,9 @@ export function parseVersion(version: string): Types.Version {
patch,
};
}
/** Crumb linking to the list page for a resource type, eg. `Servers` -> /servers */
export function resourceTypeCrumb(type: UsableResource): Crumb {
const name = type === "ResourceSync" ? "Resource Sync" : type;
return { label: `${name}s`, to: `/${usableResourcePath(type)}` };
}
+4 -3
View File
@@ -6,7 +6,6 @@ import { Router } from "@/router";
import { setAuthUrl, ThemeProvider } from "mogh_ui";
import { themeAdditionalColors } from "@/lib/color";
import { Notifications } from "@mantine/notifications";
import initMonaco from "@/monaco";
import "@mantine/core/styles.css";
// ‼️ import extra package styles after core package styles
@@ -17,10 +16,12 @@ import "./index.scss";
// Import mogh_ui scss
import "mogh_ui/index.scss";
initMonaco();
// Loaded dynamically to keep monaco-editor / monaco-yaml out of the entry chunk.
// Doesn't need to be awaited - applies in background when ready.
import("@/monaco").then(({ default: initMonaco }) => initMonaco());
export const KOMODO_BASE_URL =
import.meta.env.VITE_KOMODO_HOST ?? location.origin;
import.meta.env.VITE_KOMODO_HOST || location.origin;
export const UPDATE_WS_URL =
KOMODO_BASE_URL.replace("http", "ws") + "/ws/update";
const client = new QueryClient({
+2 -9
View File
@@ -96,7 +96,6 @@ export default function Containers() {
{
id: "Name",
accessorKey: "name",
size: 260,
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
@@ -111,8 +110,7 @@ export default function Containers() {
{
id: "Server",
accessorKey: "server_id",
size: 200,
sortingFn: (a, b) => {
sortFn: (a, b) => {
const sa = a.original.server_name;
const sb = b.original.server_name;
@@ -134,7 +132,6 @@ export default function Containers() {
{
id: "State",
accessorKey: "state",
size: 160,
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
@@ -151,7 +148,6 @@ export default function Containers() {
{
id: "Image",
accessorKey: "image",
size: 300,
header: ({ column }) => (
<SortableHeader column={column} title="Image" />
),
@@ -167,7 +163,6 @@ export default function Containers() {
{
id: "Networks",
accessorKey: "networks.0",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Networks" />
),
@@ -196,8 +191,7 @@ export default function Containers() {
{
id: "Ports",
accessorKey: "ports.0",
size: 200,
sortingFn: (a, b) => {
sortFn: (a, b) => {
const getMinHostPort = (row: typeof a) => {
const ports = row.original.ports ?? [];
if (!ports.length) return Number.POSITIVE_INFINITY;
@@ -227,7 +221,6 @@ export default function Containers() {
{
id: "Volumes",
accessorKey: "volumes.0",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Volumes" />
),
+7 -2
View File
@@ -1,5 +1,9 @@
import { Page } from "mogh_ui";
import { useDashboardPreferences, useSetTitle } from "@/lib/hooks";
import {
useDashboardPreferences,
useSetTitle,
useTagsFilter,
} from "@/lib/hooks";
import { ICONS } from "@/lib/icons";
import { Group } from "@mantine/core";
import DashboardRecents from "./recents";
@@ -11,6 +15,7 @@ import DashboardActiveResources from "./active";
export default function Dashboard() {
const { preferences } = useDashboardPreferences();
const tags = useTagsFilter();
useSetTitle(undefined);
return (
<Page
@@ -21,7 +26,7 @@ export default function Dashboard() {
<Group w={{ base: "100%", xs: "fit-content" }}>
<ShowTables />
<ServerShowStats />
<ExportToml />
<ExportToml tags={preferences.showTables ? tags : undefined} />
</Group>
}
>
-2
View File
@@ -176,7 +176,6 @@ function ImageInner({
{
accessorKey: "CreatedBy",
header: "Created By",
size: 400,
},
{
accessorKey: "Created",
@@ -185,7 +184,6 @@ function ImageInner({
fmtDateWithMinutes(
new Date(row.original.Created * 1000),
),
size: 200,
},
]}
/>
-1
View File
@@ -165,7 +165,6 @@ function NetworkInner({
) : (
"Unknown"
),
size: 200,
},
{
accessorKey: "IPv4Address",
+14 -4
View File
@@ -14,13 +14,12 @@ import {
UsableResource,
} from "@/resources";
import { AddResourceTags, ResourceTags } from "@/resources/tags";
import { DividedChildren } from "mogh_ui";
import { Section } from "mogh_ui";
import { DividedChildren, EntityPage } from "mogh_ui";
import { Section, PageBreadcrumbs } from "mogh_ui";
import { Group, Stack, Text } from "@mantine/core";
import { Types } from "komodo_client";
import { Link, useParams } from "react-router-dom";
import { EntityPage } from "mogh_ui";
import { usableResourcePath } from "@/lib/utils";
import { resourceTypeCrumb, usableResourcePath } from "@/lib/utils";
import ResourceDescription from "@/resources/description";
import ResourceNotFound from "@/resources/not-found";
import NewResource from "@/resources/new";
@@ -67,6 +66,17 @@ function ResourceInner({ type, id }: { type: UsableResource; id: string }) {
? "settings"
: usableResourcePath(type))
}
breadcrumbs={
<PageBreadcrumbs
items={[
...(SETTINGS_RESOURCES.includes(type)
? [{ label: "Settings", to: "/settings" }]
: []),
resourceTypeCrumb(type),
{ label: resource.name },
]}
/>
}
actions={
<>
{canCreate && <NewResource type={type} copyId={id} />}
+4 -2
View File
@@ -1,5 +1,5 @@
import { useState } from "react";
import { useResourceParamType, useSetTitle } from "@/lib/hooks";
import { useResourceParamType, useSetTitle, useTagsFilter } from "@/lib/hooks";
import { ResourceComponents, UsableResource } from "@/resources";
import { Types } from "komodo_client";
import { Page } from "mogh_ui";
@@ -18,6 +18,8 @@ export default function Resources({ _type }: { _type?: UsableResource }) {
const [query, setQuery] = useState<Types.ResourceQuery<any>>({});
const tags = useTagsFilter();
const RC = ResourceComponents[type];
if (!RC) {
@@ -32,7 +34,7 @@ export default function Resources({ _type }: { _type?: UsableResource }) {
oppositeTitle={
<Group w={{ base: "100%", xs: "fit-content" }}>
{type === "Server" && <ServerShowStats />}
<ExportToml listQuery={{ type, query }} />
<ExportToml listQuery={{ type, query }} tags={tags} />
</Group>
}
>
-4
View File
@@ -100,7 +100,6 @@ export default function Schedules() {
}}
columns={[
{
size: 200,
id: "Name",
accessorKey: "name",
header: ({ column }) => (
@@ -114,7 +113,6 @@ export default function Schedules() {
),
},
{
size: 200,
id: "Schedule",
accessorKey: "schedule",
header: ({ column }) => (
@@ -122,7 +120,6 @@ export default function Schedules() {
),
},
{
size: 200,
id: "NextRun",
accessorKey: "next_scheduled_run",
header: ({ column }) => (
@@ -134,7 +131,6 @@ export default function Schedules() {
: "Not Scheduled",
},
{
size: 100,
id: "Enabled",
accessorKey: "enabled",
header: ({ column }) => (
+7 -10
View File
@@ -3,7 +3,12 @@ import TagSelector from "@/components/tags/selector";
import { useInvalidate, useRead, useSetTitle, useWrite } from "@/lib/hooks";
import ResourceSelector from "@/resources/selector";
import { ICONS } from "@/lib/icons";
import { DataTable, fmtDateWithMinutes, SortableHeader } from "mogh_ui";
import {
DataTable,
DataTableFeatures,
fmtDateWithMinutes,
SortableHeader,
} from "mogh_ui";
import { Badge, Group, Switch, TextInput, useMatches } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { ColumnDef } from "@tanstack/react-table";
@@ -29,13 +34,12 @@ export default function SettingsOnboardingKeys() {
xl: "md",
});
const columns: (
| ColumnDef<Types.OnboardingKey, unknown>
| ColumnDef<DataTableFeatures, Types.OnboardingKey, unknown>
| false
| undefined
)[] = useMemo(
() => [
{
size: 150,
accessorKey: "name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
@@ -54,7 +58,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 150,
accessorKey: "copy_server",
header: "Template",
cell: ({ row }) => (
@@ -69,7 +72,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 200,
accessorKey: "tags",
header: "Tags",
cell: ({ row }) => {
@@ -116,7 +118,6 @@ export default function SettingsOnboardingKeys() {
},
},
{
size: 100,
accessorKey: "privileged",
header: ({ column }) => (
<SortableHeader
@@ -138,7 +139,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 100,
accessorKey: "create_builder",
header: ({ column }) => (
<SortableHeader column={column} title="Create Builder" />
@@ -156,7 +156,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 100,
accessorKey: "enabled",
header: ({ column }) => (
<SortableHeader column={column} title="Enabled" />
@@ -174,7 +173,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 150,
accessorKey: "expires",
header: ({ column }) => (
<SortableHeader column={column} title="Expires" />
@@ -196,7 +194,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 100,
accessorKey: "public_key",
header: "Delete",
cell: ({ row }) => (
@@ -83,7 +83,6 @@ function Providers({ type }: { type: "GitProvider" | "ImageRegistry" }) {
columns={[
{
accessorKey: "domain",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Domain" />
),
@@ -141,7 +140,6 @@ function Providers({ type }: { type: "GitProvider" | "ImageRegistry" }) {
},
{
accessorKey: "username",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Username" />
),
@@ -180,7 +178,6 @@ function Providers({ type }: { type: "GitProvider" | "ImageRegistry" }) {
},
{
accessorKey: "token",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Token" />
),
@@ -220,7 +217,6 @@ function Providers({ type }: { type: "GitProvider" | "ImageRegistry" }) {
},
{
header: "Delete",
maxSize: 200,
cell: ({ row }) => (
<DeleteProviderAccount
type={type}
+10 -14
View File
@@ -1,6 +1,5 @@
import NewTag from "@/components/tags/new";
import Tag from "@/components/tags/tag";
import UserAvatar from "@/components/user-avatar";
import { useRead, useSetTitle, useUser } from "@/lib/hooks";
import { DataTable } from "mogh_ui";
import { Group, Stack } from "@mantine/core";
@@ -30,7 +29,6 @@ export default function SettingsTags() {
columns={[
{
header: "Name",
size: 200,
accessorKey: "name",
cell: ({ row }) => {
return <Tag tag={row.original} size="md" fz="md" />;
@@ -38,7 +36,6 @@ export default function SettingsTags() {
},
{
header: "Color",
size: 200,
cell: ({ row }) => (
<TagColorSelector
tagId={row.original._id?.$oid!}
@@ -47,19 +44,18 @@ export default function SettingsTags() {
/>
),
},
{
header: "Owner",
size: 200,
cell: ({ row }) =>
row.original.owner ? (
<UserAvatar userId={row.original.owner} fz="md" />
) : (
"Unknown"
),
},
// {
// header: "Owner",
// size: 200,
// cell: ({ row }) =>
// row.original.owner ? (
// <UserAvatar userId={row.original.owner} fz="md" />
// ) : (
// "Unknown"
// ),
// },
{
header: "Delete",
size: 200,
cell: ({ row }) => (
<DeleteTag
tagId={row.original._id!.$oid}
@@ -67,7 +67,6 @@ export default function SettingsVariables() {
columns={[
{
accessorKey: "name",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
@@ -79,7 +78,6 @@ export default function SettingsVariables() {
},
{
accessorKey: "value",
size: 300,
header: ({ column }) => (
<SortableHeader column={column} title="Value" />
),
@@ -117,7 +115,6 @@ export default function SettingsVariables() {
},
{
accessorKey: "description",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Description" />
),
@@ -153,7 +150,6 @@ export default function SettingsVariables() {
},
{
header: "Secret",
size: 100,
cell: ({ row }) => (
<Switch
checked={row.original.is_secret}
@@ -169,7 +165,6 @@ export default function SettingsVariables() {
},
{
header: "Delete",
size: 200,
cell: ({ row }) => (
<DeleteVariable name={row.original.name} disabled={disabled} />
),
+1 -1
View File
@@ -492,7 +492,7 @@ function MultiServerStatChart({
fontSize: 12,
}}
labelStyle={{ color: "var(--mantine-color-dimmed)" }}
labelFormatter={(label) => new Date(label).toLocaleString()}
labelFormatter={(label) => new Date(Number(label)).toLocaleString()}
// List the busiest server first.
itemSorter={(item) => -Number(item.value)}
formatter={(value, name) => {
-2
View File
@@ -123,7 +123,6 @@ export default function Terminals() {
),
},
{
size: 200,
id: "Target",
accessorKey: "target",
header: ({ column }) => (
@@ -146,7 +145,6 @@ export default function Terminals() {
),
},
{
size: 100,
id: "Size",
accessorKey: "stored_size_kb",
header: ({ column }) => (
+19 -6
View File
@@ -1,11 +1,14 @@
import UserTable from "@/components/user/table";
import { useInvalidate, useRead, useUser, useWrite } from "@/lib/hooks";
import { ICONS } from "@/lib/icons";
import { DividedChildren } from "mogh_ui";
import { EntityHeader } from "mogh_ui";
import { EntityPage } from "mogh_ui";
import { PageGuard } from "mogh_ui";
import { Section } from "mogh_ui";
import {
DividedChildren,
EntityHeader,
EntityPage,
PageBreadcrumbs,
PageGuard,
Section,
} from "mogh_ui";
import { Group, Stack, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useParams } from "react-router-dom";
@@ -66,7 +69,17 @@ export default function UserGroup() {
: undefined
}
>
<EntityPage backTo="/settings">
<EntityPage
backTo="/settings"
breadcrumbs={
<PageBreadcrumbs
items={[
{ label: "Settings", to: "/settings" },
{ label: group?.name },
]}
/>
}
>
<Stack gap="md" pb="md" className="bordered-light" bdrs="md">
<EntityHeader
name={group?.name}
+20 -12
View File
@@ -1,9 +1,12 @@
import { useInvalidate, useRead, useUser, useWrite } from "@/lib/hooks";
import { ConfirmButton } from "mogh_ui";
import { EntityPage } from "mogh_ui";
import { LabelledSwitch } from "mogh_ui";
import { PageGuard } from "mogh_ui";
import { Section } from "mogh_ui";
import {
ConfirmButton,
EntityPage,
LabelledSwitch,
PageGuard,
PageBreadcrumbs,
Section,
} from "mogh_ui";
import { Group, Stack } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { UserCheck, UserMinus } from "lucide-react";
@@ -48,17 +51,22 @@ export default function User() {
: undefined
}
>
<EntityPage backTo="/settings">
<EntityPage
backTo="/settings"
breadcrumbs={
<PageBreadcrumbs
items={[
{ label: "Settings", to: "/settings" },
{ label: user?.username },
]}
/>
}
>
<UserHeader user={user} />
<Stack mt="lg" gap="xl">
{user?._id?.$oid !== adminUser?._id?.$oid &&
(!user?.admin || (!user.super_admin && adminUser?.super_admin)) && (
<Section
title="User Permissions"
titleFz="h3"
withBorder
>
<Section title="User Permissions" titleFz="h3" withBorder>
<Group>
<ConfirmButton
icon={
+30 -6
View File
@@ -1,17 +1,41 @@
import { useExecute, useIsCancelling, useRead } from "@/lib/hooks";
import {
useExecute,
useInvalidate,
useIsCancelling,
useRead,
} from "@/lib/hooks";
import { useAction } from ".";
import ConfirmModalWithDisable from "@/components/confirm-modal-with-disable";
import { ICONS } from "@/lib/icons";
import { ConfirmButton } from "mogh_ui";
import { Types } from "komodo_client";
import { EXECUTION_ACTION_STATE_REQUERY_MS } from "@/lib/utils";
export function RunAction({ id }: { id: string }) {
const invalidate = useInvalidate();
const running =
(useRead("GetActionActionState", { action: id }, { refetchInterval: 5_000 })
.data?.running ?? 0) > 0;
const { mutateAsync: run, isPending: runPending } = useExecute("RunAction");
const { mutateAsync: cancel, isPending: cancelPending } =
useExecute("CancelAction");
(useRead(
"GetActionActionState",
{ action: id },
{ refetchInterval: 5_000 },
).data?.running ?? 0) > 0;
const { mutateAsync: run, isPending: runPending } = useExecute("RunAction", {
onSuccess: () =>
setTimeout(
() => invalidate(["GetActionActionState"]),
EXECUTION_ACTION_STATE_REQUERY_MS,
),
});
const { mutateAsync: cancel, isPending: cancelPending } = useExecute(
"CancelAction",
{
onSuccess: () =>
setTimeout(
() => invalidate(["GetActionActionState"]),
EXECUTION_ACTION_STATE_REQUERY_MS,
),
},
);
const action = useAction(id);
const cancelling = useIsCancelling(
{ type: "Action", id },
+1 -1
View File
@@ -61,7 +61,7 @@ export default function ActionTable({
header: ({ column }) => (
<SortableHeader column={column} title="Next Run" />
),
sortingFn: (a, b) => {
sortFn: (a, b) => {
const sa = a.original.info.next_scheduled_run;
const sb = b.original.info.next_scheduled_run;
+20 -3
View File
@@ -1,23 +1,40 @@
import { Types } from "komodo_client";
import {
useExecute,
useInvalidate,
useIsCancelling,
usePermissions,
useRead,
} from "@/lib/hooks";
import { ConfirmButton } from "mogh_ui";
import { ICONS } from "@/lib/icons";
import { EXECUTION_ACTION_STATE_REQUERY_MS } from "@/lib/utils";
export function RunBuild({ id }: { id: string }) {
const invalidate = useInvalidate();
const { canExecute } = usePermissions({ type: "Build", id });
const building = useRead(
"GetBuildActionState",
{ build: id },
{ refetchInterval: 5_000 },
).data?.building;
const { mutate: run, isPending: runPending } = useExecute("RunBuild");
const { mutate: cancel, isPending: cancelPending } =
useExecute("CancelBuild");
const { mutate: run, isPending: runPending } = useExecute("RunBuild", {
onSuccess: () =>
setTimeout(
() => invalidate(["GetBuildActionState"]),
EXECUTION_ACTION_STATE_REQUERY_MS,
),
});
const { mutate: cancel, isPending: cancelPending } = useExecute(
"CancelBuild",
{
onSuccess: () =>
setTimeout(
() => invalidate(["GetBuildActionState"]),
EXECUTION_ACTION_STATE_REQUERY_MS,
),
},
);
const cancelling = useIsCancelling(
{ type: "Build", id },
Types.Operation.RunBuild,
-4
View File
@@ -52,7 +52,6 @@ export default function BuildTable({
id: "Name",
accessorKey: "name",
cell: ({ row }) => <ResourceLink type="Build" id={row.original.id} />,
size: 200,
},
{
header: ({ column }) => (
@@ -61,12 +60,10 @@ export default function BuildTable({
id: "Source",
accessorKey: "info.repo",
cell: ({ row }) => <FileSource info={row.original.info} />,
size: 200,
},
{
header: "Version",
accessorFn: ({ info }) => fmtVersion(info.version),
size: 120,
},
{
id: "State",
@@ -75,7 +72,6 @@ export default function BuildTable({
<SortableHeader column={column} title="State" />
),
cell: ({ row }) => <BuildComponents.State id={row.original.id} />,
size: 120,
},
{
header: "Tags",
+1 -1
View File
@@ -10,7 +10,7 @@ export default function BuilderInstanceType({ id }: { id: string }) {
return (
<DividedChildren wrap="nowrap" gap="xs">
{info.instance_type?.split(",").map((id) => (
<ResourceLink type="Server" id={id.trim()} />
<ResourceLink key={id.trim()} type="Server" id={id.trim()} />
))}
</DividedChildren>
);
+45 -14
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { Group, Select, Stack, Text } from "@mantine/core";
import { useExecute, useListItem, useRead } from "@/lib/hooks";
import { useExecute, useInvalidate, useListItem, useRead } from "@/lib/hooks";
import { Types } from "komodo_client";
import { parseKeyValue } from "@/lib/utils";
import { EXECUTION_ACTION_STATE_REQUERY_MS, parseKeyValue } from "@/lib/utils";
import { useDeployment } from ".";
import { ICONS } from "@/lib/icons";
import { ConfirmButton } from "mogh_ui";
@@ -12,6 +12,17 @@ interface DeploymentId {
id: string;
}
const useInvalidateActionState = () => {
const invalidate = useInvalidate();
return {
onSuccess: () =>
setTimeout(
() => invalidate(["GetDeploymentActionState"]),
EXECUTION_ACTION_STATE_REQUERY_MS,
),
};
};
export function DeployDeployment({ id }: DeploymentId) {
const deployment = useRead("GetDeployment", { deployment: id }).data;
const [signal, setSignal] = useState<Types.TerminationSignal>();
@@ -21,7 +32,10 @@ export function DeployDeployment({ id }: DeploymentId) {
[deployment?.config?.termination_signal],
);
const { mutateAsync: deploy, isPending } = useExecute("Deploy");
const { mutateAsync: deploy, isPending } = useExecute(
"Deploy",
useInvalidateActionState(),
);
const deployment_item = useListItem("Deployment", id);
@@ -90,7 +104,10 @@ export function DestroyDeployment({ id }: DeploymentId) {
[deployment?.config?.termination_signal],
);
const { mutateAsync: destroy, isPending } = useExecute("DestroyDeployment");
const { mutateAsync: destroy, isPending } = useExecute(
"DestroyDeployment",
useInvalidateActionState(),
);
const state = useListItem("Deployment", id)?.info.state;
@@ -137,7 +154,10 @@ export function DestroyDeployment({ id }: DeploymentId) {
export function PullDeployment({ id }: DeploymentId) {
const deployment = useDeployment(id);
const { mutate: pull, isPending: pullPending } = useExecute("PullDeployment");
const { mutate: pull, isPending: pullPending } = useExecute(
"PullDeployment",
useInvalidateActionState(),
);
const action_state = useRead(
"GetDeploymentActionState",
{
@@ -163,8 +183,10 @@ export function PullDeployment({ id }: DeploymentId) {
export function RestartDeployment({ id }: DeploymentId) {
const deployment = useDeployment(id);
const state = deployment?.info.state;
const { mutateAsync: restart, isPending: restartPending } =
useExecute("RestartDeployment");
const { mutateAsync: restart, isPending: restartPending } = useExecute(
"RestartDeployment",
useInvalidateActionState(),
);
const action_state = useRead(
"GetDeploymentActionState",
{
@@ -195,8 +217,10 @@ export function RestartDeployment({ id }: DeploymentId) {
export function StartStopDeployment({ id }: DeploymentId) {
const deployment = useDeployment(id);
const state = deployment?.info.state;
const { mutate: start, isPending: startPending } =
useExecute("StartDeployment");
const { mutate: start, isPending: startPending } = useExecute(
"StartDeployment",
useInvalidateActionState(),
);
const action_state = useRead(
"GetDeploymentActionState",
{
@@ -233,7 +257,10 @@ function StopDeployment({ id }: DeploymentId) {
[deployment?.config?.termination_signal],
);
const { mutateAsync: stop, isPending } = useExecute("StopDeployment");
const { mutateAsync: stop, isPending } = useExecute(
"StopDeployment",
useInvalidateActionState(),
);
const stopping = useRead(
"GetDeploymentActionState",
{
@@ -306,10 +333,14 @@ function TermSignalSelector({
export function PauseUnpauseDeployment({ id }: DeploymentId) {
const deployment = useDeployment(id);
const state = deployment?.info.state;
const { mutate: unpause, isPending: unpausePending } =
useExecute("UnpauseDeployment");
const { mutateAsync: pause, isPending: pausePending } =
useExecute("PauseDeployment");
const { mutate: unpause, isPending: unpausePending } = useExecute(
"UnpauseDeployment",
useInvalidateActionState(),
);
const { mutateAsync: pause, isPending: pausePending } = useExecute(
"PauseDeployment",
useInvalidateActionState(),
);
const action_state = useRead(
"GetDeploymentActionState",
{
+1 -5
View File
@@ -57,7 +57,6 @@ export default function DeploymentTable({
<DeploymentUpdateAvailable id={row.original.id} small />
</Group>
),
size: 200,
},
{
id: "Image",
@@ -72,7 +71,6 @@ export default function DeploymentTable({
},
},
}) => <Image buildId={build_id} image={image} />,
size: 200,
},
{
header: ({ column }) => (
@@ -80,7 +78,7 @@ export default function DeploymentTable({
),
id: "Host",
accessorKey: "info.server_id",
sortingFn: (a, b) => {
sortFn: (a, b) => {
const name_a = a.original.info.swarm_id
? a.original.info.swarm_name
: a.original.info.server_name;
@@ -102,7 +100,6 @@ export default function DeploymentTable({
) : (
<ResourceLink type="Server" id={row.original.info.server_id} />
),
size: 200,
},
{
id: "State",
@@ -113,7 +110,6 @@ export default function DeploymentTable({
cell: ({ row }) => (
<DeploymentComponents.State id={row.original.id} />
),
size: 120,
},
{
header: "Tags",
@@ -131,7 +131,6 @@ export default function ProcedureStage({
columns={[
{
header: "Execution",
size: 250,
cell: ({ row: { original, index } }) => (
<ProcedureExecutionSelector
disabled={disabled}
@@ -160,7 +159,6 @@ export default function ProcedureStage({
},
{
header: "Target",
size: 250,
cell: ({
row: {
original: {
@@ -195,7 +193,6 @@ export default function ProcedureStage({
},
{
header: "Add / Remove",
size: 150,
cell: ({ row: { index } }) => (
<Group>
<ActionIcon
@@ -232,7 +229,6 @@ export default function ProcedureStage({
},
{
header: "Enabled",
size: 100,
cell: ({
row: {
original: { enabled },
+28 -5
View File
@@ -1,20 +1,43 @@
import { useExecute, useIsCancelling, useRead } from "@/lib/hooks";
import {
useExecute,
useInvalidate,
useIsCancelling,
useRead,
} from "@/lib/hooks";
import { useProcedure } from ".";
import { ICONS } from "@/lib/icons";
import ConfirmModalWithDisable from "@/components/confirm-modal-with-disable";
import { ConfirmButton } from "mogh_ui";
import { Types } from "komodo_client";
import { EXECUTION_ACTION_STATE_REQUERY_MS } from "@/lib/utils";
export function RunProcedure({ id }: { id: string }) {
const invalidate = useInvalidate();
const running = useRead(
"GetProcedureActionState",
{ procedure: id },
{ refetchInterval: 5_000 },
).data?.running;
const { mutateAsync: run, isPending: runPending } =
useExecute("RunProcedure");
const { mutateAsync: cancel, isPending: cancelPending } =
useExecute("CancelProcedure");
const { mutateAsync: run, isPending: runPending } = useExecute(
"RunProcedure",
{
onSuccess: () =>
setTimeout(
() => invalidate(["GetProcedureActionState"]),
EXECUTION_ACTION_STATE_REQUERY_MS,
),
},
);
const { mutateAsync: cancel, isPending: cancelPending } = useExecute(
"CancelProcedure",
{
onSuccess: () =>
setTimeout(
() => invalidate(["GetProcedureActionState"]),
EXECUTION_ACTION_STATE_REQUERY_MS,
),
},
);
const procedure = useProcedure(id);
const cancelling = useIsCancelling(
{ type: "Procedure", id },
+1 -1
View File
@@ -67,7 +67,7 @@ export default function ProcedureTable({
header: ({ column }) => (
<SortableHeader column={column} title="Next Run" />
),
sortingFn: (a, b) => {
sortFn: (a, b) => {
const sa = a.original.info.next_scheduled_run;
const sb = b.original.info.next_scheduled_run;
+34 -6
View File
@@ -1,12 +1,32 @@
import { useExecute, usePermissions, useRead } from "@/lib/hooks";
import {
useExecute,
useInvalidate,
usePermissions,
useRead,
} from "@/lib/hooks";
import { useRepo } from ".";
import { useBuilder } from "../builder";
import { Types } from "komodo_client";
import { ConfirmButton } from "mogh_ui";
import { ICONS } from "@/lib/icons";
import { EXECUTION_ACTION_STATE_REQUERY_MS } from "@/lib/utils";
const useInvalidateActionState = () => {
const invalidate = useInvalidate();
return {
onSuccess: () =>
setTimeout(
() => invalidate(["GetRepoActionState"]),
EXECUTION_ACTION_STATE_REQUERY_MS,
),
};
};
export function CloneRepo({ id }: { id: string }) {
const { mutate, isPending } = useExecute("CloneRepo");
const { mutate, isPending } = useExecute(
"CloneRepo",
useInvalidateActionState(),
);
const cloning = useRead(
"GetRepoActionState",
{ repo: id },
@@ -30,7 +50,10 @@ export function CloneRepo({ id }: { id: string }) {
}
export function PullRepo({ id }: { id: string }) {
const { mutate, isPending } = useExecute("PullRepo");
const { mutate, isPending } = useExecute(
"PullRepo",
useInvalidateActionState(),
);
const pulling = useRead(
"GetRepoActionState",
{ repo: id },
@@ -67,9 +90,14 @@ export function BuildRepo({ id }: { id: string }) {
"target.id": id,
},
}).data;
const { mutate: run_mutate, isPending: runPending } = useExecute("BuildRepo");
const { mutate: cancel_mutate, isPending: cancelPending } =
useExecute("CancelRepoBuild");
const { mutate: run_mutate, isPending: runPending } = useExecute(
"BuildRepo",
useInvalidateActionState(),
);
const { mutate: cancel_mutate, isPending: cancelPending } = useExecute(
"CancelRepoBuild",
useInvalidateActionState(),
);
const repo = useRepo(id);
const builder = useBuilder(repo?.info.builder_id);
-4
View File
@@ -51,7 +51,6 @@ export default function RepoTable({
id: "Name",
accessorKey: "name",
cell: ({ row }) => <ResourceLink type="Repo" id={row.original.id} />,
size: 200,
},
{
header: ({ column }) => (
@@ -65,7 +64,6 @@ export default function RepoTable({
link={row.original.info.repo_link}
/>
),
size: 200,
},
{
header: ({ column }) => (
@@ -73,7 +71,6 @@ export default function RepoTable({
),
id: "Branch",
accessorKey: "info.branch",
size: 200,
},
{
header: ({ column }) => (
@@ -82,7 +79,6 @@ export default function RepoTable({
id: "State",
accessorKey: "info.state",
cell: ({ row }) => <RepoComponents.State id={row.original.id} />,
size: 120,
},
{
header: "Tags",
+16 -2
View File
@@ -16,7 +16,7 @@ import { fmtResourceType } from "@/lib/formatting";
import { ICONS } from "@/lib/icons";
import { useDebounce, useSearchCombobox } from "mogh_ui";
import { keepPreviousData } from "@tanstack/react-query";
import { useMemo } from "react";
import { useEffect, useMemo } from "react";
import { useRead } from "@/lib/hooks";
export interface ResourceSelectorProps extends ComboboxProps {
@@ -84,6 +84,14 @@ export default function ResourceSelector({
[__resources],
);
useEffect(() => {
// useSearchCombobox only selects the first option when the search
// input changes, but the options settle later (debounce + fetch),
// which can leave nothing selected. Select the first option
// whenever newly settled options render.
combobox.selectFirstOption();
}, [resources]);
const name = selectedResource?.name;
if (
@@ -115,6 +123,7 @@ export default function ResourceSelector({
<Group gap="xs" ml="sm" wrap="nowrap">
{clearable && (
<ActionIcon
component="div"
size="sm"
variant="filled"
color="red"
@@ -122,7 +131,12 @@ export default function ResourceSelector({
e.stopPropagation();
onSelect?.("");
}}
disabled={disabled || !selected}
data-disabled={disabled || !selected || undefined}
style={
disabled || !selected
? { pointerEvents: "none" }
: undefined
}
>
<ICONS.Clear size="0.8rem" />
</ActionIcon>
@@ -63,7 +63,6 @@ export default function ServerImages({
}
/>
),
size: 200,
},
{
accessorKey: "id",
@@ -85,7 +85,6 @@ export default function ServerNetworks({
/>
</div>
),
size: 300,
},
{
accessorKey: "driver",
@@ -62,7 +62,6 @@ export default function ServerVolumes({
}
/>
),
size: 200,
},
{
accessorKey: "driver",
+15 -2
View File
@@ -1,8 +1,14 @@
import { useExecute, usePermissions, useRead } from "@/lib/hooks";
import {
useExecute,
useInvalidate,
usePermissions,
useRead,
} from "@/lib/hooks";
import { useServer } from ".";
import { ConfirmButton } from "mogh_ui";
import { ICONS } from "@/lib/icons";
import ConfirmModalWithDisable from "@/components/confirm-modal-with-disable";
import { EXECUTION_ACTION_STATE_REQUERY_MS } from "@/lib/utils";
export const Prune = ({
serverId,
@@ -12,7 +18,14 @@ export const Prune = ({
type: "Containers" | "Networks" | "Images" | "Volumes" | "Buildx" | "System";
}) => {
const server = useServer(serverId);
const { mutateAsync: prune, isPending } = useExecute(`Prune${type}`);
const invalidate = useInvalidate();
const { mutateAsync: prune, isPending } = useExecute(`Prune${type}`, {
onSuccess: () =>
setTimeout(
() => invalidate(["GetServerActionState"]),
EXECUTION_ACTION_STATE_REQUERY_MS,
),
});
const action_state = useRead(
"GetServerActionState",
{ server: serverId },
+13 -15
View File
@@ -58,21 +58,19 @@ export function useServerStats(id: string) {
).data;
}
export function useServerThresholds(id: string) {
const isServerAvailable = useIsServerAvailable(id);
const config = useRead(
"GetServer",
{ server: id },
{
enabled: isServerAvailable,
},
).data?.config as any;
export function serverThresholds(server: Types.ServerListItem | undefined) {
const thresholds = server?.info.alerting_thresholds;
return {
cpuWarning: config?.cpu_warning ?? 75,
cpuCritical: config?.cpu_critical ?? 90,
memWarning: config?.mem_warning ?? 75,
memCritical: config?.mem_critical ?? 90,
diskWarning: config?.disk_warning ?? 75,
diskCritical: config?.disk_critical ?? 90,
cpuWarning: thresholds?.cpu_warning ?? 75,
cpuCritical: thresholds?.cpu_critical ?? 90,
memWarning: thresholds?.mem_warning ?? 75,
memCritical: thresholds?.mem_critical ?? 90,
diskWarning: thresholds?.disk_warning ?? 75,
diskCritical: thresholds?.disk_critical ?? 90,
};
}
export function useServerThresholds(id: string) {
const server = useServer(id);
return serverThresholds(server);
}
+9 -14
View File
@@ -14,6 +14,7 @@ import {
} from "@mantine/core";
import { ColorIntention, hexColorByIntention } from "mogh_ui";
import { LucideIcon } from "lucide-react";
import { serverThresholds } from "./hooks";
export interface ServerStatsCardProps {
id: string;
@@ -25,20 +26,14 @@ export default function ServerStatsCard({ id }: ServerStatsCardProps) {
const isServerAvailable = server?.info.state === Types.ServerState.Ok;
const enabled = preferences.showServerStats && isServerAvailable;
const serverDetails = useRead(
"GetServer",
{ server: id },
{
enabled,
},
).data;
const cpuWarning = serverDetails?.config?.cpu_warning ?? 75;
const cpuCritical = serverDetails?.config?.cpu_critical ?? 90;
const memWarning = serverDetails?.config?.mem_warning ?? 75;
const memCritical = serverDetails?.config?.mem_critical ?? 90;
const diskWarning = serverDetails?.config?.disk_warning ?? 75;
const diskCritical = serverDetails?.config?.disk_critical ?? 90;
const {
cpuWarning,
cpuCritical,
memWarning,
memCritical,
diskWarning,
diskCritical,
} = serverThresholds(server);
const intention = (percentage: number, type: "cpu" | "memory" | "disk") => {
const warning =
@@ -58,7 +58,6 @@ export default function ServerContainerStats({ id }: { id: string }) {
columns={[
{
accessorKey: "name",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
@@ -72,14 +71,12 @@ export default function ServerContainerStats({ id }: { id: string }) {
},
{
accessorKey: "stats.cpu_perc",
size: 100,
header: ({ column }) => (
<SortableHeader column={column} title="CPU" />
),
},
{
accessorKey: "stats.mem_perc",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Memory" />
),
@@ -94,21 +91,18 @@ export default function ServerContainerStats({ id }: { id: string }) {
},
{
accessorKey: "stats.net_io",
size: 150,
header: ({ column }) => (
<SortableHeader column={column} title="Net I/O" />
),
},
{
accessorKey: "stats.block_io",
size: 150,
header: ({ column }) => (
<SortableHeader column={column} title="Block I/O" />
),
},
{
accessorKey: "stats.pids",
size: 100,
header: ({ column }) => (
<SortableHeader column={column} title="PIDs" />
),

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