Compare commits

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

* deploy 2.4.0-dev-1

* slightly optimize stack read path

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

* deploy 2.4.0-dev-2

* ui use || location.origin for base url

* move to build time versioning

* simplify run action with komodo managed version

* procedure batch executions use  tag filters

* set advanced code splitting options

* improve network module logic / edge cases

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

* lengthen periphery command timeouts

* update deps

* clients still have explicit versions

* Add page breadcrumbs

* mogh ui 1.2.6

* fix set version – set only specific path

* cargo fmt

* use mogh ui ListPagination

* bump deps, rust 1.98.0

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

* 2.3.2

* reporting error includes api message

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

* deploy 2.3.2-dev-1

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

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

* mogh ui 1.0.1 lock

* deploy 2.3.2-dev-2

* fix monaco yaml web worker for compose file suggestions

* small fix various console logs

* deploy 2.3.2-dev-3

* docsite configurable DOCSITE_URL and DOCSITE_BASE_URL

* align dockerfile with base url changes

* fix docsite docker caddy

* 2.3.2

* remove currently unsupported configuration (github webhook app)

* bump rust deps again

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

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

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

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

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

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

Fixes #1022
2026-02-07 20:34:53 -08:00
Voice Typer User 6919582f69 fix: prevent custom registry org input from deselecting
The ImageRegistryConfig component's key included registry.organization,
which caused React to remount the component on every keystroke when
typing a custom organization name. This destroyed the OrganizationSelector's
internal customMode state, causing the input to deselect after each character.

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

