mirror of
https://github.com/moghtech/komodo.git
synced 2026-10-31 05:05:57 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf3f2e0503 | ||
|
|
780ac68b99 | ||
|
|
221ad252e1 | ||
|
|
cc1c5aa5da | ||
|
|
93cce3fb60 | ||
|
|
7406c292a1 | ||
|
|
427475651c | ||
|
|
ae0c8bf98a | ||
|
|
a7592f4561 | ||
|
|
a199c8fe27 | ||
|
|
f7296c73b1 | ||
|
|
0eaf8b5f01 | ||
|
|
6919582f69 |
Generated
+233
-918
File diff suppressed because it is too large
Load Diff
+19
-19
@@ -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
@@ -1,5 +1 @@
|
||||
{
|
||||
"imports": {
|
||||
"@std/toml": "jsr:@std/toml"
|
||||
}
|
||||
}
|
||||
{}
|
||||
+1
-42
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 && \
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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}"))
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 != ¤t_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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,6 +1,5 @@
|
||||
use anyhow::Context;
|
||||
use bollard::query_parameters::ListTasksOptions;
|
||||
use komodo_client::entities::docker::task::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
FROM rust:1.97.1-trixie as builder
|
||||
FROM rust:1.98.0-trixie as builder
|
||||
WORKDIR /builder
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.97.1-trixie as builder
|
||||
FROM rust:1.98.0-trixie as builder
|
||||
WORKDIR /builder
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -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.
|
||||
|
||||
Vendored
+23
-5
@@ -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" />
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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" />
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -165,7 +165,6 @@ function NetworkInner({
|
||||
) : (
|
||||
"Unknown"
|
||||
),
|
||||
size: 200,
|
||||
},
|
||||
{
|
||||
accessorKey: "IPv4Address",
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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
@@ -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={
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user