mirror of
https://github.com/moghtech/komodo.git
synced 2026-11-01 00:12:55 -05:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d6aaef24cd | ||
|
|
3dce84c1f5 | ||
|
|
8b16f97caf | ||
|
|
c98a809db4 | ||
|
|
c066b4fe16 | ||
|
|
b54fa235bc | ||
|
|
05fd1aba2c | ||
|
|
1a5290e124 | ||
|
|
538e11ef3b | ||
|
|
9208e9bf86 | ||
|
|
31640e3df8 | ||
|
|
42751d1b90 | ||
|
|
673ab5c17d | ||
|
|
ad00f38758 | ||
|
|
b2459b5a64 | ||
|
|
5923dd0e22 | ||
|
|
51294edd73 |
@@ -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`
|
||||
Vendored
+14
-14
@@ -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": []
|
||||
},
|
||||
|
||||
Generated
+474
-278
File diff suppressed because it is too large
Load Diff
+32
-35
@@ -8,7 +8,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "2.0.0-dev-111"
|
||||
version = "2.0.0-dev-120"
|
||||
edition = "2024"
|
||||
authors = ["mbecker20 <becker.maxh@gmail.com>"]
|
||||
license = "GPL-3.0-or-later"
|
||||
@@ -18,9 +18,6 @@ homepage = "https://komo.do"
|
||||
[profile.release]
|
||||
strip = "debuginfo"
|
||||
|
||||
# [patch.crates-io]
|
||||
# mogh_logger = { path = "../lib/crates/logger" }
|
||||
|
||||
[workspace.dependencies]
|
||||
# LOCAL
|
||||
komodo_client = { path = "client/core/rs" }
|
||||
@@ -39,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.6"
|
||||
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.1.2"
|
||||
mogh_config = "1.0.2"
|
||||
mogh_logger = "1.3.1"
|
||||
mogh_server = "1.4.4"
|
||||
toml_pretty = "2.0.0"
|
||||
mogh_cache = "1.1.1"
|
||||
mogh_pki = "1.1.0"
|
||||
@@ -57,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"] }
|
||||
reqwest = { version = "0.13.2", default-features = false, features = ["json", "stream", "form", "query", "rustls"] }
|
||||
tokio = { version = "1.49.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"
|
||||
futures-util = "0.3.32"
|
||||
arc-swap = "1.8.2"
|
||||
|
||||
# SERVER
|
||||
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-native-roots"] }
|
||||
@@ -78,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"
|
||||
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"
|
||||
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
|
||||
@@ -115,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.2"
|
||||
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.14"
|
||||
aws-sdk-ec2 = "1.214.0"
|
||||
aws-credential-types = "1.2.13"
|
||||
|
||||
## 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.91.1-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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.91.1-bullseye AS builder
|
||||
FROM rust:1.93.1-bullseye AS builder
|
||||
RUN cargo install cargo-strip
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## All in one, multi stage compile + runtime Docker build for your architecture.
|
||||
|
||||
# Build Core
|
||||
FROM rust:1.91.1-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:20.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:20.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 {
|
||||
|
||||
@@ -325,6 +325,7 @@ impl Resolve<ExecuteArgs> for GlobalAutoUpdate {
|
||||
}
|
||||
|
||||
let name = deployment.name.clone();
|
||||
|
||||
if let Err(e) = check_deployment_for_update_inner(
|
||||
deployment,
|
||||
&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(
|
||||
|
||||
@@ -38,7 +38,10 @@ pub fn app() -> Router {
|
||||
.nest("/ws", ws::router())
|
||||
.nest("/client", ts_client::router())
|
||||
.layer(memory_session_layer(config))
|
||||
.fallback_service(serve_static_ui(&config.frontend_path))
|
||||
.fallback_service(serve_static_ui(
|
||||
&config.ui_path,
|
||||
config.ui_index_force_no_cache,
|
||||
))
|
||||
.layer(cors_layer(config))
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@ use std::sync::OnceLock;
|
||||
|
||||
use anyhow::{Context, anyhow};
|
||||
use database::mungos::{by_id::update_one_by_id, mongodb::bson::doc};
|
||||
use futures_util::{StreamExt as _, stream::FuturesOrdered};
|
||||
use komodo_client::{
|
||||
api::{execute::Deploy, write::*},
|
||||
entities::{
|
||||
NoData, Operation, ResourceTarget, SwarmOrServer,
|
||||
Operation, ResourceTarget, SwarmOrServer,
|
||||
alert::{Alert, AlertData, SeverityLevel},
|
||||
deployment::{
|
||||
Deployment, DeploymentImage, DeploymentInfo, DeploymentState,
|
||||
@@ -26,15 +27,18 @@ 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,
|
||||
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::{self, setup_deployment_execution},
|
||||
resource::{
|
||||
self, list_full_for_user_using_pattern,
|
||||
setup_deployment_execution,
|
||||
},
|
||||
state::{
|
||||
action_states, db_client, deployment_status_cache,
|
||||
image_digest_cache, server_status_cache,
|
||||
@@ -359,12 +363,14 @@ impl Resolve<WriteArgs> for CheckDeploymentForUpdate {
|
||||
fields(
|
||||
operator = user.id,
|
||||
deployment = self.deployment,
|
||||
skip_auto_update = self.skip_auto_update,
|
||||
wait_for_auto_update = self.wait_for_auto_update,
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
WriteArgs { user }: &WriteArgs,
|
||||
) -> mogh_error::Result<NoData> {
|
||||
) -> mogh_error::Result<Self::Response> {
|
||||
// Even though this is a write request, this doesn't change any config. Anyone that can execute the
|
||||
// deployment should be able to do this.
|
||||
let (deployment, swarm_or_server) = setup_deployment_execution(
|
||||
@@ -382,9 +388,8 @@ impl Resolve<WriteArgs> for CheckDeploymentForUpdate {
|
||||
self.skip_auto_update,
|
||||
self.wait_for_auto_update,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(NoData {})
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,6 +414,8 @@ fn deployment_alert_sent_cache() -> &'static SetCache<String> {
|
||||
skip_all,
|
||||
fields(
|
||||
deployment = deployment.id,
|
||||
skip_auto_update,
|
||||
wait_for_auto_update,
|
||||
)
|
||||
)]
|
||||
pub async fn check_deployment_for_update_inner(
|
||||
@@ -417,11 +424,18 @@ pub async fn check_deployment_for_update_inner(
|
||||
skip_auto_update: bool,
|
||||
// Otherwise spawns task to run in background
|
||||
wait_for_auto_update: bool,
|
||||
) -> anyhow::Result<bool> {
|
||||
) -> anyhow::Result<CheckDeploymentForUpdateResponse> {
|
||||
let alert_cache = deployment_alert_sent_cache();
|
||||
|
||||
let (image, account, token) = match deployment.config.image {
|
||||
let (image, account, token) = match &deployment.config.image {
|
||||
DeploymentImage::Image { image } => {
|
||||
if image.contains('@') {
|
||||
// Images with a hardcoded digest can't have update.
|
||||
return Ok(CheckDeploymentForUpdateResponse {
|
||||
deployment: deployment.id,
|
||||
update_available: false,
|
||||
});
|
||||
}
|
||||
let domain = extract_registry_domain(&image)?;
|
||||
let account =
|
||||
optional_string(&deployment.config.image_registry_account);
|
||||
@@ -436,7 +450,10 @@ pub async fn check_deployment_for_update_inner(
|
||||
alert_cache.remove(&deployment.id).await;
|
||||
// This method not used for build based deployments
|
||||
// as deployed version vs built version can be inferred from Updates.
|
||||
return Ok(false);
|
||||
return Ok(CheckDeploymentForUpdateResponse {
|
||||
deployment: deployment.id,
|
||||
update_available: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -452,39 +469,50 @@ pub async fn check_deployment_for_update_inner(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some((state, Some(current_digest))) = deployment_status_cache()
|
||||
.get(&deployment.id)
|
||||
.await
|
||||
.map(|s| (s.curr.state, s.curr.image_digest.clone()))
|
||||
let Some((state, Some(current_digests))) =
|
||||
deployment_status_cache()
|
||||
.get(&deployment.id)
|
||||
.await
|
||||
.map(|s| (s.curr.state, s.curr.image_digests.clone()))
|
||||
else {
|
||||
alert_cache.remove(&deployment.id).await;
|
||||
return Ok(false);
|
||||
return Ok(CheckDeploymentForUpdateResponse {
|
||||
deployment: deployment.id,
|
||||
update_available: false,
|
||||
});
|
||||
};
|
||||
|
||||
// If not running or latest digest matches current, early return
|
||||
if !matches!(state, DeploymentState::Running)
|
||||
|| !latest_digest.update_available(¤t_digest)
|
||||
|| !latest_digest.update_available(¤t_digests)
|
||||
{
|
||||
alert_cache.remove(&deployment.id).await;
|
||||
return Ok(false);
|
||||
return Ok(CheckDeploymentForUpdateResponse {
|
||||
deployment: deployment.id,
|
||||
update_available: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !skip_auto_update && deployment.config.auto_update {
|
||||
// Trigger deploy + alert
|
||||
let swarm_id = swarm_or_server.swarm_id().map(str::to_string);
|
||||
let swarm_name = swarm_or_server.swarm_name().map(str::to_string);
|
||||
let server_id = swarm_or_server.server_id().map(str::to_string);
|
||||
let server_name =
|
||||
swarm_or_server.server_name().map(str::to_string);
|
||||
|
||||
// Conservatively remove from alert cache so 'skip_auto_update'
|
||||
// doesn't cause alerts not to be sent on subsequent calls.
|
||||
alert_cache.remove(&deployment.id).await;
|
||||
|
||||
let swarm_id = swarm_or_server.swarm_id().map(str::to_string);
|
||||
let swarm_name = swarm_or_server.swarm_name().map(str::to_string);
|
||||
let server_id = swarm_or_server.server_id().map(str::to_string);
|
||||
let server_name =
|
||||
swarm_or_server.server_name().map(str::to_string);
|
||||
let id = deployment.id.clone();
|
||||
let name = deployment.name.clone();
|
||||
let image = image.clone();
|
||||
|
||||
let run = async move {
|
||||
match execute::inner_handler(
|
||||
ExecuteRequest::Deploy(Deploy {
|
||||
deployment: deployment.name.clone(),
|
||||
deployment: name.clone(),
|
||||
stop_signal: None,
|
||||
stop_time: None,
|
||||
}),
|
||||
@@ -492,38 +520,45 @@ 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(deployment.id.clone()),
|
||||
data: AlertData::DeploymentAutoUpdated {
|
||||
id: deployment.id.clone(),
|
||||
name: deployment.name.clone(),
|
||||
swarm_id,
|
||||
swarm_name,
|
||||
server_id,
|
||||
server_name,
|
||||
image: image.clone(),
|
||||
},
|
||||
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 {} | {e:#}",
|
||||
deployment.name
|
||||
)
|
||||
warn!("Failed to auto update Deployment {name} | {e:#}",)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -536,7 +571,10 @@ pub async fn check_deployment_for_update_inner(
|
||||
} else {
|
||||
// Avoid spamming alerts
|
||||
if alert_cache.contains(&deployment.id).await {
|
||||
return Ok(true);
|
||||
return Ok(CheckDeploymentForUpdateResponse {
|
||||
deployment: deployment.id,
|
||||
update_available: true,
|
||||
});
|
||||
}
|
||||
alert_cache.insert(deployment.id.clone()).await;
|
||||
let ts = komodo_timestamp();
|
||||
@@ -568,5 +606,69 @@ pub async fn check_deployment_for_update_inner(
|
||||
send_alerts(&[alert]).await;
|
||||
}
|
||||
|
||||
Ok(!deployment.config.auto_update)
|
||||
Ok(CheckDeploymentForUpdateResponse {
|
||||
deployment: deployment.id,
|
||||
update_available: !deployment.config.auto_update,
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<WriteArgs> for BatchCheckDeploymentForUpdate {
|
||||
#[instrument(
|
||||
"BatchCheckDeploymentForUpdate",
|
||||
skip_all,
|
||||
fields(
|
||||
operator = user.id,
|
||||
pattern = self.pattern,
|
||||
skip_auto_update = self.skip_auto_update,
|
||||
wait_for_auto_update = self.wait_for_auto_update,
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
WriteArgs { user }: &WriteArgs,
|
||||
) -> Result<Self::Response, Self::Error> {
|
||||
let deployments = list_full_for_user_using_pattern::<Deployment>(
|
||||
&self.pattern,
|
||||
Default::default(),
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let res = deployments
|
||||
.into_iter()
|
||||
.map(|deployment| async move {
|
||||
let swarm_or_server = get_swarm_or_server(
|
||||
&deployment.config.swarm_id,
|
||||
&deployment.config.server_id,
|
||||
)
|
||||
.await?;
|
||||
swarm_or_server.verify_has_target().map_err(|e| e.error)?;
|
||||
check_deployment_for_update_inner(
|
||||
deployment,
|
||||
&swarm_or_server,
|
||||
self.skip_auto_update,
|
||||
self.wait_for_auto_update,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.collect::<FuturesOrdered<_>>()
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter_map(|res| {
|
||||
res
|
||||
.inspect_err(|e| {
|
||||
warn!(
|
||||
"Failed to check deployment for update in batch run | {e:#}"
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ pub enum WriteRequest {
|
||||
WriteStackFileContents(WriteStackFileContents),
|
||||
RefreshStackCache(RefreshStackCache),
|
||||
CheckStackForUpdate(CheckStackForUpdate),
|
||||
BatchCheckStackForUpdate(BatchCheckStackForUpdate),
|
||||
|
||||
// ==== DEPLOYMENT ====
|
||||
CreateDeployment(CreateDeployment),
|
||||
@@ -104,6 +105,7 @@ pub enum WriteRequest {
|
||||
UpdateDeployment(UpdateDeployment),
|
||||
RenameDeployment(RenameDeployment),
|
||||
CheckDeploymentForUpdate(CheckDeploymentForUpdate),
|
||||
BatchCheckDeploymentForUpdate(BatchCheckDeploymentForUpdate),
|
||||
|
||||
// ==== BUILD ====
|
||||
CreateBuild(CreateBuild),
|
||||
|
||||
+143
-48
@@ -9,6 +9,7 @@ use database::{
|
||||
},
|
||||
};
|
||||
use formatting::format_serror;
|
||||
use futures_util::{StreamExt as _, stream::FuturesOrdered};
|
||||
use komodo_client::{
|
||||
api::{execute::DeployStack, write::*},
|
||||
entities::{
|
||||
@@ -32,15 +33,15 @@ 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,
|
||||
resource::{self, list_full_for_user_using_pattern},
|
||||
stack::{
|
||||
remote::{RemoteComposeContents, get_repo_compose_contents},
|
||||
services::{
|
||||
@@ -676,7 +677,7 @@ impl Resolve<WriteArgs> for CheckStackForUpdate {
|
||||
async fn resolve(
|
||||
self,
|
||||
WriteArgs { user }: &WriteArgs,
|
||||
) -> mogh_error::Result<NoData> {
|
||||
) -> mogh_error::Result<Self::Response> {
|
||||
// Even though this is a write request, this doesn't change any config. Anyone that can execute the
|
||||
// stack should be able to do this.
|
||||
let (stack, swarm_or_server) = setup_stack_execution(
|
||||
@@ -695,9 +696,8 @@ impl Resolve<WriteArgs> for CheckStackForUpdate {
|
||||
self.wait_for_auto_update,
|
||||
self.skip_cache_refresh,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(NoData {})
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,7 +731,7 @@ pub async fn check_stack_for_update_inner(
|
||||
// Otherwise spawns task to run in background
|
||||
wait_for_auto_update: bool,
|
||||
skip_cache_refresh: bool,
|
||||
) -> anyhow::Result<Vec<StackServiceWithUpdate>> {
|
||||
) -> anyhow::Result<CheckStackForUpdateResponse> {
|
||||
if !skip_cache_refresh {
|
||||
(RefreshStackCache {
|
||||
stack: stack.clone(),
|
||||
@@ -750,14 +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.
|
||||
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!(
|
||||
@@ -787,8 +801,8 @@ pub async fn check_stack_for_update_inner(
|
||||
alert_cache
|
||||
.retain(|(stack_id, _)| stack_id != &stack.id)
|
||||
.await;
|
||||
return Ok(
|
||||
extract_services_from_stack(&stack)
|
||||
return Ok(CheckStackForUpdateResponse {
|
||||
services: extract_services_from_stack(&stack)
|
||||
.into_iter()
|
||||
.map(|service| StackServiceWithUpdate {
|
||||
service: service.service_name,
|
||||
@@ -796,15 +810,17 @@ pub async fn check_stack_for_update_inner(
|
||||
update_available: false,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
stack: stack.id,
|
||||
});
|
||||
};
|
||||
|
||||
let StackState::Running = status.curr.state else {
|
||||
alert_cache
|
||||
.retain(|(stack_id, _)| stack_id != &stack.id)
|
||||
.await;
|
||||
return Ok(
|
||||
status
|
||||
return Ok(CheckStackForUpdateResponse {
|
||||
stack: stack.id,
|
||||
services: status
|
||||
.curr
|
||||
.services
|
||||
.iter()
|
||||
@@ -814,7 +830,7 @@ pub async fn check_stack_for_update_inner(
|
||||
update_available: false,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
let mut services = Vec::new();
|
||||
@@ -826,7 +842,7 @@ pub async fn check_stack_for_update_inner(
|
||||
update_available: false,
|
||||
};
|
||||
|
||||
let Some(current_digest) = &service.image_digest else {
|
||||
let Some(current_digests) = &service.image_digests else {
|
||||
services.push(service_with_update);
|
||||
continue;
|
||||
};
|
||||
@@ -845,7 +861,7 @@ pub async fn check_stack_for_update_inner(
|
||||
};
|
||||
|
||||
service_with_update.update_available =
|
||||
latest_digest.update_available(¤t_digest.0);
|
||||
latest_digest.update_available(¤t_digests);
|
||||
|
||||
if service_with_update.update_available
|
||||
&& (skip_auto_update || !stack.config.auto_update)
|
||||
@@ -902,7 +918,10 @@ pub async fn check_stack_for_update_inner(
|
||||
|| !stack.config.auto_update
|
||||
|| services_with_update.is_empty()
|
||||
{
|
||||
return Ok(services);
|
||||
return Ok(CheckStackForUpdateResponse {
|
||||
stack: stack.id,
|
||||
services,
|
||||
});
|
||||
}
|
||||
|
||||
// Conservatively remove from alert cache so 'skip_auto_update'
|
||||
@@ -925,6 +944,8 @@ pub async fn check_stack_for_update_inner(
|
||||
let server_id = swarm_or_server.server_id().map(str::to_string);
|
||||
let server_name = swarm_or_server.server_name().map(str::to_string);
|
||||
|
||||
let stack_id = stack.id.clone();
|
||||
|
||||
let run = async move {
|
||||
match execute::inner_handler(
|
||||
ExecuteRequest::DeployStack(DeployStack {
|
||||
@@ -936,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)
|
||||
@@ -976,5 +1006,70 @@ pub async fn check_stack_for_update_inner(
|
||||
tokio::spawn(run);
|
||||
}
|
||||
|
||||
Ok(services)
|
||||
Ok(CheckStackForUpdateResponse {
|
||||
stack: stack_id,
|
||||
services,
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
impl Resolve<WriteArgs> for BatchCheckStackForUpdate {
|
||||
#[instrument(
|
||||
"BatchCheckStackForUpdate",
|
||||
skip_all,
|
||||
fields(
|
||||
operator = user.id,
|
||||
pattern = self.pattern,
|
||||
skip_auto_update = self.skip_auto_update,
|
||||
wait_for_auto_update = self.wait_for_auto_update,
|
||||
)
|
||||
)]
|
||||
async fn resolve(
|
||||
self,
|
||||
WriteArgs { user }: &WriteArgs,
|
||||
) -> Result<Self::Response, Self::Error> {
|
||||
let stacks = list_full_for_user_using_pattern::<Stack>(
|
||||
&self.pattern,
|
||||
Default::default(),
|
||||
user,
|
||||
PermissionLevel::Execute.into(),
|
||||
&[],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let res = stacks
|
||||
.into_iter()
|
||||
.map(|stack| async move {
|
||||
let swarm_or_server = get_swarm_or_server(
|
||||
&stack.config.swarm_id,
|
||||
&stack.config.server_id,
|
||||
)
|
||||
.await?;
|
||||
swarm_or_server.verify_has_target().map_err(|e| e.error)?;
|
||||
check_stack_for_update_inner(
|
||||
stack.id,
|
||||
&swarm_or_server,
|
||||
self.skip_auto_update,
|
||||
self.wait_for_auto_update,
|
||||
self.skip_cache_refresh,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.collect::<FuturesOrdered<_>>()
|
||||
.collect::<Vec<_>>()
|
||||
.await
|
||||
.into_iter()
|
||||
.filter_map(|res| {
|
||||
res
|
||||
.inspect_err(|e| {
|
||||
warn!(
|
||||
"Failed to check stack for update in batch run | {e:#}"
|
||||
)
|
||||
})
|
||||
.ok()
|
||||
})
|
||||
.collect();
|
||||
Ok(res)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,9 +143,7 @@ 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 {
|
||||
|
||||
+18
-12
@@ -293,9 +293,6 @@ pub fn core_config() -> &'static CoreConfig {
|
||||
first_server_name: env
|
||||
.komodo_first_server_name
|
||||
.or(config.first_server_name),
|
||||
frontend_path: env
|
||||
.komodo_frontend_path
|
||||
.unwrap_or(config.frontend_path),
|
||||
jwt_ttl: env.komodo_jwt_ttl.unwrap_or(config.jwt_ttl),
|
||||
auth_rate_limit_disabled: env
|
||||
.komodo_auth_rate_limit_disabled
|
||||
@@ -312,15 +309,9 @@ pub fn core_config() -> &'static CoreConfig {
|
||||
cors_allow_credentials: env
|
||||
.komodo_cors_allow_credentials
|
||||
.unwrap_or(config.cors_allow_credentials),
|
||||
sync_directory: env
|
||||
.komodo_sync_directory
|
||||
.unwrap_or(config.sync_directory),
|
||||
repo_directory: env
|
||||
.komodo_repo_directory
|
||||
.unwrap_or(config.repo_directory),
|
||||
action_directory: env
|
||||
.komodo_action_directory
|
||||
.unwrap_or(config.action_directory),
|
||||
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),
|
||||
@@ -412,6 +403,21 @@ pub fn core_config() -> &'static CoreConfig {
|
||||
ssl_cert_file: env
|
||||
.komodo_ssl_cert_file
|
||||
.unwrap_or(config.ssl_cert_file),
|
||||
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),
|
||||
sync_directory: env
|
||||
.komodo_sync_directory
|
||||
.unwrap_or(config.sync_directory),
|
||||
repo_directory: env
|
||||
.komodo_repo_directory
|
||||
.unwrap_or(config.repo_directory),
|
||||
action_directory: env
|
||||
.komodo_action_directory
|
||||
.unwrap_or(config.action_directory),
|
||||
|
||||
// These can't be overridden on env
|
||||
secrets: config.secrets,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ pub async fn insert_deployments_status_unknown(
|
||||
state: DeploymentState::Unknown,
|
||||
container: None,
|
||||
service: None,
|
||||
image_digest: None,
|
||||
image_digests: None,
|
||||
},
|
||||
prev,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use komodo_client::entities::{
|
||||
container::ContainerListItem, image::ImageListItem,
|
||||
service::SwarmServiceListItem, stack::SwarmStackListItem,
|
||||
},
|
||||
optional_string,
|
||||
stack::{Stack, StackService, StackServiceNames, StackState},
|
||||
swarm::SwarmState,
|
||||
};
|
||||
@@ -80,7 +79,7 @@ pub async fn update_swarm_stack_cache(
|
||||
image: image.clone(),
|
||||
container: None,
|
||||
swarm_service,
|
||||
image_digest: None,
|
||||
image_digests: Default::default(),
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -131,7 +130,7 @@ pub async fn update_server_stack_cache(
|
||||
}.is_match(&container.name)
|
||||
}).cloned();
|
||||
|
||||
let (image, image_digest) = container
|
||||
let (image, image_digests) = container
|
||||
.as_ref()
|
||||
.and_then(|container| container.image_id.as_ref())
|
||||
.and_then(|image_id| {
|
||||
@@ -141,7 +140,7 @@ pub async fn update_server_stack_cache(
|
||||
})
|
||||
.map(|image| (
|
||||
image.name.clone(),
|
||||
optional_string(&image.digest).map(ImageDigest)
|
||||
Some(ImageDigest::vec(&image.digests)),
|
||||
))
|
||||
.unwrap_or((
|
||||
if image.contains(':') {
|
||||
@@ -157,7 +156,7 @@ pub async fn update_server_stack_cache(
|
||||
image: image.clone(),
|
||||
container,
|
||||
swarm_service: None,
|
||||
image_digest,
|
||||
image_digests,
|
||||
}
|
||||
}).collect::<Vec<_>>();
|
||||
|
||||
@@ -230,7 +229,7 @@ pub async fn update_swarm_deployment_cache(
|
||||
state: current_state,
|
||||
service,
|
||||
container: None,
|
||||
image_digest: None,
|
||||
image_digests: None,
|
||||
},
|
||||
prev: prev_state,
|
||||
}
|
||||
@@ -252,13 +251,13 @@ pub async fn update_server_deployment_cache(
|
||||
.iter()
|
||||
.find(|container| container.name == deployment.name)
|
||||
.cloned();
|
||||
let image_digest = container
|
||||
let image_digests = container
|
||||
.as_ref()
|
||||
.and_then(|container| container.image_id.as_ref())
|
||||
.and_then(|image_id| {
|
||||
images.iter().find_map(|image| {
|
||||
if &image.id == image_id {
|
||||
optional_string(&image.digest)
|
||||
Some(ImageDigest::vec(&image.digests))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -282,7 +281,7 @@ pub async fn update_server_deployment_cache(
|
||||
state: current_state,
|
||||
container,
|
||||
service: None,
|
||||
image_digest,
|
||||
image_digests,
|
||||
},
|
||||
prev: prev_state,
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ impl super::KomodoResource for Deployment {
|
||||
}
|
||||
DeploymentImage::Image { image } => (image, None),
|
||||
};
|
||||
let (image, digest) = status
|
||||
let (image, current_digests) = status
|
||||
.as_ref()
|
||||
.map(|s| {
|
||||
(
|
||||
@@ -138,14 +138,17 @@ impl super::KomodoResource for Deployment {
|
||||
.unwrap_or_else(|| String::from("Unknown"))
|
||||
})
|
||||
}),
|
||||
s.curr.image_digest.as_ref(),
|
||||
s.curr.image_digests.as_ref(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let image = image.unwrap_or(build_image);
|
||||
let update_available = digest
|
||||
.map(|digest| {
|
||||
deployment.info.latest_image_digest.update_available(digest)
|
||||
let update_available = current_digests
|
||||
.map(|current_digests| {
|
||||
deployment
|
||||
.info
|
||||
.latest_image_digest
|
||||
.update_available(current_digests)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
DeploymentListItem {
|
||||
|
||||
@@ -112,9 +112,9 @@ impl super::KomodoResource for Stack {
|
||||
.iter()
|
||||
.map(|current_service| {
|
||||
let update_available = current_service
|
||||
.image_digest
|
||||
.image_digests
|
||||
.as_ref()
|
||||
.map(|current_digest| {
|
||||
.map(|current_digests| {
|
||||
stack
|
||||
.info
|
||||
.latest_services
|
||||
@@ -126,7 +126,7 @@ impl super::KomodoResource for Stack {
|
||||
latest_service
|
||||
.image_digest
|
||||
.as_ref()?
|
||||
.update_available(¤t_digest.0)
|
||||
.update_available(current_digests)
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::sync::{Arc, OnceLock};
|
||||
use anyhow::{Context, anyhow};
|
||||
use arc_swap::ArcSwap;
|
||||
use komodo_client::entities::{
|
||||
ImageDigest,
|
||||
action::ActionState,
|
||||
build::BuildState,
|
||||
deployment::DeploymentState,
|
||||
@@ -146,7 +147,7 @@ pub struct CachedDeploymentStatus {
|
||||
pub state: DeploymentState,
|
||||
pub container: Option<ContainerListItem>,
|
||||
pub service: Option<SwarmServiceListItem>,
|
||||
pub image_digest: Option<String>,
|
||||
pub image_digests: Option<Vec<ImageDigest>>,
|
||||
}
|
||||
|
||||
/// Cache of ids to status
|
||||
|
||||
@@ -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.91.1-trixie AS builder
|
||||
FROM rust:1.93.1-trixie AS builder
|
||||
RUN cargo install cargo-strip
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::Context as _;
|
||||
use formatting::format_serror;
|
||||
use komodo_client::entities::{
|
||||
docker::config::SwarmConfig, update::Log,
|
||||
docker::config::SwarmConfigDetails, update::Log,
|
||||
};
|
||||
use mogh_resolver::Resolve;
|
||||
use periphery_client::api::swarm::*;
|
||||
@@ -17,7 +17,7 @@ impl Resolve<crate::api::Args> for InspectSwarmConfig {
|
||||
async fn resolve(
|
||||
self,
|
||||
_: &crate::api::Args,
|
||||
) -> anyhow::Result<SwarmConfig> {
|
||||
) -> anyhow::Result<SwarmConfigDetails> {
|
||||
inspect_swarm_config(&self.config).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use futures_util::{TryStreamExt as _, stream::FuturesUnordered};
|
||||
use komodo_client::entities::{
|
||||
all_logs_success,
|
||||
docker::{
|
||||
config::{SwarmConfig, SwarmConfigListItem},
|
||||
config::{SwarmConfigDetails, SwarmConfigListItem},
|
||||
service::{SwarmService, SwarmServiceListItem},
|
||||
},
|
||||
random_string,
|
||||
@@ -65,7 +65,7 @@ pub async fn list_swarm_configs(
|
||||
|
||||
pub async fn inspect_swarm_config(
|
||||
config: &str,
|
||||
) -> anyhow::Result<SwarmConfig> {
|
||||
) -> anyhow::Result<SwarmConfigDetails> {
|
||||
let res = run_komodo_standard_command(
|
||||
"Inspect Swarm Config",
|
||||
None,
|
||||
@@ -79,14 +79,15 @@ pub async fn inspect_swarm_config(
|
||||
)));
|
||||
}
|
||||
|
||||
let mut res = serde_json::from_str::<Vec<SwarmConfig>>(&res.stdout)
|
||||
.context(
|
||||
"Failed to parse 'docker config inspect' response from json",
|
||||
)?
|
||||
.pop()
|
||||
.with_context(|| {
|
||||
format!("Did not find any config matching {config}")
|
||||
})?;
|
||||
let mut res =
|
||||
serde_json::from_str::<Vec<SwarmConfigDetails>>(&res.stdout)
|
||||
.context(
|
||||
"Failed to parse 'docker config inspect' response from json",
|
||||
)?
|
||||
.pop()
|
||||
.with_context(|| {
|
||||
format!("Did not find any config matching {config}")
|
||||
})?;
|
||||
|
||||
// Convert data back to readable / editable format
|
||||
res.spec.iter_mut().next().map(|spec| {
|
||||
|
||||
@@ -27,19 +27,18 @@ impl DockerClient {
|
||||
.map(|id| id == &image.id)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let name = image
|
||||
.repo_tags
|
||||
.iter()
|
||||
.next()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| image.id.clone());
|
||||
ImageListItem {
|
||||
name: image
|
||||
.repo_tags
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| image.id.clone()),
|
||||
digest: image
|
||||
.repo_digests
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default(),
|
||||
id: image.id,
|
||||
parent_id: image.parent_id,
|
||||
name,
|
||||
tags: image.repo_tags,
|
||||
digests: image.repo_digests,
|
||||
created: image.created,
|
||||
size: image.size,
|
||||
in_use,
|
||||
|
||||
@@ -5,7 +5,7 @@ use typeshare::typeshare;
|
||||
use crate::entities::{
|
||||
SearchCombinator, U64,
|
||||
docker::{
|
||||
config::{SwarmConfig, SwarmConfigListItem},
|
||||
config::{SwarmConfigDetails, SwarmConfigListItem},
|
||||
network::NetworkListItem,
|
||||
node::{SwarmNode, SwarmNodeListItem},
|
||||
secret::{SwarmSecret, SwarmSecretListItem},
|
||||
@@ -643,7 +643,7 @@ pub struct InspectSwarmConfig {
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
pub type InspectSwarmConfigResponse = SwarmConfig;
|
||||
pub type InspectSwarmConfigResponse = SwarmConfigDetails;
|
||||
|
||||
//
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ use serde::{Deserialize, Serialize};
|
||||
use typeshare::typeshare;
|
||||
|
||||
use crate::entities::{
|
||||
NoData,
|
||||
deployment::{_PartialDeploymentConfig, Deployment},
|
||||
update::Update,
|
||||
};
|
||||
@@ -234,4 +233,62 @@ pub struct CheckDeploymentForUpdate {
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
pub type CheckDeploymentForUpdateResponse = NoData;
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
pub struct CheckDeploymentForUpdateResponse {
|
||||
/// The deployment ID
|
||||
pub deployment: String,
|
||||
/// Whether update is available
|
||||
pub update_available: bool,
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
#[cfg(feature = "utoipa")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/BatchCheckDeploymentForUpdate",
|
||||
description = "Checks for newer image than what is deployed.",
|
||||
request_body(content = BatchCheckDeploymentForUpdate),
|
||||
responses(
|
||||
(status = 200, description = "Per deployment result", body = BatchCheckDeploymentForUpdateResponse),
|
||||
),
|
||||
)]
|
||||
pub fn batch_check_deployment_for_update() {}
|
||||
|
||||
/// Checks for newer image than what is deployed. Response: [BatchCheckDeploymentForUpdateResponse]
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, PartialEq, Resolve,
|
||||
)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[empty_traits(KomodoWriteRequest)]
|
||||
#[response(BatchCheckDeploymentForUpdateResponse)]
|
||||
#[error(mogh_error::Error)]
|
||||
pub struct BatchCheckDeploymentForUpdate {
|
||||
/// Id or name or wildcard pattern or regex.
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```text
|
||||
/// # match all foo-* deployments
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
/// extra-deployment-1, extra-deployment-2
|
||||
/// ```
|
||||
pub pattern: String,
|
||||
/// Normally resources with 'auto_update' will be
|
||||
/// redeployed immediately if updates are found.
|
||||
/// With this enabled, convert this into an UpdateAvailable alert.
|
||||
#[serde(default)]
|
||||
pub skip_auto_update: bool,
|
||||
/// If check triggers auto deploy,
|
||||
/// whether this call should wait on the auto deploy,
|
||||
/// or run it in the background.
|
||||
#[serde(default)]
|
||||
pub wait_for_auto_update: bool,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
pub type BatchCheckDeploymentForUpdateResponse =
|
||||
Vec<CheckDeploymentForUpdateResponse>;
|
||||
|
||||
@@ -4,7 +4,7 @@ use typeshare::typeshare;
|
||||
|
||||
use crate::entities::{
|
||||
NoData,
|
||||
stack::{_PartialStackConfig, Stack},
|
||||
stack::{_PartialStackConfig, Stack, StackServiceWithUpdate},
|
||||
update::Update,
|
||||
};
|
||||
|
||||
@@ -266,7 +266,69 @@ pub struct CheckStackForUpdate {
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
pub type CheckStackForUpdateResponse = NoData;
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
pub struct CheckStackForUpdateResponse {
|
||||
/// The stack ID
|
||||
pub stack: String,
|
||||
/// The stack services with update available status
|
||||
pub services: Vec<StackServiceWithUpdate>,
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
#[cfg(feature = "utoipa")]
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/BatchCheckStackForUpdate",
|
||||
description = "Checks for new images.",
|
||||
request_body(content = BatchCheckStackForUpdate),
|
||||
responses(
|
||||
(status = 200, description = "Stack / service level results", body = BatchCheckStackForUpdateResponse),
|
||||
),
|
||||
)]
|
||||
pub fn batch_check_stack_for_update() {}
|
||||
|
||||
/// Checks for new images. Response: [BatchCheckStackForUpdateResponse]
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Serialize, Deserialize, Debug, Clone, PartialEq, Resolve,
|
||||
)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
#[empty_traits(KomodoWriteRequest)]
|
||||
#[response(BatchCheckStackForUpdateResponse)]
|
||||
#[error(mogh_error::Error)]
|
||||
pub struct BatchCheckStackForUpdate {
|
||||
/// Id or name or wildcard pattern or regex.
|
||||
/// Supports multiline and comma delineated combinations of the above.
|
||||
///
|
||||
/// Example:
|
||||
/// ```text
|
||||
/// # match all foo-* stacks
|
||||
/// foo-*
|
||||
/// # add some more
|
||||
/// extra-stack-1, extra-stack-2
|
||||
/// ```
|
||||
pub pattern: String,
|
||||
/// Normally resources with 'auto_update' will be
|
||||
/// redeployed immediately if updates are found.
|
||||
/// With this enabled, convert this into an UpdateAvailable alert.
|
||||
#[serde(default)]
|
||||
pub skip_auto_update: bool,
|
||||
/// If check triggers auto deploy,
|
||||
/// whether this call should wait on the auto deploy,
|
||||
/// or run it in the background.
|
||||
#[serde(default)]
|
||||
pub wait_for_auto_update: bool,
|
||||
/// Usually will refresh the stack cache before checking for updates.
|
||||
/// Skip with this option.
|
||||
#[serde(default)]
|
||||
pub skip_cache_refresh: bool,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
pub type BatchCheckStackForUpdateResponse =
|
||||
Vec<CheckStackForUpdateResponse>;
|
||||
|
||||
//
|
||||
|
||||
|
||||
@@ -94,20 +94,13 @@ pub struct Env {
|
||||
/// Override `first_server_address`
|
||||
#[serde(alias = "komodo_first_server")]
|
||||
pub komodo_first_server_address: Option<String>,
|
||||
/// Override `frontend_path`
|
||||
pub komodo_frontend_path: Option<String>,
|
||||
/// Override `jwt_secret`
|
||||
pub komodo_jwt_secret: Option<String>,
|
||||
/// Override `jwt_secret` from file
|
||||
pub komodo_jwt_secret_file: Option<PathBuf>,
|
||||
/// Override `jwt_ttl`
|
||||
pub komodo_jwt_ttl: Option<Timelength>,
|
||||
/// Override `sync_directory`
|
||||
pub komodo_sync_directory: Option<PathBuf>,
|
||||
/// Override `repo_directory`
|
||||
pub komodo_repo_directory: Option<PathBuf>,
|
||||
/// Override `action_directory`
|
||||
pub komodo_action_directory: Option<PathBuf>,
|
||||
|
||||
/// Override `resource_poll_interval`
|
||||
pub komodo_resource_poll_interval: Option<Timelength>,
|
||||
/// Override `monitoring_interval`
|
||||
@@ -232,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")]
|
||||
@@ -279,6 +274,17 @@ pub struct Env {
|
||||
pub komodo_ssl_key_file: Option<String>,
|
||||
/// Override `ssl_cert_file`
|
||||
pub komodo_ssl_cert_file: 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`
|
||||
pub komodo_sync_directory: Option<PathBuf>,
|
||||
/// Override `repo_directory`
|
||||
pub komodo_repo_directory: Option<PathBuf>,
|
||||
/// Override `action_directory`
|
||||
pub komodo_action_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn default_core_config_paths() -> Vec<PathBuf> {
|
||||
@@ -409,10 +415,6 @@ pub struct CoreConfig {
|
||||
)]
|
||||
pub first_server_address: Option<String>,
|
||||
|
||||
/// The path to the built frontend folder.
|
||||
#[serde(default = "default_frontend_path")]
|
||||
pub frontend_path: String,
|
||||
|
||||
/// Configure database connection
|
||||
#[serde(default, alias = "mongo")]
|
||||
pub database: DatabaseConfig,
|
||||
@@ -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 =
|
||||
// ============
|
||||
@@ -701,6 +709,16 @@ pub struct CoreConfig {
|
||||
/// Default: `/action-cache`
|
||||
#[serde(default = "default_action_directory")]
|
||||
pub action_directory: PathBuf,
|
||||
|
||||
/// 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
|
||||
/// of using content hash as ETag.
|
||||
#[serde(default)]
|
||||
pub ui_index_force_no_cache: bool,
|
||||
}
|
||||
|
||||
fn default_title() -> String {
|
||||
@@ -723,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 {
|
||||
@@ -798,7 +816,6 @@ impl Default for CoreConfig {
|
||||
enable_fancy_toml: Default::default(),
|
||||
first_server_address: Default::default(),
|
||||
first_server_name: Default::default(),
|
||||
frontend_path: default_frontend_path(),
|
||||
database: Default::default(),
|
||||
local_auth: Default::default(),
|
||||
min_password_length: default_min_password_length(),
|
||||
@@ -827,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(),
|
||||
@@ -843,6 +861,8 @@ impl Default for CoreConfig {
|
||||
ssl_enabled: Default::default(),
|
||||
ssl_key_file: default_ssl_key_file(),
|
||||
ssl_cert_file: default_ssl_cert_file(),
|
||||
ui_path: default_ui_path(),
|
||||
ui_index_force_no_cache: Default::default(),
|
||||
sync_directory: default_sync_directory(),
|
||||
repo_directory: default_repo_directory(),
|
||||
action_directory: default_action_directory(),
|
||||
@@ -868,12 +888,8 @@ impl CoreConfig {
|
||||
timezone: config.timezone,
|
||||
first_server_address: config.first_server_address,
|
||||
first_server_name: config.first_server_name,
|
||||
frontend_path: config.frontend_path,
|
||||
jwt_secret: empty_or_redacted(&config.jwt_secret),
|
||||
jwt_ttl: config.jwt_ttl,
|
||||
repo_directory: config.repo_directory,
|
||||
action_directory: config.action_directory,
|
||||
sync_directory: config.sync_directory,
|
||||
internet_interface: config.internet_interface,
|
||||
resource_poll_interval: config.resource_poll_interval,
|
||||
monitoring_interval: config.monitoring_interval,
|
||||
@@ -935,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(),
|
||||
@@ -973,6 +990,11 @@ impl CoreConfig {
|
||||
ssl_enabled: config.ssl_enabled,
|
||||
ssl_key_file: config.ssl_key_file,
|
||||
ssl_cert_file: config.ssl_cert_file,
|
||||
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,
|
||||
sync_directory: config.sync_directory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1011,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
|
||||
}
|
||||
@@ -1023,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
|
||||
}
|
||||
fn allow_cross_site(&self) -> bool {
|
||||
self.session_allow_cross_site
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,16 @@ pub struct SwarmConfigListItem {
|
||||
}
|
||||
|
||||
/// Swarm config details.
|
||||
///
|
||||
/// This would be just "SwarmConfig", but that would
|
||||
/// conflict with the Swarm (Komodo resource) Config type,
|
||||
/// which is also SwarmConfig.
|
||||
#[typeshare]
|
||||
#[derive(
|
||||
Debug, Clone, Default, PartialEq, Serialize, Deserialize,
|
||||
)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
pub struct SwarmConfig {
|
||||
pub struct SwarmConfigDetails {
|
||||
#[serde(rename = "ID")]
|
||||
pub id: Option<String>,
|
||||
|
||||
|
||||
@@ -13,14 +13,22 @@ use super::GraphDriverData;
|
||||
)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
pub struct 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.
|
||||
pub 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.
|
||||
pub parent_id: String,
|
||||
/// The first tag in `repo_tags`, or Id if no tags.
|
||||
pub name: String,
|
||||
/// The first digest in `repo_digests`, or empty if no digests.
|
||||
pub 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.
|
||||
pub 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.
|
||||
pub parent_id: String,
|
||||
/// The unchanged `RepoTags`.
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
/// The unchanged `RepoDigests`.
|
||||
#[serde(default)]
|
||||
pub digests: Vec<String>,
|
||||
/// Date and time at which the image was created as a Unix timestamp (number of seconds sinds EPOCH).
|
||||
pub created: I64,
|
||||
/// Total size of the image including all layers it is composed of.
|
||||
|
||||
@@ -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,
|
||||
@@ -402,24 +402,66 @@ pub struct FileContents {
|
||||
#[typeshare]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
|
||||
pub struct ImageDigest(pub String);
|
||||
pub struct ImageDigest(String);
|
||||
|
||||
impl ImageDigest {
|
||||
pub fn new(image: &str, digest: &str) -> Self {
|
||||
Self(format!("{image}@{digest}"))
|
||||
}
|
||||
|
||||
/// Handles incoming forms:
|
||||
/// - image-name@sha256:HASH
|
||||
/// - sha256:HASH
|
||||
pub fn parse(digest: &str) -> Option<Self> {
|
||||
if digest.contains('@') {
|
||||
Some(Self(digest.to_string()))
|
||||
} else if digest.starts_with("sha256:") {
|
||||
Some(Self(format!("__unknown__@{digest}")))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles incoming forms:
|
||||
/// - image-name@sha256:HASH
|
||||
/// - sha256:HASH (replaces image with __unknown__)
|
||||
pub fn vec(image_digests: &[String]) -> Vec<Self> {
|
||||
image_digests
|
||||
.iter()
|
||||
.filter_map(|digest| ImageDigest::parse(digest))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.0.as_str()
|
||||
}
|
||||
|
||||
/// Assumes this ImageDigest represents latest.
|
||||
pub fn update_available(&self, current_digest: &str) -> bool {
|
||||
/// Checks all the digests against latest, if none are equal
|
||||
/// then there is an update available.
|
||||
pub fn update_available(
|
||||
&self,
|
||||
current_digests: &[ImageDigest],
|
||||
) -> bool {
|
||||
let Some(latest_digest) = self.digest() else {
|
||||
// There is no "latest" to compare to.
|
||||
return false;
|
||||
};
|
||||
let current_digest = current_digest
|
||||
.split_once('@')
|
||||
.map(|r| r.1)
|
||||
.unwrap_or(current_digest);
|
||||
latest_digest != current_digest
|
||||
let digests = current_digests
|
||||
.iter()
|
||||
.filter_map(|digest| digest.digest())
|
||||
.collect::<Vec<_>>();
|
||||
// No valid digests to compare to,
|
||||
// avoid potentially false positive
|
||||
if digests.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// If the image digests do not contain latest
|
||||
// digest, then there is update available.
|
||||
!digests.contains(&latest_digest)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
|
||||
@@ -770,8 +770,8 @@ pub struct StackService {
|
||||
pub container: Option<ContainerListItem>,
|
||||
/// The service (Swarm mode)
|
||||
pub swarm_service: Option<SwarmServiceListItem>,
|
||||
/// The service image digest (When deployed)
|
||||
pub image_digest: Option<ImageDigest>,
|
||||
/// The service image digests
|
||||
pub image_digests: Option<Vec<ImageDigest>>,
|
||||
}
|
||||
|
||||
#[typeshare]
|
||||
@@ -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
|
||||
|
||||
@@ -290,7 +290,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,
|
||||
|
||||
@@ -226,6 +226,7 @@ export type WriteResponses = {
|
||||
WriteStackFileContents: Types.Update;
|
||||
RefreshStackCache: Types.NoData;
|
||||
CheckStackForUpdate: Types.CheckStackForUpdateResponse;
|
||||
BatchCheckStackForUpdate: Types.BatchCheckStackForUpdateResponse;
|
||||
|
||||
// ==== DEPLOYMENT ====
|
||||
CreateDeployment: Types.Deployment;
|
||||
@@ -235,6 +236,7 @@ export type WriteResponses = {
|
||||
UpdateDeployment: Types.Deployment;
|
||||
RenameDeployment: Types.Update;
|
||||
CheckDeploymentForUpdate: Types.CheckDeploymentForUpdateResponse;
|
||||
BatchCheckDeploymentForUpdate: Types.BatchCheckDeploymentForUpdateResponse;
|
||||
|
||||
// ==== BUILD ====
|
||||
CreateBuild: Types.Build;
|
||||
|
||||
+167
-72
@@ -339,6 +339,32 @@ export interface AlerterQuerySpecifics {
|
||||
|
||||
export type AlerterQuery = ResourceQuery<AlerterQuerySpecifics>;
|
||||
|
||||
export interface CheckDeploymentForUpdateResponse {
|
||||
/** The deployment ID */
|
||||
deployment: string;
|
||||
/** Whether update is available */
|
||||
update_available: boolean;
|
||||
}
|
||||
|
||||
export type BatchCheckDeploymentForUpdateResponse = CheckDeploymentForUpdateResponse[];
|
||||
|
||||
export interface StackServiceWithUpdate {
|
||||
service: string;
|
||||
/** The service's image */
|
||||
image: string;
|
||||
/** Whether there is a newer image available for this service */
|
||||
update_available: boolean;
|
||||
}
|
||||
|
||||
export interface CheckStackForUpdateResponse {
|
||||
/** The stack ID */
|
||||
stack: string;
|
||||
/** The stack services with update available status */
|
||||
services: StackServiceWithUpdate[];
|
||||
}
|
||||
|
||||
export type BatchCheckStackForUpdateResponse = CheckStackForUpdateResponse[];
|
||||
|
||||
export type BatchExecutionResponseItem =
|
||||
| { status: "Ok", data: Update }
|
||||
| { status: "Err", data: BatchExecutionResponseItemErr };
|
||||
@@ -825,10 +851,6 @@ export interface BuilderQuerySpecifics {
|
||||
|
||||
export type BuilderQuery = ResourceQuery<BuilderQuerySpecifics>;
|
||||
|
||||
export type CheckDeploymentForUpdateResponse = NoData;
|
||||
|
||||
export type CheckStackForUpdateResponse = NoData;
|
||||
|
||||
/** A wrapper for all Komodo exections. */
|
||||
export type Execution =
|
||||
/** The "null" execution. Does nothing. */
|
||||
@@ -2716,43 +2738,19 @@ export interface SwarmActionState {
|
||||
|
||||
export type GetSwarmActionStateResponse = SwarmActionState;
|
||||
|
||||
export type U64 = number;
|
||||
|
||||
/** The version number of the object such as node, service, etc. This is needed to avoid conflicting writes. The client must send the version number along with the modified specification when updating these objects. This approach ensures safe concurrency and determinism in that the change on the object may not be applied if the version number has changed from the last read. In other words, if two update requests specify the same base version, only one of the requests can succeed. As a result, two separate update requests that happen at the same time will not unintentionally overwrite each other. */
|
||||
export interface ObjectVersion {
|
||||
Index?: U64;
|
||||
}
|
||||
|
||||
/** Driver represents a driver (network, logging, secrets). */
|
||||
export interface Driver {
|
||||
/** Name of the driver. */
|
||||
Name: string;
|
||||
/** Key/value map of driver-specific options. */
|
||||
Options?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ConfigSpec {
|
||||
/** User-defined name of the config. */
|
||||
Name?: string;
|
||||
/** User-defined key/value metadata. */
|
||||
Labels?: Record<string, string>;
|
||||
/**
|
||||
* Data is the data to store as a config, formatted as a Base64-url-safe-encoded ([RFC 4648](https://tools.ietf.org/html/rfc4648#section-5)) string.
|
||||
* It must be empty if the Driver field is set, in which case the data is loaded from an external secret store.
|
||||
* The maximum allowed size is 500KB, as defined in [MaxSecretSize](https://pkg.go.dev/github.com/moby/swarmkit/v2@v2.0.0-20250103191802-8c1959736554/api/validation#MaxSecretSize).
|
||||
*/
|
||||
Data?: string;
|
||||
/** Templating driver, if applicable Templating controls whether and how to evaluate the config payload as a template. If no driver is set, no templating is used. */
|
||||
Templating?: Driver;
|
||||
}
|
||||
|
||||
/** Swarm config details. */
|
||||
export interface SwarmConfig {
|
||||
ID?: string;
|
||||
Version?: ObjectVersion;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
Spec?: ConfigSpec;
|
||||
/**
|
||||
* The Servers which are swarm manager nodes.
|
||||
* If a Server is not reachable or gives error,
|
||||
* tries the next Server.
|
||||
*/
|
||||
server_ids?: string[];
|
||||
/** Configure quick links that are displayed in the resource header */
|
||||
links?: string[];
|
||||
/** Whether to send alerts about the swarm health. */
|
||||
send_unhealthy_alerts: boolean;
|
||||
/** Scheduled maintenance windows during which alerts will be suppressed. */
|
||||
maintenance_windows?: MaintenanceWindow[];
|
||||
}
|
||||
|
||||
export interface SwarmInfo {
|
||||
@@ -3530,6 +3528,13 @@ export enum SwarmState {
|
||||
Unknown = "Unknown",
|
||||
}
|
||||
|
||||
export type U64 = number;
|
||||
|
||||
/** The version number of the object such as node, service, etc. This is needed to avoid conflicting writes. The client must send the version number along with the modified specification when updating these objects. This approach ensures safe concurrency and determinism in that the change on the object may not be applied if the version number has changed from the last read. In other words, if two update requests specify the same base version, only one of the requests can succeed. As a result, two separate update requests that happen at the same time will not unintentionally overwrite each other. */
|
||||
export interface ObjectVersion {
|
||||
Index?: U64;
|
||||
}
|
||||
|
||||
/** Describes a permission the user has to accept upon installing the plugin. */
|
||||
export interface PluginPrivilege {
|
||||
Name?: string;
|
||||
@@ -4516,7 +4521,45 @@ export type InspectStackSwarmInfoResponse = SwarmStack;
|
||||
|
||||
export type InspectStackSwarmServiceResponse = SwarmService;
|
||||
|
||||
export type InspectSwarmConfigResponse = SwarmConfig;
|
||||
/** Driver represents a driver (network, logging, secrets). */
|
||||
export interface Driver {
|
||||
/** Name of the driver. */
|
||||
Name: string;
|
||||
/** Key/value map of driver-specific options. */
|
||||
Options?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ConfigSpec {
|
||||
/** User-defined name of the config. */
|
||||
Name?: string;
|
||||
/** User-defined key/value metadata. */
|
||||
Labels?: Record<string, string>;
|
||||
/**
|
||||
* Data is the data to store as a config, formatted as a Base64-url-safe-encoded ([RFC 4648](https://tools.ietf.org/html/rfc4648#section-5)) string.
|
||||
* It must be empty if the Driver field is set, in which case the data is loaded from an external secret store.
|
||||
* The maximum allowed size is 500KB, as defined in [MaxSecretSize](https://pkg.go.dev/github.com/moby/swarmkit/v2@v2.0.0-20250103191802-8c1959736554/api/validation#MaxSecretSize).
|
||||
*/
|
||||
Data?: string;
|
||||
/** Templating driver, if applicable Templating controls whether and how to evaluate the config payload as a template. If no driver is set, no templating is used. */
|
||||
Templating?: Driver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swarm config details.
|
||||
*
|
||||
* This would be just "SwarmConfig", but that would
|
||||
* conflict with the Swarm (Komodo resource) Config type,
|
||||
* which is also SwarmConfig.
|
||||
*/
|
||||
export interface SwarmConfigDetails {
|
||||
ID?: string;
|
||||
Version?: ObjectVersion;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
Spec?: ConfigSpec;
|
||||
}
|
||||
|
||||
export type InspectSwarmConfigResponse = SwarmConfigDetails;
|
||||
|
||||
export enum NodeSpecRoleEnum {
|
||||
EMPTY = "",
|
||||
@@ -4964,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. */
|
||||
@@ -5321,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[];
|
||||
@@ -5352,14 +5405,6 @@ export enum StackState {
|
||||
Unknown = "unknown",
|
||||
}
|
||||
|
||||
export interface StackServiceWithUpdate {
|
||||
service: string;
|
||||
/** The service's image */
|
||||
image: string;
|
||||
/** Whether there is a newer image available for this service */
|
||||
update_available: boolean;
|
||||
}
|
||||
|
||||
export interface StackListItemInfo {
|
||||
/** The swarm that stack is deployed on, when in Swarm mode. */
|
||||
swarm_id: string;
|
||||
@@ -5825,6 +5870,69 @@ export interface BatchBuildRepo {
|
||||
pattern: string;
|
||||
}
|
||||
|
||||
/** Checks for newer image than what is deployed. Response: [BatchCheckDeploymentForUpdateResponse] */
|
||||
export interface BatchCheckDeploymentForUpdate {
|
||||
/**
|
||||
* Id or name or wildcard pattern or regex.
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```text
|
||||
* # match all foo-* deployments
|
||||
* foo-*
|
||||
* # add some more
|
||||
* extra-deployment-1, extra-deployment-2
|
||||
* ```
|
||||
*/
|
||||
pattern: string;
|
||||
/**
|
||||
* Normally resources with 'auto_update' will be
|
||||
* redeployed immediately if updates are found.
|
||||
* With this enabled, convert this into an UpdateAvailable alert.
|
||||
*/
|
||||
skip_auto_update?: boolean;
|
||||
/**
|
||||
* If check triggers auto deploy,
|
||||
* whether this call should wait on the auto deploy,
|
||||
* or run it in the background.
|
||||
*/
|
||||
wait_for_auto_update?: boolean;
|
||||
}
|
||||
|
||||
/** Checks for new images. Response: [BatchCheckStackForUpdateResponse] */
|
||||
export interface BatchCheckStackForUpdate {
|
||||
/**
|
||||
* Id or name or wildcard pattern or regex.
|
||||
* Supports multiline and comma delineated combinations of the above.
|
||||
*
|
||||
* Example:
|
||||
* ```text
|
||||
* # match all foo-* stacks
|
||||
* foo-*
|
||||
* # add some more
|
||||
* extra-stack-1, extra-stack-2
|
||||
* ```
|
||||
*/
|
||||
pattern: string;
|
||||
/**
|
||||
* Normally resources with 'auto_update' will be
|
||||
* redeployed immediately if updates are found.
|
||||
* With this enabled, convert this into an UpdateAvailable alert.
|
||||
*/
|
||||
skip_auto_update?: boolean;
|
||||
/**
|
||||
* If check triggers auto deploy,
|
||||
* whether this call should wait on the auto deploy,
|
||||
* or run it in the background.
|
||||
*/
|
||||
wait_for_auto_update?: boolean;
|
||||
/**
|
||||
* Usually will refresh the stack cache before checking for updates.
|
||||
* Skip with this option.
|
||||
*/
|
||||
skip_cache_refresh?: boolean;
|
||||
}
|
||||
|
||||
/** Clones multiple Repos in parallel that match pattern. Response: [BatchExecutionResponse]. */
|
||||
export interface BatchCloneRepo {
|
||||
/**
|
||||
@@ -9910,21 +10018,6 @@ export interface StopStack {
|
||||
services?: string[];
|
||||
}
|
||||
|
||||
export interface SwarmConfig {
|
||||
/**
|
||||
* The Servers which are swarm manager nodes.
|
||||
* If a Server is not reachable or gives error,
|
||||
* tries the next Server.
|
||||
*/
|
||||
server_ids?: string[];
|
||||
/** Configure quick links that are displayed in the resource header */
|
||||
links?: string[];
|
||||
/** Whether to send alerts about the swarm health. */
|
||||
send_unhealthy_alerts: boolean;
|
||||
/** Scheduled maintenance windows during which alerts will be suppressed. */
|
||||
maintenance_windows?: MaintenanceWindow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Swarm stack service list item.
|
||||
* Returned by `docker stack services --format json <NAME>`
|
||||
@@ -10858,6 +10951,7 @@ export type WriteRequest =
|
||||
| { type: "WriteStackFileContents", params: WriteStackFileContents }
|
||||
| { type: "RefreshStackCache", params: RefreshStackCache }
|
||||
| { type: "CheckStackForUpdate", params: CheckStackForUpdate }
|
||||
| { type: "BatchCheckStackForUpdate", params: BatchCheckStackForUpdate }
|
||||
| { type: "CreateDeployment", params: CreateDeployment }
|
||||
| { type: "CopyDeployment", params: CopyDeployment }
|
||||
| { type: "CreateDeploymentFromContainer", params: CreateDeploymentFromContainer }
|
||||
@@ -10865,6 +10959,7 @@ export type WriteRequest =
|
||||
| { type: "UpdateDeployment", params: UpdateDeployment }
|
||||
| { type: "RenameDeployment", params: RenameDeployment }
|
||||
| { type: "CheckDeploymentForUpdate", params: CheckDeploymentForUpdate }
|
||||
| { type: "BatchCheckDeploymentForUpdate", params: BatchCheckDeploymentForUpdate }
|
||||
| { type: "CreateBuild", params: CreateBuild }
|
||||
| { type: "CopyBuild", params: CopyBuild }
|
||||
| { type: "DeleteBuild", params: DeleteBuild }
|
||||
|
||||
@@ -5,7 +5,7 @@ use komodo_client::entities::{
|
||||
deployment::Deployment,
|
||||
docker::{
|
||||
SwarmLists,
|
||||
config::SwarmConfig,
|
||||
config::SwarmConfigDetails,
|
||||
node::{NodeSpecAvailabilityEnum, NodeSpecRoleEnum, SwarmNode},
|
||||
secret::SwarmSecret,
|
||||
service::SwarmService,
|
||||
@@ -265,7 +265,7 @@ pub struct InspectSwarmTask {
|
||||
// ========
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Resolve)]
|
||||
#[response(SwarmConfig)]
|
||||
#[response(SwarmConfigDetails)]
|
||||
#[error(anyhow::Error)]
|
||||
pub struct InspectSwarmConfig {
|
||||
pub config: String,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.91.1-bullseye as builder
|
||||
FROM rust:1.93.1-bullseye as builder
|
||||
WORKDIR /builder
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM rust:1.91.1-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",
|
||||
},
|
||||
};
|
||||
@@ -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,76 +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.9",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
"@radix-ui/react-checkbox": "1.3.2",
|
||||
"@radix-ui/react-dialog": "1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "2.1.15",
|
||||
"@radix-ui/react-hover-card": "1.1.14",
|
||||
"@radix-ui/react-icons": "1.3.2",
|
||||
"@radix-ui/react-label": "2.1.7",
|
||||
"@radix-ui/react-popover": "1.1.14",
|
||||
"@radix-ui/react-progress": "1.1.7",
|
||||
"@radix-ui/react-select": "2.2.5",
|
||||
"@radix-ui/react-separator": "1.1.7",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-switch": "1.2.5",
|
||||
"@radix-ui/react-tabs": "1.1.12",
|
||||
"@radix-ui/react-toast": "1.2.14",
|
||||
"@radix-ui/react-toggle": "1.1.9",
|
||||
"@radix-ui/react-toggle-group": "1.1.10",
|
||||
"@tanstack/react-query": "5.77.2",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
"@xterm/xterm": "5.5.0",
|
||||
"ansi-to-html": "0.7.2",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"cmdk": "1.1.1",
|
||||
"jotai": "2.12.5",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "0.511.0",
|
||||
"monaco-editor": "0.52.2",
|
||||
"monaco-yaml": "5.4.0",
|
||||
"prettier": "3.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-charts": "3.0.0-beta.57",
|
||||
"react-dom": "19.1.0",
|
||||
"react-minimal-pie-chart": "9.1.0",
|
||||
"react-router-dom": "7.6.1",
|
||||
"react-xtermjs": "1.0.10",
|
||||
"sanitize-html": "2.17.0",
|
||||
"shell-quote": "1.8.1",
|
||||
"tailwind-merge": "2.6.0",
|
||||
"tailwindcss-animate": "1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react-dom": "19.1.5",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@typescript-eslint/eslint-plugin": "8.33.0",
|
||||
"@typescript-eslint/parser": "8.33.0",
|
||||
"@vitejs/plugin-react": "4.5.0",
|
||||
"autoprefixer": "10.4.21",
|
||||
"eslint": "9.27.0",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.3",
|
||||
"tailwindcss": "3.4.17",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "6.0.7",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
},
|
||||
"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,195 +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") && (
|
||||
<>
|
||||
<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")) {
|
||||
write({ [usableResourceExecuteKey(type)]: resource } as any);
|
||||
} else {
|
||||
execute({
|
||||
[usableResourceExecuteKey(type)]: resource,
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
if (action.startsWith("Delete")) {
|
||||
setSelected([]);
|
||||
}
|
||||
}}
|
||||
disabled={action.startsWith("Refresh") ? 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,416 +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={[
|
||||
"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,141 +0,0 @@
|
||||
import { ConfirmButton } from "@components/util";
|
||||
import { useExecute, usePermissions, useRead } from "@lib/hooks";
|
||||
import {
|
||||
ArrowDownToDot,
|
||||
ArrowDownToLine,
|
||||
Ban,
|
||||
Hammer,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useRepo } from ".";
|
||||
import { Types } from "komodo_client";
|
||||
import { useBuilder } from "../builder";
|
||||
|
||||
export const CloneRepo = ({ id }: { id: string }) => {
|
||||
const { mutate, isPending } = useExecute("CloneRepo");
|
||||
const cloning = useRead(
|
||||
"GetRepoActionState",
|
||||
{ repo: id },
|
||||
{ refetchInterval: 5000 }
|
||||
).data?.cloning;
|
||||
const info = useRepo(id)?.info;
|
||||
if (!info?.server_id) return null;
|
||||
const hash = info?.latest_hash;
|
||||
const isCloned = (hash?.length || 0) > 0;
|
||||
const pending = isPending || cloning;
|
||||
return (
|
||||
<ConfirmButton
|
||||
title={isCloned ? "Reclone" : "Clone"}
|
||||
icon={
|
||||
pending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowDownToLine className="w-4 h-4" />
|
||||
)
|
||||
}
|
||||
onClick={() => mutate({ repo: id })}
|
||||
disabled={pending}
|
||||
loading={pending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const PullRepo = ({ id }: { id: string }) => {
|
||||
const { mutate, isPending } = useExecute("PullRepo");
|
||||
const pulling = useRead(
|
||||
"GetRepoActionState",
|
||||
{ repo: id },
|
||||
{ refetchInterval: 5000 }
|
||||
).data?.pulling;
|
||||
const info = useRepo(id)?.info;
|
||||
if (!info?.server_id) return null;
|
||||
const hash = info?.latest_hash;
|
||||
const isCloned = (hash?.length || 0) > 0;
|
||||
if (!isCloned) return null;
|
||||
const pending = isPending || pulling;
|
||||
return (
|
||||
<ConfirmButton
|
||||
title="Pull"
|
||||
icon={
|
||||
pending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowDownToDot className="w-4 h-4" />
|
||||
)
|
||||
}
|
||||
onClick={() => mutate({ repo: id })}
|
||||
disabled={pending}
|
||||
loading={pending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const BuildRepo = ({ id }: { id: string }) => {
|
||||
const { canExecute } = usePermissions({ type: "Repo", id });
|
||||
const building = useRead(
|
||||
"GetRepoActionState",
|
||||
{ repo: id },
|
||||
{ refetchInterval: 5000 }
|
||||
).data?.building;
|
||||
const updates = useRead("ListUpdates", {
|
||||
query: {
|
||||
"target.type": "Repo",
|
||||
"target.id": id,
|
||||
},
|
||||
}).data;
|
||||
const { mutate: run_mutate, isPending: runPending } = useExecute("BuildRepo");
|
||||
const { mutate: cancel_mutate, isPending: cancelPending } =
|
||||
useExecute("CancelRepoBuild");
|
||||
|
||||
const repo = useRepo(id);
|
||||
const builder = useBuilder(repo?.info.builder_id);
|
||||
const canCancel = builder?.info.builder_type !== "Server";
|
||||
|
||||
// Don't show if builder not attached
|
||||
if (!builder) return null;
|
||||
|
||||
// make sure hidden without perms.
|
||||
// not usually necessary, but this button also used in deployment actions.
|
||||
if (!canExecute) return null;
|
||||
|
||||
// updates come in in descending order, so 'find' will find latest update matching operation
|
||||
const latestBuild = updates?.updates.find(
|
||||
(u) => u.operation === Types.Operation.BuildRepo
|
||||
);
|
||||
const latestCancel = updates?.updates.find(
|
||||
(u) => u.operation === Types.Operation.CancelRepoBuild
|
||||
);
|
||||
const cancelDisabled =
|
||||
!canCancel ||
|
||||
cancelPending ||
|
||||
(latestCancel && latestBuild
|
||||
? latestCancel!.start_ts > latestBuild!.start_ts
|
||||
: false);
|
||||
|
||||
if (building) {
|
||||
return (
|
||||
<ConfirmButton
|
||||
title="Cancel Build"
|
||||
variant="destructive"
|
||||
icon={<Ban className="h-4 w-4" />}
|
||||
onClick={() => cancel_mutate({ repo: id })}
|
||||
disabled={cancelDisabled}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<ConfirmButton
|
||||
title="Build"
|
||||
icon={
|
||||
runPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Hammer className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
onClick={() => run_mutate({ repo: id })}
|
||||
disabled={runPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user