Closes #1094
2026-02-06 16:04:35 -08:00
89 changed files with 2543 additions and 2110 deletions
Generated
+233 -918
View File
File diff suppressed because it is too large Load Diff
+19 -19
View File
@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
version = "2.3.1"
version = "0.0.0"
edition = "2024"
license = "GPL-3.0-or-later"
repository = "https://github.com/moghtech/komodo"
@@ -34,14 +34,14 @@ mogh_error = { version = "1.0.4", default-features = false }
derive_default_builder = "0.1.8"
async_timing_util = "1.1.0"
mogh_auth_client = "1.7.0"
mogh_auth_server = "1.7.0"
mogh_auth_server = "1.8.0"
mogh_secret_file = "1.0.1"
mogh_validations = "1.0.1"
mogh_rate_limit = "1.0.1"
partial_derive2 = "0.5.0"
mongo_indexed = "2.1.0"
mogh_resolver = "1.0.0"
mogh_config = "1.1.0"
mogh_config = "1.4.2"
mogh_logger = "1.3.4"
mogh_server = "1.5.0"
toml_pretty = "2.0.0"
@@ -56,7 +56,7 @@ tokio = { version = "1.53.1", features = ["full"] }
tokio-util = { version = "0.7.19", features = ["io", "codec"] }
tokio-stream = { version = "0.1.19", features = ["sync"] }
pin-project-lite = "0.2.17"
futures-util = "0.3.33"
futures-util = "0.3.34"
arc-swap = "1.9.2"
# SERVER
@@ -74,15 +74,15 @@ indexmap = { version = "2.14.0", features = ["serde"] }
serde = { version = "1.0.229", features = ["derive"] }
strum = { version = "0.28.0", features = ["derive"] }
bson = { version = "2.15.0" } # must keep in sync with mongodb version
toml = "1.1.3"
toml = "1.1.4"
serde_yaml_ng = "0.10.0"
serde_json = "1.0.151"
serde_qs = "1.1.2"
serde_qs = "1.1.3"
url = "2.5.8"
# ERROR
anyhow = "1.0.104"
thiserror = "2.0.19"
thiserror = "2.0.20"
# SYSTEM
libc = "0.2.189"
@@ -91,16 +91,16 @@ libc = "0.2.189"
tracing = "0.1.44"
# CONFIG
clap = { version = "4.6.4", features = ["derive"] }
clap = { version = "4.6.6", features = ["derive"] }
dotenvy = "0.15.7"
envy = "0.4.2"
# CRYPTO / AUTH
uuid = { version = "1.24.0", features = ["v4", "fast-rng", "serde"] }
rustls = { version = "0.23.42", features = ["aws-lc-rs"] }
data-encoding = "2.11.0"
uuid = { version = "1.25.0", features = ["v4", "fast-rng", "serde"] }
rustls = { version = "0.23.43", features = ["aws-lc-rs"] }
data-encoding = "2.11.1"
urlencoding = "2.1.3"
bcrypt = "0.19.2"
bcrypt = "0.19.3"
hmac = "0.13.0"
sha1 = "0.11.0"
sha2 = "0.11.0"
@@ -112,26 +112,26 @@ hickory-resolver = "0.26.1"
portable-pty = "0.9.0"
shell-escape = "0.1.5"
crossterm = "0.29.0"
bollard = "0.21.0"
bollard = "0.21.1"
sysinfo = "0.39.6"
shlex = "2.0.1"
# CLOUD
aws-config = "1.10.0"
aws-sdk-ec2 = "1.241.0"
aws-config = "1.11.0"
aws-sdk-ec2 = "1.252.0"
aws-credential-types = "1.3.0"
## CRON
english-to-cron = "0.1.7"
chrono-tz = "0.10.4"
chrono = "0.4.45"
croner = "3.0.1"
croner = "4.0.0"
# MISC
async-compression = { version = "0.4.42", features = ["tokio", "gzip"] }
schemars = { version = "1.2.1", features = ["indexmap2"] }
async-compression = { version = "0.4.43", features = ["tokio", "gzip"] }
schemars = { version = "1.2.2", features = ["indexmap2"] }
derive_builder = "0.20.2"
comfy-table = "7.2.2"
comfy-table = "8.0.0"
typeshare = "1.0.5"
wildcard = "0.3.0"
colored = "3.1.1"
+1 -5
View File
@@ -1,5 +1 @@
{
"imports": {
"@std/toml": "jsr:@std/toml"
}
}
{}
+1 -42
View File
@@ -1,5 +1,3 @@
import * as TOML from "@std/toml";
export const run = async (action: string) => {
const branch = await new Deno.Command("bash", {
args: ["-c", "git rev-parse --abbrev-ref HEAD"],
@@ -7,46 +5,7 @@ export const run = async (action: string) => {
.output()
.then((r) => new TextDecoder("utf-8").decode(r.stdout).trim());
const cargo_toml_str = await Deno.readTextFile("Cargo.toml");
const prev_version = (
TOML.parse(cargo_toml_str) as {
workspace: { package: { version: string } };
}
).workspace.package.version;
const [version, tag, count] = prev_version.split("-");
const next_count = Number(count) + 1;
const next_version = `${version}-${tag}-${next_count}`;
await Deno.writeTextFile(
"Cargo.toml",
cargo_toml_str.replace(
`version = "${prev_version}"`,
`version = "${next_version}"`
)
);
// Cargo check first here to make sure lock file is updated before commit.
const cmd = `
cargo check
echo ""
git add --all
git commit --all --message "deploy ${version}-${tag}-${next_count}"
echo ""
git push
echo ""
km run -y action ${action} "KOMODO_BRANCH=${branch}&KOMODO_VERSION=${version}&KOMODO_TAG=${tag}-${next_count}"
`
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("//"))
.join(" && ");
new Deno.Command("bash", {
args: ["-c", cmd],
args: ["-c", `km run -y action ${action} "BRANCH=${branch}"`],
}).spawn();
};
+7 -2
View File
@@ -1,8 +1,8 @@
## Builds the Komodo Core, Periphery, and Util binaries
## for a specific architecture. Requires OpenSSL 3 or later.
FROM rust:1.97.1-bookworm AS builder
RUN cargo install cargo-strip
FROM rust:1.98.0-bookworm AS builder
RUN cargo install cargo-strip cargo-edit
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -14,6 +14,11 @@ COPY ./bin/periphery ./bin/periphery
COPY ./bin/cli ./bin/cli
COPY ./xtask ./xtask
# Set Version
ARG VERSION="0.0.0"
ARG IMAGE_TAG=""
RUN cargo set-version -p komodo_core ${VERSION}${IMAGE_TAG:+-${IMAGE_TAG}}
# Compile bin
RUN \
cargo build -p komodo_core --release && \
+12 -4
View File
@@ -3,7 +3,7 @@
## Uses chef for dependency caching to help speed up back-to-back builds.
FROM lukemathwalker/cargo-chef:latest-rust-1.97.1-bookworm AS chef
FROM lukemathwalker/cargo-chef:latest-rust-1.98.0-bookworm AS chef
WORKDIR /builder
# Plan just the RECIPE to see if things have changed
@@ -12,12 +12,20 @@ COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
RUN cargo install cargo-strip
COPY --from=planner /builder/recipe.json recipe.json
RUN cargo install cargo-strip cargo-edit
# Build JUST dependencies - cached layer
COPY --from=planner /builder/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json
# NOW copy again (this time into builder) and build app
# NOW copy again (this time into builder), set version, and build app
COPY . .
# Set Version
ARG VERSION="0.0.0"
ARG IMAGE_TAG=""
RUN cargo set-version -p komodo_core ${VERSION}${IMAGE_TAG:+-${IMAGE_TAG}}
RUN \
cargo build --release --bin core && \
cargo build --release --bin periphery && \
+7 -2
View File
@@ -1,5 +1,5 @@
FROM rust:1.97.1-trixie AS builder
RUN cargo install cargo-strip
FROM rust:1.98.0-trixie AS builder
RUN cargo install cargo-strip cargo-edit
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -8,6 +8,11 @@ COPY ./client/core/rs ./client/core/rs
COPY ./client/periphery ./client/periphery
COPY ./bin/cli ./bin/cli
# Set Version
ARG VERSION="0.0.0"
ARG IMAGE_TAG=""
RUN cargo set-version -p komodo_cli ${VERSION}${IMAGE_TAG:+-${IMAGE_TAG}}
# Compile bin
RUN cargo build -p komodo_cli --release && cargo strip
+2 -2
View File
@@ -100,7 +100,7 @@ fn print_items<T: PrintTable + Serialize>(
match format {
CliFormat::Table => {
let mut table = Table::new();
let preset = {
let style = {
use comfy_table::presets::*;
match cli_config().table_borders {
None | Some(CliTableBorders::Horizontal) => {
@@ -112,7 +112,7 @@ fn print_items<T: PrintTable + Serialize>(
Some(CliTableBorders::All) => UTF8_FULL,
}
};
table.load_preset(preset).set_header(
table.load_style(style).set_header(
T::header(links)
.iter()
.map(|h| Cell::new(h).add_attribute(Attribute::Bold)),
+7 -2
View File
@@ -1,8 +1,8 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
# Build Core
FROM rust:1.97.1-trixie AS core-builder
RUN cargo install cargo-strip
FROM rust:1.98.0-trixie AS core-builder
RUN cargo install cargo-strip cargo-edit
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -13,6 +13,11 @@ COPY ./bin/core ./bin/core
COPY ./bin/cli ./bin/cli
COPY ./xtask ./xtask
# Set Version
ARG VERSION="0.0.0"
ARG IMAGE_TAG=""
RUN cargo set-version -p komodo_core ${VERSION}${IMAGE_TAG:+-${IMAGE_TAG}}
# Compile app
RUN cargo build -p komodo_core --release && \
cargo build -p komodo_cli --release && \
+158
View File
@@ -0,0 +1,158 @@
use std::time::Instant;
use axum::{Router, extract::Path, http::HeaderMap, routing::post};
use derive_variants::{EnumVariants, ExtractVariant};
use komodo_client::{api::auth::*, entities::user::User};
use reqwest::StatusCode;
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::{AddStatusCode, Json};
use typeshare::typeshare;
use uuid::Uuid;
use crate::{
auth::{
get_user_id_from_headers,
github::{self, client::github_oauth_client},
google::{self, client::google_oauth_client},
oidc::{self, client::oidc_client},
},
config::core_config,
helpers::query::get_user,
state::jwt_client,
};
use super::Variant;
#[derive(Default)]
pub struct AuthArgs {
pub headers: HeaderMap,
}
#[typeshare]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants,
)]
#[args(AuthArgs)]
#[response(Response)]
#[error(serror::Error)]
#[variant_derive(Debug)]
#[serde(tag = "type", content = "params")]
#[allow(clippy::enum_variant_names, clippy::large_enum_variant)]
pub enum AuthRequest {
GetLoginOptions(GetLoginOptions),
SignUpLocalUser(SignUpLocalUser),
LoginLocalUser(LoginLocalUser),
ExchangeForJwt(ExchangeForJwt),
GetUser(GetUser),
}
pub fn router() -> Router {
let mut router = Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler));
if core_config().local_auth {
info!("🔑 Local Login Enabled");
}
if github_oauth_client().is_some() {
info!("🔑 Github Login Enabled");
router = router.nest("/github", github::router())
}
if google_oauth_client().is_some() {
info!("🔑 Google Login Enabled");
router = router.nest("/google", google::router())
}
if core_config().oidc_enabled {
info!("🔑 OIDC Login Enabled");
router = router.nest("/oidc", oidc::router())
}
router
}
async fn variant_handler(
headers: HeaderMap,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
let req: AuthRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(headers, Json(req)).await
}
#[instrument(name = "AuthHandler", level = "debug", skip(headers))]
async fn handler(
headers: HeaderMap,
Json(request): Json<AuthRequest>,
) -> serror::Result<axum::response::Response> {
let timer = Instant::now();
let req_id = Uuid::new_v4();
debug!(
"/auth request {req_id} | METHOD: {:?}",
request.extract_variant()
);
let res = request.resolve(&AuthArgs { headers }).await;
if let Err(e) = &res {
debug!("/auth request {req_id} | error: {:#}", e.error);
}
let elapsed = timer.elapsed();
debug!("/auth request {req_id} | resolve time: {elapsed:?}");
res.map(|res| res.0)
}
fn login_options_response() -> GetLoginOptionsResponse {
let config = core_config();
GetLoginOptionsResponse {
local: config.local_auth,
github: github_oauth_client().is_some(),
google: google_oauth_client().is_some(),
oidc: oidc_client().load().is_some(),
registration_disabled: config.disable_user_registration,
}
}
impl Resolve<AuthArgs> for GetLoginOptions {
#[instrument(name = "GetLoginOptions", level = "debug", skip(self))]
async fn resolve(
self,
_: &AuthArgs,
) -> serror::Result<GetLoginOptionsResponse> {
Ok(login_options_response())
}
}
impl Resolve<AuthArgs> for ExchangeForJwt {
#[instrument(name = "ExchangeForJwt", level = "debug", skip(self))]
async fn resolve(
self,
_: &AuthArgs,
) -> serror::Result<ExchangeForJwtResponse> {
jwt_client()
.redeem_exchange_token(&self.token)
.await
.map_err(Into::into)
}
}
impl Resolve<AuthArgs> for GetUser {
#[instrument(name = "GetUser", level = "debug", skip(self))]
async fn resolve(
self,
AuthArgs { headers }: &AuthArgs,
) -> serror::Result<User> {
let user_id = get_user_id_from_headers(headers)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
get_user(&user_id)
.await
.status_code(StatusCode::UNAUTHORIZED)
}
}
+50 -91
View File
@@ -13,6 +13,7 @@ use komodo_client::{
permission::PermissionLevel,
procedure::{Procedure, ProcedureStage},
repo::Repo,
resource::ResourceQuery,
stack::Stack,
update::Update,
user::procedure_user,
@@ -33,7 +34,10 @@ use crate::{
state::{all_resources_cache, db_client},
};
use super::update::{init_execution_update, update_update};
use super::{
query::get_all_tags,
update::{init_execution_update, update_update},
};
pub async fn execute_procedure(
procedure: &Procedure,
@@ -109,95 +113,41 @@ async fn execute_procedure_stage(
update: &Mutex<Update>,
) -> anyhow::Result<()> {
let mut executions = Vec::with_capacity(_executions.capacity());
for execution in _executions {
match execution {
Execution::BatchRunAction(exec) => {
extend_batch_exection::<BatchRunAction>(
&exec.pattern,
&mut executions,
)
.await?;
// Expands the batch executions into their matching
// single executions, and passes through the rest.
macro_rules! expand_batch_executions {
($($Variant:ident),* $(,)?) => {
for execution in _executions {
match execution {
$(
Execution::$Variant(exec) => {
extend_batch_execution::<$Variant>(
&exec.pattern,
&exec.tags,
&mut executions,
)
.await?;
}
)*
execution => executions.push(execution),
}
}
Execution::BatchRunProcedure(exec) => {
extend_batch_exection::<BatchRunProcedure>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchRunBuild(exec) => {
extend_batch_exection::<BatchRunBuild>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchCloneRepo(exec) => {
extend_batch_exection::<BatchCloneRepo>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchPullRepo(exec) => {
extend_batch_exection::<BatchPullRepo>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchBuildRepo(exec) => {
extend_batch_exection::<BatchBuildRepo>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchDeploy(exec) => {
extend_batch_exection::<BatchDeploy>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchDestroyDeployment(exec) => {
extend_batch_exection::<BatchDestroyDeployment>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchDeployStack(exec) => {
extend_batch_exection::<BatchDeployStack>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchDeployStackIfChanged(exec) => {
extend_batch_exection::<BatchDeployStackIfChanged>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchPullStack(exec) => {
extend_batch_exection::<BatchPullStack>(
&exec.pattern,
&mut executions,
)
.await?;
}
Execution::BatchDestroyStack(exec) => {
extend_batch_exection::<BatchDestroyStack>(
&exec.pattern,
&mut executions,
)
.await?;
}
execution => executions.push(execution),
}
};
}
expand_batch_executions!(
BatchRunAction,
BatchRunProcedure,
BatchRunBuild,
BatchCloneRepo,
BatchPullRepo,
BatchBuildRepo,
BatchDeploy,
BatchDestroyDeployment,
BatchDeployStack,
BatchDeployStackIfChanged,
BatchPullStack,
BatchDestroyStack,
);
let futures = executions.into_iter().map(|execution| async move {
let now = Instant::now();
add_line_to_update(
@@ -550,18 +500,27 @@ async fn add_line_to_update(update: &Mutex<Update>, line: &str) {
};
}
async fn extend_batch_exection<E: ExtendBatch>(
async fn extend_batch_execution<E: ExtendBatch>(
pattern: &str,
tags: &[String],
executions: &mut Vec<Execution>,
) -> anyhow::Result<()> {
let all_tags = if tags.is_empty() {
Vec::new()
} else {
get_all_tags(None).await?
};
let more = list_full_for_user_using_pattern::<E::Resource>(
pattern,
Default::default(),
ResourceQuery {
tags: tags.to_vec(),
..Default::default()
},
None,
None,
procedure_user(),
PermissionLevel::Read.into(),
&[],
&all_tags,
)
.await?
.into_iter()
+116 -119
View File
@@ -9,24 +9,26 @@
//! - Automatic container environment detection
//! - Interface validation (existence and UP state)
//! - Gateway discovery from routing tables or network configuration
//! - Safe default route modification with privilege checking
//! - Atomic default route replacement (never leaves the container without a default route)
//! - Comprehensive error handling and logging
use std::net::Ipv4Addr;
use anyhow::{Context, anyhow};
use tokio::process::Command;
use tracing::{debug, info, trace, warn};
/// Standard gateway addresses to test for Docker networks
const DOCKER_GATEWAY_CANDIDATES: &[&str] = &[".1", ".254"];
/// Container environment detection files
const DOCKERENV_FILE: &str = "/.dockerenv";
const CONTAINERENV_FILE: &str = "/run/.containerenv";
const CGROUP_FILE: &str = "/proc/1/cgroup";
/// Check if running in container environment
fn is_container_environment() -> bool {
// Check for Docker-specific indicators
if std::path::Path::new(DOCKERENV_FILE).exists() {
// Check for Docker / Podman specific indicators
if std::path::Path::new(DOCKERENV_FILE).exists()
|| std::path::Path::new(CONTAINERENV_FILE).exists()
{
return true;
}
@@ -35,7 +37,9 @@ fn is_container_environment() -> bool {
return true;
}
// Check cgroup for container runtime indicators
// Check cgroup for container runtime indicators.
// Only reliable on cgroup v1 - on cgroup v2 the file
// is usually just `0::/`, so the checks above carry detection.
if let Ok(content) = std::fs::read_to_string(CGROUP_FILE)
&& (content.contains("docker") || content.contains("containerd"))
{
@@ -51,23 +55,24 @@ pub async fn configure_internet_gateway() {
let config = core_config();
if config.internet_interface.is_empty() {
debug!("No interface specified, using default routing");
return;
}
if !is_container_environment() {
debug!("Not in container, skipping network configuration");
return;
}
if !config.internet_interface.is_empty() {
debug!(
"Configuring internet interface: {}",
config.internet_interface
);
if let Err(e) =
configure_manual_interface(&config.internet_interface).await
{
warn!("Failed to configure internet gateway: {e:#}");
}
} else {
debug!("No interface specified, using default routing");
debug!(
"Configuring internet interface: {}",
config.internet_interface
);
if let Err(e) =
configure_manual_interface(&config.internet_interface).await
{
warn!("Failed to configure internet gateway: {e:#}");
}
}
@@ -91,7 +96,10 @@ async fn configure_manual_interface(
let interface_info =
String::from_utf8_lossy(&interface_check.stdout);
if !interface_info.contains("state UP") {
// tun / wireguard style interfaces report `state UNKNOWN` while operational
if !interface_info.contains("state UP")
&& !interface_info.contains("state UNKNOWN")
{
return Err(anyhow!(
"Interface '{}' is not UP. Please ensure the interface is enabled and connected",
interface_name
@@ -159,7 +167,7 @@ async fn find_gateway(
.context("Failed to get routes for interface")?;
if route_output.status.success() {
let routes = String::from_utf8(route_output.stdout)?;
let routes = String::from_utf8_lossy(&route_output.stdout);
trace!("Routes for {}: {}", interface_name, routes.trim());
// Look for routes with gateway
@@ -179,60 +187,75 @@ async fn find_gateway(
}
}
// Derive gateway from network configuration (Docker standard: .1)
if let Some(network_base) = ip_cidr.split('/').next() {
let ip_parts: Vec<&str> = network_base.split('.').collect();
if ip_parts.len() == 4 {
let potential_gateways: Vec<String> = DOCKER_GATEWAY_CANDIDATES
.iter()
.map(|suffix| {
format!(
"{}.{}.{}{}",
ip_parts[0], ip_parts[1], ip_parts[2], suffix
)
})
.collect();
// Derive candidates from the interface subnet
let candidates = derive_gateway_candidates(ip_cidr)
.with_context(|| {
format!(
"Could not determine gateway for interface '{interface_name}' in network '{ip_cidr}'"
)
})?;
for gateway in potential_gateways {
trace!(
"Testing potential gateway {} for {}",
gateway, interface_name
);
for gateway in &candidates {
trace!(
"Testing potential gateway {} for {}",
gateway, interface_name
);
// Check if gateway is reachable
let route_test = Command::new("ip")
.args(["route", "get", &gateway, "dev", interface_name])
.output()
.await;
// Note: `ip route get` only confirms the address is on-link
// for this interface, not that a router actually answers there.
let route_test = Command::new("ip")
.args(["route", "get", gateway, "dev", interface_name])
.output()
.await;
if let Ok(output) = route_test
&& output.status.success()
{
trace!(
"Gateway {} is reachable via {}",
gateway, interface_name
);
return Ok(gateway.to_string());
}
// Fallback: assume .1 is gateway (Docker standard)
if gateway.ends_with(".1") {
trace!(
"Assuming Docker gateway {} for {}",
gateway, interface_name
);
return Ok(gateway.to_string());
}
}
if let Ok(output) = route_test
&& output.status.success()
{
trace!("Gateway {} is on-link via {}", gateway, interface_name);
return Ok(gateway.clone());
}
}
Err(anyhow!(
"Could not determine gateway for interface '{}' in network '{}'. \
Ensure the interface is properly configured with a valid gateway",
interface_name,
ip_cidr
))
// Fall back to the first host address (Docker standard)
let gateway = candidates
.into_iter()
.next()
.context("No gateway candidates derived")?;
trace!(
"Assuming Docker gateway {} for {}",
gateway, interface_name
);
Ok(gateway)
}
/// Derive candidate gateway addresses for the interface subnet:
/// the first and last usable host addresses. Docker assigns the
/// gateway the first host address of the subnet regardless of prefix length.
fn derive_gateway_candidates(
ip_cidr: &str,
) -> anyhow::Result<Vec<String>> {
let (ip, prefix) = ip_cidr
.split_once('/')
.context("Address is not in CIDR (ip/prefix) format")?;
let ip: Ipv4Addr =
ip.parse().context("Failed to parse IPv4 address")?;
let prefix: u32 =
prefix.parse().context("Failed to parse CIDR prefix")?;
if !(1..=30).contains(&prefix) {
return Err(anyhow!(
"Subnet /{prefix} cannot contain a distinct gateway address"
));
}
let mask = u32::MAX << (32 - prefix);
let network = u32::from(ip) & mask;
let first_host = network + 1;
let last_host = (network | !mask) - 1;
Ok(vec![
Ipv4Addr::from(first_host).to_string(),
Ipv4Addr::from(last_host).to_string(),
])
}
/// Set default gateway to use specified interface
@@ -245,48 +268,33 @@ async fn set_default_gateway(
gateway, interface_name
);
// Check if we have network privileges
if !check_network_privileges().await {
warn!(
"⚠️ Container lacks network privileges (NET_ADMIN capability required)"
);
warn!(
"Add 'cap_add: [\"NET_ADMIN\"]' to your docker-compose.yaml"
);
return Err(anyhow!(
"Insufficient network privileges to modify routing table. \
Container needs NET_ADMIN capability to configure network interfaces"
));
}
// Remove existing default routes
let remove_default = Command::new("sh")
.args(["-c", "ip route del default 2>/dev/null || true"])
.output()
.await;
if let Ok(output) = remove_default
&& output.status.success()
{
trace!("Removed existing default routes");
}
// Add new default route
let add_default_cmd = format!(
"ip route add default via {gateway} dev {interface_name}"
);
trace!("Adding default route: {}", add_default_cmd);
let add_default = Command::new("sh")
.args(["-c", &add_default_cmd])
// `replace` swaps the default route atomically, so a failure
// never leaves the container without any default route.
let replace = Command::new("ip")
.args([
"route",
"replace",
"default",
"via",
gateway,
"dev",
interface_name,
])
.output()
.await
.context("Failed to add default route")?;
.context("Failed to run ip route replace")?;
if !add_default.status.success() {
let error = String::from_utf8_lossy(&add_default.stderr)
.trim()
.to_string();
if !replace.status.success() {
let error =
String::from_utf8_lossy(&replace.stderr).trim().to_string();
if error.contains("Operation not permitted") {
warn!(
"⚠️ Container lacks network privileges (NET_ADMIN capability required)"
);
warn!(
"Add 'cap_add: [\"NET_ADMIN\"]' to your docker-compose.yaml"
);
}
return Err(anyhow!(
"❌ Failed to set default gateway via '{}': {}. \
Verify interface configuration and network permissions",
@@ -298,14 +306,3 @@ async fn set_default_gateway(
trace!("Default gateway set to {} via {}", gateway, interface_name);
Ok(())
}
/// Check if we have sufficient network privileges
async fn check_network_privileges() -> bool {
// Try to test NET_ADMIN capability with a harmless route operation
let capability_test = Command::new("sh")
.args(["-c", "ip route add 198.51.100.1/32 dev lo 2>/dev/null && ip route del 198.51.100.1/32 dev lo 2>/dev/null"])
.output()
.await;
matches!(capability_test, Ok(output) if output.status.success())
}
+18 -9
View File
@@ -1,6 +1,6 @@
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, report::KomodoReport,
@@ -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)
@@ -138,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)
@@ -146,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}"))
}
+11 -9
View File
@@ -145,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()
@@ -169,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.
+7 -2
View File
@@ -109,14 +109,19 @@ pub async fn on_startup() {
// Configure manual network interface if specified
network::configure_internet_gateway().await;
// Run legacy database fixes
tokio::join!(
clean_up_server_templates(),
v2_init_missing_resource_info(),
);
// Standard startup tasks
tokio::join!(
in_progress_update_cleanup(),
open_alert_cleanup(),
action_api_key_cleanup(),
ensure_first_server_and_builder(),
ensure_init_user_and_resources(),
clean_up_server_templates(),
v2_init_missing_resource_info(),
);
}
+7 -2
View File
@@ -1,7 +1,7 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
FROM rust:1.97.1-trixie AS builder
RUN cargo install cargo-strip
FROM rust:1.98.0-trixie AS builder
RUN cargo install cargo-strip cargo-edit
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -11,6 +11,11 @@ COPY ./client/periphery ./client/periphery
COPY ./bin/periphery ./bin/periphery
COPY ./xtask ./xtask
# Set Version
ARG VERSION="0.0.0"
ARG IMAGE_TAG=""
RUN cargo set-version -p komodo_periphery ${VERSION}${IMAGE_TAG:+-${IMAGE_TAG}}
# Compile app
RUN cargo build -p komodo_periphery --release && cargo strip
+23 -2
View File
@@ -304,15 +304,36 @@ impl Resolve<crate::api::Args> for build::Build {
// Do this in fallible block to ensure
// cancel token removed from cache on failure.
let command = async {
// Add VERSION to build args (if not already there)
let mut build_args = environment_vars_from_str(build_args)
.context("Invalid build_args")?;
.context("Invalid build_args")?;
// Add IMAGE_NAME to build args
if !build_args.iter().any(|a| a.variable == "IMAGE_NAME") {
build_args.push(EnvironmentVar {
variable: String::from("IMAGE_NAME"),
value: if build.config.image_name.is_empty() {
build.name.clone()
} else {
build.config.image_name.clone()
},
});
}
// Maybe Add IMAGE_TAG to build args
if !build.config.image_tag.is_empty()
&& !build_args.iter().any(|a| a.variable == "IMAGE_TAG") {
build_args.push(EnvironmentVar {
variable: String::from("IMAGE_TAG"),
value: build.config.image_tag.clone(),
});
}
// Add VERSION to build args
if !build_args.iter().any(|a| a.variable == "VERSION") {
build_args.push(EnvironmentVar {
variable: String::from("VERSION"),
value: build.config.version.to_string(),
});
}
let build_args = parse_build_args(&build_args);
let secret_args = environment_vars_from_str(secret_args)
+2 -2
View File
@@ -61,7 +61,7 @@ impl Resolve<crate::api::Args> for GetComposeLog {
run_komodo_standard_command(
"Get Stack Log",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
@@ -101,7 +101,7 @@ impl Resolve<crate::api::Args> for GetComposeLogSearch {
run_komodo_shell_command(
"Search Stack Log",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
+2 -2
View File
@@ -69,7 +69,7 @@ impl Resolve<crate::api::Args> for GetContainerLog {
run_komodo_standard_command(
"Get container log",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
@@ -104,7 +104,7 @@ impl Resolve<crate::api::Args> for GetContainerLogSearch {
run_komodo_shell_command(
"Get container log grep",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
+2 -2
View File
@@ -90,7 +90,7 @@ impl Resolve<crate::api::Args> for GetSwarmServiceLog {
run_komodo_standard_command(
"Get Swarm Service Log",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
@@ -141,7 +141,7 @@ impl Resolve<crate::api::Args> for GetSwarmServiceLogSearch {
run_komodo_shell_command(
"Search Swarm Service Log",
command,
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await,
)
+1 -1
View File
@@ -21,7 +21,7 @@ pub async fn list_compose_projects()
let res = run_komodo_standard_command(
"List Projects",
format!("{docker_compose} ls --all --format json"),
CommandOptions::default().timeout(Duration::from_secs(1)),
CommandOptions::default().timeout(Duration::from_secs(5)),
)
.await;
+2 -2
View File
@@ -26,7 +26,7 @@ pub async fn list_swarm_configs(
let res = run_komodo_standard_command(
"List Swarm Configs",
"docker config ls --format json",
CommandOptions::default().timeout(Duration::from_secs(1)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await;
@@ -73,7 +73,7 @@ pub async fn inspect_swarm_config(
// `--` so a name beginning with `-` is not parsed as a flag.
// The quotes alone give no protection, as they are stripped when lexed.
format!(r#"docker config inspect -- "{config}""#),
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await;
+3 -3
View File
@@ -74,7 +74,7 @@ pub async fn list_swarm_stacks(
let res = run_komodo_standard_command(
"List Swarm Stacks",
"docker stack ls --format json",
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await;
@@ -133,7 +133,7 @@ pub async fn list_swarm_stack_service_ids(
let res = run_komodo_standard_command(
"List Swarm Stack Services",
format!("docker stack services --format json -- {stack}"),
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await;
@@ -163,7 +163,7 @@ pub async fn list_swarm_stack_tasks(
let res = run_komodo_standard_command(
"List Swarm Stack Tasks",
format!("docker stack ps --format json --no-trunc -- {stack}"),
CommandOptions::default().timeout(Duration::from_secs(3)),
CommandOptions::default().timeout(Duration::from_secs(10)),
)
.await;
-1
View File
@@ -1,6 +1,5 @@
use anyhow::Context;
use bollard::query_parameters::ListTasksOptions;
use komodo_client::entities::docker::task::*;
use super::*;
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "komodo_client"
description = "Client for the Komodo build and deployment system"
version.workspace = true
version = "2.3.3"
edition.workspace = true
license.workspace = true
homepage.workspace = true
+8 -6
View File
@@ -256,14 +256,14 @@ impl MaybeNone for BuilderConfigDiff {
impl From<PartialBuilderConfig> for BuilderConfig {
fn from(value: PartialBuilderConfig) -> BuilderConfig {
match value {
PartialBuilderConfig::Url(server) => {
BuilderConfig::Url(server.into())
PartialBuilderConfig::Url(config) => {
BuilderConfig::Url(config.into())
}
PartialBuilderConfig::Server(server) => {
BuilderConfig::Server(server.into())
PartialBuilderConfig::Server(config) => {
BuilderConfig::Server(config.into())
}
PartialBuilderConfig::Aws(builder) => {
BuilderConfig::Aws(builder.into())
PartialBuilderConfig::Aws(config) => {
BuilderConfig::Aws(config.into())
}
}
}
@@ -469,11 +469,13 @@ pub struct ServerBuilderConfig {
deserialize_with = "string_list_deserializer"
)]
#[partial_attr(serde(
default,
alias = "server_id",
alias = "server",
alias = "servers",
deserialize_with = "option_string_list_deserializer"
))]
#[builder(default)]
#[cfg_attr(
feature = "schemars",
partial_attr(schemars(rename = "servers"))
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "komodo_client",
"version": "2.3.1",
"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"
}
+129 -8
View File
@@ -2,19 +2,140 @@
# yarn lockfile v1
"@typescript/typescript-aix-ppc64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz#cdc7ce81d60f1e09034960ddfb1fb880d7a776b6"
integrity sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==
"@typescript/typescript-darwin-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz#a55fdfcfa58df58d27db2237cde6a5c1e35a7235"
integrity sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==
"@typescript/typescript-darwin-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz#38d1c9172800a91d707bec64d2a370a016634db4"
integrity sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==
"@typescript/typescript-freebsd-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz#f1ff8810030b35d2b5be0db6a2dc650460ea94fa"
integrity sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==
"@typescript/typescript-freebsd-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz#3d86b03f353c5b1ba95162eb6ce35533bfc294bd"
integrity sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==
"@typescript/typescript-linux-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz#d9334d96d6dac6ff85da9c865588948de939e91f"
integrity sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==
"@typescript/typescript-linux-arm@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz#ad94b41e1aee2a4dcc6a298c7b67c43345fde32e"
integrity sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==
"@typescript/typescript-linux-loong64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz#2965aee4fc873360139d893daafe6397a29138ad"
integrity sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==
"@typescript/typescript-linux-mips64el@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz#1a887a311bed3a833f80bfd4a9ed37c271936cf0"
integrity sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==
"@typescript/typescript-linux-ppc64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz#8b63c9b2f445b393eb4e43ec21da225dade3577d"
integrity sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==
"@typescript/typescript-linux-riscv64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz#b6e8a35c289b3ea97a92a41d461aaeed0d3b36e1"
integrity sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==
"@typescript/typescript-linux-s390x@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz#2ef96693be4861f6d17965427e5b009cbbed1a3e"
integrity sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==
"@typescript/typescript-linux-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz#73269cb0baba50aea0ca060445a6b88e583f1ce2"
integrity sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==
"@typescript/typescript-netbsd-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz#3a3649f97fafa210b4e6e3798c15e06605c8a901"
integrity sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==
"@typescript/typescript-netbsd-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz#47ec59491a40c470d2807dc4d2b825528fd979ab"
integrity sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==
"@typescript/typescript-openbsd-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz#796be8da0bd989d8a3fb96f2801e38a8365b4baf"
integrity sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==
"@typescript/typescript-openbsd-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz#d37fe2a729eb942c076c454ee7f1815faf7d560f"
integrity sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==
"@typescript/typescript-sunos-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz#aba8d3464c3565a7044789baba96916bd4ab2c88"
integrity sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==
"@typescript/typescript-win32-arm64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz#b9de50a17196383f62620b5f9d0a2f34ad3b60d7"
integrity sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==
"@typescript/typescript-win32-x64@7.0.2":
version "7.0.2"
resolved "https://registry.yarnpkg.com/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz#cf3b7b0d6ce5635daca4c8e01c189cdcde47ec3c"
integrity sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==
jwt-decode@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b"
integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==
mogh_auth_client@^1.7.0:
version "1.7.0"
resolved "https://registry.yarnpkg.com/mogh_auth_client/-/mogh_auth_client-1.7.0.tgz#a016c8dafd0f73633098d41aa95889c444e481d9"
integrity sha512-v4TJVNIiFYBLd70yGw/eS37E15he4I5w3poj5X7W75D+m6ry9gbhtlePp5elRbqjDZ/WXkOZ/XYCHqGYb/PQ2A==
mogh_auth_client@^1.7.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/mogh_auth_client/-/mogh_auth_client-1.7.1.tgz#98469ee4cff4c47c991235acd0b96c1b27c5a1e4"
integrity sha512-IWT7ET5QmqNGqrGgNy4oyF4LMb1TeLXQhDw6AXzd5S/WfWC9JJx7xjnIdJFqXpl3zoZ+va773RrPB5ufGPkVVQ==
dependencies:
jwt-decode "^4.0.0"
typescript@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.2.tgz#0b1bfb15f68c64b97032f3d78abbf98bdbba501f"
integrity sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==
typescript@^7.0.2:
version "7.0.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-7.0.2.tgz#9ec773d7954a8c182c17cc5bbd575aa28bc51582"
integrity sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==
optionalDependencies:
"@typescript/typescript-aix-ppc64" "7.0.2"
"@typescript/typescript-darwin-arm64" "7.0.2"
"@typescript/typescript-darwin-x64" "7.0.2"
"@typescript/typescript-freebsd-arm64" "7.0.2"
"@typescript/typescript-freebsd-x64" "7.0.2"
"@typescript/typescript-linux-arm" "7.0.2"
"@typescript/typescript-linux-arm64" "7.0.2"
"@typescript/typescript-linux-loong64" "7.0.2"
"@typescript/typescript-linux-mips64el" "7.0.2"
"@typescript/typescript-linux-ppc64" "7.0.2"
"@typescript/typescript-linux-riscv64" "7.0.2"
"@typescript/typescript-linux-s390x" "7.0.2"
"@typescript/typescript-linux-x64" "7.0.2"
"@typescript/typescript-netbsd-arm64" "7.0.2"
"@typescript/typescript-netbsd-x64" "7.0.2"
"@typescript/typescript-openbsd-arm64" "7.0.2"
"@typescript/typescript-openbsd-x64" "7.0.2"
"@typescript/typescript-sunos-x64" "7.0.2"
"@typescript/typescript-win32-arm64" "7.0.2"
"@typescript/typescript-win32-x64" "7.0.2"
-19
View File
@@ -498,25 +498,6 @@ webhook_secret = "a_random_webhook_secret"
## Default: empty (none)
webhook_base_url = ""
## Configure Github webhook app. Enables webhook management apis.
## <INSERT LINK TO GUIDE>
## Env: KOMODO_GITHUB_WEBHOOK_APP_APP_ID or KOMODO_GITHUB_WEBHOOK_APP_APP_ID_FILE
# github_webhook_app.app_id = 1234455 # Find on the app page.
## Env:
## - KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_IDS or KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_IDS_FILE
## - KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_NAMESPACES
# github_webhook_app.installations = [
# ## Find the id after installing the app to user / organization. "namespace" is the username / organization name.
# { id = 1234, namespace = "mbecker20" }
# ]
## The path to Github webhook app private key. <INSERT LINK TO GUIDE>
## This is defaulted to `/github/private-key.pem`, and doesn't need to be changed if running core in Docker.
## Just mount the private key pem file on the host to `/github/private-key.pem` in the container.
## Eg. `/your/path/to/key.pem : /github/private-key.pem`
## Env: KOMODO_GITHUB_WEBHOOK_APP_PK_PATH
# github_webhook_app.pk_path = "/path/to/pk.pem"
###########
# LOGGING #
###########
+14 -6
View File
@@ -1,5 +1,8 @@
FROM node:22.12-alpine AS builder
ARG DOCSITE_URL
ARG DOCSITE_BASE_URL=/
WORKDIR /builder
COPY package.json yarn.lock ./
@@ -13,19 +16,24 @@ COPY ./docusaurus.config.ts ./docusaurus.config.ts
COPY ./sidebars.ts ./sidebars.ts
COPY ./tsconfig.json ./tsconfig.json
RUN yarn build
RUN DOCSITE_URL=${DOCSITE_URL} DOCSITE_BASE_URL=${DOCSITE_BASE_URL} yarn build
FROM caddy:latest
# Copy into caddy image /srv
COPY --from=builder /builder/build /srv
ARG DOCSITE_BASE_URL=/
# Add a basic caddyfile serving http
RUN <<EOF cat > /etc/caddy/Caddyfile
# Copy into caddy image /srv, nested under the base url path
COPY --from=builder /builder/build /srv${DOCSITE_BASE_URL}
# Add a basic caddyfile serving http.
# BASE is the base url without its trailing slash (empty when base url is "/"),
# used to redirect e.g. /komodo -> /komodo/ so relative urls resolve correctly.
RUN BASE="${DOCSITE_BASE_URL%/}" && cat > /etc/caddy/Caddyfile <<EOF
:80 {
root * /srv
${BASE:+redir $BASE $BASE/ 308}
file_server
try_files {path} /index.html
try_files {path} {path}.html ${DOCSITE_BASE_URL}index.html
}
EOF
+2 -3
View File
@@ -11,11 +11,10 @@ const config: Config = {
favicon: "img/favicon.ico",
// Set the production url of your site here
url: "https://komo.do",
url: process.env.DOCSITE_URL || "https://komo.do",
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
// baseUrl: "/komodo/",
baseUrl: "/",
baseUrl: process.env.DOCSITE_BASE_URL || "/",
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.97.1-trixie as builder
FROM rust:1.98.0-trixie as builder
WORKDIR /builder
COPY . .
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.97.1-trixie as builder
FROM rust:1.98.0-trixie as builder
WORKDIR /builder
COPY . .
+836
View File
@@ -0,0 +1,836 @@
import { KOMODO_BASE_URL } from "@main";
import { KomodoClient, Types } from "komodo_client";
import {
AuthResponses,
ExecuteResponses,
ReadResponses,
UserResponses,
WriteResponses,
} from "komodo_client/dist/responses";
import {
UseMutationOptions,
UseQueryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { UsableResource } from "@types";
import { useToast } from "@ui/use-toast";
import { atom, useAtom } from "jotai";
import { atomFamily } from "jotai/utils";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { has_minimum_permissions, RESOURCE_TARGETS } from "./utils";
export const atomWithStorage = <T>(key: string, init: T) => {
const stored = localStorage.getItem(key);
const inner = atom(stored ? JSON.parse(stored) : init);
return atom(
(get) => get(inner),
(_, set, newValue) => {
set(inner, newValue);
localStorage.setItem(key, JSON.stringify(newValue));
}
);
};
type LoginTokens = {
/** Current User ID */
current: string | undefined;
/** Array of logged in user ids / tokens */
tokens: Array<Types.JwtResponse>;
};
const LOGIN_TOKENS_KEY = "komodo-auth-tokens-v1";
export const LOGIN_TOKENS = (() => {
const stored = localStorage.getItem(LOGIN_TOKENS_KEY);
let tokens: LoginTokens = stored
? JSON.parse(stored)
: { current: undefined, tokens: [] };
const update_local_storage = () => {
localStorage.setItem(LOGIN_TOKENS_KEY, JSON.stringify(tokens));
};
const accounts = () => {
const current = tokens.tokens.find((t) => t.user_id === tokens.current);
const filtered = tokens.tokens.filter((t) => t.user_id !== tokens.current);
return current ? [current, ...filtered] : filtered;
};
const add_and_change = (user_id: string, jwt: string) => {
const filtered = tokens.tokens.filter((t) => t.user_id !== user_id);
filtered.push({ user_id, jwt });
filtered.sort();
tokens = {
current: user_id,
tokens: filtered,
};
update_local_storage();
};
const remove = (user_id: string) => {
const filtered = tokens.tokens.filter((t) => t.user_id !== user_id);
tokens = {
current:
tokens.current === user_id ? filtered[0]?.user_id : tokens.current,
tokens: filtered,
};
update_local_storage();
};
const remove_all = () => {
tokens = {
current: undefined,
tokens: [],
};
update_local_storage();
};
const change = (to_id: string) => {
tokens = {
current: to_id,
tokens: tokens.tokens,
};
update_local_storage();
};
return {
jwt: () =>
tokens.current
? (tokens.tokens.find((t) => t.user_id === tokens.current)?.jwt ?? "")
: "",
accounts,
add_and_change,
remove,
remove_all,
change,
};
})();
export const komodo_client = () =>
KomodoClient(KOMODO_BASE_URL, {
type: "jwt",
params: { jwt: LOGIN_TOKENS.jwt() },
});
// ============== RESOLVER ==============
export const useLoginOptions = () => {
return useQuery({
queryKey: ["GetLoginOptions"],
queryFn: () => komodo_client().auth("GetLoginOptions", {}),
refetchInterval: 30_000,
});
};
export const useUser = () => {
const userReset = useUserReset();
const hasJwt = !!LOGIN_TOKENS.jwt();
const query = useQuery({
queryKey: ["GetUser"],
queryFn: () => komodo_client().auth("GetUser", {}),
refetchInterval: 30_000,
enabled: hasJwt,
});
useEffect(() => {
if (query.data && query.error) {
userReset();
}
}, [query.data, query.error]);
return query;
};
export const useUserInvalidate = () => {
const qc = useQueryClient();
return () => {
qc.invalidateQueries({ queryKey: ["GetUser"] });
};
};
export const useUserReset = () => {
const qc = useQueryClient();
return () => {
qc.resetQueries({ queryKey: ["GetUser"] });
};
};
export const useRead = <
T extends Types.ReadRequest["type"],
R extends Extract<Types.ReadRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseQueryOptions<
ReadResponses[R["type"]],
unknown,
ReadResponses[R["type"]],
(T | P)[]
>,
"queryFn" | "queryKey"
>,
>(
type: T,
params: P,
config?: C
) => {
const hasJwt = !!LOGIN_TOKENS.jwt();
return useQuery({
queryKey: [type, params],
queryFn: () => komodo_client().read<T, R>(type, params),
enabled: hasJwt && (config?.enabled !== false),
...config,
});
};
export const useInvalidate = () => {
const qc = useQueryClient();
return <
Type extends Types.ReadRequest["type"],
Params extends Extract<Types.ReadRequest, { type: Type }>["params"],
>(
...keys: Array<[Type] | [Type, Params]>
) => keys.forEach((key) => qc.invalidateQueries({ queryKey: key }));
};
export const useManageUser = <
T extends Types.UserRequest["type"],
R extends Extract<Types.UserRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<UserResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>,
>(
type: T,
config?: C
) => {
const { toast } = useToast();
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().user<T, R>(type, params),
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Auth error:", e);
const msg = e.result?.error ?? "Unknown error. See console.";
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Request ${type} Failed`,
description: `${msg_log}See console for details`,
variant: "destructive",
});
config?.onError && config.onError(e, v, c);
},
...config,
});
};
export const useWrite = <
T extends Types.WriteRequest["type"],
R extends Extract<Types.WriteRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<WriteResponses[R["type"]], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>,
>(
type: T,
config?: C
) => {
const { toast } = useToast();
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().write<T, R>(type, params),
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Write error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Write request ${type} failed`,
description: `${msg_log}See console for details`,
variant: "destructive",
});
config?.onError && config.onError(e, v, c);
},
...config,
});
};
export const useExecute = <
T extends Types.ExecuteRequest["type"],
R extends Extract<Types.ExecuteRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<ExecuteResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>,
>(
type: T,
config?: C
) => {
const { toast } = useToast();
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().execute<T, R>(type, params),
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Execute error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Execute request ${type} failed`,
description: `${msg_log}See console for details`,
variant: "destructive",
});
config?.onError && config.onError(e, v, c);
},
...config,
});
};
export const useAuth = <
T extends Types.AuthRequest["type"],
R extends Extract<Types.AuthRequest, { type: T }>,
P extends R["params"],
C extends Omit<
UseMutationOptions<AuthResponses[T], unknown, P, unknown>,
"mutationKey" | "mutationFn"
>,
>(
type: T,
config?: C
) => {
const { toast } = useToast();
return useMutation({
mutationKey: [type],
mutationFn: (params: P) => komodo_client().auth<T, R>(type, params),
onError: (e: { result: { error?: string; trace?: string[] } }, v, c) => {
console.log("Auth error:", e);
const msg = e.result.error ?? "Unknown error. See console.";
const detail = e.result?.trace
?.map((msg) => msg[0].toUpperCase() + msg.slice(1))
.join(" | ");
let msg_log = msg ? msg[0].toUpperCase() + msg.slice(1) + " | " : "";
if (detail) {
msg_log += detail + " | ";
}
toast({
title: `Auth request ${type} failed`,
description: `${msg_log}See console for details`,
variant: "destructive",
});
config?.onError && config.onError(e, v, c);
},
...config,
});
};
// ============== UTILITY ==============
export const useResourceParamType = () => {
const type = useParams().type;
if (!type) return undefined;
if (type === "resource-syncs") return "ResourceSync";
return (type[0].toUpperCase() + type.slice(1, -1)) as UsableResource;
};
type ResourceMap = {
[Resource in UsableResource]: Types.ResourceListItem<unknown>[] | undefined;
};
export const useAllResources = (): ResourceMap => {
return {
Server: useRead("ListServers", {}).data,
Stack: useRead("ListStacks", {}).data,
Deployment: useRead("ListDeployments", {}).data,
Build: useRead("ListBuilds", {}).data,
Repo: useRead("ListRepos", {}).data,
Procedure: useRead("ListProcedures", {}).data,
Action: useRead("ListActions", {}).data,
Builder: useRead("ListBuilders", {}).data,
Alerter: useRead("ListAlerters", {}).data,
ResourceSync: useRead("ListResourceSyncs", {}).data,
};
};
// Returns true if Komodo has no resources.
export const useNoResources = () => {
const resources = useAllResources();
for (const target of RESOURCE_TARGETS) {
if (resources[target] && resources[target].length) {
return false;
}
}
return true;
};
/** returns function that takes a resource target and checks if it exists */
export const useCheckResourceExists = () => {
const resources = useAllResources();
return (target: Types.ResourceTarget) => {
return (
resources[target.type as UsableResource]?.some(
(resource) => resource.id === target.id
) || false
);
};
};
export const useFilterResources = <Info>(
resources?: Types.ResourceListItem<Info>[],
search?: string
) => {
const tags = useTagsFilter();
const searchSplit = search?.toLowerCase()?.split(" ") || [];
return (
resources?.filter(
(resource) =>
tags.every((tag: string) => resource.tags.includes(tag)) &&
(searchSplit.length > 0
? searchSplit.every((search) =>
resource.name.toLowerCase().includes(search)
)
: true)
) ?? []
);
};
export const usePushRecentlyViewed = ({ type, id }: Types.ResourceTarget) => {
const userInvalidate = useUserInvalidate();
const push = useManageUser("PushRecentlyViewed", {
onSuccess: userInvalidate,
}).mutate;
const exists = useRead(`List${type as UsableResource}s`, {}).data?.find(
(r) => r.id === id
)
? true
: false;
useEffect(() => {
exists && push({ resource: { type, id } });
}, [exists, push]);
return () => push({ resource: { type, id } });
};
export const useSetTitle = (more?: string) => {
const info = useRead("GetCoreInfo", {}).data;
const title = more ? `${more} | ${info?.title}` : info?.title;
useEffect(() => {
if (title) {
document.title = title;
}
}, [title]);
};
const tagsAtom = atomWithStorage<string[]>("tags-v0", []);
export const useTags = () => {
const [tags, setTags] = useAtom<string[]>(tagsAtom);
const add_tag = (tag_id: string) => setTags([...tags, tag_id]);
const remove_tag = (tag_id: string) =>
setTags(tags.filter((id) => id !== tag_id));
const toggle_tag = (tag_id: string) => {
if (tags.includes(tag_id)) {
remove_tag(tag_id);
} else {
add_tag(tag_id);
}
};
const clear_tags = () => setTags([]);
return {
tags,
add_tag,
remove_tag,
toggle_tag,
clear_tags,
};
};
export const useTagsFilter = () => {
const [tags] = useAtom<string[]>(tagsAtom);
return tags;
};
export type LocalStorageSetter<T> = (state: T) => T;
export const useLocalStorage = <T>(
key: string,
init: T
): [T, (state: T | LocalStorageSetter<T>) => void] => {
const stored = localStorage.getItem(key);
const parsed = stored ? (JSON.parse(stored) as T) : undefined;
const [state, inner_set] = useState<T>(parsed ?? init);
const set = (state: T | LocalStorageSetter<T>) => {
inner_set((prev_state) => {
const new_val =
typeof state === "function"
? (state as LocalStorageSetter<T>)(prev_state)
: state;
localStorage.setItem(key, JSON.stringify(new_val));
return new_val;
});
};
return [state, set];
};
export const useKeyListener = (listenKey: string, onPress: () => void) => {
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
// This will ignore Shift + listenKey if it is sent from input / textarea
const target = e.target as any;
if (target.matches("input") || target.matches("textarea")) return;
if (e.key === listenKey) {
e.preventDefault();
onPress();
}
};
document.addEventListener("keydown", keydown);
return () => document.removeEventListener("keydown", keydown);
});
};
export const useShiftKeyListener = (listenKey: string, onPress: () => void) => {
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
// This will ignore Shift + listenKey if it is sent from input / textarea
const target = e.target as any;
if (target.matches("input") || target.matches("textarea")) return;
if (e.shiftKey && e.key === listenKey) {
e.preventDefault();
onPress();
}
};
document.addEventListener("keydown", keydown);
return () => document.removeEventListener("keydown", keydown);
});
};
/** Listens for ctrl (or CMD on mac) + the listenKey */
export const useCtrlKeyListener = (listenKey: string, onPress: () => void) => {
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === listenKey) {
e.preventDefault();
onPress();
}
};
document.addEventListener("keydown", keydown);
return () => document.removeEventListener("keydown", keydown);
});
};
export interface PromptHotkeysConfig {
/** Function to call when Enter is pressed (confirm action) */
onConfirm?: () => void;
/** Function to call when Escape is pressed (cancel/close action) */
onCancel?: () => void;
/** Whether the hotkeys are enabled. Defaults to true */
enabled?: boolean;
/** Whether to ignore hotkeys when inside input/textarea elements. Defaults to true */
ignoreInputs?: boolean;
/** Whether the confirm action is disabled (e.g., form validation failed) */
confirmDisabled?: boolean;
}
/**
* Hook that provides standard prompt/dialog hotkey behavior:
* - Enter: Confirm/submit action
* - Escape: Cancel/close action
*/
export const usePromptHotkeys = ({
enabled = true,
onConfirm,
onCancel,
ignoreInputs = true,
confirmDisabled = false,
}: PromptHotkeysConfig) => {
useEffect(() => {
if (!enabled) return;
const findConfirmButton = (): HTMLButtonElement | null => {
const dialogContainers = document.querySelectorAll('[role="dialog"], [data-state="open"], .dialog-content');
for (const container of dialogContainers) {
const button = container.querySelector('[data-confirm-button]:not([disabled])') as HTMLButtonElement;
if (button) return button;
}
return document.querySelector('[data-confirm-button]:not([disabled])') as HTMLButtonElement;
};
const handleKeyDown = (e: KeyboardEvent) => {
if (ignoreInputs) {
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.isContentEditable
) {
return;
}
}
switch (e.key) {
case "Enter":
if (onConfirm && !confirmDisabled) {
e.preventDefault();
const confirmButton = findConfirmButton();
if (confirmButton) {
confirmButton.click();
} else {
onConfirm();
}
}
break;
case "Escape":
if (onCancel) {
e.preventDefault();
onCancel();
}
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [enabled, onConfirm, onCancel, ignoreInputs, confirmDisabled]);
};
export type WebhookIntegration = "Github" | "Gitlab";
export type WebhookIntegrations = {
[key: string]: WebhookIntegration;
};
const WEBHOOK_INTEGRATIONS_ATOM = atomWithStorage<WebhookIntegrations>(
"webhook-integrations-v2",
{}
);
export const useWebhookIntegrations = () => {
const [integrations, setIntegrations] = useAtom<WebhookIntegrations>(
WEBHOOK_INTEGRATIONS_ATOM
);
return {
integrations,
setIntegration: (provider: string, integration: WebhookIntegration) =>
setIntegrations({
...integrations,
[provider]: integration,
}),
};
};
export const getWebhookIntegration = (
integrations: WebhookIntegrations,
git_provider: string
) => {
return integrations[git_provider]
? integrations[git_provider]
: git_provider.includes("gitlab")
? "Gitlab"
: "Github";
};
export type WebhookIdOrName = "Id" | "Name";
const WEBHOOK_ID_OR_NAME_ATOM = atomWithStorage<WebhookIdOrName>(
"webhook-id-or-name-v1",
"Id"
);
export const useWebhookIdOrName = () => {
return useAtom<WebhookIdOrName>(WEBHOOK_ID_OR_NAME_ATOM);
};
export type Dimensions = { width: number; height: number };
export const useWindowDimensions = () => {
const [dimensions, setDimensions] = useState<Dimensions>({
width: 0,
height: 0,
});
useEffect(() => {
const callback = () => {
setDimensions({
width: window.screen.availWidth,
height: window.screen.availHeight,
});
};
callback();
window.addEventListener("resize", callback);
return () => {
window.removeEventListener("resize", callback);
};
}, []);
return dimensions;
};
const selected_resources = atomFamily((_: UsableResource) =>
atom<string[]>([])
);
export const useSelectedResources = (type: UsableResource) =>
useAtom(selected_resources(type));
const filter_by_update_available = atomWithStorage<boolean>(
"update-available-filter-v1",
false
);
export const useFilterByUpdateAvailable: () => [boolean, () => void] = () => {
const [filter, set] = useAtom<boolean>(filter_by_update_available);
return [filter, () => set(!filter)];
};
export const usePermissions = ({ type, id }: Types.ResourceTarget) => {
const user = useUser().data;
const perms = useRead("GetPermission", { target: { type, id } }).data as
| Types.PermissionLevelAndSpecifics
| Types.PermissionLevel
| undefined;
const info = useRead("GetCoreInfo", {}).data;
const ui_write_disabled = info?.ui_write_disabled ?? false;
const disable_non_admin_create = info?.disable_non_admin_create ?? false;
const level =
(perms && typeof perms === "string" ? perms : perms?.level) ??
Types.PermissionLevel.None;
const specific =
(perms && typeof perms === "string" ? [] : perms?.specific) ?? [];
const canWrite = !ui_write_disabled && level === Types.PermissionLevel.Write;
const canExecute = has_minimum_permissions(
{ level, specific },
Types.PermissionLevel.Execute
);
const [
specificLogs,
specificInspect,
specificTerminal,
specificAttach,
specificProcesses,
] = [
specific.includes(Types.SpecificPermission.Logs),
specific.includes(Types.SpecificPermission.Inspect),
specific.includes(Types.SpecificPermission.Terminal),
specific.includes(Types.SpecificPermission.Attach),
specific.includes(Types.SpecificPermission.Processes),
];
const canCreate =
type === "Server"
? user?.admin ||
(!disable_non_admin_create && user?.create_server_permissions)
: type === "Build"
? user?.admin ||
(!disable_non_admin_create && user?.create_build_permissions)
: type === "Alerter" ||
type === "Builder" ||
type === "Procedure" ||
type === "Action"
? user?.admin
: user?.admin || !disable_non_admin_create;
return {
canWrite,
canExecute,
canCreate,
specific,
specificLogs,
specificInspect,
specificTerminal,
specificAttach,
specificProcesses,
};
};
const templatesQueryBehaviorAtom =
atomWithStorage<Types.TemplatesQueryBehavior>(
"templates-query-behavior-v0",
Types.TemplatesQueryBehavior.Exclude
);
export const useTemplatesQueryBehavior = () =>
useAtom<Types.TemplatesQueryBehavior>(templatesQueryBehaviorAtom);
export type SettingsView =
| "Variables"
| "Tags"
| "Providers"
| "Users"
| "Profile";
const viewAtom = atomWithStorage<SettingsView>("settings-view-v2", "Variables");
export const useSettingsView = () => useAtom<SettingsView>(viewAtom);
/**
* Map of unique host ports to array of formatted full port map spec
* Formatted ex: 0.0.0.0:3000:3000/tcp
*/
export type PortsMap = { [host_port: string]: Array<Types.Port> };
export const useContainerPortsMap = (ports: Types.Port[]) => {
return useMemo(() => {
const map: PortsMap = {};
for (const port of ports) {
if (!port.PublicPort || !port.PrivatePort) continue;
if (map[port.PublicPort]) {
map[port.PublicPort].push(port);
} else {
map[port.PublicPort] = [port];
}
}
for (const key in map) {
map[key].sort();
}
return map;
}, [ports]);
};
/**
* A custom React hook that debounces a value, delaying its update until after
* a specified period of inactivity. This is useful for performance optimization
* in scenarios like search inputs, form validation, or API calls.
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
+1 -1
View File
@@ -7,7 +7,7 @@ use command::{CommandOptions, run_standard_command};
pub async fn check_installed() -> anyhow::Result<()> {
if run_standard_command(
"which git",
CommandOptions::default().timeout(Duration::from_secs(1)),
CommandOptions::default().timeout(Duration::from_secs(2)),
)
.await
.success()
+3 -3
View File
@@ -31,7 +31,7 @@ pub async fn get_commit_hash_info(
"git rev-parse --short HEAD",
CommandOptions::default()
.path(repo_dir)
.timeout(Duration::from_secs(1)),
.timeout(Duration::from_secs(2)),
)
.await;
let hash = if hash.status.success() {
@@ -46,7 +46,7 @@ pub async fn get_commit_hash_info(
"git log -1 --pretty=%B",
CommandOptions::default()
.path(repo_dir)
.timeout(Duration::from_secs(1)),
.timeout(Duration::from_secs(2)),
)
.await;
let message = if message.status.success() {
@@ -93,7 +93,7 @@ pub async fn get_remote_url(path: &Path) -> anyhow::Result<String> {
"git remote show origin",
CommandOptions::default()
.path(path)
.timeout(Duration::from_secs(1)),
.timeout(Duration::from_secs(2)),
)
.await;
if output.success() {
+25 -25
View File
@@ -10,48 +10,48 @@
"build-client": "cd ../client/core/ts && yarn && yarn build && yarn link"
},
"dependencies": {
"@mantine/core": "^9.4.1",
"@mantine/form": "^9.4.1",
"@mantine/hooks": "^9.4.1",
"@mantine/notifications": "^9.4.1",
"@mantine/spotlight": "^9.4.1",
"@mantine/core": "^9.5.2",
"@mantine/form": "^9.5.2",
"@mantine/hooks": "^9.5.2",
"@mantine/notifications": "^9.5.2",
"@mantine/spotlight": "^9.5.2",
"@monaco-editor/react": "^4.7.0",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-query": "^5.102.4",
"@tanstack/react-table": "^9.1.2",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"ansi-to-html": "^0.7.2",
"jotai": "^2.20.1",
"jotai-family": "^1.0.2",
"jotai": "^2.20.3",
"jotai-family": "^1.1.0",
"jotai-location": "^0.6.2",
"lucide-react": "^1.22.0",
"mogh_auth_client": "^1.7.0",
"mogh_ui": "^0.7.6",
"lucide-react": "^1.34.0",
"mogh_auth_client": "^1.7.1",
"mogh_ui": "^1.2.7",
"monaco-editor": "^0.56.0",
"monaco-yaml": "^5.5.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-minimal-pie-chart": "^9.1.2",
"react-router-dom": "^7.18.1",
"react-router-dom": "^7.18.2",
"react-xtermjs": "^1.0.12",
"recharts": "^3.9.1",
"sanitize-html": "^2.17.5",
"shell-quote": "^1.9.0"
"recharts": "^3.10.1",
"sanitize-html": "^2.17.7",
"shell-quote": "^1.10.0"
},
"devDependencies": {
"@types/node": "^26.0.1",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@types/node": "^26.3.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@types/sanitize-html": "^2.16.1",
"@types/shell-quote": "^1.7.5",
"@vitejs/plugin-react": "^6.0.3",
"@vitejs/plugin-react": "^6.1.0",
"dotenv": "^17.4.2",
"postcss": "^8.5.16",
"postcss": "^8.5.26",
"postcss-preset-mantine": "^1.18.0",
"postcss-simple-vars": "^7.0.1",
"sass-embedded": "^1.100.0",
"sass-embedded": "^1.103.1",
"typescript": "^7.0.2",
"vite": "^8.1.2"
"vite": "^8.2.2"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+2 -2
View File
@@ -120,10 +120,10 @@ export declare function KomodoClient(url: string, options: InitOptions): {
*/
execute_and_poll: <T extends ExecuteRequest["type"], Req extends Extract<ExecuteRequest, {
type: T;
}>>(type: T, params: Req["params"]) => Promise<Update | (Update | {
}>>(type: T, params: Req["params"]) => Promise<(Update | {
status: "Err";
data: import("./types.js").BatchExecutionResponseItemErr;
})[]>;
})[] | Update>;
/**
* Poll an Update (returned by the `execute` calls) until the `status` is `Complete`.
* https://docs.rs/komodo_client/latest/komodo_client/entities/update/struct.Update.html#structfield.status.
@@ -86,6 +86,7 @@ export default function ContainerSelector({
<Group gap="xs" ml="sm" wrap="nowrap">
{clearable && (
<ActionIcon
component="div"
size="sm"
variant="filled"
color="red"
@@ -93,7 +94,12 @@ export default function ContainerSelector({
e.stopPropagation();
onSelect?.("");
}}
disabled={disabled || !selected}
data-disabled={disabled || !selected || undefined}
style={
disabled || !selected
? { pointerEvents: "none" }
: undefined
}
>
<ICONS.Clear size="0.8rem" />
</ActionIcon>
@@ -94,7 +94,6 @@ export default function ContainersSection({
columns={[
{
accessorKey: "name",
size: 260,
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
@@ -108,7 +107,6 @@ export default function ContainersSection({
},
{
accessorKey: "state",
size: 160,
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
@@ -124,7 +122,6 @@ export default function ContainersSection({
},
{
accessorKey: "image",
size: 300,
header: ({ column }) => (
<SortableHeader column={column} title="Image" />
),
@@ -139,7 +136,6 @@ export default function ContainersSection({
},
{
accessorKey: "networks.0",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Networks" />
),
@@ -167,8 +163,7 @@ export default function ContainersSection({
},
{
accessorKey: "ports.0",
size: 200,
sortingFn: (a, b) => {
sortFn: (a, b) => {
const getMinHostPort = (row: typeof a) => {
const ports = row.original.ports ?? [];
if (!ports.length) return Number.POSITIVE_INFINITY;
@@ -197,7 +192,6 @@ export default function ContainersSection({
},
{
accessorKey: "volumes.0",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Volumes" />
),
+8 -25
View File
@@ -1,5 +1,8 @@
import { useRead } from "@/lib/hooks";
import { Group, Pagination } from "@mantine/core";
import {
ListPaginationProps,
ListPagination as MoghListPagination,
} from "mogh_ui";
/**
* The server side default page size on `List<Resource>` calls,
@@ -18,29 +21,9 @@ export function usePageSize() {
* Only renders when needed, ie the results fill the page
* or the user is past the first page.
*/
export default function ListPagination({
page,
setPage,
count,
}: {
page: number;
setPage: (page: number) => void;
count: number;
}) {
export default function ListPagination(
props: Omit<ListPaginationProps, "pageSize">,
) {
const pageSize = usePageSize();
if (count < pageSize && page === 0) return null;
return (
<Pagination.Root
total={count >= pageSize ? page + 2 : page + 1}
value={page + 1}
onChange={(page) => setPage(page - 1)}
>
<Group gap="0.2rem" justify="center">
<Pagination.First />
<Pagination.Previous />
<Pagination.Items />
<Pagination.Next />
</Group>
</Pagination.Root>
);
return <MoghListPagination {...props} pageSize={pageSize} />;
}
@@ -88,7 +88,6 @@ export default function ConfigMaintenanceWindows({
<Text>{row.original.name}</Text>
</Group>
),
size: 200,
},
{
accessorKey: "schedule_type",
@@ -98,7 +97,6 @@ export default function ConfigMaintenanceWindows({
cell: ({ row }) => (
<ScheduleDescription window={row.original} />
),
size: 150,
},
{
accessorKey: "start_time",
@@ -106,7 +104,6 @@ export default function ConfigMaintenanceWindows({
<SortableHeader column={column} title="Start Time" />
),
cell: ({ row }) => fmtMaintenanceWindowTime(row.original),
size: 180,
},
{
accessorKey: "duration_minutes",
@@ -114,7 +111,6 @@ export default function ConfigMaintenanceWindows({
<SortableHeader column={column} title="Duration" />
),
accessorFn: (row) => `${row.duration_minutes} min`,
size: 100,
},
{
accessorKey: "enabled",
@@ -128,7 +124,6 @@ export default function ConfigMaintenanceWindows({
disabled={disabled}
/>
),
size: 120,
},
{
id: "actions",
@@ -152,7 +147,6 @@ export default function ConfigMaintenanceWindows({
</ActionIcon>
</Group>
),
size: 100,
},
]}
/>
@@ -102,7 +102,6 @@ export default function BasePermissionsSection({
columns={[
{
accessorKey: "type",
size: 150,
header: ({ column }) => (
<SortableHeader column={column} title="Resource Type" />
),
@@ -119,8 +118,7 @@ export default function BasePermissionsSection({
},
{
accessorKey: "level",
size: 150,
sortingFn: (a, b) =>
sortFn: (a, b) =>
levelSortingFn(a.original.level, b.original.level),
header: ({ column }) => (
<SortableHeader column={column} title="Level" />
@@ -143,7 +141,6 @@ export default function BasePermissionsSection({
},
{
header: "Specific",
size: 300,
cell: ({ row }) => {
return (
<SpecificPermissionSelector
@@ -53,7 +53,6 @@ export default function SpecificPermissionsSection({
columns={[
{
accessorKey: "resource_target.type",
size: 150,
header: ({ column }) => (
<SortableHeader column={column} title="Resource" />
),
@@ -72,7 +71,6 @@ export default function SpecificPermissionsSection({
},
{
accessorKey: "resource_target",
size: 250,
header: ({ column }) => (
<SortableHeader column={column} title="Target" />
),
@@ -91,8 +89,7 @@ export default function SpecificPermissionsSection({
},
{
accessorKey: "level",
size: 150,
sortingFn: (a, b) =>
sortFn: (a, b) =>
levelSortingFn(a.original.level, b.original.level),
header: ({ column }) => (
<SortableHeader column={column} title="Level" />
@@ -115,7 +112,6 @@ export default function SpecificPermissionsSection({
},
{
header: "Specific",
size: 300,
cell: ({ row: { original: permission } }) => {
return (
<SpecificPermissionSelector
+7 -1
View File
@@ -101,6 +101,7 @@ export default function StackServiceSelector({
<Group gap="xs" ml="sm" wrap="nowrap">
{clearable && (
<ActionIcon
component="div"
size="sm"
variant="filled"
color="red"
@@ -108,7 +109,12 @@ export default function StackServiceSelector({
e.stopPropagation();
onSelect?.("");
}}
disabled={disabled || !selected}
data-disabled={disabled || !selected || undefined}
style={
disabled || !selected
? { pointerEvents: "none" }
: undefined
}
>
<ICONS.Clear size="0.8rem" />
</ActionIcon>
@@ -66,7 +66,6 @@ export default function SwarmServicesSection({
name={row.original.Name}
/>
),
size: 200,
},
{
accessorKey: "ID",
@@ -74,7 +73,6 @@ export default function SwarmServicesSection({
<SortableHeader column={column} title="ID" />
),
cell: ({ row }) => row.original.ID ?? "Unknown",
size: 200,
},
{
accessorKey: "UpdatedAt",
@@ -85,7 +83,6 @@ export default function SwarmServicesSection({
row.original.UpdatedAt
? new Date(row.original.UpdatedAt).toLocaleString()
: "Unknown",
size: 200,
},
{
accessorKey: "CreatedAt",
@@ -96,7 +93,6 @@ export default function SwarmServicesSection({
row.original.CreatedAt
? new Date(row.original.CreatedAt).toLocaleString()
: "Unknown",
size: 200,
},
]}
/>
@@ -86,7 +86,6 @@ export default function SwarmTasksSection({
name={row.original.ID}
/>
),
size: 150,
},
{
accessorKey: "node.Hostname",
@@ -101,7 +100,6 @@ export default function SwarmTasksSection({
name={row.original.node?.Hostname}
/>
),
size: 200,
},
{
accessorKey: "service.Name",
@@ -116,7 +114,6 @@ export default function SwarmTasksSection({
name={row.original.service?.Name}
/>
),
size: 200,
},
{
accessorKey: "State",
@@ -157,7 +154,6 @@ export default function SwarmTasksSection({
row.original.UpdatedAt
? new Date(row.original.UpdatedAt).toLocaleString()
: "Unknown",
size: 200,
},
// {
// accessorKey: "CreatedAt",
+8 -158
View File
@@ -1,169 +1,19 @@
import { Box, useComputedColorScheme } from "@mantine/core";
import { useEffect, useMemo, useRef } from "react";
import { ITheme } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { useXTerm, UseXTermProps } from "react-xtermjs";
import { TerminalCallbacks } from "komodo_client";
import { lazy, Suspense } from "react";
const LIGHT_THEME: ITheme = {
background: "#EDEEEF",
foreground: "#000000",
cursor: "#000000",
selectionBackground: "#C8D9FA",
scrollbarSliderBackground: "#D3D3D3",
};
// The implementation pulls in @xterm / react-xtermjs,
// so it is loaded lazily to keep them out of the entry chunk.
const TerminalInner = lazy(() => import("./xterm"));
const DARK_THEME: ITheme = {
background: "#15171B",
foreground: "#FFFFFF",
cursor: "#FFFFFF",
selectionBackground: "#B4BFD6",
scrollbarSliderBackground: "#232528",
};
export default function Terminal({
makeWs,
selected,
_reconnect,
_clear,
}: {
export default function Terminal(props: {
makeWs: (callbacks: TerminalCallbacks) => WebSocket | undefined;
selected: boolean;
_reconnect: boolean;
_clear?: boolean;
}) {
const currentTheme = useComputedColorScheme();
const theme = currentTheme === "dark" ? DARK_THEME : LIGHT_THEME;
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon>(new FitAddon());
const resize = () => {
fitRef.current.fit();
if (term) {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const json = JSON.stringify({
rows: term.rows,
cols: term.cols,
});
const buf = new Uint8Array(json.length + 1);
for (let i = 0; i < json.length; i++) buf[i] = json.charCodeAt(i);
buf[json.length] = 0xff; // resize postfix
wsRef.current.send(buf);
}
term.focus();
}
};
const onStdin = (data: string) => {
// This is data user writes to stdin
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const buf = new Uint8Array(data.length + 1);
for (let i = 0; i < data.length; i++) buf[i] = data.charCodeAt(i);
buf[data.length] = 0x01; // forward data postfix
wsRef.current.send(buf);
};
useEffect(resize, [selected]);
const params: UseXTermProps = useMemo(
() => ({
options: {
convertEol: false,
cursorBlink: true,
cursorStyle: "block",
fontFamily: "monospace",
scrollback: 5000,
// This is handled in ws on_message handler
scrollOnUserInput: false,
theme,
},
listeners: {
onResize: resize,
onData: onStdin,
},
addons: [fitRef.current],
}),
[theme],
);
const { instance: term, ref: termRef } = useXTerm(params);
const viewport = (term as any)?._core?.viewport?._viewportElement as
| HTMLDivElement
| undefined;
useEffect(() => {
if (!term || !viewport) return;
let delta = 0;
term.attachCustomWheelEventHandler((e) => {
e.preventDefault();
// This is used to make touchpad and mousewheel more similar
delta += Math.sign(e.deltaY) * Math.sqrt(Math.abs(e.deltaY)) * 20;
return false;
});
const int = setInterval(() => {
if (Math.abs(delta) < 1) return;
viewport.scrollTop += delta;
delta = 0;
}, 100);
return () => clearInterval(int);
}, [term, termRef.current]);
useEffect(() => {
if (!selected || !term) return;
term.clear();
let debounce = -1;
const callbacks: TerminalCallbacks = {
on_login: () => {
// console.log("Logged into terminal");
},
on_open: resize,
on_message: (e: MessageEvent<any>) => {
term.write(new Uint8Array(e.data as ArrayBuffer), () => {
if (viewport) {
viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight;
}
clearTimeout(debounce);
debounce = setTimeout(() => {
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight;
}, 500) as any;
});
},
on_close: () => {
term.writeln("\r\n\x1b[33m[connection closed]\x1b[0m");
},
};
const ws = makeWs(callbacks);
if (!ws) return;
wsRef.current = ws;
return () => {
ws.close();
wsRef.current = null;
};
}, [term, viewport, makeWs, selected, _reconnect]);
useEffect(() => term?.clear(), [_clear]);
return (
<Box
p="md"
className="bordered-light"
bdrs="md"
style={{
display: selected ? undefined : "none",
}}
>
<Box ref={termRef} h="max(200px, calc(100vh - 320px))" />
</Box>
<Suspense fallback={null}>
<TerminalInner {...props} />
</Suspense>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { Box, useComputedColorScheme } from "@mantine/core";
import { useEffect, useMemo, useRef } from "react";
import { ITheme } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { useXTerm, UseXTermProps } from "react-xtermjs";
import { TerminalCallbacks } from "komodo_client";
const LIGHT_THEME: ITheme = {
background: "#EDEEEF",
foreground: "#000000",
cursor: "#000000",
selectionBackground: "#C8D9FA",
scrollbarSliderBackground: "#D3D3D3",
};
const DARK_THEME: ITheme = {
background: "#15171B",
foreground: "#FFFFFF",
cursor: "#FFFFFF",
selectionBackground: "#B4BFD6",
scrollbarSliderBackground: "#232528",
};
export default function Terminal({
makeWs,
selected,
_reconnect,
_clear,
}: {
makeWs: (callbacks: TerminalCallbacks) => WebSocket | undefined;
selected: boolean;
_reconnect: boolean;
_clear?: boolean;
}) {
const currentTheme = useComputedColorScheme();
const theme = currentTheme === "dark" ? DARK_THEME : LIGHT_THEME;
const wsRef = useRef<WebSocket | null>(null);
const fitRef = useRef<FitAddon>(new FitAddon());
const resize = () => {
fitRef.current.fit();
if (term) {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const json = JSON.stringify({
rows: term.rows,
cols: term.cols,
});
const buf = new Uint8Array(json.length + 1);
for (let i = 0; i < json.length; i++) buf[i] = json.charCodeAt(i);
buf[json.length] = 0xff; // resize postfix
wsRef.current.send(buf);
}
term.focus();
}
};
const onStdin = (data: string) => {
// This is data user writes to stdin
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
const buf = new Uint8Array(data.length + 1);
for (let i = 0; i < data.length; i++) buf[i] = data.charCodeAt(i);
buf[data.length] = 0x01; // forward data postfix
wsRef.current.send(buf);
};
useEffect(resize, [selected]);
const params: UseXTermProps = useMemo(
() => ({
options: {
convertEol: false,
cursorBlink: true,
cursorStyle: "block",
fontFamily: "monospace",
scrollback: 5000,
// This is handled in ws on_message handler
scrollOnUserInput: false,
theme,
},
listeners: {
onResize: resize,
onData: onStdin,
},
addons: [fitRef.current],
}),
[theme],
);
const { instance: term, ref: termRef } = useXTerm(params);
const viewport = (term as any)?._core?.viewport?._viewportElement as
| HTMLDivElement
| undefined;
useEffect(() => {
if (!term || !viewport) return;
let delta = 0;
term.attachCustomWheelEventHandler((e) => {
e.preventDefault();
// This is used to make touchpad and mousewheel more similar
delta += Math.sign(e.deltaY) * Math.sqrt(Math.abs(e.deltaY)) * 20;
return false;
});
const int = setInterval(() => {
if (Math.abs(delta) < 1) return;
viewport.scrollTop += delta;
delta = 0;
}, 100);
return () => clearInterval(int);
}, [term, termRef.current]);
useEffect(() => {
if (!selected || !term) return;
term.clear();
let debounce = -1;
const callbacks: TerminalCallbacks = {
on_login: () => {
// console.log("Logged into terminal");
},
on_open: resize,
on_message: (e: MessageEvent<any>) => {
term.write(new Uint8Array(e.data as ArrayBuffer), () => {
if (viewport) {
viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight;
}
clearTimeout(debounce);
debounce = setTimeout(() => {
if (!viewport) return;
viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight;
}, 500) as any;
});
},
on_close: () => {
term.writeln("\r\n\x1b[33m[connection closed]\x1b[0m");
},
};
const ws = makeWs(callbacks);
if (!ws) return;
wsRef.current = ws;
return () => {
ws.close();
wsRef.current = null;
};
}, [term, viewport, makeWs, selected, _reconnect]);
useEffect(() => term?.clear(), [_clear]);
return (
<Box
p="md"
className="bordered-light"
bdrs="md"
style={{
display: selected ? undefined : "none",
}}
>
<Box ref={termRef} h="max(200px, calc(100vh - 320px))" />
</Box>
);
}
+6 -2
View File
@@ -2,7 +2,7 @@ import { useUser } from "@/lib/hooks";
import { ICONS } from "@/lib/icons";
import { ConfirmButton } from "mogh_ui";
import { ConfirmModal } from "mogh_ui";
import { DataTable } from "mogh_ui";
import { DataTable, DataTableFeatures } from "mogh_ui";
import { StatusBadge } from "mogh_ui";
import { Badge, BoxProps } from "@mantine/core";
import { ColumnDef } from "@tanstack/react-table";
@@ -27,7 +27,11 @@ export default function UserTable({
}: UserTableProps) {
const user = useUser().data;
const nav = useNavigate();
const columns: ColumnDef<Types.User, "User" | "Admin" | "Super Admin">[] = [
const columns: ColumnDef<
DataTableFeatures,
Types.User,
"User" | "Admin" | "Super Admin"
>[] = [
{ header: "Username", accessorKey: "username" },
{
header: "Type",
+7
View File
@@ -3,6 +3,7 @@ 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;
@@ -320,3 +321,9 @@ export function parseVersion(version: string): Types.Version {
patch,
};
}
/** Crumb linking to the list page for a resource type, eg. `Servers` -> /servers */
export function resourceTypeCrumb(type: UsableResource): Crumb {
const name = type === "ResourceSync" ? "Resource Sync" : type;
return { label: `${name}s`, to: `/${usableResourcePath(type)}` };
}
+4 -3
View File
@@ -6,7 +6,6 @@ import { Router } from "@/router";
import { setAuthUrl, ThemeProvider } from "mogh_ui";
import { themeAdditionalColors } from "@/lib/color";
import { Notifications } from "@mantine/notifications";
import initMonaco from "@/monaco";
import "@mantine/core/styles.css";
// ‼️ import extra package styles after core package styles
@@ -17,10 +16,12 @@ import "./index.scss";
// Import mogh_ui scss
import "mogh_ui/index.scss";
initMonaco();
// Loaded dynamically to keep monaco-editor / monaco-yaml out of the entry chunk.
// Doesn't need to be awaited - applies in background when ready.
import("@/monaco").then(({ default: initMonaco }) => initMonaco());
export const KOMODO_BASE_URL =
import.meta.env.VITE_KOMODO_HOST ?? location.origin;
import.meta.env.VITE_KOMODO_HOST || location.origin;
export const UPDATE_WS_URL =
KOMODO_BASE_URL.replace("http", "ws") + "/ws/update";
const client = new QueryClient({
+2 -9
View File
@@ -96,7 +96,6 @@ export default function Containers() {
{
id: "Name",
accessorKey: "name",
size: 260,
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
@@ -111,8 +110,7 @@ export default function Containers() {
{
id: "Server",
accessorKey: "server_id",
size: 200,
sortingFn: (a, b) => {
sortFn: (a, b) => {
const sa = a.original.server_name;
const sb = b.original.server_name;
@@ -134,7 +132,6 @@ export default function Containers() {
{
id: "State",
accessorKey: "state",
size: 160,
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
@@ -151,7 +148,6 @@ export default function Containers() {
{
id: "Image",
accessorKey: "image",
size: 300,
header: ({ column }) => (
<SortableHeader column={column} title="Image" />
),
@@ -167,7 +163,6 @@ export default function Containers() {
{
id: "Networks",
accessorKey: "networks.0",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Networks" />
),
@@ -196,8 +191,7 @@ export default function Containers() {
{
id: "Ports",
accessorKey: "ports.0",
size: 200,
sortingFn: (a, b) => {
sortFn: (a, b) => {
const getMinHostPort = (row: typeof a) => {
const ports = row.original.ports ?? [];
if (!ports.length) return Number.POSITIVE_INFINITY;
@@ -227,7 +221,6 @@ export default function Containers() {
{
id: "Volumes",
accessorKey: "volumes.0",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Volumes" />
),
-2
View File
@@ -176,7 +176,6 @@ function ImageInner({
{
accessorKey: "CreatedBy",
header: "Created By",
size: 400,
},
{
accessorKey: "Created",
@@ -185,7 +184,6 @@ function ImageInner({
fmtDateWithMinutes(
new Date(row.original.Created * 1000),
),
size: 200,
},
]}
/>
-1
View File
@@ -165,7 +165,6 @@ function NetworkInner({
) : (
"Unknown"
),
size: 200,
},
{
accessorKey: "IPv4Address",
+14 -4
View File
@@ -14,13 +14,12 @@ import {
UsableResource,
} from "@/resources";
import { AddResourceTags, ResourceTags } from "@/resources/tags";
import { DividedChildren } from "mogh_ui";
import { Section } from "mogh_ui";
import { DividedChildren, EntityPage } from "mogh_ui";
import { Section, PageBreadcrumbs } from "mogh_ui";
import { Group, Stack, Text } from "@mantine/core";
import { Types } from "komodo_client";
import { Link, useParams } from "react-router-dom";
import { EntityPage } from "mogh_ui";
import { usableResourcePath } from "@/lib/utils";
import { resourceTypeCrumb, usableResourcePath } from "@/lib/utils";
import ResourceDescription from "@/resources/description";
import ResourceNotFound from "@/resources/not-found";
import NewResource from "@/resources/new";
@@ -67,6 +66,17 @@ function ResourceInner({ type, id }: { type: UsableResource; id: string }) {
? "settings"
: usableResourcePath(type))
}
breadcrumbs={
<PageBreadcrumbs
items={[
...(SETTINGS_RESOURCES.includes(type)
? [{ label: "Settings", to: "/settings" }]
: []),
resourceTypeCrumb(type),
{ label: resource.name },
]}
/>
}
actions={
<>
{canCreate && <NewResource type={type} copyId={id} />}
-4
View File
@@ -100,7 +100,6 @@ export default function Schedules() {
}}
columns={[
{
size: 200,
id: "Name",
accessorKey: "name",
header: ({ column }) => (
@@ -114,7 +113,6 @@ export default function Schedules() {
),
},
{
size: 200,
id: "Schedule",
accessorKey: "schedule",
header: ({ column }) => (
@@ -122,7 +120,6 @@ export default function Schedules() {
),
},
{
size: 200,
id: "NextRun",
accessorKey: "next_scheduled_run",
header: ({ column }) => (
@@ -134,7 +131,6 @@ export default function Schedules() {
: "Not Scheduled",
},
{
size: 100,
id: "Enabled",
accessorKey: "enabled",
header: ({ column }) => (
+7 -10
View File
@@ -3,7 +3,12 @@ import TagSelector from "@/components/tags/selector";
import { useInvalidate, useRead, useSetTitle, useWrite } from "@/lib/hooks";
import ResourceSelector from "@/resources/selector";
import { ICONS } from "@/lib/icons";
import { DataTable, fmtDateWithMinutes, SortableHeader } from "mogh_ui";
import {
DataTable,
DataTableFeatures,
fmtDateWithMinutes,
SortableHeader,
} from "mogh_ui";
import { Badge, Group, Switch, TextInput, useMatches } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { ColumnDef } from "@tanstack/react-table";
@@ -29,13 +34,12 @@ export default function SettingsOnboardingKeys() {
xl: "md",
});
const columns: (
| ColumnDef<Types.OnboardingKey, unknown>
| ColumnDef<DataTableFeatures, Types.OnboardingKey, unknown>
| false
| undefined
)[] = useMemo(
() => [
{
size: 150,
accessorKey: "name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
@@ -54,7 +58,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 150,
accessorKey: "copy_server",
header: "Template",
cell: ({ row }) => (
@@ -69,7 +72,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 200,
accessorKey: "tags",
header: "Tags",
cell: ({ row }) => {
@@ -116,7 +118,6 @@ export default function SettingsOnboardingKeys() {
},
},
{
size: 100,
accessorKey: "privileged",
header: ({ column }) => (
<SortableHeader
@@ -138,7 +139,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 100,
accessorKey: "create_builder",
header: ({ column }) => (
<SortableHeader column={column} title="Create Builder" />
@@ -156,7 +156,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 100,
accessorKey: "enabled",
header: ({ column }) => (
<SortableHeader column={column} title="Enabled" />
@@ -174,7 +173,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 150,
accessorKey: "expires",
header: ({ column }) => (
<SortableHeader column={column} title="Expires" />
@@ -196,7 +194,6 @@ export default function SettingsOnboardingKeys() {
),
},
{
size: 100,
accessorKey: "public_key",
header: "Delete",
cell: ({ row }) => (
@@ -83,7 +83,6 @@ function Providers({ type }: { type: "GitProvider" | "ImageRegistry" }) {
columns={[
{
accessorKey: "domain",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Domain" />
),
@@ -141,7 +140,6 @@ function Providers({ type }: { type: "GitProvider" | "ImageRegistry" }) {
},
{
accessorKey: "username",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Username" />
),
@@ -180,7 +178,6 @@ function Providers({ type }: { type: "GitProvider" | "ImageRegistry" }) {
},
{
accessorKey: "token",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Token" />
),
@@ -220,7 +217,6 @@ function Providers({ type }: { type: "GitProvider" | "ImageRegistry" }) {
},
{
header: "Delete",
maxSize: 200,
cell: ({ row }) => (
<DeleteProviderAccount
type={type}
+10 -14
View File
@@ -1,6 +1,5 @@
import NewTag from "@/components/tags/new";
import Tag from "@/components/tags/tag";
import UserAvatar from "@/components/user-avatar";
import { useRead, useSetTitle, useUser } from "@/lib/hooks";
import { DataTable } from "mogh_ui";
import { Group, Stack } from "@mantine/core";
@@ -30,7 +29,6 @@ export default function SettingsTags() {
columns={[
{
header: "Name",
size: 200,
accessorKey: "name",
cell: ({ row }) => {
return <Tag tag={row.original} size="md" fz="md" />;
@@ -38,7 +36,6 @@ export default function SettingsTags() {
},
{
header: "Color",
size: 200,
cell: ({ row }) => (
<TagColorSelector
tagId={row.original._id?.$oid!}
@@ -47,19 +44,18 @@ export default function SettingsTags() {
/>
),
},
{
header: "Owner",
size: 200,
cell: ({ row }) =>
row.original.owner ? (
<UserAvatar userId={row.original.owner} fz="md" />
) : (
"Unknown"
),
},
// {
// header: "Owner",
// size: 200,
// cell: ({ row }) =>
// row.original.owner ? (
// <UserAvatar userId={row.original.owner} fz="md" />
// ) : (
// "Unknown"
// ),
// },
{
header: "Delete",
size: 200,
cell: ({ row }) => (
<DeleteTag
tagId={row.original._id!.$oid}
@@ -67,7 +67,6 @@ export default function SettingsVariables() {
columns={[
{
accessorKey: "name",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
@@ -79,7 +78,6 @@ export default function SettingsVariables() {
},
{
accessorKey: "value",
size: 300,
header: ({ column }) => (
<SortableHeader column={column} title="Value" />
),
@@ -117,7 +115,6 @@ export default function SettingsVariables() {
},
{
accessorKey: "description",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Description" />
),
@@ -153,7 +150,6 @@ export default function SettingsVariables() {
},
{
header: "Secret",
size: 100,
cell: ({ row }) => (
<Switch
checked={row.original.is_secret}
@@ -169,7 +165,6 @@ export default function SettingsVariables() {
},
{
header: "Delete",
size: 200,
cell: ({ row }) => (
<DeleteVariable name={row.original.name} disabled={disabled} />
),
+1 -1
View File
@@ -492,7 +492,7 @@ function MultiServerStatChart({
fontSize: 12,
}}
labelStyle={{ color: "var(--mantine-color-dimmed)" }}
labelFormatter={(label) => new Date(label).toLocaleString()}
labelFormatter={(label) => new Date(Number(label)).toLocaleString()}
// List the busiest server first.
itemSorter={(item) => -Number(item.value)}
formatter={(value, name) => {
-2
View File
@@ -123,7 +123,6 @@ export default function Terminals() {
),
},
{
size: 200,
id: "Target",
accessorKey: "target",
header: ({ column }) => (
@@ -146,7 +145,6 @@ export default function Terminals() {
),
},
{
size: 100,
id: "Size",
accessorKey: "stored_size_kb",
header: ({ column }) => (
+19 -6
View File
@@ -1,11 +1,14 @@
import UserTable from "@/components/user/table";
import { useInvalidate, useRead, useUser, useWrite } from "@/lib/hooks";
import { ICONS } from "@/lib/icons";
import { DividedChildren } from "mogh_ui";
import { EntityHeader } from "mogh_ui";
import { EntityPage } from "mogh_ui";
import { PageGuard } from "mogh_ui";
import { Section } from "mogh_ui";
import {
DividedChildren,
EntityHeader,
EntityPage,
PageBreadcrumbs,
PageGuard,
Section,
} from "mogh_ui";
import { Group, Stack, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { useParams } from "react-router-dom";
@@ -66,7 +69,17 @@ export default function UserGroup() {
: undefined
}
>
<EntityPage backTo="/settings">
<EntityPage
backTo="/settings"
breadcrumbs={
<PageBreadcrumbs
items={[
{ label: "Settings", to: "/settings" },
{ label: group?.name },
]}
/>
}
>
<Stack gap="md" pb="md" className="bordered-light" bdrs="md">
<EntityHeader
name={group?.name}
+20 -12
View File
@@ -1,9 +1,12 @@
import { useInvalidate, useRead, useUser, useWrite } from "@/lib/hooks";
import { ConfirmButton } from "mogh_ui";
import { EntityPage } from "mogh_ui";
import { LabelledSwitch } from "mogh_ui";
import { PageGuard } from "mogh_ui";
import { Section } from "mogh_ui";
import {
ConfirmButton,
EntityPage,
LabelledSwitch,
PageGuard,
PageBreadcrumbs,
Section,
} from "mogh_ui";
import { Group, Stack } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { UserCheck, UserMinus } from "lucide-react";
@@ -48,17 +51,22 @@ export default function User() {
: undefined
}
>
<EntityPage backTo="/settings">
<EntityPage
backTo="/settings"
breadcrumbs={
<PageBreadcrumbs
items={[
{ label: "Settings", to: "/settings" },
{ label: user?.username },
]}
/>
}
>
<UserHeader user={user} />
<Stack mt="lg" gap="xl">
{user?._id?.$oid !== adminUser?._id?.$oid &&
(!user?.admin || (!user.super_admin && adminUser?.super_admin)) && (
<Section
title="User Permissions"
titleFz="h3"
withBorder
>
<Section title="User Permissions" titleFz="h3" withBorder>
<Group>
<ConfirmButton
icon={
+1 -1
View File
@@ -61,7 +61,7 @@ export default function ActionTable({
header: ({ column }) => (
<SortableHeader column={column} title="Next Run" />
),
sortingFn: (a, b) => {
sortFn: (a, b) => {
const sa = a.original.info.next_scheduled_run;
const sb = b.original.info.next_scheduled_run;
-4
View File
@@ -52,7 +52,6 @@ export default function BuildTable({
id: "Name",
accessorKey: "name",
cell: ({ row }) => <ResourceLink type="Build" id={row.original.id} />,
size: 200,
},
{
header: ({ column }) => (
@@ -61,12 +60,10 @@ export default function BuildTable({
id: "Source",
accessorKey: "info.repo",
cell: ({ row }) => <FileSource info={row.original.info} />,
size: 200,
},
{
header: "Version",
accessorFn: ({ info }) => fmtVersion(info.version),
size: 120,
},
{
id: "State",
@@ -75,7 +72,6 @@ export default function BuildTable({
<SortableHeader column={column} title="State" />
),
cell: ({ row }) => <BuildComponents.State id={row.original.id} />,
size: 120,
},
{
header: "Tags",
+1 -1
View File
@@ -10,7 +10,7 @@ export default function BuilderInstanceType({ id }: { id: string }) {
return (
<DividedChildren wrap="nowrap" gap="xs">
{info.instance_type?.split(",").map((id) => (
<ResourceLink type="Server" id={id.trim()} />
<ResourceLink key={id.trim()} type="Server" id={id.trim()} />
))}
</DividedChildren>
);
+1 -5
View File
@@ -57,7 +57,6 @@ export default function DeploymentTable({
<DeploymentUpdateAvailable id={row.original.id} small />
</Group>
),
size: 200,
},
{
id: "Image",
@@ -72,7 +71,6 @@ export default function DeploymentTable({
},
},
}) => <Image buildId={build_id} image={image} />,
size: 200,
},
{
header: ({ column }) => (
@@ -80,7 +78,7 @@ export default function DeploymentTable({
),
id: "Host",
accessorKey: "info.server_id",
sortingFn: (a, b) => {
sortFn: (a, b) => {
const name_a = a.original.info.swarm_id
? a.original.info.swarm_name
: a.original.info.server_name;
@@ -102,7 +100,6 @@ export default function DeploymentTable({
) : (
<ResourceLink type="Server" id={row.original.info.server_id} />
),
size: 200,
},
{
id: "State",
@@ -113,7 +110,6 @@ export default function DeploymentTable({
cell: ({ row }) => (
<DeploymentComponents.State id={row.original.id} />
),
size: 120,
},
{
header: "Tags",
@@ -131,7 +131,6 @@ export default function ProcedureStage({
columns={[
{
header: "Execution",
size: 250,
cell: ({ row: { original, index } }) => (
<ProcedureExecutionSelector
disabled={disabled}
@@ -160,7 +159,6 @@ export default function ProcedureStage({
},
{
header: "Target",
size: 250,
cell: ({
row: {
original: {
@@ -195,7 +193,6 @@ export default function ProcedureStage({
},
{
header: "Add / Remove",
size: 150,
cell: ({ row: { index } }) => (
<Group>
<ActionIcon
@@ -232,7 +229,6 @@ export default function ProcedureStage({
},
{
header: "Enabled",
size: 100,
cell: ({
row: {
original: { enabled },
+1 -1
View File
@@ -67,7 +67,7 @@ export default function ProcedureTable({
header: ({ column }) => (
<SortableHeader column={column} title="Next Run" />
),
sortingFn: (a, b) => {
sortFn: (a, b) => {
const sa = a.original.info.next_scheduled_run;
const sb = b.original.info.next_scheduled_run;
-4
View File
@@ -51,7 +51,6 @@ export default function RepoTable({
id: "Name",
accessorKey: "name",
cell: ({ row }) => <ResourceLink type="Repo" id={row.original.id} />,
size: 200,
},
{
header: ({ column }) => (
@@ -65,7 +64,6 @@ export default function RepoTable({
link={row.original.info.repo_link}
/>
),
size: 200,
},
{
header: ({ column }) => (
@@ -73,7 +71,6 @@ export default function RepoTable({
),
id: "Branch",
accessorKey: "info.branch",
size: 200,
},
{
header: ({ column }) => (
@@ -82,7 +79,6 @@ export default function RepoTable({
id: "State",
accessorKey: "info.state",
cell: ({ row }) => <RepoComponents.State id={row.original.id} />,
size: 120,
},
{
header: "Tags",
+7 -1
View File
@@ -123,6 +123,7 @@ export default function ResourceSelector({
<Group gap="xs" ml="sm" wrap="nowrap">
{clearable && (
<ActionIcon
component="div"
size="sm"
variant="filled"
color="red"
@@ -130,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",
@@ -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" />
),
+1 -1
View File
@@ -335,7 +335,7 @@ function StatChart({
fontSize: 12,
}}
labelStyle={{ color: "var(--mantine-color-dimmed)" }}
labelFormatter={(label) => new Date(label).toLocaleString()}
labelFormatter={(label) => new Date(Number(label)).toLocaleString()}
// Memory tooltip lists top-of-stack first, matching the chart.
itemSorter={
isMemory
+7 -3
View File
@@ -1,4 +1,4 @@
import { ReactNode } from "react";
import { lazy, ReactNode, Suspense } from "react";
import { usePermissions, useRead } from "@/lib/hooks";
import { Types } from "komodo_client";
import { Section } from "mogh_ui";
@@ -6,10 +6,12 @@ import ServerProcesses from "./processes";
import ServerContainerStats from "./containers";
import ServerDisks from "./disks";
import ServerCurrentStats from "./current";
import ServerHistoricalStats from "./historical";
import ServerSystemInfo from "./system-info";
import { useIsServerAvailable } from "../hooks";
// Loaded lazily to keep recharts out of the entry chunk.
const ServerHistoricalStats = lazy(() => import("./historical"));
export default function ServerStats({
id,
titleOther,
@@ -35,7 +37,9 @@ export default function ServerStats({
<ServerCurrentStats id={id} stats={stats} />
<ServerHistoricalStats id={id} />
<Suspense fallback={null}>
<ServerHistoricalStats id={id} />
</Suspense>
<ServerContainerStats id={id} />
+1 -6
View File
@@ -53,7 +53,6 @@ export default function StandardServerTable({
}}
columns={[
{
size: 250,
id: "Name",
accessorKey: "name",
header: ({ column }) => (
@@ -64,12 +63,11 @@ export default function StandardServerTable({
),
},
{
size: 100,
accessorKey: "id",
// The resource count is computed on the client,
// it cannot be sorted server side.
enableSorting: !onServerSort,
sortingFn: (a, b) => {
sortFn: (a, b) => {
const sa = resourcesCount(a.original.id);
const sb = resourcesCount(b.original.id);
@@ -89,7 +87,6 @@ export default function StandardServerTable({
},
},
{
size: 200,
id: "Region",
accessorKey: "info.region",
header: ({ column }) => (
@@ -97,7 +94,6 @@ export default function StandardServerTable({
),
},
{
size: 150,
id: "Version",
accessorKey: "info.version",
header: ({ column }) => (
@@ -106,7 +102,6 @@ export default function StandardServerTable({
// cell: ({ row }) => <ServerVersion id={row.original.id} />,
},
{
size: 150,
id: "State",
accessorKey: "info.state",
header: ({ column }) => (
-10
View File
@@ -55,7 +55,6 @@ function StackServicesSwarm({
columns={[
{
accessorKey: "service",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Service" />
),
@@ -65,7 +64,6 @@ function StackServicesSwarm({
},
{
accessorKey: "swarm_service.State",
size: 160,
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
@@ -87,14 +85,12 @@ function StackServicesSwarm({
},
{
accessorKey: "swarm_service.Runtime",
size: 300,
header: ({ column }) => (
<SortableHeader column={column} title="Runtime" />
),
},
{
accessorKey: "swarm_service.Image",
size: 300,
header: ({ column }) => (
<SortableHeader column={column} title="Image" />
),
@@ -105,7 +101,6 @@ function StackServicesSwarm({
},
{
accessorKey: "swarm_service.Replicas",
size: 300,
header: ({ column }) => (
<SortableHeader column={column} title="Replicas" />
),
@@ -132,7 +127,6 @@ function StackServicesServer({
columns={[
{
accessorKey: "service",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Service" />
),
@@ -142,7 +136,6 @@ function StackServicesServer({
},
{
accessorKey: "container.state",
size: 160,
header: ({ column }) => (
<SortableHeader column={column} title="State" />
),
@@ -164,7 +157,6 @@ function StackServicesServer({
},
{
accessorKey: "container.image",
size: 300,
header: ({ column }) => (
<SortableHeader column={column} title="Image" />
),
@@ -181,7 +173,6 @@ function StackServicesServer({
},
{
accessorKey: "container.networks.0",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Networks" />
),
@@ -215,7 +206,6 @@ function StackServicesServer({
},
{
accessorKey: "container.ports.0",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Ports" />
),
+1 -5
View File
@@ -59,7 +59,6 @@ export default function StackTable({
</Group>
);
},
size: 200,
},
{
header: ({ column }) => (
@@ -68,7 +67,6 @@ export default function StackTable({
id: "Source",
accessorKey: "info.repo",
cell: ({ row }) => <FileSource info={row.original.info} />,
size: 200,
},
{
header: ({ column }) => (
@@ -76,7 +74,7 @@ export default function StackTable({
),
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;
@@ -98,7 +96,6 @@ export default function StackTable({
) : (
<ResourceLink type="Server" id={row.original.info.server_id} />
),
size: 200,
},
{
id: "State",
@@ -107,7 +104,6 @@ export default function StackTable({
<SortableHeader column={column} title="State" />
),
cell: ({ row }) => <StackComponents.State id={row.original.id} />,
size: 120,
},
{
header: "Tags",
+18 -2
View File
@@ -1,16 +1,17 @@
import { UsableResource } from ".";
import { ResourceComponents, UsableResource } from ".";
import {
EntityHeader,
EntityHeaderProps,
EntityPage,
EntityPageProps,
PageBreadcrumbs,
} from "mogh_ui";
import { ReactNode } from "react";
import { Group, Stack, Text } from "@mantine/core";
import { DividedChildren } from "mogh_ui";
import ResourceLink from "./link";
import ResourceDescription from "./description";
import { usableResourcePath } from "@/lib/utils";
import { resourceTypeCrumb, usableResourcePath } from "@/lib/utils";
import ResourceUpdates from "@/components/updates/resource";
import { usePermissions } from "@/lib/hooks";
import { Section } from "mogh_ui";
@@ -37,6 +38,7 @@ export default function ResourceSubPage({
...headerProps
}: ResourceSubPageProps) {
const { canExecute } = usePermissions({ type: parentType, id: parentId });
const parent = ResourceComponents[parentType].useListItem(parentId);
const Header = (
<Stack justify="space-between">
<Stack gap="md" pb="md" className="bordered-light" bdrs="md">
@@ -56,6 +58,20 @@ export default function ResourceSubPage({
backTo={
pageProps?.backTo ?? `/${usableResourcePath(parentType)}/${parentId}`
}
breadcrumbs={
pageProps?.breadcrumbs ?? (
<PageBreadcrumbs
items={[
resourceTypeCrumb(parentType),
{
label: parent?.name ?? "Unknown",
to: `/${usableResourcePath(parentType)}/${parentId}`,
},
{ label: headerProps.name },
]}
/>
)
}
>
<Stack hiddenFrom="lg" w="100%">
{Header}
@@ -67,7 +67,6 @@ export default function SwarmNodes({
name={row.original.Hostname}
/>
),
size: 200,
},
{
accessorKey: "ID",
@@ -75,7 +74,6 @@ export default function SwarmNodes({
<SortableHeader column={column} title="ID" />
),
cell: ({ row }) => row.original.ID ?? "Unknown",
size: 200,
},
{
accessorKey: "Role",
@@ -149,7 +147,6 @@ export default function SwarmNodes({
row.original.UpdatedAt
? new Date(row.original.UpdatedAt).toLocaleString()
: "Unknown",
size: 200,
},
// {
// accessorKey: "CreatedAt",
@@ -61,7 +61,6 @@ export default function SwarmSecrets({
}
/>
),
size: 200,
},
{
accessorKey: "ID",
@@ -69,7 +68,6 @@ export default function SwarmSecrets({
<SortableHeader column={column} title="ID" />
),
cell: ({ row }) => row.original.ID ?? "Unknown",
size: 200,
},
{
accessorKey: "Driver",
@@ -100,7 +98,6 @@ export default function SwarmSecrets({
row.original.UpdatedAt
? new Date(row.original.UpdatedAt).toLocaleString()
: "Unknown",
size: 200,
},
{
accessorKey: "CreatedAt",
@@ -111,7 +108,6 @@ export default function SwarmSecrets({
row.original.CreatedAt
? new Date(row.original.CreatedAt).toLocaleString()
: "Unknown",
size: 200,
},
]}
/>
-2
View File
@@ -50,7 +50,6 @@ export default function SwarmTable({
id: "Name",
accessorKey: "name",
cell: ({ row }) => <ResourceLink type="Swarm" id={row.original.id} />,
size: 200,
},
{
header: ({ column }) => (
@@ -59,7 +58,6 @@ export default function SwarmTable({
id: "State",
accessorKey: "info.state",
cell: ({ row }) => <SwarmComponents.State id={row.original.id} />,
size: 120,
},
{
header: "Tags",
-4
View File
@@ -52,7 +52,6 @@ export default function ResourceSyncTable({
cell: ({ row }) => (
<ResourceLink type="ResourceSync" id={row.original.id} />
),
size: 200,
},
{
header: ({ column }) => (
@@ -61,7 +60,6 @@ export default function ResourceSyncTable({
id: "Source",
accessorKey: "info.repo",
cell: ({ row }) => <FileSource info={row.original.info} />,
size: 200,
},
{
header: ({ column }) => (
@@ -69,7 +67,6 @@ export default function ResourceSyncTable({
),
id: "Branch",
accessorKey: "info.branch",
size: 200,
},
{
header: ({ column }) => (
@@ -80,7 +77,6 @@ export default function ResourceSyncTable({
cell: ({ row }) => (
<ResourceSyncComponents.State id={row.original.id} />
),
size: 120,
},
{
header: "Tags",
+47 -2
View File
@@ -13,7 +13,7 @@ export default defineConfig({
},
resolve: {
alias: [
{ find: "@", replacement: path.resolve(__dirname, "./src") },
{ find: "@", replacement: path.resolve(import.meta.dirname, "./src") },
// monaco-editor >= 0.53 has an exports map ("./*.js": "./esm/vs/*.js"),
// so legacy deep imports like "monaco-editor/esm/vs/..." no longer
// resolve. monaco-worker-manager (used by monaco-yaml's worker) still
@@ -40,7 +40,52 @@ export default defineConfig({
"react-router-dom",
],
},
optimizeDeps: { exclude: ["mogh_ui"] },
optimizeDeps: {
exclude: ["mogh_ui"],
// path-browserify is a CJS dep of monaco-yaml's yaml.worker. Vite's dep
// scanner doesn't traverse `?worker` graphs, so without this it gets
// served raw ("module is not defined" inside the worker). Force it
// through prebundling to get CJS -> ESM interop.
// @mantine/form and @tanstack/react-table are runtime-imported only via
// the excluded (linked) mogh_ui — app src imports of react-table are
// type-only, which the scanner erases — so they get served raw and their
// nested CJS deps (fast-deep-equal, use-sync-external-store) break with
// "does not provide an export named ...". Prebundling the packages
// inlines those CJS deps with proper interop.
include: ["path-browserify", "@mantine/form", "@tanstack/react-table"],
},
build: {
// The only chunks above the default 500 kB warning limit are
// monaco-editor's core (~2.7 MB) and prettier's typescript parser
// (~900 kB). Both are already async — mogh_ui lazy-loads the editor
// implementation, so they only download when an editor mounts — and
// neither can be split further (each is one static import graph).
// Raise the limit above them so the warning still catches an eager
// chunk regression instead of firing on every build.
chunkSizeWarningLimit: 2800,
rolldownOptions: {
output: {
// Split stable vendor code out of the main chunk so app-code
// changes don't invalidate the browser cache for react/mantine.
codeSplitting: {
groups: [
{
name: "react",
test: /node_modules[\\/](react|react-dom|scheduler|react-router)[\\/]/,
},
{
name: "mantine",
test: /node_modules[\\/](@mantine|@floating-ui|react-transition-group|react-remove-scroll)[\\/]/,
},
{
name: "tanstack",
test: /node_modules[\\/]@tanstack[\\/]/,
},
],
},
},
},
},
css: {
preprocessorOptions: {
scss: {
+448 -422
View File
File diff suppressed because it is too large Load Diff