Compare commits

..

83 Commits

Author SHA1 Message Date
mbecker20
655a149f48 Update deployments description 2026-03-15 23:33:33 -07:00
mbecker20
3867305a2e example execute terminal uses bash 2026-03-15 17:21:36 -07:00
mbecker20
4aebd5ed92 fix confirm update showing entries which have not changed 2026-03-15 16:44:51 -07:00
mbecker20
a9352ad90b improve deployment network, restart, termination signal config 2026-03-15 15:26:47 -07:00
mbecker20
37bd221609 improve procedure config UI including run stack service 2026-03-15 15:20:26 -07:00
mbecker20
bf5975bb93 fix repo Links config 2026-03-15 01:36:18 -07:00
mbecker20
d7706a32c1 Fix Pull repo 2026-03-14 17:34:35 -07:00
mbecker20
dea9c49772 add missing Repo header Info 2026-03-14 17:15:12 -07:00
mbecker20
95666e69d8 sidebar more compact 2026-03-13 04:17:20 -07:00
mbecker20
cbc008fc89 improve tabs 2026-03-13 04:02:19 -07:00
mbecker20
445fa42a01 improve batch execs 2026-03-13 03:05:19 -07:00
mbecker20
2c2b0eda8f better api key create 2026-03-13 02:25:06 -07:00
mbecker20
15eefa3385 Fix api key modal too thin 2026-03-13 01:49:20 -07:00
mbecker20
cb9bc80346 confirm modal better responsiveness 2026-03-13 01:36:40 -07:00
mbecker20
8c682c091f better responsive confirm save width 2026-03-13 01:17:23 -07:00
mbecker20
ca021a3979 km cli needs to install crypto provider for tls ws 2026-03-10 16:24:51 -07:00
mbecker20
1480c3b020 better maxheight for mobile friendly tabs 2026-03-09 03:13:40 -07:00
mbecker20
42f452fdda rename resource invalidates 2026-03-08 22:38:41 -07:00
mbecker20
8ded07dfe5 improve mobile friendly tabs width, and onboardng key copy 2026-03-08 21:53:00 -07:00
mbecker20
d51c1cb1e7 fix init admin user 2026-03-08 07:48:20 -07:00
mbecker20
b5f184b286 proper base64url decode 2026-03-05 11:37:15 -08:00
mbecker20
294ef20019 deploy 2.0.0-dev-124 2026-03-05 11:28:54 -08:00
mbecker20
3efcaa0740 confirm action / save modals don't need ConfirmButton 2026-03-05 11:06:03 -08:00
mbecker20
b5b680dd1d Deploy / DeployStack updates invalidate corresponding list query 2026-03-05 10:40:19 -08:00
mbecker20
bf5ac8b6ac deploy 2.0.0-dev-123 2026-03-05 10:32:39 -08:00
mbecker20
dcccc878c8 mogh auth 1.2.11 2026-03-05 10:27:37 -08:00
mbecker20
1c87fba8f5 mogh auth server 1.2.10 2026-03-05 10:04:25 -08:00
mbecker20
2fc35b3c2d debug level core <> periphery auth identifiers logs 2026-03-05 00:10:30 -08:00
mbecker20
2012fd1dd9 deploy 2.0.0-dev-122 2026-03-04 19:37:13 -08:00
mbecker20
550c0339d6 read request are trace 2026-03-04 19:36:34 -08:00
mbecker20
50cf2f2d50 fmt 2026-03-04 16:31:13 -08:00
mbecker20
b223fefec6 notification green contents written success 2026-03-04 16:19:44 -08:00
mbecker20
7fe56f72ae setters use maps instead of mutations 2026-03-04 16:14:57 -08:00
mbecker20
0a9bc397ca config sidebar save 2026-03-04 15:58:09 -08:00
mbecker20
a03fceba7f thinner topbar 2026-03-04 10:46:45 -08:00
mbecker20
1bf1574c2a rename for clarity 2026-03-03 12:40:25 -08:00
mbecker20
83d90e0a16 improve disk usage hover card styling 2026-03-02 20:39:50 -08:00
mbecker20
304ffbf01d Add hoverable disk info 2026-03-02 20:32:34 -08:00
mbecker20
07787d6fa1 "never" -> "Never" 2026-03-02 19:59:30 -08:00
mbecker20
5d04142a99 fix sidebar margin right 2026-02-27 14:21:15 -08:00
mbecker20
aecba3be9f refine lg screen size view 2026-02-27 14:19:28 -08:00
mbecker20
4c4e5b62e0 taller data table and blue omnisearch 2026-02-27 13:10:46 -08:00
mbecker20
62f0ca9093 tweaks for lg size 2026-02-27 03:39:01 -08:00
mbecker20
3d43e2419f Update / Alert table filter selector formatted 2026-02-27 02:06:35 -08:00
mbecker20
d4081c2d6b single delete terminal 2026-02-26 18:52:39 -08:00
mbecker20
d397cb4ea4 fix terminal height - same as logs 2026-02-26 18:35:03 -08:00
mbecker20
0c96e24cd4 fix some wrapping stuff in tables and tag text disappear 2026-02-26 18:15:39 -08:00
mbecker20
1c90e768ef fix login github / google icon color 2026-02-26 16:55:01 -08:00
mbecker20
be73f20fd5 improve login styling 2026-02-26 16:47:15 -08:00
mbecker20
330178dbb8 standard api key modal size 2026-02-26 16:30:03 -08:00
mbecker20
c8c01307a0 improve profile delete styling 2026-02-26 16:27:42 -08:00
mbecker20
2e80adff2d deploy 2.0.0-dev-121 2026-02-26 16:13:04 -08:00
mbecker20
ef5a0982cb post link redirect should be to profile 2026-02-26 15:56:37 -08:00
mbecker20
9ffa40022d topbar user dropdown shows user avatar if available 2026-02-26 15:49:03 -08:00
mbecker20
15eaeab68d deploy 2.0.0-dev-120 2026-02-26 15:06:15 -08:00
mbecker20
89ea5ad5a4 stack and deployment state color include update available. Ensure server version mismatch color applied everywhere 2026-02-26 15:02:37 -08:00
mbecker20
c05dde3678 add close button to update / alert details drawer 2026-02-26 14:43:24 -08:00
mbecker20
a17a1005c7 smaller more consistent gaps 2026-02-26 14:38:36 -08:00
mbecker20
d13314eb98 stack stopped and deployment exited warning instead of critical 2026-02-26 14:25:31 -08:00
mbecker20
f41a61116a fix batch executions width 2026-02-26 14:24:58 -08:00
mbecker20
13fbcae105 edit swarm join command 2026-02-26 14:09:01 -08:00
mbecker20
71c437962f ensure section headers consistent spacing 2026-02-26 14:06:57 -08:00
mbecker20
e4147ccdaf rename ConfigSwitch onChange -> onCheckedChange 2026-02-26 13:41:51 -08:00
mbecker20
5089375211 improve tab styling 2026-02-26 13:38:07 -08:00
mbecker20
e489812af4 Fix config input issue 2026-02-26 13:37:32 -08:00
mbecker20
7373ff8d0e execution buttons disabled when loading 2026-02-26 12:53:58 -08:00
mbecker20
4e1cd32f3f Select Template 2026-02-25 19:31:28 -08:00
mbecker20
57211746f1 hide actions when none 2026-02-25 19:21:48 -08:00
mbecker20
5e153fb02b dashboard active styling 2026-02-25 19:21:22 -08:00
mbecker20
b28a413005 procedure / action webhook branch mobile style 2026-02-25 17:46:22 -08:00
mbecker20
691671abbd Load Average is first historical stat 2026-02-25 17:43:54 -08:00
mbecker20
504e81c2f7 improve login no auth configured and passkey pending 2026-02-25 17:36:02 -08:00
mbecker20
0a479a0f4a bump chef rust version 2026-02-25 16:08:55 -08:00
mbecker20
6a05779ceb deploy 2.0.0-dev-118 2026-02-25 16:07:05 -08:00
mbecker20
acd27ba058 bump rust version to 1.93.1 and dep versions 2026-02-25 16:06:37 -08:00
Maxwell Becker
96c4ae9fc5 2.0.0 UI (#1220)
* new ui using mantine

* resources page

* prog on resource page

* resources and resource layouts

* confirm button and modal

* tweaks

* update details

* topbar updates

* add skeletons for resource implementations

* add resource tables

* add tags to recents cards

* resource page table scrolling

* table component + tags filter

* export toml

* New Resource button

* Fix update details capture closing

* tweaks

* omni search

* refine config

* config tweaks

* implement more configs / resource selector

* add profile page

* provider / account selectors

* container table page

* build config

* deployment config

* fix deployment build version selector

* fix secrets selector

* resource sync config

* mobile topbar and updates

* update details fz sm

* stack config

* terminals page

* create terminal in prog

* create terminal menu

* finish create terminal menu

* terminal pages working

* stack tabs / info

* add executions

* add server header info

* confirm pubkey modal

* improve resource header styling

* FileSource component

* stack service table, move icons.ts

* basic procedure config

* tweak procedure config

* container / image pages

* network / volume pages

* clean up docker resource pages

*  basic log / terminal ui

* reusable log section

* styling

* clean up resource components

* delete in resource header

* log auto select stderr

* fix some bgs

* stack logs with service selector

* stack terminals

* add deployment executions

* use correct icon

* useResource hooks

* build info

* build info

* tweaks

* server tabs

* fix terminal section target

* prog on server tabs

* server stats

* light theme

* start on historical stats

* stack service page

* resource sync tabs

* sync tabs

* more topbar icons

* add settings basic

* add topbar alerts

* tweak stream selector behavior

* tweak alert icon topbar

* improve styling smaller screen

* schedules page and other progress

* onboarding keys

* improve schedule page descriptions

* improve update notifications

* schedule timezone selector

* tag color selector

* finish settings / providers

* use shared-text-update component so settings tables aren't janky

* updates page

* refine updates page

* alert page

* standardize borders

* theme and swarm

* swarm tabs

* swarm node page

* swarm config page

* swarm pages

* swarm task and secret pages

* swarm stack page

* fix stack log service selector in swarm mode

* standard inspect section

* swarm inspect tab

* server and swarm resources tab

* add disable confirm dialog (modal) option for executions

* stack update available indicator

* deployment update available

* add template switch to resource headers

* ResourceHeader + rename

* set editing name onclick

* repo tabs

* server stats table

* refine a bit

* refine deployment / stack header info

* show server stats dashboard. dashboard tables

* action last run in config

* SettingsUsers page

* user page etc

* manage api key

* user base permissions

* color the table multi select

* user group page

* UserAddUserGroup

* active includes deployments / stacks

* improve small screen view

* fix docker pages execution showing

* clean up

* rename frontend to UI

* align profile page styling

* config maintenance windows

* finish maintenance windows

* builder config

* add batch execute dropdown / confirm menu

* batch execute styling

* deploy 2.0.0-dev-117

* improve stats card light theme

* add update page

* improve mobile

* terminal group nowrap

* mobile improvements

* allow unused again

* improve mobile font sizing

* improve mobile updates / alerts

* mobile tabs

* alert page

* add server version mismatch color

* new resource, clearable selector

* Fix build show info tab

* copy resources

* keyboard shortcuts

* server resource header version mismatch

* fix type errors

* container page server multi select

* confirm button clear timeout

* hash compare force uses first 8 for short hash

* fix log height

* copy webhooks

* responsive tweaks

* add icons to server stat sections

* add historical server stats charts

* server stat current card shows usage numbers

* refine current stats more

* fix shortcuts interfering with monaco brave

* clean up unused

* remove v1 frontend
2026-02-25 15:28:23 -08:00
mbecker20
5c99958cba only send auto updated alert after verifying the deploy was successful 2026-02-15 18:38:01 -08:00
mbecker20
7288f067c5 deploy 2.0.0-dev-116 2026-02-11 10:20:39 -08:00
mbecker20
5d0f7de9fb Stack check for update prefers check against deployed service image 2026-02-11 10:20:09 -08:00
mbecker20
4129abcfd0 add new config value to example config 2026-01-27 23:26:44 -08:00
mbecker20
4275e903eb deploy 2.0.0-dev-115 2026-01-27 23:19:49 -08:00
mbecker20
c57c321cbb configure session allow cross site 2026-01-27 23:19:20 -08:00
mbecker20
9a7d49b35e bump mogh server and auth 2026-01-27 22:51:05 -08:00
605 changed files with 37042 additions and 42349 deletions

View File

@@ -1,123 +0,0 @@
# Komodo AI Coding Instructions
## Project Overview
Komodo is a distributed system for building and deploying software across multiple servers. It consists of:
- **Core** (`bin/core`): Central Rust/Axum API server managing resources, scheduling, monitoring
- **Periphery** (`bin/periphery`): Rust agent deployed to managed servers, executes docker/git operations
- **Frontend** (`frontend`): React/TypeScript SPA using TanStack Query and shadcn/ui components
- **Client Libraries** (`client/core/{rs,ts}`): Type-safe API clients generated from shared entity definitions
## Architecture Fundamentals
### Core ↔ Periphery Communication
- Core and Periphery communicate via **WebSocket connections** that can be initiated from either direction
- Periphery can connect outbound to Core (common) or Core can connect inbound to Periphery (less common)
- Connection management is in `bin/core/src/connection` and `bin/periphery/src/connection`
- Use `state::periphery_connections()` in Core to send requests to Periphery agents
### Type Generation Pipeline
**Critical**: Rust entities are the source of truth for all types across the stack:
1. Entities defined in `client/core/rs/src/entities/` with `#[typeshare]` macro
2. Run `node ./client/core/ts/generate_types.mjs` (uses typeshare CLI) → generates `client/core/ts/src/types.ts`
3. TypeScript client is built and linked to frontend via `yarn link komodo_client`
4. Frontend imports types: `import { Types } from "komodo_client"`
**When modifying entities**: Always run the `gen-client` (alias: `gc`) runfile task to regenerate TypeScript types.
### API Pattern (Resolver-Based)
Core and Periphery use `mogh_resolver::Resolve` trait for RPC-style endpoints:
- Request enums in `client/*/rs/src/api/` implement `Resolve<Response = T, Error = E>`
- Core handlers in `bin/core/src/api/{read,write,execute}/*.rs`
- Periphery handlers in `bin/periphery/src/api/*.rs`
- Router maps `/{variant}` paths to enum variants automatically
Example request handling:
```rust
#[derive(Serialize, Deserialize, Resolve)]
#[response(MyResponse)]
#[error(anyhow::Error)]
pub struct MyRequest { /* fields */ }
```
### State Management
Core maintains singleton state via `OnceLock` patterns in `bin/core/src/state.rs`:
- `db_client()`: MongoDB connection
- `periphery_connections()`: WebSocket connections to Periphery agents
- `action_states()`, `build_states()`, etc.: In-memory caches using `CloneCache<K, V>`
- Always use `state::*()` accessors, never instantiate directly
## Development Workflows
### Running Services Locally
```bash
# Start all local services (MongoDB, Core, Periphery, Frontend)
run dc # dev-core: runs Core in release mode with .dev/core.config.toml
run dp # dev-periphery: runs Periphery with .dev/periphery.config.toml
# Or use docker-compose for full stack
run dev-compose # deploys dev.compose.yaml with ferretdb
```
### Frontend Development
```bash
cd frontend && yarn dev # Vite dev server on port 5173
# After changing Rust entities, regenerate client:
run gc # gen-client: typeshare → build TS client → copy to frontend/public/client
```
### Building & Deploying
- Deno scripts in `action/` automate version bumps and deployments
- `run dk` (deploy-komodo): Full build and deploy via Komodo Action
- `run bk` (build-komodo): Build binaries without deploying
## Code Conventions
### Error Handling
- Use `anyhow::Result<T>` for application code
- Use `serror` for serializable errors sent over API boundaries
- Response helper: `response::Response::from(value)` for JSON responses
- Never use `unwrap()` in production code paths; prefer `context()` for error messages
### Naming & Structure
- **Entities**: CamelCase structs in `client/core/rs/src/entities/*/mod.rs`
- **API requests**: CamelCase request types in `client/core/rs/src/api/*.rs`
- **Config derivations**: Use `derive_builder::Builder` for config update structs
- **Variants**: `derive_variants::EnumVariants` generates `TypeVariant` enums for discriminants
### Frontend Patterns
- **API hooks**: `useRead("GetStack", { stack: "my-stack" })` for queries
- **Mutations**: `useWrite("UpdateStack", { onSuccess: () => invalidate(...) })`
- **Components**: Located in `frontend/src/components/`, use shadcn/ui primitives from `@ui/*`
- **State**: Prefer TanStack Query for server state, Jotai atoms for local UI state
## Key Files & Directories
### Must-Read for Context
- [readme.md](readme.md): Project overview and deployment info
- [runfile.toml](runfile.toml): All development task definitions
- [bin/core/src/state.rs](bin/core/src/state.rs): Core's global state singletons
- [client/core/rs/src/entities/mod.rs](client/core/rs/src/entities/mod.rs): All entity definitions
### Common Touch Points
- **Adding new resource type**: Update entities, Core API handlers, Frontend pages/components
- **Changing API**: Modify request/response types in `client/core/rs/src/api/`, regenerate client
- **Frontend features**: Usually start in `frontend/src/pages/`, add hooks in `frontend/src/lib/hooks/`
- **Periphery capabilities**: Add to `periphery_client::api` enum and implement handler in `bin/periphery/src/api/`
## Configuration
- Core config: `config/core.config.toml` (see `client/core/rs/src/entities/config/core.rs` for schema)
- Periphery config: `config/periphery.config.toml`
- Environment variable pattern: Settings support `_FILE` suffix for Docker secrets (see `lib/environment_file`)
## Testing & Validation
- No comprehensive test suite currently
- Validate changes by running locally with `run dc` / `run dp`
- Check Core logs for startup errors (connection to DB, Periphery agents)
- Frontend errors appear in browser console and TanStack Query devtools
## Common Pitfalls
- **Forgetting to regenerate TS types** after Rust entity changes → frontend type errors
- **Not using `state::*()` accessors** → panics from uninitialized `OnceLock`
- **Breaking WebSocket message format** between Core/Periphery → connection failures
- **Yarn link issues**: If frontend can't find `komodo_client`, re-run `run link-client`

28
.vscode/tasks.json vendored
View File

@@ -106,62 +106,62 @@
"problemMatcher": []
},
{
"label": "Init Frontend Client",
"label": "Init UI Client",
"type": "shell",
"command": "yarn link komodo_client && yarn install",
"options": {
"cwd": "${workspaceFolder}/frontend",
"cwd": "${workspaceFolder}/ui",
},
"problemMatcher": []
},
{
"label": "Init Frontend",
"label": "Init UI",
"dependsOn": [
"Build TS Client Types",
"Init TS Client",
"Init Frontend Client"
"Init UI Client"
],
"dependsOrder": "sequence",
"problemMatcher": []
},
{
"label": "Build Frontend",
"label": "Build UI",
"type": "shell",
"command": "yarn build",
"options": {
"cwd": "${workspaceFolder}/frontend",
"cwd": "${workspaceFolder}/ui",
},
"problemMatcher": []
},
{
"label": "Prepare Frontend For Run",
"label": "Prepare UI For Run",
"type": "shell",
"command": "cp -r ./client/core/ts/dist/. frontend/public/client/.",
"command": "cp -r ./client/core/ts/dist/. ui/public/client/.",
"options": {
"cwd": "${workspaceFolder}",
},
"dependsOn": [
"Build TS Client Types",
"Build Frontend"
"Build UI"
],
"dependsOrder": "sequence",
"problemMatcher": []
},
{
"label": "Run Frontend",
"label": "Run UI",
"type": "shell",
"command": "yarn dev",
"options": {
"cwd": "${workspaceFolder}/frontend",
"cwd": "${workspaceFolder}/ui",
},
"dependsOn": ["Prepare Frontend For Run"],
"dependsOn": ["Prepare UI For Run"],
"problemMatcher": []
},
{
"label": "Init",
"dependsOn": [
"Build Backend",
"Init Frontend"
"Init UI"
],
"dependsOrder": "sequence",
"problemMatcher": []
@@ -171,7 +171,7 @@
"dependsOn": [
"Run Core",
"Run Periphery",
"Run Frontend"
"Run UI"
],
"problemMatcher": []
},

