mirror of
https://github.com/moghtech/komodo.git
synced 2026-03-18 06:30:43 -05:00
Compare commits
83 Commits
v2.0.0-dev
...
2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
655a149f48 | ||
|
|
3867305a2e | ||
|
|
4aebd5ed92 | ||
|
|
a9352ad90b | ||
|
|
37bd221609 | ||
|
|
bf5975bb93 | ||
|
|
d7706a32c1 | ||
|
|
dea9c49772 | ||
|
|
95666e69d8 | ||
|
|
cbc008fc89 | ||
|
|
445fa42a01 | ||
|
|
2c2b0eda8f | ||
|
|
15eefa3385 | ||
|
|
cb9bc80346 | ||
|
|
8c682c091f | ||
|
|
ca021a3979 | ||
|
|
1480c3b020 | ||
|
|
42f452fdda | ||
|
|
8ded07dfe5 | ||
|
|
d51c1cb1e7 | ||
|
|
b5f184b286 | ||
|
|
294ef20019 | ||
|
|
3efcaa0740 | ||
|
|
b5b680dd1d | ||
|
|
bf5ac8b6ac | ||
|
|
dcccc878c8 | ||
|
|
1c87fba8f5 | ||
|
|
2fc35b3c2d | ||
|
|
2012fd1dd9 | ||
|
|
550c0339d6 | ||
|
|
50cf2f2d50 | ||
|
|
b223fefec6 | ||
|
|
7fe56f72ae | ||
|
|
0a9bc397ca | ||
|
|
a03fceba7f | ||
|
|
1bf1574c2a | ||
|
|
83d90e0a16 | ||
|
|
304ffbf01d | ||
|
|
07787d6fa1 | ||
|
|
5d04142a99 | ||
|
|
aecba3be9f | ||
|
|
4c4e5b62e0 | ||
|
|
62f0ca9093 | ||
|
|
3d43e2419f | ||
|
|
d4081c2d6b | ||
|
|
d397cb4ea4 | ||
|
|
0c96e24cd4 | ||
|
|
1c90e768ef | ||
|
|
be73f20fd5 | ||
|
|
330178dbb8 | ||
|
|
c8c01307a0 | ||
|
|
2e80adff2d | ||
|
|
ef5a0982cb | ||
|
|
9ffa40022d | ||
|
|
15eaeab68d | ||
|
|
89ea5ad5a4 | ||
|
|
c05dde3678 | ||
|
|
a17a1005c7 | ||
|
|
d13314eb98 | ||
|
|
f41a61116a | ||
|
|
13fbcae105 | ||
|
|
71c437962f | ||
|
|
e4147ccdaf | ||
|
|
5089375211 | ||
|
|
e489812af4 | ||
|
|
7373ff8d0e | ||
|
|
4e1cd32f3f | ||
|
|
57211746f1 | ||
|
|
5e153fb02b | ||
|
|
b28a413005 | ||
|
|
691671abbd | ||
|
|
504e81c2f7 | ||
|
|
0a479a0f4a | ||
|
|
6a05779ceb | ||
|
|
acd27ba058 | ||
|
|
96c4ae9fc5 | ||
|
|
5c99958cba | ||
|
|
7288f067c5 | ||
|
|
5d0f7de9fb | ||
|
|
4129abcfd0 | ||
|
|
4275e903eb | ||
|
|
c57c321cbb | ||
|
|
9a7d49b35e |
123
.github/copilot-instructions.md
vendored
123
.github/copilot-instructions.md
vendored
@@ -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
28
.vscode/tasks.json
vendored
@@ -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
803
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
72
Cargo.toml
72
Cargo.toml
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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:#}",)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"mogh_auth_client": "^1.2.0"
|
||||
"mogh_auth_client": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.3"
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 #
|
||||
##################
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) },
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.93.0-bullseye as builder
|
||||
FROM rust:1.93.1-bullseye as builder
|
||||
WORKDIR /builder
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.93.0-bullseye as builder
|
||||
FROM rust:1.93.1-bullseye as builder
|
||||
WORKDIR /builder
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -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
24
frontend/.gitignore
vendored
@@ -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?
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 193 KiB |
@@ -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"
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)} />;
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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()} />;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
: "";
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
// );
|
||||
// };
|
||||
@@ -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=""
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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"}`}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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=""
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user