* start 1.19.4

* deploy 1.19.4-dev-1

* try smaller binaries with cargo strip

* deploy 1.19.4-dev-2

* smaller binaries with cargo strip

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

* Implement enhanced error handling and messaging for resource creation

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

* Implement error handling improvements for resource copying and validation feedback

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

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

* fmt

* bump indexmap

* fix account selector showing empty when account no longer found

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

* enforce disable_non_admin_create for tags. Clean up status code responses

* update server cache concurrency controller

* deploy 1.19.4-dev-3

* Allow signing in by pressing enter (#830)

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

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

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

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

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

* Move clicking interaction to CardHeader

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

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

* Format previous change

* Add `default_show_contents` to `handleToggleShow`

---------

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

* deploy 1.19.4-dev-4

* avoid stake info ShowHideButton double toggle

* Allow multiple simultaneous Action runs for use with Args

* deploy 1.19.4-dev-5

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

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

* autofocus on login username field (#837)

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

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

* Enable user query only when JWT is present

* Enable query execution in useRead only if JWT is present

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

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

* Implement server availability checks in various components

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

* cargo fmt

* fmt

* Auth error handling with status codes (#841)

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

* Refactor error messages

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

* clean up

* clean

* fmt

* invalid user id also UNAUTHORIZED

* deploy 1.19.4-dev-6

* deploy 1.19.4-dev-7

---------

Co-authored-by: Marcel Pfennig <82059270+MP-Tool@users.noreply.github.com>
Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com>
Co-authored-by: Guten <ywzhaifei@gmail.com>
Co-authored-by: Paulo Roberto Albuquerque <paulora2405@gmail.com>
Co-authored-by: Lorenzo Farnararo <2814802+baldarn@users.noreply.github.com>
This commit is contained in:
Maxwell Becker
2025-09-14 12:32:06 -07:00
committed by GitHub
parent 20ac04fae5
commit 98d72fc908
65 changed files with 1017 additions and 797 deletions

738
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,13 +8,16 @@ members = [
]
[workspace.package]
version = "1.19.3"
version = "1.19.4-dev-7"
edition = "2024"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
repository = "https://github.com/moghtech/komodo"
homepage = "https://komo.do"
[profile.release]
strip = "debuginfo"
[workspace.dependencies]
# LOCAL
komodo_client = { path = "client/core/rs" }
@@ -33,7 +36,7 @@ git = { path = "lib/git" }
# MOGH
run_command = { version = "0.0.6", features = ["async_tokio"] }
serror = { version = "0.5.0", default-features = false }
serror = { version = "0.5.1", default-features = false }
slack = { version = "0.4.0", package = "slack_client_rs", default-features = false, features = ["rustls"] }
derive_default_builder = "0.1.8"
derive_empty_traits = "0.1.0"
@@ -65,12 +68,12 @@ axum = { version = "0.8.4", features = ["ws", "json", "macros"] }
# SER/DE
ipnetwork = { version = "0.21.1", features = ["serde"] }
indexmap = { version = "2.11.0", features = ["serde"] }
indexmap = { version = "2.11.1", features = ["serde"] }
serde = { version = "1.0.219", features = ["derive"] }
strum = { version = "0.27.2", features = ["derive"] }
bson = { version = "2.15.0" } # must keep in sync with mongodb version
serde_yaml_ng = "0.10.0"
serde_json = "1.0.143"
serde_json = "1.0.145"
serde_qs = "0.15.0"
toml = "0.9.5"
@@ -81,19 +84,19 @@ thiserror = "2.0.16"
# LOGGING
opentelemetry-otlp = { version = "0.30.0", features = ["tls-roots", "reqwest-rustls"] }
opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio"] }
tracing-subscriber = { version = "0.3.19", features = ["json"] }
tracing-subscriber = { version = "0.3.20", features = ["json"] }
opentelemetry-semantic-conventions = "0.30.0"
tracing-opentelemetry = "0.31.0"
opentelemetry = "0.30.0"
tracing = "0.1.41"
# CONFIG
clap = { version = "4.5.45", features = ["derive"] }
clap = { version = "4.5.47", features = ["derive"] }
dotenvy = "0.15.7"
envy = "0.4.2"
# CRYPTO / AUTH
uuid = { version = "1.18.0", features = ["v4", "fast-rng", "serde"] }
uuid = { version = "1.18.1", features = ["v4", "fast-rng", "serde"] }
jsonwebtoken = { version = "9.3.1", default-features = false }
openidconnect = "4.0.1"
urlencoding = "2.1.3"
@@ -112,20 +115,20 @@ bollard = "0.19.2"
sysinfo = "0.37.0"
# CLOUD
aws-config = "1.8.5"
aws-sdk-ec2 = "1.161.0"
aws-credential-types = "1.2.5"
aws-config = "1.8.6"
aws-sdk-ec2 = "1.167.0"
aws-credential-types = "1.2.6"
## CRON
english-to-cron = "0.1.6"
chrono-tz = "0.10.4"
chrono = "0.4.41"
chrono = "0.4.42"
croner = "3.0.0"
# MISC
async-compression = { version = "0.4.28", features = ["tokio", "gzip"] }
async-compression = { version = "0.4.30", features = ["tokio", "gzip"] }
derive_builder = "0.20.2"
comfy-table = "7.1.4"
comfy-table = "7.2.1"
typeshare = "1.0.4"
octorust = "0.10.0"
dashmap = "6.1.0"

View File

@@ -2,6 +2,7 @@
## for a specific architecture.
FROM rust:1.89.0-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -16,7 +17,8 @@ COPY ./bin/cli ./bin/cli
RUN \
cargo build -p komodo_core --release && \
cargo build -p komodo_periphery --release && \
cargo build -p komodo_cli --release
cargo build -p komodo_cli --release && \
cargo strip
# Copy just the binaries to scratch image
FROM scratch

View File

@@ -12,6 +12,7 @@ COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
RUN cargo install cargo-strip
COPY --from=planner /builder/recipe.json recipe.json
# Build JUST dependencies - cached layer
RUN cargo chef cook --release --recipe-path recipe.json
@@ -20,7 +21,8 @@ COPY . .
RUN \
cargo build --release --bin core && \
cargo build --release --bin periphery && \
cargo build --release --bin km
cargo build --release --bin km && \
cargo strip
# Copy just the binaries to scratch image
FROM scratch

View File

@@ -1,4 +1,5 @@
FROM rust:1.89.0-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -8,7 +9,7 @@ COPY ./client/periphery ./client/periphery
COPY ./bin/cli ./bin/cli
# Compile bin
RUN cargo build -p komodo_cli --release
RUN cargo build -p komodo_cli --release && cargo strip
# Copy binaries to distroless base
FROM gcr.io/distroless/cc

View File

@@ -2,6 +2,7 @@
# Build Core
FROM rust:1.89.0-bullseye AS core-builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -13,7 +14,8 @@ COPY ./bin/cli ./bin/cli
# Compile app
RUN cargo build -p komodo_core --release && \
cargo build -p komodo_cli --release
cargo build -p komodo_cli --release && \
cargo strip
# Build Frontend
FROM node:20.12-alpine AS frontend-builder

View File

@@ -3,11 +3,12 @@ use std::{sync::OnceLock, time::Instant};
use axum::{Router, extract::Path, http::HeaderMap, routing::post};
use derive_variants::{EnumVariants, ExtractVariant};
use komodo_client::{api::auth::*, entities::user::User};
use reqwest::StatusCode;
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::Json;
use serror::{AddStatusCode, Json};
use typeshare::typeshare;
use uuid::Uuid;
@@ -152,7 +153,11 @@ impl Resolve<AuthArgs> for GetUser {
self,
AuthArgs { headers }: &AuthArgs,
) -> serror::Result<User> {
let user_id = get_user_id_from_headers(headers).await?;
Ok(get_user(&user_id).await?)
let user_id = get_user_id_from_headers(headers)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
get_user(&user_id)
.await
.status_code(StatusCode::UNAUTHORIZED)
}
}

View File

@@ -92,8 +92,11 @@ impl Resolve<ExecuteArgs> for RunAction {
// This will set action state back to default when dropped.
// Will also check to ensure action not already busy before updating.
let _action_guard =
action_state.update(|state| state.running = true)?;
let _action_guard = action_state.update_custom(
|state| state.running += 1,
|state| state.running -= 1,
false,
)?;
let mut update = update.clone();

View File

@@ -222,7 +222,7 @@ impl Resolve<ExecuteArgs> for Deploy {
}
};
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -343,7 +343,7 @@ pub async fn pull_deployment_inner(
Err(e) => Log::error("Pull image", format_serror(&e.into())),
};
update_cache_for_server(server).await;
update_cache_for_server(server, true).await;
anyhow::Ok(log)
}
.await;
@@ -428,7 +428,7 @@ impl Resolve<ExecuteArgs> for StartDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -477,7 +477,7 @@ impl Resolve<ExecuteArgs> for RestartDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -524,7 +524,7 @@ impl Resolve<ExecuteArgs> for PauseDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -573,7 +573,7 @@ impl Resolve<ExecuteArgs> for UnpauseDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -628,7 +628,7 @@ impl Resolve<ExecuteArgs> for StopDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -711,7 +711,7 @@ impl Resolve<ExecuteArgs> for DestroyDeployment {
update.logs.push(log);
update.finalize();
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update_update(update.clone()).await?;
Ok(update)

View File

@@ -49,7 +49,7 @@ impl Resolve<ExecuteArgs> for ClearRepoCache {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
@@ -124,7 +124,7 @@ impl Resolve<ExecuteArgs> for BackupCoreDatabase {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
@@ -173,7 +173,7 @@ impl Resolve<ExecuteArgs> for GlobalAutoUpdate {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}

View File

@@ -66,7 +66,7 @@ impl Resolve<ExecuteArgs> for StartContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -122,7 +122,7 @@ impl Resolve<ExecuteArgs> for RestartContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -176,7 +176,7 @@ impl Resolve<ExecuteArgs> for PauseContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -232,7 +232,7 @@ impl Resolve<ExecuteArgs> for UnpauseContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -288,7 +288,7 @@ impl Resolve<ExecuteArgs> for StopContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -350,7 +350,7 @@ impl Resolve<ExecuteArgs> for DestroyContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -401,7 +401,7 @@ impl Resolve<ExecuteArgs> for StartAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -453,7 +453,7 @@ impl Resolve<ExecuteArgs> for RestartAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -503,7 +503,7 @@ impl Resolve<ExecuteArgs> for PauseAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -555,7 +555,7 @@ impl Resolve<ExecuteArgs> for UnpauseAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -605,7 +605,7 @@ impl Resolve<ExecuteArgs> for StopAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -660,7 +660,7 @@ impl Resolve<ExecuteArgs> for PruneContainers {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -711,7 +711,7 @@ impl Resolve<ExecuteArgs> for DeleteNetwork {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -765,7 +765,7 @@ impl Resolve<ExecuteArgs> for PruneNetworks {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -813,7 +813,7 @@ impl Resolve<ExecuteArgs> for DeleteImage {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -865,7 +865,7 @@ impl Resolve<ExecuteArgs> for PruneImages {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -916,7 +916,7 @@ impl Resolve<ExecuteArgs> for DeleteVolume {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -968,7 +968,7 @@ impl Resolve<ExecuteArgs> for PruneVolumes {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -1020,7 +1020,7 @@ impl Resolve<ExecuteArgs> for PruneDockerBuilders {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -1072,7 +1072,7 @@ impl Resolve<ExecuteArgs> for PruneBuildx {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -1123,7 +1123,7 @@ impl Resolve<ExecuteArgs> for PruneSystem {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;

View File

@@ -260,7 +260,7 @@ impl Resolve<ExecuteArgs> for DeployStack {
}
// Ensure cached stack state up to date by updating server cache
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -761,7 +761,7 @@ pub async fn pull_stack_inner(
.await?;
// Ensure cached stack state up to date by updating server cache
update_cache_for_server(server).await;
update_cache_for_server(server, true).await;
Ok(res)
}

View File

@@ -77,10 +77,8 @@ impl Resolve<ExecuteArgs> for RunSync {
};
// get the action state for the sync (or insert default).
let action_state = action_states()
.resource_sync
.get_or_insert_default(&sync.id)
.await;
let action_state =
action_states().sync.get_or_insert_default(&sync.id).await;
// This will set action state back to default when dropped.
// Will also check to ensure sync not already busy before updating.

View File

@@ -131,8 +131,8 @@ impl Resolve<ReadArgs> for GetActionsSummary {
.unwrap_or_default()
.get()?,
) {
(_, action_states) if action_states.running => {
res.running += 1;
(_, action_states) if action_states.running > 0 => {
res.running += action_states.running;
}
(ActionState::Ok, _) => res.ok += 1,
(ActionState::Failed, _) => res.failed += 1,

View File

@@ -93,7 +93,7 @@ impl Resolve<ReadArgs> for GetResourceSyncActionState {
)
.await?;
let action_state = action_states()
.resource_sync
.sync
.get(&sync.id)
.await
.unwrap_or_default()
@@ -138,7 +138,7 @@ impl Resolve<ReadArgs> for GetResourceSyncsSummary {
continue;
}
if action_states
.resource_sync
.sync
.get(&resource_sync.id)
.await
.unwrap_or_default()

View File

@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateAction {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Action> {
Ok(
resource::create::<Action>(&self.name, self.config, user)
.await?,
)
resource::create::<Action>(&self.name, self.config, user).await
}
}
@@ -35,10 +32,7 @@ impl Resolve<WriteArgs> for CopyAction {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Action>(&self.name, config.into(), user)
.await?,
)
resource::create::<Action>(&self.name, config.into(), user).await
}
}

View File

@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateAlerter {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Alerter> {
Ok(
resource::create::<Alerter>(&self.name, self.config, user)
.await?,
)
resource::create::<Alerter>(&self.name, self.config, user).await
}
}
@@ -35,10 +32,7 @@ impl Resolve<WriteArgs> for CopyAlerter {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Alerter>(&self.name, config.into(), user)
.await?,
)
resource::create::<Alerter>(&self.name, config.into(), user).await
}
}

View File

@@ -50,10 +50,7 @@ impl Resolve<WriteArgs> for CreateBuild {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Build> {
Ok(
resource::create::<Build>(&self.name, self.config, user)
.await?,
)
resource::create::<Build>(&self.name, self.config, user).await
}
}
@@ -71,10 +68,7 @@ impl Resolve<WriteArgs> for CopyBuild {
.await?;
// reset version to 0.0.0
config.version = Default::default();
Ok(
resource::create::<Build>(&self.name, config.into(), user)
.await?,
)
resource::create::<Build>(&self.name, config.into(), user).await
}
}

View File

@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateBuilder {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Builder> {
Ok(
resource::create::<Builder>(&self.name, self.config, user)
.await?,
)
resource::create::<Builder>(&self.name, self.config, user).await
}
}
@@ -35,10 +32,7 @@ impl Resolve<WriteArgs> for CopyBuilder {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Builder>(&self.name, config.into(), user)
.await?,
)
resource::create::<Builder>(&self.name, config.into(), user).await
}
}

View File

@@ -38,10 +38,8 @@ impl Resolve<WriteArgs> for CreateDeployment {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Deployment> {
Ok(
resource::create::<Deployment>(&self.name, self.config, user)
.await?,
)
.await
}
}
@@ -58,10 +56,8 @@ impl Resolve<WriteArgs> for CopyDeployment {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Deployment>(&self.name, config.into(), user)
.await?,
)
.await
}
}
@@ -153,10 +149,7 @@ impl Resolve<WriteArgs> for CreateDeploymentFromContainer {
});
}
Ok(
resource::create::<Deployment>(&self.name, config, user)
.await?,
)
resource::create::<Deployment>(&self.name, config, user).await
}
}

View File

@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateProcedure {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<CreateProcedureResponse> {
Ok(
resource::create::<Procedure>(&self.name, self.config, user)
.await?,
)
resource::create::<Procedure>(&self.name, self.config, user).await
}
}
@@ -36,10 +33,8 @@ impl Resolve<WriteArgs> for CopyProcedure {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Procedure>(&self.name, config.into(), user)
.await?,
)
.await
}
}

View File

@@ -42,7 +42,7 @@ impl Resolve<WriteArgs> for CreateRepo {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Repo> {
Ok(resource::create::<Repo>(&self.name, self.config, user).await?)
resource::create::<Repo>(&self.name, self.config, user).await
}
}
@@ -58,10 +58,7 @@ impl Resolve<WriteArgs> for CopyRepo {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Repo>(&self.name, config.into(), user)
.await?,
)
resource::create::<Repo>(&self.name, config.into(), user).await
}
}

