diff --git a/.editorconfig b/.editorconfig index c8b98095..fed6bf6a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,33 +1,23 @@ -# EditorConfig is awesome: https://editorconfig.org +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# http://editorconfig.org -# top-most EditorConfig file root = true -# Unix-style newlines with a newline ending every file [*] -end_of_line = lf -insert_final_newline = true -# Matches multiple files with brace expansion notation -# Set default charset -[*.{js,py}] -charset = utf-8 - -# 4 space indentation -[*.py] +# Change these settings to your own preference indent_style = space indent_size = 4 -# Tab indentation (no size specified) -[Makefile] -indent_style = tab +# We recommend you to keep these unchanged +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true -# Indentation override for all JS under lib directory -[lib/**.js] -indent_style = space -indent_size = 4 +[*.md] +trim_trailing_whitespace = false -# Matches the exact files either package.json or .travis.yml -[{package.json,.travis.yml}] -indent_style = space +[*.json] indent_size = 2 diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 402ec262..42631e3a 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -2,55 +2,65 @@ name: Splunk Integration Build on: [push] +permissions: + contents: read + jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout codebase - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.9' - - - name: Install Application Dependencies - run: | - make build - make venv-tools - - # SEE https://github.com/actions/upload-artifact?tab=readme-ov-file#permission-loss - - name: Tar files (only way to preserve perms) - run: tar -cvf artifact.tar . - - - name: Store Build Artifact - uses: actions/upload-artifact@v4 - with: - name: splunk-app - path: artifact.tar - - test: - runs-on: ubuntu-latest - needs: build - steps: - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.9' - - - name: Download Build Artifact - uses: actions/download-artifact@v4 - with: - name: splunk-app - - - name: Unpack Tar Artifact - run: tar -xvf artifact.tar - - - name: Lint - run: make lint - - - name: Splunk AppInspect - run: make validate - - - name: Test - run: make test + build: + runs-on: ubuntu-latest + steps: + - name: Checkout codebase + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Build, package, and install tooling + run: | + make package + make venv-tools + + # SEE https://github.com/actions/upload-artifact?tab=readme-ov-file#permission-loss + - name: Tar files (only way to preserve perms) + run: tar -cvf artifact.tar . + + - name: Store Build Artifact + uses: actions/upload-artifact@v4 + with: + name: splunk-app + path: artifact.tar + + test: + runs-on: ubuntu-latest + needs: build + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Download Build Artifact + uses: actions/download-artifact@v4 + with: + name: splunk-app + + - name: Unpack Tar Artifact + run: tar -xvf artifact.tar + + # After unpacking so action-setup can read packageManager from package.json + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Lint + run: make lint + + - name: Splunk AppInspect + run: make validate + + - name: Test + run: make test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ac769c53..21b637df 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,26 +1,31 @@ name: Publish Splunk Application to Splunkbase on: - release: - types: [published] + release: + types: [published] + +permissions: + contents: read jobs: - publish: - runs-on: ubuntu-latest - steps: - - name: Checkout codebase - uses: actions/checkout@v4 + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout codebase + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.9' + - name: Install pnpm + uses: pnpm/action-setup@v4 - - name: Install Application Dependencies - run: | - make build + - name: Build and package + run: | + make package - - name: Package and Publish - run: | - make package - SPLUNKBASE_CREDS='${{ secrets.SPLUNKBASE_CREDS }}' make publish + - name: Publish to Splunkbase + run: | + SPLUNKBASE_CREDS='${{ secrets.SPLUNKBASE_CREDS }}' make publish diff --git a/.gitignore b/.gitignore index 1644cb42..3f455488 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,34 @@ -*.DS_Store -flare.tar.gz -venv -venv-tools -packages/flare/local -packages/flare/metadata/local.meta -__pycache__/ -.vscode/ - -packages/flare/bin/vendor/* .DS_Store .idea/ -lerna-debug.log node_modules npm-debug.log test-reports coverage_report -yarn-error.log +pnpm-debug.log licenses.json functional-temp splunktional-temp -output/ -logs/ +# Build output +dist/ +build/ +venv-tools/ +packages/flare/stage/ + +# OS files +desktop.ini + +# Python / Splunk +__pycache__/ +.mypy_cache/ +.pytest_cache/ +*.py[cod] +local/ +*local.meta +*.log +packages/flare/src/main/resources/splunk/bin/lib + +# Environment variables +.env +.env.local + diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..0eb313cc --- /dev/null +++ b/.prettierignore @@ -0,0 +1,27 @@ +# Dependencies and Python tooling +node_modules/ +venv-tools/ +.mypy_cache/ +.pytest_cache/ + +# Build output and CI artifacts +dist/ +build/ +packages/flare/stage/ +packages/*/types/ +packages/configuration/*.js +coverage_report/ +test-reports/ +functional-temp/ +splunktional-temp/ + +# Lockfiles and generated reports +pnpm-lock.yaml +licenses.json + +# Vendored Python dependencies +packages/flare/src/main/resources/splunk/bin/lib/ + +# Splunk runtime / local state +**/local/ +*.log diff --git a/.prettierrc b/.prettierrc index 8716f410..66a5c114 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,6 +2,6 @@ printWidth: 100 singleQuote: true tabWidth: 4 overrides: - - files: "*.json" - options: - tabWidth: 2 + - files: '*.json' + options: + tabWidth: 2 diff --git a/.ruff.toml b/.ruff.toml index 405c804d..12f7b5cb 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -24,6 +24,7 @@ ignore = [ "E722", "F403", "F405", + "E402", ] [lint.isort] diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..9842068d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Change Log + +## 1.0.0 – 2026‑05‑28 + +- Added **tenant filter** support in the Search UI. +- Introduced **Application Logs** and **Dashboard Charts** tabs for richer visualisation of events. +- Added configuration options: + - Ingestion interval selector. + - “Ingest full event data” checkbox to toggle between metadata‑only and full payload. +- Implemented **dynamic links** from the result table to the corresponding Flare event in the Flare UI. +- Improved tenant extraction and filtering across search results and dashboards. +- Refreshed documentation to describe the new UI tabs and configuration options. +- Migrated workspace dependency management from Yarn to pnpm for improved performance and reliability. diff --git a/Makefile b/Makefile index da352fb4..93e8ad57 100644 --- a/Makefile +++ b/Makefile @@ -1,63 +1,62 @@ -.PHONY: build -build: - $(MAKE) clean - $(MAKE) venv - $(MAKE) setup-web - -.PHONY: setup-web -setup-web: venv yarn.lock - yarn run setup - -venv: requirements.txt - python -m venv venv - venv/bin/pip install --upgrade pip - venv/bin/pip install --target packages/flare/bin/vendor -r requirements.txt - @find packages/flare/bin/vendor -type d -name "*.dist-info" -exec rm -r {} + - @find packages/flare/bin -type d -name "__pycache__" -exec rm -r {} + - @rm -rf packages/flare/bin/vendor/bin - @rm -rf packages/flare/bin/vendor/packaging - @rm -rf packages/flare/bin/vendor/*-stubs - @find packages/flare/bin/vendor -type f -name "*x86_64-linux-gnu.so" -delete - -venv-tools: requirements.tools.txt venv +# ─── Variables ─────────────────────────────────────────────────────────────── +PYTHON ?= python +STAGE := packages/flare/stage +DIST := dist +APP_BIN_LIB := packages/flare/src/main/resources/splunk/bin/lib + +# Splunk versions declared to Splunkbase on publish. Override per release: +# make publish SPLUNK_VERSIONS=9.3,... +SPLUNK_VERSIONS ?= 9.3,9.4,10.0,10.1,10.2,10.3,10.4,10.5 + +# ─── Aggregate pipeline (mirrors CI: produce then verify) ──────────────────── +.PHONY: ci +ci: package venv-tools lint validate test + +# ─── Dependencies ──────────────────────────────────────────────────────────── +# JS deps. File target so `pnpm install` only runs when manifests change. +node_modules: package.json pnpm-lock.yaml + pnpm install + @touch node_modules + +# Python tooling venv (pytest / mypy / ruff / splunk-appinspect). +venv-tools: requirements.tools.txt rm -rf venv-tools - python -m venv venv-tools + $(PYTHON) -m venv venv-tools venv-tools/bin/pip install --upgrade pip venv-tools/bin/pip install -r requirements.tools.txt -.PHONY: clean -clean: - @echo "Removing venv and venv-tools." - rm -rf venv - rm -rf venv-tools - rm -rf packages/flare/bin/vendor - @find . -type d -name "node_modules" -exec rm -rf {} + - rm -rf output/flare - @rm -f output/flare.tar.gz - @echo "Done." +# ─── Build & package ───────────────────────────────────────────────────────── +# Compile the frontend into packages/flare/stage (webpack also copies the +# Splunk app skeleton from src/main/resources/splunk into stage). +.PHONY: build +build: node_modules + pnpm -r build +# Vendor the Python runtime deps into stage/bin/lib and emit dist/*.tgz. .PHONY: package -package: packages/flare/bin/vendor - -@rm -f output/flare.tar.gz - @find output/flare/bin -type d -name "__pycache__" -exec rm -r {} + - COPYFILE_DISABLE=1 tar \ - --exclude='output/flare/local' \ - --exclude='output/flare/metadata/local.meta' \ - --format ustar \ - -C output \ - -cvzf \ - "output/flare.tar.gz" \ - "flare" +package: build + ./package.sh +# ─── Distribution ──────────────────────────────────────────────────────────── .PHONY: publish -publish: output/flare.tar.gz - curl -u $$SPLUNKBASE_CREDS --request POST https://splunkbase.splunk.com/api/v1/app/7602/new_release/ -F "files[]=@./output/flare.tar.gz" -F "filename=flare.tar.gz" -F "splunk_versions=9.3,9.4" -F "visibility=true" +publish: + @pkg=$$(ls -t $(DIST)/*.tgz 2>/dev/null | head -1); \ + if [ -z "$$pkg" ]; then echo "No package found in $(DIST)/. Run 'make package' first."; exit 1; fi; \ + echo "Publishing $$pkg to Splunkbase..."; \ + curl -u $$SPLUNKBASE_CREDS --request POST \ + https://splunkbase.splunk.com/api/v1/app/7602/new_release/ \ + -F "files[]=@$$pkg" \ + -F "filename=flare.tgz" \ + -F "splunk_versions=$(SPLUNK_VERSIONS)" \ + -F "visibility=true" .PHONY: validate validate: venv-tools @echo "Running Splunk AppInspect..." @echo "If you get an error about \"libmagic\", run \"brew install libmagic\"" - @venv-tools/bin/splunk-appinspect inspect --ci "output/flare" ; \ + @pkg=$$(ls -t $(DIST)/*.tgz 2>/dev/null | head -1); \ + if [ -z "$$pkg" ]; then echo "No package found in $(DIST)/. Run 'make package' first."; exit 1; fi; \ + venv-tools/bin/splunk-appinspect inspect --ci "$$pkg" ; \ status=$$? ; \ if [ "$$status" -eq 0 ] || [ "$$status" -eq 102 ] || [ "$$status" -eq 103 ] ; then \ exit 0 ; \ @@ -68,41 +67,55 @@ validate: venv-tools # This is helpful for identifying tags that are emitting warnings TAGS = advanced_xml alert_actions_conf ast bias cloud csv custom_search_commands custom_search_commands_v2 custom_visualizations custom_workflow_actions deprecated_feature developer_guidance django_bindings future java jquery manual markdown migration_victoria modular_inputs offensive packaging_standards private_app private_classic private_victoria pura python3_version removed_feature restmap_config savedsearches security spec splunk_5_0 splunk_6_0 splunk_6_1 splunk_6_2 splunk_6_3 splunk_6_4 splunk_6_5 splunk_6_6 splunk_7_0 splunk_7_1 splunk_7_2 splunk_7_3 splunk_8_0 splunk_9_0 splunk_appinspect web_conf windows .PHONY: inspect-tags -inspect-tags: - @for TAG in $(TAGS); do \ +inspect-tags: venv-tools + @pkg=$$(ls -t $(DIST)/*.tgz 2>/dev/null | head -1); \ + if [ -z "$$pkg" ]; then echo "No package found in $(DIST)/. Run 'make package' first."; exit 1; fi; \ + for TAG in $(TAGS); do \ echo "Tag: $$TAG" ; \ - venv-tools/bin/splunk-appinspect inspect --ci --included-tags $$TAG "output/flare" ; \ + venv-tools/bin/splunk-appinspect inspect --ci --included-tags $$TAG "$$pkg" ; \ done +# ─── Quality ───────────────────────────────────────────────────────────────── .PHONY: test -test: venv-tools - venv-tools/bin/pytest ./packages/flare/tests/**/*.py -vv ; - yarn run test:ci - -.PHONY: format setup-web -format: venv-tools - venv-tools/bin/ruff check --fix --unsafe-fixes - venv-tools/bin/ruff format - yarn run format - -.PHONY: format-check -format-check: venv-tools - venv-tools/bin/ruff check - venv-tools/bin/ruff format --check - yarn run format:verify +test: node_modules + pnpm -r test .PHONY: lint -lint: setup-web venv-tools mypy format-check - yarn run lint +lint: node_modules venv-tools mypy format-check + pnpm -r lint .PHONY: mypy mypy: venv-tools venv-tools/bin/mypy --config-file mypy.ini packages/flare +.PHONY: format +format: venv-tools node_modules + pnpm run format + +.PHONY: format-check +format-check: venv-tools node_modules + pnpm run format:verify + +# ─── Local development ─────────────────────────────────────────────────────── .PHONY: sl sl: splunk-local +# Assemble a runnable app in stage/ (frontend + vendored Python), then run it in +# a local Splunk container (compose mounts packages/flare/stage) with a watcher. .PHONY: splunk-local -splunk-local: venv setup-web +splunk-local: build + SKIP_TARBALL=1 ./package.sh docker compose up -d - yarn run start + pnpm run start + +# ─── Housekeeping ──────────────────────────────────────────────────────────── +.PHONY: clean +clean: + @echo "Cleaning build artifacts..." + rm -rf venv-tools + rm -rf $(DIST) + rm -rf $(STAGE) + rm -rf $(APP_BIN_LIB) + @find . -type d -name "node_modules" -exec rm -rf {} + + @find . -type d -name "__pycache__" -exec rm -rf {} + + @echo "Done." diff --git a/README.md b/README.md index b9990a86..5e9f0b3e 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,103 @@ -# Splunk Integration +# Flare Splunk Integration +## Overview -## Requirements +The **Flare Splunk Integration** is a specialized app designed to seamlessly pull security event data from the [Flare](https://flare.io/) platform directly into your Splunk environment. This application bridges the gap between external digital risk protection intelligence and Splunk's powerful analytics capabilities, enabling security teams to maintain real-time awareness of emerging threats, leaked credentials, and vulnerabilities from the dark web. -At the time of this writing, Splunk Enterprise is at version 9.3.0. This version of splunk requires Python v3.9. +## Key Features -## Installation +- **Automated Event Ingestion:** Automatically retrieve high-fidelity continuous threat exposure events from the Flare API based on a scheduled interval. +- **Dynamic Configuration UI:** Securely manage API keys, proxy settings, backfill days, and tenant selections directly within Splunk. +- **Pre-Built Dashboards:** Includes an **Executive Overview Dashboard** with dynamic severity drilldowns to detailed event tables, and an **Application Logs Dashboard** for administrators to monitor the health and performance of the integration. -Installation instructions are available [here](https://docs.flare.io/splunk-app-integration). +## Getting started -## Architecture Overview +**Prerequisites:** -The project contains a variety of packages that are published and versioned collectively. Each package lives in its own -directory in the `/packages` directory. Each package is self contained, and defines its dependencies in a package.json file. +- **Node.js**: `>= 22` (required by project configuration) +- **pnpm**: `9.x` or later for workspace dependency management -We use [Yarn Workspaces](https://yarnpkg.com/lang/en/docs/workspaces/) and [Lerna](https://github.com/lerna/lerna) for -managing and publishing multiple packages in the same repository. +Once the required versions are installed, you can proceed to install the project dependencies: +```bash +$ pnpm run setup +``` + +You’ll have two main directories, one for the created React page and one for the created Splunk app. -## Development -We use an official splunk docker image for development, by binding the local folder to a folder in the container. In order for this to work, you need to have build the application before starting the docker. +- `packages/configuration` +- `packages/flare` + +## Splunk demo + +Splunk demo will allow you to view your new app inside your local Splunk instance: ```bash -make build +# navigate to your app folder +$ cd packages/flare + +# link the app to your local Splunk instance +$ pnpm run link:app + +# check that the link is set (optional) +$ ls -l $SPLUNK_HOME/etc/apps/flare_splunk_app + +# restart Splunk (will start Splunk if not already started) +$ splunk restart + +# navigate to the root project directory +$ cd ../../ + +# start the Splunk app +$ pnpm run start ``` -Then you can start the local docker and the frontend runner. +This will watch both your `flare` and `configuration` folders for changes and rebundle. + +You should now see your app in the left hand menu of the Splunk Enterprise home page, typically located at `https://localhost:8000`. + +There is no hot-reloading within Splunk, you'll need to manually refresh the page to see changes. + +If you are not seeing your changes you can try: + +- hard reloading **Shift+Command+R** (Ctrl+Shift+R on Windows) in Google Chrome +- disabling Splunk asset cache (not recommended for production environments) +- using `https://localhost:8000/en-US/_bump` + +For comprehensive instructions on configuring the index, modifying search macros, setting up proxies, leveraging saved searches, and exporting logs, please refer to the detailed **[Installation Guide and User Guide for Flare Splunk](https://docs.google.com/document/d/1VkZKqMMHkePH3HAfB1nB5G3NJ9SjGdV3T88ESpolUTY)** included in this repository. + +## Building & CI + +The build is driven by `make`, and the exact same targets run in GitHub Actions +and locally. `package.sh` is the single packaging tool; `make` orchestrates it. + +| Command | What it does | +| --------------- | -------------------------------------------------------------------------------------------- | +| `make ci` | Full pipeline (what CI runs): build → package → tooling → lint → validate → test. | +| `make build` | Compile the frontend into `packages/flare/stage/`. | +| `make package` | Vendor the Python runtime deps and produce the installable app at `dist/*.tgz`. | +| `make validate` | Run Splunk AppInspect against the packaged `dist/*.tgz`. | +| `make lint` | ESLint + Stylelint + mypy + Prettier check. | +| `make format` | Auto-format (Prettier + Ruff). | +| `make test` | Run the workspace test suites. | +| `make clean` | Remove build artifacts (`dist/`, `stage/`, `venv-tools/`, vendored `lib/`, `node_modules/`). | + +Prerequisites for `make`: Node.js `>= 22`, `pnpm` `9.x`, Python `3.9`, and Docker +(for `make splunk-local`). AppInspect needs `libmagic` (`brew install libmagic` on macOS). + +### Local Splunk via Docker ```bash -make splunk-local +$ make splunk-local ``` + +This builds the app, vendors the Python dependencies into `packages/flare/stage`, +starts a Splunk container (compose mounts `packages/flare/stage` as the app), and +runs the webpack watcher. Open `https://localhost:8000` to view the app. This is +an alternative to the `pnpm run link:app` symlink flow above and does not require +a local Splunk installation. + +## Support + +For technical support, assistance with specific use cases, or additional guidance regarding the Flare Splunk Integration, please contact: +**Email**: [support@metronlabs.com](mailto:support@metronlabs.com) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0c811b53..d203d08a 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,53 +1,74 @@ # Flare -1.3.4 ------ -* Update the minimum version of the Flare SDK. - -1.3.3 ------ -* Bump the version number and to update the splunkbase listing to support Splunk v10. - -1.3.2 ------ -* Improve debugging and fix an issue where ingestion would terminate when an exception occurs. - -1.3.1 ------ -* Adds support for custom number of days to backfill - -1.3.0 ------ -* Multi-tenant configuration is now supported -* Adds retry mechanism for full event data -* Removes dependency on KV store for improved compatibility -* Various bug fixes - -1.2.2 ------ -* Various bug fixes -* Changes start date to now - 30 days - -1.2.1 ------ -* Various bug fixes -* Prevents use of Python 3.7 - -1.2.0 ------ -* Various bug fixes -* 'Events metadata only' option has been changed to 'Full event data' and is now off by default -* Severity filter -* Source type filter - -1.1.0 ------ -* Various bug fixes -* Adds index chooser during configuration - -1.0.0 ------ -* Release of Splunk Flare app. - -0.1.0 ------ -* Initial pre-release of Splunk Flare app. + +## 1.3.4 + +- Update the minimum version of the Flare SDK. + + 1.3.3 + +--- + +- Bump the version number and to update the splunkbase listing to support Splunk v10. + + 1.3.2 + +--- + +- Improve debugging and fix an issue where ingestion would terminate when an exception occurs. + + 1.3.1 + +--- + +- Adds support for custom number of days to backfill + + 1.3.0 + +--- + +- Multi-tenant configuration is now supported +- Adds retry mechanism for full event data +- Removes dependency on KV store for improved compatibility +- Various bug fixes + + 1.2.2 + +--- + +- Various bug fixes +- Changes start date to now - 30 days + + 1.2.1 + +--- + +- Various bug fixes +- Prevents use of Python 3.7 + + 1.2.0 + +--- + +- Various bug fixes +- 'Events metadata only' option has been changed to 'Full event data' and is now off by default +- Severity filter +- Source type filter + + 1.1.0 + +--- + +- Various bug fixes +- Adds index chooser during configuration + + 1.0.0 + +--- + +- Release of Splunk Flare app. + + 0.1.0 + +--- + +- Initial pre-release of Splunk Flare app. diff --git a/compose.yml b/compose.yml index bdaa90c0..2bde4e2f 100644 --- a/compose.yml +++ b/compose.yml @@ -1,17 +1,17 @@ services: - splunk: - image: splunk/splunk:latest - container_name: splunk - platform: linux/amd64 - restart: unless-stopped - ports: - - "8000:8000" - - "8088:8088" - environment: - - SPLUNK_START_ARGS=--accept-license - - SPLUNK_PASSWORD=a_password - - SPLUNK_GENERAL_TERMS=--accept-sgt-current-at-splunk-com - volumes: - - ./output/flare:/opt/splunk/etc/apps/flare - - ./splunk/default.yml:/tmp/defaults/default.yml - - ./logs:/opt/splunk/var/log/splunk + splunk: + image: splunk/splunk:latest + container_name: splunk + platform: linux/amd64 + restart: unless-stopped + ports: + - '8000:8000' + - '8088:8088' + environment: + - SPLUNK_START_ARGS=--accept-license + - SPLUNK_PASSWORD=a_password + - SPLUNK_GENERAL_TERMS=--accept-sgt-current-at-splunk-com + volumes: + - ./packages/flare/stage:/opt/splunk/etc/apps/flare_splunk_app + - ./splunk/default.yml:/tmp/defaults/default.yml + - ./logs:/opt/splunk/var/log/splunk diff --git a/lerna.json b/lerna.json deleted file mode 100644 index b1e275db..00000000 --- a/lerna.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "lerna": "2.0.0", - "commands": { - "publish": { - "ignore": ["*.md"] - } - }, - "npmClient": "yarn", - "useWorkspaces": true -} diff --git a/mypy.ini b/mypy.ini index 801a81de..7533efc2 100644 --- a/mypy.ini +++ b/mypy.ini @@ -12,6 +12,5 @@ strict_equality = True disallow_incomplete_defs = True disallow_untyped_defs = True disallow_untyped_calls = True -exclude = (vendor*)/$ +exclude = (/stage/|/bin/lib/|/vendor/) follow_imports = skip -mypy_path = packages/flare/bin/vendor diff --git a/package.json b/package.json index 23e609a9..8d8d2e59 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,25 @@ { - "name": "@splunk/flare", - "license": "Apache 2.0", + "name": "@splunk/splunk-create-monorepo", + "license": "UNLICENSED", "private": true, + "packageManager": "pnpm@11.8.0", "scripts": { - "build": "lerna run build", - "format": "git ls-files | grep -E \"\\.(jsx|css|tsx?)$\" | xargs prettier --write", - "format:verify": "git ls-files | grep -E \"\\.(jsx|css|tsx?)$\" | xargs prettier --list-different", - "lint": "lerna run lint --no-sort", - "setup": "yarn && lerna run build", - "start": "lerna run start --stream --no-sort --concurrency 100", - "test": "cd packages/react-components && yarn run test", - "test:ci": "cd packages/react-components && yarn run test:ci" + "build": "pnpm -r build", + "format": "pnpm format:python && pnpm format:prettier", + "format:verify": "pnpm format:python:verify && pnpm format:prettier:verify", + "format:prettier": "prettier --write .", + "format:prettier:verify": "prettier --check .", + "format:python": "node scripts/format-python.cjs", + "format:python:verify": "node scripts/format-python.cjs --check", + "lint": "pnpm -r lint", + "setup": "pnpm install && pnpm run build", + "test": "pnpm -r test", + "start": "pnpm --filter @splunk/flare run start" }, "devDependencies": { - "lerna": "^2.9.0", - "prettier": "^2.0.5" + "prettier": "^3.6.2" }, - "workspaces": [ - "packages/*" - ], "engines": { - "node": ">=14" + "node": ">=22" } } diff --git a/package.sh b/package.sh new file mode 100755 index 00000000..8baad2f1 --- /dev/null +++ b/package.sh @@ -0,0 +1,98 @@ +#!/bin/bash + +################################################################################ +# This is a script for generating an installable app package for Splunk. +# It ensures the build is fresh by removing local development artifacts. +################################################################################ + +set -e # Stop script on errors + +# Set up build variables +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" +PACKAGE_DIR=$SCRIPT_DIR/dist +REQUIREMENTS_FILE="$SCRIPT_DIR/requirements.txt" +PYTHON="${PYTHON:-python}" + +# Clean previous builds +rm -rf $PACKAGE_DIR/* +mkdir -p $PACKAGE_DIR + +# Identification +COMMIT_ID=$(git rev-parse --short=6 HEAD 2>/dev/null || echo "local") +APP_FOLDER="flare_splunk_app" +REAL_SRC_DIR=$SCRIPT_DIR/packages/flare/src/main/resources/splunk +SRC_DIR=$SCRIPT_DIR/packages/flare/stage +FULLAPP_DIR=$PACKAGE_DIR/$APP_FOLDER + +echo "Building package for $APP_FOLDER (Commit: $COMMIT_ID)..." + +mkdir -p $FULLAPP_DIR + +# ─── SYNC src → stage ──────────────────────────────────────────────────────── +# Always sync the real source into stage before packaging. +echo "Syncing src → stage..." +# Delete old backend folders to prevent stale files, but preserve appserver/ which holds compiled frontend assets +rm -rf "$SRC_DIR/bin" "$SRC_DIR/default" "$SRC_DIR/metadata" "$SRC_DIR/lookups" +cp -R "$REAL_SRC_DIR"/. "$SRC_DIR/" +# Remove Python bytecode artifacts that shouldn't be packaged +find "$SRC_DIR" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true +find "$SRC_DIR" -name '*.pyc' -delete 2>/dev/null || true +echo "Sync complete." +# ───────────────────────────────────────────────────────────────────────────── + +# ─── VENDOR PYTHON DEPENDENCIES ────────────────────────────────────────────── +# Install the runtime dependencies (requirements.txt) into bin/lib/ so the app +# is self-contained. bin/lib is the exact path the Python modules add to sys.path. +echo "Vendoring runtime dependencies into stage/bin/lib..." +"$PYTHON" -m pip install -r "$REQUIREMENTS_FILE" --target="$SRC_DIR/bin/lib" --quiet --upgrade + +# Strip compiled binaries (e.g. charset_normalizer's mypyc .so files): AppInspect +# rejects undeclared binaries, and the packages fall back to their pure-Python .py +# sources. Platform-specific binaries wouldn't run on the Splunk server anyway. +find "$SRC_DIR/bin/lib" \( -name '*.so' -o -name '*.pyd' -o -name '*.dylib' \) -delete + +echo "Runtime dependencies vendored successfully." +# ───────────────────────────────────────────────────────────────────────────── + +# For local development we only need the assembled, runnable app in stage/ — +# skip building the distributable tarball. +if [ -n "${SKIP_TARBALL:-}" ]; then + find "$SRC_DIR" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true + find "$SRC_DIR" -name '*.pyc' -delete 2>/dev/null || true + echo "----------------------------------------------------------------" + echo "SKIP_TARBALL set — runnable app assembled at: $SRC_DIR" + echo "----------------------------------------------------------------" + exit 0 +fi + +cp -R $SRC_DIR/* $FULLAPP_DIR/ + +echo "Cleaning local development artifacts..." + +rm -rf $FULLAPP_DIR/local +rm -rf $FULLAPP_DIR/metadata/local.meta + +rm -rf $FULLAPP_DIR/lookups/*.csv + +# Remove specific unnecessary UI files +rm -rf $FULLAPP_DIR/default/data/ui/nav/default-ia.xml + + +echo "Purging newly generated pip install bytecode artifacts..." +find "$FULLAPP_DIR" -name '__pycache__' -type d -exec rm -rf {} + 2>/dev/null || true +find "$FULLAPP_DIR" -name '*.pyc' -delete 2>/dev/null || true +# ───────────────────────────────────────────────────────────────────────────── + +# Creating the tarball (.tgz) +cd $PACKAGE_DIR +FILENAME="$APP_FOLDER-${COMMIT_ID}.tgz" +# COPYFILE_DISABLE/--no-xattrs: keep macOS bsdtar from emitting AppleDouble +# ._* entries (from xattrs like com.apple.provenance), which fail AppInspect +COPYFILE_DISABLE=1 tar --no-xattrs -czf $FILENAME $APP_FOLDER + +# Cleanup the temporary folder after zipping +rm -rf $FULLAPP_DIR + +echo "----------------------------------------------------------------" +echo "SUCCESS: Package created at: $PACKAGE_DIR/$FILENAME" +echo "----------------------------------------------------------------" diff --git a/packages/configuration/.babelrc.js b/packages/configuration/.babelrc.js new file mode 100644 index 00000000..1d78f8eb --- /dev/null +++ b/packages/configuration/.babelrc.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ['@splunk/babel-preset', '@babel/preset-typescript'], +}; diff --git a/packages/configuration/.eslintrc.js b/packages/configuration/.eslintrc.js new file mode 100644 index 00000000..b5ccb02d --- /dev/null +++ b/packages/configuration/.eslintrc.js @@ -0,0 +1,34 @@ +module.exports = { + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + extends: ['@splunk/eslint-config/base', '@splunk/eslint-config/browser-prettier'], + rules: { + 'react/jsx-filename-extension': ['error', { extensions: ['.tsx', '.jsx'] }], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { args: 'after-used', argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + 'no-restricted-syntax': 'off', + 'no-await-in-loop': 'off', + 'no-param-reassign': ['error', { props: false }], + 'no-use-before-define': ['error', { functions: false, variables: false }], + 'react-hooks/exhaustive-deps': 'warn', + 'jsx-a11y/click-events-have-key-events': 'warn', + 'jsx-a11y/no-static-element-interactions': 'warn', + 'import/no-extraneous-dependencies': [ + 'error', + { + devDependencies: ['src/**/tests/*.unit*'], + }, + ], + }, + overrides: [ + { + files: ['src/**/tests/*.unit*'], + env: { + jest: true, + }, + }, + ], +}; diff --git a/packages/react-components/.gitignore b/packages/configuration/.gitignore similarity index 58% rename from packages/react-components/.gitignore rename to packages/configuration/.gitignore index f09ad0b6..44d76167 100644 --- a/packages/react-components/.gitignore +++ b/packages/configuration/.gitignore @@ -1,6 +1,8 @@ +demo/splunk-app/appserver/static/pages +types + /*.js !/.babelrc.js !/.eslintrc.js !/jest.config.js !/*.config.js -!/build.js diff --git a/packages/react-components/.npmignore b/packages/configuration/.npmignore similarity index 97% rename from packages/react-components/.npmignore rename to packages/configuration/.npmignore index 47f30315..cf0d102b 100644 --- a/packages/react-components/.npmignore +++ b/packages/configuration/.npmignore @@ -1,5 +1,7 @@ # Source code /src/ +/test/ +/demo/ # Tools /.babelrc* diff --git a/packages/react-components/build.js b/packages/configuration/bin/build.js similarity index 62% rename from packages/react-components/build.js rename to packages/configuration/bin/build.js index ff4d02ba..6e7062c0 100644 --- a/packages/react-components/build.js +++ b/packages/configuration/bin/build.js @@ -4,11 +4,11 @@ const shell = require('shelljs'); const OS = require('os').platform().toLocaleLowerCase(); const arg = process.argv[2]; -const commands = ['build']; +const commands = ['build', 'link', 'demo']; if (!arg) { shell.echo( - `No command received, please supply a command to run. \nCommands: ${commands.join(', ')}` + `No command received, please supply a command to run. \nCommands: ${commands.join(', ')}`, ); shell.exit(1); } @@ -22,9 +22,13 @@ if (!commands.includes(arg)) { const runCommands = { win32: { build: () => shell.exec('set NODE_ENV=production&&.\\node_modules\\.bin\\webpack --mode=production'), + demo: () => shell.exec('.\\node_modules\\.bin\\webpack serve --config .\\demo\\webpack.standalone.config.js --port 8080'), + link: () => shell.exec('mklink /D "%SPLUNK_HOME%\\etc\\apps\\configuration" "%cd%\\demo\\splunk-app"'), }, nix: { build: () => shell.exec('export NODE_ENV=production && ./node_modules/.bin/webpack --mode=production'), + demo: () => shell.exec('./node_modules/.bin/webpack serve --config demo/webpack.standalone.config.js --port 8080'), + link: () => shell.exec('ln -s $PWD/demo/splunk-app $SPLUNK_HOME/etc/apps/configuration-demo-app'), }, }; diff --git a/packages/react-components/jest.config.js b/packages/configuration/jest.config.js similarity index 52% rename from packages/react-components/jest.config.js rename to packages/configuration/jest.config.js index af03d113..8aa9eb35 100644 --- a/packages/react-components/jest.config.js +++ b/packages/configuration/jest.config.js @@ -1,3 +1,5 @@ module.exports = { testMatch: ['**/*.unit.[jt]s?(x)'], + testEnvironment: 'jsdom', + passWithNoTests: true, }; diff --git a/packages/configuration/package.json b/packages/configuration/package.json new file mode 100644 index 00000000..541bbbf7 --- /dev/null +++ b/packages/configuration/package.json @@ -0,0 +1,85 @@ +{ + "name": "@splunk/configuration", + "version": "0.0.1", + "license": "UNLICENSED", + "scripts": { + "build": "node bin/build.js build && pnpm types:build", + "eslint": "eslint src --ext \".ts,.tsx,.js,.jsx\"", + "eslint:ci": "pnpm run eslint -f junit -o test-reports/lint-results.xml", + "eslint:fix": "eslint src --ext \".ts,.tsx,.js,.jsx\" --fix", + "link:app": "node bin/build.js link", + "lint": "pnpm run eslint && pnpm run stylelint", + "lint:ci": "pnpm run eslint:ci && pnpm run stylelint", + "start:app": "webpack --watch --config demo/webpack.splunkapp.config.js", + "start:demo": "node bin/build.js demo", + "stylelint": "stylelint \"src/**/*.{ts,tsx,js,jsx}\" --config stylelint.config.js", + "test": "jest", + "test:ci": "JEST_JUNIT_OUTPUT_DIR=./test-reports JEST_JUNIT_OUTPUT_NAME=unit-results.xml JEST_JUNIT_CLASSNAME=unit pnpm run test --ci --reporters=default jest-junit --coverage --coverageDirectory=coverage_report/coverage_maps_unit --coverageReporters=cobertura", + "test:watch": "jest --watch", + "types:build": "tsc", + "types:start": "pnpm types:build --watch" + }, + "main": "src/index.ts", + "dependencies": { + "@splunk/react-ui": "^5.9.0", + "@splunk/splunk-utils": "^3.4.0", + "@splunk/themes": "^1.6.0" + }, + "devDependencies": { + "@babel/core": "^7.28.0", + "@babel/eslint-parser": "^7.28.0", + "@babel/preset-typescript": "^7.28.5", + "@splunk/babel-preset": "^4.0.0", + "@splunk/eslint-config": "^5.0.0", + "@splunk/stylelint-config": "^5.0.0", + "@splunk/webpack-configs": "^7.0.3", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/jest": "^30.0.0", + "@types/node": "^18.16.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@types/styled-components": "^5.1.0", + "@types/webpack-env": "^1.15.2", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "babel-loader": "^8.3.0", + "css-loader": "^7.1.2", + "eslint": "^8.57.1", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-prettier": "^9.1.0", + "eslint-import-resolver-webpack": "^0.13.4", + "eslint-plugin-import": "^2.30.1", + "eslint-plugin-jest": "^28.8.3", + "eslint-plugin-jest-dom": "^5.4.0", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.36.1", + "eslint-plugin-react-hooks": "^4.6.2", + "eslint-plugin-testing-library": "^6.3.0", + "html-webpack-plugin": "^5.5.3", + "imports-loader": "^4.0.1", + "jest": "^30.1.3", + "jest-environment-jsdom": "^30.1.2", + "jest-junit": "^10.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "shelljs": "^0.8.5", + "style-loader": "^4.0.0", + "styled-components": "^5.3.10", + "stylelint": "^15.11.0", + "typescript": "^5.8.3", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^5.2.2", + "webpack-merge": "^5.9.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "styled-components": "^5.3.10" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/configuration/src/Configuration.tsx b/packages/configuration/src/Configuration.tsx new file mode 100644 index 00000000..2ec1aeea --- /dev/null +++ b/packages/configuration/src/Configuration.tsx @@ -0,0 +1,774 @@ +import React, { useCallback, useEffect, useState, useRef } from 'react'; + +import Button from '@splunk/react-ui/Button'; +import ControlGroup from '@splunk/react-ui/ControlGroup'; +import Heading from '@splunk/react-ui/Heading'; +import Message from '@splunk/react-ui/Message'; +import Select from '@splunk/react-ui/Select'; +import Switch from '@splunk/react-ui/Switch'; +import Text from '@splunk/react-ui/Text'; +import WaitSpinner from '@splunk/react-ui/WaitSpinner'; +import { SplunkThemeProvider } from '@splunk/themes'; + +import { Severity, SourceType, SourceTypeCategory, Tenant } from './models/flare'; +import { DEFAULT_INDEX_NAME, LOG_LEVEL_OPTIONS } from './models/constants'; +import { + fetchApiKey, + fetchIngestionInterval, + fetchLogLevel, + fetchTenantIds, + saveConfiguration, + fetchAvailableIndexNames, + fetchCurrentIndexName, + fetchIngestFullEventData, + fetchNumberOfDaysToBackfill, + fetchSeveritiesFilter, + fetchSourceTypesFilter, + createFlareIndex, + getSeverityFilterValue, + getSourceTypesFilterValue, + fetchProxyEnabled, + fetchProxyHost, + fetchProxyPort, + fetchProxyType, + fetchProxyUsername, + fetchProxyPassword, + fetchSslVerify, + disableIngestion, +} from './utils/setupConfiguration'; + +import { useMessages } from './hooks/useMessages'; +import { useApiKeyValidation } from './hooks/useApiKeyValidation'; +import { useFormHandlers } from './hooks/useFormHandlers'; +import { + validateInterval, + validateBackfill, + isProxyValid, + isFormValid as checkFormValid, +} from './validation/formValidation'; +import { ApiKeyField } from './components/ApiKeyField'; +import { TenantSelect } from './components/TenantSelect'; +import { SeverityFilter } from './components/SeverityFilter'; +import { CategoriesFilter } from './components/CategoriesFilter'; +import { ProxySettings } from './components/ProxySettings'; +import { SaveConfirmModal } from './components/SaveConfirmModal'; +import { ResetConfirmModal } from './components/ResetConfirmModal'; + +const Configuration = () => { + // ── UI status state ─────────────────────────────────────────────── + const [isInitializing, setIsInitializing] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + const [isDirty, setIsDirty] = useState(false); + const [isSaveModalOpen, setIsSaveModalOpen] = useState(false); + const [isResetModalOpen, setIsResetModalOpen] = useState(false); + const [showMacroWarningModal, setShowMacroWarningModal] = useState(false); + const saveBtnRef = useRef(null); + const resetBtnRef = useRef(null); + const prevApiKeyRef = useRef(null); + const proxyEnabledRef = useRef(false); + const savedSeveritiesFilterRef = useRef(undefined); + const savedSourceTypesFilterRef = useRef(undefined); + + // ── Form field state ────────────────────────────────────────────── + const [apiKey, setApiKey] = useState(''); + const [tenants, setTenants] = useState([]); + const [selectedTenantIds, setSelectedTenantIds] = useState([]); + const [logLevel, setLogLevel] = useState('INFO'); + const [ingestionInterval, setIngestionInterval] = useState('1440'); + const [indexName, setIndexName] = useState(''); + const [initialIndexName, setInitialIndexName] = useState(''); + const [indexNames, setIndexNames] = useState([]); + const [isIngestingFullEventData, setIsIngestingFullEventData] = useState(false); + const [numberOfDaysToBackfill, setNumberOfDaysToBackfill] = useState('5'); + const [, setIsFirstSetup] = useState(false); + + // ── Proxy state ─────────────────────────────────────────────────── + const [proxyEnabled, setProxyEnabled] = useState(false); + const [proxyType, setProxyType] = useState('http'); + const [proxyHost, setProxyHost] = useState(''); + const [proxyPort, setProxyPort] = useState(''); + const [proxyUsername, setProxyUsername] = useState(''); + const [proxyPassword, setProxyPassword] = useState(''); + const [sslVerify, setSslVerify] = useState(true); + + // ── Filter state ────────────────────────────────────────────────── + const [severities, setSeverities] = useState([]); + const [sourceTypeCategories, setSourceTypeCategories] = useState([]); + const [selectedSeverities, setSelectedSeverities] = useState([]); + const [selectedSourceTypes, setSelectedSourceTypes] = useState([]); + + // ── Hooks ───────────────────────────────────────────────────────── + const { + setSuccessMessage, + errorMessage, + setErrorMessage, + showSuccess, + showError, + clearMessages, + } = useMessages(); + + const { + isValidatingApiKey, + isApiKeyValidated, + setIsApiKeyValidated, + apiKeyError, + setApiKeyError, + validateApiKeyOnly, + loadApiKeyDependentData, + } = useApiKeyValidation(prevApiKeyRef, proxyEnabledRef, clearMessages, showError, { + setTenants, + setSeverities, + setSourceTypeCategories, + setSelectedSeverities, + setSelectedSourceTypes, + setSelectedTenantIds, + }); + + const handlers = useFormHandlers(proxyEnabledRef, selectedSeverities, selectedSourceTypes, { + setApiKey, + setApiKeyError, + setSelectedTenantIds, + setLogLevel, + setIngestionInterval, + setIndexName, + setNumberOfDaysToBackfill, + setIsIngestingFullEventData, + setSelectedSeverities, + setSelectedSourceTypes, + setSourceTypeCategories, + setProxyEnabled, + setProxyType, + setProxyHost, + setProxyPort, + setProxyUsername, + setProxyPassword, + setSslVerify, + setIsDirty, + clearMessages, + }); + + // ── Derived validation ──────────────────────────────────────────── + const isIntervalValid = validateInterval(ingestionInterval); + const isBackfillValid = validateBackfill(numberOfDaysToBackfill); + const proxyValid = isProxyValid(proxyEnabled, proxyHost, proxyPort); + const formValid = checkFormValid({ + apiKey, + isApiKeyValidated, + selectedTenantIds, + selectedSeveritiesCount: selectedSeverities.length, + selectedSourceTypesCount: selectedSourceTypes.length, + interval: ingestionInterval, + backfill: numberOfDaysToBackfill, + proxyEnabled, + proxyHost, + proxyPort, + }); + + // ── Initialization: load saved configuration ────────────────────── + useEffect(() => { + Promise.all([ + fetchApiKey(), + createFlareIndex(), + fetchAvailableIndexNames(), + fetchCurrentIndexName(), + fetchTenantIds(), + fetchIngestFullEventData(), + fetchNumberOfDaysToBackfill(), + fetchIngestionInterval(), + fetchLogLevel(), + fetchSeveritiesFilter(), + fetchSourceTypesFilter(), + fetchProxyEnabled(), + fetchProxyType(), + fetchProxyHost(), + fetchProxyPort(), + fetchProxyUsername(), + fetchProxyPassword(), + fetchSslVerify(), + ]) + .then( + ([ + savedApiKey, + , + availableIndexNames, + currentIndex, + savedTenantIds, + ingestFullEvent, + backfillDays, + interval, + savedLogLevel, + savedSeveritiesFilter, + savedSourceTypesFilter, + savedProxyEnabled, + savedProxyType, + savedProxyHost, + savedProxyPort, + savedProxyUsername, + savedProxyPassword, + savedSslVerify, + ]) => { + setIsSaveModalOpen(false); + setIsResetModalOpen(false); + setSuccessMessage(''); + setErrorMessage(''); + + setApiKey(savedApiKey); + prevApiKeyRef.current = savedApiKey; + setIndexNames(availableIndexNames); + setIndexName(currentIndex || DEFAULT_INDEX_NAME); + setInitialIndexName(currentIndex || DEFAULT_INDEX_NAME); + setSelectedTenantIds(savedTenantIds); + setIsIngestingFullEventData(ingestFullEvent); + setNumberOfDaysToBackfill(backfillDays || '5'); + setIngestionInterval( + interval + ? String(Math.max(1, Math.floor(parseInt(interval, 10) / 60))) + : '1440', + ); + setLogLevel(savedLogLevel); + setProxyEnabled(savedProxyEnabled); + proxyEnabledRef.current = savedProxyEnabled; + setProxyType(savedProxyType); + setProxyHost(savedProxyHost); + setProxyPort(savedProxyPort); + setProxyUsername(savedProxyUsername); + setProxyPassword(savedProxyPassword); + setSslVerify(savedSslVerify); + + savedSeveritiesFilterRef.current = savedSeveritiesFilter; + savedSourceTypesFilterRef.current = savedSourceTypesFilter; + + if (!savedTenantIds.length) { + setIsFirstSetup(true); + } + + if (savedApiKey && savedApiKey.length > 0) { + const proxyConfig = { + proxyEnabled: savedProxyEnabled, + proxyType: savedProxyType, + proxyHost: savedProxyHost, + proxyPort: savedProxyPort, + proxyUsername: savedProxyUsername, + proxyPassword: savedProxyPassword, + }; + loadApiKeyDependentData( + savedApiKey, + savedSeveritiesFilter, + savedSourceTypesFilter, + proxyConfig, + ); + } + + setIsInitializing(false); + }, + ) + .catch(() => { + setIsInitializing(false); + showError('Failed to load configuration. Please refresh the page.'); + }); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Debounced API key auto-validation ───────────────────────────── + useEffect(() => { + if (isInitializing) { + return undefined; + } + if (!apiKey || apiKey.length === 0) { + setIsApiKeyValidated(false); + setTenants([]); + setSelectedTenantIds([]); + setApiKeyError(''); + prevApiKeyRef.current = apiKey; + return undefined; + } + + const timerId = setTimeout(() => { + const isNewApiKey = apiKey !== prevApiKeyRef.current; + prevApiKeyRef.current = apiKey; + + const currentProxyConfig = { + proxyEnabled, + proxyType, + proxyHost, + proxyPort, + proxyUsername, + proxyPassword, + }; + + const parsedPort = parseInt(proxyPort, 10); + const proxyIsInvalid = + proxyEnabled && + (proxyHost.trim() === '' || + Number.isNaN(parsedPort) || + parsedPort < 1 || + parsedPort > 65535); + if (proxyIsInvalid) { + return; + } + + validateApiKeyOnly(apiKey, currentProxyConfig).then((isValid) => { + if (isValid && (isNewApiKey || tenants.length === 0)) { + loadApiKeyDependentData( + apiKey, + isNewApiKey ? undefined : savedSeveritiesFilterRef.current, + isNewApiKey ? undefined : savedSourceTypesFilterRef.current, + currentProxyConfig, + ); + } + }); + }, 800); + + return () => clearTimeout(timerId); + }, [ + apiKey, + validateApiKeyOnly, + loadApiKeyDependentData, + isInitializing, + proxyEnabled, + proxyType, + proxyHost, + proxyPort, + proxyUsername, + proxyPassword, + ]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Save handler ────────────────────────────────────────────────── + const handleSave = useCallback((): void => { + clearMessages(); + setIsSaving(true); + + const proceedWithSave = () => { + if ( + !isApiKeyValidated || + !proxyValid || + selectedTenantIds.length === 0 || + selectedSeverities.length === 0 || + selectedSourceTypes.length === 0 || + !isIntervalValid || + !isBackfillValid + ) { + setIsSaving(false); + return; + } + const hasIndexChanged = indexName !== initialIndexName; + const tenantNamesMap: Record = {}; + tenants.forEach((t) => { + if (selectedTenantIds.includes(t.id)) { + tenantNamesMap[String(t.id)] = t.name; + } + }); + + saveConfiguration( + apiKey, + selectedTenantIds, + tenantNamesMap, + indexName, + isIngestingFullEventData, + getSeverityFilterValue(selectedSeverities, severities), + getSourceTypesFilterValue(selectedSourceTypes, sourceTypeCategories), + ingestionInterval ? String(parseInt(ingestionInterval, 10) * 60) : '60', + numberOfDaysToBackfill, + logLevel, + proxyEnabled, + proxyHost, + proxyPort, + proxyType, + proxyUsername, + proxyPassword, + sslVerify, + ) + .then(() => { + setIsSaving(false); + setIsDirty(false); + setShowMacroWarningModal(hasIndexChanged); + if (hasIndexChanged) { + setInitialIndexName(indexName); + } + setIsSaveModalOpen(true); + }) + .catch((e: any) => { + setIsSaving(false); + showError(`Failed to save configuration. ${e?.responseText || ''}`); + }); + }; + + if (proxyEnabled) { + const proxyConfig = { + proxyEnabled, + proxyType, + proxyHost, + proxyPort, + proxyUsername, + proxyPassword, + }; + validateApiKeyOnly(apiKey, proxyConfig).then((isValid) => { + if (isValid) { + proceedWithSave(); + } else { + setIsSaving(false); + } + }); + } else { + proceedWithSave(); + } + }, [ + apiKey, + selectedTenantIds, + tenants, + indexName, + initialIndexName, + isIngestingFullEventData, + selectedSeverities, + severities, + selectedSourceTypes, + sourceTypeCategories, + ingestionInterval, + numberOfDaysToBackfill, + logLevel, + proxyEnabled, + proxyHost, + proxyPort, + proxyType, + proxyUsername, + proxyPassword, + sslVerify, + isApiKeyValidated, + proxyValid, + isIntervalValid, + isBackfillValid, + clearMessages, + showError, + validateApiKeyOnly, + ]); + + // ── Remove configuration handler ────────────────────────────────── + const handleRemoveConfiguration = useCallback((): void => { + setIsRemoving(true); + clearMessages(); + + saveConfiguration( + '', + [], + {}, + indexName, + false, + '', + '', + '60', + '', + 'INFO', + false, + '', + '', + 'http', + '', + '', + true, + ) + .then(() => disableIngestion()) + .then(() => { + setApiKey(''); + setSelectedTenantIds([]); + setTenants([]); + setLogLevel('INFO'); + setIngestionInterval(''); + setNumberOfDaysToBackfill(''); + setIsIngestingFullEventData(false); + setIsApiKeyValidated(false); + setSelectedSeverities([]); + setSelectedSourceTypes([]); + setSeverities([]); + setSourceTypeCategories([]); + setApiKeyError(''); + setProxyEnabled(false); + setProxyType('http'); + setProxyHost(''); + setProxyPort(''); + setProxyUsername(''); + setProxyPassword(''); + setIsRemoving(false); + setIsResetModalOpen(false); + setIsDirty(false); + showSuccess('Configuration removed successfully. Data ingestion has been stopped.'); + }) + .catch((e: any) => { + setIsRemoving(false); + setIsResetModalOpen(false); + showError(`Failed to remove configuration. ${e?.responseText || ''}`); + }); + }, [indexName, clearMessages, showSuccess, showError, setIsApiKeyValidated, setApiKeyError]); + + // ── Loading screen ──────────────────────────────────────────────── + if (isInitializing) { + return ( + +
+ +
+
+ ); + } + + // ── Render ──────────────────────────────────────────────────────── + return ( + +
+
+ + Configure Flare Account + + {/* Scoped style: keeps the link identical across :link/:visited/:active states */} + + + Learn More + +
+ + {/* Index for Ingestion */} + + + + + {/* API Key */} + + + {/* Tenants */} + + + {/* Remaining fields — disabled until API key is validated */} +
+ {/* Severity & Categories */} + + + + + {/* Initial Backfill Range */} + +
+ + {!isBackfillValid && ( +
+ Invalid backfill range. Must be between 0 and 180 days. +
+ )} +
+
+ + {/* Ingestion Interval */} + +
+ + {!isIntervalValid && ( +
+ Invalid ingestion interval. Must be between 1 and 2880 minutes. +
+ )} +
+
+ + {/* Ingest Full Event Data */} + +
+ + + Ingest Full Event Data + +
+
+ + {/* Log Level */} + + + +
+ + {/* Proxy Settings */} + + + {/* Action buttons */} +
+
+ + {/* Error message */} + {errorMessage && ( +
+ setErrorMessage('')}> + {errorMessage} + +
+ )} + + {/* Modals */} + setIsSaveModalOpen(false)} + /> + setIsResetModalOpen(false)} + onConfirm={handleRemoveConfiguration} + /> +
+
+ ); +}; + +export default Configuration; diff --git a/packages/configuration/src/components/ApiKeyField.tsx b/packages/configuration/src/components/ApiKeyField.tsx new file mode 100644 index 00000000..a7a9452b --- /dev/null +++ b/packages/configuration/src/components/ApiKeyField.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import ControlGroup from '@splunk/react-ui/ControlGroup'; +import Text from '@splunk/react-ui/Text'; +import WaitSpinner from '@splunk/react-ui/WaitSpinner'; + +interface ApiKeyFieldProps { + apiKey: string; + apiKeyError: string; + isValidatingApiKey: boolean; + onChange: (_e: unknown, { value }: { value: string }) => void; +} + +/** + * Flare API Key input field with inline red error and validation spinner. + */ +export function ApiKeyField({ + apiKey, + apiKeyError, + isValidatingApiKey, + onChange, +}: ApiKeyFieldProps) { + return ( + 0} + tooltip={apiKeyError || undefined} + > +
+ 0} + /> + {isValidatingApiKey && ( +
+ + Validating API key… +
+ )} + {apiKeyError && !isValidatingApiKey && ( +
+ {apiKeyError} +
+ )} +
+
+ ); +} diff --git a/packages/configuration/src/components/CategoriesFilter.tsx b/packages/configuration/src/components/CategoriesFilter.tsx new file mode 100644 index 00000000..59533545 --- /dev/null +++ b/packages/configuration/src/components/CategoriesFilter.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import CollapsiblePanel, { SingleOpenPanelGroup } from '@splunk/react-ui/CollapsiblePanel'; +import ControlGroup from '@splunk/react-ui/ControlGroup'; +import { SourceType, SourceTypeCategory } from '../models/flare'; + +interface CategoriesFilterProps { + sourceTypeCategories: SourceTypeCategory[]; + selectedSourceTypes: SourceType[]; + isValidatingApiKey: boolean; + isCategoryFullySelected: (c: SourceTypeCategory) => boolean; + isSourceTypeSelected: (s: SourceType) => boolean; + onCategoryToggle: (c: SourceTypeCategory) => void; + onSourceTypeToggle: (s: SourceType) => void; +} + +/** + * Collapsible category / source-type filter tree with inline empty-selection error. + */ +export function CategoriesFilter({ + sourceTypeCategories, + selectedSourceTypes, + isValidatingApiKey, + isCategoryFullySelected, + isSourceTypeSelected, + onCategoryToggle, + onSourceTypeToggle, +}: CategoriesFilterProps) { + const hasError = !isValidatingApiKey && selectedSourceTypes.length === 0; + + return ( + +
+ + {sourceTypeCategories.map((category) => { + if (category.types.length === 0) { + return ( +
+
+ onCategoryToggle(category)} + style={{ accentColor: '#5b9cf4', cursor: 'pointer' }} + /> + + {category.label} + +
+
+ ); + } + + return ( + + { + e.stopPropagation(); + onCategoryToggle(category); + }} + onClick={(e) => e.stopPropagation()} + style={{ accentColor: '#5b9cf4', cursor: 'pointer' }} + /> + + {category.label} + +
+ } + > +
+ {category.types.map((sourceType) => ( +
onSourceTypeToggle(sourceType)} + > + {}} // Handled by onClick + style={{ accentColor: '#5b9cf4' }} + /> + + {sourceType.label} + +
+ ))} +
+ + ); + })} + + {hasError && ( +
+ At least one event category must be selected. +
+ )} + +
+ ); +} diff --git a/packages/configuration/src/components/ProxySettings.tsx b/packages/configuration/src/components/ProxySettings.tsx new file mode 100644 index 00000000..dc81a386 --- /dev/null +++ b/packages/configuration/src/components/ProxySettings.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import ControlGroup from '@splunk/react-ui/ControlGroup'; +import Heading from '@splunk/react-ui/Heading'; +import Select from '@splunk/react-ui/Select'; +import Switch from '@splunk/react-ui/Switch'; +import Text from '@splunk/react-ui/Text'; +import { isProxyHostMissing, isProxyPortInvalid } from '../validation/formValidation'; + +interface ProxySettingsProps { + proxyEnabled: boolean; + proxyType: string; + proxyHost: string; + proxyPort: string; + proxyUsername: string; + proxyPassword: string; + sslVerify: boolean; + onProxyEnabledChange: (_e: unknown, { selected }: { selected: boolean }) => void; + onProxyTypeChange: (_e: unknown, { value }: { value: string | number | boolean }) => void; + onProxyHostChange: (_e: unknown, { value }: { value: string }) => void; + onProxyPortChange: (_e: unknown, { value }: { value: string }) => void; + onProxyUsernameChange: (_e: unknown, { value }: { value: string }) => void; + onProxyPasswordChange: (_e: unknown, { value }: { value: string }) => void; + onSslVerifyChange: (_e: unknown, { selected }: { selected: boolean }) => void; +} + +/** + * Full Proxy Settings section — enable toggle, SSL switch, and all proxy sub-fields. + */ +export function ProxySettings({ + proxyEnabled, + proxyType, + proxyHost, + proxyPort, + proxyUsername, + proxyPassword, + sslVerify, + onProxyEnabledChange, + onProxyTypeChange, + onProxyHostChange, + onProxyPortChange, + onProxyUsernameChange, + onProxyPasswordChange, + onSslVerifyChange, +}: ProxySettingsProps) { + const hostMissing = isProxyHostMissing(proxyEnabled, proxyHost); + const portInvalid = isProxyPortInvalid(proxyEnabled, proxyPort); + + return ( +
+ Proxy Settings + + + + + + + + + + {proxyEnabled && ( +
+ + + + + +
+ + {hostMissing && ( +
+ Proxy host is required when proxy is enabled. +
+ )} +
+
+ + +
+ + {portInvalid && ( +
+ Invalid proxy port. Must be a number between 1 and 65535. +
+ )} +
+
+ + + + + + + + +
+ )} +
+ ); +} diff --git a/packages/configuration/src/components/ResetConfirmModal.tsx b/packages/configuration/src/components/ResetConfirmModal.tsx new file mode 100644 index 00000000..7a13ee02 --- /dev/null +++ b/packages/configuration/src/components/ResetConfirmModal.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import Button from '@splunk/react-ui/Button'; +import Message from '@splunk/react-ui/Message'; +import Modal from '@splunk/react-ui/Modal'; + +interface ResetConfirmModalProps { + open: boolean; + isRemoving: boolean; + resetBtnRef: React.RefObject; + onCancel: () => void; + onConfirm: () => void; +} + +/** + * "Confirm Removal" modal — warns the user before wiping all configuration + * and stopping data ingestion. + */ +export function ResetConfirmModal({ + open, + isRemoving, + resetBtnRef, + onCancel, + onConfirm, +}: ResetConfirmModalProps) { + return ( + + + + + Are you sure you want to remove all configuration values? This will clear your + API key, disable the integration, and immediately stop all Flare data ingestion + into Splunk. + + + + + ))} + + {hasError && ( +
+ At least one severity level must be selected. +
+ )} + + + ); +} diff --git a/packages/configuration/src/components/TenantSelect.tsx b/packages/configuration/src/components/TenantSelect.tsx new file mode 100644 index 00000000..05b54934 --- /dev/null +++ b/packages/configuration/src/components/TenantSelect.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import ControlGroup from '@splunk/react-ui/ControlGroup'; +import Multiselect from '@splunk/react-ui/Multiselect'; +import { Tenant } from '../models/flare'; + +interface TenantSelectProps { + tenants: Tenant[]; + selectedTenantIds: number[]; + isApiKeyValidated: boolean; + isValidatingApiKey: boolean; + isSaving: boolean; + onChange: (_e: unknown, { values }: { values: Array }) => void; +} + +/** + * Flare Tenants multi-select with inline error when no tenant is selected. + */ +export function TenantSelect({ + tenants, + selectedTenantIds, + isApiKeyValidated, + isValidatingApiKey, + isSaving, + onChange, +}: TenantSelectProps) { + const hasError = !isValidatingApiKey && isApiKeyValidated && selectedTenantIds.length === 0; + + return ( + +
+ + {tenants.map((tenant) => ( + + ))} + + {hasError && ( +
+ At least one tenant must be selected. +
+ )} +
+
+ ); +} diff --git a/packages/configuration/src/css.d.ts b/packages/configuration/src/css.d.ts new file mode 100644 index 00000000..522a54f4 --- /dev/null +++ b/packages/configuration/src/css.d.ts @@ -0,0 +1,4 @@ +declare module '*.css' { + const content: { [className: string]: string }; + export default content; +} diff --git a/packages/configuration/src/hooks/useApiKeyValidation.ts b/packages/configuration/src/hooks/useApiKeyValidation.ts new file mode 100644 index 00000000..56f5a59e --- /dev/null +++ b/packages/configuration/src/hooks/useApiKeyValidation.ts @@ -0,0 +1,202 @@ +import { useCallback, useState, MutableRefObject } from 'react'; + +import { Severity, SourceType, SourceTypeCategory, Tenant } from '../models/flare'; +import { + fetchUserTenants, + fetchSeverityFilters, + fetchSourceTypeFilters, + validateApiKey, + convertSeverityFilterToArray, + convertSourceTypeFilterToArray, + ProxyValidationConfig, +} from '../utils/setupConfiguration'; + +interface ApiKeyValidationSetters { + setTenants: (v: Tenant[]) => void; + setSeverities: (v: Severity[]) => void; + setSourceTypeCategories: (v: SourceTypeCategory[]) => void; + setSelectedSeverities: (v: Severity[]) => void; + setSelectedSourceTypes: (v: SourceType[]) => void; + setSelectedTenantIds: (v: number[]) => void; +} + +/** + * Owns the API key validation state and exposes `validateApiKeyOnly` and + * `loadApiKeyDependentData`. The debounced useEffect that triggers these + * remains in Configuration.tsx because it depends on too many cross-cutting + * concerns (apiKey, all proxy fields, isInitializing). + */ +export function useApiKeyValidation( + prevApiKeyRef: MutableRefObject, + proxyEnabledRef: MutableRefObject, + clearMessages: () => void, + showError: (msg: string) => void, + setters: ApiKeyValidationSetters, +) { + const [isValidatingApiKey, setIsValidatingApiKey] = useState(false); + const [isApiKeyValidated, setIsApiKeyValidated] = useState(false); + const [apiKeyError, setApiKeyError] = useState(''); + + const { + setTenants, + setSeverities, + setSourceTypeCategories, + setSelectedSeverities, + setSelectedSourceTypes, + setSelectedTenantIds, + } = setters; + + const loadApiKeyDependentData = useCallback( + ( + key: string, + savedSevsFilter?: string[], + savedTypesFilter?: string[], + proxyConfig?: ProxyValidationConfig, + ): void => { + setIsValidatingApiKey(true); + setApiKeyError(''); + clearMessages(); + + Promise.all([ + fetchUserTenants(key, proxyConfig), + fetchSeverityFilters(key, proxyConfig), + fetchSourceTypeFilters(key, proxyConfig), + ]) + .then(([userTenants, fetchedSeverities, fetchedSourceTypes]) => { + if (key !== prevApiKeyRef.current) { + return; + } + if (proxyConfig && proxyConfig.proxyEnabled !== proxyEnabledRef.current) { + return; + } + setApiKeyError(''); + clearMessages(); + setTenants(userTenants); + setSeverities(fetchedSeverities); + setSourceTypeCategories(fetchedSourceTypes); + setIsApiKeyValidated(true); + setIsValidatingApiKey(false); + + if (savedSevsFilter !== undefined) { + setSelectedSeverities( + convertSeverityFilterToArray(savedSevsFilter, fetchedSeverities), + ); + } else { + setSelectedSeverities(fetchedSeverities); + } + + if (savedTypesFilter !== undefined) { + setSelectedSourceTypes( + convertSourceTypeFilterToArray(savedTypesFilter, fetchedSourceTypes), + ); + } else { + const allTypes: SourceType[] = []; + fetchedSourceTypes.forEach((category) => { + category.types.forEach((t) => allTypes.push(t)); + }); + setSelectedSourceTypes(allTypes); + } + }) + .catch(() => { + if (key !== prevApiKeyRef.current) { + return; + } + if (proxyConfig && proxyConfig.proxyEnabled !== proxyEnabledRef.current) { + return; + } + setApiKeyError('Invalid API key or network error'); + setIsApiKeyValidated(false); + setIsValidatingApiKey(false); + setTenants([]); + setSeverities([]); + setSourceTypeCategories([]); + setSelectedTenantIds([]); + showError('Invalid API key.'); + }); + }, + [ + clearMessages, + showError, + prevApiKeyRef, + proxyEnabledRef, + setTenants, + setSeverities, + setSourceTypeCategories, + setSelectedSeverities, + setSelectedSourceTypes, + setSelectedTenantIds, + ], + ); + + const validateApiKeyOnly = useCallback( + (key: string, proxyConfig?: ProxyValidationConfig): Promise => { + setIsValidatingApiKey(true); + setApiKeyError(''); + clearMessages(); + + return validateApiKey(key, proxyConfig) + .then((result) => { + if (key !== prevApiKeyRef.current) { + return false; + } + if (proxyConfig && proxyConfig.proxyEnabled !== proxyEnabledRef.current) { + return false; + } + + setIsValidatingApiKey(false); + + if (result.valid) { + setApiKeyError(''); + setIsApiKeyValidated(true); + return true; + } + + if (result.error_type === 'proxy_error') { + setApiKeyError('Proxy connection failed'); + showError( + 'Failed to connect through the configured proxy. Please verify your proxy host, port, and credentials.', + ); + // Deliberately NOT setting setIsApiKeyValidated(false) — a proxy/network + // error shouldn't discard the user's previously loaded tenants/filters. + } else if (result.error_type === 'connection_error') { + setApiKeyError('Connection error'); + showError( + 'Unable to reach the Flare API. Please check your network connection.', + ); + } else if (result.error_type === 'auth_error') { + setApiKeyError('Invalid API key'); + showError('Invalid API key. Please check and re-enter your Flare API key.'); + setIsApiKeyValidated(false); + } else { + setApiKeyError('Validation failed'); + showError(result.error || 'API key validation failed. Please try again.'); + setIsApiKeyValidated(false); + } + return false; + }) + .catch(() => { + if (key !== prevApiKeyRef.current) { + return false; + } + if (proxyConfig && proxyConfig.proxyEnabled !== proxyEnabledRef.current) { + return false; + } + setIsValidatingApiKey(false); + setApiKeyError('Validation failed'); + showError('API key validation failed. Please try again.'); + return false; + }); + }, + [clearMessages, showError, prevApiKeyRef, proxyEnabledRef], + ); + + return { + isValidatingApiKey, + isApiKeyValidated, + setIsApiKeyValidated, + apiKeyError, + setApiKeyError, + validateApiKeyOnly, + loadApiKeyDependentData, + }; +} diff --git a/packages/configuration/src/hooks/useFormHandlers.ts b/packages/configuration/src/hooks/useFormHandlers.ts new file mode 100644 index 00000000..cd877590 --- /dev/null +++ b/packages/configuration/src/hooks/useFormHandlers.ts @@ -0,0 +1,307 @@ +import { useCallback, MutableRefObject } from 'react'; + +import { Severity, SourceType, SourceTypeCategory } from '../models/flare'; + +interface FormSetters { + setApiKey: (v: string) => void; + setApiKeyError: (v: string) => void; + setSelectedTenantIds: (v: number[]) => void; + setLogLevel: (v: string) => void; + setIngestionInterval: (v: string) => void; + setIndexName: (v: string) => void; + setNumberOfDaysToBackfill: (v: string) => void; + setIsIngestingFullEventData: (v: boolean) => void; + setSelectedSeverities: (v: Severity[]) => void; + setSelectedSourceTypes: (v: SourceType[]) => void; + setSourceTypeCategories: (v: SourceTypeCategory[]) => void; + setProxyEnabled: (v: boolean) => void; + setProxyType: (v: string) => void; + setProxyHost: (v: string) => void; + setProxyPort: (v: string) => void; + setProxyUsername: (v: string) => void; + setProxyPassword: (v: string) => void; + setSslVerify: (v: boolean) => void; + setIsDirty: (v: boolean) => void; + clearMessages: () => void; +} + +/** + * All onChange / onClick form event handlers for the Configuration page. + * Receives state setters from the parent component — no state is owned here. + */ +export function useFormHandlers( + proxyEnabledRef: MutableRefObject, + selectedSeverities: Severity[], + selectedSourceTypes: SourceType[], + setters: FormSetters, +) { + const { + setApiKey, + setApiKeyError, + setSelectedTenantIds, + setLogLevel, + setIngestionInterval, + setIndexName, + setNumberOfDaysToBackfill, + setIsIngestingFullEventData, + setSelectedSeverities, + setSelectedSourceTypes, + setProxyEnabled, + setProxyType, + setProxyHost, + setProxyPort, + setProxyUsername, + setProxyPassword, + setSslVerify, + setIsDirty, + clearMessages, + } = setters; + + // ── Basic field handlers ──────────────────────────────────────────── + + const handleApiKeyChange = useCallback( + (_e: unknown, { value }: { value: string }): void => { + setApiKeyError(''); + clearMessages(); + setApiKey(value); + setIsDirty(true); + }, + [setApiKeyError, clearMessages, setApiKey, setIsDirty], + ); + + const handleTenantChange = useCallback( + (_e: unknown, { values }: { values: Array }): void => { + setSelectedTenantIds(values.map((v) => Number(v))); + setIsDirty(true); + }, + [setSelectedTenantIds, setIsDirty], + ); + + const handleLogLevelChange = useCallback( + (_e: unknown, { value }: { value: string | number | boolean }): void => { + setLogLevel(String(value)); + setIsDirty(true); + }, + [setLogLevel, setIsDirty], + ); + + const handleIngestionIntervalChange = useCallback( + (_e: unknown, { value }: { value: string }): void => { + const digits = value.replace(/\D/g, ''); + if (digits === '' || digits === '0') { + setIngestionInterval(digits); + } else { + setIngestionInterval(String(Math.max(1, parseInt(digits, 10)))); + } + setIsDirty(true); + }, + [setIngestionInterval, setIsDirty], + ); + + const handleIndexChange = useCallback( + (_e: unknown, { value }: { value: string | number | boolean }): void => { + setIndexName(String(value)); + setIsDirty(true); + }, + [setIndexName, setIsDirty], + ); + + const handleBackfillChange = useCallback( + (_e: unknown, { value }: { value: string }): void => { + const safeValue = value.replace(/\D/g, ''); + if (safeValue === '') { + setNumberOfDaysToBackfill(safeValue); + } else { + setNumberOfDaysToBackfill(String(parseInt(safeValue, 10))); + } + setIsDirty(true); + }, + [setNumberOfDaysToBackfill, setIsDirty], + ); + + const handleIngestFullEventToggle = useCallback( + (_e: unknown, { selected }: { selected: boolean }): void => { + setIsIngestingFullEventData(!selected); + setIsDirty(true); + }, + [setIsIngestingFullEventData, setIsDirty], + ); + + // ── Severity filter handlers ──────────────────────────────────────── + + const isSeveritySelected = useCallback( + (severity: Severity): boolean => selectedSeverities.some((s) => s.value === severity.value), + [selectedSeverities], + ); + + const handleSeverityToggle = useCallback( + (severity: Severity): void => { + if (isSeveritySelected(severity)) { + setSelectedSeverities(selectedSeverities.filter((s) => s.value !== severity.value)); + } else { + setSelectedSeverities([...selectedSeverities, severity]); + } + }, + [selectedSeverities, isSeveritySelected, setSelectedSeverities], + ); + + const handleSeverityToggleWithDirty = useCallback( + (severity: Severity): void => { + handleSeverityToggle(severity); + setIsDirty(true); + }, + [handleSeverityToggle, setIsDirty], + ); + + // ── Source type / category handlers ──────────────────────────────── + + const isSourceTypeSelected = useCallback( + (sourceType: SourceType): boolean => + selectedSourceTypes.some((s) => s.value === sourceType.value), + [selectedSourceTypes], + ); + + const isCategoryFullySelected = useCallback( + (category: SourceTypeCategory): boolean => { + if (category.types.length === 0) { + return isSourceTypeSelected({ label: category.label, value: category.value }); + } + return category.types.every((t) => isSourceTypeSelected(t)); + }, + [isSourceTypeSelected], + ); + + const handleSourceTypeToggle = useCallback( + (sourceType: SourceType): void => { + if (isSourceTypeSelected(sourceType)) { + setSelectedSourceTypes( + selectedSourceTypes.filter((s) => s.value !== sourceType.value), + ); + } else { + setSelectedSourceTypes([...selectedSourceTypes, sourceType]); + } + setIsDirty(true); + }, + [selectedSourceTypes, isSourceTypeSelected, setSelectedSourceTypes, setIsDirty], + ); + + const handleCategoryToggle = useCallback( + (category: SourceTypeCategory): void => { + if (category.types.length === 0) { + handleSourceTypeToggle({ label: category.label, value: category.value }); + return; + } + if (isCategoryFullySelected(category)) { + setSelectedSourceTypes( + selectedSourceTypes.filter( + (s) => !category.types.some((t) => t.value === s.value), + ), + ); + } else { + const allTypes = [...selectedSourceTypes]; + category.types.forEach((t) => { + if (!selectedSourceTypes.some((s) => s.value === t.value)) { + allTypes.push(t); + } + }); + setSelectedSourceTypes(allTypes); + } + setIsDirty(true); + }, + [ + selectedSourceTypes, + isCategoryFullySelected, + handleSourceTypeToggle, + setSelectedSourceTypes, + setIsDirty, + ], + ); + + // ── Proxy handlers ───────────────────────────────────────────────── + + const handleProxyEnabledChange = useCallback( + (_e: unknown, { selected }: { selected: boolean }) => { + const newValue = !selected; + proxyEnabledRef.current = newValue; + setProxyEnabled(newValue); + setIsDirty(true); + }, + [proxyEnabledRef, setProxyEnabled, setIsDirty], + ); + + const handleProxyTypeChange = useCallback( + (_e: unknown, { value }: { value: string | number | boolean }) => { + setProxyType(String(value)); + setIsDirty(true); + }, + [setProxyType, setIsDirty], + ); + + const handleProxyHostChange = useCallback( + (_e: unknown, { value }: { value: string }) => { + setProxyHost(value); + setIsDirty(true); + }, + [setProxyHost, setIsDirty], + ); + + const handleProxyPortChange = useCallback( + (_e: unknown, { value }: { value: string }) => { + setProxyPort(value); + setIsDirty(true); + }, + [setProxyPort, setIsDirty], + ); + + const handleProxyUsernameChange = useCallback( + (_e: unknown, { value }: { value: string }) => { + setProxyUsername(value); + setIsDirty(true); + }, + [setProxyUsername, setIsDirty], + ); + + const handleProxyPasswordChange = useCallback( + (_e: unknown, { value }: { value: string }) => { + setProxyPassword(value); + setIsDirty(true); + }, + [setProxyPassword, setIsDirty], + ); + + const handleSslVerifyChange = useCallback( + (_e: unknown, { selected }: { selected: boolean }) => { + setSslVerify(!selected); + setIsDirty(true); + }, + [setSslVerify, setIsDirty], + ); + + return { + // Basic + handleApiKeyChange, + handleTenantChange, + handleLogLevelChange, + handleIngestionIntervalChange, + handleIndexChange, + handleBackfillChange, + handleIngestFullEventToggle, + // Severity + isSeveritySelected, + handleSeverityToggle, + handleSeverityToggleWithDirty, + // Source types + isSourceTypeSelected, + isCategoryFullySelected, + handleSourceTypeToggle, + handleCategoryToggle, + // Proxy + handleProxyEnabledChange, + handleProxyTypeChange, + handleProxyHostChange, + handleProxyPortChange, + handleProxyUsernameChange, + handleProxyPasswordChange, + handleSslVerifyChange, + }; +} diff --git a/packages/configuration/src/hooks/useMessages.ts b/packages/configuration/src/hooks/useMessages.ts new file mode 100644 index 00000000..0783b6a3 --- /dev/null +++ b/packages/configuration/src/hooks/useMessages.ts @@ -0,0 +1,36 @@ +import { useCallback, useState } from 'react'; + +/** + * Manages global success/error banner messages displayed to the user. + * Success messages auto-dismiss after 5 seconds. + */ +export function useMessages() { + const [successMessage, setSuccessMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + + const showSuccess = useCallback((msg: string) => { + setSuccessMessage(msg); + setErrorMessage(''); + setTimeout(() => setSuccessMessage(''), 5000); + }, []); + + const showError = useCallback((msg: string) => { + setErrorMessage(msg); + setSuccessMessage(''); + }, []); + + const clearMessages = useCallback(() => { + setSuccessMessage(''); + setErrorMessage(''); + }, []); + + return { + successMessage, + setSuccessMessage, + errorMessage, + setErrorMessage, + showSuccess, + showError, + clearMessages, + }; +} diff --git a/packages/configuration/src/index.ts b/packages/configuration/src/index.ts new file mode 100644 index 00000000..e0000b73 --- /dev/null +++ b/packages/configuration/src/index.ts @@ -0,0 +1,2 @@ +export { default } from './Configuration'; +export * from './Configuration'; diff --git a/packages/configuration/src/models/constants.ts b/packages/configuration/src/models/constants.ts new file mode 100644 index 00000000..bcbf8806 --- /dev/null +++ b/packages/configuration/src/models/constants.ts @@ -0,0 +1,41 @@ +import { ApplicationNamespace } from './splunk'; + +export const APP_NAME = 'flare_splunk_app'; +export const DEFAULT_INDEX_NAME = 'flare'; +export const STORAGE_REALM = 'flare_v2_integration_realm'; +export const APPLICATION_NAMESPACE: ApplicationNamespace = { + owner: 'nobody', + app: APP_NAME, + sharing: 'app', +}; +export const FLARE_SAVED_SEARCH_NAME = 'Flare Search'; +export const SEVERITY_SAVED_SEARCH_NAME = 'Severity'; + +export enum PasswordKeys { + API_KEY = 'api_key', + TENANT_IDS = 'tenant_ids', + INGEST_FULL_EVENT_DATA = 'ingest_full_event_data', + SEVERITIES_FILTER = 'severities_filter', + SOURCE_TYPES_FILTER = 'source_types_filter', + NUMBER_OF_DAYS_TO_BACKFILL = 'number_of_days_to_backfill', + INGESTION_INTERVAL = 'ingestion_interval', + LOG_LEVEL = 'log_level', + PROXY_ENABLED = 'proxy_enabled', + PROXY_HOST = 'proxy_host', + PROXY_PORT = 'proxy_port', + PROXY_TYPE = 'proxy_type', + PROXY_USERNAME = 'proxy_username', + PROXY_PASSWORD = 'proxy_password', + INDEX_NAME = 'index_name', + SSL_VERIFY = 'ssl_verify', + TENANT_NAMES = 'tenant_names', +} + +// UI select options for the log level dropdown +export const LOG_LEVEL_OPTIONS = [ + { label: 'DEBUG', value: 'DEBUG' }, + { label: 'INFO', value: 'INFO' }, + { label: 'WARNING', value: 'WARNING' }, + { label: 'ERROR', value: 'ERROR' }, + { label: 'CRITICAL', value: 'CRITICAL' }, +]; diff --git a/packages/react-components/src/models/flare.ts b/packages/configuration/src/models/flare.ts similarity index 79% rename from packages/react-components/src/models/flare.ts rename to packages/configuration/src/models/flare.ts index 12da6be9..c49fa227 100644 --- a/packages/react-components/src/models/flare.ts +++ b/packages/configuration/src/models/flare.ts @@ -3,12 +3,6 @@ export interface Tenant { name: string; } -export enum ConfigurationStep { - Initial = 1, - UserPreferences = 2, - Completed = 3, -} - export interface Severity { value: string; label: string; diff --git a/packages/react-components/src/models/splunk.ts b/packages/configuration/src/models/splunk.ts similarity index 100% rename from packages/react-components/src/models/splunk.ts rename to packages/configuration/src/models/splunk.ts diff --git a/packages/react-components/src/utils/configurationFileHelper.ts b/packages/configuration/src/utils/configurationFileHelper.ts similarity index 88% rename from packages/react-components/src/utils/configurationFileHelper.ts rename to packages/configuration/src/utils/configurationFileHelper.ts index 4d365305..dbecb1fe 100644 --- a/packages/react-components/src/utils/configurationFileHelper.ts +++ b/packages/configuration/src/utils/configurationFileHelper.ts @@ -2,15 +2,11 @@ import { APPLICATION_NAMESPACE } from '../models/constants'; import { ConfigurationFile, Configurations, Entity, HTTPResponse, Service } from '../models/splunk'; import { promisify } from './util'; -// ---------------------------------- // Splunk JS SDK Helpers -// ---------------------------------- -// --------------------- // Existence Functions -// --------------------- function doesConfigurationExist( configurations: Configurations, - configurationFilename: string + configurationFilename: string, ): boolean { for (const stanza of configurations.list()) { if (stanza.name === configurationFilename) { @@ -23,7 +19,7 @@ function doesConfigurationExist( function doesStanzaExist( configurationFileAccessor: ConfigurationFile, - stanzaName: string + stanzaName: string, ): boolean { for (const stanza of configurationFileAccessor.list()) { if (stanza.name === stanzaName) { @@ -34,46 +30,42 @@ function doesStanzaExist( return false; } -// --------------------- +// // Retrieval Functions -// --------------------- +// function getConfigurationFile( configurations: Configurations, - configurationFilename: string + configurationFilename: string, ): Promise { return promisify(configurations.item(configurationFilename, APPLICATION_NAMESPACE).fetch)(); } function getConfigurationFileStanza( configurationFile: ConfigurationFile, - configurationStanzaName: string + configurationStanzaName: string, ): Promise { return promisify( - configurationFile.item(configurationStanzaName, APPLICATION_NAMESPACE).fetch + configurationFile.item(configurationStanzaName, APPLICATION_NAMESPACE).fetch, )(); } function createStanza( configurationFile: ConfigurationFile, - newStanzaName: string + newStanzaName: string, ): Promise { return promisify(configurationFile.create)(newStanzaName); } function updateStanzaProperties( configurationStanza: Entity, - newStanzaProperties: Record + newStanzaProperties: Record, ): Promise { return promisify(configurationStanza.update)(newStanzaProperties); } -// --------------------- -// Process Helpers -// --------------------- - function createConfigurationFile( configurations: Configurations, - configurationFilename: string + configurationFilename: string, ): Promise { return promisify(configurations.create)(configurationFilename); } @@ -82,7 +74,7 @@ export async function updateConfigurationFile( service: Service, configurationFilename: string, stanzaName: string, - properties: Record + properties: Record, ): Promise { // Fetch the accessor used to get a configuration file let configurations = service.configurations(APPLICATION_NAMESPACE); @@ -128,7 +120,7 @@ export async function getConfigurationStanzaValue( configurationFilename: string, stanzaName: string, propertyName: string, - defaultValue: string + defaultValue: string, ): Promise { // Fetch the accessor used to get a configuration file let configurations = service.configurations(APPLICATION_NAMESPACE); @@ -140,7 +132,7 @@ export async function getConfigurationStanzaValue( // Fetchs the configuration stanza accessor const configurationStanzaAccessor = await getConfigurationFileStanza( configurationFile, - stanzaName + stanzaName, ); let propertyValue = defaultValue; diff --git a/packages/configuration/src/utils/setupConfiguration.ts b/packages/configuration/src/utils/setupConfiguration.ts new file mode 100644 index 00000000..bbc12d97 --- /dev/null +++ b/packages/configuration/src/utils/setupConfiguration.ts @@ -0,0 +1,634 @@ +import { + APPLICATION_NAMESPACE, + APP_NAME, + DEFAULT_INDEX_NAME, + FLARE_SAVED_SEARCH_NAME, + PasswordKeys, + STORAGE_REALM, +} from '../models/constants'; +import { IngestionStatus, Severity, SourceType, SourceTypeCategory, Tenant } from '../models/flare'; +import { HTTPResponse, Service, StoragePasswords } from '../models/splunk'; +import { getConfigurationStanzaValue, updateConfigurationFile } from './configurationFileHelper'; +import { promisify } from './util'; + +// eslint-disable-next-line no-undef +declare const splunkjs: any; + +async function completeSetup(splunkService: Service): Promise { + await updateConfigurationFile(splunkService, 'app', 'install', { + is_configured: 'true', + }); +} + +function getRedirectUrl(): string { + return `/app/${APP_NAME}`; +} + +async function getFlareSearchDataUrl(): Promise { + const service = createService(); + const savedSearches = await promisify(service.savedSearches().fetch)(); + const savedSearch = savedSearches.item(FLARE_SAVED_SEARCH_NAME, APPLICATION_NAMESPACE); + return `/app/${APP_NAME}/@go?s=${savedSearch.qualifiedPath}`; +} + +function redirectToHomepage(): void { + window.location.href = getRedirectUrl(); +} + +function createService(): Service { + // The splunkjs is injected by Splunk + const http = new splunkjs.SplunkWebHttp(); + const service: Service = new splunkjs.Service(http, APPLICATION_NAMESPACE); + return service; +} + +export interface ProxyValidationConfig { + proxyEnabled?: boolean; + proxyType?: string; + proxyHost?: string; + proxyPort?: string; + proxyUsername?: string; + proxyPassword?: string; +} + +function appendProxyToData(data: any, proxyConfig?: ProxyValidationConfig) { + if (proxyConfig && proxyConfig.proxyEnabled !== undefined) { + data.proxy_enabled = proxyConfig.proxyEnabled ? 'true' : 'false'; + data.proxy_type = proxyConfig.proxyType || ''; + data.proxy_host = proxyConfig.proxyHost || ''; + data.proxy_port = proxyConfig.proxyPort || ''; + data.proxy_username = proxyConfig.proxyUsername || ''; + data.proxy_password = proxyConfig.proxyPassword || ''; + } + return data; +} + +function fetchUserTenants( + apiKey: string, + proxyConfig?: ProxyValidationConfig, +): Promise> { + const service = createService(); + const data = appendProxyToData({ apiKey }, proxyConfig); + return promisify(service.post)('/services/fetch_user_tenants', data).then( + (response: HTTPResponse) => { + return response.data.tenants; + }, + ); +} + +function fetchSeverityFilters( + apiKey: string, + proxyConfig?: ProxyValidationConfig, +): Promise> { + const service = createService(); + const data = appendProxyToData({ apiKey }, proxyConfig); + return promisify(service.post)('/services/fetch_severity_filters', data).then( + (response: HTTPResponse) => { + return response.data.severities; + }, + ); +} + +function fetchSourceTypeFilters( + apiKey: string, + proxyConfig?: ProxyValidationConfig, +): Promise> { + const service = createService(); + const data = appendProxyToData({ apiKey }, proxyConfig); + return promisify(service.post)('/services/fetch_source_type_filters', data).then( + (response: HTTPResponse) => { + return response.data.categories; + }, + ); +} + +export interface ApiKeyValidationResult { + valid: boolean; + error?: string; + error_type?: 'proxy_error' | 'connection_error' | 'auth_error' | 'unknown'; +} + +function validateApiKey( + apiKey: string, + proxyConfig?: ProxyValidationConfig, +): Promise { + const service = createService(); + const data = appendProxyToData({ apiKey }, proxyConfig); + + return promisify(service.post)('/services/validate_api_key', data) + .then((response: HTTPResponse) => { + return response.data as ApiKeyValidationResult; + }) + .catch((err: any) => { + console.error('[Flare Setup] API Key validation error:', err); + let errorData: ApiKeyValidationResult = { + valid: false, + error: 'Unknown error', + error_type: 'unknown', + }; + + // Attempt to parse Splunk's error response + let responseString = ''; + if (typeof err?.data === 'string') { + responseString = err.data; + } else if (typeof err?.error === 'string') { + responseString = err.error; + } else if (err?.responseText) { + responseString = err.responseText; + } + + try { + if (responseString) { + const parsed = JSON.parse(responseString); + console.error('[Flare Setup] Parsed error JSON:', parsed); + errorData = { + valid: false, + error: parsed.error || 'Validation failed', + error_type: parsed.error_type || 'unknown', + }; + } else if (err?.data?.error) { + errorData = { + valid: false, + error: err.data.error, + error_type: err.data.error_type || 'unknown', + }; + } + } catch (e) { + console.error( + '[Flare Setup] Error parsing validation failure string:', + e, + responseString, + ); + } + return errorData; + }); +} + +async function savePassword(storage: StoragePasswords, key: string, value: string): Promise { + const passwordId = `${STORAGE_REALM}:${key}:`; + + // Check if the current value matches — skip the expensive + // DELETE + CREATE cycle when nothing has changed. + for (const password of storage.list()) { + if (password.name === passwordId) { + if (password.properties().clear_password === value) { + return; // unchanged + } + // Value differs — delete the old entry before creating the new one + await storage.del(passwordId); + break; + } + } + + if (value.length > 0) { + await promisify(storage.create)({ + name: key, + realm: STORAGE_REALM, + password: value, + }); + } +} + +async function saveConfiguration( + apiKey: string, + tenantIds: number[], + tenantNamesMap: Record, + indexName: string, + isIngestingFullEventData: boolean, + severitiesFilter: string, + sourceTypesFilter: string, + ingestionInterval?: string, + numberOfDaysToBackfill?: string, + logLevel?: string, + proxyEnabled?: boolean, + proxyHost?: string, + proxyPort?: string, + proxyType?: string, + proxyUsername?: string, + proxyPassword?: string, + sslVerify: boolean = true, +): Promise { + const service = createService(); + const storagePasswords = await promisify(service.storagePasswords().fetch)(); + await savePassword(storagePasswords, PasswordKeys.API_KEY, apiKey); + await savePassword(storagePasswords, PasswordKeys.TENANT_IDS, JSON.stringify(tenantIds)); + await savePassword(storagePasswords, PasswordKeys.TENANT_NAMES, JSON.stringify(tenantNamesMap)); + await savePassword( + storagePasswords, + PasswordKeys.INGEST_FULL_EVENT_DATA, + `${isIngestingFullEventData}`, + ); + await savePassword( + storagePasswords, + PasswordKeys.NUMBER_OF_DAYS_TO_BACKFILL, + numberOfDaysToBackfill ?? '', + ); + await savePassword(storagePasswords, PasswordKeys.SEVERITIES_FILTER, `${severitiesFilter}`); + await savePassword(storagePasswords, PasswordKeys.SOURCE_TYPES_FILTER, `${sourceTypesFilter}`); + await savePassword(storagePasswords, PasswordKeys.INGESTION_INTERVAL, ingestionInterval ?? ''); + await savePassword(storagePasswords, PasswordKeys.LOG_LEVEL, logLevel ?? 'INFO'); + await savePassword(storagePasswords, PasswordKeys.PROXY_ENABLED, `${proxyEnabled ?? false}`); + await savePassword(storagePasswords, PasswordKeys.PROXY_HOST, proxyHost ?? ''); + await savePassword(storagePasswords, PasswordKeys.PROXY_PORT, proxyPort ?? ''); + await savePassword(storagePasswords, PasswordKeys.PROXY_TYPE, proxyType ?? 'http'); + await savePassword(storagePasswords, PasswordKeys.PROXY_USERNAME, proxyUsername ?? ''); + await savePassword(storagePasswords, PasswordKeys.PROXY_PASSWORD, proxyPassword ?? ''); + await savePassword(storagePasswords, PasswordKeys.SSL_VERIFY, `${sslVerify}`); + await savePassword(storagePasswords, PasswordKeys.INDEX_NAME, indexName); + + await fetchIsFirstConfiguration(); + const activeInterval = + ingestionInterval && ingestionInterval.trim().length > 0 ? ingestionInterval : '60'; + + // ── Batched inputs.conf update ────────────────────────────── + // when something has actually changed. + const inputsStanza = `script://$SPLUNK_HOME/etc/apps/${APP_NAME}/bin/cron_job_ingest_events.py`; + const currentIndex = await getConfigurationStanzaValue( + service, + 'inputs', + inputsStanza, + 'index', + '', + ); + const currentInterval = await getConfigurationStanzaValue( + service, + 'inputs', + inputsStanza, + 'interval', + '', + ); + const currentDisabled = await getConfigurationStanzaValue( + service, + 'inputs', + inputsStanza, + 'disabled', + 'true', + ); + + const inputsNeedUpdate = + currentIndex !== indexName || + currentInterval !== activeInterval || + currentDisabled !== 'false'; + + if (inputsNeedUpdate) { + // Single batched write to inputs.conf — one reload instead of four + await updateConfigurationFile(service, 'inputs', inputsStanza, { + index: indexName, + interval: activeInterval, + disabled: 'false', + }); + + try { + await promisify(service.get)(`/services/apps/local/${APP_NAME}/_reload`, {}); + } catch (err) { + console.warn('Could not actively reload Splunk App configuration engine', err); + } + } + + // Only configure the app if the required core variables are definitively present + if (apiKey && apiKey.trim() !== '' && tenantIds && tenantIds.length > 0) { + await completeSetup(service); + } else { + // If the user clears the configuration, forcefully flag the app as unconfigured + // safely dropping them back into the setup capture screen + await updateConfigurationFile(service, 'app', 'install', { + is_configured: 'false', + }); + } +} + +// updateEventIngestionCronJobInterval and updatePassAuthUsername +// have been consolidated into the batched inputs.conf update inside +// saveConfiguration() to prevent burst-spawning of script processes. + +export async function fetchSslVerify(): Promise { + const service = createService(); + const storagePasswords = await promisify(service.storagePasswords().fetch)(); + + // Default to true if not found for strict security posture natively + let sslVerify = true; + const passwordId = `${STORAGE_REALM}:${PasswordKeys.SSL_VERIFY}:`; + + for (const password of storagePasswords.list()) { + if (password.name === passwordId) { + sslVerify = password.properties().clear_password === 'true'; + break; + } + } + return sslVerify; +} + +async function fetchIngestionStatus(): Promise { + const service = createService(); + + const data = await promisify(service.get)('/services/fetch_ingestion_status', {}).then( + (r: HTTPResponse) => { + return r.data; + }, + ); + + return ( + data || { + last_fetched_at: '', + } + ); +} + +async function fetchPassword(passwordKey: string): Promise { + try { + const service = createService(); + const storagePasswords = await promisify(service.storagePasswords().fetch)(); + const passwordId = `${STORAGE_REALM}:${passwordKey}:`; + + for (const password of storagePasswords.list()) { + if (password.name === passwordId) { + return password.properties().clear_password; + } + } + } catch (e) { + console.warn(`[Mock Fetch] Could not fetch password for ${passwordKey}:`, e); + } + return undefined; +} + +async function fetchApiKey(): Promise { + return (await fetchPassword(PasswordKeys.API_KEY)) || ''; +} + +async function fetchTenantIds(): Promise { + return fetchPassword(PasswordKeys.TENANT_IDS).then((tenantIds) => { + if (!tenantIds) { + return []; + } + try { + return JSON.parse(tenantIds); + } catch { + return []; + } + }); +} + +async function fetchNumberOfDaysToBackfill(): Promise { + return fetchPassword(PasswordKeys.NUMBER_OF_DAYS_TO_BACKFILL).then((numberOfDaysToBackfill) => { + return numberOfDaysToBackfill; + }); +} + +async function fetchIngestionInterval(): Promise { + return fetchPassword(PasswordKeys.INGESTION_INTERVAL).then((interval) => { + return interval; + }); +} + +async function fetchLogLevel(): Promise { + return fetchPassword(PasswordKeys.LOG_LEVEL).then((logLevel) => { + return logLevel || 'INFO'; + }); +} + +async function fetchIngestFullEventData(): Promise { + return fetchPassword(PasswordKeys.INGEST_FULL_EVENT_DATA).then((isIngestingFullEventData) => { + return isIngestingFullEventData === 'true'; + }); +} + +async function fetchSeveritiesFilter(): Promise> { + const savedSeverities = await fetchPassword(PasswordKeys.SEVERITIES_FILTER); + if (savedSeverities) { + return savedSeverities.split(','); + } + + return []; +} + +async function fetchSourceTypesFilter(): Promise> { + const savedSourceTypes = await fetchPassword(PasswordKeys.SOURCE_TYPES_FILTER); + if (savedSourceTypes) { + return savedSourceTypes.split(','); + } + + return []; +} + +async function fetchProxyEnabled(): Promise { + return fetchPassword(PasswordKeys.PROXY_ENABLED).then((val) => val === 'true'); +} + +async function fetchProxyHost(): Promise { + return (await fetchPassword(PasswordKeys.PROXY_HOST)) || ''; +} + +async function fetchProxyPort(): Promise { + return (await fetchPassword(PasswordKeys.PROXY_PORT)) || ''; +} + +async function fetchProxyType(): Promise { + return (await fetchPassword(PasswordKeys.PROXY_TYPE)) || 'http'; +} + +async function fetchProxyUsername(): Promise { + return (await fetchPassword(PasswordKeys.PROXY_USERNAME)) || ''; +} + +async function fetchProxyPassword(): Promise { + return (await fetchPassword(PasswordKeys.PROXY_PASSWORD)) || ''; +} + +async function createFlareIndex(): Promise { + const service = createService(); + const isFirstConfiguration = await fetchIsFirstConfiguration(); + if (isFirstConfiguration) { + const currentIndexNames = await fetchAvailableIndexNames(); + if (!currentIndexNames.find((indexName) => indexName === DEFAULT_INDEX_NAME)) { + await service.indexes().create(DEFAULT_INDEX_NAME, {}); + } + } +} + +// saveIndexForIngestion has been consolidated into the batched +// inputs.conf update inside saveConfiguration(). + +async function disableIngestion(): Promise { + const service = createService(); + try { + await updateConfigurationFile( + service, + 'inputs', + `script://$SPLUNK_HOME/etc/apps/${APP_NAME}/bin/cron_job_ingest_events.py`, + { + disabled: 'true', + }, + ); + } catch (e) { + console.warn('Splunk inputs stanza may not exist yet to disable.', e); + } +} + +async function fetchAvailableIndexNames(): Promise> { + const service = createService(); + const indexes = await promisify(service.indexes().fetch)(); + const indexNames: string[] = []; + const ignoredIndexNames = ['history', 'summary', 'splunklogger']; + for (const { name: indexName } of indexes.list()) { + if (!indexName.startsWith('_') && !ignoredIndexNames.includes(indexName)) { + indexNames.push(indexName); + } + } + return indexNames; +} + +async function fetchIsFirstConfiguration(): Promise { + const service = createService(); + return ( + (await getConfigurationStanzaValue( + service, + 'app', + 'install', + 'is_configured', + 'unknown', + )) !== '1' + ); +} + +async function fetchCurrentIndexName(): Promise { + const service = createService(); + return getConfigurationStanzaValue( + service, + 'inputs', + `script://$SPLUNK_HOME/etc/apps/${APP_NAME}/bin/cron_job_ingest_events.py`, + 'index', + 'main', + ); +} + +async function fetchVersionName(defaultValue: string): Promise { + const service = createService(); + return getConfigurationStanzaValue(service, 'app', 'launcher', 'version', defaultValue); +} + +function convertSeverityFilterToArray( + severitiesFilter: string[], + allSeverities: Severity[], +): Severity[] { + // If no filter is specified, add every severities + if (severitiesFilter.length === 0) { + return [...allSeverities]; + } + + // Otherwise, find the matching severities from the filter + const severities: Severity[] = []; + severitiesFilter.forEach((severityValue) => { + const severityMatch = allSeverities.find((severity) => severity.value === severityValue); + if (severityMatch) { + severities.push(severityMatch); + } + }); + return severities; +} + +function getSeverityFilterValue( + selectedSeverities: Severity[], + _allSeverities: Severity[], +): string { + let severitiesFilter = ''; + + if (selectedSeverities.length === 0) { + return ''; + } + + // Always explicitly set the filter, even if everything is selected + severitiesFilter = selectedSeverities.map((severity) => severity.value).join(','); + return severitiesFilter; +} + +function convertSourceTypeFilterToArray( + sourceTypesFilter: string[], + allSourceTypeCategories: SourceTypeCategory[], +): SourceType[] { + // If no filter is specified, add every sub source types + if (sourceTypesFilter.length === 0) { + return [ + ...allSourceTypeCategories.reduce( + (acc, category) => acc.concat(category.types), + [] as SourceType[], + ), + ]; + } + + // Otherwise, try to match the filter with the source type categories and subtypes + const sourceTypes: SourceType[] = []; + sourceTypesFilter.forEach((sourceTypeValue) => { + // Check if the source type is actually a category and if so, add all of their subtypes + const sourceTypeCategoryMatch = allSourceTypeCategories.find( + (sourceTypeCategory) => sourceTypeCategory.value === sourceTypeValue, + ); + if (sourceTypeCategoryMatch) { + sourceTypes.push(...sourceTypeCategoryMatch.types); + } + + // Check if the source type is a sub type of a category and add it to the list if found + const sourceTypeMatch = allSourceTypeCategories + .reduce((acc, category) => acc.concat(category.types), [] as SourceType[]) + .find((sourceType) => sourceType.value === sourceTypeValue); + if (sourceTypeMatch) { + sourceTypes.push(sourceTypeMatch); + } + }); + return sourceTypes; +} + +function getSourceTypesFilterValue( + selectedSourceTypes: SourceType[], + allSourceTypeCategories: SourceTypeCategory[], +): string { + if (selectedSourceTypes.length === 0) { + return ''; + } + + // Build the filter: include subtype values AND parent category values when all subtypes are selected + const values = new Set(selectedSourceTypes.map((st) => st.value)); + + // If all subtypes of a category are selected, also include the parent category value + allSourceTypeCategories.forEach((category) => { + if (category.types.length > 0 && category.types.every((t) => values.has(t.value))) { + values.add(category.value); + } + }); + + return Array.from(values).join(','); +} + +export { + createFlareIndex, + disableIngestion, + fetchApiKey, + fetchAvailableIndexNames, + fetchSeverityFilters, + fetchIngestionStatus, + fetchCurrentIndexName, + fetchIngestFullEventData, + fetchIngestionInterval, + fetchLogLevel, + fetchSeveritiesFilter, + fetchSourceTypeFilters, + fetchSourceTypesFilter, + fetchNumberOfDaysToBackfill, + fetchTenantIds, + fetchUserTenants, + fetchVersionName, + fetchProxyEnabled, + fetchProxyHost, + fetchProxyPort, + fetchProxyType, + fetchProxyUsername, + fetchProxyPassword, + getFlareSearchDataUrl, + getRedirectUrl, + redirectToHomepage, + saveConfiguration, + validateApiKey, + getSeverityFilterValue, + convertSeverityFilterToArray, + getSourceTypesFilterValue, + convertSourceTypeFilterToArray, +}; diff --git a/packages/react-components/src/utils/util.ts b/packages/configuration/src/utils/util.ts similarity index 92% rename from packages/react-components/src/utils/util.ts rename to packages/configuration/src/utils/util.ts index 36bab87b..b6d92838 100644 --- a/packages/react-components/src/utils/util.ts +++ b/packages/configuration/src/utils/util.ts @@ -1,5 +1,5 @@ function promisify void>( - fn: T + fn: T, ): (...args: Parameters) => Promise extends void ? void : ReturnType> { return (...args: Parameters): Promise extends void ? void : ReturnType> => { return new Promise((resolve, reject) => { @@ -13,7 +13,7 @@ function promisify void>( args.push(callback); - fn.call(this, ...args); + fn(...args); }); }; } diff --git a/packages/configuration/src/validation/formValidation.ts b/packages/configuration/src/validation/formValidation.ts new file mode 100644 index 00000000..86d7cbcc --- /dev/null +++ b/packages/configuration/src/validation/formValidation.ts @@ -0,0 +1,51 @@ +/** + * Pure validation functions for the Configuration form. + * No React dependencies — these can be unit-tested in isolation. + */ + +export function validateInterval(value: string): boolean { + const n = parseInt(value, 10); + return value !== '' && !Number.isNaN(n) && n >= 1 && n <= 2880; +} + +export function validateBackfill(value: string): boolean { + const n = parseInt(value, 10); + return value !== '' && !Number.isNaN(n) && n >= 0 && n <= 180; +} + +export function isProxyHostMissing(enabled: boolean, host: string): boolean { + return enabled && host.trim() === ''; +} + +export function isProxyPortInvalid(enabled: boolean, port: string): boolean { + const n = parseInt(port, 10); + return enabled && (port.trim() === '' || Number.isNaN(n) || n < 1 || n > 65535); +} + +export function isProxyValid(enabled: boolean, host: string, port: string): boolean { + return !enabled || (!isProxyHostMissing(enabled, host) && !isProxyPortInvalid(enabled, port)); +} + +export function isFormValid(params: { + apiKey: string; + isApiKeyValidated: boolean; + selectedTenantIds: number[]; + selectedSeveritiesCount: number; + selectedSourceTypesCount: number; + interval: string; + backfill: string; + proxyEnabled: boolean; + proxyHost: string; + proxyPort: string; +}): boolean { + return ( + params.apiKey.length > 0 && + params.isApiKeyValidated && + params.selectedTenantIds.length > 0 && + params.selectedSeveritiesCount > 0 && + params.selectedSourceTypesCount > 0 && + validateInterval(params.interval) && + validateBackfill(params.backfill) && + isProxyValid(params.proxyEnabled, params.proxyHost, params.proxyPort) + ); +} diff --git a/packages/react-components/stylelint.config.js b/packages/configuration/stylelint.config.js similarity index 100% rename from packages/react-components/stylelint.config.js rename to packages/configuration/stylelint.config.js diff --git a/packages/configuration/tsconfig.json b/packages/configuration/tsconfig.json new file mode 100644 index 00000000..ee55c65a --- /dev/null +++ b/packages/configuration/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "jsx": "react", + "lib": ["es2020", "dom"], + "downlevelIteration": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "types": ["@testing-library/jest-dom", "@testing-library/react", "jest", "webpack-env"], + "rootDir": ".", + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./types" + }, + "include": ["src"] +} diff --git a/packages/react-components/webpack.config.js b/packages/configuration/webpack.config.js similarity index 81% rename from packages/react-components/webpack.config.js rename to packages/configuration/webpack.config.js index 21287a83..00950da6 100644 --- a/packages/react-components/webpack.config.js +++ b/packages/configuration/webpack.config.js @@ -4,8 +4,7 @@ const baseComponentConfig = require('@splunk/webpack-configs/component.config'). module.exports = webpackMerge(baseComponentConfig, { entry: { - ConfigurationScreen: path.join(__dirname, 'src/ConfigurationScreen.tsx'), - StatusScreen: path.join(__dirname, 'src/StatusScreen.tsx'), + Configuration: path.join(__dirname, 'src/Configuration.tsx'), }, output: { path: path.join(__dirname), @@ -27,3 +26,4 @@ module.exports = webpackMerge(baseComponentConfig, { ], }, }); + diff --git a/packages/flare/.eslintrc.js b/packages/flare/.eslintrc.js index bc8f7678..4a0639c5 100644 --- a/packages/flare/.eslintrc.js +++ b/packages/flare/.eslintrc.js @@ -1,3 +1,13 @@ module.exports = { - extends: '@splunk/eslint-config/browser-prettier', + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint'], + extends: ['@splunk/eslint-config/base', '@splunk/eslint-config/browser-prettier'], + rules: { + 'react/jsx-filename-extension': ['error', { extensions: ['.tsx', '.jsx'] }], + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { args: 'after-used', argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + }, }; diff --git a/packages/flare/.gitignore b/packages/flare/.gitignore index 211c1ece..9b92415c 100644 --- a/packages/flare/.gitignore +++ b/packages/flare/.gitignore @@ -1 +1,2 @@ -!/build.js +stage/ +types diff --git a/packages/flare/.npmignore b/packages/flare/.npmignore index 47f30315..cf0d102b 100644 --- a/packages/flare/.npmignore +++ b/packages/flare/.npmignore @@ -1,5 +1,7 @@ # Source code /src/ +/test/ +/demo/ # Tools /.babelrc* diff --git a/packages/flare/README b/packages/flare/README index 525fae0a..9e11777f 100644 --- a/packages/flare/README +++ b/packages/flare/README @@ -9,5 +9,6 @@ This application requires an **API key** and your **tenant ID** from https://app 5. In the next page, select the Tenant you want to ingest data from and press Submit. # Binary File Declaration -bin/vendor/charset_normalizer/md__mypyc.cpython-39-x86_64-linux-gnu.so + +bin/vendor/charset_normalizer/md\_\_mypyc.cpython-39-x86_64-linux-gnu.so bin/vendor/charset_normalizer/md.cpython-39-x86_64-linux-gnu.so diff --git a/packages/flare/bin/__init__.py b/packages/flare/bin/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/packages/flare/build.js b/packages/flare/bin/build.js similarity index 77% rename from packages/flare/build.js rename to packages/flare/bin/build.js index b9ccb6a2..95575651 100644 --- a/packages/flare/build.js +++ b/packages/flare/bin/build.js @@ -4,11 +4,11 @@ const shell = require('shelljs'); const OS = require('os').platform().toLocaleLowerCase(); const arg = process.argv[2]; -const commands = ['build']; +const commands = ['build', 'link']; if (!arg) { shell.echo( - `No command received, please supply a command to run. \nCommands: ${commands.join(', ')}` + `No command received, please supply a command to run. \nCommands: ${commands.join(', ')}`, ); shell.exit(1); } @@ -22,9 +22,11 @@ if (!commands.includes(arg)) { const runCommands = { win32: { build: () => shell.exec('set NODE_ENV=production&&.\\node_modules\\.bin\\webpack --mode=production'), + link: () => shell.exec('mklink /D "%SPLUNK_HOME%\\etc\\apps\\flare" "%cd%\\stage"'), }, nix: { build: () => shell.exec('export NODE_ENV=production && ./node_modules/.bin/webpack --mode=production'), + link: () => shell.exec('ln -s $PWD/stage $SPLUNK_HOME/etc/apps/flare_splunk_app'), }, }; diff --git a/packages/flare/bin/constants.py b/packages/flare/bin/constants.py deleted file mode 100644 index b2a4ec32..00000000 --- a/packages/flare/bin/constants.py +++ /dev/null @@ -1,34 +0,0 @@ -from datetime import timedelta -from enum import Enum - - -APP_NAME = "flare" -HOST = "localhost" -SPLUNK_PORT = 8089 -REALM = APP_NAME + "_realm" -CRON_JOB_THRESHOLD_SINCE_LAST_FETCH = timedelta(minutes=10) - - -class PasswordKeys(Enum): - API_KEY = "api_key" - TENANT_IDS = "tenant_ids" - INGEST_FULL_EVENT_DATA = "ingest_full_event_data" - SEVERITIES_FILTER = "severities_filter" - SOURCE_TYPES_FILTER = "source_types_filter" - NUMBER_OF_DAYS_TO_BACKFILL = "number_of_days_to_backfill" - - -class DataStoreKeys(Enum): - START_DATE = "start_date" - TIMESTAMP_LAST_FETCH = "timestamp_last_fetch" - - SECTION_METADATA = "metadata" - SECTION_TENANT_DATA = "tenant_data" - - @staticmethod - def get_next_token(tenant_id: int) -> str: - return f"next_{tenant_id}" - - @staticmethod - def get_earliest_ingested(tenant_id: int) -> str: - return f"timestamp_earliest_ingested_{tenant_id}" diff --git a/packages/flare/bin/cron_job_ingest_events.py b/packages/flare/bin/cron_job_ingest_events.py deleted file mode 100644 index 86d48595..00000000 --- a/packages/flare/bin/cron_job_ingest_events.py +++ /dev/null @@ -1,244 +0,0 @@ -import json -import os -import sys - - -if sys.version_info < (3, 9): - sys.exit("Error: This application requires Python 3.9 or higher.") - -from data_store import ConfigDataStore -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from typing import Iterator -from typing import Optional - - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "vendor")) -import vendor.splunklib.client as client - -from constants import APP_NAME -from constants import CRON_JOB_THRESHOLD_SINCE_LAST_FETCH -from constants import HOST -from constants import SPLUNK_PORT -from constants import PasswordKeys -from flare import FlareAPI -from logger import Logger - - -def main( - logger: Logger, - storage_passwords: client.StoragePasswords, - flare_api_cls: type[FlareAPI], - data_store: ConfigDataStore, -) -> None: - # To avoid cron jobs from doing the same work at the same time, exit new cron jobs if a cron job is already doing work - last_fetched_timestamp = data_store.get_last_fetch() - if last_fetched_timestamp and last_fetched_timestamp > ( - datetime.now(timezone.utc) - CRON_JOB_THRESHOLD_SINCE_LAST_FETCH - ): - logger.info( - f"Fetched events less than {int(CRON_JOB_THRESHOLD_SINCE_LAST_FETCH.seconds / 60)} minutes ago, exiting" - ) - return - - api_key = get_api_key(storage_passwords=storage_passwords) - tenant_ids = get_tenant_ids(storage_passwords=storage_passwords) - ingest_full_event_data = get_ingest_full_event_data( - storage_passwords=storage_passwords - ) - number_of_days_to_backfill = get_number_of_days_to_backfill( - storage_passwords=storage_passwords - ) - severities_filter = get_severities_filter(storage_passwords=storage_passwords) - source_types_filter = get_source_types_filter(storage_passwords=storage_passwords) - - data_store.set_last_fetch(datetime.now(timezone.utc)) - - total_events_fetched_count = 0 - - for tenant_id in tenant_ids: - events_fetched_count = 0 - - # The earliest ingested date serves as a low water mark to look - # for identifiers 30 days prior to the day a tenant was first configured. - start_date = data_store.get_earliest_ingested_by_tenant(tenant_id) - if not start_date: - start_date = datetime.now(timezone.utc) - timedelta( - days=number_of_days_to_backfill - ) - data_store.set_earliest_ingested_by_tenant(tenant_id, start_date) - - for event, next_token in fetch_feed( - logger=logger, - api_key=api_key, - tenant_id=tenant_id, - ingest_full_event_data=ingest_full_event_data, - severities=severities_filter, - source_types=source_types_filter, - flare_api_cls=flare_api_cls, - data_store=data_store, - ): - data_store.set_last_fetch(datetime.now(timezone.utc)) - - data_store.set_next_by_tenant(tenant_id, next_token) - - event["tenant_id"] = tenant_id - - # stdout is picked up by splunk and this is how events - # are ingested after being retrieved from Flare. - print(json.dumps(event), flush=True) - - events_fetched_count += 1 - logger.info(f"Fetched {events_fetched_count} events on tenant {tenant_id}") - total_events_fetched_count += events_fetched_count - - logger.info(f"Fetched {total_events_fetched_count} events across all tenants") - - -def fetch_feed( - logger: Logger, - api_key: str, - tenant_id: int, - ingest_full_event_data: bool, - severities: list[str], - source_types: list[str], - flare_api_cls: type[FlareAPI], - data_store: ConfigDataStore, -) -> Iterator[tuple[dict, str]]: - flare_api: FlareAPI = flare_api_cls( - api_key=api_key, - tenant_id=tenant_id, - logger=logger, - ) - - try: - next = data_store.get_next_by_tenant(tenant_id) - start_date = data_store.get_earliest_ingested_by_tenant(tenant_id) - logger.info(f"Fetching {tenant_id=}, {next=}, {start_date=}") - for event_next in flare_api.fetch_feed_events( - next=next, - start_date=start_date, - ingest_full_event_data=ingest_full_event_data, - severities=severities, - source_types=source_types, - ): - yield event_next - except Exception as e: - logger.error(f"Exception={e}") - - -def get_storage_password_value( - storage_passwords: client.StoragePasswords, password_key: str -) -> Optional[str]: - for item in storage_passwords.list(): - if item.content.username == password_key: - return item.clear_password - - return None - - -def get_api_key(storage_passwords: client.StoragePasswords) -> str: - api_key = get_storage_password_value( - storage_passwords=storage_passwords, password_key=PasswordKeys.API_KEY.value - ) - if not api_key: - raise Exception("API key not found") - return api_key - - -def get_number_of_days_to_backfill(storage_passwords: client.StoragePasswords) -> int: - number_of_days_to_backfill = get_storage_password_value( - storage_passwords=storage_passwords, - password_key=PasswordKeys.NUMBER_OF_DAYS_TO_BACKFILL.value, - ) - - try: - return int(number_of_days_to_backfill) if number_of_days_to_backfill else 30 - except Exception as e: - raise Exception("Number of days to backfill not a number") from e - - -def get_tenant_ids(storage_passwords: client.StoragePasswords) -> list[int]: - stored_tenant_ids = get_storage_password_value( - storage_passwords=storage_passwords, password_key=PasswordKeys.TENANT_IDS.value - ) - tenant_ids = None - try: - tenant_ids = json.loads(stored_tenant_ids) if stored_tenant_ids else None - except Exception: - pass - - if tenant_ids is None: - raise Exception("Tenant IDs not found") - return tenant_ids - - -def get_ingest_full_event_data(storage_passwords: client.StoragePasswords) -> bool: - return ( - get_storage_password_value( - storage_passwords=storage_passwords, - password_key=PasswordKeys.INGEST_FULL_EVENT_DATA.value, - ) - == "true" - ) - - -def get_severities_filter(storage_passwords: client.StoragePasswords) -> list[str]: - severities_filter = get_storage_password_value( - storage_passwords=storage_passwords, - password_key=PasswordKeys.SEVERITIES_FILTER.value, - ) - - if severities_filter: - return severities_filter.split(",") - - return [] - - -def get_source_types_filter(storage_passwords: client.StoragePasswords) -> list[str]: - source_types_filter = get_storage_password_value( - storage_passwords=storage_passwords, - password_key=PasswordKeys.SOURCE_TYPES_FILTER.value, - ) - - if source_types_filter: - return source_types_filter.split(",") - - return [] - - -def get_splunk_service(logger: Logger, token: str) -> client.Service: - try: - splunk_service = client.connect( - host=HOST, - port=SPLUNK_PORT, - app=APP_NAME, - token=token, - autologin=True, - ) - except Exception as e: - logger.error(str(e)) - raise Exception(str(e)) - - return splunk_service - - -if __name__ == "__main__": - logger = Logger(class_name=__file__) - data_store = ConfigDataStore() - token = sys.stdin.readline().strip() # SEE: passAuth in https://docs.splunk.com/Documentation/Splunk/9.4.0/Admin/Inputsconf - if not token: - raise Exception( - "Token not found - Go through the complete app configuration to update the user token." - ) - - splunk_service = get_splunk_service(logger=logger, token=token) - app: client.Application = splunk_service.apps[APP_NAME] - - main( - logger=logger, - storage_passwords=app.service.storage_passwords, - flare_api_cls=FlareAPI, - data_store=data_store, - ) diff --git a/packages/flare/bin/data_store.py b/packages/flare/bin/data_store.py deleted file mode 100644 index ea41c200..00000000 --- a/packages/flare/bin/data_store.py +++ /dev/null @@ -1,105 +0,0 @@ -import configparser -import os - -from constants import APP_NAME -from constants import DataStoreKeys -from datetime import datetime -from typing import Optional - - -# Define the config file path -splunk_home = os.environ.get("SPLUNK_HOME", "/opt/splunk") -config_path = os.path.join( - splunk_home, "etc", "apps", f"{APP_NAME}", "local", "data_store.conf" -) - - -class ConfigDataStore: - def __init__(self) -> None: - config_store = configparser.RawConfigParser() - config_store.read(config_path) - - # Add data sections - if DataStoreKeys.SECTION_METADATA.value not in config_store.sections(): - config_store.add_section(DataStoreKeys.SECTION_METADATA.value) - if DataStoreKeys.SECTION_TENANT_DATA.value not in config_store.sections(): - config_store.add_section(DataStoreKeys.SECTION_TENANT_DATA.value) - self._store = config_store - - def _commit(self) -> None: - with open(config_path, "w") as configfile: - self._store.write(configfile) - - def _sync(self) -> None: - self._store.read(config_path) - - def reset(self) -> None: - self._store.clear() - self._commit() - - def get_last_fetch(self) -> Optional[datetime]: - self._sync() - last_fetched = self._store.get( - DataStoreKeys.SECTION_METADATA.value, - DataStoreKeys.TIMESTAMP_LAST_FETCH.value, - fallback=None, - ) - - if last_fetched: - try: - return datetime.fromisoformat(last_fetched) - except Exception: - pass - return None - - def set_last_fetch(self, last_fetch: datetime) -> None: - self._store.set( - DataStoreKeys.SECTION_METADATA.value, - DataStoreKeys.TIMESTAMP_LAST_FETCH.value, - last_fetch.isoformat(), - ) - self._commit() - - def get_next_by_tenant(self, tenant_id: int) -> Optional[str]: - self._sync() - return self._store.get( - DataStoreKeys.SECTION_TENANT_DATA.value, - DataStoreKeys.get_next_token(tenant_id=tenant_id), - fallback=None, - ) - - def set_next_by_tenant(self, tenant_id: int, next: Optional[str]) -> None: - if not next: - return - - self._store.set( - DataStoreKeys.SECTION_TENANT_DATA.value, - DataStoreKeys.get_next_token(tenant_id=tenant_id), - next, - ) - self._commit() - - def get_earliest_ingested_by_tenant(self, tenant_id: int) -> Optional[datetime]: - self._sync() - earliest_ingested = self._store.get( - DataStoreKeys.SECTION_TENANT_DATA.value, - DataStoreKeys.get_earliest_ingested(tenant_id=tenant_id), - fallback=None, - ) - - if earliest_ingested: - try: - return datetime.fromisoformat(earliest_ingested) - except Exception: - pass - return None - - def set_earliest_ingested_by_tenant( - self, tenant_id: int, earliest_ingested: datetime - ) -> None: - self._store.set( - DataStoreKeys.SECTION_TENANT_DATA.value, - DataStoreKeys.get_earliest_ingested(tenant_id=tenant_id), - earliest_ingested.isoformat(), - ) - self._commit() diff --git a/packages/flare/bin/flare.py b/packages/flare/bin/flare.py deleted file mode 100644 index b80da3fc..00000000 --- a/packages/flare/bin/flare.py +++ /dev/null @@ -1,166 +0,0 @@ -import sys - - -if sys.version_info < (3, 9): - sys.exit("Error: This application requires Python 3.9 or higher.") - - -import requests -import time - -from datetime import datetime -from datetime import timedelta -from datetime import timezone -from logger import Logger -from typing import Any -from typing import Dict -from typing import Iterator -from typing import Optional -from typing import Union -from vendor.flareio import FlareApiClient -from vendor.requests.auth import AuthBase - - -def ensure_str(value: Union[str, bytes]) -> str: - if isinstance(value, bytes): - return value.decode("utf8") - return value - - -def get_flare_api_client( - *, - api_key: str, - tenant_id: Union[int, None], -) -> FlareApiClient: - api_client = FlareApiClient( - api_key=api_key, - tenant_id=tenant_id, - ) - current_user_agent: str = ensure_str( - api_client._session.headers.get("User-Agent") or "" - ) - api_client._session.headers["User-Agent"] = ( - f"{current_user_agent} flare-splunk".strip() - ) - return api_client - - -class FlareAPI(AuthBase): - def __init__( - self, - *, - api_key: str, - tenant_id: Optional[int] = None, - logger: Logger, - ) -> None: - self.flare_client = get_flare_api_client( - api_key=api_key, - tenant_id=tenant_id, - ) - self.logger = logger - - def fetch_feed_events( - self, - *, - next: Optional[str] = None, - start_date: Optional[datetime] = None, - ingest_full_event_data: bool, - severities: list[str], - source_types: list[str], - ) -> Iterator[tuple[dict, str]]: - for response in self._fetch_event_feed_metadata( - next=next, - start_date=start_date, - severities=severities, - source_types=source_types, - ): - event_feed = response.json() - self.logger.debug(event_feed) - next_token = event_feed["next"] - for event in event_feed["items"]: - try: - if ingest_full_event_data: - event = self._fetch_full_event_from_uid( - uid=event["metadata"]["uid"] - ) - time.sleep(1) # Don't hit rate limit - except: - # There is already logging in the _fetch_full_event_from_uid - # we want to continue getting the other events even if one fails. - pass - finally: - yield (event, next_token) - - def _fetch_event_feed_metadata( - self, - *, - next: Optional[str] = None, - start_date: Optional[datetime] = None, - severities: list[str], - source_types: list[str], - ) -> Iterator[requests.Response]: - data: Dict[str, Any] = { - "from": next if next else None, - "order": "asc", - "filters": { - "materialized_at": { - "gte": start_date.isoformat() - if start_date - else (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() - }, - }, - } - - if len(severities): - data["severity"] = severities - - if len(source_types): - data["type"] = source_types - - for response in self.flare_client.scroll( - method="POST", - url="/firework/v4/events/tenant/_search", - json=data, - ): - yield response - # Rate limiting. - time.sleep(1) - - def _fetch_full_event_from_uid(self, *, uid: str) -> dict: - number_of_retries = 3 - for current_try in range(number_of_retries): - try: - event_response = self.flare_client.get( - url=f"/firework/v2/activities/{uid}" - ) - event_response.raise_for_status() - except Exception as e: - time.sleep(1) - self.logger.info( - f"Failed to fetch event {current_try + 1}/{number_of_retries} retries: {e}" - ) - continue - return event_response.json()["activity"] - raise Exception( - f"failed to fetch full event data for {uid} after {number_of_retries} tries" - ) - - def fetch_api_key_validation(self) -> requests.Response: - return self.flare_client.get( - url="/tokens/test", - ) - - def fetch_tenants(self) -> requests.Response: - return self.flare_client.get( - url="/firework/v2/me/tenants", - ) - - def fetch_filters_severity(self) -> requests.Response: - return self.flare_client.get( - url="/firework/v4/events/filters/severities", - ) - - def fetch_filters_source_type(self) -> requests.Response: - return self.flare_client.get( - url="/firework/v4/events/filters/types", - ) diff --git a/packages/flare/bin/flare_external_requests.py b/packages/flare/bin/flare_external_requests.py deleted file mode 100644 index 90494c0c..00000000 --- a/packages/flare/bin/flare_external_requests.py +++ /dev/null @@ -1,102 +0,0 @@ -import sys - - -if sys.version_info < (3, 9): - sys.exit("Error: This application requires Python 3.9 or higher.") - - -import json -import os -import splunk - -from urllib import parse - - -sys.path.insert(0, os.path.dirname(__file__)) -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "vendor")) -from data_store import ConfigDataStore -from flare import FlareAPI -from logger import Logger - - -class FlareValidateApiKey(splunk.rest.BaseRestHandler): - def handle_POST(self) -> None: - logger = Logger(class_name=__file__) - payload = self.request["payload"] - params = parse.parse_qs(payload) - - if "apiKey" not in params: - raise Exception("API Key is required") - - flare_api = FlareAPI(api_key=params["apiKey"][0], logger=logger) - flare_api.fetch_api_key_validation() - self.response.setHeader("Content-Type", "application/json") - self.response.write(json.dumps({})) - - -class FlareUserTenants(splunk.rest.BaseRestHandler): - def handle_POST(self) -> None: - logger = Logger(class_name=__file__) - payload = self.request["payload"] - params = parse.parse_qs(payload) - - if "apiKey" not in params: - raise Exception("API Key is required") - - flare_api = FlareAPI(api_key=params["apiKey"][0], logger=logger) - response = flare_api.fetch_tenants() - response_json = response.json() - logger.debug(f"FlareUserTenants: {response_json}") - self.response.setHeader("Content-Type", "application/json") - self.response.write(json.dumps(response_json)) - - -class FlareSeverityFilters(splunk.rest.BaseRestHandler): - def handle_POST(self) -> None: - logger = Logger(class_name=__file__) - payload = self.request["payload"] - params = parse.parse_qs(payload) - - if "apiKey" not in params: - raise Exception("API Key is required") - - flare_api = FlareAPI(api_key=params["apiKey"][0], logger=logger) - response = flare_api.fetch_filters_severity() - response_json = response.json() - logger.debug(f"FlareSeverityFilters: {response_json}") - self.response.setHeader("Content-Type", "application/json") - self.response.write(json.dumps(response_json)) - - -class FlareSourceTypeFilters(splunk.rest.BaseRestHandler): - def handle_POST(self) -> None: - logger = Logger(class_name=__file__) - payload = self.request["payload"] - params = parse.parse_qs(payload) - - if "apiKey" not in params: - raise Exception("API Key is required") - - flare_api = FlareAPI(api_key=params["apiKey"][0], logger=logger) - response = flare_api.fetch_filters_source_type() - response_json = response.json() - logger.debug(f"FlareSourceTypeFilters: {response_json}") - self.response.setHeader("Content-Type", "application/json") - self.response.write(json.dumps(response_json)) - - -class FlareIngestionStatus(splunk.rest.BaseRestHandler): - def handle_GET(self) -> None: - logger = Logger(class_name=__file__) - - data_store = ConfigDataStore() - last_fetched_timestamp = data_store.get_last_fetch() - - status_resp = { - "last_fetched_at": last_fetched_timestamp.isoformat() - if last_fetched_timestamp is not None - else None - } - logger.debug(f"FlareIngestionStatus: {status_resp}") - self.response.setHeader("Content-Type", "application/json") - self.response.write(json.dumps(status_resp)) diff --git a/packages/flare/bin/logger.py b/packages/flare/bin/logger.py deleted file mode 100644 index b61c0b01..00000000 --- a/packages/flare/bin/logger.py +++ /dev/null @@ -1,51 +0,0 @@ -import logging -import os -import tempfile - -from constants import APP_NAME -from logging.handlers import TimedRotatingFileHandler -from typing import Any - - -class Logger: - def __init__(self, *, class_name: str) -> None: - splunk_home = os.environ.get("SPLUNK_HOME") - log_filepath = "" - if splunk_home: - log_filepath = os.path.join( - splunk_home, "var", "log", "splunk", f"{APP_NAME}.log" - ) - else: - log_filepath = os.path.join(tempfile.gettempdir(), f"{APP_NAME}.log") - - self.tag_name = os.path.splitext(os.path.basename(class_name))[0] - self._logger = logging.getLogger(f"flare-{self.tag_name}") - - if os.environ.get("FLARE_ENV") == "dev": - self._logger.setLevel(logging.DEBUG) - else: - self._logger.setLevel(logging.INFO) - formatter = logging.Formatter("%(asctime)s %(levelname)-5s %(message)s") - handler = TimedRotatingFileHandler( - log_filepath, when="d", interval=1, backupCount=5 - ) - handler.setFormatter(formatter) - self._logger.addHandler(handler) - - def debug(self, msg: Any) -> None: - self._logger.debug(msg=f"{self.tag_name}: {msg}") - - def info(self, msg: Any) -> None: - self._logger.info(msg=f"{self.tag_name}: {msg}") - - def warning(self, msg: Any) -> None: - self._logger.warning(msg=f"{self.tag_name}: {msg}") - - def error(self, msg: Any) -> None: - self._logger.error(msg=f"{self.tag_name}: {msg}") - - def exception(self, msg: Any) -> None: - self._logger.exception(msg=f"{self.tag_name}: {msg}") - - def critical(self, msg: Any) -> None: - self._logger.critical(msg=f"{self.tag_name}: {msg}") diff --git a/packages/flare/package.json b/packages/flare/package.json index 69641a43..f45d8006 100644 --- a/packages/flare/package.json +++ b/packages/flare/package.json @@ -3,49 +3,60 @@ "version": "0.0.1", "license": "UNLICENSED", "scripts": { - "build": "node build.js build", - "eslint": "eslint src --ext \".js,.jsx,.tsx,.ts\"", - "eslint:fix": "eslint src --ext \".js, .jsx,.tsx,.ts\" --fix", - "lint": "yarn run eslint && yarn run stylelint", - "lint:ci": "yarn run eslint:ci && yarn run stylelint", + "build": "node bin/build.js build && pnpm types:build", + "eslint": "eslint src --ext \".ts,.tsx,.js,.jsx\"", + "eslint:ci": "pnpm run eslint -f junit -o test-reports/lint-results.xml", + "eslint:fix": "eslint src --ext \".ts,.tsx,.js,.jsx\" --fix", + "link:app": "node bin/build.js link", + "lint": "pnpm run eslint && pnpm run stylelint", + "lint:ci": "pnpm run eslint:ci && pnpm run stylelint", "start": "webpack --watch", - "stylelint": "stylelint \"src/**/*.{js,jsx}\" --config stylelint.config.js" + "stylelint": "stylelint \"src/**/*.{ts,tsx,js,jsx}\" --config stylelint.config.js", + "types:build": "tsc", + "types:start": "pnpm types:build --watch" }, "dependencies": { - "@flare/react-components": "^0.0.1" + "@splunk/react-page": "^8.2.1", + "@splunk/react-ui": "^5.9.0", + "@splunk/splunk-utils": "^3.4.0", + "@splunk/themes": "^1.6.0", + "react": "^18.2.0", + "styled-components": "^5.3.10", + "@splunk/configuration": "workspace:*" }, "devDependencies": { - "@babel/core": "^7.2.0", + "@babel/core": "^7.28.0", + "@babel/eslint-parser": "^7.28.0", "@splunk/babel-preset": "^4.0.0", - "@splunk/eslint-config": "^4.0.0", - "@splunk/react-page": "^7.0.0", - "@splunk/react-ui": "^4.30.0", - "@splunk/splunk-utils": "^3.0.1", - "@splunk/stylelint-config": "^4.0.0", - "@splunk/themes": "^0.18.0", - "@splunk/webpack-configs": "^7.0.2", - "babel-eslint": "^10.1.0", + "@splunk/eslint-config": "^5.0.0", + "@splunk/stylelint-config": "^5.0.0", + "@splunk/webpack-configs": "^7.0.3", + "@types/react": "^18.2.0", + "@types/styled-components": "^5.1.0", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", "babel-loader": "^8.3.0", "copy-webpack-plugin": "^11.0.0", - "eslint": "^7.14.0", - "eslint-config-airbnb": "^18.2.1", - "eslint-config-prettier": "^6.15.0", + "css-loader": "^7.1.2", + "eslint": "^8.57.1", + "eslint-config-airbnb": "^19.0.4", + "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-webpack": "^0.13.4", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-jsx-a11y": "^6.4.1", - "eslint-plugin-react": "^7.21.5", - "eslint-plugin-react-hooks": "^4.2.0", + "eslint-plugin-import": "^2.30.1", + "eslint-plugin-jsx-a11y": "^6.10.0", + "eslint-plugin-react": "^7.36.1", + "eslint-plugin-react-hooks": "^4.6.2", "html-webpack-plugin": "^5.5.3", - "react": "^16.12.0", - "react-dom": "^16.12.0", + "imports-loader": "^4.0.1", + "react-dom": "^18.2.0", "shelljs": "^0.8.5", - "styled-components": "^5.3.10", - "stylelint": "^13.0.0", + "stylelint": "^15.11.0", + "typescript": "^5.8.3", "webpack": "^5.88.2", "webpack-cli": "^5.1.4", "webpack-merge": "^5.9.0" }, "engines": { - "node": ">=14" + "node": ">=22" } } diff --git a/packages/flare/src/main/resources/splunk/README/splunk_create.spec.conf b/packages/flare/src/main/resources/splunk/README/splunk_create.conf.spec similarity index 100% rename from packages/flare/src/main/resources/splunk/README/splunk_create.spec.conf rename to packages/flare/src/main/resources/splunk/README/splunk_create.conf.spec diff --git a/packages/flare/src/main/resources/splunk/appserver/templates/configuration.html b/packages/flare/src/main/resources/splunk/appserver/templates/configuration.html index 70d1e0e8..cdc9909d 100644 --- a/packages/flare/src/main/resources/splunk/appserver/templates/configuration.html +++ b/packages/flare/src/main/resources/splunk/appserver/templates/configuration.html @@ -1,29 +1,27 @@ - + + + + + Configuration + + + - - - - - Configuration - - - - + + + + + - - - - - - - <% - page_path = "/static/app/flare/pages/" + page + ".js" - %> - - - + <% page_path = "/static/app/flare/pages/" + page + ".js" %> + + diff --git a/packages/flare/src/main/resources/splunk/appserver/templates/status.html b/packages/flare/src/main/resources/splunk/appserver/templates/status.html deleted file mode 100644 index b9624604..00000000 --- a/packages/flare/src/main/resources/splunk/appserver/templates/status.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - Status - - - - - - - - - - - - <% - page_path = "/static/app/flare/pages/" + page + ".js" - %> - - - - - diff --git a/packages/flare/src/main/resources/splunk/bin/checkpoint_manager.py b/packages/flare/src/main/resources/splunk/bin/checkpoint_manager.py new file mode 100644 index 00000000..86585da9 --- /dev/null +++ b/packages/flare/src/main/resources/splunk/bin/checkpoint_manager.py @@ -0,0 +1,89 @@ +import json +import logging +import os + + +logger = logging.getLogger("flare_cron_job") + + +def _get_checkpoint_path() -> str: + """Return the path to the checkpoint file.""" + splunk_home = os.environ.get("SPLUNK_HOME", "") + if splunk_home: + checkpoint_dir = os.path.join( + splunk_home, "var", "lib", "splunk", "modinputs", "flare_splunk_app" + ) + else: + checkpoint_dir = os.path.dirname(os.path.abspath(__file__)) + os.makedirs(checkpoint_dir, exist_ok=True) + return os.path.join(checkpoint_dir, "checkpoint.json") + + +def load_checkpoint() -> dict: + """Load the checkpoint file.""" + path = _get_checkpoint_path() + if not os.path.exists(path): + logger.debug("No checkpoint file found at %s", path) + return {} + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + logger.debug("Checkpoint loaded from %s", path) + return data + except (json.JSONDecodeError, IOError) as e: + logger.warning("Failed to read checkpoint file, starting fresh: %s", e) + return {} + + +def save_checkpoint(data: dict) -> None: + """Save the checkpoint data to disk atomically.""" + path = _get_checkpoint_path() + tmp_path = path + ".tmp" + try: + with open(tmp_path, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + # Atomic rename: if the process crashes before this line, the original checkpoint is intact + os.replace(tmp_path, path) + logger.info("Checkpoint saved to %s", path) + except IOError as e: + logger.error("Failed to save checkpoint: %s", e) + # Clean up partial temp file if rename failed + try: + os.remove(tmp_path) + except OSError: + pass + + +def reconcile_checkpoint_with_config( + checkpoint: dict, backfill_days: int, index_name: str +) -> dict: + """ + Compares the current config against what was saved in the checkpoint. + If backfill_days or index_name changed, clears the checkpoint to force a full re-ingest. + Always stamps the current config into the checkpoint before returning. + """ + saved_backfill = checkpoint.get("_config", {}).get("backfill_days") + saved_index = checkpoint.get("_config", {}).get("index_name") + + config_changed = False + if saved_backfill is not None and saved_backfill != backfill_days: + logger.info( + "Backfill range changed from %d to %d days. Clearing checkpoint.", + saved_backfill, + backfill_days, + ) + config_changed = True + + if saved_index is not None and saved_index != index_name: + logger.info( + "Target index changed from %s to %s. Clearing checkpoint.", + saved_index, + index_name, + ) + config_changed = True + + if config_changed: + checkpoint = {} + + checkpoint["_config"] = {"backfill_days": backfill_days, "index_name": index_name} + return checkpoint diff --git a/packages/flare/src/main/resources/splunk/bin/cron_job_ingest_events.py b/packages/flare/src/main/resources/splunk/bin/cron_job_ingest_events.py new file mode 100644 index 00000000..c2e10820 --- /dev/null +++ b/packages/flare/src/main/resources/splunk/bin/cron_job_ingest_events.py @@ -0,0 +1,220 @@ +"""Scheduled event ingestion script for the Flare Splunk integration.""" + +import json +import os +import sys + +from datetime import datetime +from datetime import timezone +from typing import Optional + + +if sys.version_info < (3, 7): + sys.exit("Error: This application requires Python 3.7 or higher.") + +sys.path.insert(0, os.path.dirname(__file__)) + +# Ensure the vendored lib directory is on the path for flareio SDK +_LIB_DIR = os.path.join(os.path.dirname(__file__), "lib") +if _LIB_DIR not in sys.path: + sys.path.insert(0, _LIB_DIR) + +import flare_constants as const + +from checkpoint_manager import load_checkpoint +from checkpoint_manager import reconcile_checkpoint_with_config +from checkpoint_manager import save_checkpoint +from flare_external_requests import enrich_event_with_full_details +from flare_external_requests import fetch_events_by_paginating +from flare_logger import setup_logger +from flare_sdk_client import create_flare_client +from ingestion_config import parse_ingestion_config +from splunk_storage import get_all_storage_values +from splunk_storage import get_session_token_from_stdin +from splunk_storage import get_storage_passwords + + +logger = setup_logger() + + +def main() -> None: + """Main entry point for the cron job.""" + logger.debug( + "The Flare app has woken up and is beginning its scheduled " + "data collection routine." + ) + + # 1: Authenticate with Splunk + splunk_session_token = get_session_token_from_stdin() + + if not splunk_session_token: + logger.critical( + "We couldn't securely identify this session. " + "Please make sure the app is fully configured." + ) + return + + storage_passwords = get_storage_passwords(splunk_session_token) + if not storage_passwords: + logger.info( + "We couldn't find your saved app configuration. " + "Please visit the setup page to save your credentials." + ) + return + + # 2: Parse all config in one go + config = get_all_storage_values(storage_passwords) + ingestion_cfg = parse_ingestion_config(config) + if ingestion_cfg is None: + return # parse_ingestion_config already logged the reason + + api_key = ingestion_cfg["api_key"] + tenant_ids = ingestion_cfg["tenant_ids"] + tenant_names_map = ingestion_cfg["tenant_names_map"] + ingest_full = ingestion_cfg["ingest_full_event_data"] + sev_filter = ingestion_cfg["severities_filter"] + type_filter = ingestion_cfg["source_types_filter"] + backfill_days = ingestion_cfg["backfill_days"] + backfill_start = ingestion_cfg["backfill_start_date"] + proxies = ingestion_cfg["proxies"] + ssl_verify = ingestion_cfg["ssl_verify"] + index_name = ingestion_cfg["index_name"] + log_level = ingestion_cfg["log_level"] + + logger.debug( + "Successfully loaded configuration and applied logging level: %s", log_level + ) + + # 3: Checkpoint management + checkpoint = load_checkpoint() + checkpoint = reconcile_checkpoint_with_config(checkpoint, backfill_days, index_name) + + # 4: Build the Flare API client (official SDK) + client = create_flare_client( + api_key=api_key, + proxies=proxies, + ssl_verify=ssl_verify, + ) + + # 5: Ingest events per tenant + total_events = 0 + + for tenant_id in tenant_ids: + tenant_key = str(tenant_id) + tenant_name = tenant_names_map.get(tenant_key) or tenant_names_map.get( + tenant_id, tenant_key + ) + tenant_checkpoint = checkpoint.get(tenant_key, {}) + from_token: Optional[str] = tenant_checkpoint.get("last_next_token") + last_valid_cursor = from_token + tenant_events = 0 + + if from_token: + start_date = backfill_start + logger.info("Resuming search for %s from checkpoint.", tenant_name) + else: + last_run_utc = tenant_checkpoint.get("last_run_utc") + start_date = last_run_utc if last_run_utc else backfill_start + logger.info( + "Starting fresh polling for %s back to %s.", tenant_name, start_date + ) + + while True: + result = fetch_events_by_paginating( + client, + from_token=from_token, + size=const.DEFAULT_INGESTION_PAGE_SIZE, + severities=sev_filter or None, + source_types=type_filter or None, + start_date=start_date, + ) + + items = result.get("items", []) + next_token = result.get("next") + + if next_token: + last_valid_cursor = next_token + + for event in items: + if ingest_full: + event = enrich_event_with_full_details(event, client) + + metadata_obj = event.get("metadata", {}) + event_timestamp = metadata_obj.get("matched_at") or metadata_obj.get( + "estimated_created_at" + ) + + final_event = {"timestamp": event_timestamp} + final_event.update(event) + final_event["tenant_id"] = tenant_id + final_event["tenant_name"] = tenant_name + + print(json.dumps(final_event), flush=True) + logger.debug( + "Successfully ingested event UID %s for tenant %s", + metadata_obj.get("uid"), + tenant_name, + ) + + tenant_events += 1 + + if not next_token or not items: + logger.info( + "Pagination complete for %s. Reached end of feed. Events: %d", + tenant_name, + tenant_events, + ) + last_valid_cursor = None + break + + from_token = next_token + logger.info( + "Collected %d events. Next page for %s...", len(items), tenant_name + ) + + if tenant_events == 0: + logger.info("No new events for %s.", tenant_name) + else: + logger.info( + "Finished tasks for %s. Events sent: %d.", tenant_name, tenant_events + ) + total_events += tenant_events + + checkpoint[tenant_key] = { + "last_next_token": last_valid_cursor, + # re-checks from the same point and doesn't miss delayed events. + "last_run_utc": ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .strftime("%Y-%m-%dT%H:%M:%SZ") + if tenant_events > 0 + else tenant_checkpoint.get( + "last_run_utc", + datetime.now(timezone.utc) + .replace(microsecond=0) + .strftime("%Y-%m-%dT%H:%M:%SZ"), + ) + ), + "events_ingested": tenant_events, + } + save_checkpoint(checkpoint) + + if total_events == 0: + logger.info("Application completed. No new events found.") + else: + logger.info( + "Application completed. Events ingested: %d into index: %s", + total_events, + index_name, + ) + + +if __name__ == "__main__": + import urllib3 + + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + try: + main() + except Exception as e: + logger.critical("Unexpected error stopped the app: %s", e, exc_info=True) + sys.exit(0) diff --git a/packages/flare/src/main/resources/splunk/bin/flare_constants.py b/packages/flare/src/main/resources/splunk/bin/flare_constants.py new file mode 100644 index 00000000..ce9c7a5f --- /dev/null +++ b/packages/flare/src/main/resources/splunk/bin/flare_constants.py @@ -0,0 +1,38 @@ +"""Shared constants for the Flare Splunk integration.""" + +# Note: FLARE_API_BASE_URL and token generation are managed by the flareio SDK. +APP_NAME = "flare_splunk_app" +HOST = "localhost" +SPLUNK_PORT = 8089 +STORAGE_REALM = "flare_v2_integration_realm" +DEFAULT_BACKFILL_DAYS = 30 +DEFAULT_INGESTION_PAGE_SIZE = 10 +DEFAULT_INGESTION_INTERVAL_SECONDS = 60 +LOG_FILE_NAME = "flare_cron_job.log" +LOG_MAX_BYTES = 5 * 1024 * 1024 +LOG_BACKUP_COUNT = 3 + +# Flare API endpoints +ENDPOINT_TENANTS = "/firework/v2/me/tenants" +ENDPOINT_EVENTS_SEARCH = "/firework/v4/events/tenant/_search" +ENDPOINT_EVENTS_DETAIL = "/firework/v4/events/" +ENDPOINT_FILTER_SEVERITIES = "/firework/v4/events/filters/severities" +ENDPOINT_FILTER_TYPES = "/firework/v4/events/filters/types" + +# Splunk storage password keys +KEY_API_KEY = "api_key" +KEY_TENANT_IDS = "tenant_ids" +KEY_INGEST_FULL_EVENT_DATA = "ingest_full_event_data" +KEY_SEVERITIES_FILTER = "severities_filter" +KEY_SOURCE_TYPES_FILTER = "source_types_filter" +KEY_BACKFILL_DAYS = "number_of_days_to_backfill" +KEY_PROXY_ENABLED = "proxy_enabled" +KEY_PROXY_TYPE = "proxy_type" +KEY_PROXY_HOST = "proxy_host" +KEY_PROXY_PORT = "proxy_port" +KEY_PROXY_USERNAME = "proxy_username" +KEY_PROXY_PASSWORD = "proxy_password" +KEY_INDEX_NAME = "index_name" +KEY_SSL_VERIFY = "ssl_verify" +KEY_TENANT_NAMES = "tenant_names" +KEY_LOG_LEVEL = "log_level" diff --git a/packages/flare/src/main/resources/splunk/bin/flare_external_requests.py b/packages/flare/src/main/resources/splunk/bin/flare_external_requests.py new file mode 100644 index 00000000..0b4d4a86 --- /dev/null +++ b/packages/flare/src/main/resources/splunk/bin/flare_external_requests.py @@ -0,0 +1,341 @@ +"""Flare API wrappers and Splunk REST handlers for the Flare integration.""" + +import os +import sys + + +if sys.version_info < (3, 7): + sys.exit("Error: This application requires Python 3.7 or higher.") + +# Ensure the vendored lib directory is on the path +_LIB_DIR = os.path.join(os.path.dirname(__file__), "lib") +if _LIB_DIR not in sys.path: + sys.path.insert(0, _LIB_DIR) + +import json +import logging +import requests +import urllib.parse + +from typing import Optional + + +try: + import splunk.rest +except ImportError: + pass + +sys.path.insert(0, os.path.dirname(__file__)) + +import flare_constants as const + +from flare_sdk_client import create_flare_client +from flareio import FlareApiClient +from flareio.exceptions import TokenError +from ingestion_config import get_proxy_settings +from splunk_storage import get_all_storage_values + + +logger = logging.getLogger("flare_cron_job") + + +# API Request Wrappers (For Cron Job) + + +def fetch_events_by_paginating( + client: FlareApiClient, + from_token: Optional[str] = None, + size: int = const.DEFAULT_INGESTION_PAGE_SIZE, + severities: Optional[list] = None, + source_types: Optional[list] = None, + start_date: Optional[str] = None, +) -> dict: + """Fetch a page of events using the official SDK. JWT refresh is handled automatically.""" + search_body: dict = {"size": size} + if from_token: + search_body["from"] = from_token + + filters: dict = {} + if start_date: + filters["estimated_created_at"] = {"gte": start_date} + if severities: + filters["severity"] = severities + if source_types: + filters["type"] = source_types + if filters: + search_body["filters"] = filters + + resp = client.post(const.ENDPOINT_EVENTS_SEARCH, json=search_body) + resp.raise_for_status() + return resp.json() + + +def enrich_event_with_full_details( + event: dict, + client: FlareApiClient, +) -> dict: + """Enrich an event with full details from the API. 400 UNSUPPORTED handled gracefully.""" + uid = event.get("metadata", {}).get("uid") + if not uid: + return event + + encoded_uid = urllib.parse.quote(uid, safe="") + + try: + resp = client.get(f"{const.ENDPOINT_EVENTS_DETAIL}?uid={encoded_uid}") + resp.raise_for_status() + full_detail = resp.json() + except requests.exceptions.HTTPError as e: + logger.error( + "Enrichment API returned HTTP %s for UID %s. Response body: %s", + e.response.status_code if e.response is not None else "Unknown", + uid, + e.response.text if e.response is not None else "No response body", + ) + if e.response is not None and e.response.status_code == 400: + error_body = e.response.json() if e.response.text else {} + if ( + isinstance(error_body, dict) + and error_body.get("error", {}).get("code") == "UNSUPPORTED" + ): + logger.warning( + "Event UID %s is flagged as UNSUPPORTED by the enrichment API. Proceeding with base event data.", + uid, + ) + return event + + # Return base event on other HTTP errors to prevent cron job crash + return event + + if isinstance(full_detail, dict) and "data" in full_detail: + event["data"] = full_detail["data"] + + return event + + +# Internal Helpers (REST handler context only) + + +def _get_api_key_from_payload(request: dict) -> str: + payload = request.get("payload", "") + params = urllib.parse.parse_qs(payload) + if "apiKey" not in params: + raise Exception("API Key is required") + return params["apiKey"][0] + + +def _get_proxy_settings_from_session(session_key: str) -> Optional[dict]: + if not session_key: + return None + try: + _, content = splunk.rest.simpleRequest( + f"/servicesNS/nobody/{const.APP_NAME}/storage/passwords", + sessionKey=session_key, + method="GET", + getargs={"output_mode": "json"}, + ) + data = json.loads(content) + return get_proxy_settings(get_all_storage_values(data.get("entry", []))) + except Exception as e: + logger.warning("Failed to fetch proxy settings: %s", e) + return None + + +def setup_onetime_auth_client(request_obj: dict, sessionKey: str) -> FlareApiClient: + """Create a FlareApiClient for a one-off REST handler request.""" + api_key = _get_api_key_from_payload(request_obj) + + payload = request_obj.get("payload", "") + params = urllib.parse.parse_qs(payload) + + # Prioritize proxy settings directly from the UI payload + if "proxy_enabled" in params: + ui_proxy_config = { + const.KEY_PROXY_ENABLED: params["proxy_enabled"][0], + const.KEY_PROXY_TYPE: params.get("proxy_type", [""])[0], + const.KEY_PROXY_HOST: params.get("proxy_host", [""])[0], + const.KEY_PROXY_PORT: params.get("proxy_port", [""])[0], + const.KEY_PROXY_USERNAME: params.get("proxy_username", [""])[0], + const.KEY_PROXY_PASSWORD: params.get("proxy_password", [""])[0], + } + proxies = get_proxy_settings(ui_proxy_config) + else: + # Fallback to saved storage configuration + proxies = _get_proxy_settings_from_session(sessionKey) + + return create_flare_client(api_key=api_key, proxies=proxies, ssl_verify=True) + + +def normalize_response(response_json: dict, key: str) -> dict: + mapped = {} + if isinstance(response_json, list): + mapped[key] = response_json + elif isinstance(response_json, dict): + if key in response_json: + mapped[key] = response_json[key] + elif "items" in response_json: + mapped[key] = response_json["items"] + elif "results" in response_json: + mapped[key] = response_json["results"] + elif "data" in response_json: + mapped[key] = response_json["data"] + else: + mapped[key] = [response_json] + else: + mapped[key] = [] + return mapped + + +def _classify_error(e: Exception) -> dict: + """Classify an exception into a structured error response with type information.""" + # TokenError from the SDK means the API key itself is invalid + if isinstance(e, TokenError): + return {"error": str(e), "error_type": "auth_error"} + # Proxy errors surface as requests.exceptions.ProxyError or ConnectionError + # with a proxy-related message + if isinstance(e, requests.exceptions.ProxyError): + return { + "error": f"Failed to connect through the configured proxy. Details: {e}", + "error_type": "proxy_error", + } + if isinstance(e, (requests.exceptions.ConnectionError, ConnectionError)): + error_str = str(e).lower() + if "proxy" in error_str or "tunnel" in error_str: + return { + "error": f"Failed to connect through the configured proxy. Details: {e}", + "error_type": "proxy_error", + } + return { + "error": f"Unable to reach the Flare API. Details: {e}", + "error_type": "connection_error", + } + if isinstance(e, PermissionError): + return {"error": str(e), "error_type": "auth_error"} + return {"error": str(e), "error_type": "unknown"} + + +# Splunk REST Handler Classes (Configuration/Setup page) + + +class FlareValidateApiKey(splunk.rest.BaseRestHandler): + """Lightweight endpoint that validates the API key by generating a JWT token.""" + + def handle_POST(self) -> None: + try: + client = setup_onetime_auth_client(self.request, self.sessionKey) + # generate_token() is the SDK's public method — validates API key + client.generate_token() + self.response.setHeader("Content-Type", "application/json") + self.response.write(json.dumps({"valid": True})) + except Exception as e: + error_info = _classify_error(e) + if error_info["error_type"] == "proxy_error": + self.response.setStatus(502) + elif error_info["error_type"] == "auth_error": + self.response.setStatus(400) + else: + self.response.setStatus(500) + self.response.write(json.dumps(error_info)) + + +class FlareUserTenants(splunk.rest.BaseRestHandler): + def handle_POST(self) -> None: + try: + client = setup_onetime_auth_client(self.request, self.sessionKey) + resp = client.get(const.ENDPOINT_TENANTS) + resp.raise_for_status() + self.response.setHeader("Content-Type", "application/json") + self.response.write(json.dumps(normalize_response(resp.json(), "tenants"))) + except Exception as e: + error_info = _classify_error(e) + if error_info["error_type"] == "proxy_error": + self.response.setStatus(502) + elif error_info["error_type"] == "auth_error": + self.response.setStatus(400) + else: + self.response.setStatus(500) + self.response.write(json.dumps(error_info)) + + +class FlareSeverityFilters(splunk.rest.BaseRestHandler): + def handle_POST(self) -> None: + try: + client = setup_onetime_auth_client(self.request, self.sessionKey) + resp = client.get(const.ENDPOINT_FILTER_SEVERITIES) + resp.raise_for_status() + self.response.setHeader("Content-Type", "application/json") + self.response.write(json.dumps(resp.json())) + except Exception as e: + error_info = _classify_error(e) + if error_info["error_type"] == "proxy_error": + self.response.setStatus(502) + elif error_info["error_type"] == "auth_error": + self.response.setStatus(400) + else: + self.response.setStatus(500) + self.response.write(json.dumps(error_info)) + + +class FlareSourceTypeFilters(splunk.rest.BaseRestHandler): + def handle_POST(self) -> None: + try: + client = setup_onetime_auth_client(self.request, self.sessionKey) + resp = client.get(const.ENDPOINT_FILTER_TYPES) + resp.raise_for_status() + self.response.setHeader("Content-Type", "application/json") + self.response.write(json.dumps(resp.json())) + except Exception as e: + error_info = _classify_error(e) + if error_info["error_type"] == "proxy_error": + self.response.setStatus(502) + elif error_info["error_type"] == "auth_error": + self.response.setStatus(400) + else: + self.response.setStatus(500) + self.response.write(json.dumps(error_info)) + + +class FlareSearchEvents(splunk.rest.BaseRestHandler): + def handle_POST(self) -> None: + try: + client = setup_onetime_auth_client(self.request, self.sessionKey) + payload = self.request.get("payload", "") + params = urllib.parse.parse_qs(payload) + + search_body: dict = {"size": 50} + if params.get("size"): + try: + search_body["size"] = int(params["size"][0]) + except ValueError: + pass + + if params.get("from"): + search_body["from"] = params["from"][0] + if params.get("order"): + search_body["order"] = params["order"][0] + + filters: dict = {} + if params.get("severity"): + filters["severity"] = params["severity"][0].split(",") + if params.get("type"): + filters["type"] = params["type"][0].split(",") + if params.get("estimated_created_at_gte"): + filters["estimated_created_at"] = { + "gte": params["estimated_created_at_gte"][0] + } + if filters: + search_body["filters"] = filters + + resp = client.post(const.ENDPOINT_EVENTS_SEARCH, json=search_body) + resp.raise_for_status() + self.response.setHeader("Content-Type", "application/json") + self.response.write(json.dumps(resp.json())) + except Exception as e: + error_info = _classify_error(e) + if error_info["error_type"] == "proxy_error": + self.response.setStatus(502) + elif error_info["error_type"] == "auth_error": + self.response.setStatus(400) + else: + self.response.setStatus(500) + self.response.write(json.dumps(error_info)) diff --git a/packages/flare/src/main/resources/splunk/bin/flare_logger.py b/packages/flare/src/main/resources/splunk/bin/flare_logger.py new file mode 100644 index 00000000..0212e5cf --- /dev/null +++ b/packages/flare/src/main/resources/splunk/bin/flare_logger.py @@ -0,0 +1,39 @@ +import flare_constants as const +import logging +import logging.handlers +import os + + +def setup_logger() -> logging.Logger: + """Configure a centralized rotating file logger for the Flare integration.""" + logger = logging.getLogger("flare_cron_job") + # Start at INFO. The cron job will apply the + # user-configured level dynamically once config is loaded. + logger.setLevel(logging.INFO) + + if logger.handlers: + return logger + + splunk_home = os.environ.get("SPLUNK_HOME", "") + if splunk_home: + log_dir = os.path.join(splunk_home, "var", "log", "splunk") + else: + log_dir = os.path.dirname(os.path.abspath(__file__)) + + log_file = os.path.join(log_dir, const.LOG_FILE_NAME) + + handler = logging.handlers.RotatingFileHandler( + log_file, + maxBytes=const.LOG_MAX_BYTES, + backupCount=const.LOG_BACKUP_COUNT, + ) + handler.setLevel(logging.INFO) + + formatter = logging.Formatter( + "%(asctime)s %(levelname)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S %z", + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + + return logger diff --git a/packages/flare/src/main/resources/splunk/bin/flare_sdk_client.py b/packages/flare/src/main/resources/splunk/bin/flare_sdk_client.py new file mode 100644 index 00000000..8fc3498f --- /dev/null +++ b/packages/flare/src/main/resources/splunk/bin/flare_sdk_client.py @@ -0,0 +1,76 @@ +""" +Factory module for creating a configured FlareApiClient (official flareio SDK). + +This replaces the custom api_client.py. The SDK handles: + - JWT token generation and auto-refresh + - Retry with exponential backoff (5 retries, urllib3 Retry) + - Rate limiting awareness + +Proxy and SSL settings are applied via a custom requests.Session +passed to FlareApiClient(session=...). +""" + +import logging +import os +import sys + + +# Ensure the vendored lib directory is on the path +_LIB_DIR = os.path.join(os.path.dirname(__file__), "lib") +if _LIB_DIR not in sys.path: + sys.path.insert(0, _LIB_DIR) + +import requests + +from flareio import FlareApiClient +from requests.adapters import HTTPAdapter +from typing import Optional +from urllib3.util import Retry + + +logger = logging.getLogger("flare_cron_job") + + +def _build_session( + proxies: Optional[dict] = None, + ssl_verify: bool = True, +) -> requests.Session: + """ + Create a requests.Session pre-configured with: + - Proxy settings + - SSL verification preference + - Retry policy with backoff (mirrors SDK defaults for consistency) + """ + session = requests.Session() + + if proxies: + session.proxies.update(proxies) + + session.verify = ssl_verify + + # Retry policy: 5 attempts, backoff x2, retry on 429/5xx + retry = Retry( + total=5, + backoff_factor=2, + status_forcelist=[429, 502, 503, 504], + allowed_methods={"GET", "POST"}, + ) + if hasattr(retry, "backoff_max"): + retry.backoff_max = 15 + + session.mount("https://", HTTPAdapter(max_retries=retry)) + + return session + + +def create_flare_client( + api_key: str, + proxies: Optional[dict] = None, + ssl_verify: bool = True, +) -> FlareApiClient: + """ + Create and return a FlareApiClient instance configured with the given + API key, proxy settings, and SSL verification preference. + """ + session = _build_session(proxies=proxies, ssl_verify=ssl_verify) + return FlareApiClient(api_key=api_key, session=session) diff --git a/packages/flare/src/main/resources/splunk/bin/ingestion_config.py b/packages/flare/src/main/resources/splunk/bin/ingestion_config.py new file mode 100644 index 00000000..71b346ce --- /dev/null +++ b/packages/flare/src/main/resources/splunk/bin/ingestion_config.py @@ -0,0 +1,163 @@ +"""Parses and validates all ingestion configuration from Splunk storage passwords.""" + +import flare_constants as const +import json +import logging +import urllib.parse + +from datetime import datetime +from datetime import timedelta +from datetime import timezone +from typing import Optional + + +logger = logging.getLogger("flare_cron_job") + + +def get_proxy_settings(config: dict) -> Optional[dict]: + """Parse proxy settings from a storage configuration dictionary.""" + if config.get(const.KEY_PROXY_ENABLED) != "true": + return None + proxy_type = config.get(const.KEY_PROXY_TYPE) or "http" + proxy_host = config.get(const.KEY_PROXY_HOST) + proxy_port = config.get(const.KEY_PROXY_PORT) + if not proxy_host or not proxy_port: + return None + + proxy_username = config.get(const.KEY_PROXY_USERNAME) + proxy_password = config.get(const.KEY_PROXY_PASSWORD) + + if proxy_username and proxy_password: + user_enc = urllib.parse.quote_plus(proxy_username) + pass_enc = urllib.parse.quote_plus(proxy_password) + proxy_uri = f"{proxy_type}://{user_enc}:{pass_enc}@{proxy_host}:{proxy_port}" + else: + proxy_uri = f"{proxy_type}://{proxy_host}:{proxy_port}" + + return {"http": proxy_uri, "https": proxy_uri} + + +def parse_ingestion_config(config: dict) -> Optional[dict]: + """ + Parse all ingestion settings from the raw Splunk storage config dict. + Returns a structured dict with all values parsed and validated, + or None if a critical field (api_key, tenant_ids) is missing. + """ + + # Critical: API Key + api_key = config.get(const.KEY_API_KEY) + if not api_key: + logger.warning( + "Configuration has been removed or API key is missing. " + "Data ingestion is stopped." + ) + return None + + # Critical: Tenant IDs + tenant_ids: list = [] + tenant_ids_str = config.get(const.KEY_TENANT_IDS) + if tenant_ids_str: + try: + tenant_ids = json.loads(tenant_ids_str) + except Exception: + logger.warning("We had trouble reading the Tenant IDs from the config.") + + if not tenant_ids: + logger.error( + "No Tenant IDs were found. We don't know which environments " + "to fetch data for." + ) + return None + + # Optional: Tenant Names Map + tenant_names_map: dict = {} + tenant_names_raw = config.get(const.KEY_TENANT_NAMES) + if tenant_names_raw: + try: + tenant_names_map = json.loads(tenant_names_raw) + except (json.JSONDecodeError, TypeError) as e: + logger.warning("Failed to parse tenant_names from storage: %s", e) + + # Filters + ingest_full_event_data = config.get(const.KEY_INGEST_FULL_EVENT_DATA) == "true" + + severities_filter_str = config.get(const.KEY_SEVERITIES_FILTER) + severities_filter = ( + severities_filter_str.split(",") if severities_filter_str else [] + ) + + source_types_filter_str = config.get(const.KEY_SOURCE_TYPES_FILTER) + source_types_filter = ( + source_types_filter_str.split(",") if source_types_filter_str else [] + ) + + # Backfill + backfill_days_str = config.get(const.KEY_BACKFILL_DAYS) + try: + backfill_days = ( + int(backfill_days_str) if backfill_days_str else const.DEFAULT_BACKFILL_DAYS + ) + except ValueError: + logger.warning( + "The backfill setting seems invalid ('%s'), defaulting to %d.", + backfill_days_str, + const.DEFAULT_BACKFILL_DAYS, + ) + backfill_days = const.DEFAULT_BACKFILL_DAYS + + backfill_start_date = ( + (datetime.now(timezone.utc) - timedelta(days=backfill_days)) + .replace(hour=0, minute=0, second=0, microsecond=0) + .strftime("%Y-%m-%dT%H:%M:%SZ") + ) + + # Network + proxies = get_proxy_settings(config) + + ssl_verify_val = config.get(const.KEY_SSL_VERIFY) + ssl_verify = ( + ssl_verify_val.lower() == "true" if ssl_verify_val is not None else True + ) + + # Index + index_name = config.get(const.KEY_INDEX_NAME) + + # Logging + log_level_map = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, + } + log_level_str = (config.get(const.KEY_LOG_LEVEL) or "INFO").upper() + log_level = log_level_map.get(log_level_str, logging.INFO) + + # Apply dynamic log level immediately so the summary log respects it + logger.setLevel(log_level) + for handler in logger.handlers: + handler.setLevel(log_level) + + # Summary log + logger.info( + "Config valid. %d tenants, %d days backfill. Full detail: %s.%s", + len(tenant_ids), + backfill_days, + ingest_full_event_data, + " (Proxy on)" if proxies else "", + ) + + return { + "api_key": api_key, + "tenant_ids": tenant_ids, + "tenant_names_map": tenant_names_map, + "ingest_full_event_data": ingest_full_event_data, + "severities_filter": severities_filter, + "source_types_filter": source_types_filter, + "backfill_days": backfill_days, + "backfill_start_date": backfill_start_date, + "proxies": proxies, + "ssl_verify": ssl_verify, + "index_name": index_name, + "log_level": log_level, + } diff --git a/packages/flare/src/main/resources/splunk/bin/splunk_storage.py b/packages/flare/src/main/resources/splunk/bin/splunk_storage.py new file mode 100644 index 00000000..edaa10e1 --- /dev/null +++ b/packages/flare/src/main/resources/splunk/bin/splunk_storage.py @@ -0,0 +1,89 @@ +import flare_constants as const +import logging +import requests as http_requests + + +logger = logging.getLogger("flare_cron_job") + + +def get_session_token_from_stdin() -> str: + """Reads and parses the Splunk session key provided securely via standard input.""" + import sys + + session_key = "" + for line in sys.stdin: + session_key = line + + raw_token_line = session_key.strip() + if raw_token_line.startswith("sessionKey="): + return raw_token_line.split("=", 1)[1] + return raw_token_line + + +def get_storage_passwords(token: str) -> list: + """Fetch storage/passwords from the local Splunk REST API.""" + headers = {"Authorization": f"Splunk {token}"} + try: + logger.debug("Fetching storage passwords from Splunk REST API") + response = http_requests.get( + f"https://{const.HOST}:{const.SPLUNK_PORT}/servicesNS/nobody/" + f"{const.APP_NAME}/storage/passwords?output_mode=json", + headers=headers, + verify=False, + timeout=10, + ) + response.raise_for_status() + data = response.json() + entries = data.get("entry", []) + logger.debug("Retrieved %d storage password entries", len(entries)) + return entries + except Exception as e: + logger.error("Failed to fetch storage passwords: %s", e) + return [] + + +def save_storage_password_value( + splunk_session_token: str, key: str, value: str +) -> None: + """Write (create or update) a value in Splunk's storage/passwords via REST.""" + base_url = f"https://{const.HOST}:{const.SPLUNK_PORT}/servicesNS/nobody/{const.APP_NAME}/storage/passwords" + headers = {"Authorization": f"Splunk {splunk_session_token}"} + password_id = f"{const.STORAGE_REALM}:{key}:" + + # Try to delete the old entry first (ignore errors if it doesn't exist) + try: + http_requests.delete( + f"{base_url}/{password_id}", + headers=headers, + verify=False, + timeout=10, + params={"output_mode": "json"}, + ) + except Exception: + pass + + # Create the new entry + try: + http_requests.post( + base_url, + headers=headers, + data={"name": key, "realm": const.STORAGE_REALM, "password": value}, + verify=False, + timeout=10, + params={"output_mode": "json"}, + ) + logger.debug("Saved storage password for key: %s", key) + except Exception as e: + logger.error("Failed to save storage password for key %s: %s", key, e) + + +def get_all_storage_values(entries: list) -> dict: + """Extract all Flare config values from storage passwords in a single pass.""" + values: dict = {} + for entry in entries: + content = entry.get("content", {}) + if content.get("realm") == const.STORAGE_REALM: + key = content.get("username") + if key: + values[key] = content.get("clear_password") + return values diff --git a/packages/flare/src/main/resources/splunk/default/app.conf b/packages/flare/src/main/resources/splunk/default/app.conf index bd3f2f3d..ad94bf98 100644 --- a/packages/flare/src/main/resources/splunk/default/app.conf +++ b/packages/flare/src/main/resources/splunk/default/app.conf @@ -1,30 +1,24 @@ -# -# Splunk app configuration file -# - -[author=Flare Systems, Inc.] -email = info@flare.io -company = Flare Systems, Inc. - -[package] -id = flare - -[install] -python.version = python3.9 -state_change_requires_restart = true -is_configured = 0 -build = 11 +[id] +name = flare_splunk_app +version = 1.0.0 [ui] is_visible = 1 label = Flare -setup_view = configuration -supported_themes = light, dark +supported_themes = light,dark [launcher] -author = Flare Systems -description = The Flare app allows you to integrate your Flare alerts with the Splunk platform. -version = 1.3.4 +author = Flare +description = Flare Splunk Application +version = 1.0.0 + +[package] +check_for_updates = 1 +id = flare_splunk_app + +[install] +is_configured = 0 +build = 1 [triggers] reload.splunk_create = simple diff --git a/packages/flare/src/main/resources/splunk/default/data/ui/nav/default.xml b/packages/flare/src/main/resources/splunk/default/data/ui/nav/default.xml index a29ade76..d9a48d5a 100644 --- a/packages/flare/src/main/resources/splunk/default/data/ui/nav/default.xml +++ b/packages/flare/src/main/resources/splunk/default/data/ui/nav/default.xml @@ -1,11 +1,6 @@ - + \ No newline at end of file diff --git a/packages/flare/src/main/resources/splunk/default/data/ui/views/app_logs.xml b/packages/flare/src/main/resources/splunk/default/data/ui/views/app_logs.xml new file mode 100644 index 00000000..9d7bceb4 --- /dev/null +++ b/packages/flare/src/main/resources/splunk/default/data/ui/views/app_logs.xml @@ -0,0 +1,75 @@ + + + + Flare app internal logs for troubleshooting + + + + index=_internal source="*flare_cron_job.log" | search log_level="$log_level_filter$" + $log_time.earliest$ + $log_time.latest$ + + +
+ + + + -24h@h + now + + + + + All + INFO + WARNING + ERROR + DEBUG + CRITICAL + * + +
+ + + + + Log Level Trend + + + | timechart span=1h count by log_level + + + + + + + + + + + + + + + + + + + + Application Logs + + + | eval Time = strftime(_time, "%Y-%m-%d %H:%M:%S") +| eval log_message = coalesce(log_message, _raw) +| table Time, log_level, log_message +| rename log_level as "Log Level", log_message as "Message" +| sort -Time + + + + + +
+
+
+
diff --git a/packages/flare/src/main/resources/splunk/default/data/ui/views/dashboard.xml b/packages/flare/src/main/resources/splunk/default/data/ui/views/dashboard.xml new file mode 100644 index 00000000..d2314220 --- /dev/null +++ b/packages/flare/src/main/resources/splunk/default/data/ui/views/dashboard.xml @@ -0,0 +1,306 @@ + + + + Flare Security Overview + + + + `flare_index` sourcetype=flare_json $identifier_filter$ +| spath path=metadata.uid output=uid +| spath path=metadata.severity output=sev +| spath path=metadata.type output=etype +| spath path=metadata.flare_url output=flare_url +| spath path=tenant_name +| spath path=tenant_id +| eval Tenant = coalesce(tenant_name, tenant_id) +| dedup uid +| fields uid, sev, etype, flare_url, tenant_name, Tenant, _time + $global_time.earliest$ + $global_time.latest$ + + +
+ + + + -30d@d + now + + + + + All + * + + `flare_index` sourcetype=flare_json +| spath path="identifiers{}.name" output=identifier +| mvexpand identifier +| dedup identifier +| sort identifier +| eval search_value="\"" . identifier . "\"" +| table identifier search_value + $global_time.earliest$ + $global_time.latest$ + + identifier + search_value + +
+ + + + + Total Events + + + | stats count + + + + + + + + + Critical Events + + + | search sev="critical" | stats count + + + + + + + + + High Events + + + | search sev="high" | stats count + + + + + + + + + Medium Events + + + | search sev="medium" | stats count + + + + + + + + + Low Events + + + | search sev="low" | stats count + + + + + + + + + Info Events + + + | search sev="info" | stats count + + + + + + + + + + + + + Illicit Networks + + + | search etype IN("illicit_networks", "listing", "forum_post", "blog_post", "profile", "chat_message", "ransomleak", "infected_devices", "financial_data","bot") | stats count + + + + + + + + + Open Web + + + | search etype IN("open_web", "paste", "social_media", "source_code", "google", "service", "buckets","driller") | stats count + + + + + + + + + Look-alike Domains + + + | search etype IN("domains", "domain") | stats count + + + + + + + + + Leaked Credentials + + + | search etype IN("leaked_credential", "leak", "leaks") | stats count + + + + + + + + + + + + + Event Distribution by Category + + + | eval Category = case( + etype IN("open_web", "paste", "social_media", "source_code", "google", "service", "buckets","driller"), "Open Web", + etype IN("illicit_networks", "listing", "forum_post", "blog_post", "profile", "chat_message", "ransomleak", "infected_devices", "financial_data","bot"), "Illicit Networks", + etype IN("domains", "domain"), "Look-alike Domains", + etype IN("leaked_credential", "leak", "leaks"), "Leaked Credentials", + 1=1, "Other" +) +| timechart span=1mon count by Category + + + + + + + + + + $click.name2$ + + + + + Event Distribution by Severity + + + | timechart span=1mon count by sev + + + + + + + + + + $click.name2$ + + + + + + + + + Events by Tenant + + + | stats count by Tenant | sort -count + + + + + + + + + + + + + + + Events for Severity: $selected_severity$ + + + | search sev="$selected_severity$" +| eval Time = strftime(_time, "%Y-%m-%d %H:%M:%S") +| eval tenant_name = mvindex(tenant_name, 0) +| table Time etype sev tenant_name flare_url +| rename etype as "Category", sev as "Severity", tenant_name as "Tenant", flare_url as "Flare_URL" +| sort -Time + + + + + + + $row.Flare_URL|n$ + + + + + +
+
+
+ + + + + Events for Category: $selected_category$ + + + | eval Category = case( + etype IN("open_web", "paste", "social_media", "source_code", "google", "service", "buckets", "driller"), "Open Web", + etype IN("illicit_networks", "listing", "forum_post", "blog_post", "profile", "chat_message", "ransomleak", "infected_devices", "financial_data", "bot"), "Illicit Networks", + etype IN("domains", "domain"), "Look-alike Domains", + etype IN("leaked_credential", "leak", "leaks"), "Leaked Credentials", + 1=1, "Other" +) +| search Category="$selected_category$" +| eval Time = strftime(_time, "%Y-%m-%d %H:%M:%S") +| eval tenant_name = mvindex(tenant_name, 0) +| table Time etype sev tenant_name flare_url +| rename etype as "Raw_Type", sev as "Severity", tenant_name as "Tenant", flare_url as "Flare_URL" +| sort -Time + + + + + + + $row.Flare_URL|n$ + + + + + +
+
+
+ +
diff --git a/packages/flare/src/main/resources/splunk/default/data/ui/views/search.xml b/packages/flare/src/main/resources/splunk/default/data/ui/views/search.xml new file mode 100644 index 00000000..d53c0092 --- /dev/null +++ b/packages/flare/src/main/resources/splunk/default/data/ui/views/search.xml @@ -0,0 +1,148 @@ + + + + +
+ + + + -30d@d + now + + + + + All + * + + `flare_index` sourcetype=flare_json | dedup metadata_severity | table metadata_severity | sort metadata_severity + -30d@d + now + + metadata_severity + metadata_severity + + + + All + * + + `flare_index` sourcetype=flare_json | dedup event_type | table event_type | sort event_type + -30d@d + now + + event_type + event_type + + + + All + * + + `flare_index` sourcetype=flare_json | eval Tenant = coalesce(tenant_name, tenant_id) | dedup Tenant | table Tenant | sort Tenant + -30d@d + now + + Tenant + Tenant + +
+ + + + + + + Saved Searches + + + + + + + + + + Flare Events + + + `flare_index` sourcetype=flare_json metadata_severity="$severity_filter$" event_type="$event_type_filter$" +| eval Tenant = coalesce(tenant_name, tenant_id) +| search Tenant="$tenant_filter$" +| dedup metadata_uid +| eval Time = strftime(_time, "%Y-%m-%d %H:%M:%S") +| eval Tenant = mvindex(Tenant, 0) +| table Time, event_type, metadata_severity, Tenant, metadata_flare_url +| rename Time as "Time", event_type as "Category", metadata_severity as "Severity", Tenant as "Tenant", metadata_flare_url as "Flare URL" +| sort -Time + $time_range.earliest$ + $time_range.latest$ + + + + + + + + $row.Flare URL|n$ + + + + + +
+
+
+ + + + + + + Event Timeline + + + `flare_index` sourcetype=flare_json metadata_severity="$severity_filter$" event_type="$event_type_filter$" +| eval Tenant = coalesce(tenant_name, tenant_id) +| search Tenant="$tenant_filter$" +| timechart span=1d count by event_type + $time_range.earliest$ + $time_range.latest$ + + + + + + + + + + + +
diff --git a/packages/flare/src/main/resources/splunk/default/data/ui/views/status.xml b/packages/flare/src/main/resources/splunk/default/data/ui/views/status.xml deleted file mode 100644 index 9c311983..00000000 --- a/packages/flare/src/main/resources/splunk/default/data/ui/views/status.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/packages/flare/src/main/resources/splunk/default/eventtypes.conf b/packages/flare/src/main/resources/splunk/default/eventtypes.conf new file mode 100644 index 00000000..fa56fe08 --- /dev/null +++ b/packages/flare/src/main/resources/splunk/default/eventtypes.conf @@ -0,0 +1,51 @@ +############################################################ +# Flare CTEM - Event Type Classifications +############################################################ + +# Base event type: matches ALL Flare events +[flare_alert] +search = sourcetype="flare_json" + +# Ransom Leak events (ransomware gang postings) +[flare_ransomleak] +search = sourcetype="flare_json" event_type="ransomleak" + +# Stealer Log events (infostealer malware logs) +[flare_stealer_log] +search = sourcetype="flare_json" event_type="stealer_log" + +# Credit Card events (compromised payment cards) +[flare_cc] +search = sourcetype="flare_json" event_type="cc" + +# Lookalike Domain events (typosquatting, DNS twist) +[flare_lookalike] +search = sourcetype="flare_json" event_type="lookalike" + +# Blog Post events (threat actor blog mentions) +[flare_blog_post] +search = sourcetype="flare_json" event_type="blog_post" + +# Chat Message events (dark web chat monitoring) +[flare_chat_message] +search = sourcetype="flare_json" event_type="chat_message" + +# Forum Post events (underground forum discussions) +[flare_forum_post] +search = sourcetype="flare_json" event_type="forum_post" + +# Paste events (Pastebin, paste sites) +[flare_paste] +search = sourcetype="flare_json" event_type="paste" + +# Listing events (dark web marketplace listings) +[flare_listing] +search = sourcetype="flare_json" event_type="listing" + +# Bucket events (exposed cloud storage buckets) +[flare_bucket] +search = sourcetype="flare_json" event_type="bucket" + +# Social Media Account events +[flare_social_media] +search = sourcetype="flare_json" event_type="social_media_account" diff --git a/packages/flare/src/main/resources/splunk/default/inputs.conf b/packages/flare/src/main/resources/splunk/default/inputs.conf index cf2fb4d8..db8a3bc3 100644 --- a/packages/flare/src/main/resources/splunk/default/inputs.conf +++ b/packages/flare/src/main/resources/splunk/default/inputs.conf @@ -1,7 +1,9 @@ -[script://$SPLUNK_HOME/etc/apps/flare/bin/cron_job_ingest_events.py] -interval = 0 0 1 1 * -python.version = python3 +[script://$SPLUNK_HOME/etc/apps/flare_splunk_app/bin/cron_job_ingest_events.py] +disabled = true index = flare +interval = 86400 +python.version = python3 +python.required = 3.9 source = flare sourcetype = flare_json -passAuth = admin +passAuth = splunk-system-user \ No newline at end of file diff --git a/packages/flare/src/main/resources/splunk/default/macros.conf b/packages/flare/src/main/resources/splunk/default/macros.conf new file mode 100644 index 00000000..f629452b --- /dev/null +++ b/packages/flare/src/main/resources/splunk/default/macros.conf @@ -0,0 +1,3 @@ +[flare_index] +definition = index=flare +iseval = 0 diff --git a/packages/flare/src/main/resources/splunk/default/props.conf b/packages/flare/src/main/resources/splunk/default/props.conf index 2e72e556..2d13e2b8 100644 --- a/packages/flare/src/main/resources/splunk/default/props.conf +++ b/packages/flare/src/main/resources/splunk/default/props.conf @@ -1,9 +1,159 @@ +############################################################ +# Flare CTEM - Source Type Configuration & CIM Field Mapping +# Pattern: EXTRACT (regex) + EVAL (CIM normalization) +############################################################ + [flare_json] -DATETIME_CONFIG = CURRENT -BREAK_ONLY_BEFORE = ^{ -NO_BINARY_CHECK = true -SHOULD_LINEMERGE = true -TRUNCATE = 0 category = Structured -description = Flare's JSON source type -pulldown_type = true +description = Flare CTEM events +MAX_TIMESTAMP_LOOKAHEAD = 0 + +# Timestamp: explicitly parses the "timestamp" key (matched_at / estimated_created_at) prepended by the ingestion script +# TIME_PREFIX = \"timestamp\"\s*:\s*\" +# TIME_FORMAT = %Y-%m-%dT%H:%M:%S%Z + +# ============================================================ +# EXTRACT: Common Fields (present in all event types) +# ============================================================ +# event_type: extracted from metadata.type in the API response +EXTRACT-event_type = "metadata"\s*:\s*\{[^}]*"type"\s*:\s*"(?[^"]+)" +EXTRACT-tenant_id = "tenant_id"\s*:\s*(?\d+) +EXTRACT-tenant_name = "tenant_name"\s*:\s*"(?[^"]+)" + +# --- metadata block --- +EXTRACT-metadata_uid = "uid"\s*:\s*"(?[^"]+)" +EXTRACT-metadata_severity = "severity"\s*:\s*"(?[^"]+)" +EXTRACT-metadata_flare_url = "flare_url"\s*:\s*"(?[^"]+)" +EXTRACT-metadata_estimated_created_at = "estimated_created_at"\s*:\s*"(?[^"]+)" +EXTRACT-metadata_matched_at = "matched_at"\s*:\s*"(?[^"]+)" + +# ============================================================ +# EXTRACT: Common Data Fields (shared across many event types) +# ============================================================ +EXTRACT-data_url = "url"\s*:\s*"(?https?://[^"]+)" +EXTRACT-data_content = "content"\s*:\s*"(?[^"]{0,500})" +EXTRACT-data_title = "title"\s*:\s*"(?[^"]+)" +EXTRACT-data_body = "body"\s*:\s*"(?[^"]{0,500})" +EXTRACT-data_posted_at = "posted_at"\s*:\s*"(?[^"]+)" +EXTRACT-data_response_url = "response_url"\s*:\s*"(?[^"]+)" +EXTRACT-data_description = "description"\s*:\s*"(?[^"]+)" + +# --- actor block --- +EXTRACT-actor_id = "actor"\s*:\s*\{[^}]*"id"\s*:\s*"(?[^"]+)" +EXTRACT-actor_name = "actor"\s*:\s*\{[^}]*"name"\s*:\s*"(?[^"]+)" + +# --- context block --- +EXTRACT-context_conversation_name = "conversation_name"\s*:\s*"(?[^"]+)" +EXTRACT-context_category_name = "category_name"\s*:\s*"(?[^"]+)" +EXTRACT-context_topic_title = "topic_title"\s*:\s*"(?[^"]+)" + +# ============================================================ +# EXTRACT: Ransom Leak - Victim Information +# Uses [\s\S] instead of [^}] to cross nested objects +# ============================================================ +EXTRACT-victim_name = "victim_information"\s*:\s*\{[\s\S]*?"name"\s*:\s*"(?[^"]+)" +EXTRACT-victim_display_name = "display_name"\s*:\s*"(?[^"]+)" +EXTRACT-victim_domain = "victim_information"\s*:\s*\{[\s\S]*?"domain"\s*:\s*"(?[^"]+)" +EXTRACT-victim_industry = "industry"\s*:\s*"(?[^"]+)" +EXTRACT-victim_employee_count = "employee_count"\s*:\s*(?\d+) +EXTRACT-victim_city = "victim_information"\s*:\s*\{[\s\S]*?"city"\s*:\s*"(?[^"]+)" +EXTRACT-victim_state = "victim_information"\s*:\s*\{[\s\S]*?"state"\s*:\s*"(?[^"]+)" +EXTRACT-victim_country = "victim_information"\s*:\s*\{[\s\S]*?"country"\s*:\s*"(?[^"]+)" + +# ============================================================ +# EXTRACT: Stealer Log - Victim & Malware Information +# ============================================================ +EXTRACT-victim_ip_address = "ip_address"\s*:\s*"(?[^"]+)" +EXTRACT-victim_ip_network = "ip_network"\s*:\s*"(?[^"]+)" +EXTRACT-victim_username = "username"\s*:\s*"(?[^"]+)" +EXTRACT-victim_country_code = "country_code"\s*:\s*"(?[^"]+)" +EXTRACT-victim_os = "os"\s*:\s*"(?[^"]+)" +EXTRACT-victim_timezone = "timezone"\s*:\s*"(?[^"]+)" +EXTRACT-malware_family = "malware_family"\s*:\s*"(?[^"]+)" +EXTRACT-malware_build_id = "build_id"\s*:\s*"(?[^"]+)" +EXTRACT-malware_file_location = "file_location"\s*:\s*"(?[^"]+)" +EXTRACT-malware_infected_at = "infected_at"\s*:\s*"(?[^"]+)" + +# ============================================================ +# EXTRACT: Credit Card +# ============================================================ +EXTRACT-cc_bank = "bank"\s*:\s*"(?[^"]+)" +EXTRACT-cc_bin = "bin"\s*:\s*"(?[^"]+)" +EXTRACT-cc_brand = "brand"\s*:\s*"(?[^"]+)" +EXTRACT-cc_country = "country"\s*:\s*"(?[^"]+)" +EXTRACT-cc_expiration = "expiration"\s*:\s*"(?[^"]+)" +EXTRACT-cc_owner = "owner"\s*:\s*"(?[^"]+)" + +# ============================================================ +# EXTRACT: Lookalike Domain +# ============================================================ +EXTRACT-lookalike_domain = "domain"\s*:\s*"(?[^"]+)" +EXTRACT-lookalike_registered_at = "registered_at"\s*:\s*"(?[^"]+)" +EXTRACT-lookalike_issuer = "issuer"\s*:\s*"(?[^"]+)" + +# ============================================================ +# EXTRACT: Bucket +# ============================================================ +EXTRACT-bucket_host = "bucket"\s*:\s*\{[^}]*"host"\s*:\s*"(?[^"]+)" +EXTRACT-bucket_provider = "provider"\s*:\s*"(?[^"]+)" + +# ============================================================ +# EXTRACT: Social Media Account +# ============================================================ +EXTRACT-social_site = "site"\s*:\s*"(?[^"]+)" +EXTRACT-social_username = "username"\s*:\s*"(?[^"]+)" + +# ============================================================ +# EXTRACT: Listing +# ============================================================ +EXTRACT-listing_currency = "currency"\s*:\s*"(?[^"]+)" +EXTRACT-listing_price = "price"\s*:\s*(?[\d.]+) + +# ============================================================ +# EXTRACT: Chat Message - Forward Info +# ============================================================ +EXTRACT-forwarded_from = "forwarded_from"\s*:\s*"(?[^"]+)" +EXTRACT-was_forwarded = "was_forwarded"\s*:\s*(?true|false) + +# ============================================================ +# CIM Normalization (EVAL) +# ============================================================ + +# --- Alerts Data Model --- +EVAL-vendor = "Flare" +EVAL-app = "flare" +EVAL-vendor_action = "allowed" +EVAL-alert_type = event_type +EVAL-severity = metadata_severity +EVAL-signature = coalesce(data_title, victim_name, lookalike_domain, actor_name, social_username, cc_brand, "Flare CTEM Alert") +EVAL-alert_id = metadata_uid +EVAL-flare_url = metadata_flare_url + +# --- Network / Endpoint --- +EVAL-dest_ip = victim_ip_address +EVAL-dest = coalesce(victim_ip_address, victim_domain, lookalike_domain, bucket_host) +EVAL-url = coalesce(data_url, data_response_url) +EVAL-dest_host = coalesce(victim_domain, lookalike_domain, bucket_host) + +# --- User / Identity --- +EVAL-user = coalesce(victim_username, actor_name, social_username, cc_owner) +EVAL-user_name = coalesce(victim_username, actor_name, social_username) +EVAL-src_user = actor_name + +# --- Malware --- +EVAL-malware = malware_family +EVAL-file_path = malware_file_location + +# --- Threat Intel --- +EVAL-threat_source = "Flare CTEM" +EVAL-category = coalesce(context_category_name, event_type) + +# ============================================================ +# Internal Log Parsing (for App Logs Dashboard) +# ============================================================ +[source::...flare_cron_job.log] +SHOULD_LINEMERGE = 0 +TIME_FORMAT = %Y-%m-%d %H:%M:%S %z +# Log Format: 2026-03-31 16:52:15 +0530 INFO Event fetch response... +EXTRACT-log_level = ^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\s\S+\s+(?\w+) +EXTRACT-log_message = ^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}:\d{2}\s\S+\s+\w+\s+(?.+) \ No newline at end of file diff --git a/packages/flare/src/main/resources/splunk/default/restmap.conf b/packages/flare/src/main/resources/splunk/default/restmap.conf index b52050ab..40d1b4aa 100644 --- a/packages/flare/src/main/resources/splunk/default/restmap.conf +++ b/packages/flare/src/main/resources/splunk/default/restmap.conf @@ -1,24 +1,29 @@ -[script:flare_external_requests_api_key_validation] -match=/fetch_api_key_validation +[script:flare_validate_api_key] +match=/validate_api_key handler=flare_external_requests.FlareValidateApiKey python.version = python3 +python.required = 3.9 -[script:flare_external_requests_user_tenants] +[script:flare_user_tenants] match=/fetch_user_tenants handler=flare_external_requests.FlareUserTenants python.version = python3 +python.required = 3.9 -[script:flare_external_requests_severity_filters] +[script:flare_severity_filters] match=/fetch_severity_filters handler=flare_external_requests.FlareSeverityFilters python.version = python3 +python.required = 3.9 -[script:flare_external_requests_source_type_filters] +[script:flare_source_type_filters] match=/fetch_source_type_filters handler=flare_external_requests.FlareSourceTypeFilters python.version = python3 +python.required = 3.9 -[script:flare_external_requests_ingestion_status] -match=/fetch_ingestion_status -handler=flare_external_requests.FlareIngestionStatus +[script:flare_search_events] +match=/fetch_search_events +handler=flare_external_requests.FlareSearchEvents python.version = python3 +python.required = 3.9 \ No newline at end of file diff --git a/packages/flare/src/main/resources/splunk/default/savedsearches.conf b/packages/flare/src/main/resources/splunk/default/savedsearches.conf index 327787e4..ed525693 100644 --- a/packages/flare/src/main/resources/splunk/default/savedsearches.conf +++ b/packages/flare/src/main/resources/splunk/default/savedsearches.conf @@ -1,7 +1,134 @@ -[Flare Search] -description = Shows the ingested events from the last 24 hours -search = source="flare index=flare earliest=-24h latest=now" +############################################################ +# Flare CTEM — Saved Searches +# These searches are available under "Saved Searches" in the +# Splunk navigation bar for the Flare app. +############################################################ -[Severity] -description = Counts the events by severity in the last 24 hours -search = source=flare index=flare earliest=-24h latest=now | spath path=header.risk.score output=risk_score_str | eval risk_score = coalesce(tonumber(risk_score_str), 0) | eval risk_label = case(risk_score == 1, "Info", risk_score == 2, "Low", risk_score == 3, "Medium", risk_score == 4, "High", risk_score == 5, "Critical") | stats count by risk_label, risk_score | sort risk_score | fields - risk_score +# =========================================================== +# 1. All Unique Events (User-requested) +# =========================================================== +[Flare - All Unique Events] +description = Collect all unique Flare events within the selected time range, deduplicated by event UID. +search = `flare_index` sourcetype=flare_json \ +| spath path=metadata.uid output=uid \ +| dedup uid \ +| spath path=metadata.severity output=sev \ +| timechart span=1d count by sev +dispatch.earliest_time = -30d@d +dispatch.latest_time = now +is_visible = 1 +display.general.type = visualizations +display.visualizations.show = 1 +display.visualizations.charting.chart = column +display.visualizations.charting.chart.stackMode = stacked +display.visualizations.charting.axisTitleX.text = Date +display.visualizations.charting.axisTitleY.text = Event Count +display.visualizations.charting.fieldColors = {"critical": "#ff0040", "high": "#ff8629", "medium": "#ffae00", "low": "#fde82b", "info": "#8594ea"} + +# =========================================================== +# 2. Critical & High Severity Events +# =========================================================== +[Flare - Critical and High Severity Events] +description = All events with Critical or High severity — ideal for executive escalation reports. +search = `flare_index` sourcetype=flare_json \ +| spath path=metadata.uid output=uid \ +| spath path=metadata.severity output=sev \ +| search sev="critical" OR sev="high" \ +| dedup uid \ +| spath path=metadata.type output=etype \ +| timechart span=1d count by etype +dispatch.earliest_time = -7d@d +dispatch.latest_time = now +is_visible = 1 +display.general.type = visualizations +display.visualizations.show = 1 +display.visualizations.charting.chart = column +display.visualizations.charting.chart.stackMode = stacked +display.visualizations.charting.axisTitleX.text = Date +display.visualizations.charting.axisTitleY.text = Event Count + +# =========================================================== +# 3. Leaked Credentials Report +# =========================================================== +[Flare - Leaked Credentials Report] +description = All leaked credential events — critical for identity security reviews. +search = `flare_index` sourcetype=flare_json \ +| spath path=metadata.uid output=uid \ +| spath path=metadata.type output=etype \ +| search etype IN("leaked_credential", "leak", "leaks") \ +| dedup uid \ +| spath path=metadata.severity output=sev \ +| timechart span=1d count by sev +dispatch.earliest_time = -30d@d +dispatch.latest_time = now +is_visible = 1 +display.general.type = visualizations +display.visualizations.show = 1 +display.visualizations.charting.chart = line +display.visualizations.charting.axisTitleX.text = Date +display.visualizations.charting.axisTitleY.text = Leaked Credential Events +display.visualizations.charting.fieldColors = {"critical": "#ff0040", "high": "#ff8629", "medium": "#ffae00", "low": "#fde82b", "info": "#8594ea"} + +# =========================================================== +# 4. Daily Event Volume Trend +# =========================================================== +[Flare - Daily Event Volume Trend] +description = Daily count of ingested events over the past 30 days — monitors ingestion health. +search = `flare_index` sourcetype=flare_json \ +| spath path=metadata.uid output=uid \ +| dedup uid \ +| timechart span=1d count as "Events Ingested" +dispatch.earliest_time = -30d@d +dispatch.latest_time = now +is_visible = 1 +display.general.type = visualizations +display.visualizations.show = 1 +display.visualizations.charting.chart = column + +# =========================================================== +# 5. Severity Distribution Breakdown +# =========================================================== +[Flare - Severity Distribution] +description = Breakdown of events by severity level — at-a-glance risk posture. +search = `flare_index` sourcetype=flare_json \ +| spath path=metadata.uid output=uid \ +| spath path=metadata.severity output=sev \ +| dedup uid \ +| eval category="All Events" \ +| chart count over category by sev \ +| fields category, critical, high, medium, low, info +dispatch.earliest_time = -30d@d +dispatch.latest_time = now +is_visible = 1 +display.general.type = visualizations +display.visualizations.show = 1 +display.visualizations.charting.chart = column +display.visualizations.charting.fieldColors = {"critical": "#ff0040", "high": "#ff8629", "medium": "#ffae00", "low": "#fde82b", "info": "#8594ea"} + +# =========================================================== +# 6. Ingestion Health Check (App Logs) +# =========================================================== +[Flare - Ingestion Health Check] +description = Log volume trend over the last 24 hours, grouped by log level — verify the data pipeline is healthy. +search = index=_internal source="*flare_cron_job.log" log_level=* \ +| timechart span=1h count by log_level +dispatch.earliest_time = -24h@h +dispatch.latest_time = now +is_visible = 1 +display.general.type = visualizations +display.visualizations.show = 1 +display.visualizations.charting.chart = column + +# =========================================================== +# 7. Ingestion Errors Only +# =========================================================== +[Flare - Ingestion Errors] +description = Filters app logs to show only ERROR and CRITICAL entries — fast troubleshooting. +search = index=_internal source="*flare_cron_job.log" (log_level="ERROR" OR log_level="CRITICAL") \ +| timechart span=1h count by log_level +dispatch.earliest_time = -7d@d +dispatch.latest_time = now +is_visible = 1 +display.general.type = visualizations +display.visualizations.show = 1 +display.visualizations.charting.chart = column \ No newline at end of file diff --git a/packages/flare/src/main/resources/splunk/default/splunk_create.conf b/packages/flare/src/main/resources/splunk/default/splunk_create.conf index 1476062c..547ced0b 100644 --- a/packages/flare/src/main/resources/splunk/default/splunk_create.conf +++ b/packages/flare/src/main/resources/splunk/default/splunk_create.conf @@ -2,5 +2,5 @@ # please do not edit it [base] -splunk_create_version = 7.0.2 +splunk_create_version = 10.2.0 splunk_create_type = ReactSplunkApp \ No newline at end of file diff --git a/packages/flare/src/main/resources/splunk/default/tags.conf b/packages/flare/src/main/resources/splunk/default/tags.conf new file mode 100644 index 00000000..b1fef933 --- /dev/null +++ b/packages/flare/src/main/resources/splunk/default/tags.conf @@ -0,0 +1,66 @@ +############################################################ +# Flare CTEM - CIM Tags +############################################################ + +# Base: All Flare events are alerts +[eventtype=flare_alert] +alert = enabled +threat = enabled + +# Ransom Leak +[eventtype=flare_ransomleak] +alert = enabled +threat = enabled + +# Stealer Log +[eventtype=flare_stealer_log] +alert = enabled +threat = enabled +malware = enabled + +# Credit Card +[eventtype=flare_cc] +alert = enabled +threat = enabled + +# Lookalike Domain +[eventtype=flare_lookalike] +alert = enabled +threat = enabled +network = enabled + +# Blog Post +[eventtype=flare_blog_post] +alert = enabled +threat = enabled + +# Chat Message +[eventtype=flare_chat_message] +alert = enabled +threat = enabled + +# Forum Post +[eventtype=flare_forum_post] +alert = enabled +threat = enabled + +# Paste +[eventtype=flare_paste] +alert = enabled +threat = enabled + +# Listing +[eventtype=flare_listing] +alert = enabled +threat = enabled + +# Bucket +[eventtype=flare_bucket] +alert = enabled +threat = enabled +cloud = enabled + +# Social Media Account +[eventtype=flare_social_media] +alert = enabled +threat = enabled diff --git a/packages/flare/src/main/resources/splunk/default/web.conf b/packages/flare/src/main/resources/splunk/default/web.conf index 00f171a3..d7792b1a 100644 --- a/packages/flare/src/main/resources/splunk/default/web.conf +++ b/packages/flare/src/main/resources/splunk/default/web.conf @@ -1,19 +1,19 @@ -[expose:flare_external_requests_api_key_validation] -pattern=fetch_api_key_validation +[expose:flare_validate_api_key] +pattern=validate_api_key methods=POST -[expose:flare_external_requests_user_tenants] +[expose:flare_user_tenants] pattern=fetch_user_tenants methods=POST -[expose:flare_external_requests_severity_filters] +[expose:flare_severity_filters] pattern=fetch_severity_filters methods=POST -[expose:flare_external_requests_source_type_filters] +[expose:flare_source_type_filters] pattern=fetch_source_type_filters methods=POST -[expose:flare_external_requests_ingestion_status] -pattern=fetch_ingestion_status -methods=GET +[expose:flare_search_events] +pattern=fetch_search_events +methods=POST diff --git a/packages/flare/src/main/resources/splunk/metadata/default.meta b/packages/flare/src/main/resources/splunk/metadata/default.meta new file mode 100644 index 00000000..2de563b0 --- /dev/null +++ b/packages/flare/src/main/resources/splunk/metadata/default.meta @@ -0,0 +1,12 @@ +[] +access = read : [ * ], write : [ admin, power ] +export = system + +[storage/passwords] +access = read : [ admin, power ], write : [ admin, power ] + +[views/configuration] +access = read : [ admin ], write : [ admin ] + +[views/app_logs] +access = read : [ admin ], write : [ admin ] diff --git a/packages/flare/src/main/webapp/pages/configuration/Styles.ts b/packages/flare/src/main/webapp/pages/configuration/Styles.ts new file mode 100644 index 00000000..5694eec3 --- /dev/null +++ b/packages/flare/src/main/webapp/pages/configuration/Styles.ts @@ -0,0 +1,10 @@ +import styled from 'styled-components'; +import { variables, mixins } from '@splunk/themes'; + +export const StyledContainer = styled.div` + ${mixins.reset('inline')}; + display: block; + font-size: ${variables.fontSizeLarge}; + line-height: 200%; + margin: ${variables.spacingXXLarge} ${variables.spacingXXLarge}; +`; diff --git a/packages/flare/src/main/webapp/pages/configuration/index.jsx b/packages/flare/src/main/webapp/pages/configuration/index.jsx deleted file mode 100644 index d2f16acf..00000000 --- a/packages/flare/src/main/webapp/pages/configuration/index.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -import layout from '@splunk/react-page'; -import ConfigurationScreen from '@flare/react-components/configuration-screen'; -import { getUserTheme } from '@splunk/splunk-utils/themes'; - -getUserTheme() - .then((theme) => { - layout(, { - theme, - }); - }) - .catch((e) => { - const errorEl = document.createElement('span'); - errorEl.innerHTML = e; - document.body.appendChild(errorEl); - }); diff --git a/packages/flare/src/main/webapp/pages/configuration/index.tsx b/packages/flare/src/main/webapp/pages/configuration/index.tsx new file mode 100644 index 00000000..1bf2cd24 --- /dev/null +++ b/packages/flare/src/main/webapp/pages/configuration/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import layout from '@splunk/react-page/18'; +import Configuration from '@splunk/configuration'; +import { getUserTheme } from '@splunk/splunk-utils/themes'; +import { StyledContainer } from './Styles'; + +getUserTheme() + .then((theme) => { + layout( + + + , + { + theme, + }, + ); + }) + .catch((e) => { + const errorEl = document.createElement('span'); + errorEl.innerHTML = e; + document.body.appendChild(errorEl); + }); diff --git a/packages/flare/src/main/webapp/pages/status/index.jsx b/packages/flare/src/main/webapp/pages/status/index.jsx deleted file mode 100644 index b97f20a3..00000000 --- a/packages/flare/src/main/webapp/pages/status/index.jsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; - -import layout from '@splunk/react-page'; -import StatusScreen from '@flare/react-components/status-screen'; -import { getUserTheme } from '@splunk/splunk-utils/themes'; - -getUserTheme() - .then((theme) => { - layout(, { - theme, - }); - }) - .catch((e) => { - const errorEl = document.createElement('span'); - errorEl.innerHTML = e; - document.body.appendChild(errorEl); - }); diff --git a/packages/flare/tests/bin/conftest.py b/packages/flare/tests/bin/conftest.py deleted file mode 100644 index 25c89951..00000000 --- a/packages/flare/tests/bin/conftest.py +++ /dev/null @@ -1,136 +0,0 @@ -import os -import pytest -import sys - -from datetime import datetime -from pathlib import Path -from typing import Generator -from typing import List -from typing import Optional -from unittest import mock - - -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../bin")) -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../bin/vendor")) -from data_store import ConfigDataStore -from flare import FlareAPI -from logger import Logger -from vendor.splunklib.client import StoragePasswords - - -class FakeStoragePassword: - def __init__(self, username: str, clear_password: str) -> None: - self._state = { - "username": username, - "clear_password": clear_password, - } - - @property - def content(self: "FakeStoragePassword") -> "FakeStoragePassword": - return self - - @property - def username(self) -> str: - return self._state["username"] - - @property - def clear_password(self) -> str: - return self._state["clear_password"] - - -class FakeStoragePasswords(StoragePasswords): - def __init__(self, passwords: List[FakeStoragePassword]) -> None: - self._passwords = passwords - - def list(self) -> List[FakeStoragePassword]: - return self._passwords - - -class FakeLogger(Logger): - def __init__(self) -> None: - super().__init__(class_name="Logger") - self.messages: List[str] = [] - - self._mock = mock.MagicMock(spec=Logger) - - def info(self, message: str) -> None: - self.messages.append(f"INFO: {message}") - - def error(self, message: str) -> None: - self.messages.append(f"ERROR: {message}") - - -class FakeFlareAPI(FlareAPI): - def __init__(self, api_key: str, tenant_id: int, logger: Logger) -> None: - pass - - def fetch_feed_events( - self, - next: Optional[str], - start_date: Optional[datetime], - ingest_full_event_data: bool, - severities: list[str], - source_types: list[str], - ) -> List[tuple[dict, str]]: - return [ - ( - {"actor": "this guy"}, - "first_next_token", - ), - ( - {"actor": "some other guy"}, - "second_next_token", - ), - ] - - -@pytest.fixture -def storage_passwords(request: pytest.FixtureRequest) -> FakeStoragePasswords: - passwords: list[FakeStoragePassword] = [] - data: list[tuple[str, str]] = request.param if hasattr(request, "param") else [] - - if data: - for item in data: - passwords.append( - FakeStoragePassword(username=item[0], clear_password=item[1]) - ) - - return FakeStoragePasswords(passwords=passwords) - - -@pytest.fixture -def logger() -> Logger: - return FakeLogger() - - -@pytest.fixture -def mock_config_file(tmp_path: Path) -> Path: - # Creates a temporary config file for testing. - config_file = tmp_path / "data_store.conf" - with open(config_file, "w") as f: - f.write("[metadata]\n") - return config_file - - -@pytest.fixture -def mock_env(mock_config_file: Path) -> Generator[None, None, None]: - # Mocks environment variable and file interactions. - with mock.patch.dict(os.environ, {"SPLUNK_HOME": str(mock_config_file.parent)}): - with mock.patch("builtins.open", mock.mock_open(read_data="[metadata]\n")): - yield - - -@pytest.fixture -def data_store(mock_env: None) -> Generator[ConfigDataStore, None, None]: - # Creates an instance of ConfigDataStore with mocked dependencies. - with mock.patch("configparser.RawConfigParser.read") as mock_read: - mock_read.return_value = None - store = ConfigDataStore() - store._commit = lambda: None - yield store - - -@pytest.fixture -def disable_sleep() -> Generator[None, None, None]: - with mock.patch("time.sleep", return_value=None): - yield diff --git a/packages/flare/tests/bin/test_data_store.py b/packages/flare/tests/bin/test_data_store.py deleted file mode 100644 index 870a8b43..00000000 --- a/packages/flare/tests/bin/test_data_store.py +++ /dev/null @@ -1,23 +0,0 @@ -from data_store import ConfigDataStore -from datetime import datetime -from datetime import timezone - - -def test_get_and_set_last_fetch(data_store: ConfigDataStore) -> None: - date = datetime(2024, 3, 6, 14, 0, 0, tzinfo=timezone.utc) - data_store.set_last_fetch(date) - assert data_store.get_last_fetch() == date - - -def test_get_and_set_next_by_tenant(data_store: ConfigDataStore) -> None: - tenant_id = 789 - next_token = "next_token_value" - data_store.set_next_by_tenant(tenant_id, next_token) - assert data_store.get_next_by_tenant(tenant_id) == "next_token_value" - - -def test_get_and_set_earliest_ingested_by_tenant(data_store: ConfigDataStore) -> None: - tenant_id = 789 - date = datetime(2024, 3, 6, 14, 0, 0, tzinfo=timezone.utc) - data_store.set_earliest_ingested_by_tenant(tenant_id, date) - assert data_store.get_earliest_ingested_by_tenant(tenant_id) == date diff --git a/packages/flare/tests/bin/test_flare_wrapper.py b/packages/flare/tests/bin/test_flare_wrapper.py deleted file mode 100644 index 3f5b5f76..00000000 --- a/packages/flare/tests/bin/test_flare_wrapper.py +++ /dev/null @@ -1,210 +0,0 @@ -import requests_mock - -from conftest import FakeLogger -from flare import FlareAPI -from typing import Any - - -def test_flare_full_data_without_metadata( - logger: FakeLogger, - disable_sleep: Any, -) -> None: - with requests_mock.Mocker() as mocker: - mocker.register_uri( - "POST", - "https://api.flare.io/tokens/generate", - status_code=200, - json={"token": "access_token"}, - ) - - tenant_resp_page_1: Any = { - "next": "some_next_value", - "items": [ - {"metadata": {"uid": "some_uid_1"}}, - {"metadata": {"uid": "some_uid_2"}}, - ], - } - - tenant_resp_page_2: Any = { - "next": None, - "items": [], - } - - mocker.register_uri( - "POST", - "https://api.flare.io/firework/v4/events/tenant/_search", - status_code=200, - json=tenant_resp_page_1, - ) - - mocker.register_uri( - "POST", - "https://api.flare.io/firework/v4/events/tenant/_search", - additional_matcher=lambda request: request.json().get("from") - == "some_next_value", - status_code=200, - json=tenant_resp_page_2, - ) - - mock_full_event = mocker.register_uri( - "GET", - "https://api.flare.io/firework/v2/activities/some_uid_1", - status_code=200, - json={}, - ) - - flare_api = FlareAPI(api_key="some_key", tenant_id=111, logger=logger) - - events: list[dict] = [] - for event, next_token in flare_api.fetch_feed_events( - next=None, - start_date=None, - ingest_full_event_data=False, - severities=[], - source_types=[], - ): - assert next_token == tenant_resp_page_1["next"] - events.append(event) - - assert events == tenant_resp_page_1["items"] - assert not mock_full_event.called - - -def test_flare_full_data_with_metadata( - logger: FakeLogger, - disable_sleep: Any, -) -> None: - with requests_mock.Mocker() as mocker: - mocker.register_uri( - "POST", - "https://api.flare.io/tokens/generate", - status_code=200, - json={"token": "access_token"}, - ) - - tenant_resp_page_1: Any = { - "next": "some_next_value", - "items": [ - {"metadata": {"uid": "some_uid_1"}}, - {"metadata": {"uid": "some_uid_2"}}, - ], - } - - tenant_resp_page_2: Any = { - "next": None, - "items": [], - } - - mocker.register_uri( - "POST", - "https://api.flare.io/firework/v4/events/tenant/_search", - status_code=200, - json=tenant_resp_page_1, - ) - - mocker.register_uri( - "POST", - "https://api.flare.io/firework/v4/events/tenant/_search", - additional_matcher=lambda request: request.json().get("from") - == "some_next_value", - status_code=200, - json=tenant_resp_page_2, - ) - - expected_full_event_resp = [ - { - "metadata": { - "uid": "some_uid_1", - }, - }, - { - "metadata": { - "uid": "some_uid_2", - }, - }, - ] - - mock_full_event_1 = mocker.register_uri( - "GET", - "https://api.flare.io/firework/v2/activities/some_uid_1", - status_code=200, - json={"activity": expected_full_event_resp[0]}, - ) - - mock_full_event_2 = mocker.register_uri( - "GET", - "https://api.flare.io/firework/v2/activities/some_uid_2", - status_code=200, - json={"activity": expected_full_event_resp[1]}, - ) - - flare_api = FlareAPI(api_key="some_key", tenant_id=111, logger=logger) - - events: list[dict] = [] - for event, next_token in flare_api.fetch_feed_events( - next=None, - start_date=None, - ingest_full_event_data=True, - severities=[], - source_types=[], - ): - assert next_token == tenant_resp_page_1["next"] - events.append(event) - - for i in range(len(events)): - assert events[i] == expected_full_event_resp[i] - - assert mock_full_event_1.called - assert mock_full_event_2.called - - -def test_flare_full_data_retry_errors( - logger: FakeLogger, - disable_sleep: Any, -) -> None: - with requests_mock.Mocker() as mocker: - mocker.register_uri( - "POST", - "https://api.flare.io/tokens/generate", - status_code=200, - json={"token": "access_token"}, - ) - - tenant_resp_page_1 = { - "next": "some_next_value", - "items": [ - {"metadata": {"uid": "some_uid_1"}}, - {"metadata": {"uid": "some_uid_2"}}, - ], - } - - mocker.register_uri( - "POST", - "https://api.flare.io/firework/v4/events/tenant/_search", - status_code=200, - json=tenant_resp_page_1, - ) - - mocker.register_uri( - "GET", - "https://api.flare.io/firework/v2/activities/some_uid_1", - status_code=500, - ) - - flare_api = FlareAPI(api_key="some_key", tenant_id=111, logger=logger) - - next( - flare_api.fetch_feed_events( - next=None, - start_date=None, - ingest_full_event_data=True, - severities=[], - source_types=[], - ) - ) - - assert logger.messages == [ - "INFO: Failed to fetch event 1/3 retries: 500 Server Error: None for url: https://api.flare.io/firework/v2/activities/some_uid_1", - "INFO: Failed to fetch event 2/3 retries: 500 Server Error: None for url: https://api.flare.io/firework/v2/activities/some_uid_1", - "INFO: Failed to fetch event 3/3 retries: 500 Server Error: None for url: https://api.flare.io/firework/v2/activities/some_uid_1", - ] diff --git a/packages/flare/tests/bin/test_ingest_events.py b/packages/flare/tests/bin/test_ingest_events.py deleted file mode 100644 index 30f993af..00000000 --- a/packages/flare/tests/bin/test_ingest_events.py +++ /dev/null @@ -1,143 +0,0 @@ -import datetime -import pytest - -from conftest import FakeFlareAPI -from conftest import FakeLogger -from conftest import FakeStoragePasswords -from constants import CRON_JOB_THRESHOLD_SINCE_LAST_FETCH -from constants import PasswordKeys -from cron_job_ingest_events import fetch_feed -from cron_job_ingest_events import get_api_key -from cron_job_ingest_events import get_ingest_full_event_data -from cron_job_ingest_events import get_tenant_ids -from cron_job_ingest_events import main -from data_store import ConfigDataStore -from freezegun import freeze_time - - -@pytest.mark.parametrize("storage_passwords", [[]], indirect=True) -def test_get_api_key_expect_exception(storage_passwords: FakeStoragePasswords) -> None: - with pytest.raises(Exception, match="API key not found"): - get_api_key(storage_passwords=storage_passwords) - - -@pytest.mark.parametrize( - "storage_passwords", - [[(PasswordKeys.API_KEY.value, "some_api_key")]], - indirect=True, -) -def test_tenant_id_expect_exception(storage_passwords: FakeStoragePasswords) -> None: - with pytest.raises(Exception, match="Tenant IDs not found"): - get_tenant_ids(storage_passwords=storage_passwords) - - -@pytest.mark.parametrize( - "storage_passwords", - [ - [ - (PasswordKeys.API_KEY.value, "some_api_key"), - (PasswordKeys.TENANT_IDS.value, "[11111,22222]"), - ], - ], - indirect=True, -) -def test_get_api_credentials_expect_api_key_and_tenant_id( - storage_passwords: FakeStoragePasswords, -) -> None: - assert get_api_key(storage_passwords=storage_passwords) == "some_api_key" - assert get_tenant_ids(storage_passwords=storage_passwords) == [11111, 22222] - - -def test_get_default_ingest_full_event_data_value( - storage_passwords: FakeStoragePasswords, -) -> None: - assert get_ingest_full_event_data(storage_passwords=storage_passwords) is False - - -def test_fetch_feed_expect_feed_response( - logger: FakeLogger, data_store: ConfigDataStore -) -> None: - first_item = ({"actor": "this guy"}, "first_next_token") - second_item = ({"actor": "some other guy"}, "second_next_token") - expected_items = [first_item, second_item] - - index = 0 - for event, next_token in fetch_feed( - logger=logger, - api_key="some_key", - tenant_id=11111, - ingest_full_event_data=True, - severities=[], - source_types=[], - flare_api_cls=FakeFlareAPI, - data_store=data_store, - ): - assert event == expected_items[index][0] - assert next_token == expected_items[index][1] - index += 1 - - assert logger.messages == [ - "INFO: Fetching tenant_id=11111, next=None, start_date=None" - ] - - -@pytest.mark.parametrize( - "storage_passwords", - [ - [ - (PasswordKeys.API_KEY.value, "some_api_key"), - (PasswordKeys.TENANT_IDS.value, "[11111]"), - ] - ], - indirect=True, -) -@freeze_time("2000-01-01 12:09:00") -def test_main_expect_early_return( - logger: FakeLogger, - storage_passwords: FakeStoragePasswords, - data_store: ConfigDataStore, -) -> None: - data_store.set_last_fetch( - datetime.datetime.fromisoformat("2000-01-01T12:00:00+00:00") - ) - - main( - logger=logger, - storage_passwords=storage_passwords, - flare_api_cls=FakeFlareAPI, - data_store=data_store, - ) - assert logger.messages == [ - f"INFO: Fetched events less than {int(CRON_JOB_THRESHOLD_SINCE_LAST_FETCH.seconds / 60)} minutes ago, exiting" - ] - - -@pytest.mark.parametrize( - "storage_passwords", - [ - [ - (PasswordKeys.API_KEY.value, "some_api_key"), - (PasswordKeys.TENANT_IDS.value, "[11111,22222]"), - ] - ], - indirect=True, -) -@freeze_time("2000-01-01") -def test_main_expect_normal_run( - logger: FakeLogger, - storage_passwords: FakeStoragePasswords, - data_store: ConfigDataStore, -) -> None: - main( - logger=logger, - storage_passwords=storage_passwords, - flare_api_cls=FakeFlareAPI, - data_store=data_store, - ) - assert logger.messages == [ - "INFO: Fetching tenant_id=11111, next=None, start_date=FakeDatetime(1999, 12, 2, 0, 0, tzinfo=datetime.timezone.utc)", - "INFO: Fetched 2 events on tenant 11111", - "INFO: Fetching tenant_id=22222, next=None, start_date=FakeDatetime(1999, 12, 2, 0, 0, tzinfo=datetime.timezone.utc)", - "INFO: Fetched 2 events on tenant 22222", - "INFO: Fetched 4 events across all tenants", - ] diff --git a/packages/flare/tsconfig.json b/packages/flare/tsconfig.json new file mode 100644 index 00000000..cad21a42 --- /dev/null +++ b/packages/flare/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "jsx": "react", + "lib": ["es2017", "dom"], + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "types": [], + "rootDir": ".", + "emitDeclarationOnly": true, + "declaration": true, + "declarationDir": "./types" + } +} diff --git a/packages/flare/webpack.config.js b/packages/flare/webpack.config.js index f51077d5..fc64b50f 100644 --- a/packages/flare/webpack.config.js +++ b/packages/flare/webpack.config.js @@ -16,7 +16,7 @@ const entries = fs module.exports = webpackMerge(baseConfig, { entry: entries, output: { - path: path.join(__dirname, '../../output/flare/appserver/static/pages/'), + path: path.join(__dirname, 'stage/appserver/static/pages/'), filename: '[name].js', }, plugins: [ @@ -24,19 +24,13 @@ module.exports = webpackMerge(baseConfig, { patterns: [ { from: path.join(__dirname, 'src/main/resources/splunk'), - to: path.join(__dirname, '../../output/flare'), - filter: (filepath) => { return !filepath.endsWith("README/splunk_create.spec.conf"); }, - }, - { - from: path.join(__dirname, 'bin'), - to: path.join(__dirname, '../../output/flare/bin'), - }, - { - from: path.join(__dirname, 'README'), - to: path.join(__dirname, '../../output/flare/'), + to: path.join(__dirname, 'stage'), }, ], }), ], devtool: 'eval-source-map', + module: { + rules: [{ test: /\.css$/, use: 'css-loader' }], + }, }); diff --git a/packages/react-components/.babelrc.js b/packages/react-components/.babelrc.js deleted file mode 100644 index ead152c7..00000000 --- a/packages/react-components/.babelrc.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - presets: ['@splunk/babel-preset'], -}; diff --git a/packages/react-components/.eslintignore b/packages/react-components/.eslintignore deleted file mode 100644 index f0f9be9a..00000000 --- a/packages/react-components/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -src/vendor/** diff --git a/packages/react-components/.eslintrc.js b/packages/react-components/.eslintrc.js deleted file mode 100644 index c3275229..00000000 --- a/packages/react-components/.eslintrc.js +++ /dev/null @@ -1,34 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - plugins: ['@typescript-eslint'], - extends: '@splunk/eslint-config/browser-prettier', - rules: { - 'react/jsx-filename-extension': [2, { 'extensions': ['.js', '.jsx', '.ts', '.tsx'] }], - 'no-restricted-syntax': 'off', - 'no-use-before-define': 'off', - "@typescript-eslint/explicit-function-return-type": [ - "error", - { - "allowExpressions": false, - "allowTypedFunctionExpressions": true, - "allowHigherOrderFunctions": false, - "allowDirectConstAssertionInArrowFunctions": true, - "allowConciseArrowFunctionExpressionsStartingWithVoid": true, - } - ], - '@typescript-eslint/no-unused-vars': ['warn'], - 'no-unused-vars': ['warn'], - 'camelcase': 'off', - 'no-underscore-dangle': 'off', - 'no-shadow': 'off', - '@typescript-eslint/no-shadow': 'error', - 'react/jsx-no-target-blank': 'off', - 'jsx-a11y/click-events-have-key-events': 'off', - 'jsx-a11y/no-static-element-interactions': 'off', - 'jsx-a11y/click-events-have-key-events': 'off', - 'jsx-a11y/label-has-associated-control': 'off', - }, - env: { - jest: true - }, -}; diff --git a/packages/react-components/package.json b/packages/react-components/package.json deleted file mode 100644 index 89a961f0..00000000 --- a/packages/react-components/package.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "name": "@flare/react-components", - "version": "0.0.1", - "license": "UNLICENSED", - "scripts": { - "build": "node build.js build", - "eslint": "eslint src --ext \".tsx,.ts\"", - "eslint:fix": "eslint src --ext \".tsx,.ts\" --fix", - "lint": "yarn run eslint && yarn run stylelint", - "lint:ci": "yarn run eslint:ci && yarn run stylelint", - "start": "webpack --watch", - "stylelint": "stylelint \"src/**/*.{ts,tsx}\" --config stylelint.config.js", - "test": "jest", - "test:ci": "yarn run test --ci", - "test:watch": "jest --watch" - }, - "exports": { - "./configuration-screen": "./ConfigurationScreen.js", - "./status-screen": "./StatusScreen.js" - }, - "dependencies": { - "@splunk/react-ui": "^4.30.0", - "@splunk/themes": "^0.18.0" - }, - "devDependencies": { - "@babel/core": "^7.2.0", - "@splunk/babel-preset": "^4.0.0", - "@splunk/eslint-config": "^4.0.0", - "@splunk/splunk-utils": "^3.0.1", - "@splunk/stylelint-config": "^4.0.0", - "@splunk/webpack-configs": "^7.0.2", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^12", - "@types/jest": "^29.5.14", - "@typescript-eslint/eslint-plugin": "^4.0.0", - "@typescript-eslint/parser": "^4.0.0", - "babel-eslint": "^10.1.0", - "babel-loader": "^8.3.0", - "css-loader": "^7.1.2", - "eslint": "^7.14.0", - "eslint-config-airbnb": "^18.2.1", - "eslint-config-prettier": "^6.15.0", - "eslint-import-resolver-webpack": "^0.13.4", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-jsx-a11y": "^6.4.1", - "eslint-plugin-react": "^7.21.5", - "eslint-plugin-react-hooks": "^4.2.0", - "html-webpack-plugin": "^5.5.3", - "jest": "^29.7.0", - "lerna": "^8.1.9", - "react": "^16.12.0", - "react-dom": "^16.12.0", - "react-test-renderer": "^16.12.0", - "shelljs": "^0.8.5", - "style-loader": "^4.0.0", - "styled-components": "^5.3.10", - "stylelint": "^13.0.0", - "ts-jest": "^29.2.5", - "typescript": "^4.3.0", - "webpack": "^5.88.2", - "webpack-cli": "^5.1.4", - "webpack-dev-server": "^4.15.1", - "webpack-merge": "^5.9.0" - }, - "peerDependencies": { - "react": "^16.8", - "styled-components": "^5.3.10" - }, - "engines": { - "node": ">=14" - } -} diff --git a/packages/react-components/src/ConfigurationScreen.css b/packages/react-components/src/ConfigurationScreen.css deleted file mode 100644 index 20b846be..00000000 --- a/packages/react-components/src/ConfigurationScreen.css +++ /dev/null @@ -1,41 +0,0 @@ -#container { - display: flex; - flex-direction: column; - width: 100%; - height: auto; - background-color: var(--bg-color); - color: var(--text-color); - text-align: center; - gap: 2rem; -} - -.content { - display: flex; - flex-direction: row; - flex: content; - height: auto; - width: 800px; - gap: 1.5rem; - align-self: center; - padding-bottom: 2rem; -} - -.content-step { - display: flex; - flex-direction: column; - flex: content; - align-items: flex-start; - gap: 2rem; -} - -.link { - display: flex; - flex-direction: row; - gap: 0.5rem; - width: 10rem; -} - -#learn-more { - right: 0; - height: fit-content; -} diff --git a/packages/react-components/src/ConfigurationScreen.tsx b/packages/react-components/src/ConfigurationScreen.tsx deleted file mode 100644 index 17171478..00000000 --- a/packages/react-components/src/ConfigurationScreen.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React, { FC, useEffect, useState } from 'react'; -import './ConfigurationScreen.css'; -import ConfigurationCompletedStep from './components/ConfigurationCompletedStep'; -import ConfigurationInitialStep from './components/ConfigurationInitialStep'; -import ConfigurationUserPreferencesStep from './components/ConfigurationUserPreferencesStep'; -import LoadingBar from './components/LoadingBar'; -import { toastManager } from './components/ToastManager'; -import DoneIcon from './components/icons/DoneIcon'; -import ExternalLinkIcon from './components/icons/ExternalLinkIcon'; -import ToolIcon from './components/icons/ToolIcon'; -import './global.css'; -import { ConfigurationStep } from './models/flare'; -import { createFlareIndex, fetchApiKey, redirectToHomepage } from './utils/setupConfiguration'; - -const ConfigurationScreen: FC<{ theme: string }> = ({ theme }) => { - const [configurationStep, setConfigurationStep] = useState(ConfigurationStep.Initial); - const [apiKey, setApiKey] = useState(''); - - toastManager.setup('container', theme); - - const handleBackButton = (): void => { - switch (configurationStep) { - case ConfigurationStep.Initial: - redirectToHomepage(); - break; - case ConfigurationStep.UserPreferences: - case ConfigurationStep.Completed: - setConfigurationStep(ConfigurationStep.Initial); - break; - default: - throw new Error(`Back button not implemented for ${configurationStep}`); - } - }; - - useEffect(() => { - if (configurationStep === ConfigurationStep.Initial) { - Promise.all([fetchApiKey(), createFlareIndex()]).then(([key]) => { - setApiKey(key); - }); - } - }, [configurationStep]); - - useEffect(() => { - const container = document.getElementById('container') as HTMLDivElement; - const parentContainer = container.parentElement?.parentElement ?? undefined; - if (parentContainer) { - parentContainer.className = `parent-container ${theme === 'dark' ? 'dark' : ''}`; - } - }, [theme]); - - return ( -
- -
-
-
- ); -}; - -export default ConfigurationScreen; diff --git a/packages/react-components/src/StatusScreen.css b/packages/react-components/src/StatusScreen.css deleted file mode 100644 index 1e278b27..00000000 --- a/packages/react-components/src/StatusScreen.css +++ /dev/null @@ -1,58 +0,0 @@ -:root { - --content-width: 800px; -} - -#container { - display: flex; - flex-direction: column; - width: 100%; - height: auto; - background-color: var(--bg-color); - color: var(--text-color); - text-align: center; - gap: 2rem; - padding-top: 2rem; -} - -.content { - display: flex; - flex-direction: column; - flex: content; - height: auto; - width: var(--content-width); - align-self: center; - align-items: flex-start; - gap: 1.5rem; -} - -#status-list { - display: flex; - flex-direction: column; - gap: 0.5rem; - flex: content; -} - -.status-item { - text-align: start; - overflow-wrap: anywhere; - display: flex; - flex-direction: column; - gap: 0.125rem; - background-color: var(--secondary-bg-color); - border-radius: 10px; - padding: 1rem; - width: var(--content-width); -} - -.status-item[hidden] { - display: none; -} - -.status-item-name { - font-weight: bold; - color: var(--text-color); -} - -.status-item-value { - color: var(--secondary-text-color); -} diff --git a/packages/react-components/src/StatusScreen.tsx b/packages/react-components/src/StatusScreen.tsx deleted file mode 100644 index b5f820eb..00000000 --- a/packages/react-components/src/StatusScreen.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { FC, useEffect, useState } from 'react'; -import Button from './components/Button'; -import './global.css'; -import './StatusScreen.css'; -import { - fetchIngestionStatus, - fetchCurrentIndexName, - fetchVersionName, -} from './utils/setupConfiguration'; - -enum StatusItemKeys { - START_DATE = 'start_date', - LAST_FETCHED = 'timestamp_last_fetch', - NEXT_TOKEN = 'next_token', - INDEX = 'index', - VERSION = 'version', -} - -interface StatusItem { - key: StatusItemKeys; - name: string; - value: string; -} - -const StatusScreen: FC<{ theme: string }> = ({ theme }) => { - const [statusItems, setStatusItem] = useState([]); - const [advancedStatusItems, setAdvancedStatusItem] = useState([]); - const [isShowingAllItems, setShowingAllItems] = useState(false); - - useEffect(() => { - Promise.all([ - fetchIngestionStatus(), - fetchVersionName('unknown'), - fetchCurrentIndexName(), - ]).then(([ingestionStatus, version, indexName]) => { - setStatusItem([ - { - key: StatusItemKeys.VERSION, - name: 'Version', - value: `${version}`, - }, - { - key: StatusItemKeys.INDEX, - name: 'Splunk Index', - value: `${indexName}`, - }, - ]); - - setAdvancedStatusItem([ - { - key: StatusItemKeys.LAST_FETCHED, - name: 'Last moment the events were ingested', - value: ingestionStatus.last_fetched_at - ? new Date(ingestionStatus.last_fetched_at).toLocaleString() - : 'N/A', - }, - ]); - }); - }, []); - - useEffect(() => { - const container = document.getElementById('container') as HTMLDivElement; - const parentContainer = container.parentElement?.parentElement ?? undefined; - if (parentContainer) { - parentContainer.className = `parent-container ${theme === 'dark' ? 'dark' : ''}`; - } - }, [theme]); - - const toggleShowingAllItems = (): void => setShowingAllItems(!isShowingAllItems); - - return ( -
-
-
-

Status

-
-
- {statusItems.map((item) => { - return ( - - {item.name} - {item.value} - - ); - })} - {advancedStatusItems.map((item) => { - return ( - - ); - })} -
- -
-
- ); -}; - -export default StatusScreen; diff --git a/packages/react-components/src/components/Button.css b/packages/react-components/src/components/Button.css deleted file mode 100644 index 0fa1e6d0..00000000 --- a/packages/react-components/src/components/Button.css +++ /dev/null @@ -1,52 +0,0 @@ -.button-content { - display: flex; - flex-direction: row; - align-items: center; - gap: 0.5rem; -} - -button { - padding: 0.25rem 1rem; - font-size: 0.825rem; - cursor: pointer; - border-radius: 1rem; - height: 1.75rem; - flex: 1; - user-select: none; - width: fit-content; -} - -button:disabled { - background-color: var(--button-disabled-bg-color); - color: var(--button-disabled-text-color); - border: none; - cursor: not-allowed; -} - -.button-loading { - cursor: wait !important; -} - -.secondary-button { - background-color: var(--bg-color); - border: 2px solid var(--button-bg-color); - color: var(--button-bg-color); -} - -.secondary-button:hover:enabled { - background-color: var(--button-bg-secondary-hover-color); - border: 2px solid var(--button-secondary-hover-color); - color: var(--button-secondary-hover-color); -} - -.primary-button { - background-color: var(--button-bg-color); - color: var(--button-text-color); - border: none; -} - -.primary-button:hover:enabled { - background-color: var(--button-bg-hover-color); - color: var(--button-text-color); - border: none; -} diff --git a/packages/react-components/src/components/Button.tsx b/packages/react-components/src/components/Button.tsx deleted file mode 100644 index db18972c..00000000 --- a/packages/react-components/src/components/Button.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React, { FC } from 'react'; -import ProgressBar from './ProgressBar'; - -import './Button.css'; - -const Button: FC<{ - onClick: () => void; - isSecondary?: boolean; - isLoading?: boolean; - isDisabled?: boolean; -}> = ({ onClick, isSecondary = false, isLoading = false, isDisabled = false, children }) => { - return ( - - ); -}; - -export default Button; diff --git a/packages/react-components/src/components/ConfigurationCompletedStep.tsx b/packages/react-components/src/components/ConfigurationCompletedStep.tsx deleted file mode 100644 index ce5b66f0..00000000 --- a/packages/react-components/src/components/ConfigurationCompletedStep.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { FC, useEffect, useState } from 'react'; -import { - fetchTenantIds, - fetchUserTenants, - getFlareSearchDataUrl, -} from '../utils/setupConfiguration'; -import Button from './Button'; -import ArrowRightIcon from './icons/ArrowRightIcon'; - -import { ConfigurationStep, Tenant } from '../models/flare'; -import './ConfigurationGlobalStep.css'; -import FlareLogoLoading from './FlareLogoLoading'; - -const ConfigurationCompletedStep: FC<{ - apiKey: string; - configurationStep: ConfigurationStep; - onEditConfigurationClick: () => void; -}> = ({ apiKey, configurationStep, onEditConfigurationClick }) => { - const [isInitializingData, setIsInitializingData] = useState(true); - const [flareSearchUrl, setFlareSearchUrl] = useState(''); - const [tenantNames, setTenantNames] = useState(''); - - useEffect(() => { - if (configurationStep === ConfigurationStep.Completed) { - Promise.all([getFlareSearchDataUrl(), fetchTenantIds(), fetchUserTenants(apiKey)]).then( - ([url, tenantIds, userTenants]) => { - setFlareSearchUrl(url); - const tenantNameStrings = tenantIds - .map( - (tenantId) => - userTenants.find((tenant: Tenant) => tenant.id === tenantId)?.name - ) - .filter(Boolean) - .join(', '); - setTenantNames(tenantNameStrings || 'Unknown'); - setIsInitializingData(false); - } - ); - } else { - setIsInitializingData(true); - setFlareSearchUrl(''); - setTenantNames(''); - } - }, [configurationStep, apiKey]); - - if (configurationStep !== ConfigurationStep.Completed) { - return null; - } - - if (isInitializingData) { - return ; - } - - return ( -
-
- {`You can now access `} - {tenantNames} - {` Flare Data in Splunk.`} -
-
-
- - -
-
-
- ); -}; - -export default ConfigurationCompletedStep; diff --git a/packages/react-components/src/components/ConfigurationGlobalStep.css b/packages/react-components/src/components/ConfigurationGlobalStep.css deleted file mode 100644 index ea67c81b..00000000 --- a/packages/react-components/src/components/ConfigurationGlobalStep.css +++ /dev/null @@ -1,51 +0,0 @@ -.button-group { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - margin-top: 1.5rem; - gap: 1rem; -} - -.form-group { - display: flex; - flex-direction: column; - width: 100%; - align-items: flex-start; - margin-top: 2rem; - gap: 2rem; -} - -.form-item { - display: flex; - flex-direction: column; - align-items: flex-start; -} - -.progress-bar-container { - justify-content: center; - align-items: center; - height: auto; -} - -.progress-bar-svg { - transform-origin: 50% 50%; - animation: rotate 1s linear infinite; -} - -.progress-bar-circle { - stroke: var(--button-disabled-text-color); - stroke-dasharray: 100, 150; - stroke-dashoffset: 0; - fill: none; - stroke-linecap: round; - stroke-width: 8; - transform-origin: 50% 50%; -} - -/* Animations */ -@keyframes rotate { - 100% { - transform: rotate(360deg); - } -} diff --git a/packages/react-components/src/components/ConfigurationInitialStep.css b/packages/react-components/src/components/ConfigurationInitialStep.css deleted file mode 100644 index 425c7ad0..00000000 --- a/packages/react-components/src/components/ConfigurationInitialStep.css +++ /dev/null @@ -1,23 +0,0 @@ -.error-container { - display: flex; - flex-direction: row; - margin-top: 0.5rem; - gap: 0.5rem; - align-items: center; - color: var(--error); -} - -.error-container[hidden] { - display: none; -} - -.border-error { - border: 1px solid var(--error); -} - -.label-tooltip { - display: flex; - flex-direction: row; - gap: 0.5rem; - align-items: center; -} diff --git a/packages/react-components/src/components/ConfigurationInitialStep.tsx b/packages/react-components/src/components/ConfigurationInitialStep.tsx deleted file mode 100644 index b882b053..00000000 --- a/packages/react-components/src/components/ConfigurationInitialStep.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React, { FC, useEffect, useState } from 'react'; -import Button from './Button'; -import Label from './Label'; -import Tooltip from './Tooltip'; -import ErrorIcon from './icons/ErrorIcon'; - -import { ConfigurationStep } from '../models/flare'; -import { fetchApiKeyValidation } from '../utils/setupConfiguration'; -import './ConfigurationGlobalStep.css'; -import './ConfigurationInitialStep.css'; -import { ToastKeys, toastManager } from './ToastManager'; -import FlareLogoLoading from './FlareLogoLoading'; - -const ConfigurationInitialStep: FC<{ - configurationStep: ConfigurationStep; - apiKey?: string; - setApiKey: (apiKey: string) => void; - onCancelConfigurationClick: () => void; - onApiKeyValidated: () => void; -}> = ({ - configurationStep, - apiKey = undefined, - setApiKey, - onCancelConfigurationClick, - onApiKeyValidated, -}) => { - const [isInitializingData, setIsInitializingData] = useState(true); - const [errorMessage, setErrorMessage] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const handleApiKeyChange = (e): void => setApiKey(e.target.value); - - const handleSubmitApiKey = (): void => { - if (apiKey !== undefined) { - setIsLoading(true); - fetchApiKeyValidation(apiKey) - .then(() => { - setErrorMessage(''); - setIsLoading(false); - onApiKeyValidated(); - }) - .catch((error: any) => { - setErrorMessage(error.data); - setIsLoading(false); - toastManager.show({ - id: ToastKeys.ERROR, - isError: true, - content: 'Something went wrong. Please review your form.', - }); - }); - } - }; - - const isFormValid = (): boolean => { - return apiKey !== undefined && apiKey.length > 0; - }; - - useEffect(() => { - if (configurationStep === ConfigurationStep.Initial) { - if (apiKey !== undefined) { - setIsInitializingData(false); - } - } else { - setIsLoading(false); - setIsInitializingData(true); - } - }, [configurationStep, apiKey]); - - if (configurationStep !== ConfigurationStep.Initial) { - return null; - } - - if (isInitializingData) { - return ; - } - - return ( -
-
Enter your API key
-
-
-
- - -
- {'You can find your API Keys in your '} - - Flare Profile - -
-
-
- 0 ? 'border-error' : ''}`} - placeholder="Your API Key" - /> - -
-
- - -
-
-
- ); -}; - -export default ConfigurationInitialStep; diff --git a/packages/react-components/src/components/ConfigurationUserPreferencesStep.css b/packages/react-components/src/components/ConfigurationUserPreferencesStep.css deleted file mode 100644 index 67d1cf9e..00000000 --- a/packages/react-components/src/components/ConfigurationUserPreferencesStep.css +++ /dev/null @@ -1,9 +0,0 @@ -.note { - font-size: 0.75rem; - color: var(--secondary-text-color); - margin-top: 5px; -} - -.switch-container { - margin-top: 0.5rem; -} diff --git a/packages/react-components/src/components/ConfigurationUserPreferencesStep.tsx b/packages/react-components/src/components/ConfigurationUserPreferencesStep.tsx deleted file mode 100644 index aa64082c..00000000 --- a/packages/react-components/src/components/ConfigurationUserPreferencesStep.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import React, { FC, useEffect, useState } from 'react'; -import { - ConfigurationStep, - Severity, - SourceType, - SourceTypeCategory, - Tenant, -} from '../models/flare'; -import Button from './Button'; -import Label from './Label'; -import Select from './Select'; - -import { APP_NAME } from '../models/constants'; -import { - convertSeverityFilterToArray, - fetchAvailableIndexNames, - fetchCurrentIndexName, - fetchSeverityFilters, - fetchSourceTypeFilters, - fetchIngestFullEventData, - fetchSeveritiesFilter, - fetchUserTenants, - fetchSourceTypesFilter, - getSeverityFilterValue, - getSourceTypesFilterValue, - saveConfiguration, - convertSourceTypeFilterToArray, - fetchTenantIds, - fetchNumberOfDaysToBackfill, -} from '../utils/setupConfiguration'; -import './ConfigurationGlobalStep.css'; -import './ConfigurationUserPreferencesStep.css'; -import SeverityOptions from './SeverityOptions'; -import SourceTypeCategoryOptions from './SourceTypeCategoryOptions'; -import Switch from './Switch'; -import { ToastKeys, toastManager } from './ToastManager'; -import Tooltip from './Tooltip'; -import FlareLogoLoading from './FlareLogoLoading'; -import TenantSelection from './TenantSelection'; -import Input from './Input'; - -const ConfigurationUserPreferencesStep: FC<{ - configurationStep: ConfigurationStep; - apiKey: string; - onNavigateBackClick: () => void; - onUserPreferencesSaved: () => void; -}> = ({ configurationStep, apiKey, onNavigateBackClick, onUserPreferencesSaved }) => { - const [isInitializingData, setIsInitializingData] = useState(true); - const [selectedTenantIds, setSelectedTenantIds] = useState>(new Set()); - const [tenants, setUserTenants] = useState([]); - const [selectedSeverities, setSelectedSeverities] = useState([]); - const [severities, setSeverities] = useState([]); - const [sourceTypeCategories, setSourceTypeCategories] = useState([]); - const [selectedSourceTypes, setSelectedSourceTypes] = useState([]); - const [indexName, setIndexName] = useState(''); - const [indexNames, setIndexNames] = useState([]); - const [isIngestingFullEventData, setIsIngestingFullEventData] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [numberOfDaysToBackfill, setNumberOfDaysToBackfill] = useState(); - const [isFirstSetup, setIsFirstSetup] = useState(false); - - const handleIndexNameChange = (e): void => setIndexName(e.target.value); - const handleIsIngestingFullEventDataChange = (e): void => - setIsIngestingFullEventData(e.target.checked); - - const handleSubmitUserPreferences = (): void => { - setIsLoading(true); - - saveConfiguration( - apiKey, - Array.from(selectedTenantIds), - indexName, - isIngestingFullEventData, - getSeverityFilterValue(selectedSeverities, severities), - getSourceTypesFilterValue(selectedSourceTypes, sourceTypeCategories), - numberOfDaysToBackfill - ) - .then(() => { - setIsLoading(false); - toastManager.destroy(ToastKeys.ERROR); - toastManager.show({ - id: ToastKeys.SUCCESS, - content: 'Configured Flare Account', - }); - onUserPreferencesSaved(); - }) - .catch((e: any) => { - setIsLoading(false); - toastManager.show({ - id: ToastKeys.ERROR, - isError: true, - content: `Something went wrong. ${e.responseText}`, - }); - }); - }; - - useEffect(() => { - if (configurationStep === ConfigurationStep.UserPreferences) { - Promise.all([ - fetchTenantIds(), - fetchIngestFullEventData(), - fetchCurrentIndexName(), - fetchUserTenants(apiKey), - fetchAvailableIndexNames(), - fetchSeverityFilters(apiKey), - fetchSeveritiesFilter(), - fetchSourceTypeFilters(apiKey), - fetchSourceTypesFilter(), - fetchNumberOfDaysToBackfill(), - ]) - .then( - ([ - tenantIds, - ingestFullEventData, - index, - userTenants, - availableIndexNames, - allSeverities, - severitiesFilter, - allSourceTypeCategories, - sourceTypeFilter, - numberOfDaysToBackfillSaved, - ]) => { - // The form can't be submitted without any tenant ids - // so the absence of tenant ids indicates that it is the first setup. - if (!tenantIds.length) { - setIsFirstSetup(true); - } - setNumberOfDaysToBackfill(numberOfDaysToBackfillSaved ?? ''); - setSelectedTenantIds(new Set(tenantIds)); - setIsIngestingFullEventData(ingestFullEventData); - setIndexName(index); - setUserTenants(userTenants); - setIndexNames(availableIndexNames); - setSeverities(allSeverities); - setSelectedSeverities( - convertSeverityFilterToArray(severitiesFilter, allSeverities) - ); - setSourceTypeCategories(allSourceTypeCategories); - setSelectedSourceTypes( - convertSourceTypeFilterToArray( - sourceTypeFilter, - allSourceTypeCategories - ) - ); - setIsInitializingData(false); - } - ) - .catch(() => { - toastManager.show({ - id: ToastKeys.ERROR, - isError: true, - content: 'Something went wrong.', - }); - }); - } else { - setSelectedTenantIds(new Set([])); - setIndexName(APP_NAME); - setIndexNames([]); - setUserTenants([]); - setIsLoading(false); - setSeverities([]); - setSelectedSeverities([]); - setSourceTypeCategories([]); - setSelectedSourceTypes([]); - setIsInitializingData(true); - } - }, [configurationStep, apiKey]); - - const isFormValid = (): boolean => { - return ( - selectedTenantIds.size > 0 && - selectedSeverities.length > 0 && - selectedSourceTypes.length > 0 - ); - }; - - if (configurationStep !== ConfigurationStep.UserPreferences) { - return null; - } - - if (isInitializingData) { - return ; - } - - return ( -
-
Please select the Tenant you want to ingest events from
-
-
- - { - if (isChecked) { - selectedTenantIds.add(tenant.id); - } else { - selectedTenantIds.delete(tenant.id); - } - setSelectedTenantIds(new Set([...selectedTenantIds])); - }} - /> -
-
- - -
-
-
- - -
- Select the minimal alert severity to ignore less critical events - associated with this identifier. -
-
- {'To learn more about severities see '} - - Understand Severity Scoring. - -
-
-
- -
-
-
- - -
- {'For more details on Identifier Categories, please visit our '} - - Documentation. - -
-
-
- -
-
-
- - -
- Select this option if you want to ingest the full data of the events - instead of the metadata of them. -
-
-
- - - -
-
-
- - -
- This field can only be set when setting up the app for the first - time. -
-
-
- setNumberOfDaysToBackfill(e.target.value)} - value={numberOfDaysToBackfill} - min="0" - type="number" - placeholder="30" - disabled={!isFirstSetup} - /> -
-
- - -
-
-
- ); -}; - -export default ConfigurationUserPreferencesStep; diff --git a/packages/react-components/src/components/FlareLogoLoading.css b/packages/react-components/src/components/FlareLogoLoading.css deleted file mode 100644 index 55b5695e..00000000 --- a/packages/react-components/src/components/FlareLogoLoading.css +++ /dev/null @@ -1,21 +0,0 @@ -.flare-logo-loading-container { - position: relative; - top: 100%; - left: 50%; - margin-left: -2.5rem; -} - -.flare-logo-loading-svg { - transform-origin: 50% 50%; - animation-name: rotate-flare-logo; - animation-iteration-count: infinite; - animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1); - animation-duration: 0.8s; -} - -/* Animations */ -@keyframes rotate-flare-logo { - 100% { - transform: rotate(90deg); - } -} diff --git a/packages/react-components/src/components/FlareLogoLoading.tsx b/packages/react-components/src/components/FlareLogoLoading.tsx deleted file mode 100644 index 42806258..00000000 --- a/packages/react-components/src/components/FlareLogoLoading.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React, { FC } from 'react'; - -import './FlareLogoLoading.css'; -import FlareLogo from './icons/FlareLogo'; - -const FlareLogoLoading: FC<{ hidden?: boolean }> = ({ hidden }) => { - return ( - - ); -}; - -export default FlareLogoLoading; diff --git a/packages/react-components/src/components/Input.css b/packages/react-components/src/components/Input.css deleted file mode 100644 index 597fe0bb..00000000 --- a/packages/react-components/src/components/Input.css +++ /dev/null @@ -1,5 +0,0 @@ -input { - color: var(--text-color); - border: 0px; - outline: none; -} diff --git a/packages/react-components/src/components/Input.tsx b/packages/react-components/src/components/Input.tsx deleted file mode 100644 index a3be4289..00000000 --- a/packages/react-components/src/components/Input.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React, { FC } from 'react'; - -import './Input.css'; - -const Input: FC> = ({ ...props }) => { - return ; -}; - -export default Input; diff --git a/packages/react-components/src/components/Label.css b/packages/react-components/src/components/Label.css deleted file mode 100644 index 9460f481..00000000 --- a/packages/react-components/src/components/Label.css +++ /dev/null @@ -1,10 +0,0 @@ -.label { - color: var(--header-text-color); - display: flex; - flex-direction: row; - gap: 0.125rem; -} - -.error-message { - color: var(--error); -} diff --git a/packages/react-components/src/components/Label.tsx b/packages/react-components/src/components/Label.tsx deleted file mode 100644 index 5f58e115..00000000 --- a/packages/react-components/src/components/Label.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React, { FC } from 'react'; - -import './Label.css'; - -const Label: FC<{ isRequired?: boolean }> = ({ isRequired = false, children }) => { - return ( -
- {children} - -
- ); -}; - -export default Label; diff --git a/packages/react-components/src/components/LoadingBar.css b/packages/react-components/src/components/LoadingBar.css deleted file mode 100644 index 55183b27..00000000 --- a/packages/react-components/src/components/LoadingBar.css +++ /dev/null @@ -1,9 +0,0 @@ -.loading-bar { - background-color: var(--loading-bar-bg-color); - height: 1rem; -} - -.loading-bar .value-loading-bar { - background-color: var(--button-bg-color); - height: 100%; -} diff --git a/packages/react-components/src/components/LoadingBar.tsx b/packages/react-components/src/components/LoadingBar.tsx deleted file mode 100644 index 53c8f54f..00000000 --- a/packages/react-components/src/components/LoadingBar.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React, { FC } from 'react'; - -import './LoadingBar.css'; - -const LoadingBar: FC<{ max: number; value: number }> = ({ max, value }) => { - return ( -
-
-
- ); -}; - -export default LoadingBar; diff --git a/packages/react-components/src/components/ProgressBar.css b/packages/react-components/src/components/ProgressBar.css deleted file mode 100644 index d0a3b1c6..00000000 --- a/packages/react-components/src/components/ProgressBar.css +++ /dev/null @@ -1,27 +0,0 @@ -.progress-bar-container { - justify-content: center; - align-items: center; - height: auto; -} - -.progress-bar-svg { - transform-origin: 50% 50%; - animation: rotate 1s linear infinite; -} - -.progress-bar-circle { - stroke: var(--button-disabled-text-color); - stroke-dasharray: 100, 150; - stroke-dashoffset: 0; - fill: none; - stroke-linecap: round; - stroke-width: 8; - transform-origin: 50% 50%; -} - -/* Animations */ -@keyframes rotate { - 100% { - transform: rotate(360deg); - } -} diff --git a/packages/react-components/src/components/ProgressBar.tsx b/packages/react-components/src/components/ProgressBar.tsx deleted file mode 100644 index f2f67e52..00000000 --- a/packages/react-components/src/components/ProgressBar.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React, { FC } from 'react'; - -import './ProgressBar.css'; - -const ProgressBar: FC<{ hidden?: boolean }> = ({ hidden }) => { - return ( - - ); -}; - -export default ProgressBar; diff --git a/packages/react-components/src/components/Select.css b/packages/react-components/src/components/Select.css deleted file mode 100644 index d8de1473..00000000 --- a/packages/react-components/src/components/Select.css +++ /dev/null @@ -1,16 +0,0 @@ -select { - color: var(--text-color); - border: 0px; - outline: none; -} - -.select-container { - border: 1px solid var(--secondary-text-color); - border-radius: 40px; - padding-right: 1rem; - margin-top: 0.5rem; -} - -.select-container:hover { - border: 1px solid var(--button-bg-color); -} diff --git a/packages/react-components/src/components/Select.tsx b/packages/react-components/src/components/Select.tsx deleted file mode 100644 index c8b03297..00000000 --- a/packages/react-components/src/components/Select.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React, { ChangeEvent, FC } from 'react'; - -import './Select.css'; - -const Select: FC<{ id?: string; value?: any; onChange: (e: ChangeEvent) => void }> = ({ - id, - value, - onChange, - children, -}) => { - return ( -
- -
- ); -}; - -export default Select; diff --git a/packages/react-components/src/components/SeverityOption.css b/packages/react-components/src/components/SeverityOption.css deleted file mode 100644 index 29972e73..00000000 --- a/packages/react-components/src/components/SeverityOption.css +++ /dev/null @@ -1,28 +0,0 @@ -.toggle { - position: relative; - width: 1rem; - height: 1rem; - display: inline-block; - z-index: 2; -} - -.toggle input { - opacity: 0; - width: 0; - height: 0; -} - -.dot { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - height: 1rem; - width: 1rem; - border-radius: 50%; - border-width: 1px; - border-style: solid; - display: inline-block; -} diff --git a/packages/react-components/src/components/SeverityOption.tsx b/packages/react-components/src/components/SeverityOption.tsx deleted file mode 100644 index 55560dbc..00000000 --- a/packages/react-components/src/components/SeverityOption.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import React, { FC, useState } from 'react'; - -import { Severity } from '../models/flare'; -import './SeverityOption.css'; -import './Tooltip.css'; - -const SeverityOption: FC<{ - isChecked?: boolean; - severity: Severity; - onCheckChange: (isChecked: boolean) => void; -}> = ({ isChecked = false, severity, onCheckChange }) => { - const [isShowingTooltip, setShowingTooltip] = useState(false); - - return ( -
- - -
- ); -}; - -export default SeverityOption; diff --git a/packages/react-components/src/components/SeverityOptions.css b/packages/react-components/src/components/SeverityOptions.css deleted file mode 100644 index bf6cb350..00000000 --- a/packages/react-components/src/components/SeverityOptions.css +++ /dev/null @@ -1,6 +0,0 @@ -#severities-container { - display: flex; - flex-direction: row; - margin-top: 0.5rem; - gap: 0.75rem; -} diff --git a/packages/react-components/src/components/SeverityOptions.tsx b/packages/react-components/src/components/SeverityOptions.tsx deleted file mode 100644 index 05b36daa..00000000 --- a/packages/react-components/src/components/SeverityOptions.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { FC } from 'react'; - -import { Severity } from '../models/flare'; -import SeverityOption from './SeverityOption'; -import './SeverityOptions.css'; - -const SeverityOptions: FC<{ - severities: Severity[]; - selectedSeverities: Severity[]; - setSelectedSeverities: (selectedSeverities: Severity[]) => void; -}> = ({ severities, selectedSeverities, setSelectedSeverities }) => { - const isSeverityChecked = (severity: Severity): boolean => { - return ( - selectedSeverities.findIndex( - (selectedSeverity) => selectedSeverity.value === severity.value - ) >= 0 - ); - }; - - const handleOnSeverityChange = (severity: Severity, isChecked: boolean): void => { - if (isChecked) { - setSelectedSeverities([...selectedSeverities, severity]); - } else { - setSelectedSeverities( - selectedSeverities.filter( - (selectedSeverity) => selectedSeverity.value !== severity.value - ) - ); - } - }; - - return ( -
- {severities.map((severity) => { - return ( - - handleOnSeverityChange(severity, isChecked) - } - /> - ); - })} -
- ); -}; - -export default SeverityOptions; diff --git a/packages/react-components/src/components/SourceTypeCategoryOption.css b/packages/react-components/src/components/SourceTypeCategoryOption.css deleted file mode 100644 index d9ab5aa2..00000000 --- a/packages/react-components/src/components/SourceTypeCategoryOption.css +++ /dev/null @@ -1,65 +0,0 @@ -.source-types-category-container { - display: flex; - flex-direction: column; -} - -.source-types-children-container { - margin-left: 2rem; - margin-top: 0.25rem; - display: flex; - flex-direction: column; - align-items: start; - gap: 0.25rem; -} - -.source-types-children-container[hidden] { - display: none; -} - -.source-types-category-header { - width: 14rem; - display: flex; - flex-direction: row; -} - -.source-types-category-filler { - flex: 1; -} - -.source-types-category-count-container { - display: flex; - flex-direction: row; - cursor: pointer; -} - -.source-types-category-count { - width: 1rem; - user-select: none; - color: var(--secondary-text-color); - text-align: center; -} - -.source-types-category { - width: 1rem; - height: 1rem; - margin-left: 0.5rem; - align-self: center; -} - -.source-types-category > span { - display: inline-table; - width: 0.5rem; - height: 0.5rem; - border: solid var(--text-color); - border-width: 0 0 0.125rem 0.125rem; -} - -.source-types-category-expand > span { - transform: rotate(-45deg); - margin-bottom: 0.5rem; -} - -.source-types-category-collapse > span { - transform: rotate(135deg); - margin-top: 0.25rem; -} diff --git a/packages/react-components/src/components/SourceTypeCategoryOption.tsx b/packages/react-components/src/components/SourceTypeCategoryOption.tsx deleted file mode 100644 index f055866e..00000000 --- a/packages/react-components/src/components/SourceTypeCategoryOption.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, { FC, useState } from 'react'; - -import { SourceType, SourceTypeCategory } from '../models/flare'; -import './SourceTypeCategoryOption.css'; -import SourceTypeOption from './SourceTypeOption'; - -const SourceTypeCategoryOption: FC<{ - isChecked?: boolean; - sourceTypeCategory: SourceTypeCategory; - isSourceTypeChecked: (sourceType: SourceType) => boolean; - onCategoryCheckChange: (isChecked: boolean) => void; - onSourceTypeCheckChange: (sourceType: SourceType, isChecked: boolean) => void; -}> = ({ - isChecked = false, - sourceTypeCategory, - isSourceTypeChecked, - onCategoryCheckChange, - onSourceTypeCheckChange, -}) => { - const [isExpanded, setExpanded] = useState(true); - - const getSelectedCategoryCount = (): number => { - return sourceTypeCategory.types.filter((sourceType) => isSourceTypeChecked(sourceType)) - .length; - }; - - const selectedCategoryCount = getSelectedCategoryCount(); - - return ( -
-
- 0 && !isChecked} - onCheckChange={(checked): void => onCategoryCheckChange(checked)} - /> - - - -
- ); -}; - -export default SourceTypeCategoryOption; diff --git a/packages/react-components/src/components/SourceTypeCategoryOptions.css b/packages/react-components/src/components/SourceTypeCategoryOptions.css deleted file mode 100644 index 3a2f3d74..00000000 --- a/packages/react-components/src/components/SourceTypeCategoryOptions.css +++ /dev/null @@ -1,8 +0,0 @@ -#source-types-categories-container { - display: flex; - flex-direction: column; - margin-top: 0.5rem; - gap: 1rem; - align-items: start; - text-align: start; -} diff --git a/packages/react-components/src/components/SourceTypeCategoryOptions.tsx b/packages/react-components/src/components/SourceTypeCategoryOptions.tsx deleted file mode 100644 index d9ece35a..00000000 --- a/packages/react-components/src/components/SourceTypeCategoryOptions.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import React, { FC } from 'react'; - -import { SourceType, SourceTypeCategory } from '../models/flare'; -import SourceTypeCategoryOption from './SourceTypeCategoryOption'; -import './SourceTypeCategoryOptions.css'; - -const SourceTypeCategoryOptions: FC<{ - sourceTypeCategories: SourceTypeCategory[]; - selectedSourceTypes: SourceType[]; - setSelectedSourceTypes: (selectedSourceTypes: SourceType[]) => void; -}> = ({ sourceTypeCategories, selectedSourceTypes, setSelectedSourceTypes }) => { - const isSourceTypeChecked = (sourceType: SourceType): boolean => { - return ( - selectedSourceTypes.findIndex( - (selectedSourceType) => selectedSourceType.value === sourceType.value - ) >= 0 - ); - }; - - const isSourceTypeCategoryChecked = (sourceTypeCategory: SourceTypeCategory): boolean => { - return ( - sourceTypeCategory.types.filter((sourceType) => { - return isSourceTypeChecked(sourceType); - }).length === sourceTypeCategory.types.length - ); - }; - - const handleOnSourceTypeChange = (sourceType: SourceType, isChecked: boolean): void => { - if (isChecked) { - const newSourceTypes = new Array(...selectedSourceTypes); - newSourceTypes.push(sourceType); - setSelectedSourceTypes(newSourceTypes); - } else { - const newSourceTypes = selectedSourceTypes.filter( - (selectedSourceType) => selectedSourceType.value !== sourceType.value - ); - setSelectedSourceTypes(newSourceTypes); - } - }; - - const handleOnSourceTypeCategoryChange = ( - sourceTypeCategory: SourceTypeCategory, - isChecked: boolean - ): void => { - if (isChecked) { - const newSourceTypes = new Set(selectedSourceTypes); - sourceTypeCategory.types.forEach((sourceType) => newSourceTypes.add(sourceType)); - setSelectedSourceTypes(new Array(...newSourceTypes)); - } else { - const newSourceTypes = selectedSourceTypes.filter( - (selectedSourceType) => sourceTypeCategory.types.indexOf(selectedSourceType) === -1 - ); - setSelectedSourceTypes(newSourceTypes); - } - }; - - return ( -
- {sourceTypeCategories.map((sourceTypeCategory) => { - return ( - - handleOnSourceTypeCategoryChange(sourceTypeCategory, checked) - } - onSourceTypeCheckChange={(sourceType, checked): void => - handleOnSourceTypeChange(sourceType, checked) - } - /> - ); - })} -
- ); -}; - -export default SourceTypeCategoryOptions; diff --git a/packages/react-components/src/components/SourceTypeOption.css b/packages/react-components/src/components/SourceTypeOption.css deleted file mode 100644 index d6969ce0..00000000 --- a/packages/react-components/src/components/SourceTypeOption.css +++ /dev/null @@ -1,82 +0,0 @@ -.source-type-container { - user-select: none; - display: flex; - flex-direction: row; - gap: 0.5rem; - cursor: pointer; - align-items: center; -} - -.source-type-option-input { - width: 0; - height: 0; - opacity: 0; -} - -.source-type-checkbox { - width: 1rem; - height: 1rem; - background-color: var(--switch-disabled-bg-color); - border: 1px solid var(--button-bg-color); - border-radius: 0.125rem; - display: flex; - justify-content: center; - align-items: center; - position: relative; -} - -.source-type-container:hover .source-type-checkbox { - background-color: var(--switch-disabled-hover-bg-color); -} - -.source-type-checkbox-partial { - width: 1rem; - height: 1rem; - background-color: var(--button-bg-color); - border: 1px solid var(--button-bg-color); - border-radius: 0.125rem; - display: flex; - justify-content: center; - align-items: center; - position: relative; -} - -.source-type-container:hover .source-type-checkbox-partial { - background-color: var(--button-bg-hover-color); -} - -.source-type-checkbox-partial::after { - content: ''; - width: 0.5rem; - height: 0; - border: solid white; - border-width: 0 0 0.125rem 0.125rem; - position: absolute; -} - -input:checked + .source-type-checkbox { - width: 1rem; - height: 1rem; - background-color: var(--button-bg-color); - border: 1px solid var(--button-bg-color); - border-radius: 0.125rem; - display: flex; - justify-content: center; - align-items: center; - position: relative; -} - -.source-type-container:hover:has(> input:checked) .source-type-checkbox { - background-color: var(--button-bg-hover-color); -} - -input:checked + .source-type-checkbox::after { - content: ''; - width: 0.5rem; - height: 0.25rem; - border: solid white; - border-width: 0 0 0.125rem 0.125rem; - transform: rotate(-45deg); - position: absolute; - margin-bottom: 0.125rem; -} diff --git a/packages/react-components/src/components/SourceTypeOption.tsx b/packages/react-components/src/components/SourceTypeOption.tsx deleted file mode 100644 index 39ea2a40..00000000 --- a/packages/react-components/src/components/SourceTypeOption.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { FC } from 'react'; - -import { SourceType } from '../models/flare'; -import './SourceTypeOption.css'; - -const SourceTypeOption: FC<{ - isChecked?: boolean; - isPartiallyChecked?: boolean; - sourceType: SourceType; - onCheckChange: (isChecked: boolean) => void; -}> = ({ isChecked = false, isPartiallyChecked = false, sourceType, onCheckChange }) => { - return ( -