diff --git a/.gitignore b/.gitignore index f4c03c999..92f85112c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ target /flamegraph*.svg /perf.data* + +static/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index f9d1e96d8..62b13a339 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,29 @@ dependencies = [ "smallvec", ] +[[package]] +name = "actix-files" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0bdd6ff79de7c9a021f5d9ea79ce23e108d8bfc9b49b5b4a2cf6fad5a35212" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags 2.4.2", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + [[package]] name = "actix-http" version = "3.6.0" @@ -859,6 +882,16 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bstr" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "buffered-reader" version = "1.3.0" @@ -994,6 +1027,28 @@ dependencies = [ "windows-targets 0.52.4", ] +[[package]] +name = "chrono-tz" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + [[package]] name = "clap" version = "4.5.2" @@ -1475,6 +1530,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "deunicode" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6e854126756c496b8c81dec88f9a706b15b875c5849d4097a3854476b9fdf94" + [[package]] name = "digest" version = "0.10.7" @@ -1962,6 +2023,30 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata 0.4.6", + "regex-syntax 0.8.2", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "gloo-timers" version = "0.2.6" @@ -2114,6 +2199,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.8.0" @@ -2144,6 +2235,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91f255a4535024abf7640cb288260811fc14794f62b063652ed349f9a6c2348e" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "humantime" version = "2.1.0" @@ -2278,6 +2378,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" +[[package]] +name = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata 0.4.6", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "impl-more" version = "0.1.6" @@ -3092,6 +3208,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + [[package]] name = "paste" version = "1.0.14" @@ -3178,6 +3303,35 @@ dependencies = [ "indexmap 2.2.5", ] +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator", + "phf_shared 0.11.2", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared 0.11.2", + "rand", +] + [[package]] name = "phf_shared" version = "0.10.0" @@ -3187,6 +3341,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.5" @@ -4496,6 +4659,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "slug" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bd94acec9c8da640005f8e135a39fc0372e74535e6b368b7a04b875f784c8c4" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + [[package]] name = "smallvec" version = "1.13.1" @@ -4842,7 +5015,7 @@ dependencies = [ "new_debug_unreachable", "once_cell", "parking_lot 0.12.1", - "phf_shared", + "phf_shared 0.10.0", "precomputed-hash", ] @@ -5011,6 +5184,28 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "tera" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand", + "regex", + "serde", + "serde_json", + "slug", + "unic-segment", +] + [[package]] name = "term" version = "0.7.0" @@ -5751,6 +5946,7 @@ dependencies = [ name = "trustify-server" version = "0.1.0" dependencies = [ + "actix-files", "actix-web", "anyhow", "clap", @@ -5772,6 +5968,7 @@ dependencies = [ "trustify-module-ingestor", "trustify-module-search", "trustify-module-storage", + "trustify-ui", "url", "url-escape", "utoipa", @@ -5797,6 +5994,16 @@ dependencies = [ "trustify-server", ] +[[package]] +name = "trustify-ui" +version = "0.1.0" +dependencies = [ + "base64 0.22.0", + "serde", + "serde_json", + "tera", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5815,6 +6022,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" +dependencies = [ + "unic-ucd-segment", +] + +[[package]] +name = "unic-ucd-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicase" version = "2.7.0" @@ -5978,6 +6235,12 @@ dependencies = [ "serde", ] +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + [[package]] name = "validator" version = "0.16.1" diff --git a/Cargo.toml b/Cargo.toml index f759be274..92762a3ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "common", "common/auth", "common/infrastructure", + "common/ui", "cvss", "modules/graph", "modules/importer", @@ -15,6 +16,7 @@ members = [ "migration", "server", ] +default-members = ["trustd"] [workspace.dependencies] actix-cors = "0.7" @@ -109,6 +111,7 @@ trustify-module-importer = { path = "modules/importer" } trustify-module-search = { path = "modules/search" } trustify-module-storage = { path = "modules/storage" } trustify-infrastructure = { path = "common/infrastructure" } +trustify-ui = { path = "common/ui" } [patch.crates-io] csaf-walker = { git = "https://github.com/ctron/csaf-walker", rev = "7b6e64dd56e4be79e184b053ef754a42e1496fe0" } diff --git a/common/ui/Cargo.toml b/common/ui/Cargo.toml new file mode 100644 index 000000000..e2a8d73bb --- /dev/null +++ b/common/ui/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "trustify-ui" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true, features = ["raw_value"] } +tera = "1.19.1" +base64 = { workspace = true } \ No newline at end of file diff --git a/common/ui/src/lib.rs b/common/ui/src/lib.rs new file mode 100644 index 000000000..79b1f4bf3 --- /dev/null +++ b/common/ui/src/lib.rs @@ -0,0 +1,60 @@ +use std::fs; + +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use serde::Serialize; +use serde_json::Value; + +static STATIC_DIR: &str = "./static"; + +#[derive(Serialize, Clone, Default)] +pub struct UI { + #[serde(rename(serialize = "VERSION"))] + pub version: String, + + #[serde(rename(serialize = "AUTH_REQUIRED"))] + pub auth_required: String, + + #[serde(rename(serialize = "OIDC_SERVER_URL"))] + pub oidc_server_url: String, + + #[serde(rename(serialize = "OIDC_CLIENT_ID"))] + pub oidc_client_id: String, + + #[serde(rename(serialize = "OIDC_SCOPE"))] + pub oidc_scope: String, + + #[serde(rename(serialize = "ANALYTICS_ENABLED"))] + pub analytics_enabled: String, + + #[serde(rename(serialize = "ANALYTICS_WRITE_KEY"))] + pub analytics_write_key: String, +} + +pub fn generate_index_html(ui: &UI) -> tera::Result { + let template_file = fs::read_to_string(format!("{STATIC_DIR}/{}", "index.html.ejs"))?; + let template = template_file + .replace("<%=", "{{") + .replace("%>", "}}") + .replace( + "?? branding.application.title", + "| default(value=branding.application.title)", + ) + .replace( + "?? branding.application.title", + "| default(value=branding.application.title)", + ); + + let env_json = serde_json::to_string(&ui).expect("Could not create JSON from ENV"); + let env_base64 = BASE64_STANDARD.encode(env_json.as_bytes()); + + let branding_file_content = + fs::read_to_string(format!("{STATIC_DIR}/{}", "branding/strings.json")).unwrap(); + let branding: Value = serde_json::from_str(&branding_file_content).unwrap(); + + let mut context = tera::Context::new(); + context.insert("_env", &env_base64); + context.insert("branding", &branding); + + tera::Tera::one_off(&template, &context, true) +} diff --git a/server/Cargo.toml b/server/Cargo.toml index b122a2a62..e8af1b136 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -8,11 +8,13 @@ trustify-auth = { workspace = true } trustify-common = { workspace = true } trustify-infrastructure = { workspace = true } trustify-entity = { workspace = true } +trustify-ui = { workspace = true } trustify-module-graph = { workspace = true } trustify-module-ingestor = { workspace = true } trustify-module-importer = { workspace = true } trustify-module-search = { workspace = true } trustify-module-storage = { workspace = true } +actix-files = "0.6.5" actix-web = { workspace = true } anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } diff --git a/server/src/lib.rs b/server/src/lib.rs index 0338f2e18..1f075f60d 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -2,6 +2,7 @@ mod openapi; +use actix_web::dev::{ServiceRequest, ServiceResponse}; use actix_web::{ body::MessageBody, dev::{ConnectionInfo, Url}, @@ -39,6 +40,7 @@ use trustify_module_graph::graph::Graph; use trustify_module_importer::server::importer; use trustify_module_storage::service::dispatch::DispatchBackend; use trustify_module_storage::service::fs::FileSystemBackend; +use trustify_ui::{generate_index_html, UI}; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; @@ -169,7 +171,6 @@ impl InitData { .default_authenticator(self.authenticator) .authorizer(self.authorizer.clone()) .configure(move |svc| { - svc.service(index); svc.service(swagger_ui_with_auth( openapi::openapi(), swagger_oidc.clone(), @@ -185,6 +186,35 @@ impl InitData { ); trustify_module_search::endpoints::configure(svc, db.clone()); }); + + svc.service( + actix_files::Files::new("/", "./static") + .index_file("index.html") + .default_handler(|req: ServiceRequest| { + let (http_req, _payload) = req.into_parts(); + async { + // TODO Set these values with ENV or Config values + let ui = UI { + version: String::from("99.0.0"), + auth_required: String::from("false"), + oidc_server_url: String::from( + "http://localhost:8180/realms/trustify", + ), + oidc_client_id: String::from("trustify-ui"), + oidc_scope: String::from("email"), + analytics_enabled: String::from("false"), + analytics_write_key: String::from(""), + }; + + let index_html = generate_index_html(&ui).unwrap(); + let http_response = HttpResponse::Ok() + .content_type(actix_web::http::header::ContentType::html()) + .body(index_html); + + Ok(ServiceResponse::new(http_req, http_response)) + } + }), + ); }) }; diff --git a/trustd/build.rs b/trustd/build.rs new file mode 100644 index 000000000..34a31cd01 --- /dev/null +++ b/trustd/build.rs @@ -0,0 +1,59 @@ +use std::path::Path; +use std::process::Command; +use std::{fs, io}; + +static UI_DIR: &str = "../ui"; +static UI_DIR_SRC: &str = "../ui/src"; +static UI_DIST_DIR: &str = "../ui/client/dist"; +static STATIC_DIR: &str = "../static"; + +#[cfg(windows)] +static NPM_CMD: &str = "npm.cmd"; +#[cfg(not(windows))] +static NPM_CMD: &str = "npm"; + +fn main() { + println!("Build Trustify - build.rs!"); + + println!("cargo:rerun-if-changed={}", UI_DIR_SRC); + + install_ui_deps(); + build_ui(); + copy_dir_all(UI_DIST_DIR, STATIC_DIR).unwrap(); +} + +fn install_ui_deps() { + if !Path::new("./ui/node_modules").exists() { + println!("Installing node dependencies..."); + Command::new(NPM_CMD) + .args(["clean-install", "--ignore-scripts"]) + .current_dir(UI_DIR) + .status() + .unwrap(); + } +} + +fn build_ui() { + if !Path::new(STATIC_DIR).exists() { + println!("Building UI..."); + Command::new(NPM_CMD) + .args(["run", "build"]) + .current_dir(UI_DIR) + .status() + .unwrap(); + } +} + +fn copy_dir_all(src: impl AsRef, dst: impl AsRef) -> io::Result<()> { + fs::create_dir_all(&dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_dir() { + copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?; + } else { + fs::copy(entry.path(), dst.as_ref().join(entry.file_name()))?; + } + } + Ok(()) +} diff --git a/ui/.containerignore b/ui/.containerignore new file mode 100644 index 000000000..a6f610eee --- /dev/null +++ b/ui/.containerignore @@ -0,0 +1,2 @@ +node_modules +*/dist/ diff --git a/ui/.editorconfig b/ui/.editorconfig new file mode 100644 index 000000000..8bb29e3b2 --- /dev/null +++ b/ui/.editorconfig @@ -0,0 +1,13 @@ +root=true + +[*] +# standard prettier behaviors +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +# Configurable prettier behaviors +end_of_line = lf +indent_style = space +indent_size = 2 +max_line_length = 80 diff --git a/ui/.eslintrc.cjs b/ui/.eslintrc.cjs new file mode 100644 index 000000000..c3a69ba4a --- /dev/null +++ b/ui/.eslintrc.cjs @@ -0,0 +1,94 @@ +/* eslint-env node */ + +module.exports = { + root: true, + + env: { + browser: true, + es2020: true, + jest: true, + }, + + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2020, // keep in sync with tsconfig.json + sourceType: "module", + }, + + // eslint-disable-next-line prettier/prettier + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "prettier", + ], + + // eslint-disable-next-line prettier/prettier + plugins: [ + "prettier", + "unused-imports", // or eslint-plugin-import? + "@typescript-eslint", + "react", + "react-hooks", + "@tanstack/query", + ], + + // NOTE: Tweak the rules as needed when bulk fixes get merged + rules: { + // TODO: set to "error" when prettier v2 to v3 style changes are fixed + "prettier/prettier": ["warn"], + + // TODO: set to "error" when all resolved, but keep the `argsIgnorePattern` + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + + // TODO: each one of these can be removed or set to "error" when they're all resolved + "unused-imports/no-unused-imports": ["warn"], + "@typescript-eslint/ban-types": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "react/jsx-key": "warn", + "react-hooks/rules-of-hooks": "warn", + "react-hooks/exhaustive-deps": "warn", + "no-extra-boolean-cast": "warn", + "prefer-const": "warn", + + // Allow the "cy-data" property for trustification-ui-test (but should really be "data-cy" w/o this rule) + "react/no-unknown-property": ["error", { ignore: ["cy-data"] }], + + "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/prefer-query-object-syntax": "error", + }, + + settings: { + react: { version: "detect" }, + }, + + ignorePatterns: [ + // don't ignore dot files so config files get linted + "!.*.js", + "!.*.cjs", + "!.*.mjs", + + // take the place of `.eslintignore` + "dist/", + "generated/", + "node_modules/", + ], + + // this is a hack to make sure eslint will look at all of the file extensions we + // care about without having to put it on the command line + overrides: [ + { + files: [ + "**/*.js", + "**/*.jsx", + "**/*.cjs", + "**/*.mjs", + "**/*.ts", + "**/*.tsx", + ], + }, + ], +}; diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 000000000..5089c820f --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,30 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules/ +/.pnp +.pnp.js + +# testing +coverage/ + +# production +dist/ +/qa/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* + +.eslintcache + +# VSCode +.vscode/* + +# Intellij IDEA +.idea/ diff --git a/ui/.npmrc b/ui/.npmrc new file mode 100644 index 000000000..d8c5ebffa --- /dev/null +++ b/ui/.npmrc @@ -0,0 +1,2 @@ +engine-strict=true +fetch-timeout=60000 diff --git a/ui/.prettierignore b/ui/.prettierignore new file mode 100644 index 000000000..ccd6c3041 --- /dev/null +++ b/ui/.prettierignore @@ -0,0 +1,12 @@ +# Library, IDE and build locations +**/node_modules/ +**/coverage/ +**/dist/ +.vscode/ +.idea/ +.eslintcache/ + +# +# NOTE: Could ignore anything that eslint will look at since eslint also applies +# prettier. +# diff --git a/ui/.prettierrc.mjs b/ui/.prettierrc.mjs new file mode 100644 index 000000000..c4d9a638a --- /dev/null +++ b/ui/.prettierrc.mjs @@ -0,0 +1,14 @@ +/** @type {import("prettier").Config} */ +const config = { + trailingComma: "es5", // es5 was the default in prettier v2 + semi: true, + singleQuote: false, + + // Values used from .editorconfig: + // - printWidth == max_line_length + // - tabWidth == indent_size + // - useTabs == indent_style + // - endOfLine == end_of_line +}; + +export default config; diff --git a/ui/BRANDING.md b/ui/BRANDING.md new file mode 100644 index 000000000..39fb1e869 --- /dev/null +++ b/ui/BRANDING.md @@ -0,0 +1,151 @@ +# Branding + +The UI supports static branding at build time. Dynamically switching brands is not +possible with the current implementation. + +## Summary + +Each of the project modules need to do some branding enablement. + +- `@trustify-ui/common` pulls in the branding assets and packages the configuration, + strings and assets within the common package. The other modules pull branding + from the common module. + +- `@trustify-ui/client` uses branding from the common package: + + - The location of `favicon.ico`, `manifest.json` and any other branding + assets that may be referenced in the `brandingStrings` are sourced from the + common package. + + - The `brandingStrings` are used by the dev-server runtime, to fill out the + `index.html` template. + + - The about modal and application masthead components use the branding strings + provided by the common module to display brand appropriate logos, titles and + about information. Since the common module provides all the information, it + is packaged directly into the app at build time. + +- `@trustify-ui/server` uses the `brandingStrings` from the common package to fill + out the `index.html` template. + +## Providing alternate branding + +To provide an alternate branding to the build, specify the path to the branding assets +with the `BRANDING` environment variable. Relative paths in `BRANDING` are computed +from the project source root. + +Each brand requires the presence of at least the following files: + +- `strings.json` +- `favicon.ico` +- `manifest.json` + +With a file path of `/alt/custom-branding`, a build that uses an alternate branding +is run as: + +```sh +> BRANDING=/alt/custom-branding npm run build +``` + +The dev server can also be run this way. Since file watching of the branding assets +is not implemented in the common module's build watch mode, it may be necessary to +manually build the common module before running the dev server. When working on a +brand, it is useful to run the dev server like this: + +```sh +> export BRANDING=/alt/custom-branding +> npm run build -w common +> npm run start:dev +> unset BRANDING # when you don't want to use the custom branding path anymore +``` + +### File details + +#### strings.json + +The expected shape of `strings.json` is defined in [branding.ts](./common/src/branding.ts). + +The default version of the file is [branding/strings.json](./branding/strings.json). + +A minimal viable example of the file is: + +```json +{ + "application": { + "title": "Konveyor" + }, + "about": { + "displayName": "Konveyor" + }, + "masthead": {} +} +``` + +At build time, the json file is processed as an [ejs](https://ejs.co/) template. The +variable `brandingRoot` is provided as the relative root of the branding +assets within the build of the common module. Consider the location of `strings.json` +in your branding directory as the base `brandingRoot` when creating a new brand. + +For example, to properly reference a logo within this branding structure: + +``` + special-brand/ + images/ + masthead-logo.svg + about-logo.svg + strings.json +``` + +Use a url string like this: + +```json +{ + "about": { + "imageSrc": "<%= brandingRoot %>/images/about-logo.svg" + } +} +``` + +and in the output of `BRANDING=special-brand npm run build -w common`, the `imageSrc` +will be `branding/images/about-logo.svg` with all of the files in `special-branding/*` +copied to and available to the client and server modules from +`@trustify-ui/common/branding/*`. + +#### favicon.ico + +A standard favorite icon file `favicon.ico` is required to be in the same directory +as `strings.json` + +#### manifest.json + +A standard [web app manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) +file `manifest.json` is required to be in the same directory as `strings.json`. + +## Technical details + +All branding strings and assets are pulled in to the common module. The client and +server modules access the branding from the common module build. + +The `common` module relies on rollup packaging to embed all of the brand for easy +use. The use of branding strings in `client` and `server` modules is straight forward. +Pulling in `strings.json` and providing the base path to the brand assets is a +more complicated. + +The `common` module provides the `brandingAssetPath()` function to let the build time +code find the root path to all brand assets. Webpack configuration files use this +function to source the favicon.ico, manifest.json and other brand assets to be copied +to the application bundle. + +The `brandingStrings` is typed and sourced from a json file. To pass typescript builds, +a stub json file needs to be available at transpile time. By using a typescript paths +of `@branding/strings.json`, the stub json is found at transpile time. The generated +javascript will still import the path alias. The +[virtual rollup plugin](https://github.com/rollup/plugins/tree/master/packages/virtual) +further transform the javascript output by replacing the `@branding/strings.json` import +with a dynamically built module containing the contents of the brand's `strings.json`. +The brand json becomes a virtual module embedded in the common module. + +A build for a custom brand will fail (1) if the expected files cannot be read, or (2) +if `strings.json` is not a valid JSON file. **Note:** The context of `stings.json` is +not currently validated. If something is missing or a url is malformed, it will only +be visible as a runtime error. diff --git a/ui/Containerfile b/ui/Containerfile new file mode 100644 index 000000000..253d0f3ac --- /dev/null +++ b/ui/Containerfile @@ -0,0 +1,38 @@ +# Builder image +FROM registry.access.redhat.com/ubi9/nodejs-20:latest as builder + +USER 1001 +COPY --chown=1001 . . +RUN npm clean-install --ignore-scripts && npm run build && npm run dist + +# Runner image +FROM registry.access.redhat.com/ubi9/nodejs-20-minimal:latest + +# Add ps package to allow liveness probe for k8s cluster +# Add tar package to allow copying files with kubectl scp +USER 0 +RUN microdnf -y install tar procps-ng && microdnf clean all + +USER 1001 + +LABEL name="trustify/trustify-ui" \ + description="Trustify - User Interface" \ + help="For more information visit https://trustification.github.io/" \ + license="Apache License 2.0" \ + maintainer="carlosthe19916@gmail.com" \ + summary="Trustify - User Interface" \ + url="https://ghcr.io/trustification/trustify-ui" \ + usage="podman run -p 80 -v trustification/trustify-ui:latest" \ + io.k8s.display-name="trustify-ui" \ + io.k8s.description="Trustify - User Interface" \ + io.openshift.expose-services="80:http" \ + io.openshift.tags="operator,trustification,trustify,ui,nodejs20" \ + io.openshift.min-cpu="100m" \ + io.openshift.min-memory="350Mi" + +COPY --from=builder /opt/app-root/src/dist /opt/app-root/dist/ + +ENV DEBUG=1 + +WORKDIR /opt/app-root/dist +ENTRYPOINT ["./entrypoint.sh"] diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 000000000..7a3737997 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,65 @@ +# frontend + +## dev-env + +### Install node and npm + +Use [nvm](https://github.com/nvm-sh/nvm?tab=readme-ov-file#install--update-script) +that installs both node and npm. + +Then + +```shell +nvm install node +node --version +npm --version +``` + +### Install dependencies: + +```shell +npm clean-install --ignore-scripts +``` + +### Init the dev server: + +```shell +npm run start:dev +``` + +> Known issue: after installing the dependencies for the first time and then executing `npm run start:dev` you will see +> an error +> `config/webpack.dev.ts(18,8): error TS2307: Cannot find module '@trustify-ui/common' or its corresponding type declarations` +> Stop the comand with Ctrl+C and run the command `npm run start:dev` again and the error should be gone. This only +> happens the very first time we install dependencies in a clean environment, subsequent commands `npm run start:dev` +> should not give that error. (bug under investigation) + +Open browser at + +## Environment variables + +| ENV VAR | Description | Defaul value | +| ---------------------- | ----------------------------- | ------------------------------------ | +| TRUSTIFICATION_API_URL | Set Trustification API URL | http://localhost:8080 | +| AUTH_REQUIRED | Enable/Disable authentication | false | +| OIDC_CLIENT_ID | Set Oidc Client | frontend | +| OIDC_SERVER_URL | Set Oidc Server URL | http://localhost:8090/realms/chicken | +| OIDC_SCOPE | Set Oidc Scope | openid | +| ANALYTICS_ENABLED | Enable/Disable analytics | false | +| ANALYTICS_WRITE_KEY | Set Segment Write key | null | + +## Mock data + +Enable mocks: + +```shell +export MOCK=stub +``` + +Start app: + +```shell +npm run start:dev +``` + +Mock data is defined at `client/src/mocks` diff --git a/ui/branding/favicon.ico b/ui/branding/favicon.ico new file mode 100644 index 000000000..130b38c8d Binary files /dev/null and b/ui/branding/favicon.ico differ diff --git a/ui/branding/images/logo.png b/ui/branding/images/logo.png new file mode 100644 index 000000000..9a640c940 Binary files /dev/null and b/ui/branding/images/logo.png differ diff --git a/ui/branding/images/logo192.png b/ui/branding/images/logo192.png new file mode 100644 index 000000000..9a640c940 Binary files /dev/null and b/ui/branding/images/logo192.png differ diff --git a/ui/branding/images/logo512.png b/ui/branding/images/logo512.png new file mode 100644 index 000000000..9f72223b7 Binary files /dev/null and b/ui/branding/images/logo512.png differ diff --git a/ui/branding/images/masthead-logo.svg b/ui/branding/images/masthead-logo.svg new file mode 100644 index 000000000..57080f42b --- /dev/null +++ b/ui/branding/images/masthead-logo.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ui/branding/manifest.json b/ui/branding/manifest.json new file mode 100644 index 000000000..b0013e63b --- /dev/null +++ b/ui/branding/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "trustification-ui", + "name": "Trustification UI", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/ui/branding/strings.json b/ui/branding/strings.json new file mode 100644 index 000000000..0d2e2f2cc --- /dev/null +++ b/ui/branding/strings.json @@ -0,0 +1,21 @@ +{ + "application": { + "title": "Trustification", + "name": "Trustification UI", + "description": "Trustification UI" + }, + "about": { + "displayName": "Trustification", + "imageSrc": "<%= brandingRoot %>/images/masthead-logo.svg", + "documentationUrl": "https://trustification.io/" + }, + "masthead": { + "leftBrand": { + "src": "<%= brandingRoot %>/images/masthead-logo.svg", + "alt": "brand", + "height": "40px" + }, + "leftTitle": null, + "rightBrand": null + } +} diff --git a/ui/client/config/jest.config.ts b/ui/client/config/jest.config.ts new file mode 100644 index 000000000..23d6246e6 --- /dev/null +++ b/ui/client/config/jest.config.ts @@ -0,0 +1,50 @@ +import type { JestConfigWithTsJest } from "ts-jest"; + +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +const config: JestConfigWithTsJest = { + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: false, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // Stub out resources and provide handling for tsconfig.json paths + moduleNameMapper: { + // stub out files that don't matter for tests + "\\.(css|less)$": "/__mocks__/styleMock.js", + "\\.(xsd)$": "/__mocks__/styleMock.js", + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": + "/__mocks__/fileMock.js", + "@patternfly/react-icons/dist/esm/icons/": + "/__mocks__/fileMock.js", + + // match the paths in tsconfig.json + "@app/(.*)": "/src/app/$1", + "@assets/(.*)": + "../node_modules/@patternfly/react-core/dist/styles/assets/$1", + }, + + // A list of paths to directories that Jest should use to search for files + roots: ["/src"], + + // The test environment that will be used for testing + testEnvironment: "jest-environment-jsdom", + + // The pattern or patterns Jest uses to find test files + testMatch: ["/src/**/*.{test,spec}.{js,jsx,ts,tsx}"], + + // Process js/jsx/mjs/mjsx/ts/tsx/mts/mtsx with `ts-jest` + transform: { + "^.+\\.(js|mjs|ts|mts)x?$": "ts-jest", + }, + + // Code to set up the testing framework before each test file in the suite is executed + setupFilesAfterEnv: ["/src/app/test-config/setupTests.ts"], +}; + +export default config; diff --git a/ui/client/config/monacoConstants.ts b/ui/client/config/monacoConstants.ts new file mode 100644 index 000000000..35c16dc6f --- /dev/null +++ b/ui/client/config/monacoConstants.ts @@ -0,0 +1,22 @@ +import { EditorLanguage } from "monaco-editor/esm/metadata"; + +export const LANGUAGES_BY_FILE_EXTENSION = { + java: "java", + go: "go", + xml: "xml", + js: "javascript", + ts: "typescript", + html: "html", + htm: "html", + css: "css", + yaml: "yaml", + yml: "yaml", + json: "json", + md: "markdown", + php: "php", + py: "python", + pl: "perl", + rb: "ruby", + sh: "shell", + bash: "shell", +} as const satisfies Record; diff --git a/ui/client/config/stylePaths.js b/ui/client/config/stylePaths.js new file mode 100644 index 000000000..a7f76aada --- /dev/null +++ b/ui/client/config/stylePaths.js @@ -0,0 +1,17 @@ +/* eslint-env node */ + +import * as path from "path"; + +export const stylePaths = [ + // Include our sources + path.resolve(__dirname, "../src"), + + // Include =PF4 paths, even if nested under another package because npm cannot hoist + // a single package to the root node_modules/ + /node_modules\/@patternfly\/patternfly/, + /node_modules\/@patternfly\/react-core\/.*\.css/, + /node_modules\/@patternfly\/react-styles/, +]; diff --git a/ui/client/config/webpack.common.ts b/ui/client/config/webpack.common.ts new file mode 100644 index 000000000..36b28eb13 --- /dev/null +++ b/ui/client/config/webpack.common.ts @@ -0,0 +1,221 @@ +import path from "path"; +import { Configuration } from "webpack"; +import CopyPlugin from "copy-webpack-plugin"; +import Dotenv from "dotenv-webpack"; +import { TsconfigPathsPlugin } from "tsconfig-paths-webpack-plugin"; +import MonacoWebpackPlugin from "monaco-editor-webpack-plugin"; + +import { brandingAssetPath } from "@trustify-ui/common"; +import { LANGUAGES_BY_FILE_EXTENSION } from "./monacoConstants"; + +const pathTo = (relativePath: string) => path.resolve(__dirname, relativePath); + +const brandingPath = brandingAssetPath(); +const manifestPath = path.resolve(brandingPath, "manifest.json"); + +const BG_IMAGES_DIRNAME = "images"; + +const config: Configuration = { + entry: { + app: [pathTo("../src/index.tsx")], + }, + + output: { + path: pathTo("../dist"), + publicPath: "auto", + clean: true, + }, + + module: { + rules: [ + { + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: { + loader: "ts-loader", + options: { + transpileOnly: true, + }, + }, + }, + { + test: /\.(svg|ttf|eot|woff|woff2)$/, + // only process modules with this loader + // if they live under a 'fonts' or 'pficon' directory + include: [ + pathTo("../../node_modules/patternfly/dist/fonts"), + pathTo( + "../../node_modules/@patternfly/react-core/dist/styles/assets/fonts" + ), + pathTo( + "../../node_modules/@patternfly/react-core/dist/styles/assets/pficon" + ), + pathTo("../../node_modules/@patternfly/patternfly/assets/fonts"), + pathTo("../../node_modules/@patternfly/patternfly/assets/pficon"), + ], + use: { + loader: "file-loader", + options: { + // Limit at 50k. larger files emited into separate files + limit: 5000, + outputPath: "fonts", + name: "[name].[ext]", + }, + }, + }, + { + test: /\.(xsd)$/, + include: [pathTo("../src")], + use: { + loader: "raw-loader", + options: { + esModule: true, + }, + }, + }, + { + test: /\.svg$/, + include: (input) => input.indexOf("background-filter.svg") > 1, + use: [ + { + loader: "url-loader", + options: { + limit: 5000, + outputPath: "svgs", + name: "[name].[ext]", + }, + }, + ], + type: "javascript/auto", + }, + { + test: /\.svg$/, + // only process SVG modules with this loader if they live under a 'bgimages' directory + // this is primarily useful when applying a CSS background using an SVG + include: (input) => input.indexOf(BG_IMAGES_DIRNAME) > -1, + use: { + loader: "svg-url-loader", + options: {}, + }, + }, + { + test: /\.svg$/, + // only process SVG modules with this loader when they don't live under a 'bgimages', + // 'fonts', or 'pficon' directory, those are handled with other loaders + include: (input) => + input.indexOf(BG_IMAGES_DIRNAME) === -1 && + input.indexOf("fonts") === -1 && + input.indexOf("background-filter") === -1 && + input.indexOf("pficon") === -1, + use: { + loader: "raw-loader", + options: {}, + }, + type: "javascript/auto", + }, + { + test: /\.(jpg|jpeg|png|gif)$/i, + include: [ + pathTo("../src"), + pathTo("../../node_modules/patternfly"), + pathTo("../../node_modules/@patternfly/patternfly/assets/images"), + pathTo( + "../../node_modules/@patternfly/react-styles/css/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-core/dist/styles/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css/assets/images" + ), + ], + use: [ + { + loader: "url-loader", + options: { + limit: 5000, + outputPath: "images", + name: "[name].[ext]", + }, + }, + ], + type: "javascript/auto", + }, + { + test: pathTo("../../node_modules/xmllint/xmllint.js"), + loader: "exports-loader", + options: { + exports: "xmllint", + }, + }, + { + test: /\.yaml$/, + use: "raw-loader", + }, + + // For monaco-editor-webpack-plugin + { + test: /\.css$/, + include: [pathTo("../../node_modules/monaco-editor")], + use: ["style-loader", "css-loader"], + }, + { + test: /\.ttf$/, + type: "asset/resource", + }, + // <--- For monaco-editor-webpack-plugin + ], + }, + + plugins: [ + // new CaseSensitivePathsWebpackPlugin(), + new Dotenv({ + systemvars: true, + silent: true, + }), + new CopyPlugin({ + patterns: [ + { + from: manifestPath, + to: ".", + }, + { + from: brandingPath, + to: "./branding/", + }, + ], + }), + new MonacoWebpackPlugin({ + filename: "monaco/[name].worker.js", + languages: Object.values(LANGUAGES_BY_FILE_EXTENSION), + }), + ], + + resolve: { + // alias: { + // "react-dom": "@hot-loader/react-dom", + // }, + extensions: [".js", ".ts", ".tsx", ".jsx"], + plugins: [ + new TsconfigPathsPlugin({ + configFile: pathTo("../tsconfig.json"), + }), + ], + symlinks: false, + cacheWithContext: false, + fallback: { crypto: false, fs: false, path: false }, + }, + + externals: { + // required by xmllint (but not really used in the browser) + ws: "{}", + }, +}; + +export default config; diff --git a/ui/client/config/webpack.dev.ts b/ui/client/config/webpack.dev.ts new file mode 100644 index 000000000..941bca965 --- /dev/null +++ b/ui/client/config/webpack.dev.ts @@ -0,0 +1,121 @@ +import path from "path"; +import { mergeWithRules } from "webpack-merge"; +import type { Configuration as WebpackConfiguration } from "webpack"; +import type { Configuration as DevServerConfiguration } from "webpack-dev-server"; +import CopyPlugin from "copy-webpack-plugin"; +import HtmlWebpackPlugin from "html-webpack-plugin"; +import ReactRefreshTypeScript from "react-refresh-typescript"; +import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin"; +import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; + +import { + encodeEnv, + TRUSTIFICATION_ENV, + SERVER_ENV_KEYS, + proxyMap, + brandingStrings, + brandingAssetPath, +} from "@trustify-ui/common"; +import { stylePaths } from "./stylePaths"; +import commonWebpackConfiguration from "./webpack.common"; + +const pathTo = (relativePath: string) => path.resolve(__dirname, relativePath); +const faviconPath = path.resolve(brandingAssetPath(), "favicon.ico"); + +interface Configuration extends WebpackConfiguration { + devServer?: DevServerConfiguration; +} + +const devServer: DevServerConfiguration = { + port: 3000, + historyApiFallback: { + disableDotRule: true, + }, + hot: true, + proxy: proxyMap, +}; + +const config: Configuration = mergeWithRules({ + module: { + rules: { + test: "match", + use: { + loader: "match", + options: "replace", + }, + }, + }, +})(commonWebpackConfiguration, { + mode: "development", + devtool: "eval-source-map", + output: { + filename: "[name].js", + chunkFilename: "js/[name].js", + assetModuleFilename: "assets/[name][ext]", + }, + + devServer, + + module: { + rules: [ + { + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: { + loader: "ts-loader", + options: { + transpileOnly: true, // HMR in webpack-dev-server requires transpileOnly + getCustomTransformers: () => ({ + before: [ReactRefreshTypeScript()], + }), + }, + }, + }, + { + test: /\.css$/, + include: [...stylePaths], + use: ["style-loader", "css-loader"], + }, + ], + }, + + plugins: [ + new ReactRefreshWebpackPlugin(), + new ForkTsCheckerWebpackPlugin({ + typescript: { + mode: "readonly", + }, + }), + new CopyPlugin({ + patterns: [ + { + from: pathTo("../public/mockServiceWorker.js"), + }, + ], + }), + + // index.html generated at compile time to inject `_env` + new HtmlWebpackPlugin({ + filename: "index.html", + template: pathTo("../public/index.html.ejs"), + templateParameters: { + _env: encodeEnv(TRUSTIFICATION_ENV, SERVER_ENV_KEYS), + branding: brandingStrings, + }, + favicon: faviconPath, + minify: { + collapseWhitespace: false, + keepClosingSlash: true, + minifyJS: true, + removeEmptyAttributes: true, + removeRedundantAttributes: true, + }, + }), + ], + + watchOptions: { + // ignore watching everything except @trustify-ui packages + ignored: /node_modules\/(?!@trustify-ui\/)/, + }, +} as Configuration); +export default config; diff --git a/ui/client/config/webpack.prod.ts b/ui/client/config/webpack.prod.ts new file mode 100644 index 000000000..93bbf8127 --- /dev/null +++ b/ui/client/config/webpack.prod.ts @@ -0,0 +1,72 @@ +import path from "path"; +import merge from "webpack-merge"; +import webpack, { Configuration } from "webpack"; +import MiniCssExtractPlugin from "mini-css-extract-plugin"; +import CssMinimizerPlugin from "css-minimizer-webpack-plugin"; +import HtmlWebpackPlugin from "html-webpack-plugin"; + +import { brandingAssetPath } from "@trustify-ui/common"; +import { stylePaths } from "./stylePaths"; +import commonWebpackConfiguration from "./webpack.common"; + +const pathTo = (relativePath: string) => path.resolve(__dirname, relativePath); +const faviconPath = path.resolve(brandingAssetPath(), "favicon.ico"); + +const config = merge(commonWebpackConfiguration, { + mode: "production", + devtool: "nosources-source-map", // used to map stack traces on the client without exposing all of the source code + output: { + filename: "[name].[contenthash:8].min.js", + chunkFilename: "js/[name].[chunkhash:8].min.js", + assetModuleFilename: "assets/[name].[contenthash:8][ext]", + }, + + optimization: { + minimize: true, + minimizer: [ + "...", // The '...' string represents the webpack default TerserPlugin instance + new CssMinimizerPlugin(), + ], + }, + + module: { + rules: [ + { + test: /\.css$/, + include: [...stylePaths], + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + + plugins: [ + new MiniCssExtractPlugin({ + filename: "[name].[contenthash:8].css", + chunkFilename: "css/[name].[chunkhash:8].min.css", + }), + new CssMinimizerPlugin({ + minimizerOptions: { + preset: ["default", { mergeLonghand: false }], + }, + }), + new webpack.EnvironmentPlugin({ + NODE_ENV: "production", + }), + + // index.html generated at runtime via the express server to inject `_env` + new HtmlWebpackPlugin({ + filename: "index.html.ejs", + template: `!!raw-loader!${pathTo("../public/index.html.ejs")}`, + favicon: faviconPath, + minify: { + collapseWhitespace: false, + keepClosingSlash: true, + minifyJS: true, + removeEmptyAttributes: true, + removeRedundantAttributes: true, + }, + }), + ], +}); + +export default config; diff --git a/ui/client/package.json b/ui/client/package.json new file mode 100644 index 000000000..c6fa2ffdb --- /dev/null +++ b/ui/client/package.json @@ -0,0 +1,112 @@ +{ + "name": "@trustify-ui/client", + "version": "0.1.0", + "license": "Apache-2.0", + "private": true, + "scripts": { + "analyze": "source-map-explorer 'dist/static/js/*.js'", + "clean": "rimraf ./dist", + "prebuild": "npm run clean && npm run tsc -- --noEmit", + "build": "NODE_ENV=production webpack --config ./config/webpack.prod.ts", + "build:dev": "NODE_ENV=development webpack --config ./config/webpack.dev.ts", + "start:dev": "NODE_ENV=development webpack serve --config ./config/webpack.dev.ts", + "test": "NODE_ENV=test jest --rootDir=. --config=./config/jest.config.ts", + "lint": "eslint .", + "tsc": "tsc -p ./tsconfig.json" + }, + "lint-staged": { + "*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}": "eslint --fix", + "*.{css,json,md,yaml,yml}": "prettier --write" + }, + "dependencies": { + "@carlosthe19916-latest/react-table-batteries": "^0.0.3", + "@hookform/resolvers": "^2.9.11", + "@patternfly/patternfly": "^5.2.1", + "@patternfly/react-charts": "^7.2.1", + "@patternfly/react-code-editor": "^5.2.1", + "@patternfly/react-component-groups": "^5.0.0", + "@patternfly/react-core": "^5.2.1", + "@patternfly/react-table": "^5.2.1", + "@patternfly/react-tokens": "^5.2.1", + "@segment/analytics-next": "^1.64.0", + "@tanstack/react-query": "^5.22.2", + "@tanstack/react-query-devtools": "^5.24.0", + "axios": "^0.21.2", + "dayjs": "^1.11.7", + "ejs": "^3.1.7", + "file-saver": "^2.0.5", + "monaco-editor": "0.34.1", + "oidc-client-ts": "^2.4.0", + "packageurl-js": "^1.2.1", + "pretty-bytes": "^6.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.43.1", + "react-markdown": "^8.0.7", + "react-monaco-editor": "0.51.0", + "react-oidc-context": "^2.3.1", + "react-router-dom": "^6.21.1", + "usehooks-ts": "^2.14.0", + "web-vitals": "^0.2.4", + "xmllint": "^0.1.1", + "yaml": "^1.10.2", + "yup": "^0.32.11" + }, + "devDependencies": { + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^14.4.3", + "@types/dotenv-webpack": "^7.0.3", + "@types/ejs": "^3.1.0", + "@types/file-saver": "^2.0.2", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "browserslist": "^4.19.1", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "copy-webpack-plugin": "^11.0.0", + "css-loader": "^5.2.7", + "css-minimizer-webpack-plugin": "^3.4.1", + "dotenv-webpack": "^7.0.3", + "exports-loader": "^3.1.0", + "file-loader": "^6.2.0", + "fork-ts-checker-webpack-plugin": "^8.0.0", + "html-webpack-plugin": "^5.5.0", + "mini-css-extract-plugin": "^2.5.2", + "monaco-editor-webpack-plugin": "^7.0.1", + "msw": "^1.2.3", + "raw-loader": "^4.0.2", + "react-refresh": "^0.14.0", + "react-refresh-typescript": "^2.0.9", + "sass-loader": "^12.4.0", + "source-map-explorer": "^2.5.2", + "style-loader": "^3.3.1", + "svg-url-loader": "^7.1.1", + "terser-webpack-plugin": "^5.3.0", + "ts-loader": "^9.4.1", + "tsconfig-paths-webpack-plugin": "^4.0.0", + "url-loader": "^4.1.1", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1", + "webpack-merge": "^5.9.0" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "nyc": { + "exclude": "client/src/reportWebVitals.ts" + }, + "msw": { + "workerDirectory": "public" + } +} diff --git a/ui/client/public/index.html.ejs b/ui/client/public/index.html.ejs new file mode 100644 index 000000000..4fc0deb38 --- /dev/null +++ b/ui/client/public/index.html.ejs @@ -0,0 +1,21 @@ + + + + <%= branding.application.title %> + + + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/ui/client/public/manifest.json b/ui/client/public/manifest.json new file mode 100644 index 000000000..b0013e63b --- /dev/null +++ b/ui/client/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "trustification-ui", + "name": "Trustification UI", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/ui/client/public/mockServiceWorker.js b/ui/client/public/mockServiceWorker.js new file mode 100644 index 000000000..e5aa935a6 --- /dev/null +++ b/ui/client/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.2.3). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = "3d6b9f06410d179a7f7404d4bf4c3c70"; +const activeClientIds = new Set(); + +self.addEventListener("install", function () { + self.skipWaiting(); +}); + +self.addEventListener("activate", function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("message", async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + switch (event.data) { + case "KEEPALIVE_REQUEST": { + sendToClient(client, { + type: "KEEPALIVE_RESPONSE", + }); + break; + } + + case "INTEGRITY_CHECK_REQUEST": { + sendToClient(client, { + type: "INTEGRITY_CHECK_RESPONSE", + payload: INTEGRITY_CHECKSUM, + }); + break; + } + + case "MOCK_ACTIVATE": { + activeClientIds.add(clientId); + + sendToClient(client, { + type: "MOCKING_ENABLED", + payload: true, + }); + break; + } + + case "MOCK_DEACTIVATE": { + activeClientIds.delete(clientId); + break; + } + + case "CLIENT_CLOSED": { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener("fetch", function (event) { + const { request } = event; + const accept = request.headers.get("accept") || ""; + + // Bypass server-sent events. + if (accept.includes("text/event-stream")) { + return; + } + + // Bypass navigation requests. + if (request.mode === "navigate") { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === "only-if-cached" && request.mode !== "same-origin") { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2); + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === "NetworkError") { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url + ); + return; + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}` + ); + }) + ); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const clonedResponse = response.clone(); + sendToClient(client, { + type: "RESPONSE", + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (client?.frameType === "top-level") { + return client; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === "visible"; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + const clonedRequest = request.clone(); + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()); + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers["x-msw-bypass"]; + + return fetch(clonedRequest, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get("x-msw-bypass") === "true") { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: "REQUEST", + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }); + + switch (clientMessage.type) { + case "MOCK_RESPONSE": { + return respondWithMock(clientMessage.data); + } + + case "MOCK_NOT_FOUND": { + return passthrough(); + } + + case "NETWORK_ERROR": { + const { name, message } = clientMessage.data; + const networkError = new Error(message); + networkError.name = name; + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError; + } + } + + return passthrough(); +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2]); + }); +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs); + }); +} + +async function respondWithMock(response) { + await sleep(response.delay); + return new Response(response.body, response); +} diff --git a/ui/client/public/robots.txt b/ui/client/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/ui/client/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/ui/client/src/app/App.css b/ui/client/src/app/App.css new file mode 100644 index 000000000..50707665f --- /dev/null +++ b/ui/client/src/app/App.css @@ -0,0 +1,10 @@ +.pf-c-select__toggle:before { + border-top: var(--pf-c-select__toggle--before--BorderTopWidth) solid + var(--pf-c-select__toggle--before--BorderTopColor) !important; + border-right: var(--pf-c-select__toggle--before--BorderRightWidth) solid + var(--pf-c-select__toggle--before--BorderRightColor) !important; + border-bottom: var(--pf-c-select__toggle--before--BorderBottomWidth) solid + var(--pf-c-select__toggle--before--BorderBottomColor) !important; + border-left: var(--pf-c-select__toggle--before--BorderLeftWidth) solid + var(--pf-c-select__toggle--before--BorderLeftColor) !important; +} diff --git a/ui/client/src/app/App.tsx b/ui/client/src/app/App.tsx new file mode 100644 index 000000000..5b7088d55 --- /dev/null +++ b/ui/client/src/app/App.tsx @@ -0,0 +1,27 @@ +import "./App.css"; +import React from "react"; +import { BrowserRouter as Router } from "react-router-dom"; + +import { DefaultLayout } from "./layout"; +import { AppRoutes } from "./Routes"; +import { NotificationsProvider } from "./components/NotificationsContext"; +import { AnalyticsProvider } from "./components/AnalyticsProvider"; + +import "@patternfly/patternfly/patternfly.css"; +import "@patternfly/patternfly/patternfly-addons.css"; + +const App: React.FC = () => { + return ( + + + + + + + + + + ); +}; + +export default App; diff --git a/ui/client/src/app/Constants.ts b/ui/client/src/app/Constants.ts new file mode 100644 index 000000000..61beb83dd --- /dev/null +++ b/ui/client/src/app/Constants.ts @@ -0,0 +1,29 @@ +import ENV from "./env"; + +export const RENDER_DATE_FORMAT = "MMM DD, YYYY"; +export const FILTER_DATE_FORMAT = "YYYY-MM-DD"; + +export const TablePersistenceKeyPrefixes = { + advisories: "ad", + cves: "cv", + sboms: "sb", + packages: "pk", +}; + +// URL param prefixes: should be short, must be unique for each table that uses one +export enum TableURLParamKeyPrefix { + repositories = "r", + tags = "t", +} + +export const isAuthRequired = ENV.AUTH_REQUIRED !== "false"; +export const isAnalyticsEnabled = ENV.ANALYTICS_ENABLED !== "false"; +export const uploadLimit = "500m"; + +/** + * The name of the client generated id field inserted in a object marked with mixin type + * `WithUiId`. + */ +export const UI_UNIQUE_ID = "_ui_unique_id"; + +export const FORM_DATA_FILE_KEY = "file"; diff --git a/ui/client/src/app/Routes.tsx b/ui/client/src/app/Routes.tsx new file mode 100644 index 000000000..4eaeda0f2 --- /dev/null +++ b/ui/client/src/app/Routes.tsx @@ -0,0 +1,70 @@ +import React, { Suspense, lazy } from "react"; +import { useParams, useRoutes } from "react-router-dom"; + +import { Bullseye, Spinner } from "@patternfly/react-core"; + +const Home = lazy(() => import("./pages/home")); +const AdvisoryList = lazy(() => import("./pages/advisory-list")); +const AdvisoryDetails = lazy(() => import("./pages/advisory-details")); +const CVEList = lazy(() => import("./pages/cve-list")); +const CVEDetails = lazy(() => import("./pages/cve-details")); +const PackageList = lazy(() => import("./pages/package-list")); +const PackageDetails = lazy(() => import("./pages/package-details")); +const SBOMList = lazy(() => import("./pages/sbom-list")); +const SBOMDetails = lazy(() => import("./pages/sbom-details")); +const ImporterList = lazy(() => import("./pages/importer-list")); + +export enum PathParam { + ADVISORY_ID = "advisoryId", + CVE_ID = "cveId", + SBOM_ID = "sbomId", + PACKAGE_ID = "packageId", + IMPORTER_ID = "importerId", +} + +export const AppRoutes = () => { + const allRoutes = useRoutes([ + { path: "/", element: }, + { path: "/advisories", element: }, + { + path: `/advisories/:${PathParam.ADVISORY_ID}`, + element: , + }, + { path: "/cves", element: }, + { + path: `/cves/:${PathParam.CVE_ID}`, + element: , + }, + { path: "/packages", element: }, + { + path: `/packages/:${PathParam.PACKAGE_ID}`, + element: , + }, + { path: "/sboms", element: }, + { + path: `/sboms/:${PathParam.SBOM_ID}`, + element: , + }, + { + path: `/importers`, + element: , + }, + ]); + + return ( + + + + } + > + {allRoutes} + + ); +}; + +export const useRouteParams = (pathParam: PathParam) => { + const params = useParams(); + return params[pathParam]; +}; diff --git a/ui/client/src/app/analytics.ts b/ui/client/src/app/analytics.ts new file mode 100644 index 000000000..1d3d7015c --- /dev/null +++ b/ui/client/src/app/analytics.ts @@ -0,0 +1,6 @@ +import { AnalyticsBrowserSettings } from "@segment/analytics-next"; +import { ENV } from "./env"; + +export const analyticsSettings: AnalyticsBrowserSettings = { + writeKey: ENV.ANALYTICS_WRITE_KEY || "", +}; diff --git a/ui/client/src/app/api/model-utils.ts b/ui/client/src/app/api/model-utils.ts new file mode 100644 index 000000000..190309d72 --- /dev/null +++ b/ui/client/src/app/api/model-utils.ts @@ -0,0 +1,67 @@ +import { + global_palette_purple_400 as criticalColor, + global_danger_color_100 as importantColor, + global_info_color_100 as lowColor, + global_warning_color_100 as moderateColor, +} from "@patternfly/react-tokens"; + +import { ProgressProps } from "@patternfly/react-core"; + +import { Severity } from "./models"; + +type ListType = { + [key in Severity]: { + name: string; + shieldIconColor: { name: string; value: string; var: string }; + progressProps: Pick; + }; +}; + +export const severityList: ListType = { + low: { + name: "Low", + shieldIconColor: lowColor, + progressProps: { variant: undefined }, + }, + moderate: { + name: "Moderate", + shieldIconColor: moderateColor, + progressProps: { variant: "warning" }, + }, + important: { + name: "Important", + shieldIconColor: importantColor, + progressProps: { variant: "danger" }, + }, + critical: { + name: "Critical", + shieldIconColor: criticalColor, + progressProps: { variant: "danger" }, + }, +}; + +const getSeverityPriority = (val: Severity) => { + switch (val) { + case "low": + return 1; + case "moderate": + return 2; + case "important": + return 3; + case "critical": + return 4; + default: + return 0; + } +}; + +export function compareBySeverityFn( + severityExtractor: (elem: T) => Severity +) { + return (a: T, b: T) => { + return ( + getSeverityPriority(severityExtractor(a)) - + getSeverityPriority(severityExtractor(b)) + ); + }; +} diff --git a/ui/client/src/app/api/models.ts b/ui/client/src/app/api/models.ts new file mode 100644 index 000000000..32ba5eb44 --- /dev/null +++ b/ui/client/src/app/api/models.ts @@ -0,0 +1,147 @@ +export type WithUiId = T & { _ui_unique_id: string }; + +/** Mark an object as "New" therefore does not have an `id` field. */ +export type New = Omit; + +export interface HubFilter { + field: string; + operator?: "=" | "!=" | "~" | ">" | ">=" | "<" | "<="; + value: + | string + | number + | { + list: (string | number)[]; + operator?: "AND" | "OR"; + }; +} + +export interface HubRequestParams { + filters?: HubFilter[]; + sort?: { + field: string; + direction: "asc" | "desc"; + }; + page?: { + pageNumber: number; // 1-indexed + itemsPerPage: number; + }; +} + +export interface HubPaginatedResult { + data: T[]; + total: number; + params: HubRequestParams; +} + +// Base + +export interface CVEBase { + id: string; + title: string; + description: string; + severity: Severity; + cwe: string; + date_discovered: string; + date_released: string; + date_reserved: string; + date_updated: string; +} + +export interface PackageBase { + id: string; + namespace: string; + name: string; + version: string; + type: string; + path?: string; + qualifiers: { [key: string]: string }; +} + +export interface SBOMBase { + id: string; + type: "CycloneDX" | "SPDX"; + name: string; + version: string; + supplier: string; + created_on: string; +} + +export interface AdvisoryBase { + id: string; + severity: Severity; + modified: string; + title: string; + metadata: { + category: string; + publisher: { + name: string; + namespace: string; + contact_details: string; + issuing_authority: string; + }; + tracking: { + status: string; + initial_release_date: string; + current_release_date: string; + }; + references: { + url: string; + label?: string; + }[]; + notes: string[]; + }; +} + +// Advisories + +export type Severity = "low" | "moderate" | "important" | "critical"; + +export interface Advisory extends AdvisoryBase { + cves: CVEBase[]; +} + +// CVE + +export interface CVE extends CVEBase { + related_sboms: SBOMBase[]; + related_advisories: AdvisoryBase[]; +} + +// Package + +export interface Package extends PackageBase { + related_cves: CVEBase[]; + related_sboms: SBOMBase[]; +} + +// SBOM + +export interface SBOM extends SBOMBase { + related_packages: { + count: number; + }; + related_cves: { [key in Severity]: number }; +} + +// Importer + +export type ImporterStatus = "waiting" | "running"; + +export interface Importer { + name: string; + configuration: ImporterConfiguration; + state?: ImporterStatus; +} + +export interface ImporterConfiguration { + sbom?: ImporterConfigurationValues; + csaf?: ImporterConfigurationValues; +} + +export interface ImporterConfigurationValues { + period: string; + source: string; + disabled: boolean; + v3Signatures: boolean; + onlyPatterns: string[]; +} diff --git a/ui/client/src/app/api/rest.ts b/ui/client/src/app/api/rest.ts new file mode 100644 index 000000000..e937589ba --- /dev/null +++ b/ui/client/src/app/api/rest.ts @@ -0,0 +1,182 @@ +import axios, { AxiosRequestConfig } from "axios"; + +import { FORM_DATA_FILE_KEY } from "@app/Constants"; +import { serializeRequestParamsForHub } from "@app/hooks/table-controls/getHubRequestParams"; +import { + Advisory, + CVE, + HubPaginatedResult, + HubRequestParams, + Importer, + ImporterConfiguration, + Package, + SBOM, +} from "./models"; + +const API = "/api"; + +export const ADVISORIES = API + "/advisories"; +export const ADVISORIES_SEARCH = API + "/v1/search/advisory"; +export const CVES = API + "/cves"; +export const SBOMS = API + "/sboms"; +export const PACKAGES = API + "/packages"; +export const IMPORTERS = API + "/v1/importer"; + +export interface PaginatedResponse { + items: T[]; + total: number; +} + +export const getHubPaginatedResult = ( + url: string, + params: HubRequestParams = {} +): Promise> => + axios + .get>(url, { + params: serializeRequestParamsForHub(params), + }) + .then(({ data }) => ({ + data: data.items, + total: data.total, + params, + })); + +// + +export const getAdvisories = (params: HubRequestParams = {}) => { + return getHubPaginatedResult(ADVISORIES_SEARCH, params); +}; + +export const getAdvisoryById = (id: number | string) => { + return axios + .get(`${ADVISORIES}/${id}`) + .then((response) => response.data); +}; + +export const getAdvisorySourceById = (id: number | string) => { + return axios + .get(`${ADVISORIES}/${id}/source`) + .then((response) => response.data); +}; + +export const downloadAdvisoryById = (id: number | string) => { + return axios.get(`${ADVISORIES}/${id}/source`, { + responseType: "arraybuffer", + headers: { Accept: "text/plain", responseType: "blob" }, + }); +}; + +export const uploadAdvisory = ( + formData: FormData, + config?: AxiosRequestConfig +) => { + const file = formData.get(FORM_DATA_FILE_KEY) as File; + return file.text().then((text) => { + const json = JSON.parse(text); + return axios.post(`${ADVISORIES}`, json, config); + }); +}; + +// + +export const getCVEs = (params: HubRequestParams = {}) => { + return getHubPaginatedResult(CVES, params); +}; + +export const getCVEById = (id: number | string) => { + return axios.get(`${CVES}/${id}`).then((response) => response.data); +}; + +export const getCVESourceById = (id: number | string) => { + return axios + .get(`${CVES}/${id}/source`) + .then((response) => response.data); +}; + +export const downloadCVEById = (id: number | string) => { + return axios.get(`${CVES}/${id}/source`, { + responseType: "arraybuffer", + headers: { Accept: "text/plain", responseType: "blob" }, + }); +}; + +// + +export const getPackages = (params: HubRequestParams = {}) => { + return getHubPaginatedResult(PACKAGES, params); +}; + +export const getPackageById = (id: number | string) => { + return axios + .get(`${PACKAGES}/${id}`) + .then((response) => response.data); +}; + +// + +export const getSBOMs = (params: HubRequestParams = {}) => { + return getHubPaginatedResult(SBOMS, params); +}; + +export const getSBOMById = (id: number | string) => { + return axios.get(`${SBOMS}/${id}`).then((response) => response.data); +}; + +export const getSBOMSourceById = (id: number | string) => { + return axios + .get(`${SBOMS}/${id}/source`) + .then((response) => response.data); +}; + +export const downloadSBOMById = (id: number | string) => { + return axios.get(`${SBOMS}/${id}/source`, { + responseType: "arraybuffer", + headers: { Accept: "text/plain", responseType: "blob" }, + }); +}; + +export const getPackagesBySbomId = (id: string | number) => { + return axios + .get(`${SBOMS}/${id}/packages`) + .then((response) => response.data); +}; + +export const getCVEsBySbomId = (id: string | number) => { + return axios + .get(`${SBOMS}/${id}/cves`) + .then((response) => response.data); +}; + +// + +export const getImporters = () => { + return axios.get(IMPORTERS).then((response) => response.data); +}; + +export const getImporterById = (id: number | string) => { + return axios + .get(`${IMPORTERS}/${id}`) + .then((response) => response.data); +}; + +export const createImporter = ( + id: number | string, + body: ImporterConfiguration +) => { + return axios.post(`${IMPORTERS}/${id}`, body); +}; + +export const updateImporter = ( + id: number | string, + body: ImporterConfiguration +) => { + return axios + .put(`${IMPORTERS}/${id}`, body) + .then((response) => response.data); +}; + +export const deleteImporter = (id: number | string) => { + return axios + .delete(`${IMPORTERS}/${id}`) + .then((response) => response.data); +}; diff --git a/ui/client/src/app/axios-config/apiInit.ts b/ui/client/src/app/axios-config/apiInit.ts new file mode 100644 index 000000000..8c16f7dac --- /dev/null +++ b/ui/client/src/app/axios-config/apiInit.ts @@ -0,0 +1,30 @@ +import ENV from "@app/env"; +import axios from "axios"; +import { User } from "oidc-client-ts"; + +function getUser() { + const oidcStorage = sessionStorage.getItem( + `oidc.user:${ENV.OIDC_SERVER_URL}:${ENV.OIDC_CLIENT_ID}` + ); + if (!oidcStorage) { + return null; + } + + return User.fromStorageString(oidcStorage); +} + +export const initInterceptors = () => { + axios.interceptors.request.use( + (config) => { + const user = getUser(); + const token = user?.access_token; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } + ); +}; diff --git a/ui/client/src/app/axios-config/index.ts b/ui/client/src/app/axios-config/index.ts new file mode 100644 index 000000000..98b6062d1 --- /dev/null +++ b/ui/client/src/app/axios-config/index.ts @@ -0,0 +1 @@ +export { initInterceptors } from "./apiInit"; diff --git a/ui/client/src/app/common/types.ts b/ui/client/src/app/common/types.ts new file mode 100644 index 000000000..2f6e5bcde --- /dev/null +++ b/ui/client/src/app/common/types.ts @@ -0,0 +1,9 @@ +export interface Page { + page: number; + perPage: number; +} + +export interface SortBy { + index: number; + direction: 'asc' | 'desc'; +} diff --git a/ui/client/src/app/components/AnalyticsProvider.tsx b/ui/client/src/app/components/AnalyticsProvider.tsx new file mode 100644 index 000000000..cdfe401c8 --- /dev/null +++ b/ui/client/src/app/components/AnalyticsProvider.tsx @@ -0,0 +1,56 @@ +import React, { useEffect } from "react"; +import { useLocation } from "react-router"; +import { useAuth } from "react-oidc-context"; +import { AnalyticsBrowser } from "@segment/analytics-next"; +import ENV from "@app/env"; +import { isAuthRequired } from "@app/Constants"; +import { analyticsSettings } from "@app/analytics"; + +const AnalyticsContext = React.createContext(undefined!); + +interface IAnalyticsProviderProps { + children: React.ReactNode; +} + +export const AnalyticsProvider: React.FC = ({ + children, +}) => { + return ENV.ANALYTICS_ENABLED !== "true" ? ( + <>{children} + ) : ( + {children} + ); +}; + +export const AnalyticsContextProvider: React.FC = ({ + children, +}) => { + const auth = (isAuthRequired && useAuth()) || undefined; + const analytics = React.useMemo(() => { + return AnalyticsBrowser.load(analyticsSettings); + }, []); + + // Identify + useEffect(() => { + if (auth) { + const claims = auth.user?.profile; + analytics.identify(claims?.sub, { + /* eslint-disable @typescript-eslint/no-explicit-any */ + organization_id: ((claims as any)?.organization as any)?.id, + domain: claims?.email?.split("@")[1], + }); + } + }, [auth, analytics]); + + // Watch navigation + const location = useLocation(); + useEffect(() => { + analytics.page(); + }, [analytics, location]); + + return ( + + {children} + + ); +}; diff --git a/ui/client/src/app/components/AppPlaceholder.tsx b/ui/client/src/app/components/AppPlaceholder.tsx new file mode 100644 index 000000000..a7850977c --- /dev/null +++ b/ui/client/src/app/components/AppPlaceholder.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { Bullseye, Spinner } from "@patternfly/react-core"; + +export const AppPlaceholder: React.FC = () => { + return ( + +
+
+ +
+
+

Loading...

+
+
+
+ ); +}; diff --git a/ui/client/src/app/components/ConfirmDialog.tsx b/ui/client/src/app/components/ConfirmDialog.tsx new file mode 100644 index 000000000..74c001c69 --- /dev/null +++ b/ui/client/src/app/components/ConfirmDialog.tsx @@ -0,0 +1,86 @@ +import React from "react"; +import { + Button, + Modal, + ButtonVariant, + ModalVariant, +} from "@patternfly/react-core"; + +export interface ConfirmDialogProps { + isOpen: boolean; + + title: string; + titleIconVariant?: + | "success" + | "danger" + | "warning" + | "info" + /* eslint-disable @typescript-eslint/no-explicit-any */ + | React.ComponentType; + message: string | React.ReactNode; + + confirmBtnLabel: string; + cancelBtnLabel: string; + + inProgress?: boolean; + confirmBtnVariant: ButtonVariant; + + onClose: () => void; + onConfirm: () => void; + onCancel?: () => void; +} + +export const ConfirmDialog: React.FC = ({ + isOpen, + title, + titleIconVariant, + message, + confirmBtnLabel, + cancelBtnLabel, + inProgress, + confirmBtnVariant, + onClose, + onConfirm, + onCancel, +}) => { + const confirmBtn = ( + + ); + + const cancelBtn = onCancel ? ( + + ) : undefined; + + return ( + + {message} + + ); +}; diff --git a/ui/client/src/app/components/CveGallery.tsx b/ui/client/src/app/components/CveGallery.tsx new file mode 100644 index 000000000..7bd5d3859 --- /dev/null +++ b/ui/client/src/app/components/CveGallery.tsx @@ -0,0 +1,57 @@ +import React from "react"; + +import { Divider, Flex, FlexItem } from "@patternfly/react-core"; + +import { compareBySeverityFn } from "@app/api/model-utils"; +import { Severity } from "@app/api/models"; +import { SeverityShieldAndText } from "@app/components/SeverityShieldAndText"; + +interface CVEGalleryProps { + severities: { [key in Severity]: number }; +} + +export const CveGallery: React.FC = ({ severities }) => { + const severityCount = Object.values(severities).reduce((prev, acc) => { + return prev + acc; + }, 0); + + return ( + + {severityCount} + + + + {Object.entries(severities) + .filter(([_severity, count]) => count > 0) + .sort( + compareBySeverityFn(([severity, _count]) => severity as Severity) + ) + .reverse() + .map(([severity, count], index) => ( + + + + + + {count} + + + ))} + + + + ); +}; diff --git a/ui/client/src/app/components/FilterToolbar/FilterControl.tsx b/ui/client/src/app/components/FilterToolbar/FilterControl.tsx new file mode 100644 index 000000000..52a0f73b4 --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/FilterControl.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; + +import { + FilterCategory, + FilterValue, + FilterType, + ISelectFilterCategory, + ISearchFilterCategory, + IMultiselectFilterCategory, +} from "./FilterToolbar"; +import { SelectFilterControl } from "./SelectFilterControl"; +import { SearchFilterControl } from "./SearchFilterControl"; +import { MultiselectFilterControl } from "./MultiselectFilterControl"; + +export interface IFilterControlProps { + category: FilterCategory; + filterValue: FilterValue; + setFilterValue: (newValue: FilterValue) => void; + showToolbarItem: boolean; + isDisabled?: boolean; +} + +export const FilterControl = ({ + category, + ...props +}: React.PropsWithChildren< + IFilterControlProps +>): JSX.Element | null => { + if (category.type === FilterType.select) { + return ( + } + {...props} + /> + ); + } + if ( + category.type === FilterType.search || + category.type === FilterType.numsearch + ) { + return ( + } + isNumeric={category.type === FilterType.numsearch} + {...props} + /> + ); + } + if (category.type === FilterType.multiselect) { + return ( + + } + {...props} + /> + ); + } + return null; +}; diff --git a/ui/client/src/app/components/FilterToolbar/FilterToolbar.tsx b/ui/client/src/app/components/FilterToolbar/FilterToolbar.tsx new file mode 100644 index 000000000..eaa169a2d --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/FilterToolbar.tsx @@ -0,0 +1,239 @@ +import * as React from "react"; +import { + Dropdown, + DropdownItem, + DropdownGroup, + DropdownList, + MenuToggle, + SelectOptionProps, + ToolbarToggleGroup, + ToolbarItem, +} from "@patternfly/react-core"; +import FilterIcon from "@patternfly/react-icons/dist/esm/icons/filter-icon"; + +import { FilterControl } from "./FilterControl"; + +export enum FilterType { + select = "select", + multiselect = "multiselect", + search = "search", + numsearch = "numsearch", +} + +export type FilterValue = string[] | undefined | null; + +export interface FilterSelectOptionProps { + optionProps?: SelectOptionProps; + value: string; + label?: string; + chipLabel?: string; + groupLabel?: string; +} + +export interface IBasicFilterCategory< + /** The actual API objects we're filtering */ + TItem, + TFilterCategoryKey extends string, // Unique identifiers for each filter category (inferred from key properties if possible) +> { + /** For use in the filterValues state object. Must be unique per category. */ + categoryKey: TFilterCategoryKey; + /** Title of the filter as displayed in the filter selection dropdown and filter chip groups. */ + title: string; + /** Type of filter component to use to select the filter's content. */ + type: FilterType; + /** Optional grouping to display this filter in the filter selection dropdown. */ + filterGroup?: string; + /** For client side filtering, return the value of `TItem` the filter will be applied against. */ + getItemValue?: (item: TItem) => string | boolean; // For client-side filtering + /** For server-side filtering, defaults to `key` if omitted. Does not need to be unique if the server supports joining repeated filters. */ + serverFilterField?: string; + /** + * For server-side filtering, return the search value for currently selected filter items. + * Defaults to using the UI state's value if omitted. + */ + getServerFilterValue?: (filterValue: FilterValue) => string[] | undefined; +} + +export interface IMultiselectFilterCategory< + TItem, + TFilterCategoryKey extends string, +> extends IBasicFilterCategory { + /** The full set of options to select from for this filter. */ + selectOptions: + | FilterSelectOptionProps[] + | Record; + /** Option search input field placeholder text. */ + placeholderText?: string; + /** How to connect multiple selected options together. Defaults to "AND". */ + logicOperator?: "AND" | "OR"; +} + +export interface ISelectFilterCategory + extends IBasicFilterCategory { + selectOptions: FilterSelectOptionProps[]; +} + +export interface ISearchFilterCategory + extends IBasicFilterCategory { + placeholderText: string; +} + +export type FilterCategory = + | IMultiselectFilterCategory + | ISelectFilterCategory + | ISearchFilterCategory; + +export type IFilterValues = Partial< + Record +>; + +export const getFilterLogicOperator = < + TItem, + TFilterCategoryKey extends string, +>( + filterCategory?: FilterCategory, + defaultOperator: "AND" | "OR" = "OR" +) => + (filterCategory && + (filterCategory as IMultiselectFilterCategory) + .logicOperator) || + defaultOperator; + +export interface IFilterToolbarProps { + filterCategories: FilterCategory[]; + filterValues: IFilterValues; + setFilterValues: (values: IFilterValues) => void; + beginToolbarItems?: JSX.Element; + endToolbarItems?: JSX.Element; + pagination?: JSX.Element; + showFiltersSideBySide?: boolean; + isDisabled?: boolean; +} + +export const FilterToolbar = ({ + filterCategories, + filterValues, + setFilterValues, + pagination, + showFiltersSideBySide = false, + isDisabled = false, +}: React.PropsWithChildren< + IFilterToolbarProps +>): JSX.Element | null => { + const [isCategoryDropdownOpen, setIsCategoryDropdownOpen] = + React.useState(false); + const [currentFilterCategoryKey, setCurrentFilterCategoryKey] = + React.useState(filterCategories[0].categoryKey); + + const onCategorySelect = ( + category: FilterCategory + ) => { + setCurrentFilterCategoryKey(category.categoryKey); + setIsCategoryDropdownOpen(false); + }; + + const setFilterValue = ( + category: FilterCategory, + newValue: FilterValue + ) => setFilterValues({ ...filterValues, [category.categoryKey]: newValue }); + + const currentFilterCategory = filterCategories.find( + (category) => category.categoryKey === currentFilterCategoryKey + ); + + const filterGroups = filterCategories.reduce( + (groups, category) => + !category.filterGroup || groups.includes(category.filterGroup) + ? groups + : [...groups, category.filterGroup], + [] as string[] + ); + + const renderDropdownItems = () => { + if (filterGroups.length) { + return filterGroups.map((filterGroup) => ( + + + {filterCategories + .filter( + (filterCategory) => filterCategory.filterGroup === filterGroup + ) + .map((filterCategory) => { + return ( + onCategorySelect(filterCategory)} + > + {filterCategory.title} + + ); + })} + + + )); + } else { + return filterCategories.map((category) => ( + onCategorySelect(category)} + > + {category.title} + + )); + } + }; + + return ( + <> + } + breakpoint="2xl" + spaceItems={ + showFiltersSideBySide ? { default: "spaceItemsMd" } : undefined + } + > + {!showFiltersSideBySide && ( + + ( + + setIsCategoryDropdownOpen(!isCategoryDropdownOpen) + } + isDisabled={isDisabled} + > + {currentFilterCategory?.title} + + )} + isOpen={isCategoryDropdownOpen} + > + {renderDropdownItems()} + + + )} + + {filterCategories.map((category) => ( + + key={category.categoryKey} + category={category} + filterValue={filterValues[category.categoryKey]} + setFilterValue={(newValue) => setFilterValue(category, newValue)} + showToolbarItem={ + showFiltersSideBySide || + currentFilterCategory?.categoryKey === category.categoryKey + } + isDisabled={isDisabled} + /> + ))} + + {pagination ? ( + {pagination} + ) : null} + + ); +}; diff --git a/ui/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx b/ui/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx new file mode 100644 index 000000000..25f4ba4a5 --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/MultiselectFilterControl.tsx @@ -0,0 +1,363 @@ +import * as React from "react"; +import { + Badge, + Button, + MenuToggle, + MenuToggleElement, + Select, + SelectGroup, + SelectList, + SelectOption, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, + ToolbarChip, + ToolbarFilter, + Tooltip, +} from "@patternfly/react-core"; +import { IFilterControlProps } from "./FilterControl"; +import { + IMultiselectFilterCategory, + FilterSelectOptionProps, +} from "./FilterToolbar"; +import { css } from "@patternfly/react-styles"; +import { TimesIcon } from "@patternfly/react-icons"; + +import "./select-overrides.css"; + +export interface IMultiselectFilterControlProps + extends IFilterControlProps { + category: IMultiselectFilterCategory; + isScrollable?: boolean; +} + +export const MultiselectFilterControl = ({ + category, + filterValue, + setFilterValue, + showToolbarItem, + isDisabled = false, + isScrollable = false, +}: React.PropsWithChildren< + IMultiselectFilterControlProps +>): JSX.Element | null => { + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = React.useState(false); + + const [selectOptions, setSelectOptions] = React.useState< + FilterSelectOptionProps[] + >(Array.isArray(category.selectOptions) ? category.selectOptions : []); + + React.useEffect(() => { + setSelectOptions( + Array.isArray(category.selectOptions) ? category.selectOptions : [] + ); + }, [category.selectOptions]); + + const hasGroupings = !Array.isArray(selectOptions); + + const flatOptions: FilterSelectOptionProps[] = !hasGroupings + ? selectOptions + : (Object.values(selectOptions).flatMap( + (i) => i + ) as FilterSelectOptionProps[]); + + const getOptionFromOptionValue = (optionValue: string) => + flatOptions.find(({ value }) => value === optionValue); + + const [focusedItemIndex, setFocusedItemIndex] = React.useState( + null + ); + + const [activeItem, setActiveItem] = React.useState(null); + const textInputRef = React.useRef(); + const [inputValue, setInputValue] = React.useState(""); + + const onFilterClearAll = () => setFilterValue([]); + const onFilterClear = (chip: string | ToolbarChip) => { + const value = typeof chip === "string" ? chip : chip.key; + + if (value) { + const newValue = filterValue?.filter((val) => val !== value) ?? []; + setFilterValue(newValue.length > 0 ? newValue : null); + } + }; + + /* + * Note: Create chips only as `ToolbarChip` (no plain string) + */ + const chips = filterValue + ?.map((value, index) => { + const option = getOptionFromOptionValue(value); + if (!option) { + return null; + } + + const { chipLabel, label, groupLabel } = option; + const displayValue: string = chipLabel ?? label ?? value ?? ""; + + return { + key: value, + node: groupLabel ? ( + {groupLabel}} + > +
{displayValue}
+
+ ) : ( + displayValue + ), + }; + }) + + .filter(Boolean); + + const renderSelectOptions = ( + filter: (option: FilterSelectOptionProps, groupName?: string) => boolean + ) => + hasGroupings + ? Object.entries( + selectOptions as Record + ) + .sort(([groupA], [groupB]) => groupA.localeCompare(groupB)) + .map(([group, options]): [string, FilterSelectOptionProps[]] => [ + group, + options?.filter((o) => filter(o, group)) ?? [], + ]) + .filter(([, groupFiltered]) => groupFiltered?.length) + .map(([group, groupFiltered], index) => ( + + {groupFiltered.map(({ value, label, optionProps }) => ( + + {label ?? value} + + ))} + + )) + : flatOptions + .filter((o) => filter(o)) + .map(({ label, value, optionProps = {} }, index) => ( + + {label ?? value} + + )); + + const onSelect = (value: string | undefined) => { + if (value && value !== "No results") { + let newFilterValue: string[]; + + if (filterValue && filterValue.includes(value)) { + newFilterValue = filterValue.filter((item) => item !== value); + } else { + newFilterValue = filterValue ? [...filterValue, value] : [value]; + } + + setFilterValue(newFilterValue); + } + textInputRef.current?.focus(); + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + if (isFilterDropdownOpen) { + if (key === "ArrowUp") { + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + } + + if (key === "ArrowDown") { + if ( + focusedItemIndex === null || + focusedItemIndex === selectOptions.length - 1 + ) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + } + + setFocusedItemIndex(indexToFocus); + const focusedItem = selectOptions.filter( + ({ optionProps }) => !optionProps?.isDisabled + )[indexToFocus]; + setActiveItem( + `select-multi-typeahead-checkbox-${focusedItem.value.replace(" ", "-")}` + ); + } + }; + + React.useEffect(() => { + let newSelectOptions = Array.isArray(category.selectOptions) + ? category.selectOptions + : []; + + if (inputValue) { + newSelectOptions = Array.isArray(category.selectOptions) + ? category.selectOptions?.filter((menuItem) => + String(menuItem.value) + .toLowerCase() + .includes(inputValue.trim().toLowerCase()) + ) + : []; + + if (!newSelectOptions.length) { + newSelectOptions = [ + { + value: "no-results", + optionProps: { + isDisabled: true, + hasCheckbox: false, + }, + label: `No results found for "${inputValue}"`, + }, + ]; + } + } + + setSelectOptions(newSelectOptions); + setFocusedItemIndex(null); + setActiveItem(null); + }, [inputValue, category.selectOptions]); + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const enabledMenuItems = Array.isArray(selectOptions) + ? selectOptions.filter(({ optionProps }) => !optionProps?.isDisabled) + : []; + const [firstMenuItem] = enabledMenuItems; + const focusedItem = focusedItemIndex + ? enabledMenuItems[focusedItemIndex] + : firstMenuItem; + + const newSelectOptions = flatOptions.filter((menuItem) => + menuItem.value.toLowerCase().includes(inputValue.toLowerCase()) + ); + const selectedItem = + newSelectOptions.find( + (option) => option.value.toLowerCase() === inputValue.toLowerCase() + ) || focusedItem; + + switch (event.key) { + case "Enter": + if (!isFilterDropdownOpen) { + setIsFilterDropdownOpen((prev) => !prev); + } else if (selectedItem && selectedItem.value !== "No results") { + onSelect(selectedItem.value); + } + break; + case "Tab": + case "Escape": + setIsFilterDropdownOpen(false); + setActiveItem(null); + break; + case "ArrowUp": + case "ArrowDown": + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + default: + break; + } + }; + + const onTextInputChange = ( + _event: React.FormEvent, + value: string + ) => { + setInputValue(value); + if (!isFilterDropdownOpen) { + setIsFilterDropdownOpen(true); + } + }; + + const toggle = (toggleRef: React.Ref) => ( + { + setIsFilterDropdownOpen(!isFilterDropdownOpen); + }} + isExpanded={isFilterDropdownOpen} + isDisabled={isDisabled || !category.selectOptions.length} + isFullWidth + > + + { + setIsFilterDropdownOpen(!isFilterDropdownOpen); + }} + onChange={onTextInputChange} + onKeyDown={onInputKeyDown} + id="typeahead-select-input" + autoComplete="off" + innerRef={textInputRef} + placeholder={category.placeholderText} + {...(activeItem && { "aria-activedescendant": activeItem })} + role="combobox" + isExpanded={isFilterDropdownOpen} + aria-controls="select-typeahead-listbox" + /> + + + {!!inputValue && ( + + )} + {filterValue?.length ? ( + {filterValue.length} + ) : null} + + + + ); + + return ( + onFilterClear(chip)} + deleteChipGroup={onFilterClearAll} + categoryName={category.title} + showToolbarItem={showToolbarItem} + > + + + ); +}; diff --git a/ui/client/src/app/components/FilterToolbar/SearchFilterControl.tsx b/ui/client/src/app/components/FilterToolbar/SearchFilterControl.tsx new file mode 100644 index 000000000..02244919e --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/SearchFilterControl.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { + ToolbarFilter, + InputGroup, + TextInput, + Button, + ButtonVariant, +} from "@patternfly/react-core"; +import SearchIcon from "@patternfly/react-icons/dist/esm/icons/search-icon"; +import { IFilterControlProps } from "./FilterControl"; +import { ISearchFilterCategory } from "./FilterToolbar"; + +export interface ISearchFilterControlProps< + TItem, + TFilterCategoryKey extends string, +> extends IFilterControlProps { + category: ISearchFilterCategory; + isNumeric: boolean; +} + +export const SearchFilterControl = ({ + category, + filterValue, + setFilterValue, + showToolbarItem, + isNumeric, + isDisabled = false, +}: React.PropsWithChildren< + ISearchFilterControlProps +>): JSX.Element | null => { + // Keep internal copy of value until submitted by user + const [inputValue, setInputValue] = React.useState(filterValue?.[0] || ""); + // Update it if it changes externally + React.useEffect(() => { + setInputValue(filterValue?.[0] || ""); + }, [filterValue]); + + const onFilterSubmit = () => { + const trimmedValue = inputValue.trim(); + setFilterValue(trimmedValue ? [trimmedValue.replace(/\s+/g, " ")] : []); + }; + + const id = `${category.categoryKey}-input`; + return ( + setFilterValue([])} + categoryName={category.title} + showToolbarItem={showToolbarItem} + > + + setInputValue(value)} + aria-label={`${category.title} filter`} + value={inputValue} + placeholder={category.placeholderText} + onKeyDown={(event: React.KeyboardEvent) => { + if (event.key && event.key !== "Enter") return; + onFilterSubmit(); + }} + isDisabled={isDisabled} + /> + + + + ); +}; diff --git a/ui/client/src/app/components/FilterToolbar/SelectFilterControl.tsx b/ui/client/src/app/components/FilterToolbar/SelectFilterControl.tsx new file mode 100644 index 000000000..30eabc379 --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/SelectFilterControl.tsx @@ -0,0 +1,129 @@ +import * as React from "react"; +import { + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + ToolbarFilter, +} from "@patternfly/react-core"; +import { IFilterControlProps } from "./FilterControl"; +import { ISelectFilterCategory } from "./FilterToolbar"; +import { css } from "@patternfly/react-styles"; + +import "./select-overrides.css"; + +export interface ISelectFilterControlProps< + TItem, + TFilterCategoryKey extends string, +> extends IFilterControlProps { + category: ISelectFilterCategory; + isScrollable?: boolean; +} + +export const SelectFilterControl = ({ + category, + filterValue, + setFilterValue, + showToolbarItem, + isDisabled = false, + isScrollable = false, +}: React.PropsWithChildren< + ISelectFilterControlProps +>): JSX.Element | null => { + const [isFilterDropdownOpen, setIsFilterDropdownOpen] = React.useState(false); + + const getOptionFromOptionValue = (optionValue: string) => + category.selectOptions.find(({ value }) => value === optionValue); + + const chips = filterValue + ?.map((value) => { + const option = getOptionFromOptionValue(value); + if (!option) { + return null; + } + const { chipLabel, label } = option; + return { + key: value, + node: chipLabel ?? label ?? value, + }; + }) + .filter(Boolean); + + const onFilterSelect = (value: string) => { + const option = getOptionFromOptionValue(value); + setFilterValue(option ? [value] : null); + setIsFilterDropdownOpen(false); + }; + + const onFilterClear = (chip: string) => { + const newValue = filterValue?.filter((val) => val !== chip); + setFilterValue(newValue?.length ? newValue : null); + }; + + const toggle = (toggleRef: React.Ref) => { + let displayText = "Any"; + if (filterValue && filterValue.length > 0) { + const selectedKey = filterValue[0]; + const selectedDisplayValue = getOptionFromOptionValue(selectedKey)?.label; + displayText = selectedDisplayValue ? selectedDisplayValue : selectedKey; + } + + return ( + { + setIsFilterDropdownOpen(!isFilterDropdownOpen); + }} + isExpanded={isFilterDropdownOpen} + isDisabled={isDisabled || category.selectOptions.length === 0} + > + {displayText} + + ); + }; + + return ( + onFilterClear(chip as string)} + categoryName={category.title} + showToolbarItem={showToolbarItem} + > + + + ); +}; diff --git a/ui/client/src/app/components/FilterToolbar/index.ts b/ui/client/src/app/components/FilterToolbar/index.ts new file mode 100644 index 000000000..bab2f2751 --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/index.ts @@ -0,0 +1 @@ +export * from "./FilterToolbar"; diff --git a/ui/client/src/app/components/FilterToolbar/select-overrides.css b/ui/client/src/app/components/FilterToolbar/select-overrides.css new file mode 100644 index 000000000..8fe8da929 --- /dev/null +++ b/ui/client/src/app/components/FilterToolbar/select-overrides.css @@ -0,0 +1,4 @@ +.pf-v5-c-select.isScrollable .pf-v5-c-select__menu { + max-height: 60vh; + overflow-y: auto; +} diff --git a/ui/client/src/app/components/HookFormPFFields/HookFormPFGroupController.tsx b/ui/client/src/app/components/HookFormPFFields/HookFormPFGroupController.tsx new file mode 100644 index 000000000..47fe53f5a --- /dev/null +++ b/ui/client/src/app/components/HookFormPFFields/HookFormPFGroupController.tsx @@ -0,0 +1,135 @@ +import * as React from "react"; +import { + FormGroup, + FormGroupProps, + FormHelperText, + HelperText, + HelperTextItem, +} from "@patternfly/react-core"; +import { + Control, + Controller, + ControllerProps, + FieldValues, + Path, +} from "react-hook-form"; + +// We have separate interfaces for these props with and without `renderInput` for convenience. +// Generic type params here are the same as the ones used by react-hook-form's . +export interface BaseHookFormPFGroupControllerProps< + TFieldValues extends FieldValues, + TName extends Path, +> { + control: Control; + label?: React.ReactNode; + labelIcon?: React.ReactElement; + name: TName; + fieldId: string; + isRequired?: boolean; + errorsSuppressed?: boolean; + helperText?: React.ReactNode; + className?: string; + formGroupProps?: FormGroupProps; +} + +export interface HookFormPFGroupControllerProps< + TFieldValues extends FieldValues, + TName extends Path, +> extends BaseHookFormPFGroupControllerProps { + renderInput: ControllerProps["render"]; +} + +export const HookFormPFGroupController = < + TFieldValues extends FieldValues = FieldValues, + TName extends Path = Path, +>({ + control, + label, + labelIcon, + name, + fieldId, + isRequired = false, + errorsSuppressed = false, + helperText, + className, + formGroupProps = {}, + renderInput, +}: HookFormPFGroupControllerProps) => ( + + control={control} + name={name} + render={({ field, fieldState, formState }) => { + const { isDirty, isTouched, error } = fieldState; + const shouldDisplayError = + error?.message && (isDirty || isTouched) && !errorsSuppressed; + return ( + + {renderInput({ field, fieldState, formState })} + {helperText || shouldDisplayError ? ( + + + + {shouldDisplayError ? error.message : helperText} + + + + ) : null} + + ); + }} + /> +); + +// Utility for pulling props needed by this component and passing the rest to a rendered input +export const extractGroupControllerProps = < + TFieldValues extends FieldValues, + TName extends Path, + TProps extends BaseHookFormPFGroupControllerProps, +>( + props: TProps +): { + extractedProps: BaseHookFormPFGroupControllerProps; + remainingProps: Omit< + TProps, + keyof BaseHookFormPFGroupControllerProps + >; +} => { + const { + control, + label, + labelIcon, + name, + fieldId, + isRequired, + errorsSuppressed, + helperText, + className, + formGroupProps, + ...remainingProps + } = props; + return { + extractedProps: { + control, + labelIcon, + label, + name, + fieldId, + isRequired, + errorsSuppressed, + helperText, + className, + formGroupProps, + }, + remainingProps, + }; +}; diff --git a/ui/client/src/app/components/HookFormPFFields/HookFormPFSelect.tsx b/ui/client/src/app/components/HookFormPFFields/HookFormPFSelect.tsx new file mode 100644 index 000000000..129dbd133 --- /dev/null +++ b/ui/client/src/app/components/HookFormPFFields/HookFormPFSelect.tsx @@ -0,0 +1,61 @@ +import * as React from "react"; +import { FieldValues, Path } from "react-hook-form"; +import { FormSelect, FormSelectProps } from "@patternfly/react-core"; +import { getValidatedFromErrors } from "@app/utils/utils"; +import { + extractGroupControllerProps, + HookFormPFGroupController, + BaseHookFormPFGroupControllerProps, +} from "./HookFormPFGroupController"; + +export type HookFormPFSelectProps< + TFieldValues extends FieldValues, + TName extends Path, +> = FormSelectProps & + BaseHookFormPFGroupControllerProps & { + children: React.ReactNode; + }; + +export const HookFormPFSelect = < + TFieldValues extends FieldValues = FieldValues, + TName extends Path = Path, +>( + props: HookFormPFSelectProps +) => { + const { extractedProps, remainingProps } = extractGroupControllerProps< + TFieldValues, + TName, + HookFormPFSelectProps + >(props); + const { fieldId, helperText, isRequired, errorsSuppressed } = extractedProps; + const { children, ref, ...rest } = remainingProps; + + return ( + + {...extractedProps} + renderInput={({ + field: { onChange, onBlur, value, name, ref }, + fieldState: { isDirty, error, isTouched }, + }) => ( + + {props.children} + + )} + /> + ); +}; diff --git a/ui/client/src/app/components/HookFormPFFields/HookFormPFTextArea.tsx b/ui/client/src/app/components/HookFormPFFields/HookFormPFTextArea.tsx new file mode 100644 index 000000000..9c6b93416 --- /dev/null +++ b/ui/client/src/app/components/HookFormPFFields/HookFormPFTextArea.tsx @@ -0,0 +1,54 @@ +import * as React from "react"; +import { FieldValues, Path } from "react-hook-form"; +import { TextArea, TextAreaProps } from "@patternfly/react-core"; +import { getValidatedFromErrors } from "@app/utils/utils"; +import { + HookFormPFGroupController, + BaseHookFormPFGroupControllerProps, + extractGroupControllerProps, +} from "./HookFormPFGroupController"; + +export type HookFormPFTextAreaProps< + TFieldValues extends FieldValues, + TName extends Path, +> = TextAreaProps & BaseHookFormPFGroupControllerProps; + +export const HookFormPFTextArea = < + TFieldValues extends FieldValues = FieldValues, + TName extends Path = Path, +>( + props: HookFormPFTextAreaProps +) => { + const { extractedProps, remainingProps } = extractGroupControllerProps< + TFieldValues, + TName, + HookFormPFTextAreaProps + >(props); + const { fieldId, helperText, isRequired, errorsSuppressed } = extractedProps; + return ( + + {...extractedProps} + renderInput={({ + field: { onChange, onBlur, value, name, ref }, + fieldState: { isDirty, error, isTouched }, + }) => ( +