803
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ members = [
]
[workspace.package]
version = "2.0.0-dev-114"
version = "2.0.0-dev-124"
edition = "2024"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
@@ -36,17 +36,17 @@ slack = { version = "2.0.0", package = "slack_client_rs", default-features = fal
mogh_error = { version = "1.0.3", default-features = false }
derive_default_builder = "0.1.8"
async_timing_util = "1.1.0"
mogh_auth_client = "1.2.1"
mogh_auth_server = "1.2.4"
mogh_secret_file = "1.0.0"
mogh_validations = "1.0.0"
mogh_rate_limit = "1.0.0"
mogh_auth_client = "1.2.2"
mogh_auth_server = "1.2.12"
mogh_secret_file = "1.0.1"
mogh_validations = "1.0.1"
mogh_rate_limit = "1.0.1"
partial_derive2 = "0.4.5"
mongo_indexed = "2.0.2"
mogh_resolver = "1.0.0"
mogh_config = "1.0.1"
mogh_logger = "1.3.0"
mogh_server = "1.3.0"
mogh_config = "1.0.2"
mogh_logger = "1.3.1"
mogh_server = "1.4.5"
toml_pretty = "2.0.0"
mogh_cache = "1.1.1"
mogh_pki = "1.1.0"
@@ -54,13 +54,13 @@ mungos = "3.2.2"
svi = "1.2.0"
# ASYNC
reqwest = { version = "0.13.1", default-features = false, features = ["json", "stream", "form", "query", "rustls"] }
tokio = { version = "1.49.0", features = ["full"] }
reqwest = { version = "0.13.2", default-features = false, features = ["json", "stream", "form", "query", "rustls"] }
tokio = { version = "1.50.0", features = ["full"] }
tokio-util = { version = "0.7.18", features = ["io", "codec"] }
tokio-stream = { version = "0.1.18", features = ["sync"] }
pin-project-lite = "0.2.16"
futures-util = "0.3.31"
arc-swap = "1.8.0"
pin-project-lite = "0.2.17"
futures-util = "0.3.32"
arc-swap = "1.8.2"
# SERVER
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-native-roots"] }
@@ -75,36 +75,36 @@ utoipa = "5.4.0"
ipnetwork = { version = "0.21.1", features = ["serde"] }
indexmap = { version = "2.13.0", features = ["serde"] }
serde = { version = "1.0.227", features = ["derive"] }
strum = { version = "0.27.2", features = ["derive"] }
strum = { version = "0.28.0", features = ["derive"] }
bson = { version = "2.15.0" } # must keep in sync with mongodb version
toml = "0.9.11"
toml = "1.0.4"
serde_yaml_ng = "0.10.0"
serde_json = "1.0.148"
serde_qs = "0.15.0"
url = "2.5.7"
serde_json = "1.0.149"
serde_qs = "1.0.0"
url = "2.5.8"
# ERROR
anyhow = "1.0.100"
anyhow = "1.0.102"
thiserror = "2.0.18"
# LOGGING
tracing = "0.1.44"
# CONFIG
clap = { version = "4.5.54", features = ["derive"] }
clap = { version = "4.5.60", features = ["derive"] }
dotenvy = "0.15.7"
envy = "0.4.2"
# CRYPTO / AUTH
uuid = { version = "1.19.0", features = ["v4", "fast-rng", "serde"] }
rustls = { version = "0.23.36", features = ["aws-lc-rs"] }
data-encoding = "2.9.0"
uuid = { version = "1.21.0", features = ["v4", "fast-rng", "serde"] }
rustls = { version = "0.23.37", features = ["aws-lc-rs"] }
data-encoding = "2.10.0"
urlencoding = "2.1.3"
bcrypt = "0.18.0"
bcrypt = "0.19.0"
hmac = "0.12.1"
sha1 = "0.10.6"
sha2 = "0.10.9"
rand = "0.9.2"
rand = "0.10.0"
hex = "0.4.3"
# SYSTEM
@@ -112,27 +112,27 @@ hickory-resolver = "0.25.2"
portable-pty = "0.9.0"
shell-escape = "0.1.5"
crossterm = "0.29.0"
bollard = "0.20.0"
sysinfo = "0.37.1"
bollard = "0.20.1"
sysinfo = "0.38.3"
shlex = "1.3.0"
# CLOUD
aws-config = "1.8.12"
aws-sdk-ec2 = "1.200.0"
aws-credential-types = "1.2.11"
aws-config = "1.8.15"
aws-sdk-ec2 = "1.216.0"
aws-credential-types = "1.2.14"
## CRON
english-to-cron = "0.1.7"
chrono-tz = "0.10.4"
chrono = "0.4.43"
chrono = "0.4.44"
croner = "3.0.1"
# MISC
async-compression = { version = "0.4.37", features = ["tokio", "gzip"] }
async-compression = { version = "0.4.41", features = ["tokio", "gzip"] }
derive_builder = "0.20.2"
comfy-table = "7.2.2"
typeshare = "1.0.5"
wildcard = "0.3.0"
colored = "3.0.0"
bytes = "1.11.0"
regex = "1.12.2"
colored = "3.1.1"
bytes = "1.11.1"
regex = "1.12.3"

View File

@@ -1,7 +1,7 @@
## Builds the Komodo Core, Periphery, and Util binaries
## for a specific architecture. Requires OpenSSL 3 or later.
FROM rust:1.93.0-bookworm AS builder
FROM rust:1.93.1-bookworm AS builder
RUN cargo install cargo-strip
WORKDIR /builder

View File

@@ -3,7 +3,7 @@
## Uses chef for dependency caching to help speed up back-to-back builds.
FROM lukemathwalker/cargo-chef:latest-rust-1.90.0-bookworm AS chef
FROM lukemathwalker/cargo-chef:latest-rust-1.93.1-bookworm AS chef
WORKDIR /builder
# Plan just the RECIPE to see if things have changed

View File

@@ -33,6 +33,7 @@ colored.workspace = true
dotenvy.workspace = true
anyhow.workspace = true
chrono.workspace = true
rustls.workspace = true
tokio.workspace = true
serde.workspace = true
clap.workspace = true

View File

@@ -1,4 +1,4 @@
FROM rust:1.93.0-bullseye AS builder
FROM rust:1.93.1-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder

View File

@@ -12,6 +12,9 @@ mod config;
async fn app() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.expect("Failed to install default crypto provider");
mogh_logger::init(&config::cli_config().cli_logging)?;
let args = config::cli_args();
let env = config::cli_env();

View File

@@ -1,7 +1,7 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
# Build Core
FROM rust:1.93.0-trixie AS core-builder
FROM rust:1.93.1-trixie AS core-builder
RUN cargo install cargo-strip
WORKDIR /builder
@@ -17,13 +17,13 @@ RUN cargo build -p komodo_core --release && \
cargo build -p komodo_cli --release && \
cargo strip
# Build Frontend
FROM node:22.12-alpine AS frontend-builder
# Build UI
FROM node:22.12-alpine AS ui-builder
WORKDIR /builder
COPY ./frontend ./frontend
COPY ./ui ./ui
COPY ./client/core/ts ./client
RUN cd client && yarn && yarn build && yarn link
RUN cd frontend && yarn link komodo_client && yarn && yarn build
RUN cd ui && yarn link komodo_client && yarn && yarn build
# Final Image
FROM debian:trixie-slim
@@ -37,7 +37,7 @@ WORKDIR /app
# Copy
COPY ./config/core.config.toml /config/.default.config.toml
COPY --from=frontend-builder /builder/frontend/dist /app/frontend
COPY --from=ui-builder /builder/ui/dist /app/ui
COPY --from=core-builder /builder/target/release/core /usr/local/bin/core
COPY --from=core-builder /builder/target/release/km /usr/local/bin/km
COPY --from=denoland/deno:bin /deno /usr/local/bin/deno

View File

@@ -3,14 +3,14 @@
## Since theres no heavy build here, QEMU multi-arch builds are fine for this image.
ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest
ARG FRONTEND_IMAGE=ghcr.io/moghtech/komodo-frontend:latest
ARG UI_IMAGE=ghcr.io/moghtech/komodo-ui:latest
ARG X86_64_BINARIES=${BINARIES_IMAGE}-x86_64
ARG AARCH64_BINARIES=${BINARIES_IMAGE}-aarch64
# This is required to work with COPY --from
FROM ${X86_64_BINARIES} AS x86_64
FROM ${AARCH64_BINARIES} AS aarch64
FROM ${FRONTEND_IMAGE} AS frontend
FROM ${UI_IMAGE} AS ui
# Final Image
FROM debian:trixie-slim
@@ -33,9 +33,9 @@ COPY --from=x86_64 /km /app/km/linux/amd64
COPY --from=aarch64 /km /app/km/linux/arm64
RUN mv /app/km/${TARGETPLATFORM} /usr/local/bin/km && rm -r /app/km
# Copy default config / static frontend / deno binary
# Copy default config / static ui / deno binary
COPY ./config/core.config.toml /config/.default.config.toml
COPY --from=frontend /frontend /app/frontend
COPY --from=ui /ui /app/ui
COPY --from=denoland/deno:bin /deno /usr/local/bin/deno
# Set $DENO_DIR and preload external Deno deps

View File

@@ -6,13 +6,13 @@ ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest
# This is required to work with COPY --from
FROM ${BINARIES_IMAGE} AS binaries
# Build Frontend
FROM node:22.12-alpine AS frontend-builder
# Build UI
FROM node:22.12-alpine AS ui-builder
WORKDIR /builder
COPY ./frontend ./frontend
COPY ./ui ./ui
COPY ./client/core/ts ./client
RUN cd client && yarn && yarn build && yarn link
RUN cd frontend && yarn link komodo_client && yarn && yarn build
RUN cd ui && yarn link komodo_client && yarn && yarn build
FROM debian:trixie-slim
@@ -22,7 +22,7 @@ RUN sh ./debian-deps.sh && rm ./debian-deps.sh
# Copy
COPY ./config/core.config.toml /config/.default.config.toml
COPY --from=frontend-builder /builder/frontend/dist /app/frontend
COPY --from=ui-builder /builder/ui/dist /app/ui
COPY --from=binaries /core /usr/local/bin/core
COPY --from=binaries /km /usr/local/bin/km
COPY --from=denoland/deno:bin /deno /usr/local/bin/deno

View File

@@ -111,7 +111,7 @@ impl Resolve<ExecuteArgs> for Deploy {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
// This block resolves the attached Build to an actual versioned image
@@ -454,7 +454,7 @@ impl Resolve<ExecuteArgs> for PullDeployment {
action_state.update(|state| state.pulling = true)?;
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let log = pull_deployment_inner(deployment, &server).await?;
@@ -509,7 +509,7 @@ impl Resolve<ExecuteArgs> for StartDeployment {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let log = match periphery_client(&server)
@@ -577,7 +577,7 @@ impl Resolve<ExecuteArgs> for RestartDeployment {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let log = match periphery_client(&server)
@@ -647,7 +647,7 @@ impl Resolve<ExecuteArgs> for PauseDeployment {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let log = match periphery_client(&server)
@@ -715,7 +715,7 @@ impl Resolve<ExecuteArgs> for UnpauseDeployment {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let log = match periphery_client(&server)
@@ -787,7 +787,7 @@ impl Resolve<ExecuteArgs> for StopDeployment {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let log = match periphery_client(&server)
@@ -895,7 +895,7 @@ impl Resolve<ExecuteArgs> for DestroyDeployment {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let log = match swarm_or_server {

View File

@@ -57,7 +57,7 @@ impl Resolve<ExecuteArgs> for StartContainer {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server).await?;
@@ -121,7 +121,7 @@ impl Resolve<ExecuteArgs> for RestartContainer {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server).await?;
@@ -187,7 +187,7 @@ impl Resolve<ExecuteArgs> for PauseContainer {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server).await?;
@@ -251,7 +251,7 @@ impl Resolve<ExecuteArgs> for UnpauseContainer {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server).await?;
@@ -319,7 +319,7 @@ impl Resolve<ExecuteArgs> for StopContainer {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server).await?;
@@ -393,7 +393,7 @@ impl Resolve<ExecuteArgs> for DestroyContainer {
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
// Send update after setting action state, this way UI gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server).await?;

View File

@@ -1182,7 +1182,7 @@ impl Resolve<ExecuteArgs> for DestroyStack {
action_state.update(|state| state.destroying = true)?;
let mut update = update.clone();
// Send update here for frontend to recheck action state
// Send update here for UI to recheck action state
update_update(update.clone()).await?;
match swarm_request(

View File

@@ -39,7 +39,7 @@ pub fn app() -> Router {
.nest("/client", ts_client::router())
.layer(memory_session_layer(config))
.fallback_service(serve_static_ui(
&config.frontend_path,
&config.ui_path,
config.ui_index_force_no_cache,
))
.layer(cors_layer(config))

View File

@@ -293,7 +293,7 @@ async fn handler(
let req_id = Uuid::new_v4();
let variant: ReadRequestVariant = (&request).into();
debug!(
trace!(
"READ REQUEST {req_id} | METHOD: {variant} | USER: {} ({})",
user.username, user.id
);
@@ -301,7 +301,7 @@ async fn handler(
let res = request.resolve(&ReadArgs { user }).await;
if let Err(e) = &res {
debug!(
trace!(
"READ REQUEST {req_id} | METHOD: {variant} | ERROR: {:#}",
e.error
);

View File

@@ -27,12 +27,12 @@ use periphery_client::api::{self, container::InspectContainer};
use crate::{
alert::send_alerts,
api::execute::{self, ExecuteRequest},
api::execute::{self, ExecuteRequest, ExecutionResult},
helpers::{
periphery_client,
query::{get_deployment_state, get_swarm_or_server},
registry_token,
update::{add_update, make_update},
update::{add_update, make_update, poll_update_until_complete},
},
permission::get_check_permissions,
resource::{
@@ -520,32 +520,42 @@ pub async fn check_deployment_for_update_inner(
)
.await
{
Ok(_) => {
let ts = komodo_timestamp();
let alert = Alert {
id: Default::default(),
ts,
resolved: true,
resolved_ts: ts.into(),
level: SeverityLevel::Ok,
target: ResourceTarget::Deployment(id.clone()),
data: AlertData::DeploymentAutoUpdated {
id,
name,
swarm_id,
swarm_name,
server_id,
server_name,
image,
},
Ok(res) => {
let ExecutionResult::Single(update) = res else {
unreachable!()
};
let res = db_client().alerts.insert_one(&alert).await;
if let Err(e) = res {
error!(
"Failed to record DeploymentAutoUpdated to db | {e:#}"
);
let Ok(update) =
poll_update_until_complete(&update.id).await
else {
return;
};
if update.success {
let ts = komodo_timestamp();
let alert = Alert {
id: Default::default(),
ts,
resolved: true,
resolved_ts: ts.into(),
level: SeverityLevel::Ok,
target: ResourceTarget::Deployment(id.clone()),
data: AlertData::DeploymentAutoUpdated {
id,
name,
swarm_id,
swarm_name,
server_id,
server_name,
image,
},
};
let res = db_client().alerts.insert_one(&alert).await;
if let Err(e) = res {
error!(
"Failed to record DeploymentAutoUpdated to db | {e:#}"
);
}
send_alerts(&[alert]).await;
}
send_alerts(&[alert]).await;
}
Err(e) => {
warn!("Failed to auto update Deployment {name} | {e:#}",)

View File

@@ -33,12 +33,12 @@ use periphery_client::api::compose::{
use crate::{
alert::send_alerts,
api::execute::{self, ExecuteRequest},
api::execute::{self, ExecuteRequest, ExecutionResult},
config::core_config,
helpers::{
query::get_swarm_or_server,
stack_git_token, swarm_or_server_request,
update::{add_update, make_update},
update::{add_update, make_update, poll_update_until_complete},
},
permission::get_check_permissions,
resource::{self, list_full_for_user_using_pattern},
@@ -750,17 +750,28 @@ pub async fn check_stack_for_update_inner(
let cache = image_digest_cache();
for service in &mut stack.info.latest_services {
if service.image.is_empty() ||
// Prefer the image coming from deployed services
// so it will be after any interpolation.
let image = stack
.info
.deployed_services
.as_ref()
.and_then(|services| {
services.iter().find_map(|deployed| {
(deployed.service_name == service.service_name)
.then_some(&deployed.image)
})
})
.unwrap_or(&service.image);
if image.is_empty() ||
// Images with a hardcoded digest can't have update.
service.image.contains('@')
image.contains('@')
{
service.image_digest = None;
continue;
}
match cache
.get(&swarm_or_server, &service.image, None, None)
.await
{
match cache.get(&swarm_or_server, image, None, None).await {
Ok(digest) => service.image_digest = Some(digest),
Err(e) => {
warn!(
@@ -946,33 +957,42 @@ pub async fn check_stack_for_update_inner(
)
.await
{
Ok(_) => {
let ts = komodo_timestamp();
let alert = Alert {
id: Default::default(),
ts,
resolved: true,
resolved_ts: ts.into(),
level: SeverityLevel::Ok,
target: ResourceTarget::Stack(stack.id.clone()),
data: AlertData::StackAutoUpdated {
id: stack.id.clone(),
name: stack.name.clone(),
swarm_id,
swarm_name,
server_id,
server_name,
images: services_with_update
.iter()
.map(|service| service.image.clone())
.collect(),
},
Ok(res) => {
let ExecutionResult::Single(update) = res else {
unreachable!()
};
let res = db_client().alerts.insert_one(&alert).await;
if let Err(e) = res {
error!("Failed to record StackAutoUpdated to db | {e:#}");
let Ok(update) = poll_update_until_complete(&update.id).await
else {
return;
};
if update.success {
let ts = komodo_timestamp();
let alert = Alert {
id: Default::default(),
ts,
resolved: true,
resolved_ts: ts.into(),
level: SeverityLevel::Ok,
target: ResourceTarget::Stack(stack.id.clone()),
data: AlertData::StackAutoUpdated {
id: stack.id.clone(),
name: stack.name.clone(),
swarm_id,
swarm_name,
server_id,
server_name,
images: services_with_update
.iter()
.map(|service| service.image.clone())
.collect(),
},
};
let res = db_client().alerts.insert_one(&alert).await;
if let Err(e) = res {
error!("Failed to record StackAutoUpdated to db | {e:#}");
}
send_alerts(&[alert]).await;
}
send_alerts(&[alert]).await;
}
Err(e) => {
warn!("Failed to auto update Stack {} | {e:#}", stack.name)

View File

@@ -143,14 +143,12 @@ impl AuthImpl for KomodoAuthImpl {
}
fn host(&self) -> &str {
static AUTH_HOST: LazyLock<String> =
LazyLock::new(|| format!("{}/auth", core_config().host));
&AUTH_HOST
&core_config().host
}
fn post_link_redirect(&self) -> &str {
static POST_LINK_REDIRECT: LazyLock<String> =
LazyLock::new(|| format!("{}/settings", core_config().host));
LazyLock::new(|| format!("{}/profile", core_config().host));
&POST_LINK_REDIRECT
}

View File

@@ -309,6 +309,9 @@ pub fn core_config() -> &'static CoreConfig {
cors_allow_credentials: env
.komodo_cors_allow_credentials
.unwrap_or(config.cors_allow_credentials),
session_allow_cross_site: env
.komodo_session_allow_cross_site
.unwrap_or(config.session_allow_cross_site),
resource_poll_interval: env
.komodo_resource_poll_interval
.unwrap_or(config.resource_poll_interval),
@@ -400,9 +403,9 @@ pub fn core_config() -> &'static CoreConfig {
ssl_cert_file: env
.komodo_ssl_cert_file
.unwrap_or(config.ssl_cert_file),
frontend_path: env
.komodo_frontend_path
.unwrap_or(config.frontend_path),
ui_path: env
.komodo_ui_path
.unwrap_or(config.ui_path),
ui_index_force_no_cache: env
.komodo_ui_index_force_no_cache
.unwrap_or(config.ui_index_force_no_cache),

View File

@@ -70,6 +70,14 @@ impl PeripheryConnectionArgs<'_> {
}
};
debug!(
host = identifiers.host(),
query = core_connection_query(),
sec_websocket_accept = accept.to_str().unwrap_or_default(),
resource_id = connection.args.id,
"[PERIPHERY AUTH] Zero trust identifiers"
);
let identifiers = identifiers.build(
accept.as_bytes(),
core_connection_query().as_bytes(),

View File

@@ -129,6 +129,14 @@ async fn existing_server_handler(
return;
};
debug!(
host = identifiers.host.to_str().unwrap_or_default(),
query,
sec_websocket_accept = identifiers.accept,
resource_id = &server.id,
"[PERIPHERY AUTH] Zero trust identifiers"
);
let span = info_span!(
"PeripheryLogin",
server_id = server.id,

View File

@@ -1,3 +1,5 @@
use std::time::Duration;
use anyhow::Context;
use database::mungos::{
by_id::{find_one_by_id, update_one_by_id},
@@ -16,7 +18,7 @@ use komodo_client::entities::{
stack::Stack,
swarm::Swarm,
sync::ResourceSync,
update::{Update, UpdateListItem},
update::{Update, UpdateListItem, UpdateStatus},
user::User,
};
@@ -591,3 +593,17 @@ pub async fn init_execution_update(
Ok(update)
}
pub async fn poll_update_until_complete(
update_id: &str,
) -> anyhow::Result<Update> {
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
let update = find_one_by_id(&db_client().updates, update_id)
.await?
.context("No update found at given ID")?;
if matches!(update.status, UpdateStatus::Complete) {
return Ok(update);
}
}
}

View File

@@ -82,7 +82,7 @@ pub async fn execute_compose_with_stack_and_server<
// The returned guard will set the action state back to default when dropped.
let _action_guard = action_state.update(set_in_progress)?;
// Send update here for frontend to recheck action state
// Send update here for UI to recheck action state
update_update(update.clone()).await?;
let periphery = periphery_client(&server).await?;

View File

@@ -33,6 +33,7 @@ use komodo_client::{
user::{action_user, system_user},
},
};
use mogh_auth_server::api::login::local::sign_up_local_user;
use mogh_resolver::Resolve;
use uuid::Uuid;
@@ -41,6 +42,7 @@ use crate::{
execute::{ExecuteArgs, ExecuteRequest},
write::WriteArgs,
},
auth::KomodoAuthImpl,
config::core_config,
helpers::update::init_execution_update,
network, resource,
@@ -301,7 +303,7 @@ async fn ensure_init_user_and_resources() {
// Assumes if there are any existing users, procedures, or tags,
// the default procedures do not need to be set up.
let Ok((None, None, None)) = tokio::try_join!(
let Ok((None, procedures, tags)) = tokio::try_join!(
db.users.find_one(Document::new()),
db.procedures.find_one(Document::new()),
db.tags.find_one(Document::new()),
@@ -314,20 +316,16 @@ async fn ensure_init_user_and_resources() {
// Init admin user if set in config.
if let Some(username) = &config.init_admin_username {
info!("Creating init admin user...");
// if let Err(e) = (SignUpLocalUser {
// username: username.clone(),
// password: config.init_admin_password.clone(),
// })
// .resolve(&AuthArgs {
// headers: Default::default(),
// ip: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
// session: None,
// })
// .await
// {
// error!("Failed to create init admin user | {:#}", e.error);
// return;
// }
if let Err(e) = sign_up_local_user(
&KomodoAuthImpl,
username.to_string(),
&config.init_admin_password,
)
.await
{
error!("Failed to create init admin user | {:#}", e.error);
return;
}
match db
.users
.find_one(doc! { "username": username })
@@ -347,7 +345,10 @@ async fn ensure_init_user_and_resources() {
}
}
if config.disable_init_resources {
if config.disable_init_resources
|| procedures.is_some()
|| tags.is_some()
{
info!("System resources init {}", "DISABLED".red());
return;
}

View File

@@ -45,7 +45,7 @@ async fn serve_client_file(
let contents = fs::read_to_string(format!(
"{}/client/{path}",
core_config().frontend_path
core_config().ui_path
))
.await
.with_context(|| format!("Failed to read file: {path}"))?;

View File

@@ -1,6 +1,6 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
FROM rust:1.93.0-trixie AS builder
FROM rust:1.93.1-trixie AS builder
RUN cargo install cargo-strip
WORKDIR /builder

View File

@@ -94,6 +94,13 @@ pub async fn handler(
already_logged_connection_error = false;
debug!(
host = identifiers.host(),
query,
sec_websocket_accept = accept.to_str().unwrap_or_default(),
"[CORE AUTH] Zero trust identifiers"
);
let identifiers =
identifiers.build(accept.as_bytes(), query.as_bytes());

View File

@@ -109,6 +109,13 @@ async fn handler(
let query = format!("core={}", urlencoding::encode(&core));
debug!(
host = identifiers.host.to_str().unwrap_or_default(),
query,
sec_websocket_accept = identifiers.accept,
"[CORE AUTH] Zero trust identifiers"
);
if let Err(e) =
handle_login(&mut socket, identifiers.build(query.as_bytes()))
.await

View File

@@ -225,6 +225,8 @@ pub struct Env {
pub komodo_cors_allowed_origins: Option<Vec<String>>,
/// Override `cors_allow_credentials`
pub komodo_cors_allow_credentials: Option<bool>,
/// Override `session_allow_cross_site`
pub komodo_session_allow_cross_site: Option<bool>,
/// Override `database.uri`
#[serde(alias = "komodo_mongo_uri")]
@@ -273,8 +275,8 @@ pub struct Env {
/// Override `ssl_cert_file`
pub komodo_ssl_cert_file: Option<String>,
/// Override `frontend_path`
pub komodo_frontend_path: Option<String>,
/// Override `ui_path`
pub komodo_ui_path: Option<String>,
/// Override `ui_index_force_no_cache`
pub komodo_ui_index_force_no_cache: Option<bool>,
/// Override `sync_directory`
@@ -561,6 +563,12 @@ pub struct CoreConfig {
#[serde(default)]
pub cors_allow_credentials: bool,
/// Use SameSite=None (actually allows samesite) instead of SameSite=Lax.
/// The third option, SameSite=Strict, won't work with external login,
/// as the session cookie will be lost on redirect with auth provider.
#[serde(default)]
pub session_allow_cross_site: bool,
// ============
// = Webhooks =
// ============
@@ -702,9 +710,9 @@ pub struct CoreConfig {
#[serde(default = "default_action_directory")]
pub action_directory: PathBuf,
/// The path to the built frontend folder.
#[serde(default = "default_frontend_path")]
pub frontend_path: String,
/// The path to the built ui folder.
#[serde(default = "default_ui_path")]
pub ui_path: String,
/// Force the `index.html` to served with
/// 'Cache-Content: no-cache' header instead
@@ -733,8 +741,8 @@ fn default_private_key() -> String {
String::from("file:/config/keys/core.key")
}
fn default_frontend_path() -> String {
"/app/frontend".to_string()
fn default_ui_path() -> String {
"/app/ui".to_string()
}
fn default_jwt_ttl() -> Timelength {
@@ -836,6 +844,7 @@ impl Default for CoreConfig {
default_auth_rate_limit_window_seconds(),
cors_allowed_origins: Default::default(),
cors_allow_credentials: Default::default(),
session_allow_cross_site: Default::default(),
webhook_secret: Default::default(),
webhook_base_url: Default::default(),
logging: Default::default(),
@@ -852,7 +861,7 @@ impl Default for CoreConfig {
ssl_enabled: Default::default(),
ssl_key_file: default_ssl_key_file(),
ssl_cert_file: default_ssl_cert_file(),
frontend_path: default_frontend_path(),
ui_path: default_ui_path(),
ui_index_force_no_cache: Default::default(),
sync_directory: default_sync_directory(),
repo_directory: default_repo_directory(),
@@ -942,6 +951,7 @@ impl CoreConfig {
.auth_rate_limit_window_seconds,
cors_allowed_origins: config.cors_allowed_origins,
cors_allow_credentials: config.cors_allow_credentials,
session_allow_cross_site: config.session_allow_cross_site,
webhook_secret: empty_or_redacted(&config.webhook_secret),
webhook_base_url: config.webhook_base_url,
database: config.database.sanitized(),
@@ -980,7 +990,7 @@ impl CoreConfig {
ssl_enabled: config.ssl_enabled,
ssl_key_file: config.ssl_key_file,
ssl_cert_file: config.ssl_cert_file,
frontend_path: config.frontend_path,
ui_path: config.ui_path,
ui_index_force_no_cache: config.ui_index_force_no_cache,
repo_directory: config.repo_directory,
action_directory: config.action_directory,
@@ -1023,9 +1033,6 @@ impl mogh_server::ServerConfig for &CoreConfig {
}
impl mogh_server::cors::CorsConfig for &CoreConfig {
fn allowed_origins_env_field(&self) -> &'static str {
"KOMODO_CORS_ALLOWED_ORIGINS"
}
fn allowed_origins(&self) -> &[String] {
&self.cors_allowed_origins
}
@@ -1035,13 +1042,16 @@ impl mogh_server::cors::CorsConfig for &CoreConfig {
}
impl mogh_server::session::SessionConfig for &CoreConfig {
fn expiry_seconds(&self) -> i64 {
60
fn host(&self) -> &str {
&self.host
}
fn host_env_field(&self) -> &str {
"KOMODO_HOST"
}
fn host(&self) -> &str {
&self.host
fn expiry_seconds(&self) -> i64 {
60 * 3
}
fn allow_cross_site(&self) -> bool {
self.session_allow_cross_site
}
}

View File

@@ -7,7 +7,7 @@ use anyhow::{Context, anyhow};
use async_timing_util::unix_timestamp_ms;
use clap::{Parser, ValueEnum};
use mogh_error::{AddStatusCodeError, Serror};
use rand::Rng as _;
use rand::RngExt as _;
use reqwest::StatusCode;
use serde::{
Deserialize, Serialize,

View File

@@ -1084,7 +1084,7 @@ impl<'de> Deserialize<'de> for StackFileDependency {
}
}
// // This one is nice for TOML, but annoying to use on frontend
// // This one is nice for TOML, but annoying to use in UI
// impl Serialize for StackFileDependency {
// fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
// where

View File

@@ -203,7 +203,9 @@ impl Default for UserConfig {
impl UserConfig {
pub fn sanitize(&mut self) {
if let UserConfig::Local { password } = self {
password.clear();
if !password.is_empty() {
*password = "#".repeat(8);
}
}
}
}
@@ -290,7 +292,7 @@ impl User {
/// Returns whether user is an inbuilt service user
///
/// NOTE: ALSO UPDATE `frontend/src/lib/utils/is_service_user` to match
/// NOTE: ALSO UPDATE `ui/src/lib/utils/is_service_user` to match
pub fn is_service_user(user_id: &str) -> bool {
matches!(
user_id,

View File

@@ -13,7 +13,7 @@
"build": "tsc"
},
"dependencies": {
"mogh_auth_client": "^1.2.0"
"mogh_auth_client": "^1.2.1"
},
"devDependencies": {
"typescript": "^5.6.3"

View File

@@ -5007,14 +5007,24 @@ export interface ImageHistoryResponseItem {
export type ListDockerImageHistoryResponse = ImageHistoryResponseItem[];
export interface ImageListItem {
/**
* ID is the content-addressable ID of an image.
* This identifier is a content-addressable digest calculated from the image's configuration (which includes the digests of layers used by the image).
* Note that this digest differs from the `digests` below, which holds digests of image manifests that reference the image.
*/
id: string;
/**
* ID of the parent image.
* Depending on how the image was created, this field may be empty and is only set for images that were built/created locally.
* This field is empty if the image was pulled from an image registry.
*/
parent_id: string;
/** The first tag in `repo_tags`, or Id if no tags. */
name: string;
/** The first digest in `repo_digests`, or empty if no digests. */
digest: string;
/** ID is the content-addressable ID of an image. This identifier is a content-addressable digest calculated from the image's configuration (which includes the digests of layers used by the image). Note that this digest differs from the `RepoDigests` below, which holds digests of image manifests that reference the image. */
id: string;
/** ID of the parent image. Depending on how the image was created, this field may be empty and is only set for images that were built/created locally. This field is empty if the image was pulled from an image registry. */
parent_id: string;
/** The unchanged `RepoTags`. */
tags?: string[];
/** The unchanged `RepoDigests`. */
digests?: string[];
/** Date and time at which the image was created as a Unix timestamp (number of seconds sinds EPOCH). */
created: I64;
/** Total size of the image including all layers it is composed of. */
@@ -5364,8 +5374,8 @@ export interface StackService {
container?: ContainerListItem;
/** The service (Swarm mode) */
swarm_service?: SwarmServiceListItem;
/** The service image digest (When deployed) */
image_digest?: ImageDigest;
/** The service image digests */
image_digests?: ImageDigest[];
}
export type ListStackServicesResponse = StackService[];

View File

@@ -7,10 +7,10 @@ jwt-decode@^4.0.0:
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-4.0.0.tgz#2270352425fd413785b2faf11f6e755c5151bd4b"
integrity sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==
mogh_auth_client@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/mogh_auth_client/-/mogh_auth_client-1.2.0.tgz#d840abb85c967133570f247e0d671ac4054db3ff"
integrity sha512-d60lSNzbOJcZwwSeyn7jLBmNI0FQTpXtOjR/OJj3M5KnUpmofpjlgGqLYGbVsZkoVipqohhi7aM2uVHEiicCVQ==
mogh_auth_client@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/mogh_auth_client/-/mogh_auth_client-1.2.1.tgz#c8b5e9da101dc8da7b30586e5c5463f5f7a95edb"
integrity sha512-8uUjgqagwbMW8BKtTRzfQ4txpw+54hqLszbIvcYf99murVIQu5+NsRZ8/vjP/fOMawgXJXCVsR6O2lamm1BCnQ==
dependencies:
jwt-decode "^4.0.0"

View File

@@ -324,9 +324,9 @@ auth_rate_limit_max_attempts = 5
## Default: 15
auth_rate_limit_window_seconds = 15
########
# CORS #
########
##################
# CORS / SESSION #
##################
## Specifically set list of CORS allowed origins.
## If empty, allows all origins (`*`).
@@ -341,6 +341,10 @@ cors_allowed_origins = []
## Default: false
cors_allow_credentials = false
## Enabling this sets 'SameSite=None', which allows externally
## hosted UIs to use the login flows.
session_allow_cross_site = false
##################
# POLL INTERVALS #
##################

View File

@@ -10,7 +10,7 @@ Running Komodo from [source](https://github.com/moghtech/komodo) requires either
* [Rust](https://www.rust-lang.org/) stable via [rustup installer](https://rustup.rs/)
* [MongoDB](https://www.mongodb.com/) or [FerretDB](https://www.ferretdb.com/) available locally.
* On Debian/Ubuntu: `apt install build-essential pkg-config libssl-dev` required to build the rust source.
* Frontend (Web UI)
* Web UI
* [Node](https://nodejs.org/en) >= 18.18 + NPM
* [Yarn](https://yarnpkg.com/) - (Tip: use `corepack enable` after installing `node` to use `yarn`)
* [typeshare](https://github.com/1password/typeshare)
@@ -30,7 +30,7 @@ Use the included `.devcontainer.json` with VSCode or other compatible IDE to sta
[VSCode Tasks](https://code.visualstudio.com/Docs/editor/tasks) are provided for building and running Komodo.
After opening the repository with the devcontainer run the task `Init` to build the frontend/backend. Then, the task `Run Komodo` can be used to run frontend/backend. Other tasks for rebuilding/running just one component of the stack (Core API, Periphery API, Frontend) are also provided.
After opening the repository with the devcontainer run the task `Init` to build the ui/backend. Then, the task `Run Komodo` can be used to run ui/backend. Other tasks for rebuilding/running just one component of the stack (Core API, Periphery API, UI) are also provided.
## Local
@@ -41,13 +41,13 @@ To run a full Komodo instance from a non-container environment run commands in t
* Build and Run backend
* `run dev-core` -- Build and run Core API
* `run dev-periphery` -- Build and run Periphery API
* Build Frontend
* Build UI
* Install **typeshare-cli**: `cargo install typeshare-cli`
* **Run this once** -- `run link-client` -- generates TS client and links to the frontend
* **Run this once** -- `run link-client` -- generates TS client and links to the ui
* After running the above once:
* `run gen-client` -- Rebuild client
* `run dev-frontend` -- Start in dev (watch) mode
* `run build-frontend` -- Typecheck and build
* `run dev-ui` -- Start in dev (watch) mode
* `run build-ui` -- Typecheck and build
## Docsite Development

View File

@@ -113,7 +113,7 @@ So in v1 Action:
await komodo.write("CreateTerminal", {
server: "my-server",
name: "my-terminal",
command: "sh",
command: "bash",
recreate: "Always",
});
await komodo.execute_terminal(
@@ -131,7 +131,7 @@ await komodo.execute_server_terminal(
server: "my-server",
terminal: "my-terminal",
command: "ls -l",
init: { command: "sh", recreate: "Always" },
init: { command: "bash", recreate: "Always" },
},
{ onLine: (line) => console.log(line) },
);

View File

@@ -1,4 +1,4 @@
FROM rust:1.93.0-bullseye as builder
FROM rust:1.93.1-bullseye as builder
WORKDIR /builder
COPY . .

View File

@@ -1,4 +1,4 @@
FROM rust:1.93.0-bullseye as builder
FROM rust:1.93.1-bullseye as builder
WORKDIR /builder
COPY . .

View File

@@ -1,19 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
],
ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: "@typescript-eslint/parser",
plugins: ["react-refresh"],
rules: {
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-explicit-any": "off",
},
};

24
frontend/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,26 +0,0 @@
# Komodo Frontend
Komodo JS stack uses Yarn + Vite + React + Tailwind + shadcn/ui
## Setup Dev Environment
The frontend depends on the local package `komodo_client` located at `/client/core/ts`.
This must first be built and prepared for yarn link.
The following command should setup everything up (run with /frontend as working directory):
```sh
cd ../client/core/ts && yarn && yarn build && yarn link && \
cd ../../../frontend && yarn link komodo_client && yarn
```
You can make a new file `.env.development` (gitignored) which holds:
```sh
VITE_KOMODO_HOST=https://demo.komo.do
```
You can point it to any Komodo host you like, including the demo.
Now you can start the dev frontend server:
```sh
yarn dev
```

View File

@@ -1,17 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/",
"utils": "@lib/utils"
}
}

View File

@@ -1,77 +0,0 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"build-client": "cd ../client/core/ts && yarn && yarn build && yarn link"
},
"dependencies": {
"@floating-ui/react": "0.27.16",
"@monaco-editor/react": "4.7.0",
"@radix-ui/react-checkbox": "1.3.3",
"@radix-ui/react-dialog": "1.1.15",
"@radix-ui/react-dropdown-menu": "2.1.16",
"@radix-ui/react-hover-card": "1.1.15",
"@radix-ui/react-icons": "1.3.2",
"@radix-ui/react-label": "2.1.8",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-progress": "1.1.8",
"@radix-ui/react-select": "2.2.6",
"@radix-ui/react-separator": "1.1.8",
"@radix-ui/react-slot": "1.2.4",
"@radix-ui/react-switch": "1.2.6",
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-toggle": "1.1.10",
"@radix-ui/react-toggle-group": "1.1.11",
"@tanstack/react-query": "5.90.20",
"@tanstack/react-table": "8.21.3",
"@types/node": "^25.0.10",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"ansi-to-html": "0.7.2",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
"jotai": "2.16.2",
"lucide-react": "0.563.0",
"monaco-editor": "0.55.1",
"monaco-yaml": "5.4.0",
"prettier": "3.8.1",
"react": "19.2.3",
"react-charts": "3.0.0-beta.57",
"react-dom": "19.2.3",
"react-minimal-pie-chart": "9.1.2",
"react-router-dom": "7.13.0",
"react-xtermjs": "1.0.10",
"sanitize-html": "2.17.0",
"shell-quote": "1.8.3",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7"
},
"devDependencies": {
"@types/react": "19.2.9",
"@types/react-dom": "19.2.3",
"@types/sanitize-html": "2.16.0",
"@types/shell-quote": "1.7.5",
"@typescript-eslint/eslint-plugin": "8.53.1",
"@typescript-eslint/parser": "8.53.1",
"@vitejs/plugin-react": "5.1.2",
"autoprefixer": "10.4.23",
"dotenv": "17.2.3",
"eslint": "9.39.2",
"eslint-plugin-react-hooks": "7.0.1",
"eslint-plugin-react-refresh": "0.4.26",
"postcss": "8.5.6",
"tailwindcss": "3.4.19",
"typescript": "5.9.3",
"vite": "7.3.1",
"vite-tsconfig-paths": "6.0.5"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -1,10 +0,0 @@
[dev-frontend]
alias = "df"
description = "starts the frontend in dev mode"
cmd = "yarn dev"
[build-frontend]
alias = "bf"
description = "generates fresh ts client and builds the frontend"
cmd = "yarn build"
after = "gen-client"

View File

@@ -1,120 +0,0 @@
import { ResourceLink } from "@components/resources/common";
import { useInvalidate, useRead, useUser, useWrite } from "@lib/hooks";
import { UsableResource } from "@types";
import { Button } from "@ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "@ui/dialog";
import { useState } from "react";
import { AlertLevel } from ".";
import { fmt_date_with_minutes } from "@lib/formatting";
import { DialogDescription } from "@radix-ui/react-dialog";
import {
alert_level_intention,
text_color_class_by_intention,
} from "@lib/color";
import { MonacoEditor } from "@components/monaco";
import { Types } from "komodo_client";
import { ConfirmButton } from "@components/util";
import { X } from "lucide-react";
import { useToast } from "@ui/use-toast";
export const AlertDetailsDialog = ({ id }: { id: string }) => {
const [open, set] = useState(false);
const alert = useRead("GetAlert", { id }).data;
return (
<Dialog open={open} onOpenChange={set}>
<DialogTrigger asChild>
<Button variant="secondary" className="items-center gap-2">
Details
</Button>
</DialogTrigger>
<AlertDetailsDialogContent alert={alert} onClose={() => set(false)} />
</Dialog>
);
};
export const AlertDetailsDialogContent = ({
alert,
onClose,
}: {
alert: Types.Alert | undefined;
onClose: () => void;
}) => {
const { toast } = useToast();
const isAdmin = useUser().data?.admin ?? false;
const inv = useInvalidate();
const { mutate: close_alert, isPending: closePending } = useWrite(
"CloseAlert",
{
onSuccess: () => {
inv(["ListAlerts"], ["GetAlert"]);
toast({ title: "Closed alert." });
onClose();
},
}
);
if (!alert) return null;
return (
<DialogContent className="w-[90vw] max-w-[900px]">
<DialogHeader>
<div className="flex items-center gap-4 flex-wrap">
<ResourceLink
type={alert.target.type as UsableResource}
id={alert.target.id}
onClick={onClose}
/>
<div className="text-muted-foreground">
{fmt_date_with_minutes(new Date(alert.ts))}
</div>
</div>
</DialogHeader>
<DialogDescription>
<div className="flex flex-col gap-4">
<div className="flex gap-4 items-center flex-wrap">
{/** Alert type */}
<div className="flex gap-2">
<div className="text-muted-foreground">type:</div>{" "}
{alert.data.type}
</div>
{/** Resolved */}
<div className="flex gap-2">
<div className="text-muted-foreground">status:</div>{" "}
<div
className={text_color_class_by_intention(
alert.resolved ? "Good" : alert_level_intention(alert.level)
)}
>
{alert.resolved ? "RESOLVED" : "OPEN"}
</div>
</div>
{/** Level */}
<div className="flex gap-2 text-muted-foreground">
level: <AlertLevel level={alert.level} />
</div>
{isAdmin && (
<ConfirmButton
title="Close"
icon={<X className="w-4 h-4" />}
variant="destructive"
className="max-w-[120px]"
onClick={() => close_alert({ id: alert?._id?.$oid! })}
loading={closePending}
/>
)}
</div>
{/** Alert data */}
<MonacoEditor
value={JSON.stringify(alert.data.data, undefined, 2)}
language="json"
readOnly
/>
</div>
</DialogDescription>
</DialogContent>
);
};

View File

@@ -1,37 +0,0 @@
import { Section } from "@components/layouts";
import { alert_level_intention } from "@lib/color";
import { useRead, useLocalStorage } from "@lib/hooks";
import { Types } from "komodo_client";
import { Button } from "@ui/button";
import { AlertTriangle } from "lucide-react";
import { AlertsTable } from "./table";
import { StatusBadge } from "@components/util";
export const OpenAlerts = () => {
const [open, setOpen] = useLocalStorage("open-alerts-v0", true);
const alerts = useRead("ListAlerts", { query: { resolved: false } }).data
?.alerts;
if (!alerts || alerts.length === 0) return null;
return (
<Section
title="Open Alerts"
icon={<AlertTriangle className="w-4 h-4" />}
actions={
<Button variant="ghost" onClick={() => setOpen(!open)}>
{open ? "hide" : "show"}
</Button>
}
>
{open && <AlertsTable alerts={alerts ?? []} />}
</Section>
);
};
export const AlertLevel = ({
level,
}: {
level: Types.SeverityLevel | undefined;
}) => {
if (!level) return null;
return <StatusBadge text={level} intent={alert_level_intention(level)} />;
};

View File

@@ -1,65 +0,0 @@
import { Types } from "komodo_client";
import { DataTable } from "@ui/data-table";
import { AlertLevel } from ".";
import { AlertDetailsDialog } from "./details";
import { UsableResource } from "@types";
import { ResourceLink } from "@components/resources/common";
import {
alert_level_intention,
text_color_class_by_intention,
} from "@lib/color";
export const AlertsTable = ({
alerts,
showResolved,
}: {
alerts: Types.Alert[];
showResolved?: boolean;
}) => {
return (
<DataTable
tableKey="alerts"
data={alerts ?? []}
columns={[
{
header: "Details",
cell: ({ row }) =>
row.original._id?.$oid && (
<AlertDetailsDialog id={row.original._id?.$oid} />
),
},
{
header: "Resource",
cell: ({ row }) => {
const type = row.original.target.type as UsableResource;
return <ResourceLink type={type} id={row.original.target.id} />;
},
},
showResolved && {
header: "Status",
cell: ({ row }) => {
return (
<div
className={text_color_class_by_intention(
row.original.resolved
? "Good"
: alert_level_intention(row.original.level)
)}
>
{row.original.resolved ? "RESOLVED" : "OPEN"}
</div>
);
},
},
{
header: "Level",
cell: ({ row }) => <AlertLevel level={row.original.level} />,
},
{
header: "Alert Type",
accessorKey: "data.type",
},
]}
/>
);
};

View File

@@ -1,57 +0,0 @@
import { CopyButton } from "@components/util";
import { Types } from "komodo_client";
import { DataTable } from "@ui/data-table";
import { Input } from "@ui/input";
import { ReactNode } from "react";
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
export const ApiKeysTable = ({
keys,
DeleteKey,
}: {
keys: Types.ApiKey[];
DeleteKey: (params: { api_key: string }) => ReactNode;
}) => {
return (
<DataTable
tableKey="api-keys"
data={keys}
columns={[
{ header: "Name", accessorKey: "name" },
{
header: "Key",
cell: ({
row: {
original: { key },
},
}) => {
return (
<div className="flex items-center gap-2">
<Input
className="w-[100px] lg:w-[200px] overflow-ellipsis"
value={key}
disabled
/>
<CopyButton content={key} />
</div>
);
},
},
{
header: "Expires",
accessorFn: ({ expires }) =>
expires
? "In " +
((expires - Date.now()) / ONE_DAY_MS).toFixed() +
" Days"
: "Never",
},
{
header: "Delete",
cell: ({ row }) => <DeleteKey api_key={row.original.key} />,
},
]}
/>
);
};

View File

@@ -1,76 +0,0 @@
import { SecretSelector } from "@components/config/util";
import { useRead } from "@lib/hooks";
import { Types } from "komodo_client";
import { useToast } from "@ui/use-toast";
export const SecretsSearch = ({
server,
}: {
/// eg server id
server?: string;
}) => {
if (server) return <SecretsWithServer server={server} />;
return <SecretsNoServer />;
};
const SecretsNoServer = () => {
const variables = useRead("ListVariables", {}).data ?? [];
const secrets = useRead("ListSecrets", {}).data ?? [];
return <SecretsView variables={variables} secrets={secrets} />;
};
const SecretsWithServer = ({
server,
}: {
/// eg server id
server: string;
}) => {
const variables = useRead("ListVariables", {}).data ?? [];
const secrets =
useRead("ListSecrets", { target: { type: "Server", id: server } }).data ??
[];
return <SecretsView variables={variables} secrets={secrets} />;
};
const SecretsView = ({
variables,
secrets,
}: {
variables: Types.ListVariablesResponse;
secrets: Types.ListSecretsResponse;
}) => {
const { toast } = useToast();
if (variables.length === 0 && secrets.length === 0) return;
return (
<div className="flex items-center gap-2">
{variables.length > 0 && (
<SecretSelector
type="Variable"
keys={variables.map((v) => v.name)}
onSelect={(variable) => {
if (!variable) return;
navigator.clipboard.writeText("[[" + variable + "]]");
toast({ title: "Copied selection" });
}}
disabled={false}
side="right"
align="start"
/>
)}
{secrets.length > 0 && (
<SecretSelector
type="Secret"
keys={secrets}
onSelect={(secret) => {
if (!secret) return;
navigator.clipboard.writeText("[[" + secret + "]]");
toast({ title: "Copied selection" });
}}
disabled={false}
side="right"
align="start"
/>
)}
</div>
);
};

View File

@@ -1,439 +0,0 @@
import {
ConfigInput,
ConfigSwitch,
ConfirmUpdate,
} from "@components/config/util";
import { Section } from "@components/layouts";
import { MonacoLanguage } from "@components/monaco";
import { Types } from "komodo_client";
import { cn } from "@lib/utils";
import { Button } from "@ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import { AlertTriangle, Bookmark, History, Settings } from "lucide-react";
import { Fragment, ReactNode, SetStateAction } from "react";
const keys = <T extends Record<string, unknown>>(obj: T) =>
Object.keys(obj) as Array<keyof T>;
export const ConfigLayout = <
T extends Types.Resource<unknown, unknown>["config"],
>({
original,
update,
children,
disabled,
onConfirm,
onReset,
selector,
titleOther,
file_contents_language,
}: {
original: T;
update: Partial<T>;
children: ReactNode;
disabled: boolean;
onConfirm: () => void;
onReset: () => void;
selector?: ReactNode;
titleOther?: ReactNode;
file_contents_language?: MonacoLanguage;
}) => {
const titleProps = titleOther
? { titleOther }
: { title: "Config", icon: <Settings className="w-4 h-4" /> };
const changesMade = Object.keys(update).length ? true : false;
return (
<Section
{...titleProps}
actions={
<div className="flex gap-2">
{changesMade && (
<div className="text-muted-foreground flex items-center gap-2">
<AlertTriangle className="w-4 h-4" /> Unsaved changes
<AlertTriangle className="w-4 h-4" />
</div>
)}
{selector}
{changesMade && (
<>
<Button
variant="outline"
onClick={onReset}
disabled={disabled || !changesMade}
className="flex items-center gap-2"
>
<History className="w-4 h-4" />
Reset
</Button>
<ConfirmUpdate
previous={original}
content={update}
onConfirm={async () => onConfirm()}
disabled={disabled}
file_contents_language={file_contents_language}
key_listener
/>
</>
)}
</div>
}
>
{children}
</Section>
);
};
export type PrimitiveConfigArgs = {
hidden?: boolean;
placeholder?: string;
label?: string;
boldLabel?: boolean;
description?: ReactNode;
};
export type ConfigComponent<T> = {
label: string;
boldLabel?: boolean; // defaults to true
icon?: ReactNode;
actions?: ReactNode;
labelExtra?: ReactNode;
description?: ReactNode;
hidden?: boolean;
labelHidden?: boolean;
contentHidden?: boolean;
components: {
[K in keyof Partial<T>]:
| boolean
| PrimitiveConfigArgs
| ((value: T[K], set: (value: Partial<T>) => void) => ReactNode);
};
};
export const Config = <T,>({
original,
update,
disabled,
disableSidebar,
set,
onSave,
components,
selector,
titleOther,
file_contents_language,
}: {
original: T;
update: Partial<T>;
disabled: boolean;
disableSidebar?: boolean;
set: React.Dispatch<SetStateAction<Partial<T>>>;
onSave: () => Promise<void>;
selector?: ReactNode;
titleOther?: ReactNode;
components: Record<
string, // sidebar key
ConfigComponent<T>[] | false | undefined
>;
file_contents_language?: MonacoLanguage;
}) => {
const sections = keys(components).filter((section) => !!components[section]);
const changesMade = Object.keys(update).length ? true : false;
const onConfirm = async () => {
await onSave();
set({});
};
const onReset = () => set({});
return (
<ConfigLayout
original={original}
titleOther={titleOther}
update={update}
disabled={disabled}
onConfirm={onConfirm}
onReset={onReset}
selector={selector}
file_contents_language={file_contents_language}
>
<div className="flex gap-6">
{!disableSidebar && (
<div className="hidden xl:block relative pr-6 border-r border-t rounded-md">
<div className="sticky top-24 hidden xl:flex flex-col gap-4 w-[140px] pb-24">
<div
className={cn(
"flex flex-col gap-8 h-fit pt-4 overflow-auto max-h-[calc(100vh-130px)]",
changesMade && "max-h-[calc(100vh-220px)]",
)}
>
{sections.map((section) => (
<div key={section}>
<div className="flex items-center gap-2 justify-end text-muted-foreground mb-2">
<Bookmark className="w-4 h-4" />
<p className="uppercase">{section || "GENERAL"}</p>
</div>
<div className="flex flex-col gap-2">
{components[section] &&
components[section]
.filter((item) => !item.hidden)
.map((item) => (
// uses a tags becasue react-router-dom Links don't reliably hash scroll
<a
href={"#" + section + item.label}
key={section + item.label}
>
<Button
variant="ghost"
className="justify-end w-full"
size="sm"
>
{item.label}
</Button>
</a>
))}
</div>
</div>
))}
</div>
{changesMade && (
<div className="flex flex-col gap-2">
<ConfirmUpdate
previous={original}
content={update}
onConfirm={onConfirm}
disabled={disabled || !changesMade}
file_contents_language={file_contents_language}
/>
<Button
variant="outline"
onClick={onReset}
disabled={disabled || !changesMade}
className="flex items-center gap-2"
>
<History className="w-4 h-4" />
Reset
</Button>
</div>
)}
</div>
</div>
)}
<div className="w-full flex flex-col gap-12">
{sections.map(
(section) =>
components[section] && (
<div
key={section}
className="relative pb-12 border-b last:pb-0 last:border-b-0 "
>
<div className="xl:hidden sticky top-16 h-16 flex items-center justify-between bg-background z-10">
{section && <p className="uppercase text-2xl">{section}</p>}
<Select
onValueChange={(value) => (window.location.hash = value)}
>
<SelectTrigger className="w-32 capitalize xl:hidden">
<SelectValue placeholder="Go To" />
</SelectTrigger>
<SelectContent className="w-32">
{components[section]
.filter((item) => !item.hidden)
.map(({ label }) => (
<SelectItem
key={section + label}
value={section + label}
className="capitalize"
>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{section && (
<p className="hidden xl:block bg-background text-2xl uppercase mb-6 h-fit">
{section}
</p>
)}
<div className="flex flex-col gap-6 w-full">
{components[section].map(
({
label,
boldLabel = true,
labelHidden,
icon,
labelExtra,
actions,
description,
hidden,
contentHidden,
components,
}) => (
<div
key={section + label}
id={section + label}
className={cn(
"p-6 border rounded-md flex flex-col gap-6 scroll-mt-40 xl:scroll-mt-24",
hidden && "hidden",
)}
>
{!labelHidden && (
<div className="flex justify-between">
<div>
<div className="flex items-center gap-4">
{icon}
<div
className={cn(
"text-lg",
boldLabel && "font-bold",
)}
>
{label}
</div>
{labelExtra}
</div>
{description && (
<div className="text-sm text-muted-foreground">
{description}
</div>
)}
</div>
{actions}
</div>
)}
{!contentHidden && (
<ConfigAgain
config={original}
update={update}
set={(u) => set((p) => ({ ...p, ...u }))}
components={components}
disabled={disabled}
/>
)}
</div>
),
)}
</div>
</div>
),
)}
{changesMade && (
<div className="flex gap-2 justify-end">
<div className="text-muted-foreground flex items-center gap-2">
<AlertTriangle className="w-4 h-4" /> Unsaved changes
<AlertTriangle className="w-4 h-4" />
</div>
<Button
variant="outline"
onClick={onReset}
disabled={disabled}
className="flex items-center gap-2"
>
<History className="w-4 h-4" />
Reset
</Button>
<ConfirmUpdate
previous={original}
content={update}
onConfirm={onConfirm}
disabled={disabled}
file_contents_language={file_contents_language}
/>
</div>
)}
</div>
</div>
</ConfigLayout>
);
};
export const ConfigAgain = <
T extends Types.Resource<unknown, unknown>["config"],
>({
config,
update,
disabled,
components,
set,
}: {
config: T;
update: Partial<T>;
disabled: boolean;
components: Partial<{
[K in keyof T extends string ? keyof T : never]:
| boolean
| PrimitiveConfigArgs
| ((value: T[K], set: (value: Partial<T>) => void) => ReactNode);
}>;
set: (value: Partial<T>) => void;
}) => {
return (
<>
{keys(components).map((key) => {
const component = components[key];
const value = update[key] ?? config[key];
if (typeof component === "function") {
return (
<Fragment key={key.toString()}>{component(value, set)}</Fragment>
);
} else if (typeof component === "object" || component === true) {
const args =
typeof component === "object"
? (component as PrimitiveConfigArgs)
: undefined;
if (args?.hidden) return null;
switch (typeof value) {
case "string":
return (
<ConfigInput
key={key.toString()}
label={args?.label ?? key.toString()}
value={value}
onChange={(value) => set({ [key]: value } as Partial<T>)}
disabled={disabled}
placeholder={args?.placeholder}
description={args?.description}
boldLabel={args?.boldLabel}
/>
);
case "number":
return (
<ConfigInput
key={key.toString()}
label={args?.label ?? key.toString()}
value={Number(value)}
onChange={(value) =>
set({ [key]: Number(value) } as Partial<T>)
}
disabled={disabled}
placeholder={args?.placeholder}
description={args?.description}
boldLabel={args?.boldLabel}
/>
);
case "boolean":
return (
<ConfigSwitch
key={key.toString()}
label={args?.label ?? key.toString()}
value={value}
onChange={(value) => set({ [key]: value } as Partial<T>)}
disabled={disabled}
description={args?.description}
boldLabel={args?.boldLabel}
/>
);
default:
return (
<div key={key.toString()}>{args?.label ?? key.toString()}</div>
);
}
} else {
return <Fragment key={key.toString()} />;
}
})}
</>
);
};

View File

@@ -1,58 +0,0 @@
import { ResourceLink, ResourceSelector } from "@components/resources/common";
import { ConfigItem } from "./util";
export const LinkedRepoConfig = ({
linked_repo,
repo_linked,
set,
disabled,
}: {
linked_repo: string | undefined;
repo_linked: boolean;
set: (update: {
linked_repo: string;
// Set other props back to default.
git_provider: string;
git_account: string;
git_https: boolean;
repo: string;
branch: string;
commit: string;
}) => void;
disabled: boolean;
}) => {
return (
<ConfigItem
label={
linked_repo ? (
<div className="flex gap-3 text-lg font-bold">
Repo:
<ResourceLink type="Repo" id={linked_repo} />
</div>
) : (
"Select Repo"
)
}
description={`Select an existing Repo to attach${!repo_linked ? ", or configure the repo below" : ""}.`}
>
<ResourceSelector
type="Repo"
selected={linked_repo}
onSelect={(linked_repo) =>
set({
linked_repo,
// Set other props back to default.
git_provider: "github.com",
git_account: "",
git_https: true,
repo: linked_repo ? "" : "namespace/repo",
branch: "main",
commit: "",
})
}
disabled={disabled}
align="start"
/>
</ConfigItem>
);
};

View File

@@ -1,511 +0,0 @@
import { Button } from "@ui/button";
import { Input } from "@ui/input";
import { Switch } from "@ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/dialog";
import { Badge } from "@ui/badge";
import { DataTable, SortableHeader } from "@ui/data-table";
import { Types } from "komodo_client";
import { useState } from "react";
import {
PlusCircle,
Pen,
Clock,
Calendar,
CalendarDays,
Trash,
} from "lucide-react";
import { TimezoneSelector } from "@components/util";
export const MaintenanceWindows = ({
windows,
onUpdate,
disabled,
}: {
windows: Types.MaintenanceWindow[];
onUpdate: (windows: Types.MaintenanceWindow[]) => void;
disabled: boolean;
}) => {
const [isCreating, setIsCreating] = useState(false);
const [editingWindow, setEditingWindow] = useState<
[number, Types.MaintenanceWindow] | null
>(null);
const addWindow = (newWindow: Types.MaintenanceWindow) => {
onUpdate([...windows, newWindow]);
setIsCreating(false);
};
const updateWindow = (
index: number,
updatedWindow: Types.MaintenanceWindow
) => {
onUpdate(windows.map((w, i) => (i === index ? updatedWindow : w)));
setEditingWindow(null);
};
const deleteWindow = (index: number) => {
onUpdate(windows.filter((_, i) => i !== index));
};
const toggleWindow = (index: number, enabled: boolean) => {
onUpdate(windows.map((w, i) => (i === index ? { ...w, enabled } : w)));
};
return (
<div className="space-y-4">
{!disabled && (
<Dialog open={isCreating} onOpenChange={setIsCreating}>
<DialogTrigger asChild>
<Button variant="secondary" className="flex items-center gap-2">
<PlusCircle className="w-4 h-4" />
Add Maintenance Window
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<MaintenanceWindowForm
onSave={addWindow}
onCancel={() => setIsCreating(false)}
/>
</DialogContent>
</Dialog>
)}
{windows.length > 0 && (
<DataTable
tableKey="maintenance-windows"
data={windows}
columns={[
{
accessorKey: "name",
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<ScheduleIcon
scheduleType={
row.original.schedule_type ??
Types.MaintenanceScheduleType.Daily
}
/>
<span className="font-medium">{row.original.name}</span>
</div>
),
size: 200,
},
{
accessorKey: "schedule_type",
header: ({ column }) => (
<SortableHeader column={column} title="Schedule" />
),
cell: ({ row }) => (
<span className="text-sm">
<ScheduleDescription window={row.original} />
</span>
),
size: 150,
},
{
accessorKey: "start_time",
header: ({ column }) => (
<SortableHeader column={column} title="Start Time" />
),
cell: ({ row }) => (
<span className="text-sm font-mono">
{formatTime(row.original)}
</span>
),
size: 180,
},
{
accessorKey: "duration_minutes",
header: ({ column }) => (
<SortableHeader column={column} title="Duration" />
),
cell: ({ row }) => (
<span className="text-sm">
{row.original.duration_minutes} min
</span>
),
size: 100,
},
{
accessorKey: "enabled",
header: ({ column }) => (
<SortableHeader column={column} title="Status" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Badge
variant={row.original.enabled ? "default" : "secondary"}
>
{row.original.enabled ? "Enabled" : "Disabled"}
</Badge>
{!disabled && (
<Switch
checked={row.original.enabled}
onCheckedChange={(enabled) =>
toggleWindow(row.index, enabled)
}
/>
)}
</div>
),
size: 120,
},
{
id: "actions",
header: "Actions",
cell: ({ row }) =>
!disabled && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() =>
setEditingWindow([row.index, row.original])
}
className="h-8 w-8 p-0"
>
<Pen className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => deleteWindow(row.index)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
>
<Trash className="w-4 h-4" />
</Button>
</div>
),
size: 100,
},
]}
/>
)}
{editingWindow && (
<Dialog
open={!!editingWindow}
onOpenChange={() => setEditingWindow(null)}
>
<DialogContent className="max-w-2xl">
<MaintenanceWindowForm
initialData={editingWindow[1]}
onSave={(window) => updateWindow(editingWindow[0], window)}
onCancel={() => setEditingWindow(null)}
/>
</DialogContent>
</Dialog>
)}
</div>
);
};
const ScheduleIcon = ({
scheduleType,
}: {
scheduleType: Types.MaintenanceScheduleType;
}) => {
switch (scheduleType) {
case "Daily":
return <Clock className="w-4 h-4" />;
case "Weekly":
return <Calendar className="w-4 h-4" />;
case "OneTime":
return <CalendarDays className="w-4 h-4" />;
default:
return <Clock className="w-4 h-4" />;
}
};
const ScheduleDescription = ({
window,
}: {
window: Types.MaintenanceWindow;
}): string => {
switch (window.schedule_type) {
case "Daily":
return "Daily";
case "Weekly":
return `Weekly (${window.day_of_week || "Monday"})`;
case "OneTime":
return `One-time (${window.date || "No date"})`;
default:
return "Unknown";
}
};
const formatTime = (window: Types.MaintenanceWindow) => {
const hours = window.hour!.toString().padStart(2, "0");
const minutes = window.minute!.toString().padStart(2, "0");
return `${hours}:${minutes} ${window.timezone ? `(${window.timezone})` : ""}`;
};
interface MaintenanceWindowFormProps {
initialData?: Types.MaintenanceWindow;
onSave: (window: Types.MaintenanceWindow) => void;
onCancel: () => void;
}
const MaintenanceWindowForm = ({
initialData,
onSave,
onCancel,
}: MaintenanceWindowFormProps) => {
const [formData, setFormData] = useState<Types.MaintenanceWindow>(
initialData || {
name: "",
description: "",
schedule_type: Types.MaintenanceScheduleType.Daily,
day_of_week: "",
date: "",
hour: 5,
minute: 0,
timezone: "",
duration_minutes: 60,
enabled: true,
}
);
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.name.trim()) {
newErrors.name = "Name is required";
}
if (formData.hour! < 0 || formData.hour! > 23) {
newErrors.hour = "Hour must be between 0 and 23";
}
if (formData.minute! < 0 || formData.minute! > 59) {
newErrors.minute = "Minute must be between 0 and 59";
}
if (formData.duration_minutes <= 0) {
newErrors.duration = "Duration must be greater than 0";
}
if (formData.schedule_type && formData.schedule_type === "OneTime") {
const date = formData.date;
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
newErrors.date = "Date must be in YYYY-MM-DD format";
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (validate()) {
onSave(formData);
}
};
const updateScheduleType = (schedule_type: Types.MaintenanceScheduleType) => {
setFormData((data) => ({
...data,
schedule_type,
day_of_week:
schedule_type === Types.MaintenanceScheduleType.Weekly ? "Monday" : "",
date:
schedule_type === Types.MaintenanceScheduleType.OneTime
? new Date().toISOString().split("T")[0]
: "",
}));
};
return (
<>
<DialogHeader>
<DialogTitle>
{initialData
? "Edit Maintenance Window"
: "Create Maintenance Window"}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">Name</label>
<Input
value={formData.name}
onChange={(e) =>
setFormData((data) => ({ ...data, name: e.target.value }))
}
placeholder="e.g., Daily Backup"
className={errors.name ? "border-destructive" : ""}
/>
{errors.name && (
<p className="text-sm text-destructive mt-1">{errors.name}</p>
)}
</div>
<div>
<label className="text-sm font-medium">Schedule Type</label>
<Select
value={formData.schedule_type}
onValueChange={(value: Types.MaintenanceScheduleType) =>
updateScheduleType(value)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(Types.MaintenanceScheduleType).map(
(schedule_type) => (
<SelectItem key={schedule_type} value={schedule_type}>
{schedule_type}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
{formData.schedule_type === "Weekly" && (
<div>
<label className="text-sm font-medium">Day of Week</label>
<Select
value={formData.day_of_week || "Monday"}
onValueChange={(value: Types.DayOfWeek) =>
setFormData((data) => ({
...data,
day_of_week: value,
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(Types.DayOfWeek).map((day_of_week) => (
<SelectItem key={day_of_week} value={day_of_week}>
{day_of_week}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{formData.schedule_type === "OneTime" && (
<div>
<label className="text-sm font-medium">Date</label>
<Input
type="date"
value={formData.date || new Date().toISOString().split("T")[0]}
onChange={(e) =>
setFormData({
...formData,
date: e.target.value,
})
}
className={errors.date ? "border-destructive" : ""}
/>
{errors.date && (
<p className="text-sm text-destructive mt-1">{errors.date}</p>
)}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">Start Time</label>
<Input
type="time"
value={`${formData.hour!.toString().padStart(2, "0")}:${formData.minute!.toString().padStart(2, "0")}`}
onChange={(e) => {
const [hour, minute] = e.target.value
.split(":")
.map((n) => parseInt(n) || 0);
setFormData({
...formData,
hour,
minute,
});
}}
className={
errors.hour || errors.minute ? "border-destructive" : ""
}
/>
{(errors.hour || errors.minute) && (
<p className="text-sm text-destructive mt-1">
{errors.hour || errors.minute}
</p>
)}
</div>
<div>
<label className="text-sm font-medium">Timezone</label>
<TimezoneSelector
timezone={formData.timezone ?? ""}
onChange={(timezone) =>
setFormData((data) => ({ ...data, timezone }))
}
triggerClassName="w-full"
/>
</div>
</div>
<div>
<label className="text-sm font-medium">Duration (minutes)</label>
<Input
type="number"
min={1}
value={formData.duration_minutes}
onChange={(e) =>
setFormData((data) => ({
...data,
duration_minutes: parseInt(e.target.value) || 60,
}))
}
className={errors.duration ? "border-destructive" : ""}
/>
{errors.duration && (
<p className="text-sm text-destructive mt-1">{errors.duration}</p>
)}
</div>
<div>
<label className="text-sm font-medium">Description (optional)</label>
<Input
value={formData.description}
onChange={(e) =>
setFormData((data) => ({ ...data, description: e.target.value }))
}
placeholder="e.g., Automated backup process"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleSave}>
{initialData ? "Update" : "Create"}
</Button>
</DialogFooter>
</>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,99 +0,0 @@
import { useRead } from "@lib/hooks";
import { Types } from "komodo_client";
import { Button } from "@ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/dialog";
import { FileDown, Loader2 } from "lucide-react";
import { useState } from "react";
import { CopyButton } from "./util";
import { MonacoEditor } from "./monaco";
export const ExportButton = ({
targets,
user_groups,
tags,
include_variables,
}: {
targets?: Types.ResourceTarget[];
user_groups?: string[];
tags?: string[];
include_variables?: boolean;
}) => {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="secondary" className="flex gap-2 items-center">
<FileDown className="w-4 h-4" />
Toml
</Button>
</DialogTrigger>
<DialogContent className="w-[900px] max-w-[95vw]">
<DialogHeader>
<DialogTitle>Export to Toml</DialogTitle>
</DialogHeader>
{targets || user_groups || include_variables ? (
<ExportTargetsLoader
targets={targets}
user_groups={user_groups}
include_variables={include_variables}
/>
) : (
<ExportAllLoader tags={tags} />
)}
</DialogContent>
</Dialog>
);
};
const ExportTargetsLoader = ({
targets,
user_groups,
include_variables,
}: {
targets?: Types.ResourceTarget[];
user_groups?: string[];
include_variables?: boolean;
}) => {
const { data, isPending } = useRead("ExportResourcesToToml", {
targets: targets ? targets : [],
user_groups: user_groups ? user_groups : [],
include_variables,
});
return <ExportPre loading={isPending} content={data?.toml} />;
};
const ExportAllLoader = ({
tags,
}: {
tags?: string[];
}) => {
const { data, isPending } = useRead("ExportAllResourcesToToml", {
tags,
include_resources: true,
include_variables: true,
include_user_groups: true,
});
return <ExportPre loading={isPending} content={data?.toml} />;
};
const ExportPre = ({
loading,
content,
}: {
loading: boolean;
content: string | undefined;
}) => {
return (
<div className="relative flex justify-center w-full overflow-y-scroll max-h-[80vh]">
{loading && <Loader2 className="w-8 h-8 animate-spin" />}
<MonacoEditor value={content} language="fancy_toml" readOnly />
<CopyButton content={content} className="absolute top-4 right-4" />
</div>
);
};

View File

@@ -1,202 +0,0 @@
import { useSelectedResources, useExecute, useWrite } from "@lib/hooks";
import { UsableResource } from "@types";
import { Button } from "@ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@ui/dropdown-menu";
import { Input } from "@ui/input";
import { Types } from "komodo_client";
import { ChevronDown, CheckCircle } from "lucide-react";
import { useState } from "react";
import { ConfirmButton } from "./util";
import { useToast } from "@ui/use-toast";
import { usableResourceExecuteKey } from "@lib/utils";
export const GroupActions = <
T extends Types.ExecuteRequest["type"] | Types.WriteRequest["type"],
>({
type,
actions,
}: {
type: UsableResource;
actions: T[];
}) => {
const [action, setAction] = useState<T>();
const [selected] = useSelectedResources(type);
return (
<>
<GroupActionDropdownMenu
type={type}
actions={actions}
onSelect={setAction}
disabled={!selected.length}
/>
<GroupActionDialog
type={type}
action={action}
onClose={() => setAction(undefined)}
/>
</>
);
};
const GroupActionDropdownMenu = <
T extends Types.ExecuteRequest["type"] | Types.WriteRequest["type"],
>({
type,
actions,
onSelect,
disabled,
}: {
type: UsableResource;
actions: T[];
onSelect: (item: T) => void;
disabled: boolean;
}) => (
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={disabled}>
<Button variant="outline" className="w-40 justify-between">
Group Actions <ChevronDown className="w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className={type === "Server" ? "w-56" : "w-40"}
>
{type === "ResourceSync" && (
<DropdownMenuItem
onClick={() => onSelect("RefreshResourceSyncPending" as any)}
>
<Button variant="secondary" className="w-full">
Refresh
</Button>
</DropdownMenuItem>
)}
{actions.map((action) => (
<DropdownMenuItem key={action} onClick={() => onSelect(action)}>
<Button variant="secondary" className="w-full">
{action === "RunBuild"
? "Build"
: action.replaceAll("Batch", "").replaceAll(type, "")}
</Button>
</DropdownMenuItem>
))}
<DropdownMenuItem onClick={() => onSelect(`Delete${type}` as any)}>
<Button variant="destructive" className="w-full">
Delete
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
const GroupActionDialog = ({
type,
action,
onClose,
}: {
type: UsableResource;
action:
| (Types.ExecuteRequest["type"] | Types.WriteRequest["type"])
| undefined;
onClose: () => void;
}) => {
const { toast } = useToast();
const [selected, setSelected] = useSelectedResources(type);
const [text, setText] = useState("");
const { mutate: execute, isPending: executePending } = useExecute(
action! as Types.ExecuteRequest["type"],
{
onSuccess: onClose,
},
);
const { mutate: write, isPending: writePending } = useWrite(
action! as Types.WriteRequest["type"],
{
onSuccess: onClose,
},
);
if (!action) return;
const formatted = action.replaceAll("Batch", "").replaceAll(type, "");
const isPending = executePending || writePending;
return (
<Dialog open={!!action} onOpenChange={(o) => !o && onClose()}>
<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 max-h-[300px] overflow-y-auto">
{selected.map((resource) => (
<li key={resource}>{resource}</li>
))}
</ul>
{!action.startsWith("Refresh") && !action.startsWith("Check") && (
<>
<p
onClick={() => {
navigator.clipboard.writeText(formatted);
toast({ title: `Copied "${formatted}" to clipboard!` });
}}
className="cursor-pointer"
>
Please enter <b>{formatted}</b> below to confirm this action.
<br />
<span className="text-xs text-muted-foreground">
You may click the action in bold to copy it
</span>
</p>
<Input value={text} onChange={(e) => setText(e.target.value)} />
</>
)}
</div>
<DialogFooter>
<ConfirmButton
title="Confirm"
icon={<CheckCircle className="w-4 h-4" />}
onClick={() => {
for (const resource of selected) {
if (action.startsWith("Delete")) {
write({ id: resource } as any);
} else if (
action.startsWith("Refresh") ||
action.startsWith("Check")
) {
write({ [usableResourceExecuteKey(type)]: resource } as any);
} else {
execute({
[usableResourceExecuteKey(type)]: resource,
} as any);
}
}
if (action.startsWith("Delete")) {
setSelected([]);
}
}}
disabled={
action.startsWith("Refresh") || action.startsWith("Check")
? false
: text !== formatted
}
loading={isPending}
/>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,45 +0,0 @@
import { Loader2 } from "lucide-react";
import { MonacoEditor } from "./monaco";
export const InspectResponseViewer = ({
response,
error,
isPending,
isError,
}: {
response: Record<any, any> | undefined;
error: unknown;
isPending: boolean;
isError: boolean;
}) => {
if (isPending) {
return (
<div className="flex justify-center w-full py-4 min-h-[60vh]">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
}
if (isError) {
return (
<div className="min-h-[60vh] flex flex-col">
<h1 className="flex w-full py-4">Failed to inspect.</h1>
{(error ?? undefined) && (
<MonacoEditor
value={JSON.stringify(error, null, 2)}
language="json"
readOnly
/>
)}
</div>
);
}
return (
<div className="min-h-[60vh]">
<MonacoEditor
value={JSON.stringify(response, null, 2)}
language="json"
readOnly
/>
</div>
);
};

View File

@@ -1,354 +0,0 @@
import { Button } from "@ui/button";
import { PlusCircle } from "lucide-react";
import { ReactNode, useState } from "react";
import { Link, Outlet, useNavigate } from "react-router-dom";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/dialog";
import { Types } from "komodo_client";
import { ResourceComponents } from "./resources";
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@ui/card";
import { ResourceTags } from "./tags";
import { Topbar } from "./topbar";
import { cn, usableResourcePath } from "@lib/utils";
import { Sidebar } from "./sidebar";
import { ResourceNameSimple } from "./resources/common";
import { useSettingsView, useShiftKeyListener } from "@lib/hooks";
export const Layout = () => {
const nav = useNavigate();
const [_, setSettingsView] = useSettingsView();
useShiftKeyListener("H", () => nav("/"));
useShiftKeyListener("G", () => nav("/servers"));
useShiftKeyListener("Z", () => nav("/stacks"));
useShiftKeyListener("D", () => nav("/deployments"));
useShiftKeyListener("B", () => nav("/builds"));
useShiftKeyListener("R", () => nav("/repos"));
useShiftKeyListener("P", () => nav("/procedures"));
useShiftKeyListener("X", () => nav("/terminals"));
useShiftKeyListener("C", () => nav("/schedules"));
useShiftKeyListener("V", () => {
setSettingsView("Variables");
nav("/settings");
});
return (
<>
<Topbar />
<div className="h-screen overflow-y-scroll">
<div className="container px-[1.2rem]">
<Sidebar />
<div className="lg:ml-[200px] lg:pl-8 py-[88px]">
<Outlet />
</div>
</div>
</div>
</>
);
};
interface PageProps {
title?: ReactNode;
icon?: ReactNode;
titleRight?: ReactNode;
titleOther?: ReactNode;
children?: ReactNode;
subtitle?: ReactNode;
actions?: ReactNode;
superHeader?: ReactNode;
className?: string;
}
export const Page = ({
superHeader,
title,
icon,
titleRight,
titleOther,
subtitle,
actions,
children,
className,
}: PageProps) => {
const Header = (
<>
{(title || icon || subtitle || actions) && (
<div
className={`flex flex-col gap-6 md:flex-row md:gap-0 md:justify-between`}
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4 items-center">
{icon}
<h1 className="text-4xl">{title}</h1>
{titleRight}
</div>
<div className="flex flex-col">{subtitle}</div>
</div>
{actions}
</div>
)}
</>
);
return (
<div className={cn("w-full flex flex-col gap-12", className)}>
{superHeader ? (
<div className="flex flex-col gap-4">
{superHeader}
{Header}
</div>
) : (
Header
)}
{titleOther}
{children}
</div>
);
};
export const PageXlRow = ({
superHeader,
title,
icon,
titleRight,
titleOther,
subtitle,
actions,
children,
}: PageProps) => (
<div className="flex flex-col gap-10 container py-8 pr-12">
{superHeader ? (
<div className="flex flex-col gap-4">
{superHeader}
{(title || icon || subtitle || actions) && (
<div
className={`flex flex-col gap-6 lg:flex-row lg:gap-0 lg:justify-between`}
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4 items-center">
{icon}
<h1 className="text-4xl">{title}</h1>
{titleRight}
</div>
<div className="flex flex-col">{subtitle}</div>
</div>
{actions}
</div>
)}
</div>
) : (
(title || icon || subtitle || actions) && (
<div
className={`flex flex-col gap-6 lg:flex-row lg:gap-0 lg:justify-between`}
>
<div className="flex flex-col gap-4">
<div className="flex flex-wrap gap-4 items-center">
{icon}
<h1 className="text-4xl">{title}</h1>
{titleRight}
</div>
<div className="flex flex-col">{subtitle}</div>
</div>
{actions}
</div>
)
)}
{titleOther}
{children}
</div>
);
interface SectionProps {
title?: ReactNode;
icon?: ReactNode;
titleRight?: ReactNode;
titleOther?: ReactNode;
children?: ReactNode;
actions?: ReactNode;
// otherwise items-start
itemsCenterTitleRow?: boolean;
className?: string;
}
export const Section = ({
title,
icon,
titleRight,
titleOther,
actions,
children,
itemsCenterTitleRow,
className,
}: SectionProps) => (
<div className={cn("flex flex-col gap-4", className)}>
{(title || icon || titleRight || titleOther || actions) && (
<div
className={cn(
"flex flex-wrap gap-4 justify-between mb-3",
itemsCenterTitleRow ? "items-center" : "items-start"
)}
>
{title || icon ? (
<div className="px-2 flex items-center gap-2 text-muted-foreground">
{icon}
{title && <h2 className="text-xl">{title}</h2>}
{titleRight}
</div>
) : (
titleOther
)}
{actions}
</div>
)}
{children}
</div>
);
export const NewLayout = ({
entityType,
children,
enabled,
onConfirm,
onOpenChange,
configureLabel = "a unique name",
open: _open,
setOpen: _setOpen,
}: {
entityType: string;
children: ReactNode;
enabled: boolean;
onConfirm: () => Promise<unknown>;
onOpenChange?: (open: boolean) => void;
configureLabel?: string;
open?: boolean;
setOpen?: (open: boolean) => void;
}) => {
const [__open, __setOpen] = useState(false);
const open = _open ? _open : __open;
const setOpen = _setOpen ? _setOpen : __setOpen;
const [loading, setLoading] = useState(false);
return (
<Dialog
open={open}
onOpenChange={(open) => {
setOpen(open);
onOpenChange && onOpenChange(open);
}}
>
<DialogTrigger asChild>
<Button className="items-center gap-2" variant="secondary">
New {entityType} <PlusCircle className="w-4 h-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New {entityType}</DialogTitle>
<DialogDescription>
Enter {configureLabel} for the new {entityType}.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6 py-8">{children}</div>
<DialogFooter>
<Button
variant="secondary"
onClick={async () => {
setLoading(true);
try {
await onConfirm();
setOpen(false);
} catch (error: any) {
const status = error?.status || error?.response?.status;
if (status !== 409 && status !== 400) {
setOpen(false);
}
} finally {
setLoading(false);
}
}}
disabled={!enabled || loading}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const ResourceCard = ({
target: { type, id },
}: {
target: Exclude<Types.ResourceTarget, { type: "System" }>;
}) => {
const Components = ResourceComponents[type];
return (
<Link
to={`/${usableResourcePath(type)}/${id}`}
className="group hover:translate-y-[-2.5%] focus:translate-y-[-2.5%] transition-transform"
>
<Card className="h-full hover:bg-accent/50 group-focus:bg-accent/50 transition-colors">
<CardHeader className="flex-row justify-between">
<div>
<CardTitle>
<ResourceNameSimple type={type} id={id} />
</CardTitle>
{/* <CardDescription>
<Components.Description id={id} />
</CardDescription> */}
</div>
<Components.Icon id={id} />
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{Object.entries(Components.Info).map(([key, Info]) => (
<Info key={key} id={id} />
))}
</CardContent>
<CardFooter className="flex items-center gap-2">
<ResourceTags target={{ type, id }} />
</CardFooter>
</Card>
</Link>
);
};
export const ResourceRow = ({
target: { type, id },
}: {
target: Exclude<Types.ResourceTarget, { type: "System" }>;
}) => {
const Components = ResourceComponents[type];
return (
<Link
to={`/${usableResourcePath(type)}/${id}`}
className="group hover:translate-y-[-2.5%] focus:translate-y-[-2.5%] transition-transform"
>
<Card className="h-full hover:bg-accent/50 group-focus:bg-accent/50 transition-colors">
<CardHeader className="grid grid-cols-4 items-center">
<CardTitle>
<ResourceNameSimple type={type} id={id} />
</CardTitle>
{Object.entries(Components.Info).map(([key, Info]) => (
<Info key={key} id={id} />
))}
<div className="flex items-center gap-2">
<Components.Icon id={id} />
{/* <CardDescription>
<Components.Description id={id} />
</CardDescription> */}
</div>
</CardHeader>
</Card>
</Link>
);
};

View File

@@ -1,234 +0,0 @@
import { logToHtml } from "@lib/utils";
import { Types } from "komodo_client";
import { Button } from "@ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import {
AlertOctagon,
ChevronDown,
RefreshCw,
ScrollText,
X,
} from "lucide-react";
import { ReactNode, useEffect, useRef, useState } from "react";
import { Section } from "./layouts";
import { Switch } from "@ui/switch";
import { Input } from "@ui/input";
import { ToggleGroup, ToggleGroupItem } from "@ui/toggle-group";
import { useToast } from "@ui/use-toast";
import { useLocalStorage } from "@lib/hooks";
export type LogStream = "stdout" | "stderr";
export const LogSection = ({
regular_logs,
search_logs,
titleOther,
extraParams,
}: {
regular_logs: (
timestamps: boolean,
stream: LogStream,
tail: number,
poll: boolean
) => {
Log: ReactNode;
refetch: () => void;
stderr: boolean;
};
search_logs: (
timestamps: boolean,
terms: string[],
invert: boolean,
poll: boolean
) => { Log: ReactNode; refetch: () => void; stderr: boolean };
titleOther?: ReactNode;
extraParams?: ReactNode;
}) => {
const { toast } = useToast();
const [timestamps, setTimestamps] = useLocalStorage(
"log-timestamps-v1",
false
);
const [stream, setStream] = useState<LogStream>("stdout");
const [tail, set] = useState("100");
const [terms, setTerms] = useState<string[]>([]);
const [invert, setInvert] = useState(false);
const [search, setSearch] = useState("");
const [poll, setPoll] = useLocalStorage("log-poll-v1", false);
const addTerm = () => {
if (!search.length) return;
if (terms.includes(search)) {
toast({ title: "Search term is already present" });
setSearch("");
return;
}
setTerms([...terms, search]);
setSearch("");
};
const clearSearch = () => {
setSearch("");
setTerms([]);
};
const { Log, refetch, stderr } = terms.length
? search_logs(timestamps, terms, invert, poll)
: regular_logs(timestamps, stream, Number(tail), poll);
return (
<Section
title={titleOther ? undefined : "Log"}
icon={titleOther ? undefined : <ScrollText className="w-4 h-4" />}
titleOther={titleOther}
itemsCenterTitleRow
actions={
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<div className="text-muted-foreground flex gap-1 text-sm">
Invert
</div>
<Switch checked={invert} onCheckedChange={setInvert} />
</div>
{terms.map((term, index) => (
<Button
key={term}
variant="destructive"
onClick={() => setTerms(terms.filter((_, i) => i !== index))}
className="flex gap-2 items-center py-0 px-2"
>
{term}
<X className="w-4 h-h" />
</Button>
))}
<div className="relative">
<Input
placeholder="Search Logs"
value={search}
onChange={(e) => setSearch(e.target.value)}
onBlur={addTerm}
onKeyDown={(e) => {
if (e.key === "Enter") addTerm();
}}
className="w-[180px] xl:w-[240px]"
/>
<Button
variant="ghost"
size="icon"
onClick={clearSearch}
className="absolute right-0 top-1/2 -translate-y-1/2"
>
<X className="w-4 h-4" />
</Button>
</div>
<ToggleGroup
type="single"
value={stream}
onValueChange={setStream as any}
>
<ToggleGroupItem value="stdout">stdout</ToggleGroupItem>
<ToggleGroupItem value="stderr">
stderr
{stderr && (
<AlertOctagon className="w-4 h-4 ml-2 stroke-red-500" />
)}
</ToggleGroupItem>
</ToggleGroup>
<Button variant="secondary" size="icon" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4" />
</Button>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setTimestamps((t) => !t)}
>
<div className="text-muted-foreground text-sm">Timestamps</div>
<Switch checked={timestamps} />
</div>
<div
className="flex items-center gap-2 cursor-pointer"
onClick={() => setPoll((p) => !p)}
>
<div className="text-muted-foreground text-sm">Poll</div>
<Switch checked={poll} />
</div>
<TailLengthSelector
selected={tail}
onSelect={set}
disabled={search.length > 0}
/>
{extraParams}
</div>
}
>
{Log}
</Section>
);
};
export const Log = ({
log,
stream,
}: {
log: Types.Log | undefined;
stream: "stdout" | "stderr";
}) => {
const _log = log?.[stream as keyof typeof log] as string | undefined;
const ref = useRef<HTMLDivElement>(null);
const scroll = () =>
ref.current?.scroll({
top: ref.current.scrollHeight,
behavior: "smooth",
});
useEffect(scroll, [_log]);
return (
<>
<div ref={ref} className="h-[75vh] overflow-y-auto">
<pre
dangerouslySetInnerHTML={{
__html: _log ? logToHtml(_log) : `no ${stream} logs`,
}}
className="-scroll-mt-24 pb-[20vh]"
/>
</div>
<Button
variant="secondary"
className="absolute top-4 right-4"
onClick={scroll}
>
<ChevronDown className="h-4 w-4" />
</Button>
</>
);
};
export const TailLengthSelector = ({
selected,
onSelect,
disabled,
}: {
selected: string;
onSelect: (value: string) => void;
disabled?: boolean;
}) => (
<Select value={selected} onValueChange={onSelect} disabled={disabled}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{["100", "500", "1000", "5000"].map((length) => (
<SelectItem key={length} value={length}>
{length} lines
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);

View File

@@ -1,349 +0,0 @@
import {
useAllResources,
useLocalStorage,
useRead,
useSettingsView,
useUser,
} from "@lib/hooks";
import { Button } from "@ui/button";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandList,
CommandSeparator,
CommandItem,
} from "@ui/command";
import { Box, CalendarDays, Home, Search, Terminal, User } from "lucide-react";
import { Fragment, ReactNode, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
cn,
RESOURCE_TARGETS,
terminalLink,
usableResourcePath,
} from "@lib/utils";
import { Badge } from "@ui/badge";
import { ResourceComponents } from "./resources";
import { Switch } from "@ui/switch";
import { DOCKER_LINK_ICONS, TemplateMarker } from "./util";
import { UsableResource } from "@types";
export const OmniSearch = ({
className,
setOpen,
}: {
className?: string;
setOpen: (open: boolean) => void;
}) => {
return (
<Button
variant="outline"
onClick={() => setOpen(true)}
className={cn(
"flex items-center gap-4 w-fit md:w-[200px] lg:w-[300px] xl:w-[400px] justify-between hover:bg-card/50",
className
)}
>
<div className="flex items-center gap-4">
<Search className="w-4 h-4" />{" "}
<span className="text-muted-foreground hidden md:flex">Search</span>
</div>
<Badge
variant="outline"
className="text-muted-foreground hidden md:inline-flex"
>
shift + s
</Badge>
</Button>
);
};
type OmniItem = {
key: string;
type: UsableResource;
label: string;
icon: ReactNode;
template: boolean;
onSelect: () => void;
};
export const OmniDialog = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const [search, setSearch] = useState("");
const navigate = useNavigate();
const nav = (value: string) => {
setOpen(false);
navigate(value);
};
const items = useOmniItems(nav, search);
const [showContainers, setShowContainers] = useLocalStorage(
"omni-show-containers",
false
);
return (
<CommandDialog open={open} onOpenChange={setOpen} manualFilter>
<CommandInput
placeholder="Search for resources..."
value={search}
onValueChange={setSearch}
/>
<div className="flex gap-2 text-xs items-center justify-end px-2 py-1">
<div className="text-muted-foreground">Show containers</div>
<Switch checked={showContainers} onCheckedChange={setShowContainers} />
</div>
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{Object.entries(items)
.filter(([_, items]) => items.length > 0)
.map(([key, items], i) => (
<Fragment key={key}>
{i !== 0 && <CommandSeparator />}
<CommandGroup heading={key ? key : undefined}>
{items.map(({ key, type, label, icon, onSelect, template }) => (
<CommandItem
key={key}
value={key}
className="flex items-center gap-2 cursor-pointer"
onSelect={onSelect}
>
{icon}
{label}
{template && <TemplateMarker type={type} />}
</CommandItem>
))}
</CommandGroup>
</Fragment>
))}
{showContainers && (
<OmniContainers search={search} closeSearch={() => setOpen(false)} />
)}
<OmniTerminals search={search} closeSearch={() => setOpen(false)} />
</CommandList>
</CommandDialog>
);
};
const useOmniItems = (
nav: (path: string) => void,
search: string
): Record<string, OmniItem[]> => {
const user = useUser().data;
const resources = useAllResources();
const [_, setSettingsView] = useSettingsView();
return useMemo(() => {
const searchTerms = search
.toLowerCase()
.split(" ")
.filter((term) => term);
return {
"": [
{
key: "Home",
type: "Server" as UsableResource,
label: "Home",
icon: <Home className="w-4 h-4" />,
onSelect: () => nav("/"),
template: false,
},
...RESOURCE_TARGETS.map((_type) => {
const type = _type === "ResourceSync" ? "Sync" : _type;
const Components = ResourceComponents[_type];
return {
key: type + "s",
type: _type,
label: type + "s",
icon: <Components.Icon />,
onSelect: () => nav(usableResourcePath(_type)),
template: false,
};
}),
{
key: "Containers",
type: "Server" as UsableResource,
label: "Containers",
icon: <Box className="w-4 h-4" />,
onSelect: () => nav("/containers"),
template: false,
},
{
key: "Terminals",
type: "Server" as UsableResource,
label: "Terminals",
icon: <Terminal className="w-4 h-4" />,
onSelect: () => nav("/terminals"),
template: false,
},
{
key: "Schedules",
type: "Server" as UsableResource,
label: "Schedules",
icon: <CalendarDays className="w-4 h-4" />,
onSelect: () => nav("/schedules"),
template: false,
},
(user?.admin && {
key: "Users",
type: "Server" as UsableResource,
label: "Users",
icon: <User className="w-4 h-4" />,
onSelect: () => {
setSettingsView("Users");
nav("/settings");
},
template: false,
}) as OmniItem,
]
.filter((item) => item)
.filter((item) => {
const label = item.label.toLowerCase();
return (
searchTerms.length === 0 ||
searchTerms.every((term) => label.includes(term))
);
}),
...Object.fromEntries(
RESOURCE_TARGETS.map((_type) => {
const type = _type === "ResourceSync" ? "Sync" : _type;
const lower_type = type.toLowerCase();
const Components = ResourceComponents[_type];
return [
type + "s",
resources[_type]
?.filter((resource) => {
const lower_name = resource.name.toLowerCase();
return (
searchTerms.length === 0 ||
searchTerms.every(
(term) =>
lower_name.includes(term) || lower_type.includes(term)
)
);
})
.map((resource) => ({
key: type + "-" + resource.name,
type: _type,
label: resource.name,
icon: <Components.Icon id={resource.id} />,
onSelect: () =>
nav(`/${usableResourcePath(_type)}/${resource.id}`),
template: resource.template,
})) || [],
];
})
),
};
}, [user, resources, search]);
};
const OmniContainers = ({
search,
closeSearch,
}: {
search: string;
closeSearch: () => void;
}) => {
const _containers = useRead("ListAllDockerContainers", {}).data;
const containers = useMemo(() => {
return _containers?.filter((c) => {
const searchTerms = search
.toLowerCase()
.split(" ")
.filter((term) => term);
if (searchTerms.length === 0) return true;
const lower = c.name.toLowerCase();
return searchTerms.every(
(term) => lower.includes(term) || "containers".includes(term)
);
});
}, [_containers, search]);
const navigate = useNavigate();
if ((containers?.length ?? 0) < 1) return null;
return (
<>
<CommandSeparator />
<CommandGroup heading="Containers">
{containers?.map((container) => {
const key = container.server_id + container.name;
return (
<CommandItem
key={key}
value={key}
className="flex items-center gap-2 cursor-pointer"
onSelect={() => {
closeSearch();
navigate(
`/servers/${container.server_id!}/container/${container.name}`
);
}}
>
<DOCKER_LINK_ICONS.container
server_id={container.server_id!}
name={container.name}
/>
{container.name}
</CommandItem>
);
})}
</CommandGroup>
</>
);
};
const OmniTerminals = ({
search,
closeSearch,
}: {
search: string;
closeSearch: () => void;
}) => {
const _terminals = useRead("ListTerminals", {}).data;
const terminals = useMemo(() => {
return _terminals?.filter((c) => {
const searchTerms = search
.toLowerCase()
.split(" ")
.filter((term) => term);
if (searchTerms.length === 0) return true;
const lower = c.name.toLowerCase();
return searchTerms.every(
(term) => lower.includes(term) || "terminals".includes(term)
);
});
}, [_terminals, search]);
const navigate = useNavigate();
if ((terminals?.length ?? 0) < 1) return null;
return (
<>
<CommandSeparator />
<CommandGroup heading="Terminals">
{terminals?.map((terminal) => {
const key = JSON.stringify(terminal.target) + terminal.name;
return (
<CommandItem
key={key}
value={key}
className="flex items-center gap-2 cursor-pointer"
onSelect={() => {
closeSearch();
navigate(terminalLink(terminal));
}}
>
<Terminal className="w-4 h-4" />
{terminal.name}
</CommandItem>
);
})}
</CommandGroup>
</>
);
};

View File

@@ -1,164 +0,0 @@
import { ActionWithDialog, StatusBadge } from "@components/util";
import { useExecute, useRead } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { Clapperboard, Clock } from "lucide-react";
import { ActionConfig } from "./config";
import { ActionTable } from "./table";
import { DeleteResource, NewResource, ResourcePageHeader } from "../common";
import {
action_state_intention,
stroke_color_class_by_intention,
} from "@lib/color";
import { cn, updateLogToHtml } from "@lib/utils";
import { Types } from "komodo_client";
import { DashboardPieChart } from "@components/util";
import { GroupActions } from "@components/group-actions";
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
import { Card } from "@ui/card";
const useAction = (id?: string) =>
useRead("ListActions", {}).data?.find((d) => d.id === id);
const ActionIcon = ({ id, size }: { id?: string; size: number }) => {
const state = useAction(id)?.info.state;
const color = stroke_color_class_by_intention(action_state_intention(state));
return <Clapperboard className={cn(`w-${size} h-${size}`, state && color)} />;
};
export const ActionComponents: RequiredResourceComponents = {
list_item: (id) => useAction(id),
resource_links: () => undefined,
Description: () => <>Custom scripts using the Komodo client.</>,
Dashboard: () => {
const summary = useRead("GetActionsSummary", {}).data;
return (
<DashboardPieChart
data={[
{ title: "Ok", intention: "Good", value: summary?.ok ?? 0 },
{
title: "Running",
intention: "Warning",
value: summary?.running ?? 0,
},
{
title: "Failed",
intention: "Critical",
value: summary?.failed ?? 0,
},
{
title: "Unknown",
intention: "Unknown",
value: summary?.unknown ?? 0,
},
]}
/>
);
},
New: () => <NewResource type="Action" />,
GroupActions: () => <GroupActions type="Action" actions={["RunAction"]} />,
Table: ({ resources }) => (
<ActionTable actions={resources as Types.ActionListItem[]} />
),
Icon: ({ id }) => <ActionIcon id={id} size={4} />,
BigIcon: ({ id }) => <ActionIcon id={id} size={8} />,
State: ({ id }) => {
let state = useAction(id)?.info.state;
return <StatusBadge text={state} intent={action_state_intention(state)} />;
},
Status: {},
Info: {
Schedule: ({ id }) => {
const next_scheduled_run = useAction(id)?.info.next_scheduled_run;
return (
<div className="flex gap-2 items-center">
<Clock className="w-4 h-4" />
Next Run:
<div className="font-bold">
{next_scheduled_run
? new Date(next_scheduled_run).toLocaleString()
: "Not Scheduled"}
</div>
</div>
);
},
ScheduleErrors: ({ id }) => {
const error = useAction(id)?.info.schedule_error;
if (!error) {
return null;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Card className="px-3 py-2 bg-destructive/75 hover:bg-destructive transition-colors cursor-pointer">
<div className="text-sm text-nowrap overflow-hidden overflow-ellipsis">
Schedule Error
</div>
</Card>
</TooltipTrigger>
<TooltipContent className="w-[400px]">
<pre
dangerouslySetInnerHTML={{
__html: updateLogToHtml(error),
}}
className="max-h-[500px] overflow-y-auto"
/>
</TooltipContent>
</Tooltip>
);
},
},
Actions: {
RunAction: ({ id }) => {
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;
return (
<ActionWithDialog
name={action.name}
title={running ? "Running" : "Run Action"}
icon={<Clapperboard className="h-4 w-4" />}
onClick={() => mutate({ action: id, args: {} })}
disabled={running || isPending}
loading={running}
/>
);
},
},
Page: {},
Config: ActionConfig,
DangerZone: ({ id }) => <DeleteResource type="Action" id={id} />,
ResourcePageHeader: ({ id }) => {
const action = useAction(id);
return (
<ResourcePageHeader
intent={action_state_intention(action?.info.state)}
icon={<ActionIcon id={id} size={8} />}
type="Action"
id={id}
resource={action}
state={action?.info.state}
status={undefined}
/>
);
},
};

View File

@@ -1,79 +0,0 @@
import { Section } from "@components/layouts";
import { Card, CardContent, CardHeader } from "@ui/card";
import { cn, getUpdateQuery, updateLogToHtml } from "@lib/utils";
import { useRead } from "@lib/hooks";
import { text_color_class_by_intention } from "@lib/color";
export const ActionInfo = ({ id }: { id: string }) => {
const update = useRead("ListUpdates", {
query: {
...getUpdateQuery({ type: "Action", id }, undefined),
operation: "RunAction",
},
}).data?.updates[0];
const full_update = useRead(
"GetUpdate",
{ id: update?.id! },
{ enabled: !!update?.id }
).data;
const log = full_update?.logs.find((log) => log.stage === "Execute Action");
if (!log?.stdout && !log?.stderr) {
return (
<Section>
<Card className="flex flex-col gap-4">
<CardHeader
className={cn(
"flex flex-row justify-between items-center",
text_color_class_by_intention("Neutral")
)}
>
Never run
</CardHeader>
</Card>
</Section>
);
}
return (
<Section>
{/* Last run */}
{log?.stdout && (
<Card className="flex flex-col gap-4">
<CardHeader className="flex flex-row items-center gap-1 pb-0">
Last run -
<div className={text_color_class_by_intention("Good")}>Stdout</div>
</CardHeader>
<CardContent className="pr-8">
<pre
dangerouslySetInnerHTML={{
__html: updateLogToHtml(log.stdout),
}}
className="max-h-[500px] overflow-y-auto"
/>
</CardContent>
</Card>
)}
{log?.stderr && (
<Card className="flex flex-col gap-4">
<CardHeader className="flex flex-row items-center gap-1 pb-0">
Last run -
<div className={text_color_class_by_intention("Critical")}>
Stderr
</div>
</CardHeader>
<CardContent className="pr-8">
<pre
dangerouslySetInnerHTML={{
__html: updateLogToHtml(log.stderr),
}}
className="max-h-[500px] overflow-y-auto"
/>
</CardContent>
</Card>
)}
</Section>
);
};

View File

@@ -1,90 +0,0 @@
import { ConfigItem } from "@components/config/util";
import { Types } from "komodo_client";
import { Badge } from "@ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@ui/select";
import { MinusCircle } from "lucide-react";
const ALERT_TYPES: Types.AlertData["type"][] = [
// Server
"ServerVersionMismatch",
"ServerUnreachable",
"ServerCpu",
"ServerMem",
"ServerDisk",
// Stack
"StackStateChange",
"StackImageUpdateAvailable",
"StackAutoUpdated",
// Deployment
"ContainerStateChange",
"DeploymentImageUpdateAvailable",
"DeploymentAutoUpdated",
// Misc
"ScheduleRun",
"BuildFailed",
"ResourceSyncPendingUpdates",
"RepoBuildFailed",
"ActionFailed",
"ProcedureFailed",
"AwsBuilderTerminationFailed",
"Custom",
];
export const AlertTypeConfig = ({
alert_types,
set,
disabled,
}: {
alert_types: Types.AlertData["type"][];
set: (alert_types: Types.AlertData["type"][]) => void;
disabled: boolean;
}) => {
const at = ALERT_TYPES.filter(
(alert_type) => !alert_types.includes(alert_type),
);
return (
<ConfigItem
label="Alert Types"
description="Only send alerts of certain types."
boldLabel
>
<div className="flex items-center gap-4">
{at.length ? (
<Select
value={undefined}
onValueChange={(type: Types.AlertData["type"]) => {
set([...alert_types, type]);
}}
disabled={disabled}
>
<SelectTrigger className="w-[150px]">
<div className="pr-2">Add Filter</div>
</SelectTrigger>
<SelectContent align="start">
{at.map((alert_type) => (
<SelectItem key={alert_type} value={alert_type}>
{alert_type}
</SelectItem>
))}
</SelectContent>
</Select>
) : undefined}
<div className="flex items-center flex-wrap gap-2 w-[75%]">
{alert_types.map((type) => (
<Badge
variant="secondary"
className="text-sm flex items-center gap-2 cursor-pointer"
onClick={() => {
if (disabled) return;
set(alert_types.filter((t) => t !== type));
}}
>
{type}
{!disabled && <MinusCircle className="w-3 h-3" />}
</Badge>
))}
</div>
</div>
</ConfigItem>
);
};

View File

@@ -1,99 +0,0 @@
import { ConfigItem } from "@components/config/util";
import { MonacoEditor } from "@components/monaco";
import { Types } from "komodo_client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import { Input } from "@ui/input";
const ENDPOINT_TYPES: Types.AlerterEndpoint["type"][] = [
"Custom",
"Discord",
"Slack",
"Ntfy",
"Pushover",
];
export const EndpointConfig = ({
endpoint,
set,
disabled,
}: {
endpoint: Types.AlerterEndpoint;
set: (endpoint: Types.AlerterEndpoint) => void;
disabled: boolean;
}) => {
return (
<ConfigItem
label="Endpoint"
description="Configure the endpoint to send the alert to."
boldLabel
>
<Select
value={endpoint.type}
onValueChange={(type: Types.AlerterEndpoint["type"]) => {
set({ type, params: { url: default_url(type) } });
}}
disabled={disabled}
>
<SelectTrigger className="w-[150px]" disabled={disabled}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ENDPOINT_TYPES.map((endpoint) => (
<SelectItem key={endpoint} value={endpoint}>
{endpoint}
</SelectItem>
))}
</SelectContent>
</Select>
<MonacoEditor
value={endpoint.params.url}
language={undefined}
onValueChange={(url) =>
set({ ...endpoint, params: { ...endpoint.params, url } })
}
readOnly={disabled}
/>
{endpoint.type == "Ntfy" ? (
<ConfigItem
label="Email"
description="Request Ntfy to send an email to this address. SMTP must be configured on the Ntfy instance. Only one email address per alerter is supported."
>
<Input
value={endpoint.params.email}
type="email"
readOnly={disabled}
placeholder="john@example.com"
onChange={(input) =>
set({
...endpoint,
params: { ...endpoint.params, email: input.target.value },
})
}
></Input>
</ConfigItem>
) : (
""
)}
</ConfigItem>
);
};
const default_url = (type: Types.AlerterEndpoint["type"]) => {
return type === "Custom"
? "http://localhost:7000"
: type === "Slack"
? "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX"
: type === "Discord"
? "https://discord.com/api/webhooks/XXXXXXXXXXXX/XXXX-XXXXXXXXXX"
: type === "Ntfy"
? "https://ntfy.sh/komodo"
: type === "Pushover"
? "https://api.pushover.net/1/messages.json?token=XXXXXXXXXXXXX&user=XXXXXXXXXXXXX"
: "";
};

View File

@@ -1,232 +0,0 @@
import { ConfigItem } from "@components/config/util";
import { ResourceComponents } from "@components/resources";
import { ResourceLink } from "@components/resources/common";
import { useRead } from "@lib/hooks";
import { resource_name } from "@lib/utils";
import { Types } from "komodo_client";
import { UsableResource } from "@types";
import { Button } from "@ui/button";
import { DataTable, SortableHeader } from "@ui/data-table";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "@ui/dialog";
import { Input } from "@ui/input";
import { Switch } from "@ui/switch";
import { useState } from "react";
export const ResourcesConfig = ({
resources,
set,
disabled,
blacklist,
}: {
resources: Types.ResourceTarget[];
set: (resources: Types.ResourceTarget[]) => void;
disabled: boolean;
blacklist: boolean;
}) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const servers = useRead("ListServers", {}).data ?? [];
const stacks = useRead("ListStacks", {}).data ?? [];
const deployments = useRead("ListDeployments", {}).data ?? [];
const builds = useRead("ListBuilds", {}).data ?? [];
const repos = useRead("ListRepos", {}).data ?? [];
const syncs = useRead("ListResourceSyncs", {}).data ?? [];
const all_resources = [
...servers.map((server) => {
return {
type: "Server",
id: server.id,
name: server.name.toLowerCase(),
enabled: resources.find(
(r) => r.type === "Server" && r.id === server.id
)
? true
: false,
};
}),
...stacks.map((stack) => {
return {
type: "Stack",
id: stack.id,
name: stack.name.toLowerCase(),
enabled: resources.find((r) => r.type === "Stack" && r.id === stack.id)
? true
: false,
};
}),
...deployments.map((deployment) => ({
type: "Deployment",
id: deployment.id,
name: deployment.name.toLowerCase(),
enabled: resources.find(
(r) => r.type === "Deployment" && r.id === deployment.id
)
? true
: false,
})),
...builds.map((build) => ({
type: "Build",
id: build.id,
name: build.name.toLowerCase(),
enabled: resources.find((r) => r.type === "Build" && r.id === build.id)
? true
: false,
})),
...repos.map((repo) => ({
type: "Repo",
id: repo.id,
name: repo.name.toLowerCase(),
enabled: resources.find((r) => r.type === "Repo" && r.id === repo.id)
? true
: false,
})),
...syncs.map((sync) => ({
type: "ResourceSync",
id: sync.id,
name: sync.name.toLowerCase(),
enabled: resources.find(
(r) => r.type === "ResourceSync" && r.id === sync.id
)
? true
: false,
})),
];
const searchSplit = search.split(" ");
const filtered_resources = searchSplit.length
? all_resources.filter((r) => {
const name = r.name.toLowerCase();
return searchSplit.every((term) => name.includes(term));
})
: all_resources;
return (
<ConfigItem label={`Resource ${blacklist ? "Blacklist" : "Whitelist"}`}>
<div className="flex items-center gap-4">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>
<Button variant="secondary">Edit Resources</Button>
</DialogTrigger>
<DialogContent className="min-w-[90vw] xl:min-w-[1200px]">
<DialogHeader>Alerter Resources</DialogHeader>
<div className="flex flex-col gap-4">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search..."
className="w-[200px] lg:w-[300px]"
/>
<div className="max-h-[70vh] overflow-auto">
<DataTable
tableKey="alerter-resources"
data={filtered_resources}
columns={[
{
accessorKey: "type",
header: ({ column }) => (
<SortableHeader column={column} title="Resource" />
),
cell: ({ row }) => {
const Components =
ResourceComponents[
row.original.type as UsableResource
];
return (
<div className="flex gap-2 items-center">
<Components.Icon />
{row.original.type}
</div>
);
},
},
{
accessorKey: "id",
sortingFn: (a, b) => {
const ra = resource_name(
a.original.type as UsableResource,
a.original.id
);
const rb = resource_name(
b.original.type as UsableResource,
b.original.id
);
if (!ra && !rb) return 0;
if (!ra) return -1;
if (!rb) return 1;
if (ra > rb) return 1;
else if (ra < rb) return -1;
else return 0;
},
header: ({ column }) => (
<SortableHeader column={column} title="Target" />
),
cell: ({ row: { original: resource_target } }) => {
return (
<ResourceLink
type={resource_target.type as UsableResource}
id={resource_target.id}
/>
);
},
},
{
accessorKey: "enabled",
header: ({ column }) => (
<SortableHeader
column={column}
title={blacklist ? "Blacklist" : "Whitelist"}
/>
),
cell: ({ row }) => {
return (
<Switch
disabled={disabled}
checked={row.original.enabled}
onCheckedChange={() => {
if (row.original.enabled) {
set(
resources.filter(
(r) =>
r.type !== row.original.type ||
r.id !== row.original.id
)
);
} else {
set([
...resources,
{
type: row.original.type as UsableResource,
id: row.original.id,
},
]);
}
}}
/>
);
},
},
]}
/>
</div>
</div>
<DialogFooter>
<Button onClick={() => setOpen(false)}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{resources.length ? (
<div className="text-muted-foreground">
Alerts {blacklist ? "blacklisted" : "whitelisted"} by{" "}
{resources.length} resources
</div>
) : undefined}
</div>
</ConfigItem>
);
};

View File

@@ -1,104 +0,0 @@
import { useExecute, useRead, useUser } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { AlarmClock, FlaskConical } from "lucide-react";
import { Link } from "react-router-dom";
import { Card, CardDescription, CardHeader, CardTitle } from "@ui/card";
import { AlerterConfig } from "./config";
import { DeleteResource, NewResource, ResourcePageHeader } from "../common";
import { AlerterTable } from "./table";
import { Types } from "komodo_client";
import { ConfirmButton } from "@components/util";
import { GroupActions } from "@components/group-actions";
const useAlerter = (id?: string) =>
useRead("ListAlerters", {}).data?.find((d) => d.id === id);
export const AlerterComponents: RequiredResourceComponents = {
list_item: (id) => useAlerter(id),
resource_links: () => undefined,
Description: () => <>Route alerts to various endpoints.</>,
Dashboard: () => {
const alerters_count = useRead("ListAlerters", {}).data?.length;
return (
<Link to="/alerters/" className="w-full">
<Card className="hover:bg-accent/50 transition-colors cursor-pointer">
<CardHeader>
<div className="flex justify-between">
<div>
<CardTitle>Alerters</CardTitle>
<CardDescription>{alerters_count} Total</CardDescription>
</div>
<AlarmClock className="w-4 h-4" />
</div>
</CardHeader>
</Card>
</Link>
);
},
New: () => {
const is_admin = useUser().data?.admin;
return is_admin && <NewResource type="Alerter" />;
},
GroupActions: () => <GroupActions type="Alerter" actions={["TestAlerter"]} />,
Table: ({ resources }) => (
<AlerterTable alerters={resources as Types.AlerterListItem[]} />
),
Icon: () => <AlarmClock className="w-4 h-4" />,
BigIcon: () => <AlarmClock className="w-8 h-8" />,
State: () => null,
Status: {},
Info: {
Type: ({ id }) => {
const alerter = useAlerter(id);
return (
<div className="capitalize">Type: {alerter?.info.endpoint_type}</div>
);
},
},
Actions: {
TestAlerter: ({ id }) => {
const { mutate, isPending } = useExecute("TestAlerter");
const alerter = useAlerter(id);
if (!alerter) return null;
return (
<ConfirmButton
title="Test Alerter"
icon={<FlaskConical className="h-4 w-4" />}
loading={isPending}
onClick={() => mutate({ alerter: id })}
disabled={isPending}
/>
);
},
},
Page: {},
Config: AlerterConfig,
DangerZone: ({ id }) => <DeleteResource type="Alerter" id={id} />,
ResourcePageHeader: ({ id }) => {
const alerter = useAlerter(id);
return (
<ResourcePageHeader
intent="None"
icon={<AlarmClock className="w-8" />}
type="Alerter"
id={id}
resource={alerter}
state={alerter?.info.enabled ? "Enabled" : "Disabled"}
status={alerter?.info.endpoint_type}
/>
);
},
};

View File

@@ -1,105 +0,0 @@
// import {
// ColorType,
// IChartApi,
// ISeriesApi,
// Time,
// createChart,
// } from "lightweight-charts";
// import { useEffect, useRef } from "react";
// import { useRead } from "@lib/hooks";
// import {
// Card,
// CardContent,
// CardDescription,
// CardHeader,
// CardTitle,
// } from "@ui/card";
// import { Hammer } from "lucide-react";
// import { Link } from "react-router-dom";
// import { convertTsMsToLocalUnixTsInSecs } from "@lib/utils";
// export const BuildChart = () => {
// const container_ref = useRef<HTMLDivElement>(null);
// const line_ref = useRef<IChartApi>();
// const series_ref = useRef<ISeriesApi<"Histogram">>();
// const build_stats = useRead("GetBuildMonthlyStats", {}).data;
// const summary = useRead("GetBuildsSummary", {}).data;
// const handleResize = () =>
// line_ref.current?.applyOptions({
// width: container_ref.current?.clientWidth,
// });
// useEffect(() => {
// if (!build_stats) return;
// if (line_ref.current) line_ref.current.remove();
// const init = () => {
// if (!container_ref.current) return;
// // INIT LINE
// line_ref.current = createChart(container_ref.current, {
// width: container_ref.current.clientWidth,
// height: container_ref.current.clientHeight,
// layout: {
// background: { type: ColorType.Solid, color: "transparent" },
// textColor: "grey",
// fontSize: 12,
// },
// grid: {
// horzLines: { color: "transparent" },
// vertLines: { color: "transparent" },
// },
// handleScale: false,
// handleScroll: false,
// });
// line_ref.current.timeScale().fitContent();
// // INIT SERIES
// series_ref.current = line_ref.current.addHistogramSeries({
// priceLineVisible: false,
// });
// const max = build_stats.days.reduce((m, c) => Math.max(m, c.time), 0);
// series_ref.current.setData(
// build_stats.days.map((d) => ({
// time: convertTsMsToLocalUnixTsInSecs(d.ts) as Time,
// value: d.count,
// color:
// d.time > max * 0.7
// ? "darkred"
// : d.time > max * 0.35
// ? "darkorange"
// : "darkgreen",
// })) ?? []
// );
// };
// // Run the effect
// init();
// window.addEventListener("resize", handleResize);
// return () => {
// window.removeEventListener("resize", handleResize);
// };
// }, [build_stats]);
// return (
// <Link to="/builds" className="w-full">
// <Card className="hover:bg-accent/50 transition-colors cursor-pointer">
// <CardHeader>
// <div className="flex justify-between">
// <div>
// <CardTitle>Builds</CardTitle>
// <CardDescription className="flex gap-2">
// <div>{summary?.total} Total</div> |{" "}
// <div>{build_stats?.total_time.toFixed(2)} Hours</div>
// </CardDescription>
// </div>
// <Hammer className="w-4 h-4" />
// </div>
// </CardHeader>
// <CardContent className="hidden xl:flex h-[200px]">
// <div className="w-full max-w-full h-full" ref={container_ref} />
// </CardContent>
// </Card>
// </Link>
// );
// };

View File

@@ -1,243 +0,0 @@
import { useInvalidate, useRead, useUser, useWrite } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { Factory, FolderGit, Hammer, Loader2, RefreshCcw } from "lucide-react";
import { BuildTable } from "./table";
import {
DeleteResource,
NewResource,
ResourceLink,
ResourcePageHeader,
StandardSource,
} from "../common";
import { RunBuild } from "./actions";
import {
border_color_class_by_intention,
build_state_intention,
stroke_color_class_by_intention,
} from "@lib/color";
import { cn } from "@lib/utils";
import { Types } from "komodo_client";
import { DashboardPieChart } from "@components/util";
import { StatusBadge } from "@components/util";
import { Card } from "@ui/card";
import { Badge } from "@ui/badge";
import { useToast } from "@ui/use-toast";
import { Button } from "@ui/button";
import { useBuilder } from "../builder";
import { GroupActions } from "@components/group-actions";
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
import { BuildTabs } from "./tabs";
export const useBuild = (id?: string) =>
useRead("ListBuilds", {}, { refetchInterval: 10_000 }).data?.find(
(d) => d.id === id
);
export const useFullBuild = (id: string) =>
useRead("GetBuild", { build: id }, { refetchInterval: 10_000 }).data;
const BuildIcon = ({ id, size }: { id?: string; size: number }) => {
const state = useBuild(id)?.info.state;
const color = stroke_color_class_by_intention(build_state_intention(state));
return <Hammer className={cn(`w-${size} h-${size}`, state && color)} />;
};
export const BuildComponents: RequiredResourceComponents = {
list_item: (id) => useBuild(id),
resource_links: (resource) => (resource.config as Types.BuildConfig).links,
Description: () => <>Build container images.</>,
Dashboard: () => {
const summary = useRead("GetBuildsSummary", {}).data;
return (
<DashboardPieChart
data={[
{ title: "Ok", intention: "Good", value: summary?.ok ?? 0 },
{
title: "Building",
intention: "Warning",
value: summary?.building ?? 0,
},
{
title: "Failed",
intention: "Critical",
value: summary?.failed ?? 0,
},
{
title: "Unknown",
intention: "Unknown",
value: summary?.unknown ?? 0,
},
]}
/>
);
},
New: () => {
const user = useUser().data;
const builders = useRead("ListBuilders", {}).data;
if (!user) return null;
if (!user.admin && !user.create_build_permissions) return null;
const builder_id =
builders && builders.length === 1 ? builders[0].id : undefined;
return (
<NewResource
type="Build"
builder_id={
builders && builders.length === 1 ? builders[0].id : undefined
}
selectBuilder={!builder_id}
/>
);
},
GroupActions: () => <GroupActions type="Build" actions={["RunBuild"]} />,
Table: ({ resources }) => (
<BuildTable builds={resources as Types.BuildListItem[]} />
),
Icon: ({ id }) => <BuildIcon id={id} size={4} />,
BigIcon: ({ id }) => <BuildIcon id={id} size={8} />,
State: ({ id }) => {
let state = useBuild(id)?.info.state;
return <StatusBadge text={state} intent={build_state_intention(state)} />;
},
Info: {
Builder: ({ id }) => {
const info = useBuild(id)?.info;
const builder = useBuilder(info?.builder_id);
return builder?.id ? (
<ResourceLink type="Builder" id={builder?.id} />
) : (
<div className="flex gap-2 items-center text-sm">
<Factory className="w-4 h-4" />
<div>Unknown Builder</div>
</div>
);
},
Source: ({ id }) => {
const info = useBuild(id)?.info;
return <StandardSource info={info} />;
},
Branch: ({ id }) => {
const branch = useBuild(id)?.info.branch;
return (
<div className="flex items-center gap-2">
<FolderGit className="w-4 h-4" />
{branch}
</div>
);
},
},
Status: {
Hash: ({ id }) => {
const info = useFullBuild(id)?.info;
if (!info?.latest_hash) {
return null;
}
const out_of_date =
info.built_hash && info.built_hash !== info.latest_hash;
return (
<Tooltip>
<TooltipTrigger asChild>
<Card
className={cn(
"px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer",
out_of_date && border_color_class_by_intention("Warning")
)}
>
<div className="text-muted-foreground text-sm text-nowrap overflow-hidden overflow-ellipsis">
{info.built_hash ? "built" : "latest"}:{" "}
{info.built_hash || info.latest_hash}
</div>
</Card>
</TooltipTrigger>
<TooltipContent>
<div className="grid gap-2">
<Badge
variant="secondary"
className="w-fit text-muted-foreground"
>
message
</Badge>
{info.built_message || info.latest_message}
{out_of_date && (
<>
<Badge
variant="secondary"
className={cn(
"w-fit text-muted-foreground border-[1px]",
border_color_class_by_intention("Warning")
)}
>
latest
</Badge>
<div>
<span className="text-muted-foreground">
{info.latest_hash}
</span>
: {info.latest_message}
</div>
</>
)}
</div>
</TooltipContent>
</Tooltip>
);
},
Refresh: ({ id }) => {
const { toast } = useToast();
const inv = useInvalidate();
const { mutate, isPending } = useWrite("RefreshBuildCache", {
onSuccess: () => {
inv(["ListBuilds"], ["GetBuild", { build: id }]);
toast({ title: "Refreshed build status cache" });
},
});
return (
<Button
variant="outline"
size="icon"
onClick={() => {
mutate({ build: id });
toast({ title: "Triggered refresh of build status cache" });
}}
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCcw className="w-4 h-4" />
)}
</Button>
);
},
},
Actions: { RunBuild },
Page: {},
Config: BuildTabs,
DangerZone: ({ id }) => <DeleteResource type="Build" id={id} />,
ResourcePageHeader: ({ id }) => {
const build = useBuild(id);
return (
<ResourcePageHeader
intent={build_state_intention(build?.info.state)}
icon={<BuildIcon id={id} size={8} />}
type="Build"
id={id}
resource={build}
state={build?.info.state}
status=""
/>
);
},
};

View File

@@ -1,227 +0,0 @@
import { Section } from "@components/layouts";
import { ReactNode, useState } from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@ui/card";
import { useFullBuild } from ".";
import { cn, updateLogToHtml } from "@lib/utils";
import { MonacoEditor } from "@components/monaco";
import { usePermissions } from "@lib/hooks";
import { ConfirmUpdate } from "@components/config/util";
import { useLocalStorage, useRead, useWrite } from "@lib/hooks";
import { Button } from "@ui/button";
import { Clock, FilePlus, History } from "lucide-react";
import { useToast } from "@ui/use-toast";
import { ConfirmButton, ShowHideButton } from "@components/util";
import { DEFAULT_BUILD_DOCKERFILE_CONTENTS } from "./config";
import { fmt_duration } from "@lib/formatting";
export const BuildInfo = ({
id,
titleOther,
}: {
id: string;
titleOther: ReactNode;
}) => {
const [edits, setEdits] = useLocalStorage<{ contents: string | undefined }>(
`build-${id}-edits`,
{ contents: undefined }
);
const [showContents, setShowContents] = useState(true);
const { canWrite } = usePermissions({ type: "Build", id });
const { toast } = useToast();
const { mutateAsync, isPending } = useWrite("WriteBuildFileContents", {
onSuccess: (res) => {
toast({
title: res.success ? "Contents written." : "Failed to write contents.",
variant: res.success ? undefined : "destructive",
});
},
});
const build = useFullBuild(id);
const recent_builds = useRead("ListUpdates", {
query: { "target.type": "Build", "target.id": id, operation: "RunBuild" },
}).data;
const _last_build = recent_builds?.updates[0];
const last_build = useRead(
"GetUpdate",
{
id: _last_build?.id!,
},
{ enabled: !!_last_build }
).data;
const file_on_host = build?.config?.files_on_host ?? false;
const git_repo =
build?.config?.repo || build?.config?.linked_repo ? true : false;
const canEdit = canWrite && (file_on_host || git_repo);
const remote_path = build?.info?.remote_path;
const remote_contents = build?.info?.remote_contents;
const remote_error = build?.info?.remote_error;
return (
<Section titleOther={titleOther}>
{/* Errors */}
{remote_error && remote_error.length > 0 && (
<Card className="flex flex-col gap-4">
<CardHeader className="flex flex-row justify-between items-center pb-0">
<div className="font-mono flex gap-2">
{remote_path && (
<>
<div className="text-muted-foreground">Path:</div>
{remote_path}
</>
)}
</div>
{canEdit && (
<ConfirmButton
title="Initialize File"
icon={<FilePlus className="w-4 h-4" />}
onClick={() => {
if (build) {
mutateAsync({
build: build.name,
contents: DEFAULT_BUILD_DOCKERFILE_CONTENTS,
});
}
}}
loading={isPending}
/>
)}
</CardHeader>
<CardContent className="pr-8">
<pre
dangerouslySetInnerHTML={{
__html: updateLogToHtml(remote_error),
}}
className="max-h-[500px] overflow-y-auto"
/>
</CardContent>
</Card>
)}
{/* Update latest contents */}
{remote_contents && remote_contents.length > 0 && (
<Card className="flex flex-col gap-4">
<CardHeader
className={cn(
"flex flex-row justify-between items-center",
showContents && "pb-0"
)}
>
{remote_path && (
<CardTitle className="font-mono flex gap-2">
<div className="text-muted-foreground">Path:</div>
{remote_path}
</CardTitle>
)}
<div className="flex items-center gap-2">
{canEdit && (
<>
<Button
variant="outline"
onClick={() => setEdits({ contents: undefined })}
className="flex items-center gap-2"
disabled={!edits.contents}
>
<History className="w-4 h-4" />
Reset
</Button>
<ConfirmUpdate
previous={{ contents: remote_contents }}
content={{ contents: edits.contents }}
onConfirm={async () => {
if (build) {
return await mutateAsync({
build: build.name,
contents: edits.contents!,
}).then(() => setEdits({ contents: undefined }));
}
}}
disabled={!edits.contents}
language="dockerfile"
loading={isPending}
/>
</>
)}
<ShowHideButton show={showContents} setShow={setShowContents} />
</div>
</CardHeader>
{showContents && (
<CardContent className="pr-8">
<MonacoEditor
value={edits.contents ?? remote_contents}
language="dockerfile"
readOnly={!canEdit}
onValueChange={(contents) => setEdits({ contents })}
/>
</CardContent>
)}
</Card>
)}
{/* Last build output */}
{last_build && last_build.logs.length > 0 && (
<code className="font-bold">Last Build Logs</code>
)}
{last_build &&
last_build.logs.length > 0 &&
last_build.logs?.map((log, i) => (
<Card key={i}>
<CardHeader className="flex-col">
<CardTitle>{log.stage}</CardTitle>
<CardDescription className="flex gap-2">
<span>
Stage {i + 1} of {last_build.logs.length}
</span>
<span>|</span>
<span className="flex items-center gap-2">
<Clock className="w-4 h-4" />
{fmt_duration(log.start_ts, log.end_ts)}
</span>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{log.command && (
<div>
<CardDescription>command</CardDescription>
<pre className="max-h-[500px] overflow-y-auto">
{log.command}
</pre>
</div>
)}
{log.stdout && (
<div>
<CardDescription>stdout</CardDescription>
<pre
dangerouslySetInnerHTML={{
__html: updateLogToHtml(log.stdout),
}}
className="max-h-[500px] overflow-y-auto"
/>
</div>
)}
{log.stderr && (
<div>
<CardDescription>stderr</CardDescription>
<pre
dangerouslySetInnerHTML={{
__html: updateLogToHtml(log.stderr),
}}
className="max-h-[500px] overflow-y-auto"
/>
</div>
)}
</CardContent>
</Card>
))}
</Section>
);
};

View File

@@ -1,65 +0,0 @@
import { useLocalStorage, useRead } from "@lib/hooks";
import {
MobileFriendlyTabsSelector,
TabNoContent,
} from "@ui/mobile-friendly-tabs";
import { useMemo } from "react";
import { BuildInfo } from "./info";
import { Section } from "@components/layouts";
import { ResourceComponents } from "..";
import { DeploymentTable } from "../deployment/table";
import { BuildConfig } from "./config";
type BuildTabsView = "Config" | "Info" | "Deployments";
export const BuildTabs = ({ id }: { id: string }) => {
const [view, setView] = useLocalStorage<BuildTabsView>(
"build-tabs-v1",
"Config"
);
const deployments = useRead("ListDeployments", {}).data?.filter(
(deployment) => deployment.info.build_id === id
);
const deploymentsDisabled = (deployments?.length || 0) === 0;
const tabsNoContent = useMemo<TabNoContent<BuildTabsView>[]>(
() => [
{
value: "Config",
},
{
value: "Info",
},
{
value: "Deployments",
disabled: deploymentsDisabled,
},
],
[deploymentsDisabled]
);
const Selector = (
<MobileFriendlyTabsSelector
tabs={tabsNoContent}
value={view}
onValueChange={setView as any}
tabsTriggerClassname="w-[110px]"
/>
);
switch (view) {
case "Config":
return <BuildConfig id={id} titleOther={Selector} />;
case "Info":
return <BuildInfo id={id} titleOther={Selector} />;
case "Deployments":
return (
<Section
titleOther={Selector}
actions={<ResourceComponents.Deployment.New build_id={id} />}
>
<DeploymentTable deployments={deployments ?? []} />
</Section>
);
}
};

View File

@@ -1,639 +0,0 @@
import { Config } from "@components/config";
import { ConfigItem, ConfigList } from "@components/config/util";
import { useLocalStorage, usePermissions, useRead, useWrite } from "@lib/hooks";
import { Types } from "komodo_client";
import { useState } from "react";
import { ResourceLink, ResourceSelector } from "../common";
import { Button } from "@ui/button";
import { MinusCircle, PlusCircle } from "lucide-react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTrigger,
} from "@ui/dialog";
import { Card } from "@ui/card";
import { cn } from "@lib/utils";
import { Input } from "@ui/input";
import { MonacoEditor } from "@components/monaco";
export const BuilderConfig = ({ id }: { id: string }) => {
const config = useRead("GetBuilder", { builder: id }).data?.config;
if (config?.type === "Aws") return <AwsBuilderConfig id={id} />;
if (config?.type === "Server") return <ServerBuilderConfig id={id} />;
if (config?.type === "Url") return <UrlBuilderConfig id={id} />;
};
const AwsBuilderConfig = ({ id }: { id: string }) => {
const { canWrite } = usePermissions({ type: "Builder", id });
const config = useRead("GetBuilder", { builder: id }).data?.config
?.params as Types.AwsBuilderConfig;
const global_disabled =
useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false;
const [update, set] = useLocalStorage<Partial<Types.AwsBuilderConfig>>(
`aws-builder-${id}-update-v1`,
{}
);
const { mutateAsync } = useWrite("UpdateBuilder");
if (!config) return null;
const disabled = global_disabled || !canWrite;
return (
<Config
disabled={disabled}
original={config}
update={update}
set={set}
onSave={async () => {
await mutateAsync({ id, config: { type: "Aws", params: update } });
}}
components={{
"": [
{
label: "General",
components: {
region: {
description:
"Configure the AWS region to launch the instance in.",
placeholder: "Input region",
},
instance_type: {
description: "Choose the instance type to launch",
placeholder: "Input instance type",
},
ami_id: {
description:
"Create an Ami with Docker and Komodo Periphery installed.",
placeholder: "Input Ami Id",
},
volume_gb: {
description: "The size of the disk to attach to the instance.",
placeholder: "Input size",
},
key_pair_name: {
description: "Attach a key pair to the instance",
placeholder: "Input key pair name",
},
},
},
{
label: "Network",
components: {
subnet_id: {
description: "Configure the subnet to launch the instance in.",
placeholder: "Input subnet id",
},
security_group_ids: (values, set) => (
<ConfigList
label="Security Group Ids"
description="Attach security groups to the instance."
field="security_group_ids"
values={values ?? []}
set={set}
disabled={disabled}
placeholder="Input Id"
/>
),
assign_public_ip: {
description:
"Whether to assign a public IP to the build instance.",
},
use_public_ip: {
description:
"Whether to connect to the instance over the public IP. Otherwise, will use the internal IP.",
},
},
},
{
label: "User Data",
description: "Run a script to setup the instance.",
components: {
user_data: (user_data, set) => {
return (
<MonacoEditor
value={user_data}
language="shell"
onValueChange={(user_data) => set({ user_data })}
readOnly={disabled}
/>
);
},
},
},
],
additional: [
{
label: "Connection",
labelHidden: true,
components: {
periphery_public_key: {
label: "Periphery Public Key",
description:
"If provided, the associated private key must be set as Periphery 'private_key'.",
placeholder: "custom-public-key",
},
port: {
description: "Configure the port to connect to Periphery on.",
placeholder: "Input port",
},
use_https: {
description: "Whether to connect to Periphery using HTTPS.",
},
insecure_tls: {
description: "Skip Periphery TLS certificate validation when HTTPS is enabled.",
},
},
},
{
label: "Git Providers",
boldLabel: false,
description:
"If you configured additional git providers / tokens in Periphery config on the builder, add them here so they will be suggested.",
components: {
git_providers: (providers, set) =>
providers && (
<>
{!disabled && (
<Button
variant="secondary"
onClick={() =>
set({
git_providers: [
...(update.git_providers ??
config.git_providers ??
[]),
{
domain: "github.com",
https: true,
accounts: [],
},
],
})
}
className="flex items-center gap-2 w-[200px]"
>
<PlusCircle className="w-4 h-4" />
Add Git Provider
</Button>
)}
<ProvidersConfig
type="git"
providers={providers}
set={set}
disabled={disabled}
/>
</>
),
},
},
{
label: "Docker Registries",
boldLabel: false,
description:
"If you configured additional registries / tokens in Periphery config on the builder, add them here so they will be suggested.",
components: {
docker_registries: (providers, set) =>
providers && (
<>
{!disabled && (
<Button
variant="secondary"
onClick={() =>
set({
docker_registries: [
...(update.docker_registries ??
config.docker_registries ??
[]),
{
domain: "docker.io",
accounts: [],
organizations: [],
},
],
})
}
className="flex items-center gap-2 w-[200px]"
>
<PlusCircle className="w-4 h-4" />
Add Docker Registry
</Button>
)}
<ProvidersConfig
type="docker"
providers={providers}
set={set}
disabled={disabled}
/>
</>
),
},
},
{
label: "Secret Keys",
labelHidden: true,
components: {
secrets: (secrets, set) => (
<ConfigList
label="Secret Keys"
description="If you configured additional secrets in Periphery config on the builder, add them here so they will be suggested."
field="secrets"
values={secrets ?? []}
set={set}
disabled={disabled}
placeholder="SECRET_KEY"
/>
),
},
},
],
}}
/>
);
};
const ServerBuilderConfig = ({ id }: { id: string }) => {
const { canWrite } = usePermissions({ type: "Builder", id });
const config = useRead("GetBuilder", { builder: id }).data?.config;
const [update, set] = useLocalStorage<Partial<Types.ServerBuilderConfig>>(
`server-builder-${id}-update-v1`,
{}
);
const { mutateAsync } = useWrite("UpdateBuilder");
if (!config) return null;
const disabled = !canWrite;
return (
<Config
disabled={disabled}
original={config.params as Types.ServerBuilderConfig}
update={update}
set={set}
onSave={async () => {
await mutateAsync({ id, config: { type: "Server", params: update } });
}}
components={{
"": [
{
label: "Server",
labelHidden: true,
components: {
server_id: (server_id, set) => {
return (
<ConfigItem
label={
server_id ? (
<div className="flex gap-3 text-lg">
Server:
<ResourceLink type="Server" id={server_id} />
</div>
) : (
"Select Server"
)
}
description="Select the Server to build on."
>
<ResourceSelector
type="Server"
selected={server_id}
onSelect={(server_id) => set({ server_id })}
disabled={disabled}
align="start"
/>
</ConfigItem>
);
},
},
},
],
}}
/>
);
};
const UrlBuilderConfig = ({ id }: { id: string }) => {
const { canWrite } = usePermissions({ type: "Builder", id });
const config = useRead("GetBuilder", { builder: id }).data?.config;
const [update, set] = useLocalStorage<Partial<Types.UrlBuilderConfig>>(
`url-builder-${id}-update-v1`,
{}
);
const { mutateAsync } = useWrite("UpdateBuilder");
if (!config) return null;
const disabled = !canWrite;
const params = config.params as Types.UrlBuilderConfig;
const address = update.address ?? params.address;
const tls_address = !!address && !address.startsWith("ws://");
return (
<Config
disabled={disabled}
original={params}
update={update}
set={set}
onSave={async () => {
await mutateAsync({ id, config: { type: "Url", params: update } });
}}
components={{
"": [
{
label: "General",
labelHidden: true,
components: {
address: {
description: "The address of the Periphery agent",
placeholder: "https://periphery:8120",
},
},
},
{
label: "Connection",
labelHidden: true,
components: {
periphery_public_key: {
label: "Periphery Public Key",
description:
"If provided, the associated private key must be set as Periphery 'private_key'. For Periphery -> Core connection, either this or using 'periphery_public_key' in Core config is required for Periphery to be able to connect.",
placeholder: "custom-public-key",
},
insecure_tls: {
hidden: !tls_address,
description: "Skip Periphery TLS certificate validation.",
},
},
},
],
}}
/>
);
};
const ProvidersConfig = (params: {
type: "git" | "docker";
providers: Types.GitProvider[] | Types.DockerRegistry[];
set: (input: Partial<Types.AwsBuilderConfig>) => void;
disabled: boolean;
}) => {
const arr_field =
params.type === "git" ? "git_providers" : "docker_registries";
if (!params.providers.length) return null;
return (
<div className="w-full flex">
<div className="flex flex-col gap-4 w-full max-w-[400px]">
{params.providers?.map((_, index) => (
<div key={index} className="flex items-center justify-between gap-4">
<ProviderDialog {...params} index={index} />
{!params.disabled && (
<Button
variant="secondary"
onClick={() =>
params.set({
[arr_field]: params.providers.filter((_, i) => i !== index),
})
}
>
<MinusCircle className="w-4 h-4" />
</Button>
)}
</div>
))}
</div>
</div>
);
};
const ProviderDialog = ({
type,
providers,
set,
disabled,
index,
}: {
type: "git" | "docker";
providers: Types.GitProvider[] | Types.DockerRegistry[];
index: number;
set: (input: Partial<Types.AwsBuilderConfig>) => void;
disabled: boolean;
}) => {
const [open, setOpen] = useState(false);
const provider = providers[index];
const arr_field = type === "git" ? "git_providers" : "docker_registries";
const example_domain = type === "git" ? "github.com" : "docker.io";
const update_domain = (domain: string) =>
set({
[arr_field]: providers.map((provider, i) =>
i === index ? { ...provider, domain } : provider
),
});
const add_account = () =>
set({
[arr_field]: providers.map(
(provider: Types.GitProvider | Types.DockerRegistry, i) =>
i === index
? {
...provider,
accounts: [...(provider.accounts ?? []), { username: "" }],
}
: provider
) as Types.GitProvider[] | Types.DockerRegistry[],
});
const update_username = (username: string, account_index: number) =>
set({
[arr_field]: providers.map(
(provider: Types.GitProvider | Types.DockerRegistry, provider_index) =>
provider_index === index
? {
...provider,
accounts: provider.accounts?.map((account, i) =>
account_index === i ? { username } : account
),
}
: provider
) as Types.GitProvider[] | Types.DockerRegistry[],
});
const remove_account = (account_index: number) =>
set({
[arr_field]: providers.map(
(provider: Types.GitProvider | Types.DockerRegistry, provider_index) =>
provider_index === index
? {
...provider,
accounts: provider.accounts?.filter(
(_, i) => account_index !== i
),
}
: provider
) as Types.GitProvider[] | Types.DockerRegistry[],
});
const add_organization = () =>
set({
[arr_field]: providers.map((provider: Types.DockerRegistry, i) =>
i === index
? {
...provider,
organizations: [...(provider.organizations ?? []), ""],
}
: provider
) as Types.DockerRegistry[],
});
const update_organization = (name: string, organization_index: number) =>
set({
[arr_field]: providers.map(
(provider: Types.DockerRegistry, provider_index) =>
provider_index === index
? {
...provider,
organizations: provider.organizations?.map((organization, i) =>
organization_index === i ? name : organization
),
}
: provider
) as Types.GitProvider[] | Types.DockerRegistry[],
});
const remove_organization = (organization_index: number) =>
set({
[arr_field]: providers.map(
(provider: Types.DockerRegistry, provider_index) =>
provider_index === index
? {
...provider,
organizations: provider.organizations?.filter(
(_, i) => organization_index !== i
),
}
: provider
) as Types.DockerRegistry[],
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Card className="px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer w-full">
<div
className={cn(
"flex gap-2 text-sm text-nowrap overflow-hidden overflow-ellipsis"
)}
>
<div className="flex gap-2">{provider.domain}</div>
<div className="flex gap-2">
<div className="text-muted-foreground">accounts:</div>{" "}
{provider.accounts?.length || 0}
</div>
{(provider as Types.DockerRegistry).organizations !== undefined && (
<div className="flex gap-2">
<div className="text-muted-foreground">organizations:</div>{" "}
{(provider as Types.DockerRegistry).organizations?.length || 0}
</div>
)}
</div>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>
{type === "git" ? "Git Provider" : "Docker Registry"}
</DialogHeader>
<div className="flex flex-col gap-4">
{/* Domain */}
<div className="flex items-center justify-between w-fill">
<div className="text-nowrap">Domain</div>
<Input
value={provider.domain}
onChange={(e) => update_domain(e.target.value)}
disabled={disabled}
className="w-[300px]"
placeholder={example_domain}
/>
</div>
{/* Accounts */}
<div className="flex flex-col gap-2 w-fill">
<div className="flex items-center justify-between w-fill">
<div className="text-nowrap">Available Accounts</div>
<Button variant="secondary" onClick={add_account}>
Add
</Button>
</div>
<div className="flex flex-col gap-2">
{provider.accounts?.map((account, account_index) => {
return (
<div
key={account_index}
className="flex gap-2 items-center justify-end"
>
<Input
placeholder="Account Username"
value={account.username}
onChange={(e) =>
update_username(e.target.value, account_index)
}
/>
{!disabled && (
<Button
variant="secondary"
onClick={() => remove_account(account_index)}
>
<MinusCircle className="w-4 h-4" />
</Button>
)}
</div>
);
})}
</div>
</div>
{/* Organizations */}
{type === "docker" && (
<div className="flex flex-col gap-2 w-fill">
<div className="flex items-center justify-between w-fill">
<div className="text-nowrap">Available Organizations</div>
<Button variant="secondary" onClick={add_organization}>
Add
</Button>
</div>
<div className="flex flex-col gap-2">
{(provider as Types.DockerRegistry).organizations?.map(
(organization, organization_index) => {
return (
<div
key={organization_index}
className="flex gap-2 items-center justify-end"
>
<Input
value={organization}
onChange={(e) =>
update_organization(
e.target.value,
organization_index
)
}
placeholder="Organization Name"
/>
{!disabled && (
<Button
variant="secondary"
onClick={() =>
remove_organization(organization_index)
}
>
<MinusCircle className="w-4 h-4" />
</Button>
)}
</div>
);
}
)}
</div>
</div>
)}
</div>
<DialogFooter>
<Button onClick={() => setOpen(false)}>Confirm</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,253 +0,0 @@
import { NewLayout } from "@components/layouts";
import { useRead, useUser, useWrite } from "@lib/hooks";
import { Types } from "komodo_client";
import { RequiredResourceComponents } from "@types";
import { Card, CardDescription, CardHeader, CardTitle } from "@ui/card";
import { Input } from "@ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import { Cloud, Bot, Factory } from "lucide-react";
import { ReactNode, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { BuilderConfig } from "./config";
import { DeleteResource, ResourceLink, ResourcePageHeader } from "../common";
import { BuilderTable } from "./table";
import { GroupActions } from "@components/group-actions";
import { useServer } from "../server";
import { cn } from "@lib/utils";
import {
ColorIntention,
server_state_intention,
stroke_color_class_by_intention,
} from "@lib/color";
export const useBuilder = (id?: string) =>
useRead("ListBuilders", {}, { refetchInterval: 10_000 }).data?.find(
(d) => d.id === id
);
const Icon = ({ id, size }: { id?: string; size: number }) => {
const info = useBuilder(id)?.info;
if (info?.builder_type === "Server" && info.instance_type) {
return <ServerIcon server_id={info.instance_type} size={size} />;
} else {
return <Factory className={`w-${size} h-${size}`} />;
}
};
const ServerIcon = ({
server_id,
size,
}: {
server_id: string;
size: number;
}) => {
const state = useServer(server_id)?.info.state;
return (
<Factory
className={cn(
`w-${size} h-${size}`,
state && stroke_color_class_by_intention(server_state_intention(state))
)}
/>
);
};
export const BuilderInstanceType = ({ id }: { id: string }) => {
let info = useBuilder(id)?.info;
if (info?.builder_type === "Server") {
return (
info.instance_type && (
<ResourceLink type="Server" id={info.instance_type} />
)
);
} else {
return (
<div className="flex items-center gap-2">
<Bot className="w-4 h-4" />
{info?.instance_type}
</div>
);
}
};
export const BuilderComponents: RequiredResourceComponents = {
list_item: (id) => useBuilder(id),
resource_links: () => undefined,
Description: () => <>Build on your servers, or single-use AWS instances.</>,
Dashboard: () => {
const builders_count = useRead("ListBuilders", {}).data?.length;
return (
<Link to="/builders/" className="w-full">
<Card className="hover:bg-accent/50 transition-colors cursor-pointer">
<CardHeader>
<div className="flex justify-between">
<div>
<CardTitle>Builders</CardTitle>
<CardDescription>{builders_count} Total</CardDescription>
</div>
<Factory className="w-4 h-4" />
</div>
</CardHeader>
</Card>
</Link>
);
},
New: () => {
const is_admin = useUser().data?.admin;
const nav = useNavigate();
const { mutateAsync } = useWrite("CreateBuilder");
const [name, setName] = useState("");
const [type, setType] = useState<Types.BuilderConfig["type"]>();
if (!is_admin) return null;
return (
<NewLayout
entityType="Builder"
onConfirm={async () => {
if (!type) return;
const id = (await mutateAsync({ name, config: { type, params: {} } }))
._id?.$oid!;
nav(`/builders/${id}`);
}}
enabled={!!name && !!type}
>
<div className="grid md:grid-cols-2 items-center">
Name
<Input
placeholder="builder-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid md:grid-cols-2 items-center">
Builder Type
<Select
value={type}
onValueChange={(value) => setType(value as typeof type)}
>
<SelectTrigger>
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="Aws">Aws</SelectItem>
<SelectItem value="Server">Server</SelectItem>
<SelectItem value="Url">Url</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</NewLayout>
);
},
GroupActions: () => <GroupActions type="Builder" actions={[]} />,
Table: ({ resources }) => (
<BuilderTable builders={resources as Types.BuilderListItem[]} />
),
Icon: ({ id }) => <Icon id={id} size={4} />,
BigIcon: ({ id }) => <Icon id={id} size={8} />,
State: () => null,
Status: {},
Info: {
Provider: ({ id }) => {
const builder_type = useBuilder(id)?.info.builder_type;
return (
<div className="flex items-center gap-2">
<Cloud className="w-4 h-4" />
{builder_type}
</div>
);
},
InstanceType: ({ id }) => <BuilderInstanceType id={id} />,
},
Actions: {},
Page: {},
Config: BuilderConfig,
DangerZone: ({ id }) => <DeleteResource type="Builder" id={id} />,
ResourcePageHeader: ({ id }) => {
const builder = useBuilder(id);
if (builder?.info.builder_type === "Server" && builder.info.instance_type) {
return (
<ServerInnerResourcePageHeader
builder={builder}
server_id={builder.info.instance_type}
/>
);
}
return (
<InnerResourcePageHeader
id={id}
builder={builder}
intent="None"
icon={<Factory className="w-8 h-8" />}
/>
);
},
};
const ServerInnerResourcePageHeader = ({
builder,
server_id,
}: {
builder: Types.BuilderListItem;
server_id: string;
}) => {
const state = useServer(server_id)?.info.state;
return (
<InnerResourcePageHeader
id={builder.id}
builder={builder}
intent={server_state_intention(state)}
icon={<ServerIcon server_id={server_id} size={8} />}
/>
);
};
const InnerResourcePageHeader = ({
id,
builder,
intent,
icon,
}: {
id: string;
builder: Types.BuilderListItem | undefined;
intent: ColorIntention;
icon: ReactNode;
}) => {
return (
<ResourcePageHeader
intent={intent}
icon={icon}
type="Builder"
id={id}
resource={builder}
state={builder?.info.builder_type}
status={
builder?.info.builder_type === "Aws"
? builder?.info.instance_type
: undefined
}
/>
);
};

View File

@@ -1,889 +0,0 @@
import {
ActionWithDialog,
ConfirmButton,
CopyButton,
RepoLink,
TemplateMarker,
TextUpdateMenuSimple,
} from "@components/util";
import {
useInvalidate,
usePermissions,
useRead,
useShiftKeyListener,
useWrite,
WebhookIntegration,
} from "@lib/hooks";
import { UsableResource } from "@types";
import { Button } from "@ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@ui/command";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover";
import {
Check,
ChevronsUpDown,
Copy,
Edit2,
Loader2,
MinusCircle,
NotepadText,
PlusCircle,
SearchX,
Server,
Tag,
Trash,
X,
} from "lucide-react";
import { ReactNode, useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { ResourceComponents } from ".";
import { Input } from "@ui/input";
import { useToast } from "@ui/use-toast";
import { NewLayout } from "@components/layouts";
import { Types } from "komodo_client";
import { cn, filterBySplit, usableResourcePath } from "@lib/utils";
import {
ColorIntention,
hex_color_by_intention,
tag_background_class,
text_color_class_by_intention,
} from "@lib/color";
import { Switch } from "@ui/switch";
import { ResourceListItem } from "komodo_client/dist/types";
import { Badge } from "@ui/badge";
import { TagsWithBadge } from "@components/tags";
export const ResourcePageHeader = ({
type,
id,
intent,
icon,
resource,
name,
state,
status,
}: {
type: UsableResource | undefined;
id: string | undefined;
intent: ColorIntention;
icon: ReactNode;
resource: Types.ResourceListItem<unknown> | undefined;
/** Only pass if not passing resource */
name?: string;
state: ReactNode | undefined;
status: ReactNode | undefined;
}) => {
const color = text_color_class_by_intention(intent);
const background = hex_color_by_intention(intent) + "15";
return (
<div
className="flex flex-wrap items-center justify-between gap-4 pl-8 pr-8 py-4 rounded-t-md w-full"
style={{ background }}
>
<div className="flex items-center gap-8">
{icon}
<div>
{type && id && resource?.name ? (
<ResourceName type={type} id={id} name={resource.name} />
) : (
<p />
)}
{!type && (
<p className="text-3xl font-semibold">{resource?.name ?? name}</p>
)}
<div className="flex items-center gap-2 text-sm uppercase">
<p className={cn(color, "font-semibold")}>{state}</p>
<p className="text-muted-foreground">{status}</p>
</div>
</div>
</div>
{type && id && resource && (
<TemplateSwitch type={type} id={id} resource={resource} />
)}
</div>
);
};
const TemplateSwitch = ({
type,
id,
resource,
}: {
type: UsableResource;
id: string;
resource: ResourceListItem<unknown>;
}) => {
const { toast } = useToast();
const inv = useInvalidate();
const { canWrite } = usePermissions({ type, id });
const { mutate, isPending } = useWrite("UpdateResourceMeta", {
onSuccess: () => {
inv([`List${type}s`], [`Get${type}`]);
toast({ title: `Updated is template on ${type} ${resource.name}` });
},
});
return (
<div
className="flex items-center flex-wrap gap-2 cursor-pointer"
onClick={() =>
canWrite &&
resource &&
!isPending &&
mutate({ target: { type, id }, template: !resource.template })
}
>
<Badge
variant={resource?.template ? "default" : "secondary"}
className="text-sm"
>
Template
</Badge>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Switch checked={resource?.template} disabled={!canWrite} />
)}
</div>
);
};
const ResourceName = ({
type,
id,
name,
}: {
type: UsableResource;
id: string;
name: string;
}) => {
const invalidate = useInvalidate();
const { toast } = useToast();
const { canWrite } = usePermissions({ type, id });
const [newName, setName] = useState("");
const [editing, setEditing] = useState(false);
const { mutate, isPending } = useWrite(`Rename${type}`, {
onSuccess: () => {
invalidate([`List${type}s`]);
toast({ title: `${type} Renamed` });
setEditing(false);
},
onError: () => {
// If fails, set name back to original
setName(name);
},
});
// Ensure the newName is updated if the outer name changes
useEffect(() => setName(name), [name]);
if (editing) {
return (
<div className="flex items-center gap-2">
<Input
className="text-3xl font-semibold px-1 w-[200px] lg:w-[300px]"
placeholder="name"
value={newName}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (newName && name !== newName) {
mutate({ id, name: newName });
}
} else if (e.key === "Escape") {
setEditing(false);
}
}}
autoFocus
/>
{name !== newName && (
<Button
onClick={() => mutate({ id, name: newName })}
disabled={!newName || isPending}
>
{isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : "Save"}
</Button>
)}
{name === newName && (
<Button variant="ghost" onClick={() => setEditing(false)}>
<X className="w-4 h-4" />
</Button>
)}
</div>
);
} else {
return (
<div
className={cn(
"flex items-center gap-2 w-full",
canWrite && "cursor-pointer"
)}
onClick={() => {
if (canWrite) {
setEditing(true);
}
}}
>
<p className="text-3xl font-semibold">{name}</p>
{canWrite && (
<Button variant="ghost" className="p-2 h-fit">
<Edit2 className="w-4 h-4" />
</Button>
)}
</div>
);
}
};
export const ResourceDescription = ({
type,
id,
disabled,
}: {
type: UsableResource;
id: string;
disabled: boolean;
}) => {
const { toast } = useToast();
const inv = useInvalidate();
const key = type === "ResourceSync" ? "sync" : type.toLowerCase();
const resource = useRead(`Get${type}`, {
[key]: id,
} as any).data;
const { mutate: update_description } = useWrite("UpdateResourceMeta", {
onSuccess: () => {
inv([`Get${type}`]);
toast({ title: `Updated description on ${type} ${resource?.name}` });
},
});
return (
<TextUpdateMenuSimple
title="Update Description"
placeholder="Set Description"
value={resource?.description}
onUpdate={(description) =>
update_description({
target: { type, id },
description,
})
}
triggerClassName="text-muted-foreground"
disabled={disabled}
/>
);
};
export const ResourceSelector = ({
type,
selected,
onSelect,
disabled,
align,
templates = Types.TemplatesQueryBehavior.Exclude,
placeholder,
targetClassName,
state,
exclude_ids,
}: {
type: UsableResource;
selected: string | undefined;
templates?: Types.TemplatesQueryBehavior;
onSelect?: (id: string) => void;
disabled?: boolean;
align?: "start" | "center" | "end";
placeholder?: string;
targetClassName?: string;
state?: unknown;
exclude_ids?: string[];
}) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const templateFilterFn =
templates === Types.TemplatesQueryBehavior.Exclude
? (r: Types.ResourceListItem<unknown>) => !r.template
: templates === Types.TemplatesQueryBehavior.Only
? (r: Types.ResourceListItem<unknown>) => r.template
: () => true;
const resources = useRead(`List${type}s`, {})
.data?.filter(templateFilterFn)
.filter(
(r) =>
(!state || (r.info as any).state === state) &&
(!exclude_ids || r.id === selected || !exclude_ids?.includes(r.id))
);
const name = resources?.find((r) => r.id === selected)?.name;
if (!resources) return null;
const filtered = filterBySplit(
resources as Types.ResourceListItem<unknown>[],
search,
(item) => item.name
).sort((a, b) => {
if (a.name > b.name) {
return 1;
} else if (a.name < b.name) {
return -1;
} else {
return 0;
}
});
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
className={cn(
"flex justify-start gap-2 w-fit max-w-[350px]",
targetClassName
)}
disabled={disabled}
>
{name || (placeholder ?? `Select ${type}`)}
{!disabled && <ChevronsUpDown className="w-3 h-3" />}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] max-h-[300px] p-0" align={align}>
<Command shouldFilter={false}>
<CommandInput
placeholder={`Search ${type}s`}
className="h-9"
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty className="flex justify-evenly items-center pt-3 pb-2">
{`No ${type}s Found`}
<SearchX className="w-3 h-3" />
</CommandEmpty>
<CommandGroup>
{!search && (
<CommandItem
onSelect={() => {
onSelect && onSelect("");
setOpen(false);
}}
className="flex items-center justify-between cursor-pointer"
>
<div className="p-1">None</div>
</CommandItem>
)}
{filtered.map((resource) => (
<CommandItem
key={resource.id}
onSelect={() => {
onSelect && onSelect(resource.id);
setOpen(false);
}}
className="flex items-center justify-between cursor-pointer"
>
<div className="p-1">{resource.name}</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
export const ResourceLink = ({
type,
id,
onClick,
}: {
type: UsableResource;
id: string;
onClick?: () => void;
}) => {
const Components = ResourceComponents[type];
const resource = Components.list_item(id);
return (
<Link
to={`/${usableResourcePath(type)}/${id}`}
onClick={(e) => {
e.stopPropagation();
onClick?.();
}}
className="flex items-center gap-2 text-sm hover:underline"
>
<Components.Icon id={id} />
<ResourceNameSimple type={type} id={id} />
{resource?.template && <TemplateMarker type={type} />}
</Link>
);
};
export const ResourceNameSimple = ({
type,
id,
}: {
type: UsableResource;
id: string;
}) => {
const Components = ResourceComponents[type];
const name = Components.list_item(id)?.name ?? "unknown";
return <>{name}</>;
};
export const CopyResource = ({
id,
disabled,
type,
}: {
id: string;
disabled?: boolean;
type: Exclude<UsableResource, "Server">;
}) => {
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const nav = useNavigate();
const inv = useInvalidate();
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}>
<DialogTrigger asChild>
<Button
variant="secondary"
className="flex gap-2 items-center"
onClick={() => setOpen(true)}
disabled={disabled}
>
<Copy className="w-4 h-4" />
Copy
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Copy {type}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 my-4">
<p>Provide a name for the newly created {type}.</p>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<DialogFooter>
<ConfirmButton
title="Copy"
icon={<Check className="w-4 h-4" />}
disabled={!name}
onClick={async () => {
await onConfirm();
}}
/>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export const NewResource = ({
type,
readable_type,
swarm_id,
server_id,
builder_id,
build_id,
name: _name = "",
selectSwarm,
selectServer,
selectBuilder,
}: {
type: UsableResource;
readable_type?: string;
swarm_id?: string;
server_id?: string;
builder_id?: string;
build_id?: string;
name?: string;
selectSwarm?: boolean;
selectServer?: boolean;
selectBuilder?: boolean;
}) => {
const nav = useNavigate();
const { toast } = useToast();
const showTemplateSelector =
(useRead(`List${type}s`, {}).data?.filter((r) => r.template).length ?? 0) >
0;
const swarmsExist = useRead("ListSwarms", {}, { enabled: selectSwarm }).data
?.length
? true
: false;
const { mutateAsync: create } = useWrite(`Create${type}`);
const { mutateAsync: copy } = useWrite(`Copy${type}`);
const [swarmId, setSwarmId] = useState("");
const [serverId, setServerId] = useState("");
const [builderId, setBuilderId] = useState("");
const [templateId, setTemplateId] = useState("");
const [name, setName] = useState(_name);
const type_display =
type === "ResourceSync" ? "resource-sync" : type.toLowerCase();
const config: Types._PartialDeploymentConfig | Types._PartialRepoConfig =
type === "Deployment"
? {
swarm_id: swarm_id ?? swarmId,
server_id: server_id ?? serverId,
image: build_id
? { type: "Build", params: { build_id } }
: { type: "Image", params: { image: "" } },
}
: type === "Stack"
? { swarm_id: swarm_id ?? swarmId, server_id: server_id ?? serverId }
: type === "Repo"
? {
server_id: server_id ?? serverId,
builder_id: builder_id ?? builderId,
}
: type === "Build"
? { builder_id: builder_id ?? builderId }
: {};
const onConfirm = async () => {
if (!name) toast({ title: "Name cannot be empty" });
const result = templateId
? await copy({ name, id: templateId })
: await create({ name, config });
const resourceId = result._id?.$oid;
if (resourceId) {
nav(`/${usableResourcePath(type)}/${resourceId}`);
}
};
const [open, setOpen] = useState(false);
useShiftKeyListener("N", () => !open && setOpen(true));
return (
<NewLayout
entityType={readable_type ?? type}
onConfirm={onConfirm}
enabled={!!name}
onOpenChange={() => setName(_name)}
open={open}
setOpen={setOpen}
>
<div className="grid md:grid-cols-2 gap-6 items-center">
{readable_type ?? type} Name
<Input
placeholder={`${type_display}-name`}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (!name) return;
if (e.key === "Enter") {
onConfirm().catch(() => {});
}
}}
/>
{selectSwarm && swarmsExist && (
<>
Swarm
<ResourceSelector
type="Swarm"
selected={swarmId}
onSelect={setSwarmId}
targetClassName="w-full justify-between"
align="end"
/>
</>
)}
{selectServer && (
<>
Server
<ResourceSelector
type="Server"
selected={serverId}
onSelect={setServerId}
targetClassName="w-full justify-between"
align="end"
/>
</>
)}
{selectBuilder && (
<>
Builder
<ResourceSelector
type="Builder"
selected={builderId}
onSelect={setBuilderId}
targetClassName="w-full justify-between"
align="end"
/>
</>
)}
{showTemplateSelector && (
<>
Template
<ResourceSelector
type={type}
selected={templateId}
onSelect={setTemplateId}
templates={Types.TemplatesQueryBehavior.Only}
placeholder="Template (Optional)"
targetClassName="w-full justify-between"
align="end"
/>
</>
)}
</div>
</NewLayout>
);
};
export const DeleteResource = ({
type,
id,
}: {
type: UsableResource;
id: string;
}) => {
const nav = useNavigate();
const key = type === "ResourceSync" ? "sync" : type.toLowerCase();
const resource = useRead(`Get${type}`, {
[key]: id,
} as any).data;
const { mutateAsync, isPending } = useWrite(`Delete${type}`);
if (!resource) return null;
return (
<div className="flex items-center justify-end">
<ActionWithDialog
name={resource.name}
title="Delete"
variant="destructive"
icon={<Trash className="h-4 w-4" />}
onClick={async () => {
await mutateAsync({ id });
nav(`/${usableResourcePath(type)}`);
}}
disabled={isPending}
loading={isPending}
forceConfirmDialog
/>
</div>
);
};
export const CopyWebhook = ({
integration,
path,
}: {
integration: WebhookIntegration;
path: string;
}) => {
const base_url = useRead("GetCoreInfo", {}).data?.webhook_base_url;
const url = base_url + "/listener/" + integration.toLowerCase() + path;
return (
<div className="flex gap-2 items-center">
<Input className="w-[400px] max-w-[70vw]" value={url} readOnly />
<CopyButton content={url} />
</div>
);
};
export const StandardSource = ({
info,
}: {
info:
| {
linked_repo: string;
files_on_host: boolean;
repo: string;
repo_link: string;
}
| undefined;
}) => {
if (!info) {
return <Loader2 className="w-4 h-4 animate-spin" />;
}
if (info.files_on_host) {
return (
<div className="flex items-center gap-2">
<Server className="w-4 h-4" />
Files on Server
</div>
);
}
if (info.linked_repo) {
return <ResourceLink type="Repo" id={info.linked_repo} />;
}
if (info.repo) {
return <RepoLink repo={info.repo} link={info.repo_link} />;
}
return (
<div className="flex items-center gap-2">
<NotepadText className="w-4 h-4" />
UI Defined
</div>
);
};
export const TagSelector = ({
tags,
set,
disabled,
small,
useName,
icon,
}: {
tags: string[];
set: (tags: string[]) => void;
disabled: boolean;
small?: boolean;
useName?: boolean;
icon?: ReactNode;
}) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const all_tags = useRead("ListTags", {}).data ?? [];
const all_tag_names = all_tags.map((tag) => tag.name);
const { toast } = useToast();
const inv = useInvalidate();
const { mutateAsync: create } = useWrite("CreateTag", {
onSuccess: () => inv([`ListTags`]),
});
useEffect(() => {
if (open) setSearch("");
}, [open]);
const create_tag = async () => {
if (!search) return toast({ title: "Must provide tag name in input" });
const tag = await create({ name: search });
set([...tags, useName ? tag.name : tag._id?.$oid!]);
setOpen(false);
};
const filtered = filterBySplit(all_tags, search, (item) => item.name)?.sort(
(a, b) => {
if (a.name > b.name) {
return 1;
} else if (a.name < b.name) {
return -1;
} else {
return 0;
}
}
);
return (
<div className={cn("flex items-center", small ? "gap-2" : "gap-3")}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="secondary"
className={cn(
"flex items-center gap-2",
small && "px-2 py-2 h-fit"
)}
>
{icon ?? <Tag className={small ? "w-3 h-3" : "w-4 h-4"} />}
{!small && "Select Tag"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" sideOffset={12} align="start">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search / Create"
className="h-9"
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty className="m-1">
<Button
variant="ghost"
onClick={create_tag}
className="w-full flex items-center justify-between hover:bg-accent"
>
Create Tag
<PlusCircle className="w-4" />
</Button>
</CommandEmpty>
<CommandGroup>
{filtered
?.filter(
(tag) => !tags.includes(useName ? tag.name : tag._id!.$oid)
)
.map((tag) => (
<CommandItem
key={tag._id?.$oid}
value={tag.name}
onSelect={() =>
set([...tags, useName ? tag.name : tag._id!.$oid])
}
className="cursor-pointer flex items-center justify-between gap-2"
>
<div className="p-1">{tag.name}</div>
<div
className={cn(
"w-[25px] h-[25px] rounded-sm",
tag_background_class(tag.color)
)}
/>
</CommandItem>
))}
{search && !all_tag_names.includes(search) && (
<CommandItem onSelect={create_tag} className="cursor-pointer">
<div className="w-full p-1 flex items-center justify-between">
Create Tag
<PlusCircle className="w-4" />
</div>
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<TagsWithBadge
tag_ids={tags}
onBadgeClick={(tag) => {
if (disabled) return;
set(tags.filter((t) => t != tag));
}}
className={small ? "text-sm" : "text-md px-3 py-1"}
icon={!disabled && <MinusCircle className="w-3 h-3" />}
useName={useName}
/>
</div>
);
};

View File

@@ -1,209 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ResourceSelector } from "@components/resources/common";
import { fmt_date, fmt_version } from "@lib/formatting";
import { useRead } from "@lib/hooks";
import { filterBySplit } from "@lib/utils";
import { Types } from "komodo_client";
import { CaretSortIcon } from "@radix-ui/react-icons";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@ui/command";
import { Input } from "@ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import { SearchX } from "lucide-react";
import { useState } from "react";
const BuildVersionSelector = ({
disabled,
buildId,
selected,
onSelect,
}: {
disabled: boolean;
buildId: string | undefined;
selected: Types.Version | undefined;
onSelect: (version: Types.Version) => void;
}) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const versions = useRead(
"ListBuildVersions",
{ build: buildId! },
{ enabled: !!buildId }
).data;
const filtered = filterBySplit(versions, search, (item) =>
fmt_version(item.version)
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild disabled={disabled}>
<div className="h-full w-[150px] cursor-pointer flex items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1">
{selected ? fmt_version(selected) : "Latest"}
<CaretSortIcon className="h-4 w-4 opacity-50" />
</div>
</PopoverTrigger>
<PopoverContent align="end" className="w-[200px] max-h-[200px] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search Versions"
value={search}
onValueChange={setSearch}
className="h-9"
/>
<CommandList>
<CommandEmpty className="flex justify-evenly items-center">
No Versions Found
<SearchX className="w-3 h-3" />
</CommandEmpty>
<CommandGroup>
<CommandItem
className="cursor-pointer"
onSelect={() => {
onSelect({ major: 0, minor: 0, patch: 0 });
setOpen(false);
}}
>
<div>Latest</div>
</CommandItem>
{filtered?.map((v) => {
const version = fmt_version(v.version);
return (
<CommandItem
key={version}
onSelect={() => {
onSelect(v.version);
setOpen(false);
}}
className="flex items-center justify-between cursor-pointer"
>
<div>{version}</div>
<div className="text-muted-foreground">
{fmt_date(new Date(v.ts))}
</div>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
const ImageTypeSelector = ({
selected,
onSelect,
disabled,
}: {
selected: Types.DeploymentImage["type"] | undefined;
onSelect: (type: Types.DeploymentImage["type"]) => void;
disabled: boolean;
}) => (
<Select
value={selected || undefined}
onValueChange={onSelect}
disabled={disabled}
>
<SelectTrigger className="max-w-[150px]" disabled={disabled}>
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value={"Image"}>Image</SelectItem>
<SelectItem value={"Build"}>Build</SelectItem>
</SelectContent>
</Select>
);
export const ImageConfig = ({
image,
set,
disabled,
}: {
image: Types.DeploymentImage | undefined;
set: (input: Partial<Types.DeploymentConfig>) => void;
disabled: boolean;
}) => (
<div className="flex gap-4 w-full items-center">
<ImageTypeSelector
selected={image?.type}
disabled={disabled}
onSelect={(type) =>
set({
image: {
type: type,
params:
type === "Image"
? { image: "" }
: ({
build_id: "",
version: { major: 0, minor: 0, patch: 0 },
} as any),
},
})
}
/>
{image?.type === "Build" && (
<>
<ResourceSelector
type="Build"
selected={image.params.build_id}
onSelect={(id) =>
set({
image: {
...image,
params: { ...image.params, build_id: id },
},
})
}
disabled={disabled}
/>
<BuildVersionSelector
buildId={image.params.build_id}
selected={image.params.version}
onSelect={(version) =>
set({
image: {
...image,
params: {
...image.params,
version,
},
},
})
}
disabled={disabled}
/>
</>
)}
{image?.type === "Image" && (
<Input
value={image.params.image}
onChange={(e) =>
set({
image: {
...image,
params: { image: e.target.value },
},
})
}
className="w-full"
placeholder="image name"
disabled={disabled}
/>
)}
</div>
);

View File

@@ -1,98 +0,0 @@
import { ConfigItem } from "@components/config/util";
import { useRead } from "@lib/hooks";
import { Input } from "@ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import { useState } from "react";
export const NetworkModeSelector = ({
swarm_id,
server_id,
selected,
onSelect,
disabled,
}: {
swarm_id: string | undefined;
server_id: string | undefined;
selected: string | undefined;
onSelect: (type: string) => void;
disabled: boolean;
}) => {
const _networks =
useRead(
swarm_id ? "ListSwarmNetworks" : "ListDockerNetworks",
{ swarm: swarm_id, server: server_id! },
{ enabled: !!swarm_id || !!server_id }
)
.data?.filter((n) => n.name)
.map((network) => network.name) ?? [];
const [customMode, setCustomMode] = useState(false);
const networks =
!selected || _networks.includes(selected)
? _networks
: [..._networks, selected];
return (
<ConfigItem
label="Network Mode"
boldLabel
description="Choose the --network attached to container"
>
{customMode ? (
<Input
placeholder="Input custom network name"
value={selected}
onChange={(e) => onSelect(e.target.value)}
className="max-w-[75%] lg:max-w-[400px]"
onBlur={() => setCustomMode(false)}
onKeyDown={(e) => {
if (e.key === "Enter") {
setCustomMode(false);
}
}}
autoFocus
/>
) : (
<Select
value={selected || undefined}
onValueChange={(value) => {
if (value === "Custom") {
setCustomMode(true);
onSelect("");
} else {
onSelect(value);
}
}}
disabled={disabled}
>
<SelectTrigger className="w-[200px]" disabled={disabled}>
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent>
{networks
?.filter((network) => network)
.map((network) => (
<SelectItem
key={network}
value={network!}
className="cursor-pointer"
>
{network!}
</SelectItem>
))}
<SelectItem value="Custom" className="cursor-pointer">
Custom
</SelectItem>
</SelectContent>
</Select>
)}
</ConfigItem>
);
};

View File

@@ -1,51 +0,0 @@
import { ConfigItem } from "@components/config/util";
import { Types } from "komodo_client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import { object_keys } from "@lib/utils";
const format_mode = (m: string) => m.split("-").join(" ");
export const RestartModeSelector = ({
selected,
set,
disabled,
}: {
selected: Types.RestartMode | undefined;
set: (input: Partial<Types.DeploymentConfig>) => void;
disabled: boolean;
}) => (
<ConfigItem
label="Restart Mode"
boldLabel
description="Configure the --restart behavior."
>
<Select
value={selected || undefined}
onValueChange={(restart: Types.RestartMode) => set({ restart })}
disabled={disabled}
>
<SelectTrigger className="w-[200px] capitalize" disabled={disabled}>
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent>
{object_keys(Types.RestartMode).map((mode) => (
<SelectItem
key={mode}
value={Types.RestartMode[mode]}
className="capitalize cursor-pointer"
>
{mode === "NoRestart"
? "Don't Restart"
: format_mode(Types.RestartMode[mode])}
</SelectItem>
))}
</SelectContent>
</Select>
</ConfigItem>
);

View File

@@ -1,93 +0,0 @@
import { ConfigItem } from "@components/config/util";
import { Types } from "komodo_client";
import { Input } from "@ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import { useToast } from "@ui/use-toast";
import { useEffect, useState } from "react";
export const DefaultTerminationSignal = ({
arg,
set,
disabled,
}: {
arg?: Types.TerminationSignal;
set: (input: Partial<Types.DeploymentConfig>) => void;
disabled: boolean;
}) => {
return (
<ConfigItem label="Default Termination Signal">
<Select
value={arg}
onValueChange={(value) =>
set({ termination_signal: value as Types.TerminationSignal })
}
disabled={disabled}
>
<SelectTrigger className="w-[200px]" disabled={disabled}>
<SelectValue placeholder="Select Type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.values(Types.TerminationSignal)
.reverse()
.map((term_signal) => (
<SelectItem
key={term_signal}
value={term_signal}
className="cursor-pointer"
>
{term_signal}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</ConfigItem>
);
};
export const TerminationTimeout = ({
arg,
set,
disabled,
}: {
arg: number;
set: (input: Partial<Types.DeploymentConfig>) => void;
disabled: boolean;
}) => {
const { toast } = useToast();
const [input, setInput] = useState(arg.toString());
useEffect(() => {
setInput(arg.toString());
}, [arg]);
return (
<ConfigItem label="Termination Timeout">
<div className="flex items-center gap-4">
<Input
className="w-[100px]"
placeholder="time in seconds"
value={input}
onChange={(e) => setInput(e.target.value)}
onBlur={(e) => {
const num = Number(e.target.value);
if (num || num === 0) {
set({ termination_timeout: num });
} else {
toast({ title: "Termination timeout must be a number" });
setInput(arg.toString());
}
}}
disabled={disabled}
/>
seconds
</div>
</ConfigItem>
);
};

View File

@@ -1,417 +0,0 @@
import {
useExecute,
useInvalidate,
usePermissions,
useRead,
useWrite,
} from "@lib/hooks";
import { Types } from "komodo_client";
import { RequiredResourceComponents } from "@types";
import { CircleArrowUp, HardDrive, Rocket, Server } from "lucide-react";
import { cn } from "@lib/utils";
import { useServer } from "../server";
import {
DeployDeployment,
StartStopDeployment,
DestroyDeployment,
RestartDeployment,
PauseUnpauseDeployment,
PullDeployment,
} from "./actions";
import {
deployment_state_intention,
stroke_color_class_by_intention,
} from "@lib/color";
import { DeploymentTable } from "./table";
import {
DeleteResource,
NewResource,
ResourceLink,
ResourcePageHeader,
} from "../common";
import { RunBuild } from "../build/actions";
import {
ActionButton,
ActionWithDialog,
DashboardPieChart,
} from "@components/util";
import {
ContainerPortsTableView,
DockerResourceLink,
StatusBadge,
} from "@components/util";
import { GroupActions } from "@components/group-actions";
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
import { DeploymentTabs } from "./tabs";
import { SwarmResourceLink, useSwarm } from "../swarm";
import { useToast } from "@ui/use-toast";
// const configOrLog = atomWithStorage("config-or-log-v1", "Config");
export const useDeployment = (id?: string) =>
useRead("ListDeployments", {}, { refetchInterval: 10_000 }).data?.find(
(d) => d.id === id,
);
export const useFullDeployment = (id: string) =>
useRead("GetDeployment", { deployment: id }, { refetchInterval: 10_000 })
.data;
const DeploymentIcon = ({ id, size }: { id?: string; size: number }) => {
const state = useDeployment(id)?.info.state;
const color = stroke_color_class_by_intention(
deployment_state_intention(state),
);
return <Rocket className={cn(`w-${size} h-${size}`, state && color)} />;
};
export const DeploymentComponents: RequiredResourceComponents = {
list_item: (id) => useDeployment(id),
resource_links: (resource) =>
(resource.config as Types.DeploymentConfig).links,
Description: () => <>Deploy containers on your servers.</>,
Dashboard: () => {
const summary = useRead("GetDeploymentsSummary", {}).data;
const all = [
summary?.running ?? 0,
summary?.stopped ?? 0,
summary?.unhealthy ?? 0,
summary?.unknown ?? 0,
];
const [running, stopped, unhealthy, unknown] = all;
return (
<DashboardPieChart
data={[
all.every((item) => item === 0) && {
title: "Not Deployed",
intention: "Neutral",
value: summary?.not_deployed ?? 0,
},
{ intention: "Good", value: running, title: "Running" },
{
title: "Stopped",
intention: "Warning",
value: stopped,
},
{
title: "Unhealthy",
intention: "Critical",
value: unhealthy,
},
{
title: "Unknown",
intention: "Unknown",
value: unknown,
},
]}
/>
);
},
New: ({ swarm_id, server_id: _server_id, build_id }) => {
const swarmsExist = useRead("ListSwarms", {}).data?.length ? true : false;
const servers = useRead("ListServers", {}).data;
const server_id = _server_id
? _server_id
: !swarmsExist && servers && servers.length === 1
? servers[0].id
: undefined;
return (
<NewResource
type="Deployment"
swarm_id={swarm_id}
selectSwarm={!swarm_id && !server_id}
server_id={server_id}
selectServer={!swarm_id && !server_id}
build_id={build_id}
/>
);
},
Table: ({ resources }) => {
return (
<DeploymentTable deployments={resources as Types.DeploymentListItem[]} />
);
},
GroupActions: () => (
<GroupActions
type="Deployment"
actions={[
"CheckDeploymentForUpdate",
"PullDeployment",
"Deploy",
"RestartDeployment",
"StopDeployment",
"DestroyDeployment",
]}
/>
),
Icon: ({ id }) => <DeploymentIcon id={id} size={4} />,
BigIcon: ({ id }) => <DeploymentIcon id={id} size={8} />,
State: ({ id }) => {
const state =
useDeployment(id)?.info.state ?? Types.DeploymentState.Unknown;
return (
<StatusBadge text={state} intent={deployment_state_intention(state)} />
);
},
Info: {
DeployTarget: ({ id }) => {
const info = useDeployment(id)?.info;
const swarm = useSwarm(info?.swarm_id);
const server = useServer(info?.server_id);
return swarm?.id ? (
<ResourceLink type="Swarm" id={swarm?.id} />
) : server?.id ? (
<ResourceLink type="Server" id={server?.id} />
) : (
<div className="flex gap-2 items-center">
<Server className="w-4 h-4" />
<div>Unknown</div>
</div>
);
},
Image: ({ id }) => {
const config = useFullDeployment(id)?.config;
const info = useDeployment(id)?.info;
return info?.build_id ? (
<ResourceLink type="Build" id={info.build_id} />
) : (
<div className="flex gap-2 items-center text-sm">
<HardDrive className="w-4 h-4" />
<div>
{info?.image.startsWith("sha256:")
? (
config?.image as Extract<
Types.DeploymentImage,
{ type: "Image" }
>
)?.params.image
: info?.image.split("@")[0] || "N/A"}
</div>
</div>
);
},
DockerResource: ({ id }) => {
const deployment = useDeployment(id);
const service = useRead(
"ListSwarmServices",
{ swarm: deployment?.info.swarm_id! },
{ enabled: !!deployment?.info.swarm_id },
).data?.find((service) => service.Name === deployment?.name);
if (
!deployment ||
[
Types.DeploymentState.Unknown,
Types.DeploymentState.NotDeployed,
].includes(deployment.info.state)
) {
return null;
}
if (deployment.info.swarm_id) {
return (
<div className="flex items-center gap-x-4 gap-y-2 flex-wrap">
<SwarmResourceLink
type="Service"
swarm_id={deployment.info.swarm_id}
resource_id={deployment.name}
name={deployment.name}
/>
{service?.Configs.map((config) => (
<div key={config} className="border-l pl-4 text-sm">
<SwarmResourceLink
type="Config"
swarm_id={deployment.info.swarm_id}
resource_id={config}
name={config}
/>
</div>
))}
{service?.Secrets.map((secret) => (
<div key={secret} className="border-l pl-4 text-sm">
<SwarmResourceLink
type="Secret"
swarm_id={deployment.info.swarm_id}
resource_id={secret}
name={secret}
/>
</div>
))}
</div>
);
} else {
return (
<DockerResourceLink
type="container"
name={deployment.name}
server_id={deployment.info.server_id}
/>
);
}
},
Ports: ({ id }) => {
const deployment = useDeployment(id);
const container = useRead(
"ListDockerContainers",
{
server: deployment?.info.server_id!,
},
{ refetchInterval: 10_000, enabled: !!deployment?.info.server_id },
).data?.find((container) => container.name === deployment?.name);
if (!container) return null;
return (
<ContainerPortsTableView
ports={container?.ports ?? []}
server_id={deployment?.info.server_id}
/>
);
},
},
Status: {
UpdateAvailable: ({ id }) => <UpdateAvailable id={id} />,
},
Actions: {
RunBuild: ({ id }) => {
const build_id = useDeployment(id)?.info.build_id;
if (!build_id) return null;
return <RunBuild id={build_id} />;
},
DeployDeployment,
PullDeployment,
RestartDeployment,
PauseUnpauseDeployment,
StartStopDeployment,
DestroyDeployment,
},
Page: {},
Config: DeploymentTabs,
DangerZone: ({ id }) => <DeleteResource type="Deployment" id={id} />,
ResourcePageHeader: ({ id }) => {
const deployment = useDeployment(id);
return (
<ResourcePageHeader
intent={deployment_state_intention(deployment?.info.state)}
icon={<DeploymentIcon id={id} size={8} />}
type="Deployment"
id={id}
resource={deployment}
state={
deployment?.info.state === Types.DeploymentState.NotDeployed
? "Not Deployed"
: deployment?.info.state
}
status={deployment?.info.status}
/>
);
},
};
export const UpdateAvailable = ({
id,
small,
}: {
id: string;
small?: boolean;
}) => {
const { toast } = useToast();
const { canExecute } = usePermissions({ type: "Deployment", id });
const { mutate: deploy, isPending } = useExecute("Deploy");
const inv = useInvalidate();
const { mutate: checkForUpdate, isPending: checkPending } = useWrite(
"CheckDeploymentForUpdate",
{
onSuccess: () => {
toast({ title: "Checked for updates" });
inv(["ListDeployments"]);
},
},
);
const deploying = useRead(
"GetDeploymentActionState",
{ deployment: id },
{ refetchInterval: 5_000 },
).data?.deploying;
const pending = isPending || deploying;
const deployment = useDeployment(id);
const info = deployment?.info;
const state = info?.state ?? Types.DeploymentState.Unknown;
if (
!info ||
info.swarm_id ||
info.build_id ||
[Types.DeploymentState.NotDeployed, Types.DeploymentState.Unknown].includes(
state,
)
) {
return null;
}
if (small || !canExecute) {
if (!info?.update_available) {
return null;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"px-2 py-1 border rounded-md border-blue-400 hover:border-blue-500 opacity-50 hover:opacity-70 transition-colors cursor-pointer flex items-center gap-2",
small ? "px-2 py-1" : "px-3 py-2",
)}
>
{!small && (
<div className="text-sm text-nowrap overflow-hidden overflow-ellipsis">
Update Available
</div>
)}
<CircleArrowUp className="w-4 h-4" />
</div>
</TooltipTrigger>
<TooltipContent className="w-fit text-sm">
There is a newer image available.
</TooltipContent>
</Tooltip>
);
}
if (!info?.update_available) {
return (
<ActionButton
variant="outline"
className="flex gap-2 items-center opacity-50 max-w-fit"
onClick={() => checkForUpdate({ deployment: id })}
loading={checkPending}
title="Check"
icon={<CircleArrowUp className="w-4 h-4" />}
/>
);
}
return (
<ActionWithDialog
name={deployment.name}
variant="outline"
targetClassName="border-blue-400 hover:border-blue-500 opacity-50 hover:opacity-70 max-w-fit"
title="Update Available"
openTitle="Redeploy"
icon={<CircleArrowUp className="w-4 h-4" />}
onClick={() => deploy({ deployment: id })}
disabled={pending}
loading={pending}
/>
);
};

View File

@@ -1,56 +0,0 @@
import { usePermissions, useRead } from "@lib/hooks";
import { ReactNode } from "react";
import { Types } from "komodo_client";
import { Section } from "@components/layouts";
import { InspectResponseViewer } from "@components/inspect";
export const DeploymentInspect = ({
id,
useSwarm,
titleOther,
}: {
id: string;
useSwarm: boolean;
titleOther: ReactNode;
}) => {
const { specific } = usePermissions({ type: "Deployment", id });
if (!specific.includes(Types.SpecificPermission.Inspect)) {
return (
<Section titleOther={titleOther}>
<div className="min-h-[60vh]">
<h1>User does not have permission to inspect this Deployment.</h1>
</div>
</Section>
);
}
return (
<Section titleOther={titleOther}>
<DeploymentInspectInner id={id} useSwarm={useSwarm} />
</Section>
);
};
const DeploymentInspectInner = ({
id,
useSwarm,
}: {
id: string;
useSwarm: boolean;
}) => {
const {
data: container,
error,
isPending,
isError,
} = useRead(`InspectDeployment${useSwarm ? "SwarmService" : "Container"}`, {
deployment: id,
});
return (
<InspectResponseViewer
response={container}
error={error}
isPending={isPending}
isError={isError}
/>
);
};

View File

@@ -1,99 +0,0 @@
import { useRead } from "@lib/hooks";
import { Types } from "komodo_client";
import { ReactNode } from "react";
import { useDeployment } from ".";
import { Log, LogSection } from "@components/log";
export const DeploymentLogs = ({
id,
titleOther,
}: {
id: string;
titleOther: ReactNode;
}) => {
const state = useDeployment(id)?.info.state;
if (
state === undefined ||
state === Types.DeploymentState.Unknown ||
state === Types.DeploymentState.NotDeployed
) {
return null;
}
return <DeploymentLogsInner id={id} titleOther={titleOther} />;
};
const DeploymentLogsInner = ({
id,
titleOther,
}: {
id: string;
titleOther: ReactNode;
}) => {
return (
<LogSection
regular_logs={(timestamps, stream, tail, poll) =>
NoSearchLogs(id, tail, timestamps, stream, poll)
}
search_logs={(timestamps, terms, invert, poll) =>
SearchLogs(id, terms, invert, timestamps, poll)
}
titleOther={titleOther}
/>
);
};
const NoSearchLogs = (
id: string,
tail: number,
timestamps: boolean,
stream: string,
poll: boolean
) => {
const { data: log, refetch } = useRead(
"GetDeploymentLog",
{
deployment: id,
tail,
timestamps,
},
{ refetchInterval: poll ? 3000 : false }
);
return {
Log: (
<div className="relative">
<Log log={log} stream={stream as "stdout" | "stderr"} />
</div>
),
refetch,
stderr: !!log?.stderr,
};
};
const SearchLogs = (
id: string,
terms: string[],
invert: boolean,
timestamps: boolean,
poll: boolean
) => {
const { data: log, refetch } = useRead(
"SearchDeploymentLog",
{
deployment: id,
terms,
combinator: Types.SearchCombinator.And,
invert,
timestamps,
},
{ refetchInterval: poll ? 10000 : false }
);
return {
Log: (
<div className="h-full relative">
<Log log={log} stream="stdout" />
</div>
),
refetch,
stderr: !!log?.stderr,
};
};

View File

@@ -1,159 +0,0 @@
import { Types } from "komodo_client";
import { useDeployment } from ".";
import { useLocalStorage, usePermissions, useRead } from "@lib/hooks";
import { useServer } from "../server";
import { ReactNode, useMemo, useState } from "react";
import {
MobileFriendlyTabsSelector,
TabNoContent,
} from "@ui/mobile-friendly-tabs";
import { DeploymentConfig } from "./config";
import { DeploymentLogs } from "./log";
import { DeploymentInspect } from "./inspect";
import { ContainerTerminals } from "@components/terminal/container";
import { SwarmServiceTasksTable } from "../swarm/table";
import { useWebsocketMessages } from "@lib/socket";
export const DeploymentTabs = ({ id }: { id: string }) => {
const deployment = useDeployment(id);
if (!deployment) return null;
return <DeploymentTabsInner deployment={deployment} />;
};
type DeploymentTabsView = "Config" | "Tasks" | "Log" | "Inspect" | "Terminals";
const DeploymentTabsInner = ({
deployment,
}: {
deployment: Types.DeploymentListItem;
}) => {
const [_view, setView] = useLocalStorage<DeploymentTabsView>(
"deployment-tabs-v1",
"Config"
);
const { specificLogs, specificInspect, specificTerminal } = usePermissions({
type: "Deployment",
id: deployment.id,
});
const container_terminals_disabled =
useServer(deployment.info.server_id)?.info.container_terminals_disabled ??
false;
const state = deployment.info.state;
const downOrUnknown =
state === undefined ||
state === Types.DeploymentState.Unknown ||
state === Types.DeploymentState.NotDeployed;
const logsDisabled = !specificLogs || downOrUnknown;
const inspectDisabled = !specificInspect || downOrUnknown;
const terminalDisabled =
!specificTerminal ||
container_terminals_disabled ||
state !== Types.DeploymentState.Running;
const view =
(logsDisabled && _view === "Log") ||
(downOrUnknown && _view === "Tasks") ||
(inspectDisabled && _view === "Inspect") ||
(terminalDisabled && _view === "Terminals")
? "Config"
: _view;
const tabs = useMemo<TabNoContent<DeploymentTabsView>[]>(
() => [
{
value: "Config",
},
{
value: "Tasks",
disabled: downOrUnknown,
hidden: !deployment.info.swarm_id,
},
{
value: "Log",
disabled: logsDisabled,
},
{
value: "Inspect",
disabled: inspectDisabled,
},
{
value: "Terminals",
disabled: terminalDisabled,
hidden: !!deployment.info.swarm_id,
},
],
[logsDisabled, inspectDisabled, terminalDisabled, deployment.info.swarm_id]
);
const Selector = (
<MobileFriendlyTabsSelector
tabs={tabs}
value={view}
onValueChange={setView as any}
tabsTriggerClassname="w-[110px]"
/>
);
const target: Types.TerminalTarget = useMemo(
() => ({
type: "Deployment",
params: {
deployment: deployment.id,
},
}),
[deployment.id]
);
switch (view) {
case "Config":
return <DeploymentConfig id={deployment.id} titleOther={Selector} />;
case "Tasks":
return (
<DeploymentTasksTable deployment={deployment} Selector={Selector} />
);
case "Log":
return <DeploymentLogs id={deployment.id} titleOther={Selector} />;
case "Inspect":
return (
<DeploymentInspect
id={deployment.id}
titleOther={Selector}
useSwarm={!!deployment.info.swarm_id}
/>
);
case "Terminals":
return <ContainerTerminals target={target} titleOther={Selector} />;
}
};
const DeploymentTasksTable = ({
deployment,
Selector,
}: {
deployment: Types.DeploymentListItem;
Selector: ReactNode;
}) => {
const { data, refetch } = useRead("ListSwarmServices", {
swarm: deployment.info.swarm_id,
});
const service = data?.find((service) => service.Name === deployment.name);
useWebsocketMessages(
"deployment-swarm-tasks",
(update) =>
update.operation === Types.Operation.Deploy &&
update.target.id === deployment.id &&
refetch()
);
const _search = useState("");
return (
<SwarmServiceTasksTable
id={deployment.info.swarm_id}
service_id={service?.ID}
titleOther={Selector}
_search={_search}
/>
);
};

View File

@@ -1,28 +0,0 @@
import { RequiredResourceComponents, UsableResource } from "@types";
import { SwarmComponents } from "./swarm";
import { ServerComponents } from "./server";
import { StackComponents } from "./stack";
import { DeploymentComponents } from "./deployment";
import { BuildComponents } from "./build";
import { RepoComponents } from "./repo";
import { ProcedureComponents } from "./procedure/index";
import { ActionComponents } from "./action";
import { BuilderComponents } from "./builder";
import { AlerterComponents } from "./alerter";
import { ResourceSyncComponents } from "./sync";
export const ResourceComponents: {
[key in UsableResource]: RequiredResourceComponents;
} = {
Swarm: SwarmComponents,
Server: ServerComponents,
Stack: StackComponents,
Deployment: DeploymentComponents,
Build: BuildComponents,
Repo: RepoComponents,
Procedure: ProcedureComponents,
Action: ActionComponents,
ResourceSync: ResourceSyncComponents,
Builder: BuilderComponents,
Alerter: AlerterComponents,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,170 +0,0 @@
import { ActionWithDialog, StatusBadge } from "@components/util";
import { useExecute, useRead } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { Clock, Route } from "lucide-react";
import { ProcedureConfig } from "./config";
import { ProcedureTable } from "./table";
import { DeleteResource, NewResource, ResourcePageHeader } from "../common";
import {
procedure_state_intention,
stroke_color_class_by_intention,
} from "@lib/color";
import { cn, updateLogToHtml } from "@lib/utils";
import { Types } from "komodo_client";
import { DashboardPieChart } from "@components/util";
import { GroupActions } from "@components/group-actions";
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
import { Card } from "@ui/card";
const useProcedure = (id?: string) =>
useRead("ListProcedures", {}).data?.find((d) => d.id === id);
const ProcedureIcon = ({ id, size }: { id?: string; size: number }) => {
const state = useProcedure(id)?.info.state;
const color = stroke_color_class_by_intention(
procedure_state_intention(state)
);
return <Route className={cn(`w-${size} h-${size}`, state && color)} />;
};
export const ProcedureComponents: RequiredResourceComponents = {
list_item: (id) => useProcedure(id),
resource_links: () => undefined,
Description: () => <>Orchestrate multiple Komodo executions.</>,
Dashboard: () => {
const summary = useRead("GetProceduresSummary", {}).data;
return (
<DashboardPieChart
data={[
{ title: "Ok", intention: "Good", value: summary?.ok ?? 0 },
{
title: "Running",
intention: "Warning",
value: summary?.running ?? 0,
},
{
title: "Failed",
intention: "Critical",
value: summary?.failed ?? 0,
},
{
title: "Unknown",
intention: "Unknown",
value: summary?.unknown ?? 0,
},
]}
/>
);
},
New: () => <NewResource type="Procedure" />,
GroupActions: () => (
<GroupActions type="Procedure" actions={["RunProcedure"]} />
),
Table: ({ resources }) => (
<ProcedureTable procedures={resources as Types.ProcedureListItem[]} />
),
Icon: ({ id }) => <ProcedureIcon id={id} size={4} />,
BigIcon: ({ id }) => <ProcedureIcon id={id} size={8} />,
State: ({ id }) => {
let state = useProcedure(id)?.info.state;
return (
<StatusBadge text={state} intent={procedure_state_intention(state)} />
);
},
Status: {},
Info: {
Schedule: ({ id }) => {
const next_scheduled_run = useProcedure(id)?.info.next_scheduled_run;
return (
<div className="flex gap-2 items-center">
<Clock className="w-4 h-4" />
Next Run:
<div className="font-bold">
{next_scheduled_run
? new Date(next_scheduled_run).toLocaleString()
: "Not Scheduled"}
</div>
</div>
);
},
ScheduleErrors: ({ id }) => {
const error = useProcedure(id)?.info.schedule_error;
if (!error) {
return null;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Card className="px-3 py-2 bg-destructive/75 hover:bg-destructive transition-colors cursor-pointer">
<div className="text-sm text-nowrap overflow-hidden overflow-ellipsis">
Schedule Error
</div>
</Card>
</TooltipTrigger>
<TooltipContent className="w-[400px]">
<pre
dangerouslySetInnerHTML={{
__html: updateLogToHtml(error),
}}
className="max-h-[500px] overflow-y-auto"
/>
</TooltipContent>
</Tooltip>
);
},
},
Actions: {
RunProcedure: ({ id }) => {
const running = useRead(
"GetProcedureActionState",
{ procedure: id },
{ refetchInterval: 5000 }
).data?.running;
const { mutate, isPending } = useExecute("RunProcedure");
const procedure = useProcedure(id);
if (!procedure) return null;
return (
<ActionWithDialog
name={procedure.name}
title={running ? "Running" : "Run Procedure"}
icon={<Route className="h-4 w-4" />}
onClick={() => mutate({ procedure: id })}
disabled={running || isPending}
loading={running}
/>
);
},
},
Page: {},
Config: ProcedureConfig,
DangerZone: ({ id }) => <DeleteResource type="Procedure" id={id} />,
ResourcePageHeader: ({ id }) => {
const procedure = useProcedure(id);
return (
<ResourcePageHeader
intent={procedure_state_intention(procedure?.info.state)}
icon={<ProcedureIcon id={id} size={8} />}
type="Procedure"
id={id}
resource={procedure}
state={procedure?.info.state}
status={`${procedure?.info.stages} Stage${procedure?.info.stages === 1 ? "" : "s"}`}
/>
);
},
};

View File

@@ -1,277 +0,0 @@
import { useInvalidate, useRead, useWrite } from "@lib/hooks";
import { RequiredResourceComponents } from "@types";
import { Card } from "@ui/card";
import { GitBranch, Loader2, RefreshCcw } from "lucide-react";
import { RepoConfig } from "./config";
import { BuildRepo, CloneRepo, PullRepo } from "./actions";
import {
DeleteResource,
NewResource,
ResourceLink,
ResourcePageHeader,
} from "../common";
import { RepoTable } from "./table";
import {
repo_state_intention,
stroke_color_class_by_intention,
} from "@lib/color";
import { cn } from "@lib/utils";
import { useServer } from "../server";
import { Types } from "komodo_client";
import { DashboardPieChart } from "@components/util";
import { RepoLink, StatusBadge } from "@components/util";
import { Badge } from "@ui/badge";
import { useToast } from "@ui/use-toast";
import { Button } from "@ui/button";
import { useBuilder } from "../builder";
import { GroupActions } from "@components/group-actions";
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
export const useRepo = (id?: string) =>
useRead("ListRepos", {}, { refetchInterval: 10_000 }).data?.find(
(d) => d.id === id
);
export const useFullRepo = (id: string) =>
useRead("GetRepo", { repo: id }, { refetchInterval: 10_000 }).data;
const RepoIcon = ({ id, size }: { id?: string; size: number }) => {
const state = useRepo(id)?.info.state;
const color = stroke_color_class_by_intention(repo_state_intention(state));
return <GitBranch className={cn(`w-${size} h-${size}`, state && color)} />;
};
export const RepoComponents: RequiredResourceComponents = {
list_item: (id) => useRepo(id),
resource_links: (resource) => (resource.config as Types.RepoConfig).links,
Description: () => <>Build using custom scripts. Or anything else.</>,
Dashboard: () => {
const summary = useRead("GetReposSummary", {}).data;
return (
<DashboardPieChart
data={[
{ intention: "Good", value: summary?.ok ?? 0, title: "Ok" },
{
intention: "Warning",
value: (summary?.cloning ?? 0) + (summary?.pulling ?? 0),
title: "Pulling",
},
{
intention: "Critical",
value: summary?.failed ?? 0,
title: "Failed",
},
{
intention: "Unknown",
value: summary?.unknown ?? 0,
title: "Unknown",
},
]}
/>
);
},
New: ({ server_id, builder_id }) => {
return (
<NewResource
type="Repo"
server_id={server_id}
selectServer={!server_id}
builder_id={builder_id}
selectBuilder={!builder_id}
/>
);
},
GroupActions: () => (
<GroupActions
type="Repo"
actions={["PullRepo", "CloneRepo", "BuildRepo"]}
/>
),
Table: ({ resources }) => (
<RepoTable repos={resources as Types.RepoListItem[]} />
),
Icon: ({ id }) => <RepoIcon id={id} size={4} />,
BigIcon: ({ id }) => <RepoIcon id={id} size={8} />,
State: ({ id }) => {
const state = useRepo(id)?.info.state;
return <StatusBadge text={state} intent={repo_state_intention(state)} />;
},
Info: {
Target: ({ id }) => {
const info = useRepo(id)?.info;
const server = useServer(info?.server_id);
const builder = useBuilder(info?.builder_id);
return (
<div className="flex items-center gap-x-4 gap-y-2 flex-wrap">
{server?.id &&
(builder?.id ? (
<div className="pr-4 text-sm border-r">
<ResourceLink type="Server" id={server.id} />
</div>
) : (
<ResourceLink type="Server" id={server.id} />
))}
{builder?.id && <ResourceLink type="Builder" id={builder.id} />}
</div>
);
},
Source: ({ id }) => {
const info = useRepo(id)?.info;
if (!info) {
return <Loader2 className="w-4 h-4 animate-spin" />;
}
return <RepoLink link={info.repo_link} repo={info.repo} />;
},
Branch: ({ id }) => {
const branch = useRepo(id)?.info.branch;
return (
<div className="flex items-center gap-2">
<GitBranch className="w-4 h-4" />
{branch}
</div>
);
},
},
Status: {
Cloned: ({ id }) => {
const info = useRepo(id)?.info;
if (!info?.cloned_hash || info.cloned_hash === info.latest_hash) {
return null;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Card className="px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer">
<div className="text-muted-foreground text-sm text-nowrap overflow-hidden overflow-ellipsis">
cloned: {info.cloned_hash}
</div>
</Card>
</TooltipTrigger>
<TooltipContent>
<div className="grid">
<div className="text-muted-foreground">commit message:</div>
{info.cloned_message}
</div>
</TooltipContent>
</Tooltip>
);
},
Built: ({ id }) => {
const info = useRepo(id)?.info;
const fullInfo = useFullRepo(id)?.info;
if (!info?.built_hash || info.built_hash === info.latest_hash) {
return null;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Card className="px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer">
<div className="text-muted-foreground text-sm text-nowrap overflow-hidden overflow-ellipsis">
built: {info.built_hash}
</div>
</Card>
</TooltipTrigger>
<TooltipContent>
<div className="grid gap-2">
<Badge
variant="secondary"
className="w-fit text-muted-foreground"
>
commit message
</Badge>
{fullInfo?.built_message}
</div>
</TooltipContent>
</Tooltip>
);
},
Latest: ({ id }) => {
const info = useRepo(id)?.info;
const fullInfo = useFullRepo(id)?.info;
if (!info?.latest_hash) {
return null;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<Card className="px-3 py-2 hover:bg-accent/50 transition-colors cursor-pointer">
<div className="text-muted-foreground text-sm text-nowrap overflow-hidden overflow-ellipsis">
latest: {info.latest_hash}
</div>
</Card>
</TooltipTrigger>
<TooltipContent>
<div className="grid gap-2">
<Badge
variant="secondary"
className="w-fit text-muted-foreground"
>
commit message
</Badge>
{fullInfo?.latest_message}
</div>
</TooltipContent>
</Tooltip>
);
},
Refresh: ({ id }) => {
const { toast } = useToast();
const inv = useInvalidate();
const { mutate, isPending } = useWrite("RefreshRepoCache", {
onSuccess: () => {
inv(["ListRepos"], ["GetRepo", { repo: id }]);
toast({ title: "Refreshed repo status cache" });
},
});
return (
<Button
variant="outline"
size="icon"
onClick={() => {
mutate({ repo: id });
toast({ title: "Triggered refresh of repo status cache" });
}}
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<RefreshCcw className="w-4 h-4" />
)}
</Button>
);
},
},
Actions: { BuildRepo, PullRepo, CloneRepo },
Page: {},
Config: RepoConfig,
DangerZone: ({ id }) => <DeleteResource type="Repo" id={id} />,
ResourcePageHeader: ({ id }) => {
const repo = useRepo(id);
return (
<ResourcePageHeader
intent={repo_state_intention(repo?.info.state)}
icon={<RepoIcon id={id} size={8} />}
type="Repo"
id={id}
resource={repo}
state={repo?.info.state}
status=""
/>
);
},
};

View File

@@ -1,86 +0,0 @@
import { useToast } from "@ui/use-toast";
import { useServer } from ".";
import { usePermissions, useWrite } from "@lib/hooks";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@ui/dialog";
import { Card } from "@ui/card";
import { cn } from "@lib/utils";
import { Button } from "@ui/button";
import { Loader2 } from "lucide-react";
export const ConfirmAttemptedPubkey = ({ id }: { id: string }) => {
const { toast } = useToast();
const server = useServer(id);
const { canWrite } = usePermissions({ type: "Server", id });
const [open, setOpen] = useState(false);
const { mutate, isPending } = useWrite("UpdateServerPublicKey", {
onSuccess: () => {
toast({ title: "Confirmed Server public key" });
setOpen(false);
},
onError: () => {
setOpen(false);
},
});
if (!server?.info.attempted_public_key) return null;
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger disabled={!canWrite}>
<Card
className={cn(
"px-3 py-2 bg-destructive/75 hover:bg-destructive transition-colors",
canWrite && "cursor-pointer"
)}
>
<div className="text-sm text-nowrap overflow-hidden overflow-ellipsis">
Invalid Pubkey
</div>
</Card>
</DialogTrigger>
<DialogContent className="w-[90vw] max-w-[700px]">
<DialogHeader>
<DialogTitle>Confirm {server.name} public key?</DialogTitle>
</DialogHeader>
<div className="text-muted-foreground text-sm">
<div>
Public Key:{" "}
<span className="text-foreground">
{server.info.attempted_public_key}
</span>
</div>
{!server.info.address && (
<div>Note. May take a few moments for status to update.</div>
)}
</div>
<DialogFooter>
<Button
className="w-[200px]"
variant="secondary"
onClick={() =>
mutate({
server: id,
public_key: server.info.attempted_public_key!,
})
}
disabled={isPending}
>
{isPending ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
"Confirm"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,11 +0,0 @@
import { atomWithStorage } from "@lib/hooks";
import { Types } from "komodo_client";
import { useAtom } from "jotai";
const statsGranularityAtom = atomWithStorage<Types.Timelength>(
"stats-granularity-v0",
Types.Timelength.FiveMinutes
);
export const useStatsGranularity = () =>
useAtom<Types.Timelength>(statsGranularityAtom);

View File

@@ -1,560 +0,0 @@
import { useExecute, useRead, useUser } from "@lib/hooks";
import { cn } from "@lib/utils";
import { Types } from "komodo_client";
import { RequiredResourceComponents } from "@types";
import {
Server,
Cpu,
MemoryStick,
Database,
Play,
RefreshCcw,
Pause,
Square,
AlertCircle,
CheckCircle2,
Globe,
} from "lucide-react";
import { Prune } from "./actions";
import {
server_state_intention,
stroke_color_class_by_intention,
} from "@lib/color";
import { ServerTable } from "./table";
import { DeleteResource, NewResource, ResourcePageHeader } from "../common";
import { ActionWithDialog, ConfirmButton, StatusBadge } from "@components/util";
import { DashboardPieChart } from "@components/util";
import { ServerStatsMini } from "./stats-mini";
import { GroupActions } from "@components/group-actions";
import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip";
import { useToast } from "@ui/use-toast";
import { ServerTabs } from "./tabs";
import { ConfirmAttemptedPubkey } from "./confirm-pubkey";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@ui/hover-card";
export const useServer = (id?: string) =>
useRead("ListServers", {}, { refetchInterval: 10_000 }).data?.find(
(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;
// Helper function to check for version mismatch
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;
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)
)
)}
/>
);
};
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 (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 cursor-pointer">
<AlertCircle
className={cn(
"w-4 h-4",
stroke_color_class_by_intention("Unknown")
)}
/>
Unknown
</div>
</TooltipTrigger>
<TooltipContent>
<div>
Server is <span className="font-bold">disabled</span> - version
unknown.
</div>
</TooltipContent>
</Tooltip>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 cursor-pointer">
{unknown ? (
<AlertCircle
className={cn(
"w-4 h-4",
stroke_color_class_by_intention("Unknown")
)}
/>
) : mismatch ? (
<AlertCircle
className={cn(
"w-4 h-4",
stroke_color_class_by_intention("Critical")
)}
/>
) : (
<CheckCircle2
className={cn("w-4 h-4", stroke_color_class_by_intention("Good"))}
/>
)}
{version ?? "Unknown"}
</div>
</TooltipTrigger>
<TooltipContent>
{unknown ? (
<div>
Periphery version is <span className="font-bold">unknown</span>.
</div>
) : mismatch ? (
<div>
Periphery version <span className="font-bold">mismatch</span>.
Expected <span className="font-bold">{core_version}</span>.
</div>
) : (
<div>
Periphery and Core version <span className="font-bold">match</span>.
</div>
)}
</TooltipContent>
</Tooltip>
);
};
export { ServerStatsMini };
export const ServerComponents: RequiredResourceComponents = {
list_item: (id) => useServer(id),
resource_links: (resource) => (resource.config as Types.ServerConfig).links,
Description: () => (
<>Connect servers for alerting, building, and deploying.</>
),
Dashboard: () => {
const summary = useRead(
"GetServersSummary",
{},
{ refetchInterval: 15_000 }
).data;
return (
<DashboardPieChart
data={[
{ title: "Healthy", intention: "Good", value: summary?.healthy ?? 0 },
{
title: "Warning",
intention: "Warning",
value: summary?.warning ?? 0,
},
{
title: "Unhealthy",
intention: "Critical",
value: summary?.unhealthy ?? 0,
},
{
title: "Disabled",
intention: "Neutral",
value: summary?.disabled ?? 0,
},
]}
/>
);
},
New: () => {
const user = useUser().data;
if (!user) return null;
if (!user.admin && !user.create_server_permissions) return null;
return <NewResource type="Server" />;
},
GroupActions: () => (
<GroupActions
type="Server"
actions={[
"PruneContainers",
"PruneNetworks",
"PruneVolumes",
"PruneImages",
"PruneSystem",
"RestartAllContainers",
"StopAllContainers",
]}
/>
),
Table: ({ resources }) => (
<ServerTable servers={resources as Types.ServerListItem[]} />
),
Icon: ({ id }) => <Icon id={id} size={4} />,
BigIcon: ({ id }) => <Icon id={id} size={8} />,
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;
return (
<StatusBadge
text={displayState}
intent={server_state_intention(state, hasVersionMismatch)}
/>
);
},
Status: {
ConfirmAttemptedPubkey,
},
Info: {
ServerVersion,
PublicIP: ({ id }) => {
const { toast } = useToast();
const public_ip = useServer(id)?.info.public_ip;
return (
<HoverCard>
<HoverCardTrigger>
<div
className="flex gap-2 items-center cursor-pointer"
onClick={() => {
public_ip &&
navigator.clipboard
.writeText(public_ip)
.then(() => toast({ title: "Copied public IP" }));
}}
>
<Globe className="w-4 h-4" />
{public_ip ?? "Unknown IP"}
</div>
</HoverCardTrigger>
<HoverCardContent sideOffset={4} className="w-fit text-sm">
Public IP (click to copy)
</HoverCardContent>
</HoverCard>
);
},
Cpu: ({ id }) => {
const isServerAvailable = useIsServerAvailable(id);
const core_count =
useRead(
"GetSystemInformation",
{ server: id },
{
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data?.core_count ?? 0;
return (
<HoverCard>
<HoverCardTrigger>
<div className="flex gap-2 items-center">
<Cpu className="w-4 h-4" />
{core_count
? `${core_count} Core${core_count === 1 ? "" : "s"}`
: "N/A"}
</div>
</HoverCardTrigger>
<HoverCardContent sideOffset={4} className="w-fit text-sm">
CPU Core Count
</HoverCardContent>
</HoverCard>
);
},
LoadAvg: ({ id }) => {
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
const one = stats?.load_average?.one;
return (
<HoverCard>
<HoverCardTrigger>
<div className="flex gap-2 items-center">
<Cpu className="w-4 h-4" />
{one?.toFixed(2) ?? "N/A"}
</div>
</HoverCardTrigger>
<HoverCardContent sideOffset={4} className="w-fit text-sm">
1m Load Average
</HoverCardContent>
</HoverCard>
);
},
Mem: ({ id }) => {
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
return (
<HoverCard>
<HoverCardTrigger>
<div className="flex gap-2 items-center">
<MemoryStick className="w-4 h-4" />
{stats?.mem_total_gb.toFixed(2).concat(" GB") ?? "N/A"}
</div>
</HoverCardTrigger>
<HoverCardContent sideOffset={4} className="w-fit text-sm">
Total Memory
</HoverCardContent>
</HoverCard>
);
},
Disk: ({ id }) => {
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: isServerAvailable,
refetchInterval: 5000,
}
).data;
const disk_total_gb = stats?.disks.reduce(
(acc, curr) => acc + curr.total_gb,
0
);
return (
<HoverCard>
<HoverCardTrigger>
<div className="flex gap-2 items-center">
<Database className="w-4 h-4" />
{disk_total_gb?.toFixed(2).concat(" GB") ?? "N/A"}
</div>
</HoverCardTrigger>
<HoverCardContent sideOffset={4} className="w-fit text-sm">
Total Disk Capacity
</HoverCardContent>
</HoverCard>
);
},
},
Actions: {
StartAll: ({ id }) => {
const server = useServer(id);
const { mutate, isPending } = useExecute("StartAllContainers");
const starting = useRead(
"GetServerActionState",
{ server: id },
{ refetchInterval: 5000 }
).data?.starting_containers;
const dontShow =
useRead("ListDockerContainers", {
server: id,
}).data?.every(
(container) =>
container.state === Types.ContainerStateStatusEnum.Running
) ?? true;
if (dontShow) {
return null;
}
const pending = isPending || starting;
return (
server && (
<ConfirmButton
title="Start Containers"
icon={<Play className="w-4 h-4" />}
onClick={() => mutate({ server: id })}
loading={pending}
disabled={pending}
/>
)
);
},
RestartAll: ({ id }) => {
const server = useServer(id);
const { mutate, isPending } = useExecute("RestartAllContainers");
const restarting = useRead(
"GetServerActionState",
{ server: id },
{ refetchInterval: 5000 }
).data?.restarting_containers;
const pending = isPending || restarting;
return (
server && (
<ActionWithDialog
name={server?.name}
title="Restart Containers"
icon={<RefreshCcw className="w-4 h-4" />}
onClick={() => mutate({ server: id })}
disabled={pending}
loading={pending}
/>
)
);
},
PauseAll: ({ id }) => {
const server = useServer(id);
const { mutate, isPending } = useExecute("PauseAllContainers");
const pausing = useRead(
"GetServerActionState",
{ server: id },
{ refetchInterval: 5000 }
).data?.pausing_containers;
const dontShow =
useRead("ListDockerContainers", {
server: id,
}).data?.every(
(container) =>
container.state !== Types.ContainerStateStatusEnum.Running
) ?? true;
if (dontShow) {
return null;
}
const pending = isPending || pausing;
return (
server && (
<ActionWithDialog
name={server?.name}
title="Pause Containers"
icon={<Pause className="w-4 h-4" />}
onClick={() => mutate({ server: id })}
disabled={pending}
loading={pending}
/>
)
);
},
UnpauseAll: ({ id }) => {
const server = useServer(id);
const { mutate, isPending } = useExecute("UnpauseAllContainers");
const unpausing = useRead(
"GetServerActionState",
{ server: id },
{ refetchInterval: 5000 }
).data?.unpausing_containers;
const dontShow =
useRead("ListDockerContainers", {
server: id,
}).data?.every(
(container) =>
container.state !== Types.ContainerStateStatusEnum.Paused
) ?? true;
if (dontShow) {
return null;
}
const pending = isPending || unpausing;
return (
server && (
<ConfirmButton
title="Unpause Containers"
icon={<Play className="w-4 h-4" />}
onClick={() => mutate({ server: id })}
loading={pending}
disabled={pending}
/>
)
);
},
StopAll: ({ id }) => {
const server = useServer(id);
const { mutate, isPending } = useExecute("StopAllContainers");
const stopping = useRead(
"GetServerActionState",
{ server: id },
{ refetchInterval: 5000 }
).data?.stopping_containers;
const pending = isPending || stopping;
return (
server && (
<ActionWithDialog
name={server.name}
title="Stop Containers"
icon={<Square className="w-4 h-4" />}
onClick={() => mutate({ server: id })}
disabled={pending}
loading={pending}
/>
)
);
},
PruneBuildx: ({ id }) => <Prune server_id={id} type="Buildx" />,
PruneSystem: ({ id }) => <Prune server_id={id} type="System" />,
},
Page: {},
Config: ServerTabs,
DangerZone: ({ id }) => <DeleteResource type="Server" id={id} />,
ResourcePageHeader: ({ id }) => {
const server = useServer(id);
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;
return (
<ResourcePageHeader
intent={server_state_intention(server?.info.state, hasVersionMismatch)}
icon={<Icon id={id} size={8} />}
type="Server"
id={id}
resource={server}
state={displayState}
status={server?.info.region}
/>
);
},
};

View File

@@ -1,94 +0,0 @@
import { Section } from "@components/layouts";
import { DockerResourceLink } from "@components/util";
import { format_size_bytes } from "@lib/formatting";
import { useRead } from "@lib/hooks";
import { Badge } from "@ui/badge";
import { DataTable, SortableHeader } from "@ui/data-table";
import { Dispatch, ReactNode, SetStateAction } from "react";
import { Prune } from "../actions";
import { Search } from "lucide-react";
import { Input } from "@ui/input";
import { filterBySplit } from "@lib/utils";
export const Images = ({
id,
titleOther,
_search,
}: {
id: string;
titleOther: ReactNode;
_search: [string, Dispatch<SetStateAction<string>>];
}) => {
const [search, setSearch] = _search;
const images =
useRead("ListDockerImages", { server: id }, { refetchInterval: 10_000 })
.data ?? [];
const allInUse = images.every((image) => image.in_use);
const filtered = filterBySplit(images, search, (image) => image.name);
return (
<Section
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
{!allInUse && <Prune server_id={id} type="Images" />}
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
}
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="server-images"
data={filtered}
columns={[
{
accessorKey: "name",
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
cell: ({ row }) => (
<DockerResourceLink
type="image"
server_id={id}
name={row.original.name}
id={row.original.id}
extra={
!row.original.in_use && (
<Badge variant="destructive">Unused</Badge>
)
}
/>
),
size: 200,
},
{
accessorKey: "id",
header: ({ column }) => (
<SortableHeader column={column} title="Id" />
),
},
{
accessorKey: "size",
header: ({ column }) => (
<SortableHeader column={column} title="Size" />
),
cell: ({ row }) =>
row.original.size
? format_size_bytes(row.original.size)
: "Unknown",
},
]}
/>
</Section>
);
};

View File

@@ -1,86 +0,0 @@
import { Section } from "@components/layouts";
import { ReactNode, useMemo, useState } from "react";
import { Networks } from "./networks";
import { useServer } from "..";
import { Types } from "komodo_client";
import { useLocalStorage } from "@lib/hooks";
import { Images } from "./images";
import { Containers } from "./containers";
import { Volumes } from "./volumes";
import {
MobileFriendlyTabsSelector,
TabNoContent,
} from "@ui/mobile-friendly-tabs";
type ServerInfoView = "Containers" | "Networks" | "Volumes" | "Images";
export const ServerInfo = ({
id,
titleOther,
}: {
id: string;
titleOther: ReactNode;
}) => {
const _search = useState("");
const state = useServer(id)?.info.state ?? Types.ServerState.NotOk;
const [view, setView] = useLocalStorage<ServerInfoView>(
"server-info-view-v1",
"Containers"
);
if ([Types.ServerState.NotOk, Types.ServerState.Disabled].includes(state)) {
return (
<Section titleOther={titleOther}>
<h2 className="text-muted-foreground">
Server unreachable, info is not available
</h2>
</Section>
);
}
const tabsNoContent = useMemo<TabNoContent<ServerInfoView>[]>(
() => [
{
value: "Containers",
},
{
value: "Networks",
},
{
value: "Volumes",
},
{
value: "Images",
},
],
[]
);
const Selector = (
<MobileFriendlyTabsSelector
tabs={tabsNoContent}
value={view}
onValueChange={setView as any}
tabsTriggerClassname="w-[110px]"
/>
);
const Component = () => {
switch (view) {
case "Containers":
return <Containers id={id} titleOther={Selector} _search={_search} />;
case "Networks":
return <Networks id={id} titleOther={Selector} _search={_search} />;
case "Volumes":
return <Volumes id={id} titleOther={Selector} _search={_search} />;
case "Images":
return <Images id={id} titleOther={Selector} _search={_search} />;
}
};
return (
<Section titleOther={titleOther}>
<Component />
</Section>
);
};

View File

@@ -1,88 +0,0 @@
import { Section } from "@components/layouts";
import { DockerResourceLink } from "@components/util";
import { useRead } from "@lib/hooks";
import { Badge } from "@ui/badge";
import { DataTable, SortableHeader } from "@ui/data-table";
import { Dispatch, ReactNode, SetStateAction } from "react";
import { Prune } from "../actions";
import { Search } from "lucide-react";
import { Input } from "@ui/input";
import { filterBySplit } from "@lib/utils";
export const Volumes = ({
id,
titleOther,
_search,
}: {
id: string;
titleOther: ReactNode;
_search: [string, Dispatch<SetStateAction<string>>];
}) => {
const [search, setSearch] = _search;
const volumes =
useRead("ListDockerVolumes", { server: id }, { refetchInterval: 10_000 })
.data ?? [];
const allInUse = volumes.every((volume) => volume.in_use);
const filtered = filterBySplit(volumes, search, (volume) => volume.name);
return (
<Section
titleOther={titleOther}
actions={
<div className="flex items-center gap-4 flex-wrap">
{!allInUse && <Prune server_id={id} type="Volumes" />}
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
</div>
}
>
<DataTable
containerClassName="min-h-[60vh]"
tableKey="server-volumes"
data={filtered}
columns={[
{
accessorKey: "name",
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
cell: ({ row }) => (
<DockerResourceLink
type="volume"
server_id={id}
name={row.original.name}
extra={
!row.original.in_use && (
<Badge variant="destructive">Unused</Badge>
)
}
/>
),
size: 200,
},
{
accessorKey: "driver",
header: ({ column }) => (
<SortableHeader column={column} title="Driver" />
),
},
{
accessorKey: "scope",
header: ({ column }) => (
<SortableHeader column={column} title="Scope" />
),
},
]}
/>
</Section>
);
};

View File

@@ -1,214 +0,0 @@
import { ResourceLink } from "@components/resources/common";
import { ServerVersion } from "@components/resources/server";
import { DataTable, SortableHeader } from "@ui/data-table";
import { useRead, useSelectedResources } from "@lib/hooks";
import { useIsServerAvailable } from ".";
import { Types } from "komodo_client";
export const ServerMonitoringTable = ({
servers,
}: {
servers: Types.ServerListItem[];
}) => {
const [_, setSelectedResources] = useSelectedResources("Server");
return (
<DataTable
tableKey="servers-monitoring-v1"
data={servers}
selectOptions={{
selectKey: ({ name }) => name,
onSelect: setSelectedResources,
}}
columns={[
{
accessorKey: "name",
size: 250,
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
cell: ({ row }) => (
<ResourceLink type="Server" id={row.original.id} />
),
},
{
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: "Version",
size: 160,
cell: ({ row }) => <ServerVersion id={row.original.id} />,
},
]}
/>
);
};
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>
);
};

View File

@@ -1,247 +0,0 @@
import { hex_color_by_intention } from "@lib/color";
import { useRead } from "@lib/hooks";
import { Types } from "komodo_client";
import { useMemo } from "react";
import { useStatsGranularity } from "./hooks";
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"
| "Load Average";
type StatDatapoint = { date: number; value: number };
export const StatChart = ({
server_id,
type,
className,
}: {
server_id: string;
type: StatType;
className?: string;
}) => {
const [granularity] = useStatsGranularity();
const { data, isPending } = useRead(
"GetHistoricalServerStats",
{
server: server_id,
granularity,
},
{
refetchInterval:
granularity === Types.Timelength.FiveSeconds
? 5_000
: granularity === Types.Timelength.FifteenSeconds
? 10_000
: 15_000,
}
);
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}>
<h1 className="px-2 py-1">{type}</h1>
{isPending ? (
<div className="w-full max-w-full h-full flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
) : (
<InnerStatChart
type={type}
stats={seriesData.flatMap((s) => s.data)}
seriesData={seriesData}
/>
)}
</div>
);
};
const BYTES_PER_GB = 1073741824.0;
const BYTES_PER_MB = 1048576.0;
const BYTES_PER_KB = 1024.0;
export const InnerStatChart = ({
type,
stats,
seriesData,
}: {
type: StatType;
stats: StatDatapoint[] | undefined;
seriesData?: { label: string; data: StatDatapoint[] }[];
}) => {
const { currentTheme } = useTheme();
const min = stats?.[0]?.date ?? 0;
const max = stats?.[stats.length - 1]?.date ?? 0;
const diff = max - min;
const timeAxis = useMemo((): AxisOptions<StatDatapoint> => {
return {
getValue: (datum) => new Date(datum.date),
hardMax: new Date(max + diff * 0.02),
hardMin: new Date(min - diff * 0.02),
tickCount: 6,
formatters: {
// scale: (value?: Date) => fmt_date(value ?? new Date()),
tooltip: (value?: Date) => (
<div className="text-lg font-mono">
{fmt_utc_date(value ?? new Date())}
</div>
),
cursor: (_value?: Date) => false,
},
};
}, [min, max, diff]);
const maxStatValue = stats ? Math.max(...stats.map((s) => s.value)) : 0;
const { maxUnitValue, unitValue, unit } = useMemo(() => {
if (type === "Network Ingress" || type === "Network Egress") {
const maxUnitValue = 2 ** Math.ceil(Math.log2(maxStatValue));
if (maxStatValue <= BYTES_PER_MB) {
return {
unit: "KB",
unitValue: BYTES_PER_KB,
maxUnitValue,
};
} else if (maxStatValue <= BYTES_PER_GB) {
return {
unit: "MB",
unitValue: BYTES_PER_MB,
maxUnitValue,
};
} else {
return {
unit: "GB",
unitValue: BYTES_PER_GB,
maxUnitValue,
};
}
}
if (type === "Load Average") {
return {
maxUnitValue: maxStatValue === 0 ? 1 : maxStatValue * 1.2,
};
}
if (type === "Cpu") {
return {
maxUnitValue: Math.min(2 ** (Math.log2(maxStatValue) + 0.3), 100),
};
}
return { maxUnitValue: 100 }; // Default for memory, disk
}, [type, maxStatValue]);
const valueAxis = useMemo(
(): AxisOptions<StatDatapoint>[] => [
{
getValue: (datum) => datum.value,
elementType: type === "Load Average" ? "line" : "area",
stacked: type !== "Load Average",
min: 0,
max: maxUnitValue,
formatters: {
tooltip: (value?: number) => (
<div className="text-lg font-mono">
{(type === "Network Ingress" || type === "Network Egress") && unit
? `${((value ?? 0) / unitValue).toFixed(2)} ${unit}`
: type === "Load Average"
? `${(value ?? 0).toFixed(2)}`
: `${value?.toFixed(2)}%`}
</div>
),
},
},
],
[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: seriesData ?? [{ label: type, data: stats ?? [] }],
primaryAxis: timeAxis,
secondaryAxes: valueAxis,
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,
},
// tooltip: {
// showDatumInTooltip: () => false,
// },
}}
/>
);
};
const getStat = (stat: Types.SystemStatsRecord, type: StatType) => {
if (type === "Cpu") return stat.cpu_perc || 0;
if (type === "Memory") return (100 * stat.mem_used_gb) / stat.mem_total_gb;
if (type === "Disk") return (100 * stat.disk_used_gb) / stat.disk_total_gb;
if (type === "Network Ingress") return stat.network_ingress_bytes || 0;
if (type === "Network Egress") return stat.network_egress_bytes || 0;
return 0;
};
const getColor = (type: StatType) => {
if (type === "Cpu") return hex_color_by_intention("Good");
if (type === "Memory") return hex_color_by_intention("Warning");
if (type === "Disk") return hex_color_by_intention("Neutral");
if (type === "Network Ingress") return hex_color_by_intention("Good");
if (type === "Network Egress") return hex_color_by_intention("Critical");
return hex_color_by_intention("Unknown");
};

View File

@@ -1,145 +0,0 @@
import { useRead } from "@lib/hooks";
import { cn } from "@lib/utils";
import { Progress } from "@ui/progress";
import { ServerState } from "komodo_client/dist/types";
import { Cpu, Database, MemoryStick, LucideIcon } from "lucide-react";
import { useMemo } from "react";
interface ServerStatsMiniProps {
id: string;
className?: string;
enabled?: boolean;
}
interface StatItemProps {
icon: LucideIcon;
label: string;
percentage: number;
type: "cpu" | "memory" | "disk";
isUnreachable: boolean;
getTextColor: (percentage: number, type: "cpu" | "memory" | "disk") => string;
}
const StatItem = ({ icon: Icon, label, percentage, type, isUnreachable, getTextColor }: StatItemProps) => (
<div className="flex items-center gap-2">
<Icon className="w-3 h-3 text-muted-foreground" aria-hidden="true" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between pb-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span
className={cn(
"text-xs font-medium",
isUnreachable ? "text-muted-foreground" : getTextColor(percentage, type)
)}
>
{isUnreachable ? "N/A" : `${percentage}%`}
</span>
</div>
<Progress
value={isUnreachable ? 0 : percentage}
className={cn("h-1", "[&>div]:transition-all")}
/>
</div>
</div>
);
export const ServerStatsMini = ({ id, className, enabled = true }: ServerStatsMiniProps) => {
const calculatePercentage = (value: number) =>
Number((value ?? 0).toFixed(2));
const servers = useRead("ListServers", {}).data;
const server = servers?.find((s) => s.id === id);
const isServerAvailable = server &&
server.info.state !== ServerState.Disabled &&
server.info.state !== ServerState.NotOk;
const serverDetails = useRead("GetServer", { server: id }, {
enabled: enabled && isServerAvailable
}).data;
const cpuWarning = serverDetails?.config?.cpu_warning ?? 75;
const cpuCritical = serverDetails?.config?.cpu_critical ?? 90;
const memWarning = serverDetails?.config?.mem_warning ?? 75;
const memCritical = serverDetails?.config?.mem_critical ?? 90;
const diskWarning = serverDetails?.config?.disk_warning ?? 75;
const diskCritical = serverDetails?.config?.disk_critical ?? 90;
const getTextColor = (percentage: number, type: "cpu" | "memory" | "disk") => {
const warning = type === "cpu" ? cpuWarning : type === "memory" ? memWarning : diskWarning;
const critical = type === "cpu" ? cpuCritical : type === "memory" ? memCritical : diskCritical;
if (percentage >= critical) return "text-red-600";
if (percentage >= warning) return "text-yellow-600";
return "text-green-600";
};
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: enabled && isServerAvailable,
refetchInterval: 15_000,
staleTime: 5_000,
},
).data;
if (!server) {
return null;
}
const calculations = useMemo(() => {
const cpuPercentage = stats ? calculatePercentage(stats.cpu_perc) : 0;
const memoryPercentage = stats && stats.mem_total_gb > 0 ? calculatePercentage((stats.mem_used_gb / stats.mem_total_gb) * 100) : 0;
const diskUsed = stats ? stats.disks.reduce((acc, disk) => acc + disk.used_gb, 0) : 0;
const diskTotal = stats ? stats.disks.reduce((acc, disk) => acc + disk.total_gb, 0) : 0;
const diskPercentage = diskTotal > 0 ? calculatePercentage((diskUsed / diskTotal) * 100) : 0;
const isUnreachable = !stats || server.info.state === ServerState.NotOk;
const isDisabled = server.info.state === ServerState.Disabled;
return {
cpuPercentage,
memoryPercentage,
diskPercentage,
isUnreachable,
isDisabled
};
}, [stats, server.info.state]);
const { cpuPercentage, memoryPercentage, diskPercentage, isUnreachable, isDisabled } = calculations;
const overlayClass = (isUnreachable || isDisabled) ? "opacity-50" : "";
const statItems = useMemo(() => [
{ icon: Cpu, label: "CPU", percentage: cpuPercentage, type: "cpu" as const },
{ icon: MemoryStick, label: "Memory", percentage: memoryPercentage, type: "memory" as const },
{ icon: Database, label: "Disk", percentage: diskPercentage, type: "disk" as const },
], [cpuPercentage, memoryPercentage, diskPercentage]);
return (
<div className={cn("relative flex flex-col gap-2", overlayClass, className)}>
{statItems.map((item) => (
<StatItem
key={item.label}
icon={item.icon}
label={item.label}
percentage={item.percentage}
type={item.type}
isUnreachable={isUnreachable || isDisabled}
getTextColor={getTextColor}
/>
))}
{isDisabled && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-black/60 z-10">
<span className="text-xs text-foreground font-bold italic text-center">Disabled</span>
</div>
)}
{isUnreachable && !isDisabled && (
<div className="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-black/60 z-10">
<span className="text-xs text-foreground font-bold italic text-center">Unreachable</span>
</div>
)}
</div>
);
};

View File

@@ -1,674 +0,0 @@
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 { useLocalStorage, usePermissions, useRead } from "@lib/hooks";
import { Types } from "komodo_client";
import { DataTable, SortableHeader } from "@ui/data-table";
import { ReactNode, useMemo, useState } from "react";
import { Input } from "@ui/input";
import { StatChart } from "./stat-chart";
import { useStatsGranularity } from "./hooks";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@ui/select";
import { DockerResourceLink, ShowHideButton } from "@components/util";
import { filterBySplit } from "@lib/utils";
import { useIsServerAvailable } from ".";
export const ServerStats = ({
id,
titleOther,
}: {
id: string;
titleOther?: ReactNode;
}) => {
const [interval, setInterval] = useStatsGranularity();
const { specific } = usePermissions({ type: "Server", id });
const isServerAvailable = useIsServerAvailable(id);
const stats = useRead(
"GetSystemStats",
{ server: id },
{
enabled: isServerAvailable,
refetchInterval: 10_000,
}
).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",
true
);
const [containerSearch, setContainerSearch] = useState("");
const filteredContainers = filterBySplit(
containers,
containerSearch,
(container) => container.name
);
const [showDisks, setShowDisks] = useLocalStorage(
"stats-show-disks-table-v1",
true
);
const disk_used = stats?.disks.reduce(
(acc, curr) => (acc += curr.used_gb),
0
);
const disk_total = stats?.disks.reduce(
(acc, curr) => (acc += curr.total_gb),
0
);
const [showHistorical, setShowHistorical] = useState(false);
return (
<Section titleOther={titleOther}>
<div className="flex flex-col gap-8">
{/* System Info */}
<Section title="System Info">
<DataTable
tableKey="system-info"
data={
info
? [{ ...info, mem_total: stats?.mem_total_gb, disk_total }]
: []
}
columns={[
{
header: "Hostname",
accessorKey: "host_name",
},
{
header: "Os",
accessorKey: "os",
},
{
header: "Kernel",
accessorKey: "kernel",
},
{
header: "CPU",
accessorKey: "cpu_brand",
},
{
header: "Core Count",
accessorFn: ({ core_count }) =>
`${core_count} Core${(core_count || 0) > 1 ? "s" : ""}`,
},
{
header: "Total Memory",
accessorFn: ({ mem_total }) => `${mem_total?.toFixed(2)} GB`,
},
{
header: "Total Disk Size",
accessorFn: ({ disk_total }) => `${disk_total?.toFixed(2)} GB`,
},
]}
/>
</Section>
{/* 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} />
<NETWORK stats={stats} />
</div>
</Section>
{/* Container Breakdown */}
<Section
title="Containers"
actions={
<div className="flex gap-4 items-center">
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={containerSearch}
onChange={(e) => setContainerSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
<ShowHideButton
show={showContainers}
setShow={setShowContainers}
/>
</div>
}
>
{showContainers && (
<DataTable
tableKey="container-stats"
data={filteredContainers}
columns={[
{
accessorKey: "name",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Name" />
),
cell: ({ row }) => (
<DockerResourceLink
type="container"
server_id={id}
name={row.original.name}
/>
),
},
{
accessorKey: "stats.cpu_perc",
size: 100,
header: ({ column }) => (
<SortableHeader column={column} title="CPU" />
),
},
{
accessorKey: "stats.mem_perc",
size: 200,
header: ({ column }) => (
<SortableHeader column={column} title="Memory" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
{row.original.stats?.mem_perc}
<div className="text-muted-foreground text-sm">
({row.original.stats?.mem_usage})
</div>
</div>
),
},
{
accessorKey: "stats.net_io",
size: 150,
header: ({ column }) => (
<SortableHeader column={column} title="Net I/O" />
),
},
{
accessorKey: "stats.block_io",
size: 150,
header: ({ column }) => (
<SortableHeader column={column} title="Block I/O" />
),
},
{
accessorKey: "stats.pids",
size: 100,
header: ({ column }) => (
<SortableHeader column={column} title="PIDs" />
),
},
]}
/>
)}
</Section>
{/* Current Disk Breakdown */}
<Section
title="Disks"
actions={
<div className="flex gap-4 items-center">
<div className="flex gap-2 items-center">
<div className="text-muted-foreground">Used:</div>
{disk_used?.toFixed(2)} GB
</div>
<div className="flex gap-2 items-center">
<div className="text-muted-foreground">Total:</div>
{disk_total?.toFixed(2)} GB
</div>
<ShowHideButton show={showDisks} setShow={setShowDisks} />
</div>
}
>
{showDisks && (
<DataTable
sortDescFirst
tableKey="server-disks"
data={
stats?.disks.map((disk) => ({
...disk,
percentage: 100 * (disk.used_gb / disk.total_gb),
})) ?? []
}
columns={[
{
header: "Path",
cell: ({ row }) => (
<div className="overflow-hidden overflow-ellipsis">
{row.original.mount}
</div>
),
},
{
accessorKey: "used_gb",
header: ({ column }) => (
<SortableHeader
column={column}
title="Used"
sortDescFirst
/>
),
cell: ({ row }) => <>{row.original.used_gb.toFixed(2)} GB</>,
},
{
accessorKey: "total_gb",
header: ({ column }) => (
<SortableHeader
column={column}
title="Total"
sortDescFirst
/>
),
cell: ({ row }) => <>{row.original.total_gb.toFixed(2)} GB</>,
},
{
accessorKey: "percentage",
header: ({ column }) => (
<SortableHeader
column={column}
title="Percentage"
sortDescFirst
/>
),
cell: ({ row }) => (
<>{row.original.percentage.toFixed(2)}% Full</>
),
},
]}
/>
)}
</Section>
{specific.includes(Types.SpecificPermission.Processes) && (
<Processes id={id} />
)}
{/* Historical Charts */}
<Section
title="Historical"
actions={
<div className="flex gap-4 items-center">
{/* Granularity Dropdown */}
<div className="flex items-center gap-2">
<div className="text-muted-foreground">Interval:</div>
<Select
value={interval}
onValueChange={(interval) =>
setInterval(interval as Types.Timelength)
}
>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[
Types.Timelength.FiveSeconds,
Types.Timelength.FifteenSeconds,
Types.Timelength.ThirtySeconds,
Types.Timelength.OneMinute,
Types.Timelength.FiveMinutes,
Types.Timelength.FifteenMinutes,
Types.Timelength.ThirtyMinutes,
Types.Timelength.OneHour,
Types.Timelength.SixHours,
Types.Timelength.OneDay,
].map((timelength) => (
<SelectItem key={timelength} value={timelength}>
{timelength}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<ShowHideButton
show={showHistorical}
setShow={setShowHistorical}
/>
</div>
}
>
{showHistorical && (
<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}
type="Memory"
className="w-full h-[250px]"
/>
<StatChart
server_id={id}
type="Disk"
className="w-full h-[250px]"
/>
<StatChart
server_id={id}
type="Network Ingress"
className="w-full h-[250px]"
/>
<StatChart
server_id={id}
type="Network Egress"
className="w-full h-[250px]"
/>
</div>
)}
</Section>
</div>
</Section>
);
};
const Processes = ({ id }: { id: string }) => {
const [show, setShow] = useState(false);
const [search, setSearch] = useState("");
const searchSplit = search.toLowerCase().split(" ");
return (
<Section
title="Processes"
actions={
<div className="flex gap-4 items-center">
<div className="relative">
<Search className="w-4 absolute top-[50%] left-3 -translate-y-[50%] text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="search..."
className="pl-8 w-[200px] lg:w-[300px]"
/>
</div>
<ShowHideButton show={show} setShow={setShow} />
</div>
}
>
{show && <ProcessesInner id={id} searchSplit={searchSplit} />}
</Section>
);
};
const ProcessesInner = ({
id,
searchSplit,
}: {
id: string;
searchSplit: string[];
}) => {
const { data: processes, isPending } = useRead("ListSystemProcesses", {
server: id,
});
const filtered = useMemo(
() =>
processes?.filter((process) => {
if (searchSplit.length === 0) return true;
const name = process.name.toLowerCase();
return searchSplit.every((search) => name.includes(search));
}),
[processes, searchSplit]
);
if (isPending)
return (
<div className="flex items-center justify-center h-[200px]">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
if (!processes) return null;
return (
<DataTable
sortDescFirst
tableKey="server-processes"
data={filtered ?? []}
columns={[
{
header: "Name",
accessorKey: "name",
},
{
header: "Exe",
accessorKey: "exe",
cell: ({ row }) => (
<div className="overflow-hidden overflow-ellipsis">
{row.original.exe}
</div>
),
},
{
accessorKey: "cpu_perc",
header: ({ column }) => (
<SortableHeader column={column} title="Cpu" sortDescFirst />
),
cell: ({ row }) => <>{row.original.cpu_perc.toFixed(2)}%</>,
},
{
accessorKey: "mem_mb",
header: ({ column }) => (
<SortableHeader column={column} title="Memory" sortDescFirst />
),
cell: ({ row }) => (
<>
{row.original.mem_mb > 1000
? `${(row.original.mem_mb / 1024).toFixed(2)} GB`
: `${row.original.mem_mb.toFixed(2)} MB`}
</>
),
},
]}
/>
);
};
const StatBar = ({
title,
icon,
percentage,
}: {
title: string;
icon: ReactNode;
percentage: number | undefined;
}) => {
return (
<Card className="w-full">
<CardHeader className="flex-row items-center justify-between">
<CardTitle>{title}</CardTitle>
<div className="flex gap-2 items-center">
<div className="text-lg">{percentage?.toFixed(2)}%</div>
{icon}
</div>
</CardHeader>
<CardContent>
<Progress value={percentage} className="h-4" />
</CardContent>
</Card>
);
};
const CPU = ({ stats }: { stats: Types.SystemStats | undefined }) => {
return (
<StatBar
title="CPU Usage"
icon={<Cpu className="w-5 h-5" />}
percentage={stats?.cpu_perc}
/>
);
};
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;
return (
<StatBar
title="RAM Usage"
icon={<MemoryStick className="w-5 h-5" />}
percentage={((used ?? 0) / (total ?? 0)) * 100}
/>
);
};
const DISK = ({ stats }: { stats: Types.SystemStats | undefined }) => {
const used = stats?.disks.reduce((acc, curr) => (acc += curr.used_gb), 0);
const total = stats?.disks.reduce((acc, curr) => (acc += curr.total_gb), 0);
return (
<StatBar
title="Disk Usage"
icon={<Database className="w-5 h-5" />}
percentage={((used ?? 0) / (total ?? 0)) * 100}
/>
);
};
const formatBytes = (bytes: number) => {
const BYTES_PER_KB = 1024;
const BYTES_PER_MB = 1024 * BYTES_PER_KB;
const BYTES_PER_GB = 1024 * BYTES_PER_MB;
if (bytes >= BYTES_PER_GB) {
return { value: bytes / BYTES_PER_GB, unit: "GB" };
} else if (bytes >= BYTES_PER_MB) {
return { value: bytes / BYTES_PER_MB, unit: "MB" };
} else if (bytes >= BYTES_PER_KB) {
return { value: bytes / BYTES_PER_KB, unit: "KB" };
} else {
return { value: bytes, unit: "bytes" };
}
};
const NETWORK = ({ stats }: { stats: Types.SystemStats | undefined }) => {
const ingress = stats?.network_ingress_bytes ?? 0;
const egress = stats?.network_egress_bytes ?? 0;
const formattedIngress = formatBytes(ingress);
const formattedEgress = formatBytes(egress);
return (
<Card className="w-full">
<CardHeader className="flex-row justify-between">
<CardTitle>Network Usage</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-between items-center mb-4">
<p className="font-medium">Ingress</p>
<span className="text-sm text-gray-600">
{formattedIngress.value.toFixed(2)} {formattedIngress.unit}
</span>
</div>
<div className="flex justify-between items-center">
<p className="font-medium">Egress</p>
<span className="text-sm text-gray-600">
{formattedEgress.value.toFixed(2)} {formattedEgress.unit}
</span>
</div>
</CardContent>
</Card>
);
};

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