View File

@@ -30,10 +30,7 @@ impl Resolve<WriteArgs> for CreateServer {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Server> {
Ok(
resource::create::<Server>(&self.name, self.config, user)
.await?,
)
resource::create::<Server>(&self.name, self.config, user).await
}
}
@@ -49,10 +46,8 @@ impl Resolve<WriteArgs> for CopyServer {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Server>(&self.name, config.into(), user)
.await?,
)
resource::create::<Server>(&self.name, config.into(), user).await
}
}

View File

@@ -51,10 +51,7 @@ impl Resolve<WriteArgs> for CreateStack {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Stack> {
Ok(
resource::create::<Stack>(&self.name, self.config, user)
.await?,
)
resource::create::<Stack>(&self.name, self.config, user).await
}
}
@@ -70,10 +67,8 @@ impl Resolve<WriteArgs> for CopyStack {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Stack>(&self.name, config.into(), user)
.await?,
)
resource::create::<Stack>(&self.name, config.into(), user).await
}
}

View File

@@ -68,10 +68,8 @@ impl Resolve<WriteArgs> for CreateResourceSync {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<ResourceSync> {
Ok(
resource::create::<ResourceSync>(&self.name, self.config, user)
.await?,
)
.await
}
}
@@ -88,14 +86,8 @@ impl Resolve<WriteArgs> for CopyResourceSync {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<ResourceSync>(
&self.name,
config.into(),
user,
)
.await?,
)
resource::create::<ResourceSync>(&self.name, config.into(), user)
.await
}
}

