Compare commits

..
Author SHA1 Message Date
Maxwell BeckerandGitHub 34a9f8eb9e 1.19.5 (#846)
* start 1.19.5

* deploy 1.19.5-dev-1

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

* improve image tagging customization

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

* deploy 1.19.4-dev-1

* try smaller binaries with cargo strip

* deploy 1.19.4-dev-2

* smaller binaries with cargo strip

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

* Implement enhanced error handling and messaging for resource creation

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

* Implement error handling improvements for resource copying and validation feedback

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

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

* fmt

* bump indexmap

* fix account selector showing empty when account no longer found

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

* enforce disable_non_admin_create for tags. Clean up status code responses

* update server cache concurrency controller

* deploy 1.19.4-dev-3

* Allow signing in by pressing enter (#830)

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

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

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

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

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

* Move clicking interaction to CardHeader

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

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

* Format previous change

* Add `default_show_contents` to `handleToggleShow`

---------

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

* deploy 1.19.4-dev-4

* avoid stake info ShowHideButton double toggle

* Allow multiple simultaneous Action runs for use with Args

* deploy 1.19.4-dev-5

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

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

* autofocus on login username field (#837)

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

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

* Enable user query only when JWT is present

* Enable query execution in useRead only if JWT is present

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

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

* Implement server availability checks in various components

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

* cargo fmt

* fmt

* Auth error handling with status codes (#841)

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

* Refactor error messages

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

* clean up

* clean

* fmt

* invalid user id also UNAUTHORIZED

* deploy 1.19.4-dev-6

* deploy 1.19.4-dev-7

---------

Co-authored-by: Marcel Pfennig <82059270+MP-Tool@users.noreply.github.com>
Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com>
Co-authored-by: Guten <ywzhaifei@gmail.com>
Co-authored-by: Paulo Roberto Albuquerque <paulora2405@gmail.com>
Co-authored-by: Lorenzo Farnararo <2814802+baldarn@users.noreply.github.com>
2025-09-14 12:32:06 -07:00
azrikaharandGitHub 20ac04fae5 fix navigation link to users page via omnibar (#838) 2025-09-11 10:04:45 -07:00
a65fd4dca7 1.19.3 (#792)
* start. 1.19.3

* deploy 1.19.3-dev-1

* repo state from db includes BuildRepo success

* clean up version mismatch text

* feat(containers): debounced search input and added filter by server name (#796)

* Fix cleaning Alerter resource whitelist / blacklist on resource delete re #581

* fmt

* Fix signup button not working correctly (#801)

* Improve route protection and authentication flow (#798)

* Improve route protection and authentication flow

* Cleanup

* fix: inconsistent behaviour of new resource create button (#800)

* fix monaco crashing with absolute path config files

* deploy 1.19.3-dev-2

* proofread config

* Fix #427

* deploy 1.19.3-dev-3

* poll logs use println

* Sync: Only show commit / execute when viewing pending tab

* Improve sync UX

* deploy 1.19.3-dev-4

* bold link

* remove claims about database resource usage.

* 1.19.3

---------

Co-authored-by: mbecker20 <max@mogh.tech>
Co-authored-by: Antonio Sarro <tech@antoniosarro.dev>
Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com>
2025-09-05 13:41:58 -07:00
mbecker20 0873104b5a fmt 2025-08-31 19:13:14 -07:00
9a7b6ebd51 1.19.2 (#764)
* 1.19.2-dev-0

* deploy 1.19.2-dev-1

* Add option to make run command detachable (#766)

* improve missing files log to include the missing paths

* bump mungos for urlencoding mongo creds

* Update permissioning.md - typo: "priviledges" -> "privileges" (#770)

* Add support for monaco-yaml and docker compose spec validatiaon (#772)

* deploy 1.19.2-dev-2

* on delete user, remove from all user groups

* fix Google login issues around `picture`

* unsafe_unsanitized_startup_config

* improve git provider support re #355

* should fix #468

* should fix exit code re #597

* deploy 1.19.2-dev-3

* fix container ports sorting (#776)

* missing serde default

* deploy 1.19.2-dev-4

* ensure git tokens trimmed in remote url

* Add link to Authentik support docs

* Fix incorrect commit branch when using linked repo re #634

* Better display container port ranges (#786)

* ensure build and sync also commit to correct branch. re #634

* deploy 1.19.2-dev-5

* Improve login form (#788)

* Use proper form for login, add autocomplete and names to input fields

* Do not return null if loading

* Remove unused function

* Cleanup and streamline

* improve login screen flash on reload

* first builder given same name as first server

* 1.19.2

---------

Co-authored-by: mbecker20 <max@mogh.tech>
Co-authored-by: Brian Bradley <brian.bradley.p@gmail.com>
Co-authored-by: Ravi Wolter-Krishan <rkn@gedikas.net>
Co-authored-by: Christopher Hoage <iam@chrishoage.com>
Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com>
2025-08-31 19:08:45 -07:00
mbecker20 a4153fa28b fix UI showing Redeploy when its actually None 2025-08-24 14:30:43 -07:00
e732da3b05 1.19.1 (#740)
* start 1.19.1

* deploy 1.19.1-dev-1

* Global Auto Update rustdoc

* support stack additional files

* deploy 1.19.1-dev-2

* Fe support additional file language detection

* fix tsc

* Fix: Example code blocks got interpreted as rust code, leading to compilation errors (#743)

* Enhanced Server Stats Dashboard with Performance Optimizations (#746)

* Improve the layout of server mini stats in the dashboard.

- Server stats and tags made siblings for clearer responsibilities
- Changed margin to padding
- Unreachable indicator made into an overlay of the stats

* feat: optimize dashboard server stats with lazy loading and smart server availability checks

- Add enabled prop to ServerStatsMini for conditional data fetching
- Implement server availability check (only fetch stats for Ok servers, not NotOk/Disabled)
- Prevent 500 errors by avoiding API calls to offline servers
- Increase polling interval from 10s to 15s and add 5s stale time
- Add useMemo for expensive calculations to reduce re-renders
- Add conditional overlay rendering for unreachable servers
- Only render stats when showServerStats preference is enabled

* fix: show disabled servers with overlay instead of hiding component

- Maintain consistent layout by showing disabled state overlay
- Prevent UX inconsistency where disabled servers disappeared entirely

* fix: show button height

* feat: add enhance card animations

* cleanup

* gen types

* deploy 1.19.1-dev-3

* add .ini

* deploy 1.19.1-dev-4

* simple configure action args as JSON

* server enabled actually defaults false

* SendAlert via Action / CLI

* fix clippy if let string

* deploy 1.19.1-dev-5

* improve cli ergonomics

* gen types and fix responses formatting

* Add RunStackService API implementing `docker compose run` (#732)

* Add RunStackService API implementing `docker compose run`

* Add working Procedure configuration

* Remove `km execute run` alias. Remove redundant ``#[serde(default)]` on `Option`.

* Refactor command from `String` to `Vec<String>`

* Implement proper shell escaping

* bump deps

* Update configuration.md - fix typo: "affect" -> "effect" (#747)

* clean up SendAlert doc

* deploy 1.19.1-dev-6

* env file args won't double pass env file

* deploy 1.19.1-dev-7

* Add Enter Key Support for Dialog Confirmations (#750)

* start 1.19.1

* deploy 1.19.1-dev-1

* Implement usePromptHotkeys for enhanced dialog interactions and UX

* Refactor usePromptHotkeys to enhance confirm button detection and improve UX

* Remove forceConfirmDialog prop from ActionWithDialog and related logic for cleaner implementation

* Add dialog descriptions to ConfirmUpdate and ActionWithDialog for better clarity and resolve warnings

* fix

* Restore forceConfirmDialog prop to ActionWithDialog for enhanced confirmation handling

* cleanup

* Remove conditional className logic from ConfirmButton

---------

Co-authored-by: mbecker20 <max@mogh.tech>

* Support complex file depency action resolution

* get FE compile

* deploy 1.19.1-dev-8

* implement additional file dependency configuration

* deploy 1.19.1-dev-9

* UI default file dependency None

* default additional file requires is None

* deploy 1.19.1-dev-10

* rename additional_files => config_files for clarity

* deploy 1.19.1-dev-11

* fix skip serializing if None

* deploy 1.19.1-dev-12

* stack file dependency toml parsing aliases

* fmt

* Add: Server Version Mismatch Warnings & Alert System (#748)

* start 1.19.1

* deploy 1.19.1-dev-1

* feat: implement version mismatch warnings in server UI
- Replace orange warning colors with yellow for better visibility
- Add version mismatch detection that shows warnings instead of OK status
Implement responsive "VERSION MISMATCH" badge layout
- Update server dashboard to include warning counts
- Add backend version comparison logic for GetServersSummary

* feat: add warning count to server summary and update backup documentation link

* feat: add server version mismatch alert handling and update server summary invalidation logic

* fix: correct version mismatch alert config and disabled server display

- Use send_version_mismatch_alerts instead of send_unreachable_alerts
- Show 'Unknown' instead of 'Disabled' for disabled server versions
- Remove commented VersionAlert and Alerts UI components
- Update version to 1.19.0

* cleanup

* Update TypeScript types after merge

* cleanup

* cleanup

* cleanup

* Add "ServerVersionMismatch" to alert types

* Adjust color classes for warning states and revert server update invalidation logic

---------

Co-authored-by: mbecker20 <max@mogh.tech>

* backend for build multi registry push support

* deploy 1.19.1-dev-13

* build multi registry configuration

* deploy 1.19.1-dev-14

* fix invalid tokens JSON

* DeployStackIfChanged restarts also update stack.info.deployed_contents

* update deployed services comments

* deploy 1.19.1-dev-15

* Enhance server monitoring with load average data and new server monitoring table (#761)

* add monitoring page

* initial table

* moving monitoring table to servers

* add cpu load average

* typeshare doesnt allow tuples

* fix GetHistoricalServerStats

* add loadAvg to the server monitoring table

* improve styling

* add load average chart

* multiple colors for average loads chart

* make load average chart line and non-stacked

* cleanup

* use server thresholds

* cleanup

* Change "Dependents:" to "Services:" in config file service dependency
selector

* deploy 1.19.1-dev-16

* 1.19.1

---------

Co-authored-by: mbecker20 <max@mogh.tech>
Co-authored-by: Marcel Pfennig <82059270+MP-Tool@users.noreply.github.com>
Co-authored-by: Brian Bradley <brian.bradley.p@gmail.com>
Co-authored-by: Ravi Wolter-Krishan <rkn@gedikas.net>
Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com>
2025-08-24 12:51:04 -07:00
Marcel PfennigandGitHub 75ffbd559b Fix: Correct environment variable name for container stats polling rate (#752)
* docs(config): Update environment variable name and default value for container stats polling rate

* fix(config): Update default value for container stats polling rate to 30 seconds
2025-08-21 15:01:10 -07:00
118 changed files with 4591 additions and 1271 deletions
Generated
+431 -354
View File
File diff suppressed because it is too large Load Diff
+19 -16
View File
@@ -8,13 +8,16 @@ members = [
]
[workspace.package]
version = "1.19.1-dev-14"
version = "1.19.5"
edition = "2024"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
repository = "https://github.com/moghtech/komodo"
homepage = "https://komo.do"
[profile.release]
strip = "debuginfo"
[workspace.dependencies]
# LOCAL
komodo_client = { path = "client/core/rs" }
@@ -33,7 +36,7 @@ git = { path = "lib/git" }
# MOGH
run_command = { version = "0.0.6", features = ["async_tokio"] }
serror = { version = "0.5.0", default-features = false }
serror = { version = "0.5.1", default-features = false }
slack = { version = "0.4.0", package = "slack_client_rs", default-features = false, features = ["rustls"] }
derive_default_builder = "0.1.8"
derive_empty_traits = "0.1.0"
@@ -43,7 +46,7 @@ derive_variants = "1.0.0"
mongo_indexed = "2.0.2"
resolver_api = "3.0.0"
toml_pretty = "1.2.0"
mungos = "3.2.1"
mungos = "3.2.2"
svi = "1.2.0"
# ASYNC
@@ -65,11 +68,12 @@ axum = { version = "0.8.4", features = ["ws", "json", "macros"] }
# SER/DE
ipnetwork = { version = "0.21.1", features = ["serde"] }
indexmap = { version = "2.10.0", features = ["serde"] }
indexmap = { version = "2.11.1", features = ["serde"] }
serde = { version = "1.0.219", features = ["derive"] }
strum = { version = "0.27.2", features = ["derive"] }
bson = { version = "2.15.0" } # must keep in sync with mongodb version
serde_yaml_ng = "0.10.0"
serde_json = "1.0.143"
serde_json = "1.0.145"
serde_qs = "0.15.0"
toml = "0.9.5"
@@ -80,19 +84,19 @@ thiserror = "2.0.16"
# LOGGING
opentelemetry-otlp = { version = "0.30.0", features = ["tls-roots", "reqwest-rustls"] }
opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio"] }
tracing-subscriber = { version = "0.3.19", features = ["json"] }
tracing-subscriber = { version = "0.3.20", features = ["json"] }
opentelemetry-semantic-conventions = "0.30.0"
tracing-opentelemetry = "0.31.0"
opentelemetry = "0.30.0"
tracing = "0.1.41"
# CONFIG
clap = { version = "4.5.45", features = ["derive"] }
clap = { version = "4.5.47", features = ["derive"] }
dotenvy = "0.15.7"
envy = "0.4.2"
# CRYPTO / AUTH
uuid = { version = "1.18.0", features = ["v4", "fast-rng", "serde"] }
uuid = { version = "1.18.1", features = ["v4", "fast-rng", "serde"] }
jsonwebtoken = { version = "9.3.1", default-features = false }
openidconnect = "4.0.1"
urlencoding = "2.1.3"
@@ -111,26 +115,25 @@ bollard = "0.19.2"
sysinfo = "0.37.0"
# CLOUD
aws-config = "1.8.5"
aws-sdk-ec2 = "1.160.0"
aws-credential-types = "1.2.5"
aws-config = "1.8.6"
aws-sdk-ec2 = "1.167.0"
aws-credential-types = "1.2.6"
## CRON
english-to-cron = "0.1.6"
chrono-tz = "0.10.4"
chrono = "0.4.41"
chrono = "0.4.42"
croner = "3.0.0"
# MISC
async-compression = { version = "0.4.27", features = ["tokio", "gzip"] }
async-compression = { version = "0.4.30", features = ["tokio", "gzip"] }
derive_builder = "0.20.2"
comfy-table = "7.1.4"
comfy-table = "7.2.1"
typeshare = "1.0.4"
octorust = "0.10.0"
dashmap = "6.1.0"
wildcard = "0.3.0"
colored = "3.0.0"
regex = "1.11.1"
regex = "1.11.2"
bytes = "1.10.1"
bson = "2.15.0"
shell-escape = "0.1.5"
+3 -1
View File
@@ -2,6 +2,7 @@
## for a specific architecture.
FROM rust:1.89.0-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -16,7 +17,8 @@ COPY ./bin/cli ./bin/cli
RUN \
cargo build -p komodo_core --release && \
cargo build -p komodo_periphery --release && \
cargo build -p komodo_cli --release
cargo build -p komodo_cli --release && \
cargo strip
# Copy just the binaries to scratch image
FROM scratch
+3 -1
View File
@@ -12,6 +12,7 @@ COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
RUN cargo install cargo-strip
COPY --from=planner /builder/recipe.json recipe.json
# Build JUST dependencies - cached layer
RUN cargo chef cook --release --recipe-path recipe.json
@@ -20,7 +21,8 @@ COPY . .
RUN \
cargo build --release --bin core && \
cargo build --release --bin periphery && \
cargo build --release --bin km
cargo build --release --bin km && \
cargo strip
# Copy just the binaries to scratch image
FROM scratch
+2 -1
View File
@@ -1,4 +1,5 @@
FROM rust:1.89.0-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -8,7 +9,7 @@ COPY ./client/periphery ./client/periphery
COPY ./bin/cli ./bin/cli
# Compile bin
RUN cargo build -p komodo_cli --release
RUN cargo build -p komodo_cli --release && cargo strip
# Copy binaries to distroless base
FROM gcr.io/distroless/cc
+3 -3
View File
@@ -549,20 +549,20 @@ async fn poll_update_until_complete(
} else {
format!("{}/updates/{}", cli_config().host, update.id)
};
info!("Link: '{}'", link.bold());
println!("Link: '{}'", link.bold());
let client = super::komodo_client().await?;
let timer = tokio::time::Instant::now();
let update = client.poll_update_until_complete(&update.id).await?;
if update.success {
info!(
println!(
"FINISHED in {}: {}",
format!("{:.1?}", timer.elapsed()).bold(),
"EXECUTION SUCCESSFUL".green(),
);
} else {
warn!(
eprintln!(
"FINISHED in {}: {}",
format!("{:.1?}", timer.elapsed()).bold(),
"EXECUTION FAILED".red(),
+3 -1
View File
@@ -2,6 +2,7 @@
# Build Core
FROM rust:1.89.0-bullseye AS core-builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -13,7 +14,8 @@ COPY ./bin/cli ./bin/cli
# Compile app
RUN cargo build -p komodo_core --release && \
cargo build -p komodo_cli --release
cargo build -p komodo_cli --release && \
cargo strip
# Build Frontend
FROM node:20.12-alpine AS frontend-builder
+2 -2
View File
@@ -29,12 +29,12 @@ pub async fn send_alert(
match alert.level {
SeverityLevel::Ok => {
format!(
"{level} | **{name}** ({region}) | Server version now matches core version ✅\n{link}"
"{level} | **{name}**{region} | Periphery version now matches Core version ✅\n{link}"
)
}
_ => {
format!(
"{level} | **{name}** ({region}) | Version mismatch detected ⚠️\nServer: **{server_version}** | Core: **{core_version}**\n{link}"
"{level} | **{name}**{region} | Version mismatch detected ⚠️\nPeriphery: **{server_version}** | Core: **{core_version}**\n{link}"
)
}
}
+2 -2
View File
@@ -275,12 +275,12 @@ fn standard_alert_content(alert: &Alert) -> String {
match alert.level {
SeverityLevel::Ok => {
format!(
"{level} | {name} ({region}) | Server version now matches core version ✅\n{link}"
"{level} | {name}{region} | Periphery version now matches Core version ✅\n{link}"
)
}
_ => {
format!(
"{level} | {name} ({region}) | Version mismatch detected ⚠️\nServer: {server_version} | Core: {core_version}\n{link}"
"{level} | {name}{region} | Version mismatch detected ⚠️\nPeriphery: {server_version} | Core: {core_version}\n{link}"
)
}
}
+2 -2
View File
@@ -34,12 +34,12 @@ pub async fn send_alert(
let text = match alert.level {
SeverityLevel::Ok => {
format!(
"{level} | {name} ({region}) | Server version now matches core version ✅"
"{level} | *{name}*{region} | Periphery version now matches Core version ✅"
)
}
_ => {
format!(
"{level} | {name} ({region}) | Version mismatch detected ⚠️\nServer: {server_version} | Core: {core_version}"
"{level} | *{name}*{region} | Version mismatch detected ⚠️\nPeriphery: {server_version} | Core: {core_version}"
)
}
};
+8 -3
View File
@@ -3,11 +3,12 @@ use std::{sync::OnceLock, time::Instant};
use axum::{Router, extract::Path, http::HeaderMap, routing::post};
use derive_variants::{EnumVariants, ExtractVariant};
use komodo_client::{api::auth::*, entities::user::User};
use reqwest::StatusCode;
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::Json;
use serror::{AddStatusCode, Json};
use typeshare::typeshare;
use uuid::Uuid;
@@ -152,7 +153,11 @@ impl Resolve<AuthArgs> for GetUser {
self,
AuthArgs { headers }: &AuthArgs,
) -> serror::Result<User> {
let user_id = get_user_id_from_headers(headers).await?;
Ok(get_user(&user_id).await?)
let user_id = get_user_id_from_headers(headers)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
get_user(&user_id)
.await
.status_code(StatusCode::UNAUTHORIZED)
}
}
+5 -2
View File
@@ -92,8 +92,11 @@ impl Resolve<ExecuteArgs> for RunAction {
// This will set action state back to default when dropped.
// Will also check to ensure action not already busy before updating.
let _action_guard =
action_state.update(|state| state.running = true)?;
let _action_guard = action_state.update_custom(
|state| state.running += 1,
|state| state.running -= 1,
false,
)?;
let mut update = update.clone();
+5 -7
View File
@@ -27,7 +27,7 @@ use komodo_client::{
build::{Build, BuildConfig},
builder::{Builder, BuilderConfig},
deployment::DeploymentState,
komodo_timestamp,
komodo_timestamp, optional_string,
permission::PermissionLevel,
repo::Repo,
update::{Log, Update},
@@ -290,12 +290,10 @@ impl Resolve<ExecuteArgs> for RunBuild {
repo,
registry_tokens,
replacers: secret_replacers.into_iter().collect(),
// Push a commit hash tagged image
additional_tags: if update.commit_hash.is_empty() {
Default::default()
} else {
vec![update.commit_hash.clone()]
},
// To push a commit hash tagged image
commit_hash: optional_string(&update.commit_hash),
// Unused for now
additional_tags: Default::default(),
}) => res.context("failed at call to periphery to build"),
_ = cancel.cancelled() => {
info!("build cancelled during build, cleaning up builder");
+11 -11
View File
@@ -12,7 +12,7 @@ use komodo_client::{
deployment::{
Deployment, DeploymentImage, extract_registry_domain,
},
get_image_names, komodo_timestamp, optional_string,
komodo_timestamp, optional_string,
permission::PermissionLevel,
server::Server,
update::{Log, Update},
@@ -115,7 +115,7 @@ impl Resolve<ExecuteArgs> for Deploy {
let (version, registry_token) = match &deployment.config.image {
DeploymentImage::Build { build_id, version } => {
let build = resource::get::<Build>(build_id).await?;
let image_names = get_image_names(&build);
let image_names = build.get_image_names();
let image_name = image_names
.first()
.context("No image name could be created")
@@ -222,7 +222,7 @@ impl Resolve<ExecuteArgs> for Deploy {
}
};
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -249,7 +249,7 @@ pub async fn pull_deployment_inner(
let (image, account, token) = match deployment.config.image {
DeploymentImage::Build { build_id, version } => {
let build = resource::get::<Build>(&build_id).await?;
let image_names = get_image_names(&build);
let image_names = build.get_image_names();
let image_name = image_names
.first()
.context("No image name could be created")
@@ -343,7 +343,7 @@ pub async fn pull_deployment_inner(
Err(e) => Log::error("Pull image", format_serror(&e.into())),
};
update_cache_for_server(server).await;
update_cache_for_server(server, true).await;
anyhow::Ok(log)
}
.await;
@@ -428,7 +428,7 @@ impl Resolve<ExecuteArgs> for StartDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -477,7 +477,7 @@ impl Resolve<ExecuteArgs> for RestartDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -524,7 +524,7 @@ impl Resolve<ExecuteArgs> for PauseDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -573,7 +573,7 @@ impl Resolve<ExecuteArgs> for UnpauseDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -628,7 +628,7 @@ impl Resolve<ExecuteArgs> for StopDeployment {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -711,7 +711,7 @@ impl Resolve<ExecuteArgs> for DestroyDeployment {
update.logs.push(log);
update.finalize();
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update_update(update.clone()).await?;
Ok(update)
+3 -3
View File
@@ -49,7 +49,7 @@ impl Resolve<ExecuteArgs> for ClearRepoCache {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
@@ -124,7 +124,7 @@ impl Resolve<ExecuteArgs> for BackupCoreDatabase {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
@@ -173,7 +173,7 @@ impl Resolve<ExecuteArgs> for GlobalAutoUpdate {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
+21 -21
View File
@@ -66,7 +66,7 @@ impl Resolve<ExecuteArgs> for StartContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -122,7 +122,7 @@ impl Resolve<ExecuteArgs> for RestartContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -176,7 +176,7 @@ impl Resolve<ExecuteArgs> for PauseContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -232,7 +232,7 @@ impl Resolve<ExecuteArgs> for UnpauseContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -288,7 +288,7 @@ impl Resolve<ExecuteArgs> for StopContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -350,7 +350,7 @@ impl Resolve<ExecuteArgs> for DestroyContainer {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -401,7 +401,7 @@ impl Resolve<ExecuteArgs> for StartAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -453,7 +453,7 @@ impl Resolve<ExecuteArgs> for RestartAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -503,7 +503,7 @@ impl Resolve<ExecuteArgs> for PauseAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -555,7 +555,7 @@ impl Resolve<ExecuteArgs> for UnpauseAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -605,7 +605,7 @@ impl Resolve<ExecuteArgs> for StopAllContainers {
);
}
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -660,7 +660,7 @@ impl Resolve<ExecuteArgs> for PruneContainers {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -711,7 +711,7 @@ impl Resolve<ExecuteArgs> for DeleteNetwork {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -765,7 +765,7 @@ impl Resolve<ExecuteArgs> for PruneNetworks {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -813,7 +813,7 @@ impl Resolve<ExecuteArgs> for DeleteImage {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -865,7 +865,7 @@ impl Resolve<ExecuteArgs> for PruneImages {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -916,7 +916,7 @@ impl Resolve<ExecuteArgs> for DeleteVolume {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -968,7 +968,7 @@ impl Resolve<ExecuteArgs> for PruneVolumes {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -1020,7 +1020,7 @@ impl Resolve<ExecuteArgs> for PruneDockerBuilders {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -1072,7 +1072,7 @@ impl Resolve<ExecuteArgs> for PruneBuildx {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -1123,7 +1123,7 @@ impl Resolve<ExecuteArgs> for PruneSystem {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
+93 -22
View File
@@ -1,7 +1,9 @@
use std::collections::HashSet;
use std::{collections::HashSet, str::FromStr};
use anyhow::Context;
use database::mungos::mongodb::bson::{doc, to_document};
use database::mungos::mongodb::bson::{
doc, oid::ObjectId, to_bson, to_document,
};
use formatting::format_serror;
use interpolate::Interpolator;
use komodo_client::{
@@ -258,7 +260,7 @@ impl Resolve<ExecuteArgs> for DeployStack {
}
// Ensure cached stack state up to date by updating server cache
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -364,23 +366,21 @@ impl Resolve<ExecuteArgs> for DeployStackIfChanged {
// host before restart.
maybe_pull_stack(&stack, Some(&mut update)).await?;
// The existing update is initialized to DeployStack,
// but also has not been created on database.
// Setup a new update here.
let req = ExecuteRequest::RestartStack(RestartStack {
stack: stack.name,
services: Vec::new(),
});
let update = init_execution_update(&req, user).await?;
let ExecuteRequest::RestartStack(req) = req else {
unreachable!()
};
req
.resolve(&ExecuteArgs {
user: user.clone(),
update,
})
.await
let mut update =
restart_services(stack.name, Vec::new(), user).await?;
if update.success {
// Need to update 'info.deployed_contents' with the
// latest contents so next check doesn't read the same diff.
update_deployed_contents_with_latest(
&stack.id,
stack.info.remote_contents,
&mut update,
)
.await;
}
Ok(update)
}
DeployIfChangedAction::Services { deploy, restart } => {
match (deploy.is_empty(), restart.is_empty()) {
@@ -401,7 +401,22 @@ impl Resolve<ExecuteArgs> for DeployStackIfChanged {
// PullStack in order to ensure latest repo contents on the
// host before restart. Only necessary if no "deploys" (deploy already pulls stack).
maybe_pull_stack(&stack, Some(&mut update)).await?;
restart_services(stack.name, restart, user).await
let mut update =
restart_services(stack.name, restart, user).await?;
if update.success {
// Need to update 'info.deployed_contents' with the
// latest contents so next check doesn't read the same diff.
update_deployed_contents_with_latest(
&stack.id,
stack.info.remote_contents,
&mut update,
)
.await;
}
Ok(update)
}
// Only deploy
(false, true) => {
@@ -413,6 +428,8 @@ impl Resolve<ExecuteArgs> for DeployStackIfChanged {
"Execute Deploys",
format!("Deploying: {}", deploy.join(", "),),
);
// This already updates 'stack.info.deployed_services',
// restart doesn't require this again.
let deploy_update =
deploy_services(stack.name.clone(), deploy, user)
.await?;
@@ -496,6 +513,59 @@ async fn restart_services(
.await
}
/// This can safely be called in [DeployStackIfChanged]
/// when there are ONLY changes to config files requiring restart,
/// AFTER the restart has been successfully completed.
///
/// In the case the if changed action is not FullDeploy,
/// the only file diff possible is to config files.
/// Also note either full or service deploy will already update 'deployed_contents'
/// making this method unnecessary in those cases.
///
/// Changes to config files after restart is applied should
/// be taken as the deployed contents, otherwise next changed check
/// will restart service again for no reason.
async fn update_deployed_contents_with_latest(
id: &str,
contents: Option<Vec<StackRemoteFileContents>>,
update: &mut Update,
) {
let Some(contents) = contents else {
return;
};
let contents = contents
.into_iter()
.map(|f| FileContents {
path: f.path,
contents: f.contents,
})
.collect::<Vec<_>>();
if let Err(e) = (async {
let contents = to_bson(&contents)
.context("Failed to serialize contents to bson")?;
let id =
ObjectId::from_str(id).context("Id is not valid ObjectId")?;
db_client()
.stacks
.update_one(
doc! { "_id": id },
doc! { "$set": { "info.deployed_contents": contents } },
)
.await
.context("Failed to update stack 'deployed_contents'")?;
anyhow::Ok(())
})
.await
{
update.push_error_log(
"Update content cache",
format_serror(&e.into()),
);
update.finalize();
let _ = update_update(update.clone()).await;
}
}
enum DeployIfChangedAction {
/// Changes to any compose or env files
/// always lead to this.
@@ -691,7 +761,7 @@ pub async fn pull_stack_inner(
.await?;
// Ensure cached stack state up to date by updating server cache
update_cache_for_server(server).await;
update_cache_for_server(server, true).await;
Ok(res)
}
@@ -963,6 +1033,7 @@ impl Resolve<ExecuteArgs> for RunStackService {
command: self.command,
no_tty: self.no_tty,
no_deps: self.no_deps,
detach: self.detach,
service_ports: self.service_ports,
env: self.env,
workdir: self.workdir,
+2 -4
View File
@@ -77,10 +77,8 @@ impl Resolve<ExecuteArgs> for RunSync {
};
// get the action state for the sync (or insert default).
let action_state = action_states()
.resource_sync
.get_or_insert_default(&sync.id)
.await;
let action_state =
action_states().sync.get_or_insert_default(&sync.id).await;
// This will set action state back to default when dropped.
// Will also check to ensure sync not already busy before updating.
+2 -2
View File
@@ -131,8 +131,8 @@ impl Resolve<ReadArgs> for GetActionsSummary {
.unwrap_or_default()
.get()?,
) {
(_, action_states) if action_states.running => {
res.running += 1;
(_, action_states) if action_states.running > 0 => {
res.running += action_states.running;
}
(ActionState::Ok, _) => res.ok += 1,
(ActionState::Failed, _) => res.failed += 1,
+2 -2
View File
@@ -93,7 +93,7 @@ impl Resolve<ReadArgs> for GetResourceSyncActionState {
)
.await?;
let action_state = action_states()
.resource_sync
.sync
.get(&sync.id)
.await
.unwrap_or_default()
@@ -138,7 +138,7 @@ impl Resolve<ReadArgs> for GetResourceSyncsSummary {
continue;
}
if action_states
.resource_sync
.sync
.get(&resource_sync.id)
.await
.unwrap_or_default()
+2 -8
View File
@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateAction {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Action> {
Ok(
resource::create::<Action>(&self.name, self.config, user)
.await?,
)
resource::create::<Action>(&self.name, self.config, user).await
}
}
@@ -35,10 +32,7 @@ impl Resolve<WriteArgs> for CopyAction {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Action>(&self.name, config.into(), user)
.await?,
)
resource::create::<Action>(&self.name, config.into(), user).await
}
}
+2 -8
View File
@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateAlerter {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Alerter> {
Ok(
resource::create::<Alerter>(&self.name, self.config, user)
.await?,
)
resource::create::<Alerter>(&self.name, self.config, user).await
}
}
@@ -35,10 +32,7 @@ impl Resolve<WriteArgs> for CopyAlerter {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Alerter>(&self.name, config.into(), user)
.await?,
)
resource::create::<Alerter>(&self.name, config.into(), user).await
}
}
+13 -17
View File
@@ -50,10 +50,7 @@ impl Resolve<WriteArgs> for CreateBuild {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Build> {
Ok(
resource::create::<Build>(&self.name, self.config, user)
.await?,
)
resource::create::<Build>(&self.name, self.config, user).await
}
}
@@ -71,10 +68,7 @@ impl Resolve<WriteArgs> for CopyBuild {
.await?;
// reset version to 0.0.0
config.version = Default::default();
Ok(
resource::create::<Build>(&self.name, config.into(), user)
.await?,
)
resource::create::<Build>(&self.name, config.into(), user).await
}
}
@@ -186,7 +180,7 @@ async fn write_dockerfile_contents_git(
) -> serror::Result<Update> {
let WriteBuildFileContents { build: _, contents } = req;
let mut clone_args: RepoExecutionArgs = if !build
let mut repo_args: RepoExecutionArgs = if !build
.config
.files_on_host
&& !build.config.linked_repo.is_empty()
@@ -196,8 +190,8 @@ async fn write_dockerfile_contents_git(
} else {
(&build).into()
};
let root = clone_args.unique_path(&core_config().repo_directory)?;
clone_args.destination = Some(root.display().to_string());
let root = repo_args.unique_path(&core_config().repo_directory)?;
repo_args.destination = Some(root.display().to_string());
let build_path = build
.config
@@ -220,11 +214,11 @@ async fn write_dockerfile_contents_git(
})?;
}
let access_token = if let Some(account) = &clone_args.account {
git_token(&clone_args.provider, account, |https| clone_args.https = https)
let access_token = if let Some(account) = &repo_args.account {
git_token(&repo_args.provider, account, |https| repo_args.https = https)
.await
.with_context(
|| format!("Failed to get git token in call to db. Stopping run. | {} | {account}", clone_args.provider),
|| format!("Failed to get git token in call to db. Stopping run. | {} | {account}", repo_args.provider),
)?
} else {
None
@@ -235,7 +229,7 @@ async fn write_dockerfile_contents_git(
if !root.join(".git").exists() {
git::init_folder_as_repo(
&root,
&clone_args,
&repo_args,
access_token.as_deref(),
&mut update.logs,
)
@@ -249,9 +243,11 @@ async fn write_dockerfile_contents_git(
}
}
// Save this for later -- repo_args moved next.
let branch = repo_args.branch.clone();
// Pull latest changes to repo to ensure linear commit history
match git::pull_or_clone(
clone_args,
repo_args,
&core_config().repo_directory,
access_token,
)
@@ -298,7 +294,7 @@ async fn write_dockerfile_contents_git(
&format!("{}: Commit Dockerfile", args.user.username),
&root,
&build_path.join(&dockerfile_path),
&build.config.branch,
&branch,
)
.await;
+2 -8
View File
@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateBuilder {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Builder> {
Ok(
resource::create::<Builder>(&self.name, self.config, user)
.await?,
)
resource::create::<Builder>(&self.name, self.config, user).await
}
}
@@ -35,10 +32,7 @@ impl Resolve<WriteArgs> for CopyBuilder {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Builder>(&self.name, config.into(), user)
.await?,
)
resource::create::<Builder>(&self.name, config.into(), user).await
}
}
+5 -12
View File
@@ -38,10 +38,8 @@ impl Resolve<WriteArgs> for CreateDeployment {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Deployment> {
Ok(
resource::create::<Deployment>(&self.name, self.config, user)
.await?,
)
resource::create::<Deployment>(&self.name, self.config, user)
.await
}
}
@@ -58,10 +56,8 @@ impl Resolve<WriteArgs> for CopyDeployment {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Deployment>(&self.name, config.into(), user)
.await?,
)
resource::create::<Deployment>(&self.name, config.into(), user)
.await
}
}
@@ -153,10 +149,7 @@ impl Resolve<WriteArgs> for CreateDeploymentFromContainer {
});
}
Ok(
resource::create::<Deployment>(&self.name, config, user)
.await?,
)
resource::create::<Deployment>(&self.name, config, user).await
}
}
+3 -8
View File
@@ -16,10 +16,7 @@ impl Resolve<WriteArgs> for CreateProcedure {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<CreateProcedureResponse> {
Ok(
resource::create::<Procedure>(&self.name, self.config, user)
.await?,
)
resource::create::<Procedure>(&self.name, self.config, user).await
}
}
@@ -36,10 +33,8 @@ impl Resolve<WriteArgs> for CopyProcedure {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<Procedure>(&self.name, config.into(), user)
.await?,
)
resource::create::<Procedure>(&self.name, config.into(), user)
.await
}
}
+2 -5
View File
@@ -42,7 +42,7 @@ impl Resolve<WriteArgs> for CreateRepo {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Repo> {
Ok(resource::create::<Repo>(&self.name, self.config, user).await?)
resource::create::<Repo>(&self.name, self.config, user).await
}
}
@@ -58,10 +58,7 @@ impl Resolve<WriteArgs> for CopyRepo {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Repo>(&self.name, config.into(), user)
.await?,
)
resource::create::<Repo>(&self.name, config.into(), user).await
}
}
+3 -8
View File
@@ -30,10 +30,7 @@ impl Resolve<WriteArgs> for CreateServer {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Server> {
Ok(
resource::create::<Server>(&self.name, self.config, user)
.await?,
)
resource::create::<Server>(&self.name, self.config, user).await
}
}
@@ -49,10 +46,8 @@ impl Resolve<WriteArgs> for CopyServer {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Server>(&self.name, config.into(), user)
.await?,
)
resource::create::<Server>(&self.name, config.into(), user).await
}
}
+6 -9
View File
@@ -51,10 +51,7 @@ impl Resolve<WriteArgs> for CreateStack {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Stack> {
Ok(
resource::create::<Stack>(&self.name, self.config, user)
.await?,
)
resource::create::<Stack>(&self.name, self.config, user).await
}
}
@@ -70,10 +67,8 @@ impl Resolve<WriteArgs> for CopyStack {
PermissionLevel::Read.into(),
)
.await?;
Ok(
resource::create::<Stack>(&self.name, config.into(), user)
.await?,
)
resource::create::<Stack>(&self.name, config.into(), user).await
}
}
@@ -284,6 +279,8 @@ async fn write_stack_file_contents_git(
}
}
// Save this for later -- repo_args moved next.
let branch = repo_args.branch.clone();
// Pull latest changes to repo to ensure linear commit history
match git::pull_or_clone(
repo_args,
@@ -334,7 +331,7 @@ async fn write_stack_file_contents_git(
&format!("{username}: Write Stack File"),
&root,
&file_path,
&stack.config.branch,
&branch,
)
.await;
+7 -13
View File
@@ -68,10 +68,8 @@ impl Resolve<WriteArgs> for CreateResourceSync {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<ResourceSync> {
Ok(
resource::create::<ResourceSync>(&self.name, self.config, user)
.await?,
)
resource::create::<ResourceSync>(&self.name, self.config, user)
.await
}
}
@@ -88,14 +86,8 @@ impl Resolve<WriteArgs> for CopyResourceSync {
PermissionLevel::Write.into(),
)
.await?;
Ok(
resource::create::<ResourceSync>(
&self.name,
config.into(),
user,
)
.await?,
)
resource::create::<ResourceSync>(&self.name, config.into(), user)
.await
}
}
@@ -323,6 +315,8 @@ async fn write_sync_file_contents_git(
}
}
// Save this for later -- repo_args moved next.
let branch = repo_args.branch.clone();
// Pull latest changes to repo to ensure linear commit history
match git::pull_or_clone(
repo_args,
@@ -373,7 +367,7 @@ async fn write_sync_file_contents_git(
&format!("{}: Commit Resource File", args.user.username),
&root,
&resource_path.join(&file_path),
&sync.config.branch,
&branch,
)
.await;
+14 -1
View File
@@ -13,9 +13,12 @@ use komodo_client::{
server::Server, stack::Stack, sync::ResourceSync, tag::Tag,
},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::{
config::core_config,
helpers::query::{get_tag, get_tag_check_owner},
resource,
state::db_client,
@@ -29,8 +32,18 @@ impl Resolve<WriteArgs> for CreateTag {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Tag> {
if core_config().disable_non_admin_create && !user.admin {
return Err(
anyhow!("Non admins cannot create tags")
.status_code(StatusCode::FORBIDDEN),
);
}
if ObjectId::from_str(&self.name).is_ok() {
return Err(anyhow!("tag name cannot be ObjectId").into());
return Err(
anyhow!("Tag name cannot be ObjectId")
.status_code(StatusCode::BAD_REQUEST),
);
}
let mut tag = Tag {
+10 -2
View File
@@ -32,7 +32,7 @@ impl Resolve<WriteArgs> for CreateLocalUser {
if !admin.admin {
return Err(
anyhow!("This method is admin-only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
@@ -183,7 +183,7 @@ impl Resolve<WriteArgs> for DeleteUser {
if !admin.admin {
return Err(
anyhow!("This method is admin-only.")
.status_code(StatusCode::UNAUTHORIZED),
.status_code(StatusCode::FORBIDDEN),
);
}
if admin.username == self.user || admin.id == self.user {
@@ -220,6 +220,14 @@ impl Resolve<WriteArgs> for DeleteUser {
.delete_one(query)
.await
.context("Failed to delete user from database")?;
// Also remove user id from all user groups
if let Err(e) = db
.user_groups
.update_many(doc! {}, doc! { "$pull": { "users": &user.id } })
.await
{
warn!("Failed to remove deleted user from user groups | {e:?}");
};
Ok(user)
}
}
+30 -7
View File
@@ -10,7 +10,9 @@ use komodo_client::{
api::write::*,
entities::{komodo_timestamp, user_group::UserGroup},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::state::db_client;
@@ -23,7 +25,10 @@ impl Resolve<WriteArgs> for CreateUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let user_group = UserGroup {
name: self.name,
@@ -58,7 +63,10 @@ impl Resolve<WriteArgs> for RenameUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
update_one_by_id(
@@ -84,7 +92,10 @@ impl Resolve<WriteArgs> for DeleteUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -117,7 +128,10 @@ impl Resolve<WriteArgs> for AddUserToUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -161,7 +175,10 @@ impl Resolve<WriteArgs> for RemoveUserFromUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -205,7 +222,10 @@ impl Resolve<WriteArgs> for SetUsersInUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
@@ -252,7 +272,10 @@ impl Resolve<WriteArgs> for SetEveryoneUserGroup {
WriteArgs { user: admin }: &WriteArgs,
) -> serror::Result<UserGroup> {
if !admin.admin {
return Err(anyhow!("This call is admin-only").into());
return Err(
anyhow!("This call is admin-only")
.status_code(StatusCode::FORBIDDEN),
);
}
let db = db_client();
+32 -15
View File
@@ -4,7 +4,9 @@ use komodo_client::{
api::write::*,
entities::{Operation, ResourceTarget, variable::Variable},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::{
helpers::{
@@ -22,6 +24,13 @@ impl Resolve<WriteArgs> for CreateVariable {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<CreateVariableResponse> {
if !user.admin {
return Err(
anyhow!("Only admins can create variables")
.status_code(StatusCode::FORBIDDEN),
);
}
let CreateVariable {
name,
value,
@@ -29,10 +38,6 @@ impl Resolve<WriteArgs> for CreateVariable {
is_secret,
} = self;
if !user.admin {
return Err(anyhow!("only admins can create variables").into());
}
let variable = Variable {
name,
value,
@@ -44,7 +49,7 @@ impl Resolve<WriteArgs> for CreateVariable {
.variables
.insert_one(&variable)
.await
.context("failed to create variable on db")?;
.context("Failed to create variable on db")?;
let mut update = make_update(
ResourceTarget::system(),
@@ -69,7 +74,10 @@ impl Resolve<WriteArgs> for UpdateVariableValue {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateVariableValueResponse> {
if !user.admin {
return Err(anyhow!("only admins can update variables").into());
return Err(
anyhow!("Only admins can update variables")
.status_code(StatusCode::FORBIDDEN),
);
}
let UpdateVariableValue { name, value } = self;
@@ -87,7 +95,7 @@ impl Resolve<WriteArgs> for UpdateVariableValue {
doc! { "$set": { "value": &value } },
)
.await
.context("failed to update variable value on db")?;
.context("Failed to update variable value on db")?;
let mut update = make_update(
ResourceTarget::system(),
@@ -107,7 +115,7 @@ impl Resolve<WriteArgs> for UpdateVariableValue {
)
};
update.push_simple_log("update variable value", log);
update.push_simple_log("Update Variable Value", log);
update.finalize();
add_update(update).await?;
@@ -123,7 +131,10 @@ impl Resolve<WriteArgs> for UpdateVariableDescription {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateVariableDescriptionResponse> {
if !user.admin {
return Err(anyhow!("only admins can update variables").into());
return Err(
anyhow!("Only admins can update variables")
.status_code(StatusCode::FORBIDDEN),
);
}
db_client()
.variables
@@ -132,7 +143,7 @@ impl Resolve<WriteArgs> for UpdateVariableDescription {
doc! { "$set": { "description": &self.description } },
)
.await
.context("failed to update variable description on db")?;
.context("Failed to update variable description on db")?;
Ok(get_variable(&self.name).await?)
}
}
@@ -144,7 +155,10 @@ impl Resolve<WriteArgs> for UpdateVariableIsSecret {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<UpdateVariableIsSecretResponse> {
if !user.admin {
return Err(anyhow!("only admins can update variables").into());
return Err(
anyhow!("Only admins can update variables")
.status_code(StatusCode::FORBIDDEN),
);
}
db_client()
.variables
@@ -153,7 +167,7 @@ impl Resolve<WriteArgs> for UpdateVariableIsSecret {
doc! { "$set": { "is_secret": self.is_secret } },
)
.await
.context("failed to update variable is secret on db")?;
.context("Failed to update variable is secret on db")?;
Ok(get_variable(&self.name).await?)
}
}
@@ -164,14 +178,17 @@ impl Resolve<WriteArgs> for DeleteVariable {
WriteArgs { user }: &WriteArgs,
) -> serror::Result<DeleteVariableResponse> {
if !user.admin {
return Err(anyhow!("only admins can delete variables").into());
return Err(
anyhow!("Only admins can delete variables")
.status_code(StatusCode::FORBIDDEN),
);
}
let variable = get_variable(&self.name).await?;
db_client()
.variables
.delete_one(doc! { "name": &self.name })
.await
.context("failed to delete variable on db")?;
.context("Failed to delete variable on db")?;
let mut update = make_update(
ResourceTarget::system(),
@@ -180,7 +197,7 @@ impl Resolve<WriteArgs> for DeleteVariable {
);
update
.push_simple_log("delete variable", format!("{variable:#?}"));
.push_simple_log("Delete Variable", format!("{variable:#?}"));
update.finalize();
add_update(update).await?;
+3 -2
View File
@@ -187,8 +187,8 @@ impl GoogleOauthClient {
Ok(body)
} else {
let text = res.text().await.context(format!(
"method: POST | status: {status} | failed to get response text"
))?;
"method: POST | status: {status} | failed to get response text"
))?;
Err(anyhow!("method: POST | status: {status} | text: {text}"))
}
}
@@ -207,5 +207,6 @@ pub struct GoogleUser {
#[serde(rename = "sub")]
pub id: String,
pub email: String,
#[serde(default)]
pub picture: String,
}
+1
View File
@@ -250,6 +250,7 @@ pub fn core_config() -> &'static CoreConfig {
.unwrap_or(config.logging.opentelemetry_service_name),
},
pretty_startup_config: env.komodo_pretty_startup_config.unwrap_or(config.pretty_startup_config),
unsafe_unsanitized_startup_config: env.komodo_unsafe_unsanitized_startup_config.unwrap_or(config.unsafe_unsanitized_startup_config),
internet_interface: env.komodo_internet_interface.unwrap_or(config.internet_interface),
ssl_enabled: env.komodo_ssl_enabled.unwrap_or(config.ssl_enabled),
ssl_key_file: env.komodo_ssl_key_file.unwrap_or(config.ssl_key_file),
+28 -12
View File
@@ -16,17 +16,16 @@ use super::cache::Cache;
#[derive(Default)]
pub struct ActionStates {
pub build: Cache<String, Arc<ActionState<BuildActionState>>>,
pub server: Cache<String, Arc<ActionState<ServerActionState>>>,
pub stack: Cache<String, Arc<ActionState<StackActionState>>>,
pub deployment:
Cache<String, Arc<ActionState<DeploymentActionState>>>,
pub server: Cache<String, Arc<ActionState<ServerActionState>>>,
pub build: Cache<String, Arc<ActionState<BuildActionState>>>,
pub repo: Cache<String, Arc<ActionState<RepoActionState>>>,
pub procedure:
Cache<String, Arc<ActionState<ProcedureActionState>>>,
pub action: Cache<String, Arc<ActionState<ActionActionState>>>,
pub resource_sync:
Cache<String, Arc<ActionState<ResourceSyncActionState>>>,
pub stack: Cache<String, Arc<ActionState<StackActionState>>>,
pub sync: Cache<String, Arc<ActionState<ResourceSyncActionState>>>,
}
/// Need to be able to check "busy" with write lock acquired.
@@ -62,17 +61,33 @@ impl<States: Default + Busy + Copy + Send + 'static>
/// Returns a guard that returns the states to default (not busy) when dropped.
pub fn update(
&self,
handler: impl Fn(&mut States),
update_fn: impl Fn(&mut States),
) -> anyhow::Result<UpdateGuard<'_, States>> {
self.update_custom(
update_fn,
|states| *states = Default::default(),
true,
)
}
/// Will acquire lock, optionally check busy, and if not will
/// run the provided update function on the states.
/// Returns a guard that calls the provided return_fn when dropped.
pub fn update_custom(
&self,
update_fn: impl Fn(&mut States),
return_fn: impl Fn(&mut States) + Send + 'static,
busy_check: bool,
) -> anyhow::Result<UpdateGuard<'_, States>> {
let mut lock = self
.0
.lock()
.map_err(|e| anyhow!("action state lock poisoned | {e:?}"))?;
if lock.busy() {
return Err(anyhow!("resource is busy"));
.map_err(|e| anyhow!("Action state lock poisoned | {e:?}"))?;
if busy_check && lock.busy() {
return Err(anyhow!("Resource is busy"));
}
handler(&mut *lock);
Ok(UpdateGuard(&self.0))
update_fn(&mut *lock);
Ok(UpdateGuard(&self.0, Box::new(return_fn)))
}
}
@@ -82,6 +97,7 @@ impl<States: Default + Busy + Copy + Send + 'static>
/// user could drop UpdateGuard.
pub struct UpdateGuard<'a, States: Default + Send + 'static>(
&'a Mutex<States>,
Box<dyn Fn(&mut States) + Send>,
);
impl<States: Default + Send + 'static> Drop
@@ -95,6 +111,6 @@ impl<States: Default + Send + 'static> Drop
return;
}
};
*lock = States::default();
self.1(&mut *lock);
}
}
+27 -22
View File
@@ -13,26 +13,31 @@ use database::mungos::{
options::FindOneOptions,
},
};
use komodo_client::entities::{
Operation, ResourceTarget, ResourceTargetVariant,
action::{Action, ActionState},
alerter::Alerter,
build::Build,
builder::Builder,
deployment::{Deployment, DeploymentState},
docker::container::{ContainerListItem, ContainerStateStatusEnum},
permission::{PermissionLevel, PermissionLevelAndSpecifics},
procedure::{Procedure, ProcedureState},
repo::Repo,
server::{Server, ServerState},
stack::{Stack, StackServiceNames, StackState},
stats::SystemInformation,
sync::ResourceSync,
tag::Tag,
update::Update,
user::{User, admin_service_user},
user_group::UserGroup,
variable::Variable,
use komodo_client::{
busy::Busy,
entities::{
Operation, ResourceTarget, ResourceTargetVariant,
action::{Action, ActionState},
alerter::Alerter,
build::Build,
builder::Builder,
deployment::{Deployment, DeploymentState},
docker::container::{
ContainerListItem, ContainerStateStatusEnum,
},
permission::{PermissionLevel, PermissionLevelAndSpecifics},
procedure::{Procedure, ProcedureState},
repo::Repo,
server::{Server, ServerState},
stack::{Stack, StackServiceNames, StackState},
stats::SystemInformation,
sync::ResourceSync,
tag::Tag,
update::Update,
user::{User, admin_service_user},
user_group::UserGroup,
variable::Variable,
},
};
use periphery_client::api::stats;
use tokio::sync::Mutex;
@@ -467,7 +472,7 @@ pub async fn get_action_state(id: &String) -> ActionState {
.action
.get(id)
.await
.map(|s| s.get().map(|s| s.running))
.map(|s| s.get().map(|s| s.busy()))
.transpose()
.ok()
.flatten()
@@ -483,7 +488,7 @@ pub async fn get_procedure_state(id: &String) -> ProcedureState {
.procedure
.get(id)
.await
.map(|s| s.get().map(|s| s.running))
.map(|s| s.get().map(|s| s.busy()))
.transpose()
.ok()
.flatten()
+8 -4
View File
@@ -45,10 +45,14 @@ async fn app() -> anyhow::Result<()> {
info!("Komodo Core version: v{}", env!("CARGO_PKG_VERSION"));
if core_config().pretty_startup_config {
info!("{:#?}", config.sanitized());
} else {
info!("{:?}", config.sanitized());
match (
config.pretty_startup_config,
config.unsafe_unsanitized_startup_config,
) {
(true, true) => info!("{:#?}", config),
(true, false) => info!("{:#?}", config.sanitized()),
(false, true) => info!("{:?}", config),
(false, false) => info!("{:?}", config.sanitized()),
}
// Init jwt client to crash on failure
+39 -3
View File
@@ -1,3 +1,5 @@
use std::sync::{Arc, OnceLock};
use async_timing_util::wait_until_timelength;
use database::mungos::{find::find_collect, mongodb::bson::doc};
use futures::future::join_all;
@@ -15,10 +17,11 @@ use komodo_client::entities::{
};
use periphery_client::api::{self, git::GetLatestCommit};
use serror::Serror;
use tokio::sync::Mutex;
use crate::{
config::core_config,
helpers::periphery_client,
helpers::{cache::Cache, periphery_client},
monitor::{alert::check_alerts, record::record_server_stats},
state::{db_client, deployment_status_cache, repo_status_cache},
};
@@ -110,14 +113,47 @@ async fn refresh_server_cache(ts: i64) {
}
};
let futures = servers.into_iter().map(|server| async move {
update_cache_for_server(&server).await;
update_cache_for_server(&server, false).await;
});
join_all(futures).await;
tokio::join!(check_alerts(ts), record_server_stats(ts));
}
/// Makes sure cache for server doesn't update too frequently / simultaneously.
/// If forced, will still block against simultaneous update.
fn update_cache_for_server_controller()
-> &'static Cache<String, Arc<Mutex<i64>>> {
static CACHE: OnceLock<Cache<String, Arc<Mutex<i64>>>> =
OnceLock::new();
CACHE.get_or_init(Default::default)
}
/// The background loop will call this with force: false,
/// which exits early if the lock is busy or it was completed too recently.
/// If force is true, it will wait on simultaneous calls, and will
/// ignore the restriction on being completed too recently.
#[instrument(level = "debug")]
pub async fn update_cache_for_server(server: &Server) {
pub async fn update_cache_for_server(server: &Server, force: bool) {
// Concurrency controller to ensure it isn't done too often
// when it happens in other contexts.
let controller = update_cache_for_server_controller()
.get_or_insert_default(&server.id)
.await;
let mut lock = match controller.try_lock() {
Ok(lock) => lock,
Err(_) if force => controller.lock().await,
Err(_) => return,
};
let now = komodo_timestamp();
// early return if called again sooner than 1s.
if !force && *lock > now - 1_000 {
return;
}
*lock = now;
let (deployments, builds, repos, stacks) = tokio::join!(
find_collect(
&db_client().deployments,
+1
View File
@@ -21,6 +21,7 @@ pub async fn record_server_stats(ts: i64) {
ts,
sid: status.id.clone(),
cpu_perc: stats.cpu_perc,
load_average: stats.load_average.clone(),
mem_total_gb: stats.mem_total_gb,
mem_used_gb: stats.mem_used_gb,
disk_total_gb,
+14 -10
View File
@@ -81,7 +81,7 @@ pub async fn update_deployment_cache(
// If image already has tag, leave it,
// otherwise default the tag to latest
if image.contains(':') {
image
image.to_string()
} else {
format!("{image}:latest")
}
@@ -92,6 +92,9 @@ pub async fn update_deployment_cache(
..
}) = &container
{
// Docker will automatically strip `docker.io` from incoming image names re #468.
// Need to strip it in order to match by image name and find available updates.
let image = image.strip_prefix("docker.io/").unwrap_or(&image);
images
.iter()
.find(|i| i.name == image)
@@ -250,20 +253,21 @@ pub async fn update_stack_cache(
}
}.is_match(&container.name)
}).cloned();
// If image already has tag, leave it,
// otherwise default the tag to latest
let image = image.clone();
let image = if image.contains(':') {
image
image.to_string()
} else {
image + ":latest"
format!("{image}:latest")
};
let update_available = if let Some(ContainerListItem { image_id: Some(curr_image_id), .. }) = &container {
// Docker will automatically strip `docker.io` from incoming image names re #468.
// Need to strip it in order to match by image tag and find available update.
let image =
image.strip_prefix("docker.io/").unwrap_or(&image);
images
.iter()
.find(|i| i.name == image)
.map(|i| &i.id != curr_image_id)
.unwrap_or_default()
.iter()
.find(|i| i.name == image)
.map(|i| &i.id != curr_image_id)
.unwrap_or_default()
} else {
false
};
+1 -1
View File
@@ -188,7 +188,7 @@ impl super::KomodoResource for Deployment {
else {
return Ok(());
};
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
Ok(())
}
+51 -11
View File
@@ -34,8 +34,10 @@ use komodo_client::{
parsers::parse_string_list,
};
use partial_derive2::{Diff, MaybeNone, PartialDiff};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serde::{Serialize, de::DeserializeOwned};
use serror::AddStatusCodeError;
use crate::{
api::{read::ReadArgs, write::WriteArgs},
@@ -458,22 +460,31 @@ pub async fn create<T: KomodoResource>(
name: &str,
mut config: T::PartialConfig,
user: &User,
) -> anyhow::Result<Resource<T::Config, T::Info>> {
) -> serror::Result<Resource<T::Config, T::Info>> {
if !T::user_can_create(user) {
return Err(anyhow!(
"User does not have permissions to create {}.",
T::resource_type()
));
return Err(
anyhow!(
"User does not have permissions to create {}.",
T::resource_type()
)
.status_code(StatusCode::FORBIDDEN),
);
}
if name.is_empty() {
return Err(anyhow!("Must provide non-empty name for resource."));
return Err(
anyhow!("Must provide non-empty name for resource")
.status_code(StatusCode::BAD_REQUEST),
);
}
let name = T::validated_name(name);
if ObjectId::from_str(&name).is_ok() {
return Err(anyhow!("valid ObjectIds cannot be used as names."));
return Err(
anyhow!("Valid ObjectIds cannot be used as names")
.status_code(StatusCode::BAD_REQUEST),
);
}
// Ensure an existing resource with same name doesn't already exist
@@ -489,7 +500,10 @@ pub async fn create<T: KomodoResource>(
.into_iter()
.any(|r| r.name == name)
{
return Err(anyhow!("Must provide unique name for resource."));
return Err(
anyhow!("Resource with name '{}' already exists", name)
.status_code(StatusCode::CONFLICT),
);
}
let start_ts = komodo_timestamp();
@@ -853,9 +867,15 @@ pub async fn delete<T: KomodoResource>(
);
update.push_simple_log("Deleted Toml", toml);
if let Err(e) = T::post_delete(&resource, &mut update).await {
update.push_error_log("post delete", format_serror(&e.into()));
}
tokio::join!(
async {
if let Err(e) = T::post_delete(&resource, &mut update).await {
update
.push_error_log("post delete", format_serror(&e.into()));
}
},
delete_from_alerters::<T>(&resource.id)
);
refresh_all_resources_cache().await;
@@ -865,6 +885,26 @@ pub async fn delete<T: KomodoResource>(
Ok(resource)
}
async fn delete_from_alerters<T: KomodoResource>(id: &str) {
let target_bson = doc! {
"type": T::resource_type().as_ref(),
"id": id,
};
if let Err(e) = db_client()
.alerters
.update_many(Document::new(), doc! {
"$pull": {
"config.resources": &target_bson,
"config.except_resources": target_bson,
}
})
.await
.context("Failed to clear deleted resource from alerter whitelist / blacklist")
{
warn!("{e:#}");
}
}
// =======
#[instrument(level = "debug")]
+1
View File
@@ -300,6 +300,7 @@ async fn get_repo_state_from_db(id: &str) -> RepoState {
"$or": [
{ "operation": "CloneRepo" },
{ "operation": "PullRepo" },
{ "operation": "BuildRepo" },
],
})
.with_options(
+2 -2
View File
@@ -123,7 +123,7 @@ impl super::KomodoResource for Server {
created: &Resource<Self::Config, Self::Info>,
_update: &mut Update,
) -> anyhow::Result<()> {
update_cache_for_server(created).await;
update_cache_for_server(created, true).await;
Ok(())
}
@@ -145,7 +145,7 @@ impl super::KomodoResource for Server {
updated: &Self,
_update: &mut Update,
) -> anyhow::Result<()> {
update_cache_for_server(updated).await;
update_cache_for_server(updated, true).await;
Ok(())
}
+1 -1
View File
@@ -252,7 +252,7 @@ impl super::KomodoResource for Stack {
else {
return Ok(());
};
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
Ok(())
}
+2 -2
View File
@@ -113,7 +113,7 @@ impl super::KomodoResource for ResourceSync {
async fn busy(id: &String) -> anyhow::Result<bool> {
action_states()
.resource_sync
.sync
.get(id)
.await
.unwrap_or_default()
@@ -242,7 +242,7 @@ async fn get_resource_sync_state(
data: &ResourceSyncInfo,
) -> ResourceSyncState {
if let Some(state) = action_states()
.resource_sync
.sync
.get(id)
.await
.and_then(|s| {
+1 -1
View File
@@ -72,7 +72,7 @@ pub async fn execute_compose<T: ExecuteCompose>(
.push(T::execute(periphery, stack, services, extras).await?);
// Ensure cached stack state up to date by updating server cache
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
+1 -1
View File
@@ -242,7 +242,7 @@ async fn ensure_first_server_and_builder() {
return;
};
if let Err(e) = (CreateBuilder {
name: String::from("Local"),
name: config.first_server_name.clone(),
config: PartialBuilderConfig::Server(
PartialServerBuilderConfig {
server_id: Some(server.id),
+1
View File
@@ -40,6 +40,7 @@ pub fn db_client() -> &'static Client {
.expect("db_client accessed before initialized")
}
/// Must be called in app startup sequence.
pub async fn init_db_client() {
let client = Client::new(&core_config().database)
.await
+1
View File
@@ -147,6 +147,7 @@ pub trait ExecuteResourceSync: ResourceSyncTrait {
sync_user(),
)
.await
.map_err(|e| e.error)
{
Ok(resource) => resource.id,
Err(e) => {
+1
View File
@@ -825,6 +825,7 @@ impl ExecuteResourceSync for Procedure {
sync_user(),
)
.await
.map_err(|e| e.error)
{
Ok(resource) => resource.id,
Err(e) => {
+2 -1
View File
@@ -1,6 +1,7 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
FROM rust:1.89.0-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
@@ -10,7 +11,7 @@ COPY ./client/periphery ./client/periphery
COPY ./bin/periphery ./bin/periphery
# Compile app
RUN cargo build -p komodo_periphery --release
RUN cargo build -p komodo_periphery --release && cargo strip
# Final Image
FROM debian:bullseye-slim
+7 -12
View File
@@ -12,7 +12,7 @@ use interpolate::Interpolator;
use komodo_client::entities::{
EnvironmentVar, all_logs_success,
build::{Build, BuildConfig},
environment_vars_from_str, get_image_names, optional_string,
environment_vars_from_str, optional_string,
to_path_compatible_name,
update::Log,
};
@@ -25,9 +25,7 @@ use resolver_api::Resolve;
use tokio::fs;
use crate::{
build::{
image_tags, parse_build_args, parse_secret_args, write_dockerfile,
},
build::{parse_build_args, parse_secret_args, write_dockerfile},
config::periphery_config,
docker::docker_login,
helpers::{parse_extra_args, parse_labels},
@@ -126,8 +124,9 @@ impl Resolve<super::Args> for build::Build {
mut build,
repo: linked_repo,
registry_tokens,
additional_tags,
mut replacers,
commit_hash,
additional_tags,
} = self;
let mut logs = Vec::new();
@@ -145,8 +144,6 @@ impl Resolve<super::Args> for build::Build {
name,
config:
BuildConfig {
version,
image_tag,
build_path,
dockerfile_path,
build_args,
@@ -265,8 +262,6 @@ impl Resolve<super::Args> for build::Build {
// Get command parts
let image_names = get_image_names(&build);
// Add VERSION to build args (if not already there)
let mut build_args = environment_vars_from_str(build_args)
.context("Invalid build_args")?;
@@ -291,9 +286,9 @@ impl Resolve<super::Args> for build::Build {
let buildx = if *use_buildx { " buildx" } else { "" };
let image_tags =
image_tags(&image_names, image_tag, version, &additional_tags)
.context("Failed to parse image tags into command")?;
let image_tags = build
.get_image_tags_as_arg(commit_hash.as_deref(), &additional_tags)
.context("Failed to parse image tags into command")?;
let maybe_push = if should_push { " --push" } else { "" };
+4
View File
@@ -712,6 +712,7 @@ impl Resolve<super::Args> for ComposeRun {
command,
no_tty,
no_deps,
detach,
service_ports,
env,
workdir,
@@ -783,6 +784,9 @@ impl Resolve<super::Args> for ComposeRun {
}
let mut run_flags = String::from(" --rm");
if detach.unwrap_or_default() {
run_flags.push_str(" -d");
}
if no_tty.unwrap_or_default() {
run_flags.push_str(" --no-tty");
}
+1 -1
View File
@@ -347,7 +347,7 @@ async fn execute_command_on_terminal(
);
let full_command = format!(
"printf '\n{START_OF_OUTPUT}\n\n'; {command}; rc=$? printf '\n{KOMODO_EXIT_CODE}%d\n{END_OF_OUTPUT}\n' \"$rc\"\n"
"printf '\n{START_OF_OUTPUT}\n\n'; {command}; rc=$?; printf '\n{KOMODO_EXIT_CODE}%d\n{END_OF_OUTPUT}\n' \"$rc\"\n"
);
terminal
+1 -29
View File
@@ -6,7 +6,7 @@ use std::{
use anyhow::{Context, anyhow};
use formatting::format_serror;
use komodo_client::{
entities::{EnvironmentVar, Version, update::Log},
entities::{EnvironmentVar, update::Log},
parsers::QUOTE_PATTERN,
};
@@ -52,34 +52,6 @@ pub async fn write_dockerfile(
}
}
pub fn image_tags(
image_names: &[String],
custom_tag: &str,
version: &Version,
additional: &[String],
) -> anyhow::Result<String> {
let Version { major, minor, .. } = version;
let custom_tag = if custom_tag.is_empty() {
String::new()
} else {
format!("-{custom_tag}")
};
let mut res = String::new();
for image_name in image_names {
write!(
&mut res,
" -t {image_name}:latest{custom_tag} -t {image_name}:{version}{custom_tag} -t {image_name}:{major}.{minor}{custom_tag} -t {image_name}:{major}{custom_tag}"
)?;
for tag in additional {
write!(&mut res, " -t {image_name}:{tag}{custom_tag}")?;
}
}
Ok(res)
}
pub fn parse_build_args(build_args: &[EnvironmentVar]) -> String {
build_args
.iter()
+2 -1
View File
@@ -43,8 +43,9 @@ pub async fn validate_files(
"Validate Files",
format_serror(
&anyhow!(
"Ensure the run_directory and all file paths are correct."
"Missing files: {}", res.missing_files.join(", ")
)
.context("Ensure the run_directory and all file paths are correct.")
.context("A file doesn't exist after writing stack.")
.into(),
),
+9 -1
View File
@@ -2,7 +2,8 @@ use std::{cmp::Ordering, sync::OnceLock};
use async_timing_util::wait_until_timelength;
use komodo_client::entities::stats::{
SingleDiskUsage, SystemInformation, SystemProcess, SystemStats,
SingleDiskUsage, SystemInformation, SystemLoadAverage,
SystemProcess, SystemStats,
};
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
use tokio::sync::RwLock;
@@ -95,8 +96,15 @@ impl StatsClient {
network_egress_bytes += network.transmitted();
}
let load_avg = System::load_average();
SystemStats {
cpu_perc: self.system.global_cpu_usage(),
load_average: SystemLoadAverage {
one: load_avg.one,
five: load_avg.five,
fifteen: load_avg.fifteen,
},
mem_free_gb: self.system.free_memory() as f64 / BYTES_PER_GB,
mem_used_gb: (total_mem - available_mem) as f64 / BYTES_PER_GB,
mem_total_gb: total_mem as f64 / BYTES_PER_GB,
+1
View File
@@ -28,6 +28,7 @@ resolver_api.workspace = true
# external
tokio-tungstenite.workspace = true
derive_builder.workspace = true
urlencoding.workspace = true
serde_json.workspace = true
tokio-util.workspace = true
thiserror.workspace = true
+3
View File
@@ -379,6 +379,9 @@ pub struct RunStackService {
/// Do not start linked services
#[arg(long = "no-deps", action = SetTrue)]
pub no_deps: Option<bool>,
/// Detach container on run
#[arg(long = "detach", action = SetTrue)]
pub detach: Option<bool>,
/// Map service ports to the host
#[arg(long = "service-ports", action = SetTrue)]
pub service_ports: Option<bool>,
+1 -1
View File
@@ -68,7 +68,7 @@ impl Busy for ProcedureActionState {
impl Busy for ActionActionState {
fn busy(&self) -> bool {
self.running
self.running > 0
}
}
@@ -1,5 +1,4 @@
use serde::{
__private::de::{Content, ContentDeserializer},
Deserialize, Deserializer,
de::{IntoDeserializer, Visitor},
};
@@ -69,17 +68,15 @@ impl<'de, T: Deserialize<'de>> Visitor<'de>
let mut res =
Vec::with_capacity(seq.size_hint().unwrap_or_default());
loop {
match seq.next_element::<Content>() {
Ok(Some(content)) => {
match T::deserialize::<ContentDeserializer<'_, S::Error>>(
content.clone().into_deserializer(),
) {
match seq.next_element::<serde_json::Value>() {
Ok(Some(value)) => {
match T::deserialize(value.clone().into_deserializer()) {
Ok(item) => res.push(item),
Err(e) => {
// Since this is used to parse startup config (including logging config),
// the tracing logging is not initialized. Need to use eprintln.
eprintln!(
"WARN: failed to parse item in list | {content:?} | {e:?}",
"WARN: failed to parse item in list | {value:?} | {e:?}",
)
}
}
+2 -2
View File
@@ -222,8 +222,8 @@ impl Default for ActionConfig {
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)]
pub struct ActionActionState {
/// Whether the action is currently running.
pub running: bool,
/// Number of instances of the Action currently running
pub running: u32,
}
#[typeshare]
+147 -1
View File
@@ -1,4 +1,4 @@
use std::sync::OnceLock;
use std::{fmt::Write, sync::OnceLock};
use bson::{Document, doc};
use derive_builder::Builder;
@@ -26,6 +26,127 @@ use super::{
#[typeshare]
pub type Build = Resource<BuildConfig, BuildInfo>;
impl Build {
pub fn get_image_names(&self) -> Vec<String> {
let Build {
name,
config:
BuildConfig {
image_name,
image_registry,
..
},
..
} = self;
let name = if image_name.is_empty() {
name
} else {
image_name
};
// Local only
if image_registry.is_empty() {
return vec![name.to_string()];
}
image_registry
.iter()
.map(
|ImageRegistryConfig {
domain,
account,
organization,
}| {
match (
!domain.is_empty(),
!organization.is_empty(),
!account.is_empty(),
) {
// If organization and account provided, name under organization.
(true, true, true) => {
format!("{domain}/{organization}/{name}")
}
// Just domain / account provided
(true, false, true) => {
format!("{domain}/{account}/{name}")
}
// Otherwise, just use name (local only)
_ => name.to_string(),
}
},
)
.collect()
}
pub fn get_image_tags(
&self,
image_names: &[String],
commit_hash: Option<&str>,
additional: &[String],
) -> Vec<String> {
let BuildConfig {
version,
image_tag,
include_latest_tag,
include_version_tags: include_version_tag,
include_commit_tag,
..
} = &self.config;
let Version { major, minor, .. } = version;
let image_tag_postfix = if image_tag.is_empty() {
String::new()
} else {
format!("-{image_tag}")
};
let mut tags = Vec::new();
for image_name in image_names {
// Pure image tag passthrough when provided
if !image_tag.is_empty() {
tags.push(format!("{image_name}:{image_tag}"));
}
// `:latest` / `:latest-tag`
if *include_latest_tag {
tags.push(format!("{image_name}:latest{image_tag_postfix}"));
}
// `:1.19.5` + `:1.19` etc. / `1.19.5-tag`
if *include_version_tag {
tags
.push(format!("{image_name}:{version}{image_tag_postfix}"));
tags.push(format!(
"{image_name}:{major}.{minor}{image_tag_postfix}"
));
tags.push(format!("{image_name}:{major}{image_tag_postfix}"));
}
if *include_commit_tag && let Some(hash) = commit_hash {
tags.push(format!("{image_name}:{hash}{image_tag_postfix}"));
}
for tag in additional {
tags.push(format!("{image_name}:{tag}"))
}
}
tags
}
pub fn get_image_tags_as_arg(
&self,
commit_hash: Option<&str>,
additional: &[String],
) -> anyhow::Result<String> {
let mut res = String::new();
for image_tag in self.get_image_tags(
&self.get_image_names(),
commit_hash,
additional,
) {
write!(&mut res, " -t {image_tag}")?;
}
Ok(res)
}
}
#[typeshare]
pub type BuildListItem = ResourceListItem<BuildListItemInfo>;
@@ -169,6 +290,24 @@ pub struct BuildConfig {
#[builder(default)]
pub image_tag: String,
/// Push `:latest` / `:latest-image_tag` tags.
#[serde(default = "default_include_tag")]
#[builder(default = "default_include_tag()")]
#[partial_default(default_include_tag())]
pub include_latest_tag: bool,
/// Push build version semver `:1.19.5` + `1.19` / `:1.19.5-image_tag` tags.
#[serde(default = "default_include_tag")]
#[builder(default = "default_include_tag()")]
#[partial_default(default_include_tag())]
pub include_version_tags: bool,
/// Push commit hash `:a6v8h83` / `:a6v8h83-image_tag` tags.
#[serde(default = "default_include_tag")]
#[builder(default = "default_include_tag()")]
#[partial_default(default_include_tag())]
pub include_commit_tag: bool,
/// Configure quick links that are displayed in the resource header
#[serde(default, deserialize_with = "string_list_deserializer")]
#[partial_attr(serde(
@@ -344,6 +483,10 @@ fn default_auto_increment_version() -> bool {
true
}
fn default_include_tag() -> bool {
true
}
fn default_git_provider() -> String {
String::from("github.com")
}
@@ -377,6 +520,9 @@ impl Default for BuildConfig {
auto_increment_version: default_auto_increment_version(),
image_name: Default::default(),
image_tag: Default::default(),
include_latest_tag: default_include_tag(),
include_version_tags: default_include_tag(),
include_commit_tag: default_include_tag(),
links: Default::default(),
linked_repo: Default::default(),
git_provider: default_git_provider(),
@@ -125,6 +125,8 @@ pub struct Env {
pub komodo_logging_opentelemetry_service_name: Option<String>,
/// Override `pretty_startup_config`
pub komodo_pretty_startup_config: Option<bool>,
/// Override `unsafe_unsanitized_startup_config`
pub komodo_unsafe_unsanitized_startup_config: Option<bool>,
/// Override `transparent_mode`
pub komodo_transparent_mode: Option<bool>,
@@ -509,6 +511,12 @@ pub struct CoreConfig {
#[serde(default)]
pub pretty_startup_config: bool,
/// Unsafe: logs unsanitized config on startup,
/// in order to verify everything is being
/// passed correctly.
#[serde(default)]
pub unsafe_unsanitized_startup_config: bool,
// ===========
// = Pruning =
// ===========
@@ -729,6 +737,7 @@ impl Default for CoreConfig {
github_webhook_app: Default::default(),
logging: Default::default(),
pretty_startup_config: Default::default(),
unsafe_unsanitized_startup_config: Default::default(),
keep_stats_for_days: default_prune_days(),
keep_alerts_for_days: default_prune_days(),
resource_poll_interval: default_poll_interval(),
@@ -772,6 +781,8 @@ impl CoreConfig {
keep_alerts_for_days: config.keep_alerts_for_days,
logging: config.logging,
pretty_startup_config: config.pretty_startup_config,
unsafe_unsanitized_startup_config: config
.unsafe_unsanitized_startup_config,
transparent_mode: config.transparent_mode,
ui_write_disabled: config.ui_write_disabled,
disable_confirm_dialog: config.disable_confirm_dialog,
+11 -51
View File
@@ -5,7 +5,6 @@ use std::{
use anyhow::Context;
use async_timing_util::unix_timestamp_ms;
use build::ImageRegistryConfig;
use clap::Parser;
use derive_empty_traits::EmptyTraits;
use derive_variants::{EnumVariants, ExtractVariant};
@@ -129,54 +128,6 @@ pub fn optional_string(string: impl Into<String>) -> Option<String> {
}
}
pub fn get_image_names(
build::Build {
name,
config:
build::BuildConfig {
image_name,
image_registry,
..
},
..
}: &build::Build,
) -> Vec<String> {
let name = if image_name.is_empty() {
name
} else {
image_name
};
// Local only
if image_registry.is_empty() {
return vec![name.to_string()];
}
image_registry
.iter()
.map(
|ImageRegistryConfig {
domain,
account,
organization,
}| {
match (
!domain.is_empty(),
!organization.is_empty(),
!account.is_empty(),
) {
// If organization and account provided, name under organization.
(true, true, true) => {
format!("{domain}/{organization}/{name}")
}
// Just domain / account provided
(true, false, true) => format!("{domain}/{account}/{name}"),
// Otherwise, just use name (local only)
_ => name.to_string(),
}
},
)
.collect()
}
pub fn to_general_name(name: &str) -> String {
name.trim().replace('\n', "_").to_string()
}
@@ -511,8 +462,17 @@ impl RepoExecutionArgs {
&self,
access_token: Option<&str>,
) -> anyhow::Result<String> {
let access_token_at = match &access_token {
Some(token) => format!("token:{token}@"),
let access_token_at = match access_token {
Some(token) => match token.split_once(':') {
Some((username, token)) => format!(
"{}:{}@",
urlencoding::encode(username.trim()),
urlencoding::encode(token.trim())
),
None => {
format!("token:{}@", urlencoding::encode(token.trim()))
}
},
None => String::new(),
};
let protocol = if self.https { "https" } else { "http" };
+17
View File
@@ -45,6 +45,9 @@ pub struct SystemStatsRecord {
// basic stats
/// Cpu usage percentage
pub cpu_perc: f32,
/// Load average (1m, 5m, 15m)
#[serde(default)]
pub load_average: SystemLoadAverage,
/// Memory used in GB
pub mem_used_gb: f64,
/// Total memory in GB
@@ -72,6 +75,9 @@ pub struct SystemStatsRecord {
pub struct SystemStats {
/// Cpu usage percentage
pub cpu_perc: f32,
/// Load average (1m, 5m, 15m)
#[serde(default)]
pub load_average: SystemLoadAverage,
/// [1.15.9+]
/// Free memory in GB.
/// This is really the 'Free' memory, not the 'Available' memory.
@@ -175,3 +181,14 @@ pub struct SystemProcess {
/// Process disk write in KB/s
pub disk_write_kb: f64,
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct SystemLoadAverage {
/// 1m load average
pub one: f64,
/// 5m load average
pub five: f64,
/// 15m load average
pub fifteen: f64,
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "komodo_client",
"version": "1.19.1",
"version": "1.19.5",
"description": "Komodo client package",
"homepage": "https://komo.do",
"main": "dist/lib.js",
+5
View File
@@ -199,6 +199,11 @@ export function KomodoClient(url: string, options: InitOptions) {
} else {
// it is a single update
const update = res as any as Update;
if (update.status === UpdateStatus.Complete || !update._id?.$oid) {
return update;
}
return await poll_update_until_complete(update._id?.$oid!);
}
};
+23 -2
View File
@@ -598,6 +598,12 @@ export interface BuildConfig {
* independantly versioned tags.
*/
image_tag?: string;
/** Push `:latest` / `:latest-image_tag` tags. */
include_latest_tag: boolean;
/** Push build version semver `:1.19.5` + `1.19` / `:1.19.5-image_tag` tags. */
include_version_tags: boolean;
/** Push commit hash `:a6v8h83` / `:a6v8h83-image_tag` tags. */
include_commit_tag: boolean;
/** Configure quick links that are displayed in the resource header */
links?: string[];
/** Choose a Komodo Repo (Resource) to source the build files. */
@@ -1351,8 +1357,8 @@ export type ExportResourcesToTomlResponse = TomlResponse;
export type FindUserResponse = User;
export interface ActionActionState {
/** Whether the action is currently running. */
running: boolean;
/** Number of instances of the Action currently running */
running: number;
}
export type GetActionActionStateResponse = ActionActionState;
@@ -2402,6 +2408,15 @@ export interface SystemInformation {
export type GetSystemInformationResponse = SystemInformation;
export interface SystemLoadAverage {
/** 1m load average */
one: number;
/** 5m load average */
five: number;
/** 15m load average */
fifteen: number;
}
/** Info for a single disk mounted on the system. */
export interface SingleDiskUsage {
/** The mount point of the disk */
@@ -2463,6 +2478,8 @@ export enum Timelength {
export interface SystemStats {
/** Cpu usage percentage */
cpu_perc: number;
/** Load average (1m, 5m, 15m) */
load_average?: SystemLoadAverage;
/**
* [1.15.9+]
* Free memory in GB.
@@ -6097,6 +6114,8 @@ export interface SystemStatsRecord {
sid: string;
/** Cpu usage percentage */
cpu_perc: number;
/** Load average (1m, 5m, 15m) */
load_average?: SystemLoadAverage;
/** Memory used in GB */
mem_used_gb: number;
/** Total memory in GB */
@@ -7700,6 +7719,8 @@ export interface RunStackService {
no_tty?: boolean;
/** Do not start linked services */
no_deps?: boolean;
/** Detach container on run */
detach?: boolean;
/** Map service ports to the host */
service_ports?: boolean;
/** Extra environment variables for the run */
+2
View File
@@ -18,6 +18,8 @@ pub struct Build {
/// Propogate any secret replacers from core interpolation.
#[serde(default)]
pub replacers: Vec<(String, String)>,
/// Pass the commit hash to use with tagging
pub commit_hash: Option<String>,
/// Add more tags for this build in addition to the version tags.
#[serde(default)]
pub additional_tags: Vec<String>,
+3
View File
@@ -272,6 +272,9 @@ pub struct ComposeRun {
/// Do not start linked services
#[serde(default)]
pub no_deps: Option<bool>,
/// Detach container on run
#[serde(default)]
pub detach: Option<bool>,
/// Map service ports to the host
#[serde(default)]
pub service_ports: Option<bool>,
+3 -3
View File
@@ -9,14 +9,14 @@
## All fields with a "Default" provided are optional. If they are
## left out of the file, the "Default" value will be used.
## This file is bundled into the official image, `ghcr.io/moghtech/komodo`,
## This file is bundled into the official image, `ghcr.io/moghtech/komodo-core`,
## as the default config at `/config/.default.config.toml`.
## Komodo can start with no external config file mounted.
## Komodo Core can start with no external config file mounted.
## Most fields can also be configured using environment variables.
## Environment variables will override values set in this file.
## Can also use JSON or YAML if preffered. You can convert here:
## Can also use JSON or YAML if preferred. You can convert here:
## - YAML: https://it-tools.tech/toml-to-yaml
## - JSON: https://it-tools.tech/toml-to-json
+1 -1
View File
@@ -9,7 +9,7 @@
## Most fields can also be configured using cli arguments and environment variables.
## These will will override values set in this file. (cli args > env > config files).
## You can also use JSON or YAML if preffered. You can convert here:
## You can also use JSON or YAML if preferred. You can convert here:
## - YAML: https://it-tools.tech/toml-to-yaml
## - JSON: https://it-tools.tech/toml-to-json
+4 -4
View File
@@ -16,7 +16,7 @@
## Most fields can also be configured using environment variables.
## Environment variables will override values set in this file.
## You can also use JSON or YAML if preffered. You can convert here:
## You can also use JSON or YAML if preferred. You can convert here:
## - YAML: https://it-tools.tech/toml-to-yaml
## - JSON: https://it-tools.tech/toml-to-json
@@ -75,10 +75,10 @@ disable_container_exec = false
stats_polling_rate = "5-sec"
## How often Periphery polls the host for container stats,
## Env: PERIPHERY_STATS_POLLING_RATE
## Env: PERIPHERY_CONTAINER_STATS_POLLING_RATE
## Options: https://docs.rs/komodo_client/latest/komodo_client/entities/enum.Timelength.html
## Default: 5-sec
container_stats_polling_rate = "1-min"
## Default: 30-sec
container_stats_polling_rate = "30-sec"
## Whether stack actions should use `docker-compose ...`
## instead of `docker compose ...`.
+2 -1
View File
@@ -15,4 +15,5 @@ These provide alerting implementations which can be used with the `Custom` Alert
- [Telegram](https://github.com/mattsmallman/komodo-alert-to-telgram) by [mattsmallman](https://github.com/mattsmallman)
- [Ntfy](https://github.com/FoxxMD/deploy-ntfy-alerter) by [FoxxMD](https://github.com/FoxxMD)
- [Gotify](https://github.com/FoxxMD/deploy-gotify-alerter) by [FoxxMD](https://github.com/FoxxMD)
- [Apprise](https://github.com/FoxxMD/deploy-apprise-alerter) by [FoxxMD](https://github.com/FoxxMD)
- [Apprise](https://github.com/FoxxMD/deploy-apprise-alerter) by [FoxxMD](https://github.com/FoxxMD)
- [Email](https://github.com/gutenye/email-notification/blob/main/src/templates/Komodo/Komodo.md) by [Guten Ye](https://github.com/gutenye)
+2 -2
View File
@@ -71,7 +71,7 @@ permissions = [
## Administration
Users can be given Admin priviledges by a `Super Admin` (only the first user is given this status, set with `super_admin: true` on a User document in database). Super admins will see the "Make Admin" button when on a User page `/users/${user_id}`.
Users can be given Admin privileges by a `Super Admin` (only the first user is given this status, set with `super_admin: true` on a User document in database). Super admins will see the "Make Admin" button when on a User page `/users/${user_id}`.
These users have unrestricted access to all Komodo Resources. Additionally, these users can update other (non-admin) user's permissions on resources.
@@ -82,4 +82,4 @@ Users also have some configurable global permissions, these are:
- create server permission
- create build permission
Only users with these permissions (as well as admins) can add additional servers to Komodo, and can create additional builds, respectively.
Only users with these permissions (as well as admins) can add additional servers to Komodo, and can create additional builds, respectively.
+4
View File
@@ -14,6 +14,10 @@ Komodo also supports self hosted Oauth2 providers like [Authentik](https://docs.
- `<KOMODO_HOST>/auth/google/callback` for Google.
- `<KOMODO_HOST>/auth/oidc/callback` for OIDC.
### Authentik
Check out the [Authentik official support documentation](https://integrations.goauthentik.io/infrastructure/komodo/).
### Keycloak
- Create an [OIDC client](https://www.keycloak.org/docs/latest/server_admin/index.html#proc-creating-oidc-client_server_administration_guide) in Keycloak.
- Note down the `Client ID` that you enter (e.g.: "komodo"), you will need it for Komodo configuration
+5 -3
View File
@@ -5,10 +5,12 @@ To run Komodo, you will need Docker. See [the docker install docs](https://docs.
### Deploy with Docker Compose
- [**Using MongoDB**](./mongo.mdx)
- Lower CPU usage, Higher RAM usage.
- Some systems [do not support running the latest MongoDB versions](https://github.com/moghtech/komodo/issues/59).
- [**Using FerretDB** (Postgres)](./ferretdb.mdx)
- Lower RAM usage, Higher CPU usage.
:::info
Some systems [do not support running the latest MongoDB versions](https://github.com/moghtech/komodo/issues/59).
Users with these systems should use FerretDB instead.
:::
:::info
**FerretDB v1** users:
+1
View File
@@ -40,6 +40,7 @@
"jotai": "2.12.5",
"lucide-react": "0.511.0",
"monaco-editor": "0.52.2",
"monaco-yaml": "5.4.0",
"prettier": "3.5.3",
"react": "19.1.0",
"react-charts": "3.0.0-beta.57",
+3
View File
@@ -88,6 +88,9 @@ export function KomodoClient(url, options) {
else {
// it is a single update
const update = res;
if (update.status === UpdateStatus.Complete || !update._id?.$oid) {
return update;
}
return await poll_update_until_complete(update._id?.$oid);
}
};
+22 -2
View File
@@ -595,6 +595,12 @@ export interface BuildConfig {
* independantly versioned tags.
*/
image_tag?: string;
/** Push `:latest` / `:latest-image_tag` tags. */
include_latest_tag: boolean;
/** Push build version semver `:1.19.5` + `1.19` / `:1.19.5-image_tag` tags. */
include_version_tags: boolean;
/** Push commit hash `:a6v8h83` / `:a6v8h83-image_tag` tags. */
include_commit_tag: boolean;
/** Configure quick links that are displayed in the resource header */
links?: string[];
/** Choose a Komodo Repo (Resource) to source the build files. */
@@ -1457,8 +1463,8 @@ export type ExportAllResourcesToTomlResponse = TomlResponse;
export type ExportResourcesToTomlResponse = TomlResponse;
export type FindUserResponse = User;
export interface ActionActionState {
/** Whether the action is currently running. */
running: boolean;
/** Number of instances of the Action currently running */
running: number;
}
export type GetActionActionStateResponse = ActionActionState;
export type GetActionResponse = Action;
@@ -2517,6 +2523,14 @@ export interface SystemInformation {
container_exec_disabled: boolean;
}
export type GetSystemInformationResponse = SystemInformation;
export interface SystemLoadAverage {
/** 1m load average */
one: number;
/** 5m load average */
five: number;
/** 15m load average */
fifteen: number;
}
/** Info for a single disk mounted on the system. */
export interface SingleDiskUsage {
/** The mount point of the disk */
@@ -2576,6 +2590,8 @@ export declare enum Timelength {
export interface SystemStats {
/** Cpu usage percentage */
cpu_perc: number;
/** Load average (1m, 5m, 15m) */
load_average?: SystemLoadAverage;
/**
* [1.15.9+]
* Free memory in GB.
@@ -5852,6 +5868,8 @@ export interface SystemStatsRecord {
sid: string;
/** Cpu usage percentage */
cpu_perc: number;
/** Load average (1m, 5m, 15m) */
load_average?: SystemLoadAverage;
/** Memory used in GB */
mem_used_gb: number;
/** Total memory in GB */
@@ -7294,6 +7312,8 @@ export interface RunStackService {
no_tty?: boolean;
/** Do not start linked services */
no_deps?: boolean;
/** Detach container on run */
detach?: boolean;
/** Map service ports to the host */
service_ports?: boolean;
/** Extra environment variables for the run */
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -136,12 +136,12 @@ const GroupActionDialog = ({
return (
<Dialog open={!!action} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogContent className="max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Group Execute - {formatted}</DialogTitle>
</DialogHeader>
<div className="py-8 flex flex-col gap-4">
<ul className="p-4 bg-accent text-sm list-disc list-inside">
<ul className="p-4 bg-accent text-sm list-disc list-inside max-h-[300px] overflow-y-auto">
{selected.map((resource) => (
<li key={resource}>{resource}</li>
))}
+15 -4
View File
@@ -170,6 +170,7 @@ interface SectionProps {
actions?: ReactNode;
// otherwise items-start
itemsCenterTitleRow?: boolean;
className?: string;
}
export const Section = ({
@@ -180,8 +181,9 @@ export const Section = ({
actions,
children,
itemsCenterTitleRow,
className,
}: SectionProps) => (
<div className="flex flex-col gap-4">
<div className={cn("flex flex-col gap-4", className)}>
{(title || icon || titleRight || titleOther || actions) && (
<div
className={cn(
@@ -222,6 +224,7 @@ export const NewLayout = ({
}) => {
const [open, set] = useState(false);
const [loading, setLoading] = useState(false);
return (
<Dialog
open={open}
@@ -248,9 +251,17 @@ export const NewLayout = ({
variant="secondary"
onClick={async () => {
setLoading(true);
await onConfirm();
setLoading(false);
set(false);
try {
await onConfirm();
set(false);
} catch (error: any) {
const status = error?.status || error?.response?.status;
if (status !== 409 && status !== 400) {
set(false);
}
} finally {
setLoading(false);
}
}}
disabled={!enabled || loading}
>
+15 -16
View File
@@ -56,12 +56,14 @@ export const MonacoEditor = ({
onValueChange,
language: _language,
readOnly,
filename,
minHeight,
className,
}: {
value: string | undefined;
onValueChange?: (value: string) => void;
language: MonacoLanguage | undefined;
filename?: string;
readOnly?: boolean;
minHeight?: number;
className?: string;
@@ -139,13 +141,7 @@ export const MonacoEditor = ({
)}px`;
}, [editor, line_count]);
const { theme: _theme } = useTheme();
const theme =
_theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: _theme;
const { currentTheme } = useTheme();
const options: monaco.editor.IStandaloneEditorConstructionOptions = {
minimap: { enabled: false },
@@ -169,7 +165,8 @@ export const MonacoEditor = ({
<Editor
language={language}
value={value}
theme={theme}
theme={currentTheme}
defaultPath={defaultPath(filename)}
options={options}
onChange={(v) => onValueChange?.(v ?? "")}
onMount={(editor) => setEditor(editor)}
@@ -178,6 +175,14 @@ export const MonacoEditor = ({
);
};
const defaultPath = (filename?: string) => {
if (!filename) return undefined;
// Extract only the filename part of path,
// avoiding critical issue when path starts with '/'
const split = filename.split("/");
return split[split.length - 1];
};
const MIN_DIFF_HEIGHT = 100;
const MAX_DIFF_HEIGHT = 400;
@@ -222,13 +227,7 @@ export const MonacoDiffEditor = ({
)}px`;
}, [editor, line_count]);
const { theme: _theme } = useTheme();
const theme =
_theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: _theme;
const { currentTheme } = useTheme();
const options: monaco.editor.IStandaloneDiffEditorConstructionOptions = {
minimap: { enabled: true },
@@ -251,7 +250,7 @@ export const MonacoDiffEditor = ({
language={language}
original={original}
modified={modified}
theme={theme}
theme={currentTheme}
options={options}
onMount={(editor) => {
const modifiedEditor = editor.getModifiedEditor();
+6 -2
View File
@@ -1,4 +1,4 @@
import { useAllResources, useLocalStorage, useRead, useUser } from "@lib/hooks";
import { useAllResources, useLocalStorage, useRead, useSettingsView, useUser } from "@lib/hooks";
import { Button } from "@ui/button";
import {
CommandDialog,
@@ -126,6 +126,7 @@ const useOmniItems = (
): Record<string, OmniItem[]> => {
const user = useUser().data;
const resources = useAllResources();
const [_, setSettingsView] = useSettingsView();
return useMemo(() => {
const searchTerms = search
.toLowerCase()
@@ -166,7 +167,10 @@ const useOmniItems = (
type: "Server" as UsableResource,
label: "Users",
icon: <User className="w-4 h-4" />,
onSelect: () => nav("/users"),
onSelect: () => {
setSettingsView("Users");
nav("/settings");
},
template: false,
}) as OmniItem,
]
@@ -119,11 +119,12 @@ export const ActionComponents: RequiredResourceComponents = {
Actions: {
RunAction: ({ id }) => {
const running = useRead(
"GetActionActionState",
{ action: id },
{ refetchInterval: 5000 }
).data?.running;
const running =
(useRead(
"GetActionActionState",
{ action: id },
{ refetchInterval: 5000 }
).data?.running ?? 0) > 0;
const { mutate, isPending } = useExecute("RunAction");
const action = useAction(id);
if (!action) return null;
@@ -163,6 +163,7 @@ export const BuildConfig = ({
const version_component: ConfigComponent<Types.BuildConfig> = {
label: "Version",
labelHidden: true,
components: {
version: (_version, set) => {
const version =
@@ -173,6 +174,7 @@ export const BuildConfig = ({
<ConfigInput
className="text-lg w-[200px]"
label="Version"
boldLabel
description="Version the image with major.minor.patch. It can be interpolated using [[$VERSION]]."
placeholder="0.0.0"
value={version}
@@ -228,6 +230,8 @@ export const BuildConfig = ({
};
const imageName = (update.image_name ?? config.image_name) || name;
const customTag = update.image_tag ?? config.image_tag;
const customTagPostfix = customTag ? `-${customTag}` : "";
const general_common: ConfigComponent<Types.BuildConfig>[] = [
{
@@ -292,6 +296,29 @@ export const BuildConfig = ({
),
},
},
{
label: "Tagging",
labelHidden: true,
components: {
image_name: {
description: "Push the image under a different name",
placeholder: "Custom image name",
},
image_tag: {
description: `Push a custom tag, plus postfix the other tags (eg ':latest-${customTag ? customTag : "<TAG>"}').`,
placeholder: "Custom image tag",
},
include_latest_tag: {
description: `:latest${customTagPostfix}`,
},
include_version_tags: {
description: `:X.Y.Z${customTagPostfix} + :X.Y${customTagPostfix} + :X${customTagPostfix}`,
},
include_commit_tag: {
description: `:ae8f8ff${customTagPostfix}`,
},
},
},
{
label: "Links",
labelHidden: true,
@@ -314,19 +341,6 @@ export const BuildConfig = ({
];
const advanced: ConfigComponent<Types.BuildConfig>[] = [
{
label: "Tagging",
components: {
image_name: {
description: "Push the image under a different name",
placeholder: "Custom image name",
},
image_tag: {
description: "Postfix the image version with a custom tag.",
placeholder: "Custom image tag",
},
},
},
{
label: "Pre Build",
description:
+25 -12
View File
@@ -439,12 +439,23 @@ export const CopyResource = ({
const nav = useNavigate();
const inv = useInvalidate();
const { mutate } = useWrite(`Copy${type}`, {
onSuccess: (res) => {
const { mutateAsync: copy } = useWrite(`Copy${type}`);
const onConfirm = async () => {
if (!name) return;
try {
const res = await copy({ id, name });
inv([`List${type}s`]);
nav(`/${usableResourcePath(type)}/${res._id?.$oid}`);
},
});
setOpen(false);
} catch (error: any) {
// Keep dialog open for validation errors (409/400), close for system errors
const status = error?.status || error?.response?.status;
if (status !== 409 && status !== 400) {
setOpen(false);
}
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -472,9 +483,8 @@ export const CopyResource = ({
title="Copy"
icon={<Check className="w-4 h-4" />}
disabled={!name}
onClick={() => {
mutate({ id, name });
setOpen(false);
onClick={async () => {
await onConfirm();
}}
/>
</DialogFooter>
@@ -526,10 +536,13 @@ export const NewResource = ({
: {};
const onConfirm = async () => {
if (!name) toast({ title: "Name cannot be empty" });
const id = templateId
? (await copy({ name, id: templateId }))._id?.$oid!
: (await create({ name, config }))._id?.$oid!;
nav(`/${usableResourcePath(type)}/${id}`);
const result = templateId
? await copy({ name, id: templateId })
: await create({ name, config });
const resourceId = result._id?.$oid;
if (resourceId) {
nav(`/${usableResourcePath(type)}/${resourceId}`);
}
};
return (
<NewLayout
@@ -547,7 +560,7 @@ export const NewResource = ({
onKeyDown={(e) => {
if (!name) return;
if (e.key === "Enter") {
onConfirm();
onConfirm().catch(() => {});
}
}}
/>
@@ -1132,6 +1132,7 @@ const TARGET_COMPONENTS: ExecutionConfigs = {
command: undefined,
no_tty: undefined,
no_deps: undefined,
detach: undefined,
service_ports: undefined,
env: undefined,
workdir: undefined,
@@ -1151,6 +1152,7 @@ const TARGET_COMPONENTS: ExecutionConfigs = {
);
const [no_tty, setNoTty] = useState(!!params.no_tty);
const [no_deps, setNoDeps] = useState(!!params.no_deps);
const [detach, setDetach] = useState(!!params.detach);
const [service_ports, setServicePorts] = useState(!!params.service_ports);
const [workdir, setWorkdir] = useState(params.workdir ?? "");
const [user, setUser] = useState(params.user ?? "");
@@ -1175,6 +1177,7 @@ const TARGET_COMPONENTS: ExecutionConfigs = {
);
setNoTty(!!params.no_tty);
setNoDeps(!!params.no_deps);
setDetach(!!params.detach);
setServicePorts(!!params.service_ports);
setWorkdir(params.workdir ?? "");
setUser(params.user ?? "");
@@ -1216,6 +1219,7 @@ const TARGET_COMPONENTS: ExecutionConfigs = {
user: user || undefined,
entrypoint: entrypoint || undefined,
pull: pull ? true : undefined,
detach: detach ? true : undefined,
env,
} as any);
setOpen(false);
@@ -1273,6 +1277,10 @@ const TARGET_COMPONENTS: ExecutionConfigs = {
<Switch checked={no_deps} onCheckedChange={setNoDeps} />
<span className="text-sm">No Dependencies</span>
</label>
<label className="flex items-center gap-2">
<Switch checked={detach} onCheckedChange={setDetach} />
<span className="text-sm">Detach</span>
</label>
<label className="flex items-center gap-2">
<Switch
checked={service_ports}
@@ -7,7 +7,7 @@ import { useExecute, useInvalidate, useRead, useWrite } from "@lib/hooks";
import { file_contents_empty, sync_no_changes } from "@lib/utils";
import { usePermissions } from "@lib/hooks";
import { NotebookPen, RefreshCcw, SquarePlay } from "lucide-react";
import { useFullResourceSync, usePendingView } from ".";
import { useFullResourceSync, useResourceSyncTabsView } from ".";
export const RefreshSync = ({ id }: { id: string }) => {
const inv = useInvalidate();
@@ -34,11 +34,10 @@ export const ExecuteSync = ({ id }: { id: string }) => {
{ refetchInterval: 5000 }
).data?.syncing;
const sync = useFullResourceSync(id);
const [_pendingView] = usePendingView();
const pendingView = sync?.config?.managed ? _pendingView : "Execute";
const { view } = useResourceSyncTabsView(sync);
if (
pendingView === "Commit" ||
view !== "Execute" ||
!sync ||
sync_no_changes(sync) ||
!sync.info?.remote_contents
@@ -73,11 +72,12 @@ export const ExecuteSync = ({ id }: { id: string }) => {
export const CommitSync = ({ id }: { id: string }) => {
const { mutate, isPending } = useWrite("CommitSync");
const sync = useFullResourceSync(id);
const { view } = useResourceSyncTabsView(sync);
const { canWrite } = usePermissions({ type: "ResourceSync", id });
const [_pendingView] = usePendingView();
const pendingView = sync?.config?.managed ? _pendingView : "Execute";
if (pendingView === "Execute" || !canWrite || !sync) return null;
if (view !== "Commit" || !canWrite || !sync) {
return null;
}
const freshSync =
!sync.config?.files_on_host &&
@@ -1,4 +1,4 @@
import { atomWithStorage, useLocalStorage, useRead, useUser } from "@lib/hooks";
import { atomWithStorage, useRead, useUser } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { Card } from "@ui/card";
import { Clock, FolderSync } from "lucide-react";
@@ -45,23 +45,16 @@ const ResourceSyncIcon = ({ id, size }: { id?: string; size: number }) => {
return <FolderSync className={cn(`w-${size} h-${size}`, state && color)} />;
};
const pendingViewAtom = atomWithStorage<"Execute" | "Commit">(
"sync-view-v1",
"Execute"
type ResourceSyncTabsView = "Config" | "Info" | "Execute" | "Commit";
const syncTabsViewAtom = atomWithStorage<ResourceSyncTabsView>(
"sync-tabs-v4",
"Config"
);
export const usePendingView = () => {
return useAtom(pendingViewAtom) as [
"Execute" | "Commit",
(view: "Execute" | "Commit") => void,
];
};
const ConfigInfoPending = ({ id }: { id: string }) => {
const [_view, setView] = useLocalStorage<"Config" | "Info" | "Pending">(
"sync-tabs-v3",
"Config"
);
const sync = useFullResourceSync(id);
export const useResourceSyncTabsView = (
sync: Types.ResourceSync | undefined
) => {
const [_view, setView] = useAtom<ResourceSyncTabsView>(syncTabsViewAtom);
const hideInfo = sync?.config?.files_on_host
? false
@@ -75,13 +68,28 @@ const ConfigInfoPending = ({ id }: { id: string }) => {
const view =
_view === "Info" && hideInfo
? "Config"
: _view === "Pending" && !showPending
: (_view === "Execute" || _view === "Commit") && !showPending
? sync?.config?.files_on_host ||
sync?.config?.repo ||
sync?.config?.linked_repo
? "Info"
: "Config"
: _view;
: _view === "Commit" && !sync?.config?.managed
? "Execute"
: _view;
return {
view,
setView,
hideInfo,
showPending,
};
};
const ConfigInfoPending = ({ id }: { id: string }) => {
const sync = useFullResourceSync(id);
const { view, setView, hideInfo, showPending } =
useResourceSyncTabsView(sync);
const title = (
<TabsList className="justify-start w-fit">
@@ -96,12 +104,21 @@ const ConfigInfoPending = ({ id }: { id: string }) => {
Info
</TabsTrigger>
<TabsTrigger
value="Pending"
value="Execute"
className="w-[110px]"
disabled={!showPending}
>
Pending
Execute
</TabsTrigger>
{sync?.config?.managed && (
<TabsTrigger
value="Commit"
className="w-[110px]"
disabled={!showPending}
>
Commit
</TabsTrigger>
)}
</TabsList>
);
return (
@@ -112,7 +129,10 @@ const ConfigInfoPending = ({ id }: { id: string }) => {
<TabsContent value="Info">
<ResourceSyncInfo id={id} titleOther={title} />
</TabsContent>
<TabsContent value="Pending">
<TabsContent value="Execute">
<ResourceSyncPending id={id} titleOther={title} />
</TabsContent>
<TabsContent value="Commit">
<ResourceSyncPending id={id} titleOther={title} />
</TabsContent>
</Tabs>
@@ -164,7 +184,7 @@ export const ResourceSyncComponents: RequiredResourceComponents = {
},
GroupActions: () => (
<GroupActions type="ResourceSync" actions={["RunSync"]} />
<GroupActions type="ResourceSync" actions={["RunSync", "CommitSync"]} />
),
Table: ({ resources }) => (
@@ -10,8 +10,7 @@ import { cn, sanitizeOnlySpan } from "@lib/utils";
import { ConfirmButton } from "@components/util";
import { SquarePlay } from "lucide-react";
import { usePermissions } from "@lib/hooks";
import { useFullResourceSync, usePendingView } from ".";
import { Tabs, TabsList, TabsTrigger } from "@ui/tabs";
import { useFullResourceSync, useResourceSyncTabsView } from ".";
import { ResourceDiff } from "komodo_client/dist/types";
export const ResourceSyncPending = ({
@@ -24,31 +23,16 @@ export const ResourceSyncPending = ({
const syncing = useRead("GetResourceSyncActionState", { sync: id }).data
?.syncing;
const sync = useFullResourceSync(id);
const { view } = useResourceSyncTabsView(sync);
const { canExecute } = usePermissions({ type: "ResourceSync", id });
const [_pendingView, setPendingView] = usePendingView();
const pendingView = sync?.config?.managed ? _pendingView : "Execute";
const { mutate, isPending } = useExecute("RunSync");
const loading = isPending || syncing;
return (
<Section
titleOther={titleOther}
>
<div className="flex items-center gap-4 py-2 flex-wrap">
{sync?.config?.managed && (
<Tabs value={pendingView} onValueChange={setPendingView as any}>
<TabsList className="justify-start w-fit">
<TabsTrigger value="Execute" className="w-[110px]">
Execute
</TabsTrigger>
<TabsTrigger value="Commit" className="w-[110px]">
Commit
</TabsTrigger>
</TabsList>
</Tabs>
)}
<div className="text-muted-foreground">{pendingView} Mode:</div>
<Section titleOther={titleOther} className="min-h-[500px]">
<div className="flex items-center gap-4 pl-1 py-2 flex-wrap">
<div className="text-muted-foreground">{view} Mode:</div>
<div className="flex items-center gap-1 flex-wrap">
{pendingView === "Execute" && (
{view === "Execute" && (
<>
Update resources in the
<div className="font-bold">UI</div>
@@ -56,7 +40,7 @@ export const ResourceSyncPending = ({
<div className="font-bold">file changes.</div>
</>
)}
{pendingView === "Commit" && (
{view === "Commit" && (
<>
Update resources in the
<div className="font-bold">file</div>
@@ -89,7 +73,7 @@ export const ResourceSyncPending = ({
) : undefined}
{/* Pending Deploy */}
{pendingView === "Execute" && sync?.info?.pending_deploy?.to_deploy ? (
{view === "Execute" && sync?.info?.pending_deploy?.to_deploy ? (
<Card>
<CardHeader
className={cn(
@@ -118,13 +102,10 @@ export const ResourceSyncPending = ({
<div className="flex items-center gap-4 font-mono">
<div
className={text_color_class_by_intention(
diff_type_intention(
update.data.type,
pendingView === "Commit"
)
diff_type_intention(update.data.type, view === "Commit")
)}
>
{pendingView === "Commit"
{view === "Commit"
? reverse_pending_type(update.data.type)
: update.data.type}{" "}
{update.target.type}
@@ -139,7 +120,7 @@ export const ResourceSyncPending = ({
/>
)}
</div>
{canExecute && pendingView === "Execute" && (
{canExecute && view === "Execute" && (
<ConfirmButton
title="Execute Change"
icon={<SquarePlay className="w-4 h-4" />}
@@ -168,7 +149,7 @@ export const ResourceSyncPending = ({
)}
{update.data.type === "Update" && (
<>
{pendingView === "Execute" && (
{view === "Execute" && (
<MonacoDiffEditor
original={update.data.data.current}
modified={update.data.data.proposed}
@@ -176,7 +157,7 @@ export const ResourceSyncPending = ({
readOnly
/>
)}
{pendingView === "Commit" && (
{view === "Commit" && (
<MonacoDiffEditor
original={update.data.data.proposed}
modified={update.data.data.current}
@@ -205,13 +186,11 @@ export const ResourceSyncPending = ({
className={cn(
"font-mono pb-2",
text_color_class_by_intention(
diff_type_intention(data.type, pendingView === "Commit")
diff_type_intention(data.type, view === "Commit")
)
)}
>
{pendingView === "Commit"
? reverse_pending_type(data.type)
: data.type}{" "}
{view === "Commit" ? reverse_pending_type(data.type) : data.type}{" "}
Variable
</CardHeader>
<CardContent>
@@ -224,7 +203,7 @@ export const ResourceSyncPending = ({
)}
{data.type === "Update" && (
<>
{pendingView === "Execute" && (
{view === "Execute" && (
<MonacoDiffEditor
original={data.data.current}
modified={data.data.proposed}
@@ -232,7 +211,7 @@ export const ResourceSyncPending = ({
readOnly
/>
)}
{pendingView === "Commit" && (
{view === "Commit" && (
<MonacoDiffEditor
original={data.data.proposed}
modified={data.data.current}
@@ -261,13 +240,11 @@ export const ResourceSyncPending = ({
className={cn(
"font-mono pb-2",
text_color_class_by_intention(
diff_type_intention(data.type, pendingView === "Commit")
diff_type_intention(data.type, view === "Commit")
)
)}
>
{pendingView === "Commit"
? reverse_pending_type(data.type)
: data.type}{" "}
{view === "Commit" ? reverse_pending_type(data.type) : data.type}{" "}
User Group
</CardHeader>
<CardContent>
@@ -280,7 +257,7 @@ export const ResourceSyncPending = ({
)}
{data.type === "Update" && (
<>
{pendingView === "Execute" && (
{view === "Execute" && (
<MonacoDiffEditor
original={data.data.current}
modified={data.data.proposed}
@@ -288,7 +265,7 @@ export const ResourceSyncPending = ({
readOnly
/>
)}
{pendingView === "Commit" && (
{view === "Commit" && (
<MonacoDiffEditor
original={data.data.proposed}
modified={data.data.current}
@@ -44,6 +44,12 @@ export const useServer = (id?: string) =>
(d) => d.id === id
);
// Helper function to check if server is available for API calls
export const useIsServerAvailable = (serverId?: string) => {
const server = useServer(serverId);
return server?.info.state === Types.ServerState.Ok;
};
export const useFullServer = (id: string) =>
useRead("GetServer", { server: id }, { refetchInterval: 10_000 }).data;
@@ -51,24 +57,26 @@ export const useFullServer = (id: string) =>
export const useVersionMismatch = (serverId?: string) => {
const core_version = useRead("GetVersion", {}).data?.version;
const server_version = useServer(serverId)?.info.version;
const unknown = !server_version || server_version === "Unknown";
const mismatch = !!server_version && !!core_version && server_version !== core_version;
const mismatch =
!!server_version && !!core_version && server_version !== core_version;
return { unknown, mismatch, hasVersionMismatch: mismatch && !unknown };
};
const Icon = ({ id, size }: { id?: string; size: number }) => {
const state = useServer(id)?.info.state;
const { hasVersionMismatch } = useVersionMismatch(id);
return (
<Server
className={cn(
`w-${size} h-${size}`,
state && stroke_color_class_by_intention(
server_state_intention(state, hasVersionMismatch)
)
state &&
stroke_color_class_by_intention(
server_state_intention(state, hasVersionMismatch)
)
)}
/>
);
@@ -137,10 +145,7 @@ const ConfigTabs = ({ id }: { id: string }) => {
</TabsList>
);
return (
<Tabs
value={currentView}
onValueChange={setView as any}
>
<Tabs value={currentView} onValueChange={setView as any}>
<TabsContent value="Config">
<ServerConfig id={id} titleOther={tabsList} />
</TabsContent>
@@ -221,10 +226,10 @@ export const ServerVersion = ({ id }: { id: string }) => {
const core_version = useRead("GetVersion", {}).data?.version;
const version = useServer(id)?.info.version;
const server_state = useServer(id)?.info.state;
const unknown = !version || version === "Unknown";
const mismatch = !!version && !!core_version && version !== core_version;
// Don't show version for disabled servers
if (server_state === Types.ServerState.Disabled) {
return (
@@ -242,13 +247,14 @@ export const ServerVersion = ({ id }: { id: string }) => {
</TooltipTrigger>
<TooltipContent>
<div>
Server is <span className="font-bold">disabled</span> - version unknown.
Server is <span className="font-bold">disabled</span> - version
unknown.
</div>
</TooltipContent>
</Tooltip>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
@@ -306,7 +312,11 @@ export const ServerComponents: RequiredResourceComponents = {
),
Dashboard: () => {
const summary = useRead("GetServersSummary", {}, { refetchInterval: 15_000 }).data;
const summary = useRead(
"GetServersSummary",
{},
{ refetchInterval: 15_000 }
).data;
return (
<DashboardPieChart
data={[
@@ -363,18 +373,19 @@ export const ServerComponents: RequiredResourceComponents = {
State: ({ id }) => {
const state = useServer(id)?.info.state;
const { hasVersionMismatch } = useVersionMismatch(id);
// Show full version mismatch text
const displayState = state === Types.ServerState.Ok && hasVersionMismatch
? "Version Mismatch"
: state === Types.ServerState.NotOk
? "Not Ok"
: state;
const displayState =
state === Types.ServerState.Ok && hasVersionMismatch
? "Version Mismatch"
: state === Types.ServerState.NotOk
? "Not Ok"
: state;
return (
<StatusBadge
text={displayState}
intent={server_state_intention(state, hasVersionMismatch)}
<StatusBadge
text={displayState}
intent={server_state_intention(state, hasVersionMismatch)}
/>
);
},
@@ -384,13 +395,13 @@ export const ServerComponents: RequiredResourceComponents = {
Info: {
Version: ServerVersion,
Cpu: ({ id }) => {
const server = useServer(id);
const isServerAvailable = useIsServerAvailable(id);
const core_count =
useRead(
"GetSystemInformation",
{ server: id },
{
enabled: server ? server.info.state !== "Disabled" : false,
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data?.core_count ?? 0;
@@ -401,13 +412,34 @@ export const ServerComponents: RequiredResourceComponents = {
</div>
);
},
Mem: ({ id }) => {
const server = useServer(id);
LoadAvg: ({ id }) => {
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: server ? server.info.state !== "Disabled" : false,
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
if (!stats?.load_average) return null;
const one = stats.load_average?.one;
return (
<div className="flex gap-2 items-center">
<Cpu className="w-4 h-4" />
{one.toFixed(2)}
</div>
);
},
Mem: ({ id }) => {
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
@@ -419,12 +451,12 @@ export const ServerComponents: RequiredResourceComponents = {
);
},
Disk: ({ id }) => {
const server = useServer(id);
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: server ? server.info.state !== "Disabled" : false,
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
@@ -595,11 +627,12 @@ export const ServerComponents: RequiredResourceComponents = {
const { hasVersionMismatch } = useVersionMismatch(id);
// Determine display state for header (longer text is okay in header)
const displayState = server?.info.state === Types.ServerState.Ok && hasVersionMismatch
? "Version Mismatch"
: server?.info.state === Types.ServerState.NotOk
? "Not Ok"
: server?.info.state;
const displayState =
server?.info.state === Types.ServerState.Ok && hasVersionMismatch
? "Version Mismatch"
: server?.info.state === Types.ServerState.NotOk
? "Not Ok"
: server?.info.state;
return (
<ResourcePageHeader
@@ -0,0 +1,199 @@
import { ResourceLink } from "@components/resources/common";
import { ServerComponents } from "@components/resources/server";
import { DataTable, SortableHeader } from "@ui/data-table";
import { useRead } from "@lib/hooks";
import { useMemo } from "react";
import { useIsServerAvailable } from ".";
export const ServerMonitoringTable = ({ search = "" }: { search?: string }) => {
const servers = useRead("ListServers", {}).data;
const searchSplit = useMemo(
() => search.toLowerCase().split(" ").filter((t) => t),
[search]
);
const filtered = useMemo(
() =>
servers?.filter((s) =>
searchSplit.length === 0
? true
: searchSplit.every((t) => s.name.toLowerCase().includes(t))
) ?? [],
[servers, searchSplit]
);
return (
<div className="flex flex-col gap-4">
<DataTable<any, any>
tableKey="servers-monitoring-v1"
data={filtered}
columns={[
{
accessorKey: "name",
size: 250,
header: ({ column }) => (
<SortableHeader column={column} title="System" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<ResourceLink type="Server" id={row.original.id} />
</div>
),
},
{
header: "CPU",
size: 200,
cell: ({ row }) => <CpuCell id={row.original.id} />,
},
{
header: "Memory",
size: 200,
cell: ({ row }) => <MemCell id={row.original.id} />,
},
{
header: "Disk",
size: 200,
cell: ({ row }) => <DiskCell id={row.original.id} />,
},
{
header: "Load Avg",
size: 160,
cell: ({ row }) => <LoadAvgCell id={row.original.id} />,
},
{
header: "Net",
size: 100,
cell: ({ row }) => <NetCell id={row.original.id} />,
},
{
header: "Agent",
size: 160,
cell: ({ row }) => <ServerComponents.Info.Version id={row.original.id} />,
},
]}
/>
</div>
);
};
const useStats = (id: string) => {
const isServerAvailable = useIsServerAvailable(id);
return useRead("GetSystemStats", { server: id }, {
enabled: isServerAvailable,
refetchInterval: 10_000
}).data;
};
const useServerThresholds = (id: string) => {
const isServerAvailable = useIsServerAvailable(id);
const config = useRead("GetServer", { server: id }, {
enabled: isServerAvailable
}).data?.config as any;
return {
cpuWarning: config?.cpu_warning ?? 75,
cpuCritical: config?.cpu_critical ?? 90,
memWarning: config?.mem_warning ?? 75,
memCritical: config?.mem_critical ?? 90,
diskWarning: config?.disk_warning ?? 75,
diskCritical: config?.disk_critical ?? 90,
};
};
const Bar = ({ valuePerc, intent }: { valuePerc?: number; intent: "Good" | "Warning" | "Critical" }) => {
const w = Math.max(0, Math.min(100, valuePerc ?? 0)) / 100;
const color = intent === "Good" ? "bg-green-500" : intent === "Warning" ? "bg-orange-500" : "bg-red-500";
return (
<span className="grow min-w-8 block bg-muted h-[1em] relative rounded-sm overflow-hidden">
<span className={`absolute inset-0 w-full h-full origin-left ${color}`} style={{ transform: `scaleX(${w})` }} />
</span>
);
};
const CpuCell = ({ id }: { id: string }) => {
const stats = useStats(id);
const cpu = stats?.cpu_perc ?? 0;
const { cpuWarning: warning, cpuCritical: critical } = useServerThresholds(id);
const intent: "Good" | "Warning" | "Critical" =
cpu < warning ? "Good" : cpu < critical ? "Warning" : "Critical";
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-8">{cpu.toFixed(2)}%</span>
<Bar valuePerc={cpu} intent={intent} />
</div>
);
};
const MemCell = ({ id }: { id: string }) => {
const stats = useStats(id);
const used = stats?.mem_used_gb ?? 0;
const total = stats?.mem_total_gb ?? 0;
const perc = total > 0 ? (used / total) * 100 : 0;
const { memWarning: warning, memCritical: critical } = useServerThresholds(id);
const intent: "Good" | "Warning" | "Critical" =
perc < warning ? "Good" : perc < critical ? "Warning" : "Critical";
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-8">{perc.toFixed(1)}%</span>
<Bar valuePerc={perc} intent={intent} />
</div>
);
};
const DiskCell = ({ id }: { id: string }) => {
const stats = useStats(id);
const used = stats?.disks?.reduce((acc, d) => acc + (d.used_gb || 0), 0) ?? 0;
const total = stats?.disks?.reduce((acc, d) => acc + (d.total_gb || 0), 0) ?? 0;
const perc = total > 0 ? (used / total) * 100 : 0;
const { diskWarning: warning, diskCritical: critical } = useServerThresholds(id);
const intent: "Good" | "Warning" | "Critical" =
perc < warning ? "Good" : perc < critical ? "Warning" : "Critical";
return (
<div className="flex gap-2 items-center tabular-nums tracking-tight">
<span className="min-w-8">{perc.toFixed(1)}%</span>
<Bar valuePerc={perc} intent={intent} />
</div>
);
};
const formatRate = (bytes?: number) => {
const b = bytes ?? 0;
const kb = 1024;
const mb = kb * 1024;
const gb = mb * 1024;
if (b >= gb) return `${(b / gb).toFixed(2)} GB/s`;
if (b >= mb) return `${(b / mb).toFixed(2)} MB/s`;
if (b >= kb) return `${(b / kb).toFixed(2)} KB/s`;
return `${b.toFixed(0)} B/s`;
};
const NetCell = ({ id }: { id: string }) => {
const stats = useStats(id);
const ingress = stats?.network_ingress_bytes ?? 0;
const egress = stats?.network_egress_bytes ?? 0;
return (
<span className="tabular-nums whitespace-nowrap">
{formatRate(ingress + egress)}
</span>
);
};
const LoadAvgCell = ({ id }: { id: string }) => {
const stats = useStats(id);
const one = stats?.load_average?.one;
const five = stats?.load_average?.five;
const fifteen = stats?.load_average?.fifteen;
if (one === undefined || five === undefined || fifteen === undefined) {
return (
<div className="w-full flex items-center gap-[.35em] tabular-nums text-muted-foreground tracking-tight">
<span>N/A</span>
</div>
);
}
return (
<div className="w-full flex items-center gap-[.35em] tabular-nums tracking-tight">
<span>{one.toFixed(2)}</span>
<span>{five.toFixed(2)}</span>
<span>{fifteen.toFixed(2)}</span>
</div>
);
};
@@ -3,13 +3,19 @@ import { useRead } from "@lib/hooks";
import { Types } from "komodo_client";
import { useMemo } from "react";
import { useStatsGranularity } from "./hooks";
import { Loader2 } from "lucide-react";
import { Loader2, OctagonAlert } from "lucide-react";
import { AxisOptions, Chart } from "react-charts";
import { convertTsMsToLocalUnixTsInMs } from "@lib/utils";
import { useTheme } from "@ui/theme";
import { fmt_utc_date } from "@lib/formatting";
type StatType = "Cpu" | "Memory" | "Disk" | "Network Ingress" | "Network Egress";
type StatType =
| "Cpu"
| "Memory"
| "Disk"
| "Network Ingress"
| "Network Egress"
| "Load Average";
type StatDatapoint = { date: number; value: number };
@@ -29,18 +35,34 @@ export const StatChart = ({
granularity,
});
const stats = useMemo(
() =>
data?.stats
.map((stat) => {
return {
date: convertTsMsToLocalUnixTsInMs(stat.ts),
value: getStat(stat, type),
};
})
.reverse(),
[data]
);
const seriesData = useMemo(() => {
if (!data?.stats) return [] as { label: string; data: StatDatapoint[] }[];
const records = [...data.stats].reverse();
if (type === "Load Average") {
const one = records.map((s) => ({
date: convertTsMsToLocalUnixTsInMs(s.ts),
value: s.load_average?.one ?? 0,
}));
const five = records.map((s) => ({
date: convertTsMsToLocalUnixTsInMs(s.ts),
value: s.load_average?.five ?? 0,
}));
const fifteen = records.map((s) => ({
date: convertTsMsToLocalUnixTsInMs(s.ts),
value: s.load_average?.fifteen ?? 0,
}));
return [
{ label: "1m", data: one },
{ label: "5m", data: five },
{ label: "15m", data: fifteen },
];
}
const single = records.map((stat) => ({
date: convertTsMsToLocalUnixTsInMs(stat.ts),
value: getStat(stat, type),
}));
return [{ label: type, data: single }];
}, [data, type]);
return (
<div className={className}>
@@ -50,8 +72,11 @@ export const StatChart = ({
<Loader2 className="w-8 h-8 animate-spin" />
</div>
) : (
stats &&
stats.length > 0 && <InnerStatChart type={type} stats={stats} />
<InnerStatChart
type={type}
stats={seriesData.flatMap((s) => s.data)}
seriesData={seriesData}
/>
)}
</div>
);
@@ -64,17 +89,13 @@ const BYTES_PER_KB = 1024.0;
export const InnerStatChart = ({
type,
stats,
seriesData,
}: {
type: StatType;
stats: StatDatapoint[] | undefined;
seriesData?: { label: string; data: StatDatapoint[] }[];
}) => {
const { theme: _theme } = useTheme();
const theme =
_theme === "system"
? window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light"
: _theme;
const { currentTheme } = useTheme();
const min = stats?.[0]?.date ?? 0;
const max = stats?.[stats.length - 1]?.date ?? 0;
@@ -96,10 +117,13 @@ export const InnerStatChart = ({
cursor: (_value?: Date) => false,
},
};
}, []);
}, [min, max, diff]);
// Determine the dynamic scaling for network-related types
const maxStatValue = Math.max(...(stats?.map((d) => d.value) ?? [0]));
const allValues = (seriesData ?? [{ data: stats ?? [] }]).flatMap((s) =>
s.data.map((d) => d.value)
);
const maxStatValue = Math.max(...(allValues.length ? allValues : [0]));
const { unit, maxUnitValue } = useMemo(() => {
if (type === "Network Ingress" || type === "Network Egress") {
@@ -113,6 +137,13 @@ export const InnerStatChart = ({
return { unit: "TB", maxUnitValue: BYTES_PER_GB * 1024 }; // Larger scale for high values
}
}
if (type === "Load Average") {
// Leave unitless; set max slightly above observed
return {
unit: "",
maxUnitValue: maxStatValue === 0 ? 1 : maxStatValue * 1.2,
};
}
return { unit: "", maxUnitValue: 100 }; // Default for CPU, memory, disk
}, [type, maxStatValue]);
@@ -120,7 +151,8 @@ export const InnerStatChart = ({
(): AxisOptions<StatDatapoint>[] => [
{
getValue: (datum) => datum.value,
elementType: "area",
elementType: type === "Load Average" ? "line" : "area",
stacked: type !== "Load Average",
min: 0,
max: maxUnitValue,
formatters: {
@@ -128,7 +160,9 @@ export const InnerStatChart = ({
<div className="text-lg font-mono">
{(type === "Network Ingress" || type === "Network Egress") && unit
? `${(value ?? 0) / (maxUnitValue / 1024)} ${unit}`
: `${value?.toFixed(2)}%`}
: type === "Load Average"
? `${(value ?? 0).toFixed(2)}`
: `${value?.toFixed(2)}%`}
</div>
),
},
@@ -136,19 +170,31 @@ export const InnerStatChart = ({
],
[type, maxUnitValue, unit]
);
if ((seriesData?.[0]?.data.length ?? 0) < 2) {
return (
<div className="w-full h-full flex gap-4 justify-center items-center">
<OctagonAlert className="w-6 h-6" />
<h1>Not enough data yet, choose a smaller interval.</h1>
</div>
);
}
return (
<Chart
options={{
data: [
{
label: type,
data: stats ?? [],
},
],
data: seriesData ?? [{ label: type, data: stats ?? [] }],
primaryAxis: timeAxis,
secondaryAxes: valueAxis,
defaultColors: [getColor(type)],
dark: theme === "dark",
defaultColors:
type === "Load Average"
? [
hex_color_by_intention("Good"),
hex_color_by_intention("Neutral"),
hex_color_by_intention("Unknown"),
]
: [getColor(type)],
dark: currentTheme === "dark",
padding: {
left: 10,
right: 10,
@@ -1,13 +1,7 @@
import { Section } from "@components/layouts";
import { Card, CardContent, CardHeader, CardTitle } from "@ui/card";
import { Progress } from "@ui/progress";
import {
Cpu,
Database,
Loader2,
MemoryStick,
Search,
} from "lucide-react";
import { Cpu, Database, Loader2, MemoryStick, Search } from "lucide-react";
import { useLocalStorage, usePermissions, useRead } from "@lib/hooks";
import { Types } from "komodo_client";
import { DataTable, SortableHeader } from "@ui/data-table";
@@ -24,6 +18,7 @@ import {
} from "@ui/select";
import { DockerResourceLink, ShowHideButton } from "@components/util";
import { filterBySplit } from "@lib/utils";
import { useIsServerAvailable } from ".";
export const ServerStats = ({
id,
@@ -35,17 +30,23 @@ export const ServerStats = ({
const [interval, setInterval] = useStatsGranularity();
const { specific } = usePermissions({ type: "Server", id });
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{ refetchInterval: 10_000 }
{
enabled: isServerAvailable,
refetchInterval: 10_000
}
).data;
const info = useRead("GetSystemInformation", { server: id }).data;
const info = useRead("GetSystemInformation", { server: id }, { enabled: isServerAvailable }).data;
// Get all the containers with stats
const containers = useRead("ListDockerContainers", {
server: id,
}, {
enabled: isServerAvailable
}).data?.filter((c) => c.stats);
const [showContainers, setShowContainers] = useLocalStorage(
"stats-show-container-table-v1",
@@ -120,6 +121,7 @@ export const ServerStats = ({
{/* Current Overview */}
<Section title="Current">
<div className="flex flex-col xl:flex-row gap-4">
<LOAD_AVERAGE id={id} stats={stats} />
<CPU stats={stats} />
<RAM stats={stats} />
<DISK stats={stats} />
@@ -335,6 +337,11 @@ export const ServerStats = ({
}
>
<div className="flex flex-col gap-8">
<StatChart
server_id={id}
type="Load Average"
className="w-full h-[250px]"
/>
<StatChart server_id={id} type="Cpu" className="w-full h-[250px]" />
<StatChart
server_id={id}
@@ -495,6 +502,82 @@ const CPU = ({ stats }: { stats: Types.SystemStats | undefined }) => {
);
};
const LOAD_AVERAGE = ({
id,
stats,
}: {
id: string;
stats: Types.SystemStats | undefined;
}) => {
if (!stats?.load_average) return null;
const { one = 0, five = 0, fifteen = 0 } = stats.load_average || {};
const isServerAvailable = useIsServerAvailable(id);
const cores = useRead("GetSystemInformation", { server: id }, { enabled: isServerAvailable }).data?.core_count;
const pct = (load: number) =>
cores && cores > 0 ? Math.min((load / cores) * 100, 100) : undefined;
const textColor = (load: number) => {
const p = pct(load);
if (p === undefined) return "text-muted-foreground";
return p <= 50
? "text-green-600"
: p <= 80
? "text-yellow-600"
: "text-red-600";
};
return (
<Card className="w-full">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle>Load Average</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Current Load */}
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<span
className={`text-3xl font-bold tabular-nums ${textColor(one)}`}
>
{one.toFixed(2)}
</span>
<span className="text-sm text-muted-foreground">
{cores && cores > 0
? `${(pct(one) ?? 0).toFixed(0)}% of ${cores} cores`
: "N/A"}
</span>
</div>
<Progress value={pct(one) ?? 0} className="h-2" />
</div>
{/* Time Intervals */}
<div className="space-y-3">
<div className="grid grid-cols-3 gap-4 text-sm">
{[
["1m", one],
["5m", five],
["15m", fifteen],
].map(([label, value]) => (
<div className="space-y-1" key={label as string}>
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{label}</span>
<span
className={`font-medium tabular-nums ${textColor(value as number)}`}
>
{(value as number).toFixed(2)}
</span>
</div>
<Progress value={pct(value as number) ?? 0} className="h-1" />
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
};
const RAM = ({ stats }: { stats: Types.SystemStats | undefined }) => {
const used = stats?.mem_used_gb;
const total = stats?.mem_total_gb;

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