From 2b4fa006a5acd4474f60eae4c309de044bcb6f31 Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Sat, 6 Jan 2024 11:54:37 +0100 Subject: [PATCH 01/16] chore(frontend): add local dockerfile --- frontend/Dockerfile.dev | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 frontend/Dockerfile.dev diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 0000000..f021864 --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,7 @@ +FROM node:20-alpine3.19 as builder + +WORKDIR /app + +COPY . . + +RUN npm install From 34deeb8de3bba1f995a273b6f83d439e554277f5 Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Sat, 6 Jan 2024 12:50:29 +0100 Subject: [PATCH 02/16] feat: load dabatabase externally and use local build for the backend --- backend/Dockerfile.dev | 9 +++++++++ backend/src/database.rs | 2 +- backend/src/main.rs | 15 ++++----------- docker-compose-dev.yaml | 9 --------- docker-compose.yaml | 2 ++ 5 files changed, 16 insertions(+), 21 deletions(-) create mode 100644 backend/Dockerfile.dev delete mode 100644 docker-compose-dev.yaml diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..8ba4155 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,9 @@ +FROM rust:1.75.0-alpine3.18 as builder + +WORKDIR /app + +COPY . . + +# Natively the compilation fails. We need this +# source: https://github.com/ocaml/opam-repository/issues/13718#issuecomment-475550590 +RUN apk add --no-cache musl-dev pkgconfig libressl-dev diff --git a/backend/src/database.rs b/backend/src/database.rs index 048a470..945f5aa 100644 --- a/backend/src/database.rs +++ b/backend/src/database.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; -use sqlx::{Executor, Pool, Postgres}; +use sqlx::{Pool, Postgres}; use sqlx::types::Uuid; use crate::errors::QError; diff --git a/backend/src/main.rs b/backend/src/main.rs index f6c5a9d..362c738 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -50,14 +50,14 @@ async fn main() { let file = File::open(args.config).unwrap_or_else(|err| { error!("failed to open config file: {}", err); - std::process::exit(1); + std::process::exit(2); }); let yaml_config: Arc = match serde_yaml::from_reader(file) { Ok(config) => Arc::new(config), Err(err) => { error!("failed to parse config file: {}", err); - std::process::exit(1); + std::process::exit(2); } }; @@ -65,7 +65,7 @@ async fn main() { Ok(_) => {} Err(err) => { error!("failed to validate config file: {}", err); - std::process::exit(1); + std::process::exit(2); } }; @@ -82,17 +82,10 @@ async fn main() { Ok(pool) => pool, Err(err) => { error!("failed to connect to database: {}", err); - std::process::exit(1); + std::process::exit(3); } }; - init_database(&pg_pool).await.unwrap_or_else(|err| { - error!("failed to initialize database: {:?}", err); - std::process::exit(1); - }); - - info!("database initialized and up to date"); - let pg_pool = Arc::new(pg_pool); let bgw_client = pg_pool.clone(); diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml deleted file mode 100644 index 0080208..0000000 --- a/docker-compose-dev.yaml +++ /dev/null @@ -1,9 +0,0 @@ -services: - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: torii - ports: - - 5432:5432 diff --git a/docker-compose.yaml b/docker-compose.yaml index 4d39526..abe584b 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,3 +1,5 @@ +version: '3' + services: frontend: build: From 02b6aa58fa4165e3201ce1415982272d9b2c0b07 Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Sat, 6 Jan 2024 12:56:13 +0100 Subject: [PATCH 03/16] fix: clean typo --- docker-compose.yaml | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index abe584b..9b9a009 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -2,9 +2,28 @@ version: '3' services: frontend: - build: - context: ./frontend - dockerfile: Dockerfile + restart: always + build: + context: frontend + dockerfile: Dockerfile.dev + command: npx vite --host 0.0.0.0 --port 80 + ports: + - 8080:80 + volumes: + - $PWD/frontend:/app + - torii-frontend-node-modules:/app/node_modules + depends_on: + backend: + condition: service_started + + backend: + restart: always + build: + context: backend + dockerfile: Dockerfile.dev + command: cargo run --config /app/exemples/config.yaml + environment: + DB_CONNECTION_URL: postgres://postgres:postgres@postgres:5432/torii ports: - "5173:80" depends_on: From 452d51e36fc0537577a809d5f52926240fb20bf8 Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Wed, 24 Jul 2024 20:21:44 +0200 Subject: [PATCH 04/16] feat: revert exit code 2 --- backend/src/main.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index 362c738..dea7528 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -50,14 +50,14 @@ async fn main() { let file = File::open(args.config).unwrap_or_else(|err| { error!("failed to open config file: {}", err); - std::process::exit(2); + std::process::exit(1); }); let yaml_config: Arc = match serde_yaml::from_reader(file) { Ok(config) => Arc::new(config), Err(err) => { error!("failed to parse config file: {}", err); - std::process::exit(2); + std::process::exit(1); } }; @@ -65,7 +65,7 @@ async fn main() { Ok(_) => {} Err(err) => { error!("failed to validate config file: {}", err); - std::process::exit(2); + std::process::exit(1); } }; From 27689afaafb442581b3c0b11f66c511fb2b65e1c Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Wed, 24 Jul 2024 20:35:04 +0200 Subject: [PATCH 05/16] feat: docker-compose dev --- backend/Dockerfile | 2 +- backend/Dockerfile.dev | 6 +---- backend/src/database.rs | 2 +- backend/src/main.rs | 7 ++++++ docker-compose.dev.yaml | 52 +++++++++++++++++++++++++++++++++++++++++ docker-compose.yaml | 22 ++++------------- 6 files changed, 67 insertions(+), 24 deletions(-) create mode 100644 docker-compose.dev.yaml diff --git a/backend/Dockerfile b/backend/Dockerfile index 3d1d8a2..04bce68 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM rust:1.77.2-bookworm as builder +FROM rust:1.79-bookworm as builder WORKDIR /app diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 8ba4155..4785db6 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -1,9 +1,5 @@ -FROM rust:1.75.0-alpine3.18 as builder +FROM rust:1.79-bookworm WORKDIR /app COPY . . - -# Natively the compilation fails. We need this -# source: https://github.com/ocaml/opam-repository/issues/13718#issuecomment-475550590 -RUN apk add --no-cache musl-dev pkgconfig libressl-dev diff --git a/backend/src/database.rs b/backend/src/database.rs index 945f5aa..048a470 100644 --- a/backend/src/database.rs +++ b/backend/src/database.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; -use sqlx::{Pool, Postgres}; +use sqlx::{Executor, Pool, Postgres}; use sqlx::types::Uuid; use crate::errors::QError; diff --git a/backend/src/main.rs b/backend/src/main.rs index dea7528..9112d9d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -86,6 +86,13 @@ async fn main() { } }; + init_database(&pg_pool).await.unwrap_or_else(|err| { + error!("failed to initialize database: {:?}", err); + std::process::exit(1); + }); + + info!("database initialized and up to date"); + let pg_pool = Arc::new(pg_pool); let bgw_client = pg_pool.clone(); diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml new file mode 100644 index 0000000..d524db2 --- /dev/null +++ b/docker-compose.dev.yaml @@ -0,0 +1,52 @@ +services: + frontend: + restart: always + build: + context: frontend + dockerfile: Dockerfile.dev + command: npx vite --host 0.0.0.0 --port 80 + ports: + - 8080:80 + volumes: + - $PWD/frontend:/app + - torii-frontend-node-modules:/app/node_modules + depends_on: + backend: + condition: service_started + + backend: + build: + context: backend + dockerfile: Dockerfile.dev + command: cargo run --config /app/exemples/config.yaml + ports: + - '9999:9999' + volumes: + - type: bind + source: ./backend + target: /tmp + healthcheck: + test: [ "CMD", "curl", "-f", "http://localhost:9999" ] + environment: + - DB_CONNECTION_URL=postgresql://postgres:postgres@postgres:5432/torii + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: torii + ports: + - 5432:5432 + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U postgres" ] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + torii-frontend-node-modules: + \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 9b9a009..3810659 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: '3' - services: frontend: restart: always @@ -16,26 +14,11 @@ services: backend: condition: service_started - backend: - restart: always - build: - context: backend - dockerfile: Dockerfile.dev - command: cargo run --config /app/exemples/config.yaml - environment: - DB_CONNECTION_URL: postgres://postgres:postgres@postgres:5432/torii - ports: - - "5173:80" - depends_on: - backend: - condition: service_started backend: build: context: ./backend dockerfile: Dockerfile restart: no - command: - -c /app/examples/config.yaml ports: - '9999:9999' volumes: @@ -49,6 +32,7 @@ services: depends_on: postgres: condition: service_healthy + postgres: image: postgres:16-alpine environment: @@ -62,3 +46,7 @@ services: interval: 10s timeout: 5s retries: 5 + +volumes: + torii-frontend-node-modules: + \ No newline at end of file From 8ca16df1567899ff0b2101c79b4696def8c22232 Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Wed, 24 Jul 2024 21:37:59 +0200 Subject: [PATCH 06/16] feat: ux --- backend/.dockerignore | 2 +- backend/Dockerfile.dev | 3 +++ docker-compose.dev.yaml | 12 +++++------- frontend/package-lock.json | 9 +++++---- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/backend/.dockerignore b/backend/.dockerignore index cda49f0..2088f2d 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -7,4 +7,4 @@ # Sources !src -!examples +!examples \ No newline at end of file diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 4785db6..a8d2bd7 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -2,4 +2,7 @@ FROM rust:1.79-bookworm WORKDIR /app +RUN apt update && apt install -y libssl-dev python3 && \ + ln -s /usr/bin/python3 /usr/bin/python + COPY . . diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml index d524db2..1ad0a7c 100644 --- a/docker-compose.dev.yaml +++ b/docker-compose.dev.yaml @@ -18,20 +18,18 @@ services: build: context: backend dockerfile: Dockerfile.dev - command: cargo run --config /app/exemples/config.yaml + command: cargo run -- --config /app/examples/config.yaml ports: - '9999:9999' volumes: - - type: bind - source: ./backend - target: /tmp + - $PWD/backend:/app healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:9999" ] environment: - DB_CONNECTION_URL=postgresql://postgres:postgres@postgres:5432/torii - depends_on: - postgres: - condition: service_healthy + #depends_on: + # postgres: + # condition: service_healthy postgres: image: postgres:16-alpine diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5229e77..c5f2674 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2308,9 +2308,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001568", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001568.tgz", - "integrity": "sha512-vSUkH84HontZJ88MiNrOau1EBrCqEQYgkC5gIySiDlpsm8sGVrhU7Kx4V6h0tnqaHzIHZv08HlJIwPbL4XL9+A==", + "version": "1.0.30001643", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001643.tgz", + "integrity": "sha512-ERgWGNleEilSrHM6iUz/zJNSQTP8Mr21wDWpdgvRwcTXGAq6jMtOUPP4dqFPTdKqZ2wKTdtB+uucZ3MRpAUSmg==", "dev": true, "funding": [ { @@ -2325,7 +2325,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "2.4.2", From b41bdf418c138a361252a6a51e002202773bf4a3 Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Wed, 24 Jul 2024 23:00:36 +0200 Subject: [PATCH 07/16] feat: create details view of a run --- backend/src/database.rs | 18 +++++ backend/src/main.rs | 3 +- backend/src/self_service/controllers.rs | 18 ++++- backend/src/self_service/mod.rs | 6 ++ frontend/package-lock.json | 7 ++ frontend/package.json | 1 + frontend/src/components/Table.tsx | 68 +++++++++---------- frontend/src/main.tsx | 5 ++ .../pages/self-service/details/details.tsx | 66 ++++++++++++++++++ .../run-history/service-runs-table.tsx | 14 ++-- frontend/vite.config.ts | 5 ++ 11 files changed, 169 insertions(+), 42 deletions(-) create mode 100644 frontend/src/pages/self-service/details/details.tsx diff --git a/backend/src/database.rs b/backend/src/database.rs index 048a470..0685f14 100644 --- a/backend/src/database.rs +++ b/backend/src/database.rs @@ -165,6 +165,24 @@ pub async fn list_self_service_runs( ) } +pub async fn get_self_service_runs( + pg_pool: &Pool, + id: &str, +) -> Result { + Ok( + sqlx::query_as::<_, SelfServiceRun>( + r#" + SELECT * + FROM self_service_runs + WHERE id = $1 + "# + ) + .bind(Uuid::from_str(id).unwrap()) + .fetch_one(pg_pool) + .await? + ) +} + pub async fn insert_self_service_run( pg_pool: &Pool, section_slug: &str, diff --git a/backend/src/main.rs b/backend/src/main.rs index f6c5a9d..94f5963 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -15,7 +15,7 @@ use tracing_subscriber::util::SubscriberInitExt; use crate::cli::CLI; use crate::database::init_database; -use crate::self_service::controllers::{exec_self_service_section_action_post_validate_scripts, exec_self_service_section_action_validate_scripts, list_self_service_section_actions, list_self_service_section_run_logs, list_self_service_section_runs, list_self_service_section_runs_by_section_and_action_slugs, list_self_service_section_runs_by_section_slug, list_self_service_sections}; +use crate::self_service::controllers::{exec_self_service_section_action_post_validate_scripts, exec_self_service_section_action_validate_scripts, list_self_service_section_actions, list_self_service_section_run_logs, list_self_service_section_runs, list_self_service_section_runs_by_section_and_action_slugs, list_self_service_section_runs_by_section_slug, list_self_service_sections, get_self_service_runs_by_id}; use crate::self_service::services::BackgroundWorkerTask; use crate::yaml_config::YamlConfig; @@ -109,6 +109,7 @@ async fn main() { .route("/", get(|| async { "OK" })) .route("/healthz", get(|| async { "OK" })) .route("/selfServiceSections", get(list_self_service_sections)) + .route("/selfServiceSections/:id", get(get_self_service_runs_by_id)) .route("/selfServiceSections/runs", get(list_self_service_section_runs)) .route("/selfServiceSectionsRuns/:slug/logs", get(list_self_service_section_run_logs)) .route("/selfServiceSections/:slug/actions", get(list_self_service_section_actions)) diff --git a/backend/src/self_service/controllers.rs b/backend/src/self_service/controllers.rs index 5eac44e..cd68184 100644 --- a/backend/src/self_service/controllers.rs +++ b/backend/src/self_service/controllers.rs @@ -7,6 +7,7 @@ use chrono::{NaiveDateTime, Utc}; use tokio::sync::mpsc::Sender; use tracing::error; +use crate::self_service::ResultResponse; use crate::database; use crate::database::{insert_self_service_run, SelfServiceRunJson, SelfServiceRunLogJson, Status}; use crate::self_service::{check_json_payload_against_yaml_config_fields, execute_command, ExecValidateScriptRequest, find_self_service_section_by_slug, get_self_service_section_and_action, JobResponse, ResultsResponse}; @@ -20,6 +21,22 @@ pub async fn list_self_service_sections( (StatusCode::OK, Json(ResultsResponse { message: None, results: yaml_config.self_service.sections.clone() })) } +#[debug_handler] +pub async fn get_self_service_runs_by_id( + Extension(pg_pool): Extension>, + Path(id): Path<(String)>, +) -> (StatusCode, Json>) { + match database::get_self_service_runs(&pg_pool, &id).await { + Ok(self_service) => { + (StatusCode::OK, Json(ResultResponse { message: None, result: Some(self_service.to_json()) })) + } + Err(err) => { + error!("failed to get self service: {:?}", err); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ResultResponse { message: Some(err.to_string()), result: None })) + } + } +} + #[debug_handler] pub async fn list_self_service_section_actions( Extension(yaml_config): Extension>, @@ -88,7 +105,6 @@ pub async fn list_self_service_section_run_logs( Extension(pg_pool): Extension>, Path(logs_slug): Path, ) -> (StatusCode, Json>) { - // mock data for now let mut data = vec![]; diff --git a/backend/src/self_service/mod.rs b/backend/src/self_service/mod.rs index 09baa86..659b08c 100644 --- a/backend/src/self_service/mod.rs +++ b/backend/src/self_service/mod.rs @@ -18,6 +18,12 @@ pub struct ResultsResponse { results: Vec, } +#[derive(Serialize, Deserialize)] +pub struct ResultResponse { + message: Option, + result: Option, +} + #[derive(Debug, Serialize, Deserialize, PartialEq)] pub struct JobResponse { message: Option, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5229e77..7380411 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -33,6 +33,7 @@ "sort-by": "^1.2.0", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", + "ts-pattern": "^5.2.0", "wonka": "^6.3.4", "yup": "^1.4.0" }, @@ -4967,6 +4968,12 @@ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, + "node_modules/ts-pattern": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.2.0.tgz", + "integrity": "sha512-aGaSpOlDcns7ZoeG/OMftWyQG1KqPVhgplhJxNCvyIXqWrumM5uIoOSarw/hmmi/T1PnuQ/uD8NaFHvLpHicDg==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7a75b61..7cf9c37 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -35,6 +35,7 @@ "sort-by": "^1.2.0", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", + "ts-pattern": "^5.2.0", "wonka": "^6.3.4", "yup": "^1.4.0" }, diff --git a/frontend/src/components/Table.tsx b/frontend/src/components/Table.tsx index a8dfbb4..3ec8dab 100644 --- a/frontend/src/components/Table.tsx +++ b/frontend/src/components/Table.tsx @@ -1,4 +1,4 @@ -import { ArrowLongUpIcon } from "@heroicons/react/24/outline"; +import { ArrowLongUpIcon } from "@heroicons/react/24/outline" import { ColumnDef, OnChangeFn, @@ -10,63 +10,63 @@ import { getExpandedRowModel, getSortedRowModel, useReactTable, -} from "@tanstack/react-table"; -import clsx from "clsx"; -import { Fragment, useEffect } from "react"; +} from "@tanstack/react-table" +import clsx from "clsx" +import { Fragment, useEffect } from "react" // https://github.com/TanStack/table/issues/4382 // eslint-disable-next-line @typescript-eslint/no-explicit-any -export type TableColumns = ColumnDef[]; +export type TableColumns = ColumnDef[] -export type TableRowMode = "default" | "condensed"; +export type TableRowMode = "default" | "condensed" export interface TableProps { - data: T[]; - columns: TableColumns; - renderSubComponent?: (props: { row: Row }) => React.ReactElement; - sorting?: SortingState; - onSortingChange?: OnChangeFn; - manualSorting?: boolean; + data: T[] + columns: TableColumns + renderSubComponent?: (props: { row: Row }) => React.ReactElement + sorting?: SortingState + onSortingChange?: OnChangeFn + manualSorting?: boolean /** * If using expandable rows, this function will be called for each row to determine if it should be expanded or not. */ - expandCondition?: (row: Row) => boolean; + expandCondition?: (row: Row) => boolean } /** Only used for client side sorting */ export function sortHelper(rows: T[], sorting: SortingState) { - const inputRows = [...rows]; + const inputRows = [...rows] if (sorting.length > 0) { inputRows.sort((a, b) => { - const sort = sorting[0]; - const aVal = a[sort.id as keyof T]; - const bVal = b[sort.id as keyof T]; + const sort = sorting[0] + const aVal = a[sort.id as keyof T] + const bVal = b[sort.id as keyof T] if (aVal === bVal) { - return 0; + return 0 } if (sort.desc) { - return aVal! > bVal! ? -1 : 1; + return aVal! > bVal! ? -1 : 1 } else { - return aVal! > bVal! ? 1 : -1; + return aVal! > bVal! ? 1 : -1 } - }); + }) } - return inputRows; + return inputRows } export function Table(props: TableProps) { - const { data, columns, renderSubComponent, expandCondition } = props; + const { data, columns, renderSubComponent, expandCondition } = props const sortingChanged: OnChangeFn = ( sortingState: Updater, ) => { - props.onSortingChange && props.onSortingChange(sortingState); - }; + props.onSortingChange && props.onSortingChange(sortingState) + } const table = useReactTable({ data, @@ -79,17 +79,17 @@ export function Table(props: TableProps) { getSortedRowModel: getSortedRowModel(), manualSorting: props.manualSorting ?? false, onSortingChange: sortingChanged, - }); + }) useEffect(() => { if (expandCondition) { - const rows = table.getRowModel().rows; + const rows = table.getRowModel().rows for (const row of rows) { - const val = expandCondition(row); - row.toggleExpanded(val); + const val = expandCondition(row) + row.toggleExpanded(val) } } - }, [table, expandCondition]); + }, [table, expandCondition]) return ( @@ -166,7 +166,7 @@ export function Table(props: TableProps) { cell.getContext(), )} - ); + ) })} {row.getIsExpanded() && renderSubComponent && ( @@ -178,11 +178,11 @@ export function Table(props: TableProps) { )} - ); + ) })}
- ); + ) } -export default Table; +export default Table diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 21e09dc..044c1b5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import { Providers } from "./providers"; import App from "./pages/app"; import { RunHistory } from "./pages/self-service/run-history/run-history"; import CatalogList from "./pages/self-service/catalog-list/catalog-list"; +import { Details } from "./pages/self-service/details/details"; const router = createBrowserRouter([ { @@ -27,6 +28,10 @@ const router = createBrowserRouter([ path: "/self-service/run-history", element: , }, + { + path: "/self-service/:taskId/details", + element:
, + }, ], }, ], diff --git a/frontend/src/pages/self-service/details/details.tsx b/frontend/src/pages/self-service/details/details.tsx new file mode 100644 index 0000000..5866608 --- /dev/null +++ b/frontend/src/pages/self-service/details/details.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from "react"; +import Subheader from "../../../components/Subheader"; +import { API_URL } from "../../../config"; +import { match } from 'ts-pattern' + +type Log = { + id: string; + createdAt: string; + isStderr: boolean; + message: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function mapLog(incomingLog: any): Log { + return { + ...incomingLog, + createdAt: incomingLog.created_at, + isStderr: incomingLog.is_stderr, + } +} + +export const Details = () => { + const [data, setData] = useState({}); + const [logs, setLogs] = useState>([]); + useEffect(() => { + fetch(`${API_URL}/selfServiceSections/63f65f2c-f045-4e38-aef8-fd15e11b6e50`) + .then(res => res.json()) + .then((data) => { + setData(data) + }); + + fetch(`${API_URL}/selfServiceSectionsRuns/63f65f2c-f045-4e38-aef8-fd15e11b6e50/logs`) + .then(res => res.json()) + .then((data) => { + setLogs(data.results.reverse().map(mapLog) as Array); + }); + }, []); + + return ( + <> +
+
+ +
+ +
+ cc +
+ +
+ { + logs.map(log => match(log) + .with({ isStderr: true }, () => ( +
{log.createdAt} {log.message}
+ )) + .with({ isStderr: false }, () => ( +
{log.createdAt} {log.message}
+ )) + .exhaustive() + ) + } +
+
+ + ); +}; \ No newline at end of file diff --git a/frontend/src/pages/self-service/run-history/service-runs-table.tsx b/frontend/src/pages/self-service/run-history/service-runs-table.tsx index d29657b..242e2e5 100644 --- a/frontend/src/pages/self-service/run-history/service-runs-table.tsx +++ b/frontend/src/pages/self-service/run-history/service-runs-table.tsx @@ -65,10 +65,11 @@ interface ServiceRunsTable { interface ServiceRunRow { createdAt: string; + executionTime: string; + id: string; serviceSlug: string; status: string; tasks: number; - executionTime: string; } const columnHelper = createColumnHelper(); @@ -82,10 +83,11 @@ export default function ServiceRunsTable({ () => rows.map((row) => ({ createdAt: dayjs(row.created_at).fromNow(), + executionTime: millisToHumanTime(getTotalExecutionTime(row.tasks)), + id: row.id, serviceSlug: row.input_payload.name, status: row.status, tasks: totalSuccessTasks(row.tasks) / row.tasks.length, - executionTime: millisToHumanTime(getTotalExecutionTime(row.tasks)), })), [rows], ); @@ -141,11 +143,11 @@ export default function ServiceRunsTable({ }), columnHelper.display({ id: "edit", - header: () => "Edit", + header: () => "Actions", cell: (cellProps) => { return ( - - Details{" "} + + Show details{" "} , {cellProps.row.original.serviceSlug} @@ -159,7 +161,7 @@ export default function ServiceRunsTable({ return (
- ; +
); } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5a5973e..a7eede9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,4 +10,9 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + server: { + fs: { + cachedChecks: false + } + } }) From 88b5cd710857708f45aae2ab9065284eaeba4eeb Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Wed, 24 Jul 2024 23:27:11 +0200 Subject: [PATCH 08/16] feat: ensure connection and add status --- backend/src/self_service/controllers.rs | 5 ++-- .../pages/self-service/details/details.tsx | 29 ++++++++++++++----- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/backend/src/self_service/controllers.rs b/backend/src/self_service/controllers.rs index cd68184..e67308f 100644 --- a/backend/src/self_service/controllers.rs +++ b/backend/src/self_service/controllers.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use axum::{debug_handler, Extension, Json}; use axum::extract::Path; use axum::http::StatusCode; -use chrono::{NaiveDateTime, Utc}; use tokio::sync::mpsc::Sender; use tracing::error; @@ -24,14 +23,14 @@ pub async fn list_self_service_sections( #[debug_handler] pub async fn get_self_service_runs_by_id( Extension(pg_pool): Extension>, - Path(id): Path<(String)>, + Path(id): Path, ) -> (StatusCode, Json>) { match database::get_self_service_runs(&pg_pool, &id).await { Ok(self_service) => { (StatusCode::OK, Json(ResultResponse { message: None, result: Some(self_service.to_json()) })) } Err(err) => { - error!("failed to get self service: {:?}", err); + error!("Failed to get self service2: {:?}", err); (StatusCode::INTERNAL_SERVER_ERROR, Json(ResultResponse { message: Some(err.to_string()), result: None })) } } diff --git a/frontend/src/pages/self-service/details/details.tsx b/frontend/src/pages/self-service/details/details.tsx index 5866608..a979f9e 100644 --- a/frontend/src/pages/self-service/details/details.tsx +++ b/frontend/src/pages/self-service/details/details.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import Subheader from "../../../components/Subheader"; import { API_URL } from "../../../config"; import { match } from 'ts-pattern' +import { useParams } from "react-router-dom"; type Log = { id: string; @@ -10,6 +11,13 @@ type Log = { message: string; } +type SelfServiceRun = { + id: string; + section_slug: string; + action_slug: string; + status: "SUCCESS" +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any function mapLog(incomingLog: any): Log { return { @@ -20,31 +28,36 @@ function mapLog(incomingLog: any): Log { } export const Details = () => { - const [data, setData] = useState({}); + const { taskId } = useParams(); + console.log(taskId) + + const [data, setData] = useState(undefined); const [logs, setLogs] = useState>([]); useEffect(() => { - fetch(`${API_URL}/selfServiceSections/63f65f2c-f045-4e38-aef8-fd15e11b6e50`) + fetch(`${API_URL}/selfServiceSections/${taskId}`) .then(res => res.json()) .then((data) => { - setData(data) + setData(data.result as SelfServiceRun) }); - fetch(`${API_URL}/selfServiceSectionsRuns/63f65f2c-f045-4e38-aef8-fd15e11b6e50/logs`) + fetch(`${API_URL}/selfServiceSectionsRuns/${taskId}/logs`) .then(res => res.json()) .then((data) => { setLogs(data.results.reverse().map(mapLog) as Array); }); - }, []); + }, [taskId]); return ( <>
- + {data && }
-
- cc +
+ {data && ( +
{data.status}
+ )}
From 648a11fe3060143727cfa906e1296f6854e51b10 Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Wed, 24 Jul 2024 23:32:07 +0200 Subject: [PATCH 09/16] chore: remove vite option --- frontend/vite.config.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index a7eede9..5a5973e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -10,9 +10,4 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, - server: { - fs: { - cachedChecks: false - } - } }) From 1aa1da8f03c3381a17ada8ca7ddad1596b4e7e9c Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Wed, 24 Jul 2024 23:32:21 +0200 Subject: [PATCH 10/16] chore: remove console.log --- frontend/src/pages/self-service/details/details.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/pages/self-service/details/details.tsx b/frontend/src/pages/self-service/details/details.tsx index a979f9e..da57f76 100644 --- a/frontend/src/pages/self-service/details/details.tsx +++ b/frontend/src/pages/self-service/details/details.tsx @@ -29,7 +29,6 @@ function mapLog(incomingLog: any): Log { export const Details = () => { const { taskId } = useParams(); - console.log(taskId) const [data, setData] = useState(undefined); const [logs, setLogs] = useState>([]); From 8fbeda167392b443d65dd3523ffa77f1fa1ca6ce Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Thu, 25 Jul 2024 12:31:03 +0200 Subject: [PATCH 11/16] feat: watch backend files --- README.md | 273 +++++++----------- Tiltfile | 1 + backend/Dockerfile.dev | 3 +- docker-compose.dev.yaml | 50 ---- docker-compose.yaml | 18 +- .../run-history/service-runs-table.tsx | 2 +- 6 files changed, 109 insertions(+), 238 deletions(-) create mode 100644 Tiltfile delete mode 100644 docker-compose.dev.yaml diff --git a/README.md b/README.md index 5892832..a4deb8f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ That's it! ## Features | Feature | Status | -|------------------|---------------------| +| ---------------- | ------------------- | | Self Service | WIP | | Catalog Services | WIP | | Auth | Not implemented yet | @@ -34,19 +34,22 @@ That's it! ### Installation +#### Using Docker + Today you can run Torii using Docker Compose. In the future, we will provide a Helm chart to deploy Torii on Kubernetes. +It starts full environment with postgres instance. + ```bash docker-compose up -``` -If you want to run it locally you will need to start Postgres DB, the backend and the frontend separately. - -```bash -# Start Postgres -docker-compose -f docker-compose-dev.yaml up +# Alternatively, you can use tilt +# https://tilt.dev +tilt up ``` +#### Locally + ```bash # Start the backend cd backend @@ -135,16 +138,16 @@ self_service: - my-scripts/environment_management.py - create - --name - - {{name}} # this is a variable that will be replaced by the value of the field 'name' + - { { name } } # this is a variable that will be replaced by the value of the field 'name' delayed_command: - command: - python - my-scripts/environment_management.py - delete - --name - - {{name}} + - { { name } } delay: - hours: {{ttl}} # this is a variable that will be replaced by the value of the field 'ttl' + hours: { { ttl } } # this is a variable that will be replaced by the value of the field 'ttl' ``` In this example, we define a self-service section with a single action called `new-testing-environment`. This action has four fields: @@ -158,171 +161,89 @@ When the developer fills the form and submits it, Torii will run the `post_valid If the script exits with a non-zero exit code, the action will fail. If the script exits with a zero exit code, Torii will run the `delayed_command` script after the specified delay. -[//]: # (### Advanced Configuration) - -[//]: # () - -[//]: # (#### Autocomplete Fetcher) - -[//]: # () - -[//]: # (An autocomplete fetcher is a script that must print a JSON on standard output. The JSON must contain a `results` key that contains a list of) - -[//]: # (values.) - -[//]: # () - -[//]: # (```json) - -[//]: # ({) - -[//]: # ( "results": [) - -[//]: # ( "val 1",) - -[//]: # ( "val 2",) - -[//]: # ( "val 3") - -[//]: # ( ]) - -[//]: # (}) - -[//]: # (```) - -[//]: # () - -[//]: # (Example of autocomplete fetcher in python:) - -[//]: # () - -[//]: # (```python) - -[//]: # (import json) - -[//]: # () - -[//]: # () - -[//]: # (def get_data_from_fake_api():) - -[//]: # ( return [) - -[//]: # ( 'val 1',) - -[//]: # ( 'val 2',) - -[//]: # ( 'val 3',) - -[//]: # ( ]) - -[//]: # () - -[//]: # () - -[//]: # (if __name__ == '__main__':) - -[//]: # ( # do your stuff here) - -[//]: # ( results = get_data_from_fake_api()) - -[//]: # () - -[//]: # ( data = {'results': results}) - -[//]: # () - -[//]: # ( # print json on standard output) - -[//]: # ( print(json.dumps(data))) - -[//]: # (```) - -[//]: # () - -[//]: # (#### Validation Script) - -[//]: # () - -[//]: # (A validation script can be any kind of script. It can be a bash script, a python script, a terraform script, etc. The script must exit with) - -[//]: # (a non-zero exit code if the validation fails.) - -[//]: # () - -[//]: # (```bash) - -[//]: # (#!/bin/bash) - -[//]: # () - -[//]: # (set -e # exit on error) - -[//]: # (# print error on standard error output) - -[//]: # () - -[//]: # (# do your stuff here) - -[//]: # (exit 0) - -[//]: # (```) - -[//]: # () - -[//]: # (#### Post Validation Script) - -[//]: # () - -[//]: # (An post validation script can be any kind of script. It can be a bash script, a python script, a terraform script, etc.) - -[//]: # () - -[//]: # (- The script must exit with a non-zero exit code if the validation fails.) - -[//]: # (- The script must be idempotent. It can be executed multiple times without side effects.) - -[//]: # (- The output of the script must be a JSON that contains the defined model keys with their values. (Torii will update the model with) - -[//]: # ( the values returned by the script)) - -[//]: # () - -[//]: # (```json) - -[//]: # ({) - -[//]: # ( "status": "success",) - -[//]: # ( "url": "https://my-service.com",) - -[//]: # ( "username": "my-username",) - -[//]: # ( "password": "my-password") - -[//]: # (}) - -[//]: # (```) - -[//]: # () - -[//]: # (```bash) - -[//]: # (#!/bin/bash) - -[//]: # () - -[//]: # (set -e # exit on error) - -[//]: # (# print error on standard error output) - -[//]: # () - -[//]: # (# do your stuff here) - -[//]: # (exit 0) - -[//]: # (```) +[//]: # "### Advanced Configuration" +[//]: # +[//]: # "#### Autocomplete Fetcher" +[//]: # +[//]: # "An autocomplete fetcher is a script that must print a JSON on standard output. The JSON must contain a `results` key that contains a list of" +[//]: # "values." +[//]: # +[//]: # "```json" +[//]: # "{" +[//]: # ' "results": [' +[//]: # ' "val 1",' +[//]: # ' "val 2",' +[//]: # ' "val 3"' +[//]: # " ]" +[//]: # "}" +[//]: # "```" +[//]: # +[//]: # "Example of autocomplete fetcher in python:" +[//]: # +[//]: # "```python" +[//]: # "import json" +[//]: # +[//]: # +[//]: # "def get_data_from_fake_api():" +[//]: # " return [" +[//]: # " 'val 1'," +[//]: # " 'val 2'," +[//]: # " 'val 3'," +[//]: # " ]" +[//]: # +[//]: # +[//]: # "if __name__ == '__main__':" +[//]: # " # do your stuff here" +[//]: # " results = get_data_from_fake_api()" +[//]: # +[//]: # " data = {'results': results}" +[//]: # +[//]: # " # print json on standard output" +[//]: # " print(json.dumps(data))" +[//]: # "```" +[//]: # +[//]: # "#### Validation Script" +[//]: # +[//]: # "A validation script can be any kind of script. It can be a bash script, a python script, a terraform script, etc. The script must exit with" +[//]: # "a non-zero exit code if the validation fails." +[//]: # +[//]: # "```bash" +[//]: # "#!/bin/bash" +[//]: # +[//]: # "set -e # exit on error" +[//]: # "# print error on standard error output" +[//]: # +[//]: # "# do your stuff here" +[//]: # "exit 0" +[//]: # "```" +[//]: # +[//]: # "#### Post Validation Script" +[//]: # +[//]: # "An post validation script can be any kind of script. It can be a bash script, a python script, a terraform script, etc." +[//]: # +[//]: # "- The script must exit with a non-zero exit code if the validation fails." +[//]: # "- The script must be idempotent. It can be executed multiple times without side effects." +[//]: # "- The output of the script must be a JSON that contains the defined model keys with their values. (Torii will update the model with" +[//]: # " the values returned by the script)" +[//]: # +[//]: # "```json" +[//]: # "{" +[//]: # ' "status": "success",' +[//]: # ' "url": "https://my-service.com",' +[//]: # ' "username": "my-username",' +[//]: # ' "password": "my-password"' +[//]: # "}" +[//]: # "```" +[//]: # +[//]: # "```bash" +[//]: # "#!/bin/bash" +[//]: # +[//]: # "set -e # exit on error" +[//]: # "# print error on standard error output" +[//]: # +[//]: # "# do your stuff here" +[//]: # "exit 0" +[//]: # "```" ## Design @@ -347,7 +268,7 @@ Today you have the choice between three options to build your Internal Developer Torii is a simple, powerful, and extensible open-source Internal Developer Portal that aims to be the best of all worlds. It's easy to extend and customize, it's free, and you have control over the codebase. ---- +--- Curious to understand in more detail the motivation behind Torii? Read these articles: diff --git a/Tiltfile b/Tiltfile new file mode 100644 index 0000000..8ac8a97 --- /dev/null +++ b/Tiltfile @@ -0,0 +1 @@ +docker_compose("docker-compose.yaml") diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index a8d2bd7..630e729 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -3,6 +3,7 @@ FROM rust:1.79-bookworm WORKDIR /app RUN apt update && apt install -y libssl-dev python3 && \ - ln -s /usr/bin/python3 /usr/bin/python + ln -s /usr/bin/python3 /usr/bin/python & \ + cargo install cargo-watch COPY . . diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml deleted file mode 100644 index 1ad0a7c..0000000 --- a/docker-compose.dev.yaml +++ /dev/null @@ -1,50 +0,0 @@ -services: - frontend: - restart: always - build: - context: frontend - dockerfile: Dockerfile.dev - command: npx vite --host 0.0.0.0 --port 80 - ports: - - 8080:80 - volumes: - - $PWD/frontend:/app - - torii-frontend-node-modules:/app/node_modules - depends_on: - backend: - condition: service_started - - backend: - build: - context: backend - dockerfile: Dockerfile.dev - command: cargo run -- --config /app/examples/config.yaml - ports: - - '9999:9999' - volumes: - - $PWD/backend:/app - healthcheck: - test: [ "CMD", "curl", "-f", "http://localhost:9999" ] - environment: - - DB_CONNECTION_URL=postgresql://postgres:postgres@postgres:5432/torii - #depends_on: - # postgres: - # condition: service_healthy - - postgres: - image: postgres:16-alpine - environment: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: postgres - POSTGRES_DB: torii - ports: - - 5432:5432 - healthcheck: - test: [ "CMD-SHELL", "pg_isready -U postgres" ] - interval: 10s - timeout: 5s - retries: 5 - -volumes: - torii-frontend-node-modules: - \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml index 3810659..b38e688 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,12 +1,11 @@ services: frontend: - restart: always build: context: frontend dockerfile: Dockerfile.dev - command: npx vite --host 0.0.0.0 --port 80 + command: npx vite --host 0.0.0.0 --port 8080 ports: - - 8080:80 + - 8080:8080 volumes: - $PWD/frontend:/app - torii-frontend-node-modules:/app/node_modules @@ -15,16 +14,14 @@ services: condition: service_started backend: - build: - context: ./backend - dockerfile: Dockerfile - restart: no + build: + context: backend + dockerfile: Dockerfile.dev + command: cargo watch -x 'run -- --config examples/config.yaml' ports: - '9999:9999' volumes: - - type: bind - source: ./backend - target: /tmp + - $PWD/backend:/app healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:9999" ] environment: @@ -46,6 +43,7 @@ services: interval: 10s timeout: 5s retries: 5 + start_interval: 1s volumes: torii-frontend-node-modules: diff --git a/frontend/src/pages/self-service/run-history/service-runs-table.tsx b/frontend/src/pages/self-service/run-history/service-runs-table.tsx index d29657b..00b0e05 100644 --- a/frontend/src/pages/self-service/run-history/service-runs-table.tsx +++ b/frontend/src/pages/self-service/run-history/service-runs-table.tsx @@ -159,7 +159,7 @@ export default function ServiceRunsTable({ return (
-
; +
); } From c5c20b3de9431696e48f8681e5eae72c4f030b6d Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Thu, 25 Jul 2024 12:33:26 +0200 Subject: [PATCH 12/16] chore: self review --- backend/.dockerignore | 2 +- backend/src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/.dockerignore b/backend/.dockerignore index 2088f2d..cda49f0 100644 --- a/backend/.dockerignore +++ b/backend/.dockerignore @@ -7,4 +7,4 @@ # Sources !src -!examples \ No newline at end of file +!examples diff --git a/backend/src/main.rs b/backend/src/main.rs index 9112d9d..f6c5a9d 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -82,7 +82,7 @@ async fn main() { Ok(pool) => pool, Err(err) => { error!("failed to connect to database: {}", err); - std::process::exit(3); + std::process::exit(1); } }; From 806f88a898adaf474480806b4dac9f25b17ea12c Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Thu, 25 Jul 2024 12:34:56 +0200 Subject: [PATCH 13/16] chore: fix readme variable exemple --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a4deb8f..c400daf 100644 --- a/README.md +++ b/README.md @@ -138,16 +138,16 @@ self_service: - my-scripts/environment_management.py - create - --name - - { { name } } # this is a variable that will be replaced by the value of the field 'name' + - {{ name }} # this is a variable that will be replaced by the value of the field 'name' delayed_command: - command: - python - my-scripts/environment_management.py - delete - --name - - { { name } } + - {{ name }} delay: - hours: { { ttl } } # this is a variable that will be replaced by the value of the field 'ttl' + hours: {{ ttl }} # this is a variable that will be replaced by the value of the field 'ttl' ``` In this example, we define a self-service section with a single action called `new-testing-environment`. This action has four fields: From 461afb6d0833316903c8b1df6555a4176da1cc7b Mon Sep 17 00:00:00 2001 From: Tchoupinax Date: Thu, 25 Jul 2024 20:34:48 +0200 Subject: [PATCH 14/16] feat: display logs --- backend/Dockerfile.dev | 3 +- backend/examples/config.yaml | 12 +++ backend/examples/node_get_weather_paris.js | 12 +++ backend/src/database.rs | 80 +++++++++++++++++ backend/src/main.rs | 8 +- backend/src/self_service/controllers.rs | 50 +++++------ backend/src/self_service/mod.rs | 60 ++++++++++++- backend/src/self_service/services.rs | 16 +++- docker-compose.yaml | 1 + frontend/package-lock.json | 24 ++++++ frontend/package.json | 3 + frontend/src/components/Nav.tsx | 8 +- frontend/src/lib/utils.ts | 14 +-- .../catalog-list/service-card.tsx | 13 +-- .../pages/self-service/details/details.tsx | 85 +++++++++++++------ .../pages/self-service/details/xterm-addon.ts | 28 ++++++ .../helpers/get-total-execution-time.ts | 17 ++++ .../run-history/service-runs-table.tsx | 18 +--- frontend/tailwind.config.js | 8 +- 19 files changed, 362 insertions(+), 98 deletions(-) create mode 100644 backend/examples/node_get_weather_paris.js create mode 100644 frontend/src/pages/self-service/details/xterm-addon.ts create mode 100644 frontend/src/pages/self-service/helpers/get-total-execution-time.ts diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 630e729..d43add1 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -2,7 +2,8 @@ FROM rust:1.79-bookworm WORKDIR /app -RUN apt update && apt install -y libssl-dev python3 && \ +RUN apt update && \ + apt install -y libssl-dev python3 nodejs && \ ln -s /usr/bin/python3 /usr/bin/python & \ cargo install cargo-watch diff --git a/backend/examples/config.yaml b/backend/examples/config.yaml index 32b552c..d819fee 100644 --- a/backend/examples/config.yaml +++ b/backend/examples/config.yaml @@ -85,6 +85,18 @@ self_service: - bash - examples/dumb_script_ok.sh # AND then this one output_model: string (optional) # model name + - slug: get-temperature-paris + name: Get current temperature in Paris + description: Script to detect and return the current tempearture in celsius in Paris + icon: sun + icon_color: rose + fields: [] + post_validate: + - command: + - node + - examples/node_get_weather_paris.js + timeout: 5 + models: - name: string description: string (optional) diff --git a/backend/examples/node_get_weather_paris.js b/backend/examples/node_get_weather_paris.js new file mode 100644 index 0000000..6853dea --- /dev/null +++ b/backend/examples/node_get_weather_paris.js @@ -0,0 +1,12 @@ +console.log('Executing script node get weather Paris'); + +for(let i = 0; i < 20; i++) { + console.error(`Log error ${i}`); +} + +fetch('https://wttr.in/Paris?format=j1') + .then(res => res.json()) + .then(data => { + console.log(`Currently it's ${data.current_condition.at(0).temp_C}°C in Paris.`); + console.log('Finished!'); + }); diff --git a/backend/src/database.rs b/backend/src/database.rs index 0685f14..d76c846 100644 --- a/backend/src/database.rs +++ b/backend/src/database.rs @@ -3,6 +3,7 @@ use std::str::FromStr; use serde::{Deserialize, Serialize}; use sqlx::{Executor, Pool, Postgres}; use sqlx::types::Uuid; +use tracing::{debug}; use crate::errors::QError; @@ -37,6 +38,15 @@ CREATE TABLE IF NOT EXISTS self_service_runs CREATE INDEX IF NOT EXISTS self_service_runs_section_slug_idx ON self_service_runs (section_slug); CREATE INDEX IF NOT EXISTS self_service_runs_action_slug_idx ON self_service_runs (action_slug); + +-- create a table to store logs from self services runs +CREATE TABLE IF NOT EXISTS self_service_runs_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL, + self_service_runs_id UUID NOT NULL, + message TEXT NOT NULL, + is_stderr BOOLEAN NOT NULL +); "#; #[derive(sqlx::FromRow)] @@ -51,6 +61,15 @@ pub struct SelfServiceRun { tasks: serde_json::Value, } +#[derive(sqlx::FromRow)] +pub struct SelfServiceRunLog { + id: Uuid, + created_at: chrono::NaiveDateTime, + self_service_runs_id: Uuid, + message: String, + is_stderr: bool, +} + #[derive(sqlx::Type, Clone, Serialize, Deserialize, Debug)] #[sqlx(rename_all = "SCREAMING_SNAKE_CASE")] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] @@ -81,6 +100,22 @@ impl SelfServiceRun { } } +impl SelfServiceRunLog { + pub fn to_json(&self) -> SelfServiceRunLogJson { + SelfServiceRunLogJson { + id: self.id.to_string(), + created_at: self.created_at.to_string(), + self_service_runs_id: self.self_service_runs_id.to_string(), + is_stderr: self.is_stderr, + message: self.message.to_string(), + } + } + + pub fn id(&self) -> String { + self.id.to_string() + } +} + #[derive(Serialize, Deserialize, Debug)] pub struct SelfServiceRunJson { pub id: String, @@ -97,6 +132,7 @@ pub struct SelfServiceRunJson { pub struct SelfServiceRunLogJson { pub id: String, pub created_at: String, + pub self_service_runs_id: String, pub is_stderr: bool, pub message: String, } @@ -191,6 +227,8 @@ pub async fn insert_self_service_run( input_payload: &serde_json::Value, tasks: &serde_json::Value, ) -> Result { + debug!("Insert self service run with value {}", input_payload); + Ok( sqlx::query_as::<_, SelfServiceRun>( r#" @@ -209,6 +247,30 @@ pub async fn insert_self_service_run( ) } +pub async fn insert_self_service_run_log( + pg_pool: &Pool, + self_service_runs_id: &str, + message: String, + is_stderr: bool +) -> Result { + debug!("Insert self service run log {}", message); + + Ok( + sqlx::query_as::<_, SelfServiceRunLog>( + r#" + INSERT INTO self_service_runs_logs (self_service_runs_id, message, is_stderr) + VALUES ($1, $2, $3) + RETURNING * + "# + ) + .bind(Uuid::from_str(self_service_runs_id).unwrap()) + .bind(message) + .bind(is_stderr) + .fetch_one(pg_pool) + .await? + ) +} + pub async fn update_self_service_run( pg_pool: &Pool, id: &str, @@ -231,3 +293,21 @@ pub async fn update_self_service_run( .await? ) } + +pub async fn list_logs_by_self_service_run_id( + pg_pool: &Pool, + id: &str, +) -> Result, QError> { + Ok( + sqlx::query_as::<_, SelfServiceRunLog>( + r#" + SELECT * + FROM self_service_runs_logs + where self_service_runs_id = $1; + "# + ) + .bind(Uuid::from_str(id).unwrap()) + .fetch_all(pg_pool) + .await? + ) +} diff --git a/backend/src/main.rs b/backend/src/main.rs index 94f5963..784532f 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -110,13 +110,13 @@ async fn main() { .route("/healthz", get(|| async { "OK" })) .route("/selfServiceSections", get(list_self_service_sections)) .route("/selfServiceSections/:id", get(get_self_service_runs_by_id)) - .route("/selfServiceSections/runs", get(list_self_service_section_runs)) - .route("/selfServiceSectionsRuns/:slug/logs", get(list_self_service_section_run_logs)) .route("/selfServiceSections/:slug/actions", get(list_self_service_section_actions)) - .route("/selfServiceSections/:slug/runs", get(list_self_service_section_runs_by_section_slug)) - .route("/selfServiceSections/:slug/actions/:slug/validate", post(exec_self_service_section_action_validate_scripts)) .route("/selfServiceSections/:slug/actions/:slug/execute", post(exec_self_service_section_action_post_validate_scripts)) .route("/selfServiceSections/:slug/actions/:slug/runs", get(list_self_service_section_runs_by_section_and_action_slugs)) + .route("/selfServiceSections/:slug/actions/:slug/validate", post(exec_self_service_section_action_validate_scripts)) + .route("/selfServiceSections/:slug/runs", get(list_self_service_section_runs_by_section_slug)) + .route("/selfServiceSections/runs", get(list_self_service_section_runs)) + .route("/selfServiceSectionsRuns/:slug/logs", get(list_self_service_section_run_logs)) .layer(Extension(yaml_config)) .layer(Extension(tx)) .layer(Extension(pg_pool)) diff --git a/backend/src/self_service/controllers.rs b/backend/src/self_service/controllers.rs index e67308f..b29363d 100644 --- a/backend/src/self_service/controllers.rs +++ b/backend/src/self_service/controllers.rs @@ -4,11 +4,11 @@ use axum::{debug_handler, Extension, Json}; use axum::extract::Path; use axum::http::StatusCode; use tokio::sync::mpsc::Sender; -use tracing::error; +use tracing::{debug, error, info}; use crate::self_service::ResultResponse; use crate::database; -use crate::database::{insert_self_service_run, SelfServiceRunJson, SelfServiceRunLogJson, Status}; +use crate::database::{insert_self_service_run, SelfServiceRunJson, SelfServiceRunLogJson, Status,list_logs_by_self_service_run_id}; use crate::self_service::{check_json_payload_against_yaml_config_fields, execute_command, ExecValidateScriptRequest, find_self_service_section_by_slug, get_self_service_section_and_action, JobResponse, ResultsResponse}; use crate::self_service::services::BackgroundWorkerTask; use crate::yaml_config::{SelfServiceSectionActionYamlConfig, SelfServiceSectionYamlConfig, YamlConfig}; @@ -30,7 +30,7 @@ pub async fn get_self_service_runs_by_id( (StatusCode::OK, Json(ResultResponse { message: None, result: Some(self_service.to_json()) })) } Err(err) => { - error!("Failed to get self service2: {:?}", err); + error!("Failed to get self service: {:?}", err); (StatusCode::INTERNAL_SERVER_ERROR, Json(ResultResponse { message: Some(err.to_string()), result: None })) } } @@ -102,32 +102,30 @@ pub async fn list_self_service_section_runs( #[debug_handler] pub async fn list_self_service_section_run_logs( Extension(pg_pool): Extension>, - Path(logs_slug): Path, + Path(task_id): Path, ) -> (StatusCode, Json>) { - // mock data for now - let mut data = vec![]; - - for i in 1..1000 { - data.push(SelfServiceRunLogJson { - id: i.to_string(), - created_at: "2021-08-01T00:00:00Z".to_string(), - is_stderr: i % 2 == 0, - message: if i % 2 == 0 { format!("this is an error message {}", i) } else { format!("this is a log message {}", i) }, - }); - } + info!("List logs from self service run id={}", task_id); - (StatusCode::OK, Json(ResultsResponse { - message: None, - results: data, - })) + match list_logs_by_self_service_run_id(&pg_pool, &task_id).await { + Ok(logs) => { + (StatusCode::OK, Json(ResultsResponse { message: None, results: logs.iter().map(|x| x.to_json()).collect() })) + }, + Err(err) => { + error!("failed to list action execution statuses: {:?}", err); + (StatusCode::INTERNAL_SERVER_ERROR, Json(ResultsResponse { message: Some(err.to_string()), results: vec![] })) + } + } } #[debug_handler] pub async fn exec_self_service_section_action_validate_scripts( Extension(yaml_config): Extension>, Path((section_slug, action_slug)): Path<(String, String)>, + Extension(pg_pool): Extension>, Json(req): Json, ) -> (StatusCode, Json) { + debug!("validate"); + let _ = match check_json_payload_against_yaml_config_fields( section_slug.as_str(), action_slug.as_str(), @@ -146,7 +144,7 @@ pub async fn exec_self_service_section_action_validate_scripts( }; for cmd in action.validate.as_ref().unwrap_or(&vec![]) { - let _ = match execute_command(cmd, req.payload.to_string().as_str()).await { + let _ = match execute_command(pg_pool.to_owned(), cmd, req.payload.to_string().as_str(), "uuid").await { Ok(_) => (), Err(err) => return (StatusCode::BAD_REQUEST, Json(JobResponse { message: Some(err), @@ -165,6 +163,8 @@ pub async fn exec_self_service_section_action_post_validate_scripts( Path((section_slug, action_slug)): Path<(String, String)>, Json(req): Json, ) -> (StatusCode, Json) { + info!("Self service execute"); + let _ = match check_json_payload_against_yaml_config_fields( section_slug.as_str(), action_slug.as_str(), @@ -182,7 +182,7 @@ pub async fn exec_self_service_section_action_post_validate_scripts( Err(err) => return err }; - let ces = match insert_self_service_run( + let self_service_run = match insert_self_service_run( &pg_pool, §ion_slug, &action_slug, @@ -190,15 +190,17 @@ pub async fn exec_self_service_section_action_post_validate_scripts( &req.payload, &serde_json::Value::Array(vec![]), ).await { - Ok(ces) => ces, + Ok(self_service_run) => self_service_run, Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(JobResponse { message: Some(err.to_string()), })) }; + info!("Insertion OK - {}", self_service_run.id()); + // execute post validate scripts let _ = tx.send(BackgroundWorkerTask::new( - ces.id(), + self_service_run.id(), service.clone(), req, )).await.unwrap_or_else(|err| { @@ -206,7 +208,7 @@ pub async fn exec_self_service_section_action_post_validate_scripts( // TODO change catalog execution status to Failure }); - (StatusCode::NO_CONTENT, Json(JobResponse { message: Some("workflow executed".to_string()) })) + (StatusCode::CREATED, Json(JobResponse { message: Some("workflow executed".to_string()) })) } #[cfg(test)] diff --git a/backend/src/self_service/mod.rs b/backend/src/self_service/mod.rs index 659b08c..223e393 100644 --- a/backend/src/self_service/mod.rs +++ b/backend/src/self_service/mod.rs @@ -2,12 +2,21 @@ use std::time::Duration; use axum::http::StatusCode; use axum::Json; +use std::sync::Arc; use serde::{Deserialize, Serialize}; -use tokio::process; +use tokio::{process}; use tokio::time::timeout; use tracing::debug; +use tokio::io::AsyncBufReadExt; +use tokio::io::BufReader; +use tracing::error; +use axum::{debug_handler, Extension}; +use std::process::{Stdio}; use crate::yaml_config::{ExternalCommand, SelfServiceSectionActionYamlConfig, SelfServiceSectionYamlConfig, YamlConfig}; +use crate::database; +use crate::database::{insert_self_service_run_log, SelfServiceRunLog}; + pub mod controllers; pub mod services; @@ -106,8 +115,10 @@ fn check_json_payload_against_yaml_config_fields( } async fn execute_command( + pg_pool: Arc, external_command: &T, json_payload: &str, + task_id: &str, ) -> Result where T: ExternalCommand { let cmd_one_line = external_command.get_command().join(" "); @@ -120,6 +131,8 @@ async fn execute_command( } let mut cmd = process::Command::new(&external_command.get_command()[0]); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); for arg in external_command.get_command()[1..].iter() { cmd.arg(arg); @@ -135,7 +148,50 @@ async fn execute_command( Err(err) => return Err(format!("Validate script '{}' failed: {}", &cmd_one_line, err)) }; - // get stdout and stderr from child and forward it in real time to the upper function + let stdout = child.stdout.take().expect("child did not have a handle to stdout"); + let stderr = child.stderr.take().expect("child did not have a handle to stderr"); + + let mut stdout_reader = BufReader::new(stdout).lines(); + let mut stderr_reader = BufReader::new(stderr).lines(); + + loop { + tokio::select! { + result = stdout_reader.next_line() => { + match result { + Ok(Some(line)) => { + match insert_self_service_run_log(&pg_pool, task_id, line.clone(), false).await { + Ok(x) => x, + Err(_err) => break, + }; + println!("Stdout: {}", line.clone()) + }, + Err(_) => break, + _ => (), + } + } + result = stderr_reader.next_line() => { + match result { + Ok(Some(line)) => { + match insert_self_service_run_log(&pg_pool, task_id, line.clone(), true).await { + Ok(x) => x, + Err(_err) => break, + }; + println!("Stderr: {}", line.clone()) + }, + Err(_) => break, + _ => (), + } + } + result = child.wait() => { + match result { + Ok(exit_code) => println!("33 Child process exited with {}", exit_code), + _ => (), + } + break + } + }; + + } let exit_status = match timeout(Duration::from_secs(external_command.get_timeout()), child.wait()).await { Ok(exit_status) => exit_status, diff --git a/backend/src/self_service/services.rs b/backend/src/self_service/services.rs index 9303833..99596b3 100644 --- a/backend/src/self_service/services.rs +++ b/backend/src/self_service/services.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; use sqlx::{Pool, Postgres}; use tokio::sync::mpsc::Receiver; -use tracing::error; +use tracing::{debug, error, info}; use crate::database::{Status, update_self_service_run}; use crate::self_service::{execute_command, ExecValidateScriptRequest, JobOutputResult}; @@ -22,6 +22,7 @@ impl BackgroundWorkerTask { self_service_section_action_yaml_config: SelfServiceSectionActionYamlConfig, req: ExecValidateScriptRequest, ) -> Self { + debug!("BackgroundWorkerTask instantied"); Self { execution_status_id, self_service_section_action_yaml_config, @@ -40,6 +41,8 @@ pub struct TaskPayload { pub async fn background_worker(mut rx: Receiver, pg_pool: Arc>) { while let Some(task) = rx.recv().await { + info!("task id {}", task.execution_status_id.as_str()); + let r = update_self_service_run( &pg_pool, task.execution_status_id.as_str(), @@ -47,17 +50,24 @@ pub async fn background_worker(mut rx: Receiver, pg_pool: &serde_json::Value::Array(vec![]), ).await; + info!("{}", "2"); + + if let Err(err) = r { error!("failed to update action execution status: {}", err); continue; } + info!("{}", "3"); + let mut tasks = Vec::::new(); + info!("{}", "4"); let mut last_task_value = serde_json::Value::Array(vec![]); - + info!("{}", "5"); for cmd in task.self_service_section_action_yaml_config.post_validate.as_ref().unwrap_or(&vec![]) { - let job_output_result = match execute_command(cmd, task.req.payload.to_string().as_str()).await { + info!("{}", cmd); + let job_output_result = match execute_command(pg_pool.clone(), cmd, task.req.payload.to_string().as_str(), task.execution_status_id.as_str()).await { Ok(job_output_result) => job_output_result, Err(err) => { let task_payload = TaskPayload { diff --git a/docker-compose.yaml b/docker-compose.yaml index b38e688..95fbb98 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -26,6 +26,7 @@ services: test: [ "CMD", "curl", "-f", "http://localhost:9999" ] environment: - DB_CONNECTION_URL=postgresql://postgres:postgres@postgres:5432/torii + - RUST_LOG=4 depends_on: postgres: condition: service_healthy diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c2036d6..4f6628e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,6 +17,8 @@ "@tanstack/react-query": "^5.13.4", "@tanstack/react-query-devtools": "^5.29.0", "@tanstack/react-table": "^8.16.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "dayjs": "^1.11.10", @@ -30,6 +32,7 @@ "react-error-boundary": "^4.0.13", "react-hook-form": "^7.51.2", "react-router-dom": "^6.20.1", + "react-xtermjs": "^1.0.8", "sort-by": "^1.2.0", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", @@ -2048,6 +2051,21 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.11.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz", @@ -4502,6 +4520,12 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-xtermjs": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/react-xtermjs/-/react-xtermjs-1.0.8.tgz", + "integrity": "sha512-UbgXkAUuZrvg4rUZwdfJSBU8U4iK80mMuO5Uo0cJTP4NPorF4QNqZJ+43kx8aFVMnlOR/B1Dkpnw/NckZ4hEGA==", + "license": "ISC" + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7cf9c37..2766df9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,8 @@ "@tanstack/react-query": "^5.13.4", "@tanstack/react-query-devtools": "^5.29.0", "@tanstack/react-table": "^8.16.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0", "class-variance-authority": "^0.7.0", "clsx": "^2.0.0", "dayjs": "^1.11.10", @@ -32,6 +34,7 @@ "react-error-boundary": "^4.0.13", "react-hook-form": "^7.51.2", "react-router-dom": "^6.20.1", + "react-xtermjs": "^1.0.8", "sort-by": "^1.2.0", "tailwind-merge": "^2.1.0", "tailwindcss-animate": "^1.0.7", diff --git a/frontend/src/components/Nav.tsx b/frontend/src/components/Nav.tsx index a75661a..10cdbcc 100644 --- a/frontend/src/components/Nav.tsx +++ b/frontend/src/components/Nav.tsx @@ -39,9 +39,9 @@ export function MobileNav({ routes, userMenu }: MobileNavProps) {
-
+
@@ -56,11 +56,11 @@ export function MobileNav({ routes, userMenu }: MobileNavProps) {
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 3cf64a8..36a21bc 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -10,14 +10,14 @@ export function classNames(...classes: any[]): string { } export function millisToHumanTime(duration: number): string { - let milliseconds = Math.floor((duration % 1000) / 100); - let seconds = Math.floor((duration / 1000) % 60); - let minutes = Math.floor((duration / (1000 * 60)) % 60); - let hours = Math.floor((duration / (1000 * 60 * 60)) % 24); + const milliseconds = Math.floor((duration % 1000) / 100); + const seconds = Math.floor((duration / 1000) % 60); + const minutes = Math.floor((duration / (1000 * 60)) % 60); + const hours = Math.floor((duration / (1000 * 60 * 60)) % 24); - let s_hours = hours < 10 ? "0" + hours : hours; - let s_minutes = minutes < 10 ? "0" + minutes : minutes; - let s_seconds = seconds < 10 ? "0" + seconds : seconds; + const s_hours = hours < 10 ? "0" + hours : hours; + const s_minutes = minutes < 10 ? "0" + minutes : minutes; + const s_seconds = seconds < 10 ? "0" + seconds : seconds; return s_hours + ":" + s_minutes + ":" + s_seconds + "." + milliseconds; } diff --git a/frontend/src/pages/self-service/catalog-list/service-card.tsx b/frontend/src/pages/self-service/catalog-list/service-card.tsx index 72e92d9..9a6988f 100644 --- a/frontend/src/pages/self-service/catalog-list/service-card.tsx +++ b/frontend/src/pages/self-service/catalog-list/service-card.tsx @@ -4,6 +4,7 @@ import { Menu, Transition } from "@headlessui/react"; import { EllipsisHorizontalIcon, PlusIcon, + SunIcon, TrashIcon, } from "@heroicons/react/24/outline"; import clsx from "clsx"; @@ -24,18 +25,20 @@ export default function ServiceCard({ const getIcon = useCallback((icon: string) => { switch (icon?.toLowerCase()) { case "target": - return