View File

@@ -13,9 +13,12 @@ use komodo_client::{
server::Server, stack::Stack, sync::ResourceSync, tag::Tag,
},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::{
config::core_config,
helpers::query::{get_tag, get_tag_check_owner},
resource,
state::db_client,
@@ -29,8 +32,18 @@ impl Resolve<WriteArgs> for CreateTag {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Tag> {
if core_config().disable_non_admin_create && !user.admin {
return Err(
anyhow!("Non admins cannot create tags")
.status_code(StatusCode::FORBIDDEN),
);
}
if ObjectId::from_str(&self.name).is_ok() {
return Err(anyhow!("tag name cannot be ObjectId").into());
return Err(
anyhow!("Tag name cannot be ObjectId")
.status_code(StatusCode::BAD_REQUEST),
);
}
let mut tag = Tag {

View File

@@ -32,7 +32,7 @@ impl Resolve<WriteArgs> for CreateLocalUser {
if !admin.admin {
return Err(
anyhow!("This method is admin-only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
@@ -183,7 +183,7 @@ impl Resolve<WriteArgs> for DeleteUser {
if !admin.admin {
return Err(
anyhow!("This method is admin-only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
if admin.username == self.user || admin.id == self.user {

View File

@@ -10,7 +10,9 @@ use komodo_client::{
api::write::*,
entities::{komodo_timestamp, user_group::UserGroup},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::state::db_client;
@@ -23,7 +25,10 @@ impl Resolve<WriteArgs> for CreateUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let user_group = UserGroup {
name: self.name,
@@ -58,7 +63,10 @@ impl Resolve<WriteArgs> for RenameUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
update_one_by_id(
@@ -84,7 +92,10 @@ impl Resolve<WriteArgs> for DeleteUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -117,7 +128,10 @@ impl Resolve<WriteArgs> for AddUserToUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -161,7 +175,10 @@ impl Resolve<WriteArgs> for RemoveUserFromUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -205,7 +222,10 @@ impl Resolve<WriteArgs> for SetUsersInUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -252,7 +272,10 @@ impl Resolve<WriteArgs> for SetEveryoneUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();

View File

@@ -4,7 +4,9 @@ use komodo_client::{
api::write::*,
entities::{Operation, ResourceTarget, variable::Variable},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::{
helpers::{
@@ -22,6 +24,13 @@ impl Resolve<WriteArgs> for CreateVariable {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<CreateVariableResponse> {
if !user.admin {
return Err(
anyhow!("Only admins can create variables")
.status_code(StatusCode::FORBIDDEN),
);
}
let CreateVariable {
name,
value,
@@ -29,10 +38,6 @@ impl Resolve<WriteArgs> for CreateVariable {
is_secret,
} = self;
if !user.admin {
return Err(anyhow!("only admins can create variables").into());
}
let variable = Variable {
name,
value,
@@ -44,7 +49,7 @@ impl Resolve<WriteArgs> for CreateVariable {
.variables
.insert_one(&variable)
.await
.context("failed to create variable on db")?;
.context("Failed to create variable on db")?;
let mut update = make_update(
ResourceTarget::system(),
@@ -69,7 +74,10 @@ impl Resolve<WriteArgs> for UpdateVariableValue {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateVariableValueResponse> {
if !user.admin {
return Err(anyhow!("only admins can update variables").into());
return Err(
anyhow!("Only admins can update variables")
.status_code(StatusCode::FORBIDDEN),
);
}
let UpdateVariableValue { name, value } = self;
@@ -87,7 +95,7 @@ impl Resolve<WriteArgs> for UpdateVariableValue {
doc! { "$set": { "value": &value } },
)
.await
.context("failed to update variable value on db")?;
.context("Failed to update variable value on db")?;
let mut update = make_update(
ResourceTarget::system(),
@@ -107,7 +115,7 @@ impl Resolve<WriteArgs> for UpdateVariableValue {
)
};
update.push_simple_log("update variable value", log);
update.push_simple_log("Update Variable Value", log);
update.finalize();
add_update(update).await?;
@@ -123,7 +131,10 @@ impl Resolve<WriteArgs> for UpdateVariableDescription {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateVariableDescriptionResponse> {
if !user.admin {
return Err(anyhow!("only admins can update variables").into());
return Err(
anyhow!("Only admins can update variables")
.status_code(StatusCode::FORBIDDEN),
);
}
db_client()
.variables
@@ -132,7 +143,7 @@ impl Resolve<WriteArgs> for UpdateVariableDescription {
doc! { "$set": { "description": &self.description } },
)
.await
.context("failed to update variable description on db")?;
.context("Failed to update variable description on db")?;
Ok(get_variable(&self.name).await?)
}
}
@@ -144,7 +155,10 @@ impl Resolve<WriteArgs> for UpdateVariableIsSecret {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateVariableIsSecretResponse> {
if !user.admin {
return Err(anyhow!("only admins can update variables").into());
return Err(
anyhow!("Only admins can update variables")
.status_code(StatusCode::FORBIDDEN),
);
}
db_client()
.variables
@@ -153,7 +167,7 @@ impl Resolve<WriteArgs> for UpdateVariableIsSecret {
doc! { "$set": { "is_secret": self.is_secret } },
)
.await
.context("failed to update variable is secret on db")?;
.context("Failed to update variable is secret on db")?;
Ok(get_variable(&self.name).await?)
}
}
@@ -164,14 +178,17 @@ impl Resolve<WriteArgs> for DeleteVariable {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<DeleteVariableResponse> {
if !user.admin {
return Err(anyhow!("only admins can delete variables").into());
return Err(
anyhow!("Only admins can delete variables")
.status_code(StatusCode::FORBIDDEN),
);
}
let variable = get_variable(&self.name).await?;
db_client()
.variables
.delete_one(doc! { "name": &self.name })
.await
.context("failed to delete variable on db")?;
.context("Failed to delete variable on db")?;
let mut update = make_update(
ResourceTarget::system(),
@@ -180,7 +197,7 @@ impl Resolve<WriteArgs> for DeleteVariable {
);
update
.push_simple_log("delete variable", format!("{variable:#?}"));
.push_simple_log("Delete Variable", format!("{variable:#?}"));
update.finalize();
add_update(update).await?;

View File

@@ -16,17 +16,16 @@ use super::cache::Cache;
#[derive(Default)]
pub struct ActionStates {
pub build: Cache<String, Arc<ActionState<BuildActionState>>>,
pub server: Cache<String, Arc<ActionState<ServerActionState>>>,
pub stack: Cache<String, Arc<ActionState<StackActionState>>>,
pub deployment:
Cache<String, Arc<ActionState<DeploymentActionState>>>,
pub server: Cache<String, Arc<ActionState<ServerActionState>>>,
pub build: Cache<String, Arc<ActionState<BuildActionState>>>,
pub repo: Cache<String, Arc<ActionState<RepoActionState>>>,
pub procedure:
Cache<String, Arc<ActionState<ProcedureActionState>>>,
pub action: Cache<String, Arc<ActionState<ActionActionState>>>,
pub resource_sync:
Cache<String, Arc<ActionState<ResourceSyncActionState>>>,
pub stack: Cache<String, Arc<ActionState<StackActionState>>>,
pub sync: Cache<String, Arc<ActionState<ResourceSyncActionState>>>,
}
/// Need to be able to check "busy" with write lock acquired.
@@ -62,17 +61,33 @@ impl<States: Default + Busy + Copy + Send + 'static>
/// Returns a guard that returns the states to default (not busy) when dropped.
pub fn update(
&self,
handler: impl Fn(&mut States),
update_fn: impl Fn(&mut States),
) -> anyhow::Result<UpdateGuard<'_, States>> {
self.update_custom(
update_fn,
|states| *states = Default::default(),
true,
)
}
/// Will acquire lock, optionally check busy, and if not will
/// run the provided update function on the states.
/// Returns a guard that calls the provided return_fn when dropped.
pub fn update_custom(
&self,
update_fn: impl Fn(&mut States),
return_fn: impl Fn(&mut States) + Send + 'static,
busy_check: bool,
) -> anyhow::Result<UpdateGuard<'_, States>> {
let mut lock = self
.0
.lock()
.map_err(|e| anyhow!("action state lock poisoned | {e:?}"))?;
if lock.busy() {
return Err(anyhow!("resource is busy"));
.map_err(|e| anyhow!("Action state lock poisoned | {e:?}"))?;
if busy_check && lock.busy() {
return Err(anyhow!("Resource is busy"));
}
handler(&mut *lock);
Ok(UpdateGuard(&self.0))
update_fn(&mut *lock);
Ok(UpdateGuard(&self.0, Box::new(return_fn)))
}
}
@@ -82,6 +97,7 @@ impl<States: Default + Busy + Copy + Send + 'static>
/// user could drop UpdateGuard.
pub struct UpdateGuard<'a, States: Default + Send + 'static>(
&'a Mutex<States>,
Box<dyn Fn(&mut States) + Send>,
);
impl<States: Default + Send + 'static> Drop
@@ -95,6 +111,6 @@ impl<States: Default + Send + 'static> Drop
return;
}
};
*lock = States::default();
self.1(&mut *lock);
}
}

View File

@@ -13,14 +13,18 @@ use database::mungos::{
options::FindOneOptions,
},
};
use komodo_client::entities::{
use komodo_client::{
busy::Busy,
entities::{
Operation, ResourceTarget, ResourceTargetVariant,
action::{Action, ActionState},
alerter::Alerter,
build::Build,
builder::Builder,
deployment::{Deployment, DeploymentState},
docker::container::{ContainerListItem, ContainerStateStatusEnum},
docker::container::{
ContainerListItem, ContainerStateStatusEnum,
},
permission::{PermissionLevel, PermissionLevelAndSpecifics},
procedure::{Procedure, ProcedureState},
repo::Repo,
@@ -33,6 +37,7 @@ use komodo_client::entities::{
user::{User, admin_service_user},
user_group::UserGroup,
variable::Variable,
},
};
use periphery_client::api::stats;
use tokio::sync::Mutex;
@@ -467,7 +472,7 @@ pub async fn get_action_state(id: &String) -> ActionState {
.action
.get(id)
.await
.map(|s| s.get().map(|s| s.running))
.map(|s| s.get().map(|s| s.busy()))
.transpose()
.ok()
.flatten()
@@ -483,7 +488,7 @@ pub async fn get_procedure_state(id: &String) -> ProcedureState {
.procedure
.get(id)
.await
.map(|s| s.get().map(|s| s.running))
.map(|s| s.get().map(|s| s.busy()))
.transpose()
.ok()
.flatten()

View File

@@ -1,3 +1,5 @@
use std::sync::{Arc, OnceLock};
use async_timing_util::wait_until_timelength;
use database::mungos::{find::find_collect, mongodb::bson::doc};
use futures::future::join_all;
@@ -15,10 +17,11 @@ use komodo_client::entities::{
};
use periphery_client::api::{self, git::GetLatestCommit};
use serror::Serror;
use tokio::sync::Mutex;
use crate::{
config::core_config,
helpers::periphery_client,
helpers::{cache::Cache, periphery_client},
monitor::{alert::check_alerts, record::record_server_stats},
state::{db_client, deployment_status_cache, repo_status_cache},
};
@@ -110,14 +113,47 @@ async fn refresh_server_cache(ts: i64) {
}
};
let futures = servers.into_iter().map(|server| async move {
update_cache_for_server(&server).await;
update_cache_for_server(&server, false).await;
});
join_all(futures).await;
tokio::join!(check_alerts(ts), record_server_stats(ts));
}
/// Makes sure cache for server doesn't update too frequently / simultaneously.
/// If forced, will still block against simultaneous update.
fn update_cache_for_server_controller()
-> &'static Cache<String, Arc<Mutex<i64>>> {
static CACHE: OnceLock<Cache<String, Arc<Mutex<i64>>>> =
OnceLock::new();
CACHE.get_or_init(Default::default)
}
/// The background loop will call this with force: false,
/// which exits early if the lock is busy or it was completed too recently.
/// If force is true, it will wait on simultaneous calls, and will
/// ignore the restriction on being completed too recently.
#[instrument(level = "debug")]
pub async fn update_cache_for_server(server: &Server) {
pub async fn update_cache_for_server(server: &Server, force: bool) {
// Concurrency controller to ensure it isn't done too often
// when it happens in other contexts.
let controller = update_cache_for_server_controller()
.get_or_insert_default(&server.id)
.await;
let mut lock = match controller.try_lock() {
Ok(lock) => lock,
Err(_) if force => controller.lock().await,
Err(_) => return,
};
let now = komodo_timestamp();
// early return if called again sooner than 1s.
if !force && *lock > now - 1_000 {
return;
}
*lock = now;
let (deployments, builds, repos, stacks) = tokio::join!(
find_collect(
&db_client().deployments,

View File

@@ -188,7 +188,7 @@ impl super::KomodoResource for Deployment {
else {
return Ok(());
};
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
Ok(())
}

View File

@@ -34,8 +34,10 @@ use komodo_client::{
parsers::parse_string_list,
};
use partial_derive2::{Diff, MaybeNone, PartialDiff};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serde::{Serialize, de::DeserializeOwned};
use serror::AddStatusCodeError;
use crate::{
api::{read::ReadArgs, write::WriteArgs},
@@ -458,22 +460,31 @@ pub async fn create<T: KomodoResource>(
name: &str,
mut config: T::PartialConfig,
user: &User,
) -> anyhow::Result<Resource<T::Config, T::Info>> {
) -> serror::Result<Resource<T::Config, T::Info>> {
if !T::user_can_create(user) {
return Err(anyhow!(
return Err(
anyhow!(
"User does not have permissions to create {}.",
T::resource_type()
));
)
.status_code(StatusCode::FORBIDDEN),
);
}
if name.is_empty() {
return Err(anyhow!("Must provide non-empty name for resource."));
return Err(
anyhow!("Must provide non-empty name for resource")
.status_code(StatusCode::BAD_REQUEST),
);
}
let name = T::validated_name(name);
if ObjectId::from_str(&name).is_ok() {
return Err(anyhow!("valid ObjectIds cannot be used as names."));
return Err(
anyhow!("Valid ObjectIds cannot be used as names")
.status_code(StatusCode::BAD_REQUEST),
);
}
// Ensure an existing resource with same name doesn't already exist
@@ -489,7 +500,10 @@ pub async fn create<T: KomodoResource>(
.into_iter()
.any(|r| r.name == name)
{
return Err(anyhow!("Must provide unique name for resource."));
return Err(
anyhow!("Resource with name '{}' already exists", name)
.status_code(StatusCode::CONFLICT),
);
}
let start_ts = komodo_timestamp();

View File

@@ -123,7 +123,7 @@ impl super::KomodoResource for Server {
created: &Resource<Self::Config, Self::Info>,
_update: &mut Update,
) -> anyhow::Result<()> {
update_cache_for_server(created).await;
update_cache_for_server(created, true).await;
Ok(())
}
@@ -145,7 +145,7 @@ impl super::KomodoResource for Server {
updated: &Self,
_update: &mut Update,
) -> anyhow::Result<()> {
update_cache_for_server(updated).await;
update_cache_for_server(updated, true).await;
Ok(())
}

View File

@@ -252,7 +252,7 @@ impl super::KomodoResource for Stack {
else {
return Ok(());
};
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
Ok(())
}

View File

@@ -113,7 +113,7 @@ impl super::KomodoResource for ResourceSync {
async fn busy(id: &String) -> anyhow::Result<bool> {
action_states()
.resource_sync
.sync
.get(id)
.await
.unwrap_or_default()
@@ -242,7 +242,7 @@ async fn get_resource_sync_state(
data: &ResourceSyncInfo,
) -> ResourceSyncState {
if let Some(state) = action_states()
.resource_sync
.sync
.get(id)
.await
.and_then(|s| {

View File

@@ -72,7 +72,7 @@ pub async fn execute_compose<T: ExecuteCompose>(
.push(T::execute(periphery, stack, services, extras).await?);
// Ensure cached stack state up to date by updating server cache
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;

View File

@@ -40,6 +40,7 @@ pub fn db_client() -> &'static Client {
.expect("db_client accessed before initialized")
}
/// Must be called in app startup sequence.
pub async fn init_db_client() {
let client = Client::new(&core_config().database)
.await

View File

@@ -147,6 +147,7 @@ pub trait ExecuteResourceSync: ResourceSyncTrait {
sync_user(),
)
.await
.map_err(|e| e.error)
{
Ok(resource) => resource.id,
Err(e) => {

View File

@@ -825,6 +825,7 @@ impl ExecuteResourceSync for Procedure {
sync_user(),
)
.await
.map_err(|e| e.error)
{
Ok(resource) => resource.id,
Err(e) => {

View File

@@ -1,6 +1,7 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
FROM rust:1.89.0-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -10,7 +11,7 @@ COPY ./client/periphery ./client/periphery
COPY ./bin/periphery ./bin/periphery
# Compile app
RUN cargo build -p komodo_periphery --release
RUN cargo build -p komodo_periphery --release && cargo strip
# Final Image
FROM debian:bullseye-slim

View File

@@ -68,7 +68,7 @@ impl Busy for ProcedureActionState {
impl Busy for ActionActionState {
fn busy(&self) -> bool {
self.running
self.running > 0
}
}

View File

@@ -1,5 +1,4 @@
use serde::{
__private::de::{Content, ContentDeserializer},
Deserialize, Deserializer,
de::{IntoDeserializer, Visitor},
};
@@ -69,17 +68,15 @@ impl<'de, T: Deserialize<'de>> Visitor<'de>
let mut res =
Vec::with_capacity(seq.size_hint().unwrap_or_default());
loop {
match seq.next_element::<Content>() {
Ok(Some(content)) => {
match T::deserialize::<ContentDeserializer<'_, S::Error>>(
content.clone().into_deserializer(),
) {
match seq.next_element::<serde_json::Value>() {
Ok(Some(value)) => {
match T::deserialize(value.clone().into_deserializer()) {
Ok(item) => res.push(item),
Err(e) => {
// Since this is used to parse startup config (including logging config),
// the tracing logging is not initialized. Need to use eprintln.
eprintln!(
"WARN: failed to parse item in list | {content:?} | {e:?}",
"WARN: failed to parse item in list | {value:?} | {e:?}",
)
}
}

View File

@@ -222,8 +222,8 @@ impl Default for ActionConfig {
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)]
pub struct ActionActionState {
/// Whether the action is currently running.
pub running: bool,
/// Number of instances of the Action currently running
pub running: u32,
}
#[typeshare]

View File

@@ -1,6 +1,6 @@
{
"name": "komodo_client",
"version": "1.19.3",
"version": "1.19.4",
"description": "Komodo client package",
"homepage": "https://komo.do",
"main": "dist/lib.js",

View File

@@ -1351,8 +1351,8 @@ export type ExportResourcesToTomlResponse = TomlResponse;
export type FindUserResponse = User;
export interface ActionActionState {
/** Whether the action is currently running. */
running: boolean;
/** Number of instances of the Action currently running */
running: number;
}
export type GetActionActionStateResponse = ActionActionState;

View File

@@ -16,3 +16,4 @@ These provide alerting implementations which can be used with the `Custom` Alert
- [Ntfy](https://github.com/FoxxMD/deploy-ntfy-alerter) by [FoxxMD](https://github.com/FoxxMD)
- [Gotify](https://github.com/FoxxMD/deploy-gotify-alerter) by [FoxxMD](https://github.com/FoxxMD)
- [Apprise](https://github.com/FoxxMD/deploy-apprise-alerter) by [FoxxMD](https://github.com/FoxxMD)
- [Email](https://github.com/gutenye/email-notification/blob/main/src/templates/Komodo/Komodo.md) by [Guten Ye](https://github.com/gutenye)

View File

@@ -1457,8 +1457,8 @@ export type ExportAllResourcesToTomlResponse = TomlResponse;
export type ExportResourcesToTomlResponse = TomlResponse;
export type FindUserResponse = User;
export interface ActionActionState {
/** Whether the action is currently running. */
running: boolean;
/** Number of instances of the Action currently running */
running: number;
}
export type GetActionActionStateResponse = ActionActionState;
export type GetActionResponse = Action;

View File

@@ -136,12 +136,12 @@ const GroupActionDialog = ({
return (
<Dialog open={!!action} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Group Execute - {formatted}</DialogTitle>
</DialogHeader>
<div className="py-8 flex flex-col gap-4">
<ul className="p-4 bg-accent text-sm list-disc list-inside">
<ul className="p-4 bg-accent text-sm list-disc list-inside max-h-[300px] overflow-y-auto">
{selected.map((resource) => (
<li key={resource}>{resource}</li>
))}

View File

@@ -254,8 +254,11 @@ export const NewLayout = ({
try {
await onConfirm();
set(false);
} catch (error) {
console.error("Error creating resource:", error);
} catch (error: any) {
const status = error?.status || error?.response?.status;
if (status !== 409 && status !== 400) {
set(false);
}
} finally {
setLoading(false);
}

View File

@@ -141,13 +141,7 @@ export const MonacoEditor = ({
)}px`;
}, [editor, line_count]);
const { theme: _theme } = useTheme();
const theme =
_theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: _theme;
const { currentTheme } = useTheme();
const options: monaco.editor.IStandaloneEditorConstructionOptions = {
minimap: { enabled: false },
@@ -171,7 +165,7 @@ export const MonacoEditor = ({
<Editor
language={language}
value={value}
theme={theme}
theme={currentTheme}
defaultPath={defaultPath(filename)}
options={options}
onChange={(v) => onValueChange?.(v ?? "")}
@@ -233,13 +227,7 @@ export const MonacoDiffEditor = ({
)}px`;
}, [editor, line_count]);
const { theme: _theme } = useTheme();
const theme =
_theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: _theme;
const { currentTheme } = useTheme();
const options: monaco.editor.IStandaloneDiffEditorConstructionOptions = {
minimap: { enabled: true },
@@ -262,7 +250,7 @@ export const MonacoDiffEditor = ({
language={language}
original={original}
modified={modified}
theme={theme}
theme={currentTheme}
options={options}
onMount={(editor) => {
const modifiedEditor = editor.getModifiedEditor();

View File

@@ -119,11 +119,12 @@ export const ActionComponents: RequiredResourceComponents = {
Actions: {
RunAction: ({ id }) => {
const running = useRead(
const running =
(useRead(
"GetActionActionState",
{ action: id },
{ refetchInterval: 5000 }
).data?.running;
).data?.running ?? 0) > 0;
const { mutate, isPending } = useExecute("RunAction");
const action = useAction(id);
if (!action) return null;

View File

@@ -439,12 +439,23 @@ export const CopyResource = ({
const nav = useNavigate();
const inv = useInvalidate();
const { mutate } = useWrite(`Copy${type}`, {
onSuccess: (res) => {
const { mutateAsync: copy } = useWrite(`Copy${type}`);
const onConfirm = async () => {
if (!name) return;
try {
const res = await copy({ id, name });
inv([`List${type}s`]);
nav(`/${usableResourcePath(type)}/${res._id?.$oid}`);
},
});
setOpen(false);
} catch (error: any) {
// Keep dialog open for validation errors (409/400), close for system errors
const status = error?.status || error?.response?.status;
if (status !== 409 && status !== 400) {
setOpen(false);
}
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -472,9 +483,8 @@ export const CopyResource = ({
title="Copy"
icon={<Check className="w-4 h-4" />}
disabled={!name}
onClick={() => {
mutate({ id, name });
setOpen(false);
onClick={async () => {
await onConfirm();
}}
/>
</DialogFooter>
@@ -526,10 +536,13 @@ export const NewResource = ({
: {};
const onConfirm = async () => {
if (!name) toast({ title: "Name cannot be empty" });
const id = templateId
? (await copy({ name, id: templateId }))._id?.$oid!
: (await create({ name, config }))._id?.$oid!;
nav(`/${usableResourcePath(type)}/${id}`);
const result = templateId
? await copy({ name, id: templateId })
: await create({ name, config });
const resourceId = result._id?.$oid;
if (resourceId) {
nav(`/${usableResourcePath(type)}/${resourceId}`);
}
};
return (
<NewLayout
@@ -547,7 +560,7 @@ export const NewResource = ({
onKeyDown={(e) => {
if (!name) return;
if (e.key === "Enter") {
onConfirm();
onConfirm().catch(() => {});
}
}}
/>

View File

@@ -44,6 +44,12 @@ export const useServer = (id?: string) =>
(d) => d.id === id
);
// Helper function to check if server is available for API calls
export const useIsServerAvailable = (serverId?: string) => {
const server = useServer(serverId);
return server?.info.state === Types.ServerState.Ok;
};
export const useFullServer = (id: string) =>
useRead("GetServer", { server: id }, { refetchInterval: 10_000 }).data;
@@ -53,7 +59,8 @@ export const useVersionMismatch = (serverId?: string) => {
const server_version = useServer(serverId)?.info.version;
const unknown = !server_version || server_version === "Unknown";
const mismatch = !!server_version && !!core_version && server_version !== core_version;
const mismatch =
!!server_version && !!core_version && server_version !== core_version;
return { unknown, mismatch, hasVersionMismatch: mismatch && !unknown };
};
@@ -66,7 +73,8 @@ const Icon = ({ id, size }: { id?: string; size: number }) => {
<Server
className={cn(
`w-${size} h-${size}`,
state && stroke_color_class_by_intention(
state &&
stroke_color_class_by_intention(
server_state_intention(state, hasVersionMismatch)
)
)}
@@ -137,10 +145,7 @@ const ConfigTabs = ({ id }: { id: string }) => {
</TabsList>
);
return (
<Tabs
value={currentView}
onValueChange={setView as any}
>
<Tabs value={currentView} onValueChange={setView as any}>
<TabsContent value="Config">
<ServerConfig id={id} titleOther={tabsList} />
</TabsContent>
@@ -242,7 +247,8 @@ export const ServerVersion = ({ id }: { id: string }) => {
</TooltipTrigger>
<TooltipContent>
<div>
Server is <span className="font-bold">disabled</span> - version unknown.
Server is <span className="font-bold">disabled</span> - version
unknown.
</div>
</TooltipContent>
</Tooltip>
@@ -306,7 +312,11 @@ export const ServerComponents: RequiredResourceComponents = {
),
Dashboard: () => {
const summary = useRead("GetServersSummary", {}, { refetchInterval: 15_000 }).data;
const summary = useRead(
"GetServersSummary",
{},
{ refetchInterval: 15_000 }
).data;
return (
<DashboardPieChart
data={[
@@ -365,7 +375,8 @@ export const ServerComponents: RequiredResourceComponents = {
const { hasVersionMismatch } = useVersionMismatch(id);
// Show full version mismatch text
const displayState = state === Types.ServerState.Ok && hasVersionMismatch
const displayState =
state === Types.ServerState.Ok && hasVersionMismatch
? "Version Mismatch"
: state === Types.ServerState.NotOk
? "Not Ok"
@@ -384,13 +395,13 @@ export const ServerComponents: RequiredResourceComponents = {
Info: {
Version: ServerVersion,
Cpu: ({ id }) => {
const server = useServer(id);
const isServerAvailable = useIsServerAvailable(id);
const core_count =
useRead(
"GetSystemInformation",
{ server: id },
{
enabled: server ? server.info.state !== "Disabled" : false,
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data?.core_count ?? 0;
@@ -402,12 +413,12 @@ export const ServerComponents: RequiredResourceComponents = {
);
},
LoadAvg: ({ id }) => {
const server = useServer(id);
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: server ? server.info.state !== "Disabled" : false,
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
@@ -423,12 +434,12 @@ export const ServerComponents: RequiredResourceComponents = {
);
},
Mem: ({ id }) => {
const server = useServer(id);
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: server ? server.info.state !== "Disabled" : false,
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
@@ -440,12 +451,12 @@ export const ServerComponents: RequiredResourceComponents = {
);
},
Disk: ({ id }) => {
const server = useServer(id);
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: server ? server.info.state !== "Disabled" : false,
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
@@ -616,7 +627,8 @@ export const ServerComponents: RequiredResourceComponents = {
const { hasVersionMismatch } = useVersionMismatch(id);
// Determine display state for header (longer text is okay in header)
const displayState = server?.info.state === Types.ServerState.Ok && hasVersionMismatch
const displayState =
server?.info.state === Types.ServerState.Ok && hasVersionMismatch
? "Version Mismatch"
: server?.info.state === Types.ServerState.NotOk
? "Not Ok"

View File

@@ -3,6 +3,7 @@ import { ServerComponents } from "@components/resources/server";
import { DataTable, SortableHeader } from "@ui/data-table";
import { useRead } from "@lib/hooks";
import { useMemo } from "react";
import { useIsServerAvailable } from ".";
export const ServerMonitoringTable = ({ search = "" }: { search?: string }) => {
const servers = useRead("ListServers", {}).data;
@@ -73,11 +74,19 @@ export const ServerMonitoringTable = ({ search = "" }: { search?: string }) => {
);
};
const useStats = (id: string) =>
useRead("GetSystemStats", { server: id }, { refetchInterval: 10_000 }).data;
const useStats = (id: string) => {
const isServerAvailable = useIsServerAvailable(id);
return useRead("GetSystemStats", { server: id }, {
enabled: isServerAvailable,
refetchInterval: 10_000
}).data;
};
const useServerThresholds = (id: string) => {
const config = useRead("GetServer", { server: id }).data?.config as any;
const isServerAvailable = useIsServerAvailable(id);
const config = useRead("GetServer", { server: id }, {
enabled: isServerAvailable
}).data?.config as any;
return {
cpuWarning: config?.cpu_warning ?? 75,
cpuCritical: config?.cpu_critical ?? 90,

View File

@@ -95,13 +95,7 @@ export const InnerStatChart = ({
stats: StatDatapoint[] | undefined;
seriesData?: { label: string; data: StatDatapoint[] }[];
}) => {
const { theme: _theme } = useTheme();
const theme =
_theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: _theme;
const { currentTheme } = useTheme();
const min = stats?.[0]?.date ?? 0;
const max = stats?.[stats.length - 1]?.date ?? 0;
@@ -200,7 +194,7 @@ export const InnerStatChart = ({
hex_color_by_intention("Unknown"),
]
: [getColor(type)],
dark: theme === "dark",
dark: currentTheme === "dark",
padding: {
left: 10,
right: 10,

View File

@@ -18,6 +18,7 @@ import {
} from "@ui/select";
import { DockerResourceLink, ShowHideButton } from "@components/util";
import { filterBySplit } from "@lib/utils";
import { useIsServerAvailable } from ".";
export const ServerStats = ({
id,
@@ -29,17 +30,23 @@ export const ServerStats = ({
const [interval, setInterval] = useStatsGranularity();
const { specific } = usePermissions({ type: "Server", id });
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{ refetchInterval: 10_000 }
{
enabled: isServerAvailable,
refetchInterval: 10_000
}
).data;
const info = useRead("GetSystemInformation", { server: id }).data;
const info = useRead("GetSystemInformation", { server: id }, { enabled: isServerAvailable }).data;
// Get all the containers with stats
const containers = useRead("ListDockerContainers", {
server: id,
}, {
enabled: isServerAvailable
}).data?.filter((c) => c.stats);
const [showContainers, setShowContainers] = useLocalStorage(
"stats-show-container-table-v1",
@@ -504,8 +511,8 @@ const LOAD_AVERAGE = ({
}) => {
if (!stats?.load_average) return null;
const { one = 0, five = 0, fifteen = 0 } = stats.load_average || {};
const cores = useRead("GetSystemInformation", { server: id }).data
?.core_count;
const isServerAvailable = useIsServerAvailable(id);
const cores = useRead("GetSystemInformation", { server: id }, { enabled: isServerAvailable }).data?.core_count;
const pct = (load: number) =>
cores && cores > 0 ? Math.min((load / cores) * 100, 100) : undefined;

View File

@@ -16,7 +16,7 @@ import { useLocalStorage, useWrite } from "@lib/hooks";
import { Button } from "@ui/button";
import { FilePlus, History } from "lucide-react";
import { useToast } from "@ui/use-toast";
import { ConfirmButton, ShowHideButton } from "@components/util";
import { ConfirmButton, ShowHideButton, CopyButton } from "@components/util";
import { DEFAULT_STACK_FILE_CONTENTS } from "./config";
import { Types } from "komodo_client";
@@ -205,32 +205,58 @@ export const StackInfo = ({
latest_contents.length > 0 &&
latest_contents.map((content) => {
const showContents = show[content.path] ?? default_show_contents;
const handleToggleShow = () => {
setShow((show) => ({
...show,
[content.path]: !(show[content.path] ?? default_show_contents),
}));
};
return (
<Card key={content.path} className="flex flex-col gap-4">
<CardHeader
className={cn(
"flex flex-row justify-between items-center",
"flex flex-row justify-between items-center group cursor-pointer",
showContents && "pb-0"
)}
onClick={handleToggleShow}
tabIndex={0}
role="button"
aria-pressed={showContents}
onKeyDown={(e) => {
if (
(e.key === "Enter" || e.key === " ") &&
e.target === e.currentTarget
) {
if (e.key === " ") e.preventDefault();
handleToggleShow();
}
}}
>
<CardTitle className="font-mono flex gap-2">
<div className="text-muted-foreground">File:</div>
{content.path}
<CardTitle className="font-mono flex gap-2 items-center">
<div className="flex gap-2 items-center">
<span className="text-muted-foreground">File:</span>
<span>{content.path}</span>
<span onClick={(e) => e.stopPropagation()} data-copy-button>
<CopyButton content={content.path} label="file path" />
</span>
</div>
</CardTitle>
<div className="flex items-center gap-2">
{canEdit && (
<>
<Button
variant="outline"
onClick={() =>
setEdits({ ...edits, [content.path]: undefined })
}
onClick={(e) => {
e.stopPropagation();
setEdits({ ...edits, [content.path]: undefined });
}}
className="flex items-center gap-2"
disabled={!edits[content.path]}
>
<History className="w-4 h-4" />
Reset
</Button>
<span onClick={(e) => e.stopPropagation()}>
<ConfirmUpdate
previous={{ contents: content.contents }}
content={{ contents: edits[content.path] }}
@@ -241,7 +267,10 @@ export const StackInfo = ({
file_path: content.path,
contents: edits[content.path]!,
}).then(() =>
setEdits({ ...edits, [content.path]: undefined })
setEdits({
...edits,
[content.path]: undefined,
})
);
}
}}
@@ -249,11 +278,12 @@ export const StackInfo = ({
language="yaml"
loading={isPending}
/>
</span>
</>
)}
<ShowHideButton
show={showContents}
setShow={(val) => setShow({ ...show, [content.path]: val })}
setShow={() => {}}
/>
</div>
</CardHeader>

View File

@@ -31,14 +31,8 @@ export const Terminal = ({
_reconnect: boolean;
_clear?: boolean;
}) => {
const { theme: __theme } = useTheme();
const _theme =
__theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: __theme;
const theme = _theme === "dark" ? DARK_THEME : LIGHT_THEME;
const { currentTheme } = useTheme();
const theme = currentTheme === "dark" ? DARK_THEME : LIGHT_THEME;
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon>(new FitAddon());

View File

@@ -239,50 +239,15 @@ export const UserDropdown = () => {
</Button>
</div>
{accounts.map((login) => {
const selected = login.user_id === user?._id?.$oid;
return (
<div className="flex gap-2 items-center w-full">
<Button
variant={selected ? "secondary" : "ghost"}
className="flex gap-2 items-center justify-between w-full"
onClick={() => {
if (selected) {
// Noop
setOpen(false);
return;
}
LOGIN_TOKENS.change(login.user_id);
location.reload();
}}
>
<div className="flex items-center gap-2">
<Username user_id={login.user_id} />
</div>
{selected && (
<Circle className="w-3 h-3 stroke-none transition-colors fill-green-500" />
)}
</Button>
{viewLogout && (
<Button
variant="destructive"
className="px-2 py-0"
onClick={() => {
LOGIN_TOKENS.remove(login.user_id);
if (selected) {
location.reload();
} else {
rerender();
}
}}
>
<LogOut className="w-4" />
</Button>
)}
</div>
);
})}
{accounts.map((login) => (
<Account
login={login}
current_id={user?._id?.$oid}
setOpen={setOpen}
rerender={rerender}
viewLogout={viewLogout}
/>
))}
<Separator />
@@ -317,9 +282,66 @@ export const UserDropdown = () => {
);
};
const Username = ({ user_id }: { user_id: string }) => {
const res = useRead("GetUsername", { user_id }).data;
return <UsernameView username={res?.username} avatar={res?.avatar} full />;
const Account = ({
login,
current_id,
setOpen,
rerender,
viewLogout,
}: {
login: Types.JwtResponse;
current_id?: string;
setOpen: (open: boolean) => void;
rerender: () => void;
viewLogout: boolean;
}) => {
const res = useRead("GetUsername", { user_id: login.user_id });
if (!res.data) return;
const selected = login.user_id === current_id;
return (
<div className="flex gap-2 items-center w-full">
<Button
variant={selected ? "secondary" : "ghost"}
className="flex gap-2 items-center justify-between w-full"
onClick={() => {
if (selected) {
// Noop
setOpen(false);
return;
}
LOGIN_TOKENS.change(login.user_id);
location.reload();
}}
>
<div className="flex items-center gap-2">
<UsernameView
username={res.data?.username}
avatar={res.data?.avatar}
/>
</div>
{selected && (
<Circle className="w-3 h-3 stroke-none transition-colors fill-green-500" />
)}
</Button>
{viewLogout && (
<Button
variant="destructive"
className="px-2 py-0"
onClick={() => {
LOGIN_TOKENS.remove(login.user_id);
if (selected) {
location.reload();
} else {
rerender();
}
}}
>
<LogOut className="w-4" />
</Button>
)}
</div>
);
};
const UsernameView = ({

View File

@@ -128,16 +128,21 @@ export const useLoginOptions = () => {
export const useUser = () => {
const userReset = useUserReset();
const hasJwt = !!LOGIN_TOKENS.jwt();
const query = useQuery({
queryKey: ["GetUser"],
queryFn: () => komodo_client().auth("GetUser", {}),
refetchInterval: 30_000,
enabled: hasJwt,
});
useEffect(() => {
if (query.data && query.error) {
userReset();
}
}, [query.data, query.error]);
return query;
};
@@ -173,9 +178,11 @@ export const useRead = <
params: P,
config?: C
) => {
const hasJwt = !!LOGIN_TOKENS.jwt();
return useQuery({
queryKey: [type, params],
queryFn: () => komodo_client().read<T, R>(type, params),
enabled: hasJwt && (config?.enabled !== false),
...config,
});
};

View File

@@ -112,6 +112,11 @@ export default function Login() {
login(creds);
};
const handleSubmit = (e: any) => {
e.preventDefault();
handleLogin();
};
const handleSignUp = () => {
const creds = getFormCredentials();
if (!creds) return;
@@ -185,6 +190,7 @@ export default function Login() {
{options?.local && (
<form
ref={formRef}
onSubmit={handleSubmit}
autoComplete="on"
>
<CardContent className="flex flex-col justify-center w-full gap-4">
@@ -196,6 +202,7 @@ export default function Login() {
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
autoFocus
/>
</div>
<div className="flex flex-col gap-2">
@@ -222,9 +229,8 @@ export default function Login() {
)}
<Button
variant="default"
type="button"
type="submit"
value="login"
onClick={handleLogin}
disabled={loginPending}
>
Log In

View File

@@ -80,9 +80,7 @@ export function DataTable<TData, TValue>({
}, [tableKey]);
useEffect(() => {
if (sorting.length) {
localStorage.setItem("data-table-" + tableKey, JSON.stringify(sorting));
}
}, [tableKey, sorting]);
useEffect(() => {

View File

@@ -18,16 +18,21 @@ type ThemeProviderProps = {
type ThemeProviderState = {
theme: Theme;
currentTheme: Exclude<Theme, "system">;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
currentTheme: "dark",
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
const systemTheme = () =>
window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
export function ThemeProvider({
children,
defaultTheme = "system",
@@ -37,44 +42,36 @@ export function ThemeProvider({
const [theme, setTheme] = useState<Theme>(
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
);
// Tracks the current theme
// - if theme is light or dark, equal to theme.
// - if theme is system, tracks current theme with pool loop
const [currentTheme, setCurrentTheme] = useState<Exclude<Theme, "system">>(
theme === "system" ? systemTheme() : theme
);
useEffect(() => {
if (theme === "system") {
setCurrentTheme(systemTheme());
// For 'system' theme, need to poll
// matchMedia for update to theme.
const interval = setInterval(() => {
setCurrentTheme(systemTheme());
}, 5_000);
return () => clearInterval(interval);
} else {
setCurrentTheme(theme);
}
}, [theme]);
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
.matches
? "dark"
: "light";
root.classList.add(systemTheme);
return;
}
root.classList.add(theme);
}, [theme]);
// For 'system' theme, need to poll
// matchMedia for update to theme.
useEffect(() => {
if (theme === "system") {
const interval = setInterval(() => {
const [systemTheme, other] = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches
? ["dark", "light"]
: ["light", "dark"];
window.document.documentElement.classList.add(systemTheme);
window.document.documentElement.classList.remove(other);
}, 5_000);
return () => clearInterval(interval);
}
}, [theme]);
root.classList.add(currentTheme);
return () => root.classList.remove(currentTheme);
}, [currentTheme]);
const value = {
theme,
currentTheme,
setTheme: (theme: Theme) => {
localStorage.setItem(storageKey, theme);
setTheme(theme);