diff --git a/.github/workflows/create-release-n-publish.yml b/.github/workflows/create-release-n-publish.yml index 6efcfdb1..d1484032 100644 --- a/.github/workflows/create-release-n-publish.yml +++ b/.github/workflows/create-release-n-publish.yml @@ -73,6 +73,16 @@ jobs: -H "Authorization: Bearer ${ACCESS_TOKEN}" \ https://api.github.com/repos/${REPOSITORY}/releases + - name: Set up Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Build the web UI + run: | + make ui-install + make ui-build + - name: Install pypa/build run: >- python -m diff --git a/.github/workflows/run-tests-simple.yml b/.github/workflows/run-tests-simple.yml index cd3109a4..b27ed4bd 100644 --- a/.github/workflows/run-tests-simple.yml +++ b/.github/workflows/run-tests-simple.yml @@ -59,7 +59,7 @@ jobs: rm -f "${FLOWCEPT_SETTINGS_PATH:-$HOME/.flowcept/settings.yaml}" flowcept --init-settings --full --dask --mlflow -y flowcept --config-profile full-online -y - pytest --nbmake "notebooks/" --nbmake-timeout=600 --ignore="notebooks/dask_from_CLI.ipynb" --ignore="notebooks/analytics.ipynb" --ignore=notebooks/tensorboard.ipynb + pytest --nbmake "notebooks/" --nbmake-timeout=600 --ignore="notebooks/dask_from_CLI.ipynb" --ignore=notebooks/tensorboard.ipynb - name: Shut down docker compose run: make services-stop diff --git a/.github/workflows/ui-checks.yml b/.github/workflows/ui-checks.yml new file mode 100644 index 00000000..fb15e517 --- /dev/null +++ b/.github/workflows/ui-checks.yml @@ -0,0 +1,33 @@ +name: Web UI checks + +on: + push: + +jobs: + ui-checks: + runs-on: ubuntu-22.04 + if: "!contains(github.event.head_commit.message, 'CI Bot')" + steps: + - uses: actions/checkout@v4 + + - name: Set up Node 22 + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: ui/package-lock.json + + - name: Install UI dependencies + run: make ui-install + + - name: Run UI unit tests + run: make ui-test + + - name: Install Playwright browsers + run: cd ui && npx playwright install --with-deps chromium + + - name: Run UI end-to-end tests + run: make ui-e2e + + - name: Build and typecheck the UI + run: make ui-build diff --git a/.gitignore b/.gitignore index dd069593..397a1e08 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,9 @@ .claude/settings.local.json **/*mlruns* **/*.env* -**/*build* +build/ +docs/_build/ +ui/tsconfig.tsbuildinfo **/*egg* **/*pycache* #**/*dist* @@ -36,8 +38,8 @@ flowcept_code_assistants_memory.md uv.lock examples/flowcept_messages.jsonl agent_sandbox/ -*PROVENANCE_*md -*PROVENANCE_*pdf +*WORKFLOW_CARD_*md +*PROVENANCE_REPORT_*pdf CLAUDE.md GEMINI.md SKILL.md @@ -47,3 +49,14 @@ SKILLS.md .cursor/rules/ .claude/ input_data/ + +# Web UI (Node toolchain + built assets) +node_modules/ +ui/node_modules/ +ui/dist/ +ui/.vite/ +src/flowcept/webservice/ui_build/ +ui/src/routeTree.gen.ts +ui/docs/ +ui/test-results/ +ui/playwright-report/ diff --git a/AGENTS.md b/AGENTS.md index b066d94c..b96eb9c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ # Flowcept Code Assistant Instructions This file is the single source of truth for code-assistant behavior in this repository. +Each major module and the UI also has its own `README.md` (under `src/flowcept/*/`, `ui/`, `tests/`, `deployment/`, `examples/`) with deeper subsystem context; read the relevant one before working in that area. Do not duplicate these rules in `CLAUDE.md`, `.cursor/rules`, `GEMINI.md`, `SKILL.md`, or other agent files. If a tool requires its own file, make that file (which should immediately go to .gitignore) a thin pointer to this one. @@ -16,7 +17,7 @@ If a tool requires its own file, make that file (which should immediately go to - Do not commit personal absolute paths. - Do not commit secrets or keys. - Do not `pip install`; report missing packages and the command the user can run. Consider adding them to pyproject.toml. -- Do not auto-commit. Test first, then ask the user to confirm before discussing a commit. +- Do not auto-commit. Do not stage files automatically. The AI code assistant must test the fix/implemented feature and fix any error that appears before asking the user to test themselves. Do not ask to commit. ## 2. Interaction Rules @@ -29,13 +30,8 @@ If a tool requires its own file, make that file (which should immediately go to ## 3. Editing Rules - Read relevant code and tests before editing. -- Before editing Python files, explain the intended change and keep it narrow. -- After editing Python files, run: - -```bash -conda run -n flowcept make reformat -``` - +- Keep Python changes narrow, small, surgical, easy to review by a human. +- Before git commiting python files, run `make format` - Use existing tests when possible. Add a new test file only when no existing file is a good home. - Public functions/classes under `src/` need concise docstrings. - Use `FlowceptLogger` for Flowcept warnings/errors, not `print`. @@ -58,13 +54,24 @@ git add path/to/file1 path/to/file2 ## 5. Paths And Scratch Work -- `agent_sandbox/` is the assistant scratch area. +- `agent_sandbox/` is the assistant scratch area (gitignored — never committed). - Put plans and handoffs under `agent_sandbox/plans/`. - Do not create scratch scripts in source, tests, docs, or `/tmp` when `agent_sandbox/` is appropriate. - Do not run full test suites against `agent_sandbox/`, `tmp_tests/`, generated workflow cards, caches, or local data artifacts. - Deployment templates under `deployment/*.yaml` must use `${DATA_DIR}` placeholders for data paths. - Personal absolute paths belong only in untracked local settings files. +### Current-task memory file + +**`agent_sandbox/current_task.md`** is the single living document for the feature or issue actively being developed. + +- **Read it at the start of every session** before doing any work, and re-read it whenever the task context seems unclear. +- **Update it continuously**: add TODOs as they are discovered, check them off when done, record open decisions, blockers, and feature-specific mandates that do not belong in the permanent `AGENTS.md`. +- **Archive it when the feature ships**: rename to `agent_sandbox/archive/.md` and create a fresh `current_task.md` for the next task. +- Keep it short — bullets, not prose. If it grows past ~100 lines, trim resolved items. + +This file exists because context windows reset and session summaries lose nuance. It is the agent's external working memory for the current task. + ## 6. Source Map - `src/flowcept/cli.py`: CLI commands and settings/profile entry points. @@ -110,7 +117,9 @@ When a user or code assistant needs to learn or use a Flowcept feature, read the ## 8. Flowcept Usage Rules +- Copy the sample settings file `resources/sample_settings.yaml` to `agent_sandbox/settings.yaml` and set the `FLOWCEPT_SETTINGS_PATH` environment variable to point to it during local runs and tests. Do not modify the user's settings in the home directory (`~/.flowcept/settings.yaml`). - Use `FLOWCEPT_SETTINGS_PATH` to isolate settings for tests or experiments. + - `flowcept --init-settings` creates settings. - `flowcept --init-settings --full -y` creates the full template. - `flowcept --config-profile -y` changes runtime mode. @@ -142,17 +151,36 @@ flowcept --init-settings --full --dask --mlflow -y ## 10. Testing And Services +**TDD is mandatory for both Python and UI/frontend.** Write the test first, watch it fail, then implement until it passes. + +- **Python**: write a real integration test in `tests/` before the implementation. Guard service-dependent tests with `Flowcept.services_alive()` / `MONGO_ENABLED` skips. No mocks. +- **UI/Frontend**: write a vitest test in `ui/tests/` before adding new pure logic (store mutations, utility functions, graph algorithms). Use real data fixtures — no mocks, no DOM for pure-function and store tests. Component render tests are discouraged (fragile, high mock cost); test logic at the function/store level instead. Run with `make ui-test`. + Use the `flowcept` conda environment. Common commands: ```bash +# Python: lint, format, docs conda run -n flowcept make checks conda run -n flowcept make reformat conda run -n flowcept make docs + +# Python: integration tests (require live Mongo + Redis) conda run -n flowcept make tests conda run -n flowcept make tests-offline conda run -n flowcept make tests-notebooks + +# UI: vitest unit tests (pure functions, stores, utilities — no browser) +make ui-test + +# UI: Playwright E2E tests — mocked (no live services needed) +make ui-e2e + +# UI: Playwright E2E live integration tests (require live Mongo + Redis + webservice + Vite dev server) +# FLOWCEPT_SETTINGS_PATH must point to a settings file with MongoDB enabled and telemetry_capture configured +# (same file used to start the webservice, e.g. agent_sandbox/settings.yaml) +FLOWCEPT_SETTINGS_PATH=agent_sandbox/settings.yaml E2E_LIVE=1 make ui-e2e ``` Service commands: @@ -172,6 +200,9 @@ Do not run tests from scratch/sandbox directories. Target `tests/` explicitly. - Prefer real tests over mocks. Use real services, real data, and real LLMs when feasible. - Avoid mock-heavy tests unless there is no practical alternative. +- When a test fails, the correct fix is almost always to fix the implementation code, not the test; the test itself is very rarely the culprit. Always resolve warnings at their source rather than silencing them. +- **Periodically recommend running the full integration test suites** (`make tests` and `E2E_LIVE=1 make ui-e2e`) — especially after merges, significant backend or UI changes, or when the user has been iterating quickly on a feature. Mocked tests alone are not sufficient to catch regressions against real services. + ## 11. CI And Dependency Drift @@ -233,3 +264,34 @@ If docs and code disagree, verify in this order: When you find stale documentation, fix the smallest maintained document instead of adding another note elsewhere. Periodically offer to read the relevant RST and Markdown files to check for stale or duplicated documentation, especially after code, CLI, config, CI, or public API changes. + +## 14. Web UI — Workflow / Campaign List Ordering + +**Rule: list endpoints must return newest-first so that `docs[:limit]` yields the most recent items.** + +The single source of truth is `src/flowcept/webservice/services/sorting.py` → `sort_docs_by_first_date_field`. It must sort **descending** (`reverse=True`) and use `float("-inf")` as the fallback key for docs without a date field (so undated docs sort last, not first). + +All list routers — `workflows.py`, `tasks.py`, `objects.py`, `agents.py`, `campaigns.py` — call this function before slicing. If you change the sort direction or add a new list endpoint, make sure it also sorts descending before the limit slice. + +The frontend `useVisibleWorkflows` hook re-sorts the received items by `utc_timestamp` descending as well. Both layers must agree on newest-first; if either is flipped, recent runs disappear from the UI. + +## 15. Default Dashboard Configs — Two Locations Must Stay in Sync + +The default dashboard chart configs live in two places that **must always match**: + +1. `ui/public/default_dashboard_configs.json` — source of truth; served by the Vite dev server; edited here when adding/changing default charts. +2. `src/flowcept/webservice/ui_build/default_dashboard_configs.json` — built copy; this is what `dashboard_store.py` seeds into Mongo on first run (`_SEED_FILE` constant). + +After editing `ui/public/default_dashboard_configs.json`, always copy it to the `ui_build` location: +``` +cp ui/public/default_dashboard_configs.json src/flowcept/webservice/ui_build/default_dashboard_configs.json +``` + +**Mongo is seeded once (when the `dashboards` collection is empty).** If the collection already has documents, changing the seed file has no effect. To push updates to a running instance, update the Mongo records directly: +```python +import json +from flowcept.webservice.services.dashboard_store import get_dashboard_store +store = get_dashboard_store() +for doc in json.load(open("src/flowcept/webservice/ui_build/default_dashboard_configs.json")): + store.save(doc) +``` diff --git a/Makefile b/Makefile index 29db40df..211f1cf9 100644 --- a/Makefile +++ b/Makefile @@ -20,15 +20,49 @@ help: @printf "\033[32mtests-notebooks\033[0m test the notebooks using pytest\n" @printf "\033[32mclean\033[0m remove cache directories and Sphinx build output\n" @printf "\033[32mdocs\033[0m build HTML documentation using Sphinx\n" - @printf "\033[32mwebservice\033[0m run the Flowcept webservice locally (FastAPI)\n" + @printf "\033[32mwebservice\033[0m start the Flowcept webservice (REST API + web UI)\n" + @printf "\033[32mui\033[0m kill old processes, start webservice + UI dev server\n" + @printf "\033[32mui-install\033[0m install web UI dependencies (npm ci)\n" + @printf "\033[32mui-dev\033[0m run the web UI dev server (proxies /api to :5000)\n" + @printf "\033[32mui-build\033[0m build the web UI into src/flowcept/webservice/ui_build\n" + @printf "\033[32mui-checks\033[0m typecheck the web UI\n" + @printf "\033[32mui-test\033[0m run web UI unit tests (vitest)\n" + @printf "\033[32mui-e2e\033[0m run web UI end-to-end tests (playwright)\n" @printf "\033[32mchecks\033[0m run ruff linter and formatter checks\n" @printf "\033[32mreformat\033[0m run ruff linter and formatter\n" + @printf "\033[32mcompile-rules\033[0m compile central rules files for all coding assistants\n" # Run linter and formatter checks using ruff checks: ruff check src ruff format --check src +.PHONY: compile-rules +compile-rules: + python scripts/compile_rules.py + +.PHONY: ui-install ui-dev ui-build ui-checks ui-test ui-e2e ui +ui-install: + npm ci --prefix ui --no-audit --no-fund + +ui-dev: + npm run dev --prefix ui + +ui-build: + npm run build --prefix ui + +ui-checks: + npm run lint --prefix ui + +ui-test: + npm test --prefix ui + +ui-e2e: + cd ui && npx playwright test + +ui: + FLOWCEPT_SETTINGS_PATH=$(or $(FLOWCEPT_SETTINGS_PATH),$(PWD)/agent_sandbox/settings.yaml) PYTHONPATH=src python -m flowcept.cli --start-ui + reformat: ruff check src --fix --unsafe-fixes ruff format src @@ -59,7 +93,7 @@ docs: .PHONY: webservice webservice: - PYTHONPATH=src python -m flowcept.cli --start-webservice --webservice-host 127.0.0.1 --webservice-port 8008 + FLOWCEPT_SETTINGS_PATH=$(or $(FLOWCEPT_SETTINGS_PATH),$(PWD)/agent_sandbox/settings.yaml) PYTHONPATH=src python -m flowcept.cli --start-webservice # Run services using Docker services: diff --git a/README.md b/README.md index 01745346..47e32683 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ Designed for scenarios involving critical data from multiple, federated workflow - Low overhead, suitable for HPC and highly distributed setups - Telemetry capture for CPU, GPU, memory, linked to dataflow - Pluggable MQ and storage backends (Redis, Kafka, MongoDB, LMDB) +- Web UI: provenance browser, dashboards, live updates, and an embedded LLM chat agent - [W3C PROV](https://www.w3.org/TR/prov-overview/) adherence Explore [Jupyter Notebooks](notebooks) and [Examples](examples) for usage. @@ -216,7 +217,6 @@ pip install flowcept[lmdb] # LMDB lightweight database pip install flowcept[mqtt] # MQTT support pip install flowcept[llm_agent] # MCP agent, LangChain, Streamlit integration: needed either for MCP capture or for the Flowcept Agent. pip install flowcept[llm_google] # Google GenAI + Flowcept agent support -pip install flowcept[analytics] # Extra analytics (seaborn, plotly, scipy) pip install flowcept[dev] # Developer dependencies (docs, tests, lint, etc.) ``` @@ -443,6 +443,27 @@ Flowcept.generate_report(workflow_id="", report_type="provenance_report", fo See [`docs/reporting.rst`](docs/reporting.rst) and [`src/flowcept/report/README.md`](src/flowcept/report/README.md) for the full reporting reference. +## Web UI + +Flowcept ships a built-in web interface for browsing and analyzing provenance data. Start it with: + +```bash +pip install flowcept[webservice] +flowcept --start-ui # starts the webservice + dev server; open http://localhost:8008 +``` + +Key features: +- **Provenance browser** — campaigns, workflows, tasks, and artifacts with drill-down views +- **Live updates** — SSE-based streaming so the task table updates while a workflow runs +- **Dashboards** — per-workflow and per-campaign chart dashboards (configurable, stored in MongoDB) +- **Dataflow graph** — W3C PROV-style graph of task inputs/outputs; click any node to inspect its provenance +- **LLM chat agent** — ask natural-language questions about your provenance data; charts render inline; queries are automatically scoped to the current workflow or campaign +- **Lineage highlighting** — ask the chat agent to highlight the full provenance lineage (ancestors + descendants) of any task directly in the Dataflow graph + +The chat agent queries the **persisted store** (MongoDB) and the **live stream** (via near-real-time DB flushes from the MQ). For sub-second in-flight queries, use the MCP agent instead. + +See [`docs/web_ui.rst`](docs/web_ui.rst) and [`ui/README.md`](ui/README.md) for the full reference. + # Summary: Observability, Instrumentation, MQs, DBs, and Querying | Category | Supported Options | @@ -453,7 +474,7 @@ See [`docs/reporting.rst`](docs/reporting.rst) and [`src/flowcept/report/README. | **Custom Task Creation** | `FlowceptTask(activity_id=, used=, generated=, ...)`

Use for fully customizable task instrumentation. Publishes directly to the MQ either via context management (`with FlowceptTask(...)`) or by calling `send()`. It needs to have a `Flowcept().start()` first (or within a `with Flowcept()` context). See [example](examples/consumers/ping_pong_example.py). | | **Message Queues (MQ)** | - **Disabled** (offline mode: provenance events stay in an in-memory buffer, not accessible to external processes)
- [Redis](https://redis.io) → default, lightweight, easy to run anywhere
- [Kafka](https://kafka.apache.org) → for distributed, production setups
- [Mofka](https://mofka.readthedocs.io) → optimized for HPC runs

_Setup example:_ [docker compose](https://github.com/ORNL/flowcept/blob/main/deployment/compose.yml) | | **Databases** | - **Disabled** → Flowcept runs in ephemeral mode (data only in MQ, no persistence)
- **[MongoDB](https://www.mongodb.com)** → default, rich queries and efficient bulk writes
- **[LMDB](https://lmdb.readthedocs.io)** → lightweight, file-based, no external service, basic query support | -| **Querying and Monitoring** | - **[Grafana](deployment/compose-grafana.yml)** → dashboarding via MongoDB connector
- **MCP Flowcept Agent** → LLM-based querying of provenance data | +| **Querying and Monitoring** | - **[Web UI](docs/web_ui.rst)** → browser-based provenance browser with dashboards, live updates, and an embedded LLM chat agent that queries the persisted store and highlights provenance lineage in the Dataflow graph
- **[Grafana](deployment/compose-grafana.yml)** → dashboarding via MongoDB connector
- **MCP Flowcept Agent** → LLM-based querying of the live MQ stream (Redis/Kafka/Mofka) via external assistants (Claude Code, Codex, etc.) or offline JSONL buffer | | **Custom Consumer** | You can implement your own consumer to monitor or query the provenance stream in real time. Useful for custom analytics, monitoring, debugging, or to persist the data in a different data model (e.g., graph) . See [example](examples/consumers/simple_consumer.py). | diff --git a/docs/agent.rst b/docs/agent.rst index e57496f7..6d211566 100644 --- a/docs/agent.rst +++ b/docs/agent.rst @@ -1,11 +1,35 @@ Flowcept Agent ============== -The Flowcept Agent is an MCP-powered interface for querying provenance data while a workflow runs or from a JSONL -buffer file. It exposes tools for task queries, object queries, workflow-message queries, context reset, guidance -records, and report generation. - -The agent has one backend and two orchestration paths: +Flowcept exposes provenance data to LLM-based agents through two complementary surfaces: + +**1. Web Chat Agent (browser-embedded)** + An interactive chat panel in the Flowcept Web UI (``flowcept --start-ui``) that answers + natural-language questions about provenance data stored in MongoDB. It queries the + **persisted provenance store** and is always scoped to the page the user is viewing + (a specific workflow or campaign). It also supports **streaming-data context**: when a + workflow is actively running, newly-persisted records are available in near real time + through the same interface. Capabilities: + + - Query tasks, workflows, campaigns, and agents with natural-language questions. + - Generate and render charts directly in the chat (e.g., "plot task durations per activity"). + - **Highlight provenance lineage** in the Dataflow graph: ask the agent to identify an + entity of interest and it will highlight the full ancestor/descendant chain of that + entity in the Dataflow tab — purely from generic provenance edges (``used`` / + ``generated``), with no domain-specific logic. + - Queries are automatically scoped to the current workflow or campaign context. + - Requires ``agent`` + ``web_server.chat.enabled: true`` in settings (see :doc:`web_ui`). + +**2. MCP Agent (external LLM / CLI)** + A standalone MCP server (``flowcept --start-agent``) that external assistants such as + Claude Code, Codex, Cursor, or LibreChat connect to. It consumes messages from the + **live MQ stream** (Redis, Kafka, or Mofka) so it can respond to queries while the + workflow is still executing. It also supports offline JSONL buffer files. + +The two surfaces share the same underlying provenance tool core +(``src/flowcept/agents/tools/prov_tools.py``) so queries stay consistent across both. + +The MCP agent has one backend and two orchestration paths: - **Internal LLM mode**: Flowcept builds the configured LLM and routes free-text messages through ``prompt_handler``. - **External LLM mode**: your outside assistant, such as Codex, Claude, LibreChat, Cursor, or another MCP client, @@ -52,6 +76,31 @@ Like Flowcept as a whole, the agent is designed to run **while a workflow is sti it consumes messages from the MQ (typically Redis) so it can respond to queries in near real time. This is the recommended setup for interactive RAG/MCP analysis during live runs. +Web Chat: streaming vs. persisted queries +------------------------------------------ + +The web chat agent queries MongoDB (the persisted provenance store). When a workflow is +actively running, the ``DocumentInserter`` consumer continuously flushes MQ messages into +MongoDB, so the chat agent sees near-real-time data without connecting directly to the MQ. + +For true in-flight, sub-second streaming queries (before the MQ buffer flushes), use the +MCP agent path, which subscribes to the MQ directly. + +Lineage highlighting +~~~~~~~~~~~~~~~~~~~~ + +Ask the web chat agent to highlight the provenance lineage of any task or group of tasks: + +.. code-block:: text + + "highlight the lineage of the slowest task" + "show me which tasks produced outputs that were later used by failed tasks" + "highlight the lineage of tasks where status is FINISHED and generated.accuracy exists" + +The agent resolves the matching task(s) via a Mongo-style filter, then the Dataflow graph +tab dims all unrelated nodes and edges, tracing only the ancestor/descendant chain. +Click any node or empty space to reset the highlight manually. + Internal prompt-handler example ------------------------------- diff --git a/docs/index.rst b/docs/index.rst index 5132e7b0..b3029887 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -63,6 +63,7 @@ Flowcept quick_start architecture setup + web_ui agent prov_capture telemetry_capture diff --git a/docs/openapi/flowcept-openapi.json b/docs/openapi/flowcept-openapi.json index b33760d1..f164a8db 100644 --- a/docs/openapi/flowcept-openapi.json +++ b/docs/openapi/flowcept-openapi.json @@ -6,13 +6,14 @@ "version": "1.0.0" }, "paths": { - "/": { + "/api/v1/health/live": { "get": { "tags": [ "health" ], - "summary": "Root", - "operationId": "root__get", + "summary": "Live", + "description": "Liveness check.", + "operationId": "live_api_v1_health_live_get", "responses": { "200": { "description": "Successful Response", @@ -21,7 +22,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Root Get" + "title": "Response Live Api V1 Health Live Get" } } } @@ -29,14 +30,14 @@ } } }, - "/api/v1/health/live": { + "/api/v1/health/ready": { "get": { "tags": [ "health" ], - "summary": "Live", - "description": "Liveness check.", - "operationId": "live_api_v1_health_live_get", + "summary": "Ready", + "description": "Readiness check.", + "operationId": "ready_api_v1_health_ready_get", "responses": { "200": { "description": "Successful Response", @@ -45,7 +46,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Live Api V1 Health Live Get" + "title": "Response Ready Api V1 Health Ready Get" } } } @@ -53,14 +54,14 @@ } } }, - "/api/v1/health/ready": { + "/api/v1/info": { "get": { "tags": [ "health" ], - "summary": "Ready", - "description": "Readiness check.", - "operationId": "ready_api_v1_health_ready_get", + "summary": "Info", + "description": "Service name and installed version.", + "operationId": "info_api_v1_info_get", "responses": { "200": { "description": "Successful Response", @@ -69,7 +70,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response Ready Api V1 Health Ready Get" + "title": "Response Info Api V1 Info Get" } } } @@ -77,14 +78,14 @@ } } }, - "/api/v1/workflows": { + "/api/v1/campaigns": { "get": { "tags": [ - "workflows" + "campaigns" ], - "summary": "List Workflows", - "description": "List workflows with optional basic filters.", - "operationId": "list_workflows_api_v1_workflows_get", + "summary": "List Campaigns", + "description": "List derived campaign summaries, most recently active first.", + "operationId": "list_campaigns_api_v1_campaigns_get", "parameters": [ { "name": "limit", @@ -97,86 +98,6 @@ "default": 100, "title": "Limit" } - }, - { - "name": "user", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "User" - } - }, - { - "name": "campaign_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Campaign Id" - } - }, - { - "name": "parent_workflow_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Parent Workflow Id" - } - }, - { - "name": "name", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Name" - } - }, - { - "name": "filter_json", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Filter Json" - } } ], "responses": { @@ -203,22 +124,22 @@ } } }, - "/api/v1/workflows/{workflow_id}": { + "/api/v1/campaigns/{campaign_id}": { "get": { "tags": [ - "workflows" + "campaigns" ], - "summary": "Get Workflow", - "description": "Get a workflow by id.", - "operationId": "get_workflow_api_v1_workflows__workflow_id__get", + "summary": "Get Campaign", + "description": "Get one campaign: derived summary, its workflows, and a task summary.", + "operationId": "get_campaign_api_v1_campaigns__campaign_id__get", "parameters": [ { - "name": "workflow_id", + "name": "campaign_id", "in": "path", "required": true, "schema": { "type": "string", - "title": "Workflow Id" + "title": "Campaign Id" } } ], @@ -230,7 +151,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Workflow Api V1 Workflows Workflow Id Get" + "title": "Response Get Campaign Api V1 Campaigns Campaign Id Get" } } } @@ -246,33 +167,34 @@ } } } - } - }, - "/api/v1/workflows/query": { - "post": { + }, + "delete": { "tags": [ - "workflows" + "campaigns" ], - "summary": "Query Workflows", - "description": "Run an advanced read-only workflows query.", - "operationId": "query_workflows_api_v1_workflows_query_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/QueryRequest" - } + "summary": "Delete Campaign", + "description": "Recursively delete a campaign and all its workflows, tasks, and objects.", + "operationId": "delete_campaign_api_v1_campaigns__campaign_id__delete", + "parameters": [ + { + "name": "campaign_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Campaign Id" } - }, - "required": true - }, + } + ], "responses": { "200": { "description": "Successful Response", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListResponse" + "type": "object", + "additionalProperties": true, + "title": "Response Delete Campaign Api V1 Campaigns Campaign Id Delete" } } } @@ -290,22 +212,32 @@ } } }, - "/api/v1/workflows/{workflow_id}/reports/workflow-card/download": { - "post": { + "/api/v1/campaigns/{campaign_id}/workflow_card": { + "get": { "tags": [ - "workflows" + "campaigns" ], - "summary": "Download Workflow Card", - "description": "Generate and download a workflow card markdown file.", - "operationId": "download_workflow_card_api_v1_workflows__workflow_id__reports_workflow_card_download_post", + "summary": "Get Campaign Workflow Card", + "description": "Get a campaign workflow card as structured JSON or rendered markdown.", + "operationId": "get_campaign_workflow_card_api_v1_campaigns__campaign_id__workflow_card_get", "parameters": [ { - "name": "workflow_id", + "name": "campaign_id", "in": "path", "required": true, "schema": { "type": "string", - "title": "Workflow Id" + "title": "Campaign Id" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "json", + "title": "Format" } } ], @@ -331,14 +263,14 @@ } } }, - "/api/v1/tasks": { + "/api/v1/workflows": { "get": { "tags": [ - "tasks" + "workflows" ], - "summary": "List Tasks", - "description": "List tasks with optional basic filters.", - "operationId": "list_tasks_api_v1_tasks_get", + "summary": "List Workflows", + "description": "List workflows with optional basic filters.", + "operationId": "list_workflows_api_v1_workflows_get", "parameters": [ { "name": "limit", @@ -353,23 +285,7 @@ } }, { - "name": "workflow_id", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Workflow Id" - } - }, - { - "name": "parent_task_id", + "name": "user", "in": "query", "required": false, "schema": { @@ -381,7 +297,7 @@ "type": "null" } ], - "title": "Parent Task Id" + "title": "User" } }, { @@ -401,7 +317,7 @@ } }, { - "name": "task_id", + "name": "parent_workflow_id", "in": "query", "required": false, "schema": { @@ -413,11 +329,11 @@ "type": "null" } ], - "title": "Task Id" + "title": "Parent Workflow Id" } }, { - "name": "status", + "name": "name", "in": "query", "required": false, "schema": { @@ -429,7 +345,7 @@ "type": "null" } ], - "title": "Status" + "title": "Name" } }, { @@ -473,22 +389,22 @@ } } }, - "/api/v1/tasks/{task_id}": { + "/api/v1/workflows/{workflow_id}": { "get": { "tags": [ - "tasks" + "workflows" ], - "summary": "Get Task", - "description": "Get a task by id.", - "operationId": "get_task_api_v1_tasks__task_id__get", + "summary": "Get Workflow", + "description": "Get a workflow by id.", + "operationId": "get_workflow_api_v1_workflows__workflow_id__get", "parameters": [ { - "name": "task_id", + "name": "workflow_id", "in": "path", "required": true, "schema": { "type": "string", - "title": "Task Id" + "title": "Workflow Id" } } ], @@ -500,7 +416,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Task Api V1 Tasks Task Id Get" + "title": "Response Get Workflow Api V1 Workflows Workflow Id Get" } } } @@ -516,16 +432,14 @@ } } } - } - }, - "/api/v1/tasks/by_workflow/{workflow_id}": { - "get": { + }, + "delete": { "tags": [ - "tasks" + "workflows" ], - "summary": "List Tasks By Workflow", - "description": "List tasks for a workflow.", - "operationId": "list_tasks_by_workflow_api_v1_tasks_by_workflow__workflow_id__get", + "summary": "Delete Workflow", + "description": "Recursively delete a workflow and all its tasks and objects.", + "operationId": "delete_workflow_api_v1_workflows__workflow_id__delete", "parameters": [ { "name": "workflow_id", @@ -535,18 +449,6 @@ "type": "string", "title": "Workflow Id" } - }, - { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "maximum": 1000, - "minimum": 1, - "default": 100, - "title": "Limit" - } } ], "responses": { @@ -555,7 +457,9 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ListResponse" + "type": "object", + "additionalProperties": true, + "title": "Response Delete Workflow Api V1 Workflows Workflow Id Delete" } } } @@ -573,14 +477,14 @@ } } }, - "/api/v1/tasks/query": { + "/api/v1/workflows/query": { "post": { "tags": [ - "tasks" + "workflows" ], - "summary": "Query Tasks", - "description": "Run an advanced read-only task query.", - "operationId": "query_tasks_api_v1_tasks_query_post", + "summary": "Query Workflows", + "description": "Run an advanced read-only workflows query.", + "operationId": "query_workflows_api_v1_workflows_query_post", "requestBody": { "content": { "application/json": { @@ -615,14 +519,151 @@ } } }, - "/api/v1/objects": { + "/api/v1/workflows/{workflow_id}/dataflow": { "get": { "tags": [ - "objects" + "workflows" ], - "summary": "List Objects", - "description": "List objects with optional basic filters.", - "operationId": "list_objects_api_v1_objects_get", + "summary": "Get Workflow Dataflow", + "description": "Get the PROV-style dataflow graph derived from tasks' used/generated fields.", + "operationId": "get_workflow_dataflow_api_v1_workflows__workflow_id__dataflow_get", + "parameters": [ + { + "name": "workflow_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Workflow Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Workflow Dataflow Api V1 Workflows Workflow Id Dataflow Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/workflows/{workflow_id}/workflow_card": { + "get": { + "tags": [ + "workflows" + ], + "summary": "Get Workflow Card", + "description": "Get a workflow card as structured JSON or rendered markdown.", + "operationId": "get_workflow_card_api_v1_workflows__workflow_id__workflow_card_get", + "parameters": [ + { + "name": "workflow_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Workflow Id" + } + }, + { + "name": "format", + "in": "query", + "required": false, + "schema": { + "type": "string", + "default": "json", + "title": "Format" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/workflows/{workflow_id}/reports/workflow-card/download": { + "post": { + "tags": [ + "workflows" + ], + "summary": "Download Workflow Card", + "description": "Generate and download a workflow card markdown file.", + "operationId": "download_workflow_card_api_v1_workflows__workflow_id__reports_workflow_card_download_post", + "parameters": [ + { + "name": "workflow_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Workflow Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/tasks": { + "get": { + "tags": [ + "tasks" + ], + "summary": "List Tasks", + "description": "List tasks with optional basic filters.", + "operationId": "list_tasks_api_v1_tasks_get", "parameters": [ { "name": "limit", @@ -637,7 +678,7 @@ } }, { - "name": "object_id", + "name": "workflow_id", "in": "query", "required": false, "schema": { @@ -649,11 +690,11 @@ "type": "null" } ], - "title": "Object Id" + "title": "Workflow Id" } }, { - "name": "workflow_id", + "name": "parent_task_id", "in": "query", "required": false, "schema": { @@ -665,11 +706,11 @@ "type": "null" } ], - "title": "Workflow Id" + "title": "Parent Task Id" } }, { - "name": "task_id", + "name": "campaign_id", "in": "query", "required": false, "schema": { @@ -681,11 +722,11 @@ "type": "null" } ], - "title": "Task Id" + "title": "Campaign Id" } }, { - "name": "type", + "name": "task_id", "in": "query", "required": false, "schema": { @@ -697,11 +738,11 @@ "type": "null" } ], - "title": "Type" + "title": "Task Id" } }, { - "name": "filter_json", + "name": "status", "in": "query", "required": false, "schema": { @@ -713,17 +754,23 @@ "type": "null" } ], - "title": "Filter Json" + "title": "Status" } }, { - "name": "include_data", + "name": "filter_json", "in": "query", "required": false, "schema": { - "type": "boolean", - "default": false, - "title": "Include Data" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Json" } } ], @@ -751,32 +798,22 @@ } } }, - "/api/v1/objects/{object_id}": { + "/api/v1/tasks/{task_id}": { "get": { "tags": [ - "objects" + "tasks" ], - "summary": "Get Object", - "description": "Get latest version of an object by id.", - "operationId": "get_object_api_v1_objects__object_id__get", + "summary": "Get Task", + "description": "Get a task by id.", + "operationId": "get_task_api_v1_tasks__task_id__get", "parameters": [ { - "name": "object_id", + "name": "task_id", "in": "path", "required": true, "schema": { "type": "string", - "title": "Object Id" - } - }, - { - "name": "include_data", - "in": "query", - "required": false, - "schema": { - "type": "boolean", - "default": false, - "title": "Include Data" + "title": "Task Id" } } ], @@ -788,7 +825,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Object Api V1 Objects Object Id Get" + "title": "Response Get Task Api V1 Tasks Task Id Get" } } } @@ -806,41 +843,34 @@ } } }, - "/api/v1/objects/{object_id}/versions/{version}": { + "/api/v1/tasks/by_workflow/{workflow_id}": { "get": { "tags": [ - "objects" + "tasks" ], - "summary": "Get Object Version", - "description": "Get a specific object version by id and version number.", - "operationId": "get_object_version_api_v1_objects__object_id__versions__version__get", + "summary": "List Tasks By Workflow", + "description": "List tasks for a workflow.", + "operationId": "list_tasks_by_workflow_api_v1_tasks_by_workflow__workflow_id__get", "parameters": [ { - "name": "object_id", + "name": "workflow_id", "in": "path", "required": true, "schema": { "type": "string", - "title": "Object Id" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Version" + "title": "Workflow Id" } }, { - "name": "include_data", + "name": "limit", "in": "query", "required": false, "schema": { - "type": "boolean", - "default": false, - "title": "Include Data" + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" } } ], @@ -850,9 +880,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true, - "title": "Response Get Object Version Api V1 Objects Object Id Versions Version Get" + "$ref": "#/components/schemas/ListResponse" } } } @@ -870,99 +898,34 @@ } } }, - "/api/v1/objects/{object_id}/download": { - "get": { + "/api/v1/tasks/query": { + "post": { "tags": [ - "objects" + "tasks" ], - "summary": "Download Object", - "description": "Download object payload as binary.", - "operationId": "download_object_api_v1_objects__object_id__download_get", - "parameters": [ - { - "name": "object_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Object Id" + "summary": "Query Tasks", + "description": "Run an advanced read-only task query.", + "operationId": "query_tasks_api_v1_tasks_query_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/QueryRequest" + } } }, - { - "name": "version", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Version" - } - } - ], + "required": true + }, "responses": { "200": { "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } - }, - "422": { - "description": "Validation Error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/HTTPValidationError" + "$ref": "#/components/schemas/ListResponse" } } } - } - } - } - }, - "/api/v1/objects/{object_id}/versions/{version}/download": { - "get": { - "tags": [ - "objects" - ], - "summary": "Download Object Version", - "description": "Download a specific object payload version as binary.", - "operationId": "download_object_version_api_v1_objects__object_id__versions__version__download_get", - "parameters": [ - { - "name": "object_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Object Id" - } - }, - { - "name": "version", - "in": "path", - "required": true, - "schema": { - "type": "integer", - "title": "Version" - } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } }, "422": { "description": "Validation Error", @@ -977,24 +940,15 @@ } } }, - "/api/v1/objects/{object_id}/history": { + "/api/v1/objects": { "get": { "tags": [ "objects" ], - "summary": "Get Object History", - "description": "Get object metadata history (latest-first).", - "operationId": "get_object_history_api_v1_objects__object_id__history_get", + "summary": "List Objects", + "description": "List objects with optional basic filters.", + "operationId": "list_objects_api_v1_objects_get", "parameters": [ - { - "name": "object_id", - "in": "path", - "required": true, - "schema": { - "type": "string", - "title": "Object Id" - } - }, { "name": "limit", "in": "query", @@ -1006,92 +960,21 @@ "default": 100, "title": "Limit" } - } - ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListResponse" - } - } - } - }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/objects/query": { - "post": { - "tags": [ - "objects" - ], - "summary": "Query Objects", - "description": "Run an advanced read-only object query.", - "operationId": "query_objects_api_v1_objects_query_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObjectQueryRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListResponse" - } - } - } }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - } - } - } - } - }, - "/api/v1/datasets": { - "get": { - "tags": [ - "datasets" - ], - "summary": "List Datasets", - "operationId": "list_datasets_api_v1_datasets_get", - "parameters": [ { - "name": "limit", + "name": "object_id", "in": "query", "required": false, "schema": { - "type": "integer", - "maximum": 1000, - "minimum": 1, - "default": 100, - "title": "Limit" + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Object Id" } }, { @@ -1127,7 +1010,7 @@ } }, { - "name": "object_id", + "name": "object_type", "in": "query", "required": false, "schema": { @@ -1139,7 +1022,7 @@ "type": "null" } ], - "title": "Object Id" + "title": "Object Type" } }, { @@ -1193,13 +1076,14 @@ } } }, - "/api/v1/datasets/{object_id}": { + "/api/v1/objects/{object_id}": { "get": { "tags": [ - "datasets" + "objects" ], - "summary": "Get Dataset", - "operationId": "get_dataset_api_v1_datasets__object_id__get", + "summary": "Get Object", + "description": "Get latest version of an object by id.", + "operationId": "get_object_api_v1_objects__object_id__get", "parameters": [ { "name": "object_id", @@ -1210,22 +1094,6 @@ "title": "Object Id" } }, - { - "name": "version", - "in": "query", - "required": false, - "schema": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "title": "Version" - } - }, { "name": "include_data", "in": "query", @@ -1245,7 +1113,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Dataset Api V1 Datasets Object Id Get" + "title": "Response Get Object Api V1 Objects Object Id Get" } } } @@ -1263,13 +1131,14 @@ } } }, - "/api/v1/datasets/{object_id}/versions/{version}": { + "/api/v1/objects/{object_id}/versions/{version}": { "get": { "tags": [ - "datasets" + "objects" ], - "summary": "Get Dataset Version", - "operationId": "get_dataset_version_api_v1_datasets__object_id__versions__version__get", + "summary": "Get Object Version", + "description": "Get a specific object version by id and version number.", + "operationId": "get_object_version_api_v1_objects__object_id__versions__version__get", "parameters": [ { "name": "object_id", @@ -1308,7 +1177,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Dataset Version Api V1 Datasets Object Id Versions Version Get" + "title": "Response Get Object Version Api V1 Objects Object Id Versions Version Get" } } } @@ -1326,13 +1195,14 @@ } } }, - "/api/v1/datasets/{object_id}/download": { + "/api/v1/objects/{object_id}/download": { "get": { "tags": [ - "datasets" + "objects" ], - "summary": "Download Dataset", - "operationId": "download_dataset_api_v1_datasets__object_id__download_get", + "summary": "Download Object", + "description": "Download object payload as binary.", + "operationId": "download_object_api_v1_objects__object_id__download_get", "parameters": [ { "name": "object_id", @@ -1382,29 +1252,93 @@ } } }, - "/api/v1/datasets/query": { - "post": { + "/api/v1/objects/{object_id}/versions/{version}/download": { + "get": { "tags": [ - "datasets" + "objects" ], - "summary": "Query Datasets", - "operationId": "query_datasets_api_v1_datasets_query_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObjectQueryRequest" - } + "summary": "Download Object Version", + "description": "Download a specific object payload version as binary.", + "operationId": "download_object_version_api_v1_objects__object_id__versions__version__download_get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" } }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/objects/{object_id}/history": { + "get": { + "tags": [ + "objects" + ], + "summary": "Get Object History", + "description": "Get object metadata history (latest-first).", + "operationId": "get_object_history_api_v1_objects__object_id__history_get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/ListResponse" } } @@ -1423,13 +1357,56 @@ } } }, - "/api/v1/models": { + "/api/v1/objects/query": { + "post": { + "tags": [ + "objects" + ], + "summary": "Query Objects", + "description": "Run an advanced read-only object query.", + "operationId": "query_objects_api_v1_objects_query_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectQueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/datasets": { "get": { "tags": [ - "models" + "datasets" ], - "summary": "List Models", - "operationId": "list_models_api_v1_models_get", + "summary": "List Datasets", + "description": "List dataset objects with optional filters.", + "operationId": "list_datasets_api_v1_datasets_get", "parameters": [ { "name": "limit", @@ -1542,13 +1519,14 @@ } } }, - "/api/v1/models/{object_id}": { + "/api/v1/datasets/{object_id}": { "get": { "tags": [ - "models" + "datasets" ], - "summary": "Get Model", - "operationId": "get_model_api_v1_models__object_id__get", + "summary": "Get Dataset", + "description": "Get dataset object metadata by id and optional version.", + "operationId": "get_dataset_api_v1_datasets__object_id__get", "parameters": [ { "name": "object_id", @@ -1594,7 +1572,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Model Api V1 Models Object Id Get" + "title": "Response Get Dataset Api V1 Datasets Object Id Get" } } } @@ -1612,13 +1590,14 @@ } } }, - "/api/v1/models/{object_id}/versions/{version}": { + "/api/v1/datasets/{object_id}/versions/{version}": { "get": { "tags": [ - "models" + "datasets" ], - "summary": "Get Model Version", - "operationId": "get_model_version_api_v1_models__object_id__versions__version__get", + "summary": "Get Dataset Version", + "description": "Get a specific dataset object version.", + "operationId": "get_dataset_version_api_v1_datasets__object_id__versions__version__get", "parameters": [ { "name": "object_id", @@ -1657,7 +1636,7 @@ "schema": { "type": "object", "additionalProperties": true, - "title": "Response Get Model Version Api V1 Models Object Id Versions Version Get" + "title": "Response Get Dataset Version Api V1 Datasets Object Id Versions Version Get" } } } @@ -1675,13 +1654,14 @@ } } }, - "/api/v1/models/{object_id}/download": { + "/api/v1/datasets/{object_id}/download": { "get": { "tags": [ - "models" + "datasets" ], - "summary": "Download Model", - "operationId": "download_model_api_v1_models__object_id__download_get", + "summary": "Download Dataset", + "description": "Download dataset payload as a binary attachment.", + "operationId": "download_dataset_api_v1_datasets__object_id__download_get", "parameters": [ { "name": "object_id", @@ -1709,97 +1689,1719 @@ } } ], - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": {} - } - } + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/datasets/query": { + "post": { + "tags": [ + "datasets" + ], + "summary": "Query Datasets", + "description": "Run an advanced read-only query for dataset objects.", + "operationId": "query_datasets_api_v1_datasets_query_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectQueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models": { + "get": { + "tags": [ + "models" + ], + "summary": "List Models", + "description": "List ML model objects with optional filters.", + "operationId": "list_models_api_v1_models_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + }, + { + "name": "workflow_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + } + }, + { + "name": "task_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Task Id" + } + }, + { + "name": "object_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Object Id" + } + }, + { + "name": "filter_json", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Json" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/{object_id}": { + "get": { + "tags": [ + "models" + ], + "summary": "Get Model", + "description": "Get ML model object metadata by id and optional version.", + "operationId": "get_model_api_v1_models__object_id__get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Model Api V1 Models Object Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/{object_id}/versions/{version}": { + "get": { + "tags": [ + "models" + ], + "summary": "Get Model Version", + "description": "Get a specific ML model object version.", + "operationId": "get_model_version_api_v1_models__object_id__versions__version__get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "title": "Version" + } + }, + { + "name": "include_data", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "default": false, + "title": "Include Data" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Model Version Api V1 Models Object Id Versions Version Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/{object_id}/download": { + "get": { + "tags": [ + "models" + ], + "summary": "Download Model", + "description": "Download ML model payload as a binary attachment.", + "operationId": "download_model_api_v1_models__object_id__download_get", + "parameters": [ + { + "name": "object_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Object Id" + } + }, + { + "name": "version", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Version" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/models/query": { + "post": { + "tags": [ + "models" + ], + "summary": "Query Models", + "description": "Run an advanced read-only query for ML model objects.", + "operationId": "query_models_api_v1_models_query_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectQueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/agents": { + "get": { + "tags": [ + "agents" + ], + "summary": "List Agents", + "description": "List derived agent summaries, most recently active first.", + "operationId": "list_agents_api_v1_agents_get", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/agents/{agent_id}": { + "get": { + "tags": [ + "agents" + ], + "summary": "Get Agent", + "description": "Get one agent's derived summary and per-activity task summary.", + "operationId": "get_agent_api_v1_agents__agent_id__get", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Agent Api V1 Agents Agent Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/agents/{agent_id}/tasks": { + "get": { + "tags": [ + "agents" + ], + "summary": "Get Agent Tasks", + "description": "List tasks executed by or sent from an agent.", + "operationId": "get_agent_tasks_api_v1_agents__agent_id__tasks_get", + "parameters": [ + { + "name": "agent_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Agent Id" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "maximum": 1000, + "minimum": 1, + "default": 100, + "title": "Limit" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stats/tasks/summary": { + "get": { + "tags": [ + "stats" + ], + "summary": "Get Task Summary", + "description": "Summarize tasks (status counts, per-activity durations, time range).", + "operationId": "get_task_summary_api_v1_stats_tasks_summary_get", + "parameters": [ + { + "name": "workflow_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + } + }, + { + "name": "campaign_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Campaign Id" + } + }, + { + "name": "agent_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Id" + } + }, + { + "name": "filter_json", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Filter Json" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Task Summary Api V1 Stats Tasks Summary Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stats/timeseries": { + "post": { + "tags": [ + "stats" + ], + "summary": "Post Timeseries", + "description": "Extract plottable rows of dot-notated fields from tasks.", + "operationId": "post_timeseries_api_v1_stats_timeseries_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimeseriesRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Post Timeseries Api V1 Stats Timeseries Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stats/chart_data": { + "post": { + "tags": [ + "stats" + ], + "summary": "Post Chart Data", + "description": "Resolve a declarative dashboard chart data binding into rows.", + "operationId": "post_chart_data_api_v1_stats_chart_data_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChartDataRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Post Chart Data Api V1 Stats Chart Data Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/dashboards/resolve": { + "get": { + "tags": [ + "dashboards" + ], + "summary": "Resolve Dashboard", + "description": "Return merged charts for a workflow or campaign.\n\nFor a workflow: returns charts from all ``common_workflow`` configs merged\nwith charts from any ``custom_workflow`` config whose ``target`` matches\n``workflow_name``.\n\nFor a campaign: returns charts from all ``common_campaign`` configs merged\nwith charts from any ``custom_campaign`` config whose ``target`` matches\n``campaign_id``.", + "operationId": "resolve_dashboard_api_v1_dashboards_resolve_get", + "parameters": [ + { + "name": "workflow_name", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Name" + } + }, + { + "name": "campaign_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Campaign Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + }, + "title": "Response Resolve Dashboard Api V1 Dashboards Resolve Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/dashboards": { + "get": { + "tags": [ + "dashboards" + ], + "summary": "List Dashboards", + "description": "List all dashboard configs, optionally filtered by ``dashboard_type``.", + "operationId": "list_dashboards_api_v1_dashboards_get", + "parameters": [ + { + "name": "dashboard_type", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Dashboard Type" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "post": { + "tags": [ + "dashboards" + ], + "summary": "Create Dashboard", + "description": "Create a dashboard config; the server assigns its id and timestamps.", + "operationId": "create_dashboard_api_v1_dashboards_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardConfig" + } + } + } + }, + "responses": { + "201": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Create Dashboard Api V1 Dashboards Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/dashboards/{dashboard_id}": { + "get": { + "tags": [ + "dashboards" + ], + "summary": "Get Dashboard", + "description": "Get a dashboard config by id.", + "operationId": "get_dashboard_api_v1_dashboards__dashboard_id__get", + "parameters": [ + { + "name": "dashboard_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Dashboard Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Get Dashboard Api V1 Dashboards Dashboard Id Get" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "put": { + "tags": [ + "dashboards" + ], + "summary": "Update Dashboard", + "description": "Replace a dashboard config, preserving its id and creation time.", + "operationId": "update_dashboard_api_v1_dashboards__dashboard_id__put", + "parameters": [ + { + "name": "dashboard_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Dashboard Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DashboardConfig" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Update Dashboard Api V1 Dashboards Dashboard Id Put" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": [ + "dashboards" + ], + "summary": "Delete Dashboard", + "description": "Delete a dashboard config by id.", + "operationId": "delete_dashboard_api_v1_dashboards__dashboard_id__delete", + "parameters": [ + { + "name": "dashboard_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Dashboard Id" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "title": "Response Delete Dashboard Api V1 Dashboards Dashboard Id Delete" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stream/tasks": { + "get": { + "tags": [ + "stream" + ], + "summary": "Stream Tasks", + "description": "Stream new/updated tasks as SSE events, optionally scoped by workflow/campaign/agent.", + "operationId": "stream_tasks_api_v1_stream_tasks_get", + "parameters": [ + { + "name": "workflow_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Workflow Id" + } + }, + { + "name": "campaign_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Campaign Id" + } + }, + { + "name": "agent_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Id" + } + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Since" + } + }, + { + "name": "poll_interval", + "in": "query", + "required": false, + "schema": { + "type": "number", + "maximum": 60.0, + "minimum": 0.1, + "default": 2.0, + "title": "Poll Interval" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/stream/workflows": { + "get": { + "tags": [ + "stream" + ], + "summary": "Stream Workflows", + "description": "Stream new workflows as SSE events, optionally scoped by campaign.", + "operationId": "stream_workflows_api_v1_stream_workflows_get", + "parameters": [ + { + "name": "campaign_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Campaign Id" + } + }, + { + "name": "since", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Since" + } + }, + { + "name": "poll_interval", + "in": "query", + "required": false, + "schema": { + "type": "number", + "maximum": 60.0, + "minimum": 0.1, + "default": 2.0, + "title": "Poll Interval" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/chat": { + "post": { + "tags": [ + "chat" + ], + "summary": "Chat", + "description": "Answer a provenance chat message, optionally streaming SSE events.\n\nStreaming events: ``tool_call``, ``tool_result``, ``card``, ``token``, ``done``, ``error``.\nNon-streaming responses collect the same events into\n``{\"message\", \"tool_trace\", \"cards\"}``.", + "operationId": "chat_api_v1_chat_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ChatRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/query/{scope}": { + "post": { + "tags": [ + "query" + ], + "summary": "Query Scope", + "description": "Run a read-only advanced query over a constrained collection scope.", + "operationId": "query_scope_api_v1_query__scope__post", + "parameters": [ + { + "name": "scope", + "in": "path", + "required": true, + "schema": { + "enum": [ + "workflows", + "tasks", + "objects", + "models", + "datasets" + ], + "type": "string", + "title": "Scope" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ObjectQueryRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "AggregationSpec": { + "properties": { + "operator": { + "type": "string", + "enum": [ + "avg", + "sum", + "min", + "max" + ], + "title": "Operator" + }, + "field": { + "type": "string", + "minLength": 1, + "title": "Field" + } + }, + "type": "object", + "required": [ + "operator", + "field" + ], + "title": "AggregationSpec", + "description": "Aggregation operator and source field." + }, + "ChartData": { + "properties": { + "source": { + "type": "string", + "enum": [ + "tasks", + "workflows", + "objects" + ], + "title": "Source", + "default": "tasks" + }, + "filter": { + "additionalProperties": true, + "type": "object", + "title": "Filter" + }, + "group_by": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Group By" + }, + "metrics": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/MetricSpec" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Metrics" + }, + "x": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "X" + }, + "y": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Y" + }, + "sort": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/SortSpec" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Sort" + }, + "limit": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Limit", + "default": 500 + } + }, + "type": "object", + "title": "ChartData", + "description": "Declarative data binding for a chart: what to query and how to shape it." + }, + "ChartDataRequest": { + "properties": { + "data": { + "$ref": "#/components/schemas/ChartData" + }, + "context": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Context" + } + }, + "type": "object", + "required": [ + "data" + ], + "title": "ChartDataRequest", + "description": "Request body for the declarative chart-data resolver." + }, + "ChatMessage": { + "properties": { + "role": { + "type": "string", + "title": "Role" + }, + "content": { + "type": "string", + "title": "Content" + } + }, + "type": "object", + "required": [ + "role", + "content" + ], + "title": "ChatMessage", + "description": "One conversation message." + }, + "ChatRequest": { + "properties": { + "messages": { + "items": { + "$ref": "#/components/schemas/ChatMessage" + }, + "type": "array", + "minItems": 1, + "title": "Messages" }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } + "context": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" } - } + ], + "title": "Context" + }, + "stream": { + "type": "boolean", + "title": "Stream", + "default": true + }, + "allow_dashboard_edit": { + "type": "boolean", + "title": "Allow Dashboard Edit", + "default": false } - } - } - }, - "/api/v1/models/query": { - "post": { - "tags": [ - "models" + }, + "type": "object", + "required": [ + "messages" ], - "summary": "Query Models", - "operationId": "query_models_api_v1_models_query_post", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ObjectQueryRequest" + "title": "ChatRequest", + "description": "Chat request: client-passed history plus UI context." + }, + "DashboardChart": { + "properties": { + "chart_id": { + "type": "string", + "title": "Chart Id" + }, + "type": { + "type": "string", + "enum": [ + "chart", + "metric", + "table", + "markdown" + ], + "title": "Type" + }, + "title": { + "type": "string", + "title": "Title", + "default": "" + }, + "live": { + "type": "boolean", + "title": "Live", + "default": false + }, + "refresh_interval_sec": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" } - } + ], + "title": "Refresh Interval Sec" }, - "required": true - }, - "responses": { - "200": { - "description": "Successful Response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ListResponse" - } + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/ChartData" + }, + { + "type": "null" } - } + ] }, - "422": { - "description": "Validation Error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } + "viz": { + "anyOf": [ + { + "$ref": "#/components/schemas/VizSpec" + }, + { + "type": "null" } - } + ] + }, + "content": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Content" } - } - } - } - }, - "components": { - "schemas": { - "AggregationSpec": { + }, + "type": "object", + "required": [ + "chart_id", + "type" + ], + "title": "DashboardChart", + "description": "One chart inside a dashboard." + }, + "DashboardConfig": { "properties": { - "operator": { + "dashboard_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Dashboard Id" + }, + "dashboard_type": { "type": "string", "enum": [ - "avg", - "sum", - "min", - "max" + "common_workflow", + "common_campaign", + "custom_workflow", + "custom_campaign" ], - "title": "Operator" + "title": "Dashboard Type", + "default": "common_workflow" }, - "field": { + "target": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Target" + }, + "name": { "type": "string", - "minLength": 1, - "title": "Field" + "title": "Name", + "default": "" + }, + "description": { + "type": "string", + "title": "Description", + "default": "" + }, + "context": { + "additionalProperties": true, + "type": "object", + "title": "Context" + }, + "charts": { + "items": { + "$ref": "#/components/schemas/DashboardChart" + }, + "type": "array", + "title": "Charts" + }, + "layout": { + "items": { + "$ref": "#/components/schemas/LayoutItem" + }, + "type": "array", + "title": "Layout" + }, + "created_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Created At" + }, + "updated_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Updated At" } }, "type": "object", - "required": [ - "operator", - "field" - ], - "title": "AggregationSpec", - "description": "Aggregation operator and source field." + "title": "DashboardConfig", + "description": "A dashboard configuration: one of four types (common/custom \u00d7 workflow/campaign).\n\n``target`` is required for custom types:\n- ``custom_workflow``: the workflow **name** (not id) this config applies to.\n- ``custom_campaign``: the ``campaign_id`` this config applies to.\nCommon types leave ``target`` null and apply to every workflow or campaign." }, "HTTPValidationError": { "properties": { @@ -1814,6 +3416,46 @@ "type": "object", "title": "HTTPValidationError" }, + "LayoutItem": { + "properties": { + "chart_id": { + "type": "string", + "title": "Chart Id" + }, + "x": { + "type": "integer", + "maximum": 11.0, + "minimum": 0.0, + "title": "X" + }, + "y": { + "type": "integer", + "minimum": 0.0, + "title": "Y" + }, + "w": { + "type": "integer", + "maximum": 12.0, + "minimum": 1.0, + "title": "W" + }, + "h": { + "type": "integer", + "minimum": 1.0, + "title": "H" + } + }, + "type": "object", + "required": [ + "chart_id", + "x", + "y", + "w", + "h" + ], + "title": "LayoutItem", + "description": "Grid placement of a chart in a 12-column layout." + }, "ListResponse": { "properties": { "items": { @@ -1842,6 +3484,32 @@ "title": "ListResponse", "description": "Generic list envelope for collection endpoints." }, + "MetricSpec": { + "properties": { + "field": { + "type": "string", + "title": "Field" + }, + "agg": { + "type": "string", + "enum": [ + "avg", + "sum", + "min", + "max", + "count" + ], + "title": "Agg" + } + }, + "type": "object", + "required": [ + "field", + "agg" + ], + "title": "MetricSpec", + "description": "A single aggregation over a (dot-notated) field." + }, "ObjectQueryRequest": { "properties": { "filter": { @@ -2003,6 +3671,40 @@ "title": "SortSpec", "description": "Sort field/order pair." }, + "TimeseriesRequest": { + "properties": { + "filter": { + "additionalProperties": true, + "type": "object", + "title": "Filter" + }, + "fields": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Fields" + }, + "x": { + "type": "string", + "title": "X", + "default": "started_at" + }, + "limit": { + "type": "integer", + "maximum": 5000.0, + "minimum": 1.0, + "title": "Limit", + "default": 1000 + } + }, + "type": "object", + "required": [ + "fields" + ], + "title": "TimeseriesRequest", + "description": "Request body for telemetry/field timeseries extraction." + }, "ValidationError": { "properties": { "loc": { @@ -2035,7 +3737,32 @@ "type" ], "title": "ValidationError" + }, + "VizSpec": { + "properties": { + "kind": { + "type": "string", + "enum": [ + "line", + "bar", + "pie", + "scatter", + "area", + "heatmap" + ], + "title": "Kind", + "default": "line" + }, + "stacked": { + "type": "boolean", + "title": "Stacked", + "default": false + } + }, + "type": "object", + "title": "VizSpec", + "description": "How a chart renders its rows." } } } -} +} \ No newline at end of file diff --git a/docs/openapi/flowcept-openapi.yaml b/docs/openapi/flowcept-openapi.yaml index 08d1ba2d..5b7c83af 100644 --- a/docs/openapi/flowcept-openapi.yaml +++ b/docs/openapi/flowcept-openapi.yaml @@ -5,12 +5,13 @@ info: tasks, and objects endpoints with query support. version: 1.0.0 paths: - /: + /api/v1/health/live: get: tags: - health - summary: Root - operationId: root__get + summary: Live + description: Liveness check. + operationId: live_api_v1_health_live_get responses: '200': description: Successful Response @@ -19,14 +20,14 @@ paths: schema: additionalProperties: true type: object - title: Response Root Get - /api/v1/health/live: + title: Response Live Api V1 Health Live Get + /api/v1/health/ready: get: tags: - health - summary: Live - description: Liveness check. - operationId: live_api_v1_health_live_get + summary: Ready + description: Readiness check. + operationId: ready_api_v1_health_ready_get responses: '200': description: Successful Response @@ -35,14 +36,14 @@ paths: schema: additionalProperties: true type: object - title: Response Live Api V1 Health Live Get - /api/v1/health/ready: + title: Response Ready Api V1 Health Ready Get + /api/v1/info: get: tags: - health - summary: Ready - description: Readiness check. - operationId: ready_api_v1_health_ready_get + summary: Info + description: Service name and installed version. + operationId: info_api_v1_info_get responses: '200': description: Successful Response @@ -51,7 +52,128 @@ paths: schema: additionalProperties: true type: object - title: Response Ready Api V1 Health Ready Get + title: Response Info Api V1 Info Get + /api/v1/campaigns: + get: + tags: + - campaigns + summary: List Campaigns + description: List derived campaign summaries, most recently active first. + operationId: list_campaigns_api_v1_campaigns_get + parameters: + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/campaigns/{campaign_id}: + get: + tags: + - campaigns + summary: Get Campaign + description: 'Get one campaign: derived summary, its workflows, and a task summary.' + operationId: get_campaign_api_v1_campaigns__campaign_id__get + parameters: + - name: campaign_id + in: path + required: true + schema: + type: string + title: Campaign Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Campaign Api V1 Campaigns Campaign Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - campaigns + summary: Delete Campaign + description: Recursively delete a campaign and all its workflows, tasks, and + objects. + operationId: delete_campaign_api_v1_campaigns__campaign_id__delete + parameters: + - name: campaign_id + in: path + required: true + schema: + type: string + title: Campaign Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Delete Campaign Api V1 Campaigns Campaign Id Delete + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/campaigns/{campaign_id}/workflow_card: + get: + tags: + - campaigns + summary: Get Campaign Workflow Card + description: Get a campaign workflow card as structured JSON or rendered markdown. + operationId: get_campaign_workflow_card_api_v1_campaigns__campaign_id__workflow_card_get + parameters: + - name: campaign_id + in: path + required: true + schema: + type: string + title: Campaign Id + - name: format + in: query + required: false + schema: + type: string + default: json + title: Format + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /api/v1/workflows: get: tags: @@ -151,6 +273,34 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - workflows + summary: Delete Workflow + description: Recursively delete a workflow and all its tasks and objects. + operationId: delete_workflow_api_v1_workflows__workflow_id__delete + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + title: Workflow Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Delete Workflow Api V1 Workflows Workflow Id Delete + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /api/v1/workflows/query: post: tags: @@ -177,6 +327,70 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /api/v1/workflows/{workflow_id}/dataflow: + get: + tags: + - workflows + summary: Get Workflow Dataflow + description: Get the PROV-style dataflow graph derived from tasks' used/generated + fields. + operationId: get_workflow_dataflow_api_v1_workflows__workflow_id__dataflow_get + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + title: Workflow Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Workflow Dataflow Api V1 Workflows Workflow Id Dataflow + Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/workflows/{workflow_id}/workflow_card: + get: + tags: + - workflows + summary: Get Workflow Card + description: Get a workflow card as structured JSON or rendered markdown. + operationId: get_workflow_card_api_v1_workflows__workflow_id__workflow_card_get + parameters: + - name: workflow_id + in: path + required: true + schema: + type: string + title: Workflow Id + - name: format + in: query + required: false + schema: + type: string + default: json + title: Format + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /api/v1/workflows/{workflow_id}/reports/workflow-card/download: post: tags: @@ -413,14 +627,14 @@ paths: - type: string - type: 'null' title: Task Id - - name: type + - name: object_type in: query required: false schema: anyOf: - type: string - type: 'null' - title: Type + title: Object Type - name: filter_json in: query required: false @@ -660,6 +874,7 @@ paths: tags: - datasets summary: List Datasets + description: List dataset objects with optional filters. operationId: list_datasets_api_v1_datasets_get parameters: - name: limit @@ -728,6 +943,7 @@ paths: tags: - datasets summary: Get Dataset + description: Get dataset object metadata by id and optional version. operationId: get_dataset_api_v1_datasets__object_id__get parameters: - name: object_id @@ -771,6 +987,7 @@ paths: tags: - datasets summary: Get Dataset Version + description: Get a specific dataset object version. operationId: get_dataset_version_api_v1_datasets__object_id__versions__version__get parameters: - name: object_id @@ -812,6 +1029,7 @@ paths: tags: - datasets summary: Download Dataset + description: Download dataset payload as a binary attachment. operationId: download_dataset_api_v1_datasets__object_id__download_get parameters: - name: object_id @@ -845,6 +1063,7 @@ paths: tags: - datasets summary: Query Datasets + description: Run an advanced read-only query for dataset objects. operationId: query_datasets_api_v1_datasets_query_post requestBody: content: @@ -870,6 +1089,7 @@ paths: tags: - models summary: List Models + description: List ML model objects with optional filters. operationId: list_models_api_v1_models_get parameters: - name: limit @@ -938,6 +1158,7 @@ paths: tags: - models summary: Get Model + description: Get ML model object metadata by id and optional version. operationId: get_model_api_v1_models__object_id__get parameters: - name: object_id @@ -981,6 +1202,7 @@ paths: tags: - models summary: Get Model Version + description: Get a specific ML model object version. operationId: get_model_version_api_v1_models__object_id__versions__version__get parameters: - name: object_id @@ -1022,6 +1244,7 @@ paths: tags: - models summary: Download Model + description: Download ML model payload as a binary attachment. operationId: download_model_api_v1_models__object_id__download_get parameters: - name: object_id @@ -1055,6 +1278,7 @@ paths: tags: - models summary: Query Models + description: Run an advanced read-only query for ML model objects. operationId: query_models_api_v1_models_query_post requestBody: content: @@ -1075,65 +1299,919 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' -components: - schemas: - AggregationSpec: - properties: - operator: - type: string - enum: - - avg - - sum - - min - - max - title: Operator - field: - type: string - minLength: 1 - title: Field - type: object - required: - - operator - - field - title: AggregationSpec - description: Aggregation operator and source field. - HTTPValidationError: - properties: - detail: - items: - $ref: '#/components/schemas/ValidationError' - type: array - title: Detail - type: object - title: HTTPValidationError - ListResponse: - properties: - items: - items: - additionalProperties: true - type: object - type: array - title: Items - count: - type: integer - title: Count - limit: + /api/v1/agents: + get: + tags: + - agents + summary: List Agents + description: List derived agent summaries, most recently active first. + operationId: list_agents_api_v1_agents_get + parameters: + - name: limit + in: query + required: false + schema: type: integer + maximum: 1000 + minimum: 1 + default: 100 title: Limit - type: object - required: - - items - - count - - limit - title: ListResponse - description: Generic list envelope for collection endpoints. - ObjectQueryRequest: - properties: - filter: - additionalProperties: true - type: object - title: Filter - projection: + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/agents/{agent_id}: + get: + tags: + - agents + summary: Get Agent + description: Get one agent's derived summary and per-activity task summary. + operationId: get_agent_api_v1_agents__agent_id__get + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Agent Api V1 Agents Agent Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/agents/{agent_id}/tasks: + get: + tags: + - agents + summary: Get Agent Tasks + description: List tasks executed by or sent from an agent. + operationId: get_agent_tasks_api_v1_agents__agent_id__tasks_get + parameters: + - name: agent_id + in: path + required: true + schema: + type: string + title: Agent Id + - name: limit + in: query + required: false + schema: + type: integer + maximum: 1000 + minimum: 1 + default: 100 + title: Limit + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/stats/tasks/summary: + get: + tags: + - stats + summary: Get Task Summary + description: Summarize tasks (status counts, per-activity durations, time range). + operationId: get_task_summary_api_v1_stats_tasks_summary_get + parameters: + - name: workflow_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Workflow Id + - name: campaign_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Campaign Id + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: filter_json + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Filter Json + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Task Summary Api V1 Stats Tasks Summary Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/stats/timeseries: + post: + tags: + - stats + summary: Post Timeseries + description: Extract plottable rows of dot-notated fields from tasks. + operationId: post_timeseries_api_v1_stats_timeseries_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TimeseriesRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response Post Timeseries Api V1 Stats Timeseries Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/stats/chart_data: + post: + tags: + - stats + summary: Post Chart Data + description: Resolve a declarative dashboard chart data binding into rows. + operationId: post_chart_data_api_v1_stats_chart_data_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ChartDataRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: true + type: object + title: Response Post Chart Data Api V1 Stats Chart Data Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/dashboards/resolve: + get: + tags: + - dashboards + summary: Resolve Dashboard + description: 'Return merged charts for a workflow or campaign. + + + For a workflow: returns charts from all ``common_workflow`` configs merged + + with charts from any ``custom_workflow`` config whose ``target`` matches + + ``workflow_name``. + + + For a campaign: returns charts from all ``common_campaign`` configs merged + + with charts from any ``custom_campaign`` config whose ``target`` matches + + ``campaign_id``.' + operationId: resolve_dashboard_api_v1_dashboards_resolve_get + parameters: + - name: workflow_name + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Workflow Name + - name: campaign_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Campaign Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: array + items: + type: object + additionalProperties: true + title: Response Resolve Dashboard Api V1 Dashboards Resolve Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/dashboards: + get: + tags: + - dashboards + summary: List Dashboards + description: List all dashboard configs, optionally filtered by ``dashboard_type``. + operationId: list_dashboards_api_v1_dashboards_get + parameters: + - name: dashboard_type + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Dashboard Type + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + post: + tags: + - dashboards + summary: Create Dashboard + description: Create a dashboard config; the server assigns its id and timestamps. + operationId: create_dashboard_api_v1_dashboards_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DashboardConfig' + responses: + '201': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Create Dashboard Api V1 Dashboards Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/dashboards/{dashboard_id}: + get: + tags: + - dashboards + summary: Get Dashboard + description: Get a dashboard config by id. + operationId: get_dashboard_api_v1_dashboards__dashboard_id__get + parameters: + - name: dashboard_id + in: path + required: true + schema: + type: string + title: Dashboard Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Get Dashboard Api V1 Dashboards Dashboard Id Get + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + put: + tags: + - dashboards + summary: Update Dashboard + description: Replace a dashboard config, preserving its id and creation time. + operationId: update_dashboard_api_v1_dashboards__dashboard_id__put + parameters: + - name: dashboard_id + in: path + required: true + schema: + type: string + title: Dashboard Id + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DashboardConfig' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Update Dashboard Api V1 Dashboards Dashboard Id Put + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - dashboards + summary: Delete Dashboard + description: Delete a dashboard config by id. + operationId: delete_dashboard_api_v1_dashboards__dashboard_id__delete + parameters: + - name: dashboard_id + in: path + required: true + schema: + type: string + title: Dashboard Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + type: object + additionalProperties: true + title: Response Delete Dashboard Api V1 Dashboards Dashboard Id Delete + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/stream/tasks: + get: + tags: + - stream + summary: Stream Tasks + description: Stream new/updated tasks as SSE events, optionally scoped by workflow/campaign/agent. + operationId: stream_tasks_api_v1_stream_tasks_get + parameters: + - name: workflow_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Workflow Id + - name: campaign_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Campaign Id + - name: agent_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Agent Id + - name: since + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Since + - name: poll_interval + in: query + required: false + schema: + type: number + maximum: 60.0 + minimum: 0.1 + default: 2.0 + title: Poll Interval + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/stream/workflows: + get: + tags: + - stream + summary: Stream Workflows + description: Stream new workflows as SSE events, optionally scoped by campaign. + operationId: stream_workflows_api_v1_stream_workflows_get + parameters: + - name: campaign_id + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Campaign Id + - name: since + in: query + required: false + schema: + anyOf: + - type: number + - type: 'null' + title: Since + - name: poll_interval + in: query + required: false + schema: + type: number + maximum: 60.0 + minimum: 0.1 + default: 2.0 + title: Poll Interval + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/chat: + post: + tags: + - chat + summary: Chat + description: 'Answer a provenance chat message, optionally streaming SSE events. + + + Streaming events: ``tool_call``, ``tool_result``, ``card``, ``token``, ``done``, + ``error``. + + Non-streaming responses collect the same events into + + ``{"message", "tool_trace", "cards"}``.' + operationId: chat_api_v1_chat_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ChatRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /api/v1/query/{scope}: + post: + tags: + - query + summary: Query Scope + description: Run a read-only advanced query over a constrained collection scope. + operationId: query_scope_api_v1_query__scope__post + parameters: + - name: scope + in: path + required: true + schema: + enum: + - workflows + - tasks + - objects + - models + - datasets + type: string + title: Scope + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ObjectQueryRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/ListResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' +components: + schemas: + AggregationSpec: + properties: + operator: + type: string + enum: + - avg + - sum + - min + - max + title: Operator + field: + type: string + minLength: 1 + title: Field + type: object + required: + - operator + - field + title: AggregationSpec + description: Aggregation operator and source field. + ChartData: + properties: + source: + type: string + enum: + - tasks + - workflows + - objects + title: Source + default: tasks + filter: + additionalProperties: true + type: object + title: Filter + group_by: + anyOf: + - type: string + - type: 'null' + title: Group By + metrics: + anyOf: + - items: + $ref: '#/components/schemas/MetricSpec' + type: array + - type: 'null' + title: Metrics + x: + anyOf: + - type: string + - type: 'null' + title: X + y: + anyOf: + - items: + type: string + type: array + - type: 'null' + title: Y + sort: + anyOf: + - items: + $ref: '#/components/schemas/SortSpec' + type: array + - type: 'null' + title: Sort + limit: + type: integer + maximum: 5000.0 + minimum: 1.0 + title: Limit + default: 500 + type: object + title: ChartData + description: 'Declarative data binding for a chart: what to query and how to + shape it.' + ChartDataRequest: + properties: + data: + $ref: '#/components/schemas/ChartData' + context: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Context + type: object + required: + - data + title: ChartDataRequest + description: Request body for the declarative chart-data resolver. + ChatMessage: + properties: + role: + type: string + title: Role + content: + type: string + title: Content + type: object + required: + - role + - content + title: ChatMessage + description: One conversation message. + ChatRequest: + properties: + messages: + items: + $ref: '#/components/schemas/ChatMessage' + type: array + minItems: 1 + title: Messages + context: + anyOf: + - additionalProperties: true + type: object + - type: 'null' + title: Context + stream: + type: boolean + title: Stream + default: true + allow_dashboard_edit: + type: boolean + title: Allow Dashboard Edit + default: false + type: object + required: + - messages + title: ChatRequest + description: 'Chat request: client-passed history plus UI context.' + DashboardChart: + properties: + chart_id: + type: string + title: Chart Id + type: + type: string + enum: + - chart + - metric + - table + - markdown + title: Type + title: + type: string + title: Title + default: '' + live: + type: boolean + title: Live + default: false + refresh_interval_sec: + anyOf: + - type: number + - type: 'null' + title: Refresh Interval Sec + data: + anyOf: + - $ref: '#/components/schemas/ChartData' + - type: 'null' + viz: + anyOf: + - $ref: '#/components/schemas/VizSpec' + - type: 'null' + content: + anyOf: + - type: string + - type: 'null' + title: Content + type: object + required: + - chart_id + - type + title: DashboardChart + description: One chart inside a dashboard. + DashboardConfig: + properties: + dashboard_id: + anyOf: + - type: string + - type: 'null' + title: Dashboard Id + dashboard_type: + type: string + enum: + - common_workflow + - common_campaign + - custom_workflow + - custom_campaign + title: Dashboard Type + default: common_workflow + target: + anyOf: + - type: string + - type: 'null' + title: Target + name: + type: string + title: Name + default: '' + description: + type: string + title: Description + default: '' + context: + additionalProperties: true + type: object + title: Context + charts: + items: + $ref: '#/components/schemas/DashboardChart' + type: array + title: Charts + layout: + items: + $ref: '#/components/schemas/LayoutItem' + type: array + title: Layout + created_at: + anyOf: + - type: string + - type: 'null' + title: Created At + updated_at: + anyOf: + - type: string + - type: 'null' + title: Updated At + type: object + title: DashboardConfig + description: "A dashboard configuration: one of four types (common/custom \xD7\ + \ workflow/campaign).\n\n``target`` is required for custom types:\n- ``custom_workflow``:\ + \ the workflow **name** (not id) this config applies to.\n- ``custom_campaign``:\ + \ the ``campaign_id`` this config applies to.\nCommon types leave ``target``\ + \ null and apply to every workflow or campaign." + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + LayoutItem: + properties: + chart_id: + type: string + title: Chart Id + x: + type: integer + maximum: 11.0 + minimum: 0.0 + title: X + y: + type: integer + minimum: 0.0 + title: Y + w: + type: integer + maximum: 12.0 + minimum: 1.0 + title: W + h: + type: integer + minimum: 1.0 + title: H + type: object + required: + - chart_id + - x + - y + - w + - h + title: LayoutItem + description: Grid placement of a chart in a 12-column layout. + ListResponse: + properties: + items: + items: + additionalProperties: true + type: object + type: array + title: Items + count: + type: integer + title: Count + limit: + type: integer + title: Limit + type: object + required: + - items + - count + - limit + title: ListResponse + description: Generic list envelope for collection endpoints. + MetricSpec: + properties: + field: + type: string + title: Field + agg: + type: string + enum: + - avg + - sum + - min + - max + - count + title: Agg + type: object + required: + - field + - agg + title: MetricSpec + description: A single aggregation over a (dot-notated) field. + ObjectQueryRequest: + properties: + filter: + additionalProperties: true + type: object + title: Filter + projection: anyOf: - items: type: string @@ -1229,6 +2307,32 @@ components: - field title: SortSpec description: Sort field/order pair. + TimeseriesRequest: + properties: + filter: + additionalProperties: true + type: object + title: Filter + fields: + items: + type: string + type: array + title: Fields + x: + type: string + title: X + default: started_at + limit: + type: integer + maximum: 5000.0 + minimum: 1.0 + title: Limit + default: 1000 + type: object + required: + - fields + title: TimeseriesRequest + description: Request body for telemetry/field timeseries extraction. ValidationError: properties: loc: @@ -1250,3 +2354,23 @@ components: - msg - type title: ValidationError + VizSpec: + properties: + kind: + type: string + enum: + - line + - bar + - pie + - scatter + - area + - heatmap + title: Kind + default: line + stacked: + type: boolean + title: Stacked + default: false + type: object + title: VizSpec + description: How a chart renders its rows. diff --git a/docs/setup.rst b/docs/setup.rst index 701eb8bc..3fd9b0c9 100644 --- a/docs/setup.rst +++ b/docs/setup.rst @@ -39,7 +39,6 @@ Good practice is to cherry-pick the extras relevant to your workflow instead of pip install flowcept[llm_agent] # MCP agent, LangChain, Streamlit integration pip install flowcept[llm_google] # Google GenAI + Flowcept agent support pip install flowcept[llm_agent_audio] # MCP agent with audio enabled (tts). - pip install flowcept[analytics] # Extra analytics (seaborn, plotly, scipy) pip install flowcept[dev] # Developer dependencies (docs, tests, lint, etc.) Installing with Common Runtime Bundle diff --git a/docs/web_ui.rst b/docs/web_ui.rst new file mode 100644 index 00000000..f28468d9 --- /dev/null +++ b/docs/web_ui.rst @@ -0,0 +1,233 @@ +Web UI +====== + +Flowcept ships a built-in web interface for browsing and analyzing provenance data. +It is a React single-page application served directly by the Flowcept webservice — no +separate process or Node.js installation is needed by end users. + +.. contents:: On this page + :local: + :depth: 2 + + +Installation +------------ + +Install Flowcept with the ``webservice`` extra (the UI assets are bundled in the wheel): + +.. code-block:: bash + + pip install flowcept[webservice] + +For the LLM chat feature also add ``llm_agent`` and a model provider extra: + +.. code-block:: bash + + pip install flowcept[webservice,llm_agent] + + +Prerequisites +~~~~~~~~~~~~~ + +* A running MongoDB instance (recommended; most UI features work without it but + dashboards and provenance cards require Mongo). +* A running Redis instance (required for the instrumentation message queue). + +Start both with Docker Compose: + +.. code-block:: bash + + make services-mongo # Redis + MongoDB + + +Starting the UI +--------------- + +.. code-block:: bash + + flowcept --start-ui + +This command: + +1. Kills any previously running webservice or Vite process on the configured ports. +2. Starts the Flowcept webservice in the background (FastAPI + bundled UI assets). +3. Starts the Vite development server in the foreground (hot-reload, proxies ``/api`` + to the webservice). + +Press ``Ctrl+C`` to stop both. + +The UI is served on ``http://localhost:8008`` by default. +The API is available at ``http://localhost:8008/api/v1`` (interactive docs at +``http://localhost:8008/docs``). + +Settings +~~~~~~~~ + +Configure the webservice in ``~/.flowcept/settings.yaml``: + +.. code-block:: yaml + + web_server: + host: 0.0.0.0 + port: 8008 + ui_enabled: true + +All host/port values can also be set via environment variables +(``WEBSERVER_HOST``, ``WEBSERVER_PORT``), which take precedence over the settings file. + + +Pages +----- + +Overview (``/``) + At-a-glance stats: campaign and workflow counts, latest activity, recent campaigns, + and the eight most-recent named workflows with tasks. + +Campaigns (``/campaigns``) + Card grid of all campaigns (groups of related workflow runs sharing a ``campaign_id``). + Each card links to the campaign detail page. + +Campaign detail (``/campaigns/``) + Tabs: **Workflows** (list of member workflows), **Dashboard** (aggregated charts), + **Workflow Card** (generated provenance report). + +Workflows (``/workflows``) + Sortable list of all named workflows that have at least one task. + +Workflow detail (``/workflows/``) + Tabs: + + * **Tasks** — paginated, sortable task table; click a row to open the Task Inspector. + * **Graph** — BFS-ranked DAG of task dependencies. + * **Dataflow** — W3C PROV-style dataflow graph (yellow entities, blue activities). + * **Telemetry** — per-task CPU/memory/disk/network time-series. + * **Artifacts** — objects (ML models, datasets) saved during the workflow. + * **Dashboard** — per-workflow charts (see :ref:`web-ui-dashboards`). + * **Workflow Card** — downloadable Markdown/PDF provenance report. + * **Raw** — full workflow JSON document. + +Artifacts (``/objects``) + Browse all saved objects filtered by type (all / ml_model / dataset). + Shows total size per type and per-object sizes. + +Dashboard configs (``/dashboards``) + View and manage the chart configuration schemas that define which charts appear + in every workflow's and campaign's Dashboard tab. + +Agent (``/agents``) + List of agent tasks (tasks tagged with an ``agent_id``). + + +.. _web-ui-dashboards: + +Dashboards +---------- + +Each workflow and campaign has a **Dashboard** tab populated by chart configuration +schemas stored server-side (MongoDB ``dashboards`` collection, or JSON files when +Mongo is unavailable). + +There are four schema types: + +.. list-table:: + :header-rows: 1 + + * - Type + - Applies to + - Matched by + * - ``common_workflow`` + - Every workflow's Dashboard tab + - — + * - ``common_campaign`` + - Every campaign's Dashboard tab + - — + * - ``custom_workflow`` + - A specific workflow (by name) + - ``target == workflow.name`` + * - ``custom_campaign`` + - A specific campaign + - ``target == campaign_id`` + +Default chart schemas are seeded automatically from +``src/flowcept/webservice/ui_build/default_dashboard_configs.json`` the first time +the service runs with an empty ``dashboards`` collection. + +**Chart data binding (``ChartData``):** + +.. code-block:: text + + source : "tasks" | "workflows" | "objects" | "collection_sizes" + filter : {} # Mongo-style filter; ANDed with the dashboard context + group_by : string # dot-path field (e.g. "activity_id", "telemetry_at_end.cpu.percent_all") + metrics : [{field, agg}] # agg: count | avg | sum | min | max + x / y : string / string[] # for scatter/line charts + limit : 1–5000 + +Each chart's filter is automatically scoped to the current workflow or campaign via the +dashboard **context** (``workflow_id`` or ``campaign_id``). + +Charts with no data are **hidden by default** but remain accessible via the toggle pills +above the grid. + +The ``collection_sizes`` source is a special virtual source that returns BSON byte +totals for the ``tasks``, ``objects``, and ``workflows`` collections for the current +workflow or campaign — useful for storage-at-a-glance charts. + + +Chat (LLM) +---------- + +The chat panel (center-bottom, always visible) connects to ``POST /api/v1/chat`` and +answers questions about the provenance data using DB-backed tools: + +* query tasks / workflows / campaigns / agents +* get task summaries +* build and pin charts to the dashboard + +Configure the LLM in ``~/.flowcept/settings.yaml``: + +.. code-block:: yaml + + agent: + enabled: true + service_provider: openai # sambanova | azure | openai | google + llm_server_url: + api_key: + model: + web_server: + chat: + enabled: true + max_tool_iterations: 5 + max_query_limit: 1000 + +Without this configuration, the chat panel displays a "chat unavailable" message and +the rest of the UI works normally. + + +MCP Agent +--------- + +The Flowcept MCP agent is a separate server for external agent clients +(Claude Code, Codex, etc.): + +.. code-block:: bash + + flowcept --start-agent # default port: 8000 + +The web UI does not depend on the agent; the chat panel talks to the webservice +directly. See :doc:`agent` for full MCP agent documentation. + + +Development +----------- + +.. code-block:: bash + + make ui-install # install Node dependencies (once) + make ui-dev # Vite dev server with hot reload on http://localhost:5173 + # (proxies /api to the webservice on :8008) + make ui-checks # TypeScript strict type-check + ESLint + make ui-build # production build → src/flowcept/webservice/ui_build/ + +See ``ui/README.md`` in the repository for the full stack description, code layout, +and architecture notes. diff --git a/notebooks/analytics.ipynb b/notebooks/analytics.ipynb deleted file mode 100644 index 2b74ee88..00000000 --- a/notebooks/analytics.ipynb +++ /dev/null @@ -1,626 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "222b4132-fc10-4503-a108-592d5e742515", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "from datetime import datetime\n", - "import numpy as np\n", - "import pandas as pd\n", - "import flowcept.analytics as analytics\n", - "import flowcept.analytics.plot as flow_plot\n", - "from flowcept import TaskQueryAPI" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c7b11fbf-ec74-46e7-9824-4685a9288c55", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "def ingest_mock_data():\n", - " \"\"\"\n", - " This function is here just to enable the execution of the functions we are showing in this notebook.\n", - " \"\"\"\n", - " import json\n", - " from uuid import uuid4\n", - " from flowcept import Flowcept\n", - " test_data_path = '../tests/api/sample_data_with_telemetry_and_rai.json' # This sample data contains a workflow composed of 9 tasks.\n", - " with open(test_data_path) as f:\n", - " base_data = json.loads(f.read())\n", - " \n", - " docs = []\n", - " wf_id = str(uuid4())\n", - " for d in base_data:\n", - " new_doc = d.copy()\n", - " new_doc.pop(\"_id\")\n", - " new_doc[\"task_id\"] = str(uuid4())\n", - " new_doc[\"workflow_id\"] = wf_id\n", - " new_doc.pop(\"timestamp\", None)\n", - " docs.append(new_doc)\n", - " \n", - " inserted_ids = Flowcept.db._dao().insert_and_update_many_tasks(docs, \"task_id\")\n", - " #assert len(inserted_ids) == len(base_data)\n", - " return wf_id" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "176f01c5-5e59-44e3-ad65-409fcfdc2f9b", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# Need to run only if this is the first time.\n", - "wf_id = ingest_mock_data()\n", - "wf_id" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "96442d46-7ebb-470d-962b-11b65e7aca12", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "#wf_id = '100faab4-ff4c-4f78-92a7-6f20ec1fad83'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e41fe652-d7e8-4e3d-a780-dfec4e5142b0", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "query_api = TaskQueryAPI()" - ] - }, - { - "cell_type": "markdown", - "id": "dad7b3e7-1637-4034-91e0-0f00d4d64941", - "metadata": {}, - "source": [ - "## Very Simple query returning a DataFrame" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2c3cd6d6-fc22-4155-80e0-da7ffc9f8e0e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "_filter = {\n", - " \"workflow_id\": wf_id\n", - "}\n", - "df = query_api.df_query(_filter, calculate_telemetry_diff=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d2c04cbe-4b78-49ee-b74d-5e7680a4478f", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "df.head(3)" - ] - }, - { - "cell_type": "markdown", - "id": "67159316-b97b-4a99-ac22-051ee50a6117", - "metadata": {}, - "source": [ - "## Cleaning DataFrame" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8a8c1dd7-9647-4e7a-82e3-f7db7752f824", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "cleaned_df = analytics.clean_dataframe(\n", - " df,\n", - " keep_non_numeric_columns=False,\n", - " keep_only_nans_columns=False,\n", - " keep_task_id=False,\n", - " keep_telemetry_percent_columns=False,\n", - " sum_lists=True,\n", - " aggregate_telemetry=True)\n", - "cleaned_df.head()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5497b4c8-ba90-4ae4-82d7-0ef821fe2f4f", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "sort = [\n", - " (\"generated.loss\", TaskQueryAPI.ASC),\n", - " (\"generated.responsible_ai_metadata.params\", TaskQueryAPI.ASC),\n", - "]\n", - "df = query_api.df_get_top_k_tasks(\n", - " filter=_filter,\n", - " calculate_telemetry_diff=False,\n", - " sort=sort,\n", - " k=3,\n", - ")\n", - "df.filter(regex='used[.]|generated[.]')" - ] - }, - { - "cell_type": "markdown", - "id": "e9df7dfb-72b7-4d77-8447-af73f8314cd4", - "metadata": { - "tags": [] - }, - "source": [ - "## Query Returning the Top K tasks using quantile thresholds\n", - "\n", - "This query filters values based on quantiles (list only ocurrences with cpu_times < 50% quantile, i.e., median) then sort by cpu, loss, and flops." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c669ac40-60b4-49e0-ae62-a2cda2c5815a", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "clauses = [\n", - " (\"telemetry_diff.process.cpu_times.user\", \"<\", 0.5),\n", - "]\n", - "sort = [\n", - " (\"telemetry_diff.process.cpu_times.user\", TaskQueryAPI.ASC),\n", - " (\"generated.loss\", TaskQueryAPI.ASC),\n", - " (\"generated.responsible_ai_metadata.flops\", TaskQueryAPI.ASC),\n", - "]\n", - "df = query_api.df_get_tasks_quantiles(\n", - " clauses=clauses,\n", - " filter=_filter,\n", - " sort=sort,\n", - " calculate_telemetry_diff=True,\n", - " clean_dataframe=True,\n", - ")\n", - "df" - ] - }, - { - "cell_type": "markdown", - "id": "1be66d76-27f5-48d3-a10f-92f2326eb167", - "metadata": { - "tags": [] - }, - "source": [ - "## Correlation Analysis" - ] - }, - { - "cell_type": "markdown", - "id": "07d8c444-ca2c-4249-8d6c-ecdb7ea786fb", - "metadata": { - "tags": [] - }, - "source": [ - "#### Using Pandas' correlation " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "066717e4-2110-4d62-aedd-005c1198cefa", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "df.corr()" - ] - }, - { - "cell_type": "markdown", - "id": "00792ce5-7c72-443c-91f9-61c926444fc8", - "metadata": { - "tags": [] - }, - "source": [ - "#### Using FlowCept's functions for correlations" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e03dab0b-1a03-46a7-bbb2-4d16339abfe1", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "df = query_api.df_query(_filter, calculate_telemetry_diff=True)\n", - "df = analytics.clean_dataframe(df, aggregate_telemetry=True, sum_lists=True)" - ] - }, - { - "cell_type": "markdown", - "id": "9a322160-7b79-430f-af55-387a8c1c2969", - "metadata": { - "tags": [] - }, - "source": [ - "##### All correlations" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "89923e60-b251-45aa-8723-d42c548f8ea1", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "analytics.analyze_correlations(df)" - ] - }, - { - "cell_type": "markdown", - "id": "ba306bb3-b4ed-4ec8-a8e2-75e04ee9c681", - "metadata": { - "tags": [] - }, - "source": [ - "##### Only correlations >= 0.9 (absolute) and using a different method" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fe702218-c80c-4e78-a642-8a91b5571b1d", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "analytics.analyze_correlations(df, method='spearman', threshold=0.9)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f68a5db8-fcbe-4762-83fe-255a19a3ccc8", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "analytics.analyze_correlations_between(df, col_pattern1=\"generated.\", col_pattern2=\"used.\", threshold=0.5)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "43601683-a091-412a-bbc6-e66f78546fc9", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "analytics.analyze_correlations_used_vs_generated(df, threshold=0.8)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "01ea1a46-7fe7-4334-b546-b23943af98e4", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "analytics.analyze_correlations_used_vs_telemetry_diff(df, threshold=0.8)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "5b3c1356-6209-4d92-b87f-d84f00b20041", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "analytics.analyze_correlations_generated_vs_telemetry_diff(df, threshold=0.8)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bae58142-3f70-4df8-a57a-4f75eef0cca8", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "analytics.describe_col(df, col='generated.loss')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f9a5bdd4-b5ed-441d-9b96-bdd57fe89bd6", - "metadata": {}, - "outputs": [], - "source": [ - "analytics.describe_cols(df, cols=['generated.loss','generated.responsible_ai_metadata.params'], col_labels=['Loss', '#Params'])" - ] - }, - { - "cell_type": "markdown", - "id": "e79ddb7e-d5c4-4315-8a08-cf2787f12b89", - "metadata": { - "tags": [] - }, - "source": [ - "## Plots" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c369915d-b12d-4bf7-b0f4-02e5b5be8a9b", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "_filter = {\n", - " \"workflow_id\": wf_id\n", - "}\n", - "df = query_api.df_query(_filter, calculate_telemetry_diff=True, clean_dataframe=True, sum_lists=True, aggregate_telemetry=True)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e765a93d-a005-4d77-9d42-4acde84bb72a", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "flow_plot.heatmap(df)" - ] - }, - { - "cell_type": "markdown", - "id": "07bbeb1b-c2ff-4d8c-8ef8-f6fc475c967d", - "metadata": {}, - "source": [ - "## Plotting relevant 'candidates' and comparing it with the `query_api.df_get_tasks_quantiles` function. " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4613ace3-ab5a-4553-9629-ac94daa30c0f", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "df.to_csv('sample_data.csv')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6bb073fa-6e17-403e-8c9f-3884b86119f5", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "x_col = 'generated.loss'\n", - "y_col = 'telemetry_diff.cpu.times_avg.user'\n", - "color_col = 'generated.responsible_ai_metadata.params'\n", - "flow_plot.scatter2d_with_colors(df,\n", - " x_col='generated.loss',\n", - " y_col='telemetry_diff.cpu.times_avg.user',\n", - " color_col='generated.responsible_ai_metadata.params',\n", - " x_label='Loss',\n", - " y_label='User CPU', \n", - " color_label='#Params',\n", - " xaxis_title='Loss',\n", - " yaxis_title='User CPU',\n", - " plot_horizon_line=True,\n", - " horizon_quantile=0.5,\n", - " plot_pareto=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cf639f68-00e7-4f1f-924e-e22f08c61dd9", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "clauses = [\n", - " (y_col, \"<=\", 0.5),\n", - "]\n", - "sort = [\n", - " (y_col, TaskQueryAPI.ASC),\n", - " (x_col, TaskQueryAPI.ASC),\n", - " (color_col, TaskQueryAPI.ASC),\n", - "]\n", - "df = query_api.df_get_tasks_quantiles(\n", - " clauses=clauses,\n", - " filter=_filter,\n", - " sort=sort,\n", - " calculate_telemetry_diff=True,\n", - ")\n", - "df[['task_id', x_col, y_col, color_col]]" - ] - }, - { - "cell_type": "markdown", - "id": "b3b52f74-10fa-45bb-bf4d-dad73824d2db", - "metadata": { - "tags": [] - }, - "source": [ - "### Show everything we captured about that 'good' candidate, highlighted in the pareto front blue dot in the plot above." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6ff83981-3a4c-4d26-a3ca-5a9c3649917c", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "df.query(f\"task_id == '{df.head(1)['task_id'].values[0]}'\") " - ] - }, - { - "cell_type": "markdown", - "id": "f3d3b02c-5c73-484b-a1c1-1961c812bc24", - "metadata": {}, - "source": [ - "### Find Interesting Tasks with data that are sensitve according to correlations" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b2ea6476-2eb3-4990-bf85-4858901c4422", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "result = query_api.find_interesting_tasks_based_on_correlations_generated_and_telemetry_data(filter=_filter)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "62597c13-f075-45e2-a0bb-9f224bdfa20e", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "result.items()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "130eb28b-6c37-437e-8397-3f3471bc93ac", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "# task_id, res = next(iter(result.items()))\n", - "# res" - ] - }, - { - "cell_type": "markdown", - "id": "2db5fa87-3fd0-4b2d-85b5-35a670e0756a", - "metadata": {}, - "source": [ - "### Finding Tasks with Outlier Data" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6f827807-2c08-4112-b9e2-789617e8b2c3", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "tasks_with_outliers = query_api.df_find_outliers(\n", - " outlier_threshold=5,\n", - " calculate_telemetry_diff=True,\n", - " filter=_filter,\n", - " clean_dataframe=True,\n", - " keep_task_id=True\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bb1d501b-5322-4c54-a72a-46c8e82f51eb", - "metadata": { - "tags": [] - }, - "outputs": [], - "source": [ - "selected_columns = set(tasks_with_outliers['outlier_columns'].explode())\n", - "selected_columns.add(\"task_id\")\n", - "selected_columns.add(\"outlier_columns\")\n", - "result_df = tasks_with_outliers.loc[:, tasks_with_outliers.columns.isin(selected_columns)]\n", - "result_df" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.10.15" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/mlflow.ipynb b/notebooks/mlflow.ipynb index beef31cb..773ac29a 100644 --- a/notebooks/mlflow.ipynb +++ b/notebooks/mlflow.ipynb @@ -170,8 +170,8 @@ "metadata": {}, "outputs": [], "source": [ - "from flowcept import TaskQueryAPI\n", - "query_api = TaskQueryAPI()" + "from flowcept import Flowcept\n", + "query_api = Flowcept.db" ] }, { diff --git a/notebooks/tensorboard.ipynb b/notebooks/tensorboard.ipynb index 0094deea..10141497 100644 --- a/notebooks/tensorboard.ipynb +++ b/notebooks/tensorboard.ipynb @@ -237,9 +237,9 @@ }, "outputs": [], "source": [ - "from flowcept import TaskQueryAPI\n", + "from flowcept import Flowcept\n", "from flowcept.commons.utils import get_utc_minutes_ago\n", - "query_api = TaskQueryAPI()" + "query_api = Flowcept.db" ] }, { diff --git a/pyproject.toml b/pyproject.toml index 6af914b5..326ef45b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,10 +55,8 @@ redis = ["redis<8"] lmdb = ["lmdb"] telemetry = ["psutil>=6.1.1", "py-cpuinfo"] extras = ["flowcept[redis]", "flowcept[telemetry]", "flowcept[mongo]", "GitPython", "pandas", "pyarrow", "requests", "rich"] -webservice = ["fastapi", "uvicorn", "pyyaml"] -legacy_webservice = ["flask-restful"] +webservice = ["fastapi", "uvicorn", "pyyaml", "sse-starlette"] -analytics = ["seaborn", "plotly", "scipy", "matplotlib"] report_pdf = ["matplotlib", "reportlab", "networkx"] mongo = ["pymongo", "pyarrow"] dask = ["tomli", "dask[distributed]<=2024.10.0"] @@ -86,8 +84,10 @@ dev = [ "pytest", "pytest-timeout", "ruff", - "pyyaml" + "pyyaml", + "httpx2" ] + # Torch and some other ML-specific libs are only used for dev/test workflows. ml_dev = [ "torch>=2.6.0", @@ -103,7 +103,6 @@ all = [ "flowcept[redis]", "flowcept[lmdb]", "flowcept[mongo]", - "flowcept[analytics]", "flowcept[dask]", "flowcept[kafka]", "flowcept[mlflow]", @@ -130,6 +129,8 @@ convention = "numpy" [tool.hatch.build.targets.wheel] packages = ["src/flowcept"] +# Built web UI assets (created by `make ui-build` into the package dir); gitignored but shipped. +artifacts = ["src/flowcept/webservice/ui_build/**"] [tool.hatch.build.targets.wheel.force-include] "resources/sample_settings.yaml" = "resources/sample_settings.yaml" diff --git a/resources/sample_settings.yaml b/resources/sample_settings.yaml index 01600c0e..94963b1e 100644 --- a/resources/sample_settings.yaml +++ b/resources/sample_settings.yaml @@ -1,4 +1,4 @@ -flowcept_version: 0.10.5 # Version of the Flowcept package. Do not update this manually. The CI updates it. This setting file is compatible with this version. +flowcept_version: 0.10.8 # Version of the Flowcept package. Do not update this manually. The CI updates it. This setting file is compatible with this version. project: debug: true # Toggle debug mode. This will add a property `debug: true` to all saved data, making it easier to retrieve/delete them later. @@ -68,8 +68,14 @@ kv_db: # uri: use Redis connection uri here web_server: - host: 0.0.0.0 - port: 5000 + host: 127.0.0.1 # Bind address; use 0.0.0.0 to expose on all interfaces. Env: WEBSERVER_HOST. + port: 8008 # HTTP port for the FastAPI webservice and built UI. Env: WEBSERVER_PORT. + ui_enabled: true # Serve the built web UI (if assets are present) at the root path. + cors_origins: [] # Extra allowed CORS origins; empty disables the CORS middleware (UI is same-origin). + sse_poll_interval_sec: 2.0 # How often live (SSE) endpoints poll the DB for new data. + sse_max_batch: 500 # Max records pushed per SSE event. + dashboards_dir: ~/.flowcept/dashboards # Dashboard JSON storage when MongoDB is disabled. + max_label_length: 30 # Limit for graph labels. Fall back to inputs/outputs sequential names if exceeded. sys_metadata: environment_id: "laptop" # We use this to keep track of the environment used to run an experiment. Typical values include the cluster name, but it can be anything that you think will help identify your experimentation environment. @@ -77,11 +83,6 @@ sys_metadata: extra_metadata: # We use this to store any extra metadata you want to keep track of during an experiment. place_holder: "" -analytics: - sort_orders: - generated.loss: minimum_first - generated.accuracy: maximum_first - db_buffer: insertion_buffer_time_secs: 5 # Time interval (in seconds) to buffer incoming records before flushing to the database buffer_size: 50 # Maximum number of records to hold in the buffer before forcing a flush @@ -103,6 +104,9 @@ agent: audio_enabled: false debug: true start_persistence: false + chat_enabled: true # Enable the /api/v1/chat endpoint (requires the llm_agent extra and LLM settings above). + chat_max_tool_iterations: 5 # Max LLM tool-calling iterations per chat message. + chat_max_query_limit: 1000 # Hard cap for records returned by chat LLM query tools. databases: diff --git a/src/flowcept/README.md b/src/flowcept/README.md index 1533b30d..5317e89b 100644 --- a/src/flowcept/README.md +++ b/src/flowcept/README.md @@ -18,7 +18,6 @@ This directory contains the runtime package. Use this README as a code-orientati - `agents/`: MCP agent server/client/tools/prompts. - `report/`: workflow card and PDF report generation. - `webservice/`: FastAPI read-only REST API. -- `analytics/`: plotting and analysis helpers. ## Code Rules diff --git a/src/flowcept/__init__.py b/src/flowcept/__init__.py index 06508002..4f43f2f6 100644 --- a/src/flowcept/__init__.py +++ b/src/flowcept/__init__.py @@ -26,6 +26,11 @@ def __getattr__(name): return BlobObject + elif name == "AgentObject": + from flowcept.commons.flowcept_dataclasses.agent_object import AgentObject + + return AgentObject + elif name == "flowcept_task": from flowcept.instrumentation.flowcept_task import flowcept_task @@ -72,16 +77,11 @@ def __getattr__(name): from flowcept.configs import SETTINGS_PATH return SETTINGS_PATH - elif name == "TaskQueryAPI": - from flowcept.flowcept_api.task_query_api import TaskQueryAPI - - return TaskQueryAPI raise AttributeError(f"module '{__name__}' has no attribute '{name}'") __all__ = [ "FlowceptDaskWorkerAdapter", - "TaskQueryAPI", "flowcept_task", "FlowceptLoop", "FlowceptLightweightLoop", @@ -92,6 +92,7 @@ def __getattr__(name): "flowcept_torch", "WorkflowObject", "BlobObject", + "AgentObject", "__version__", "SETTINGS_PATH", ] diff --git a/src/flowcept/agents/__init__.py b/src/flowcept/agents/__init__.py index df8dd9ff..f24686ac 100644 --- a/src/flowcept/agents/__init__.py +++ b/src/flowcept/agents/__init__.py @@ -3,4 +3,5 @@ from flowcept.agents.tools.general_tools import * from flowcept.agents.tools.in_memory_queries.in_memory_queries_tools import * +from flowcept.agents.tools.db_prov_tools import * from flowcept.agents.tools.workflow_query_tools import * diff --git a/src/flowcept/agents/agents_utils.py b/src/flowcept/agents/agents_utils.py index dbd031e9..ae6c3e7f 100644 --- a/src/flowcept/agents/agents_utils.py +++ b/src/flowcept/agents/agents_utils.py @@ -124,7 +124,7 @@ def build_llm_model( LLM An initialized LLM object configured using the `AGENT` settings. """ - _model_kwargs = AGENT.get("model_kwargs", {}).copy() + _model_kwargs = (AGENT.get("model_kwargs") or {}).copy() if model_kwargs is not None: for k in model_kwargs: _model_kwargs[k] = model_kwargs[k] diff --git a/src/flowcept/agents/flowcept_agent.py b/src/flowcept/agents/flowcept_agent.py index 694ae605..5de2481a 100644 --- a/src/flowcept/agents/flowcept_agent.py +++ b/src/flowcept/agents/flowcept_agent.py @@ -90,6 +90,14 @@ def _load_buffer_once(self) -> int: def _run_server(self): """Run the MCP server (blocking call).""" + try: + # sse-starlette keeps a module-level exit Event bound to the first event loop that + # served SSE; reset it so this server's fresh loop can serve SSE in the same process. + from sse_starlette.sse import AppStatus + + AppStatus.should_exit_event = None + except ImportError: + pass config = uvicorn.Config(mcp_flowcept.streamable_http_app, host=AGENT_HOST, port=AGENT_PORT, lifespan="on") self._server = uvicorn.Server(config) self._server.run() @@ -109,17 +117,24 @@ def start(self): else: self._load_buffer_once() - self._server_thread = Thread(target=self._run_server, daemon=False) + # Daemon thread so the hosting process can always exit (e.g., test runners); + # long-running deployments block explicitly via wait(). + self._server_thread = Thread(target=self._run_server, daemon=True) self._server_thread.start() self.logger.info(f"Flowcept agent server started on {AGENT_HOST}:{AGENT_PORT}") return self def stop(self): """Stop the agent server and wait briefly for shutdown.""" + if self._server is None and self._server_thread is not None: + # The server object is created inside the thread; give it a moment to appear. + self._server_thread.join(timeout=1) if self._server is not None: self._server.should_exit = True if self._server_thread is not None: self._server_thread.join(timeout=5) + if self._server_thread.is_alive(): + self.logger.warning("Agent server thread did not stop within 5s; continuing shutdown.") def wait(self): """Block until the server thread exits.""" diff --git a/src/flowcept/agents/prompts/chat_prompts.py b/src/flowcept/agents/prompts/chat_prompts.py new file mode 100644 index 00000000..95ef8ecd --- /dev/null +++ b/src/flowcept/agents/prompts/chat_prompts.py @@ -0,0 +1,33 @@ +"""System prompt for the webservice provenance chat.""" + +CHAT_SYSTEM_PROMPT = """You are the Flowcept provenance assistant, embedded in Flowcept's web UI. +Flowcept captures workflow provenance: campaigns group workflows; workflows contain tasks; +tasks record used (inputs), generated (outputs), status, timings, telemetry, and host info; +binary artifacts (datasets, ML models) are stored as versioned objects. + +Key task fields: task_id, workflow_id, campaign_id, activity_id (function name), status +(FINISHED/ERROR/RUNNING), started_at, ended_at, used.*, generated.*, telemetry_at_start/end +(cpu, memory, disk, network, process, gpu), hostname, agent_id, tags. +Key workflow fields: workflow_id, name, campaign_id, user, utc_timestamp. + +You have tools to query this data. Rules: +- Use the tools to answer data questions; never invent values. Quote real numbers from results. +- Filters are Mongo-style; allowed operators: $and $or $nor $not $exists $eq $ne $gt $gte $lt + $lte $in $nin $regex. +- When the user context includes workflow_id/campaign_id, scope your queries with it. +- Prefer get_task_summary for aggregate questions (counts, durations) over fetching all tasks. +- When asked for a chart/plot, call make_chart with a declarative chart spec: + {"chart_id": "", "type": "chart", "title": "...", + "data": {"source": "tasks", "filter": {...}, "group_by": "", + "metrics": [{"field": "", "agg": "avg|sum|min|max|count"}] + OR "x": "started_at", "y": ["telemetry_at_end.cpu.percent_all"]}, + "viz": {"kind": "bar|line|pie|scatter|area"}} + The UI renders the chart from the tool result; afterwards summarize the insight in one or + two sentences. +- To modify the user's dashboard (only when asked), call get_dashboard, then update_dashboard + with the complete revised spec; explain what changed. +- When the user asks to highlight, trace, show, or visualise the lineage/ancestors/descendants + of a task, ALWAYS call highlight_lineage. Pass task_ids directly when given, or use filter to + find the seed tasks first. The UI will visually dim all unrelated nodes in the Dataflow graph. +- Be concise. Use markdown tables for tabular answers. State filters you used. +""" diff --git a/src/flowcept/agents/tools/db_prov_tools.py b/src/flowcept/agents/tools/db_prov_tools.py new file mode 100644 index 00000000..e81f8bb9 --- /dev/null +++ b/src/flowcept/agents/tools/db_prov_tools.py @@ -0,0 +1,47 @@ +"""MCP adapters exposing the shared provenance tool core to external agent clients. + +Thin ``@mcp.tool`` wrappers around :mod:`flowcept.agents.tools.prov_tools`, giving MCP +clients (Claude Code, Codex, etc.) real DB-backed provenance querying — the same tool +core used by the webservice chat. +""" + +from typing import Any, Dict, List, Optional + +from flowcept.agents.agents_utils import ToolResult +from flowcept.agents.flowcept_ctx_manager import mcp_flowcept +from flowcept.agents.tools import prov_tools + + +@mcp_flowcept.tool() +def query_provenance_tasks( + filter: Optional[Dict[str, Any]] = None, + projection: Optional[List[str]] = None, + limit: int = 100, + sort: Optional[List[Dict[str, Any]]] = None, +) -> ToolResult: + """Query task provenance records in the database with a Mongo-style filter.""" + return prov_tools.query_tasks(filter=filter, projection=projection, limit=limit, sort=sort) + + +@mcp_flowcept.tool() +def query_provenance_workflows(filter: Optional[Dict[str, Any]] = None, limit: int = 100) -> ToolResult: + """Query workflow provenance records in the database with a Mongo-style filter.""" + return prov_tools.query_workflows(filter=filter, limit=limit) + + +@mcp_flowcept.tool() +def get_provenance_task_summary(filter: Optional[Dict[str, Any]] = None) -> ToolResult: + """Summarize tasks matching a filter: status counts, per-activity durations, time range.""" + return prov_tools.get_task_summary(filter=filter) + + +@mcp_flowcept.tool() +def list_provenance_campaigns() -> ToolResult: + """List derived campaign summaries (campaigns group workflows and tasks).""" + return prov_tools.list_campaigns() + + +@mcp_flowcept.tool() +def list_provenance_agents() -> ToolResult: + """List derived agent summaries (agents observed in task provenance).""" + return prov_tools.list_agents() diff --git a/src/flowcept/agents/tools/prov_tools.py b/src/flowcept/agents/tools/prov_tools.py new file mode 100644 index 00000000..5105c0f1 --- /dev/null +++ b/src/flowcept/agents/tools/prov_tools.py @@ -0,0 +1,320 @@ +"""Shared provenance tool core. + +Plain-Python functions over the provenance DB used by BOTH the webservice chat +(`/api/v1/chat`, via langchain tool wrappers) and the MCP agent (via ``@mcp.tool`` +wrappers), so the two LLM surfaces never drift apart. All results follow the +``ToolResult`` convention. No web-framework imports here. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from datetime import datetime, timezone + +from flowcept.agents.agents_utils import ToolResult +from flowcept.commons.flowcept_logger import FlowceptLogger +from flowcept.configs import AGENT_CHAT_MAX_QUERY_LIMIT +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.schemas.dashboards import DashboardChart, DashboardSpec +from flowcept.webservice.services import stats +from flowcept.webservice.services.dashboard_store import get_dashboard_store +from flowcept.webservice.services.serializers import normalize_docs + +ALLOWED_FILTER_OPERATORS = { + "$and", + "$or", + "$nor", + "$not", + "$exists", + "$eq", + "$ne", + "$gt", + "$gte", + "$lt", + "$lte", + "$in", + "$nin", + "$regex", +} + +MAX_QUERY_LIMIT = AGENT_CHAT_MAX_QUERY_LIMIT + + +def validate_filter(filter_doc: Optional[Dict[str, Any]]) -> None: + """Validate a Mongo-style filter against the safe-operator allowlist. + + Raises + ------ + ValueError + When the filter uses an operator outside the allowlist or has a bad shape. + """ + + def _walk(value: Any) -> None: + if isinstance(value, dict): + for key, item in value.items(): + if key.startswith("$"): + if key not in ALLOWED_FILTER_OPERATORS: + raise ValueError(f"Unsupported filter operator: {key}") + if key in {"$and", "$or", "$nor"} and not isinstance(item, list): + raise ValueError(f"{key} must be a list.") + _walk(item) + elif isinstance(value, list): + for item in value: + _walk(item) + + _walk(filter_doc or {}) + + +def _guarded(tool_name: str): + """Decorator: validate filters, cap limits, and convert errors to ToolResult codes.""" + + def decorator(func): + def wrapper(*args, **kwargs): + try: + if "filter" in kwargs: + validate_filter(kwargs.get("filter")) + if "limit" in kwargs and kwargs["limit"]: + kwargs["limit"] = min(int(kwargs["limit"]), MAX_QUERY_LIMIT) + return func(*args, **kwargs) + except ValueError as e: + return ToolResult(code=400, result=str(e), tool_name=tool_name) + except Exception as e: + FlowceptLogger().exception(e) + return ToolResult(code=499, result=f"Error in {tool_name}: {e}", tool_name=tool_name) + + wrapper.__name__ = func.__name__ + wrapper.__doc__ = func.__doc__ + return wrapper + + return decorator + + +def _normalize(docs: List[Dict]) -> List[Dict]: + return normalize_docs(docs) + + +@_guarded("query_tasks") +def query_tasks( + filter: Optional[Dict[str, Any]] = None, + projection: Optional[List[str]] = None, + limit: int = 100, + sort: Optional[List[Dict[str, Any]]] = None, +) -> ToolResult: + """Query task provenance records with a Mongo-style filter. + + Parameters + ---------- + filter : dict, optional + Mongo-style filter (e.g., ``{"workflow_id": "...", "status": "ERROR"}``). + projection : list of str, optional + Fields to include in results. + limit : int, optional + Maximum records (capped by settings). + sort : list of dict, optional + ``[{"field": "started_at", "order": -1}]``. + + Returns + ------- + ToolResult + ``result`` holds ``{"items": [...], "count": int}``. + """ + sort_tuples = None if not sort else [(s["field"], s["order"]) for s in sort] + docs = DBAPI().task_query(filter=filter or {}, projection=projection, limit=limit, sort=sort_tuples) or [] + items = _normalize(docs) + return ToolResult(code=301, result={"items": items, "count": len(items)}, tool_name="query_tasks") + + +@_guarded("query_workflows") +def query_workflows(filter: Optional[Dict[str, Any]] = None, limit: int = 100) -> ToolResult: + """Query workflow provenance records with a Mongo-style filter. + + Parameters + ---------- + filter : dict, optional + Mongo-style filter (e.g., ``{"campaign_id": "..."}``). + limit : int, optional + Maximum records (capped by settings). + + Returns + ------- + ToolResult + ``result`` holds ``{"items": [...], "count": int}``. + """ + docs = (DBAPI().workflow_query(filter=filter or {}) or [])[:limit] + items = _normalize(docs) + return ToolResult(code=301, result={"items": items, "count": len(items)}, tool_name="query_workflows") + + +@_guarded("get_task_summary") +def get_task_summary(filter: Optional[Dict[str, Any]] = None) -> ToolResult: + """Summarize tasks matching a filter: status counts, per-activity durations, time range. + + Parameters + ---------- + filter : dict, optional + Mongo-style filter over tasks. + + Returns + ------- + ToolResult + ``result`` holds the summary dict. + """ + summary = stats.task_summary(DBAPI(), filter or {}) + return ToolResult(code=301, result=_normalize([summary])[0], tool_name="get_task_summary") + + +@_guarded("list_campaigns") +def list_campaigns() -> ToolResult: + """List derived campaign summaries (campaigns group workflows and tasks). + + Returns + ------- + ToolResult + ``result`` holds ``{"items": [...], "count": int}``. + """ + items = _normalize(stats.derive_campaigns(DBAPI())) + return ToolResult(code=301, result={"items": items, "count": len(items)}, tool_name="list_campaigns") + + +@_guarded("list_agents") +def list_agents() -> ToolResult: + """List derived agent summaries (agents observed in task provenance). + + Returns + ------- + ToolResult + ``result`` holds ``{"items": [...], "count": int}``. + """ + items = _normalize(stats.derive_agents(DBAPI())) + return ToolResult(code=301, result={"items": items, "count": len(items)}, tool_name="list_agents") + + +@_guarded("make_chart") +def make_chart(card_spec: Dict[str, Any], context: Optional[Dict[str, Any]] = None) -> ToolResult: + """Build a dashboard-style chart card: validate the spec and resolve its data rows. + + Parameters + ---------- + card_spec : dict + A dashboard ``DashboardChart`` spec (type chart/metric/table with a ``data`` binding). + context : dict, optional + Extra filter ANDed into the chart data filter (e.g., ``{"workflow_id": "..."}``). + + Returns + ------- + ToolResult + ``result`` holds ``{"chart": , "rows": [...], "count": int}``. + """ + card = DashboardChart(**card_spec) + if card.data is None: + return ToolResult(code=400, result="Chart spec must include a data binding.", tool_name="make_chart") + validate_filter(card.data.filter) + if context: + validate_filter(context) + resolved = stats.resolve_chart_data(DBAPI(), card.data, context=context) + result = {"chart": card.model_dump(), "rows": _normalize(resolved["rows"]), "count": resolved["count"]} + return ToolResult(code=301, result=result, tool_name="make_chart") + + +@_guarded("highlight_lineage") +def highlight_lineage( + task_ids: Optional[List[str]] = None, + filter: Optional[Dict[str, Any]] = None, + workflow_id: Optional[str] = None, +) -> ToolResult: + """Highlight the full provenance lineage of tasks in the Dataflow graph. + + Accepts either explicit ``task_ids`` or a Mongo-style ``filter`` to locate + the tasks of interest. ``workflow_id`` scopes the lineage traversal to one + workflow execution. The result is forwarded to the UI, which visually + highlights the ancestor/descendant chain in the Dataflow tab. + + Parameters + ---------- + task_ids : list of str, optional + Explicit task IDs to highlight. + filter : dict, optional + Mongo-style filter to find the seed tasks when ``task_ids`` is omitted. + workflow_id : str, optional + Workflow execution id — required for lineage traversal. + + Returns + ------- + ToolResult + ``result`` holds ``{"task_ids": [...], "seed_count": int}``. + """ + db = DBAPI() + resolved_ids = list(task_ids or []) + + if not resolved_ids and filter is not None: + scoped = dict(filter) + if workflow_id: + scoped["workflow_id"] = workflow_id + docs = db.task_query(filter=scoped, projection=["task_id"], limit=100) or [] + resolved_ids = [d["task_id"] for d in docs if d.get("task_id")] + + if not resolved_ids: + return ToolResult(code=404, result="No tasks found for the given criteria.", tool_name="highlight_lineage") + + # Return only the seed task IDs. The frontend BFS expands ancestors/descendants + # from these seeds using the dataflow graph — a single source of truth for lineage. + return ToolResult( + code=301, + result={"task_ids": resolved_ids}, + tool_name="highlight_lineage", + ) + + +@_guarded("get_dashboard") +def get_dashboard(dashboard_id: str) -> ToolResult: + """Get a stored dashboard spec by id. + + Parameters + ---------- + dashboard_id : str + Dashboard identifier. + + Returns + ------- + ToolResult + ``result`` holds the dashboard spec dict, or a 404 message. + """ + doc = get_dashboard_store().get(dashboard_id) + if doc is None: + return ToolResult(code=404, result=f"Dashboard not found: {dashboard_id}", tool_name="get_dashboard") + return ToolResult(code=301, result=doc, tool_name="get_dashboard") + + +@_guarded("update_dashboard") +def update_dashboard(dashboard_id: str, spec: Dict[str, Any]) -> ToolResult: + """Replace a stored dashboard spec (validated), preserving id and creation time. + + Parameters + ---------- + dashboard_id : str + Dashboard identifier. + spec : dict + Full replacement ``DashboardSpec``. + + Returns + ------- + ToolResult + ``result`` holds the saved dashboard spec dict. + """ + store = get_dashboard_store() + existing = store.get(dashboard_id) + if existing is None: + return ToolResult(code=404, result=f"Dashboard not found: {dashboard_id}", tool_name="update_dashboard") + validated = DashboardSpec(**spec) + validate_filter(validated.context) + for card in validated.cards: + if card.data is not None: + validate_filter(card.data.filter) + validated.dashboard_id = dashboard_id + validated.created_at = existing.get("created_at") + validated.updated_at = datetime.now(timezone.utc).isoformat() + doc = validated.model_dump() + if not store.save(doc): + return ToolResult(code=500, result="Could not save dashboard.", tool_name="update_dashboard") + return ToolResult(code=301, result=doc, tool_name="update_dashboard") diff --git a/src/flowcept/analytics/__init__.py b/src/flowcept/analytics/__init__.py deleted file mode 100644 index f4be6deb..00000000 --- a/src/flowcept/analytics/__init__.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Analytics subpackage.""" - -from flowcept.analytics.analytics_utils import ( - clean_dataframe, - analyze_correlations_used_vs_generated, - analyze_correlations, - analyze_correlations_used_vs_telemetry_diff, - analyze_correlations_generated_vs_telemetry_diff, - analyze_correlations_between, - describe_col, - describe_cols, -) - -__all__ = [ - "clean_dataframe", - "analyze_correlations_used_vs_generated", - "analyze_correlations", - "analyze_correlations_generated_vs_telemetry_diff", - "analyze_correlations_used_vs_telemetry_diff", - "analyze_correlations_between", - "describe_col", - "describe_cols", -] diff --git a/src/flowcept/analytics/analytics_utils.py b/src/flowcept/analytics/analytics_utils.py deleted file mode 100644 index efe6969f..00000000 --- a/src/flowcept/analytics/analytics_utils.py +++ /dev/null @@ -1,271 +0,0 @@ -"""Analytics utility module.""" - -import logging -import numbers -import numpy as np -import pandas as pd - -pd.options.mode.chained_assignment = None - -_CORRELATION_DF_HEADER = ["col_1", "col_2", "correlation"] - - -def is_list(val): - """Check if list.""" - if type(val) in {list, np.array, pd.Series}: - return True - return False - - -def flatten_list_with_sum(val): - """Flatten list with sum.""" - _sum = 0 - if is_list(val): - for el in val: - if is_list(el): - _sum += flatten_list_with_sum(el) - else: - if isinstance(el, numbers.Number) and not np.isnan(el): - _sum += el - else: - _sum += val - return _sum - - -def clean_dataframe( - df: pd.DataFrame, - logger: logging.Logger = None, - keep_non_numeric_columns=False, - keep_only_nans_columns=False, - keep_task_id=False, - keep_telemetry_percent_columns=False, - sum_lists=False, - aggregate_telemetry=False, -) -> pd.DataFrame: - """Clean the dataframe. - - :param sum_lists: - :param keep_task_id: - :param keep_only_nans_columns: - :param keep_non_numeric_columns: - :param df: - :param logger: - :param keep_telemetry_percent_columns: - :param aggregate_telemetry: We use some very simplistic forms of aggregations just - to reduce the complexity of the dataframe. Use this feature very carefully as the - aggregation may be misleading. - :return: - """ - has_telemetry_diff_columns = any(col.startswith("telemetry_diff") for col in df.columns) - - logmsg = f"Number of columns originally: {len(df.columns)}" - if logger: - logger.info(logmsg) - else: - print(logmsg) - - regex_str = "used|generated" - if keep_task_id: - regex_str += "|task_id" - if has_telemetry_diff_columns: - regex_str += "|telemetry_diff" - - # Get only the columns of interest for analysis - dfa = df.filter(regex=regex_str) - if keep_task_id: - task_ids = dfa["task_id"] - - if sum_lists: - # Identify the original columns that were lists or lists of lists - list_cols = [col for col in dfa.columns if any(isinstance(val, (list, list)) for val in dfa[col])] - - cols_to_drop = [] - # Apply the function to all columns and create new scalar columns - for col in list_cols: - try: - if "telemetry_diff" in col: - continue - dfa[f"{col}_sum"] = dfa[col].apply(flatten_list_with_sum) - cols_to_drop.append(col) - except Exception as e: - logger.exception(e) - # Apply the function to all columns and create new scalar columns - - # Drop the original columns that were lists or lists of lists - dfa = dfa.drop(columns=cols_to_drop) - - # Select numeric only columns - if not keep_non_numeric_columns: - dfa = dfa.select_dtypes(include=np.number) - - if not keep_only_nans_columns: - dfa = dfa.loc[:, (dfa != 0).any()] - - # Remove duplicate columns - dfa_T = dfa.T - dfa = dfa_T.drop_duplicates(keep="first").T - - if not keep_telemetry_percent_columns and has_telemetry_diff_columns: - cols_to_drop = [col for col in dfa.columns if "percent" in col] - dfa.drop(columns=cols_to_drop, inplace=True) - - if aggregate_telemetry and has_telemetry_diff_columns: - cols_to_drop = [] - - network_cols = [col for col in dfa.columns if col.startswith("telemetry_diff.network")] - dfa["telemetry_diff.network.activity"] = dfa[network_cols].mean(axis=1) - - io_sum_cols = [col for col in dfa.columns if "disk.io_sum" in col] - dfa["telemetry_diff.disk.activity"] = dfa[io_sum_cols].mean(axis=1) - - processes_nums_cols = [col for col in dfa.columns if "telemetry_diff.process.num_" in col] - dfa["telemetry_diff.process.activity"] = dfa[processes_nums_cols].sum(axis=1) - - cols_to_drop.extend(processes_nums_cols) - cols_to_drop.extend(network_cols) - cols_to_drop.extend(io_sum_cols) - - cols_to_drop.extend([col for col in dfa.columns if "disk.io_per_disk" in col]) - - dfa.drop(columns=cols_to_drop, inplace=True) - - # Removing any leftover cols - cols_to_drop = [col for col in dfa.columns if "telemetry_at_start" in col or "telemetry_at_end" in col] - if len(cols_to_drop): - dfa.drop(columns=cols_to_drop, inplace=True) - - if keep_task_id: - dfa["task_id"] = task_ids - - logmsg = f"Number of columns later: {len(dfa.columns)}" - if logger: - logger.info(logmsg) - else: - print(logmsg) - return dfa - - -def analyze_correlations(df, method="kendall", threshold=0): - """Analyze correlations.""" - # Create a mask to select the upper triangle of the correlation matrix - correlation_matrix = df.corr(method=method, numeric_only=True) - mask = correlation_matrix.where(np.triu(np.ones(correlation_matrix.shape), k=1).astype(bool)) - corrs = [] - - # Iterate through the selected upper triangle of the correlation matrix - for i in range(len(mask.columns)): - for j in range(i + 1, len(mask.columns)): - pair = (mask.columns[i], mask.columns[j]) - corr = mask.iloc[i, j] # Get correlation value - if abs(corr) >= threshold and pair[0] != pair[1]: - corrs.append((mask.columns[i], mask.columns[j], round(corr, 2))) - - return pd.DataFrame( - corrs, - columns=_CORRELATION_DF_HEADER, - ) - - -def analyze_correlations_between( - df: pd.DataFrame, - col_pattern1, - col_pattern2, - method="kendall", - threshold=0, -): - """Analyze correlations.""" - corr_df = analyze_correlations(df, method, threshold) - filtered_df = corr_df[ - ( - corr_df[_CORRELATION_DF_HEADER[0]].str.match(col_pattern1) - & corr_df[_CORRELATION_DF_HEADER[1]].str.match(col_pattern2) - ) - | ( - corr_df[_CORRELATION_DF_HEADER[0]].str.match(col_pattern2) - & corr_df[_CORRELATION_DF_HEADER[1]].str.match(col_pattern1) - ) - ] - return filtered_df - - -def analyze_correlations_used_vs_generated(df: pd.DataFrame, method="kendall", threshold=0): - """Analyze correlations.""" - return analyze_correlations_between( - df, - col_pattern1="used[.]", - col_pattern2="generated[.]", - method=method, - threshold=threshold, - ) - - -def analyze_correlations_used_vs_telemetry_diff(df: pd.DataFrame, method="kendall", threshold=0): - """Analyze correlations.""" - return analyze_correlations_between( - df, - col_pattern1="^used[.]*", - col_pattern2="^telemetry_diff[.]*", - method=method, - threshold=threshold, - ) - - -def analyze_correlations_generated_vs_telemetry_diff(df: pd.DataFrame, method="kendall", threshold=0): - """Analyze correlations.""" - return analyze_correlations_between( - df, - col_pattern1="^generated[.]*", - col_pattern2="^telemetry_diff[.]*", - method=method, - threshold=threshold, - ) - - -def format_number(num): - """Format a number.""" - suffixes = ["", "K", "M", "B", "T"] - idx = 0 - while abs(num) >= 1000 and idx < len(suffixes) - 1: - idx += 1 - num /= 1000.0 - formatted = f"{num:.2f}" if num % 1 != 0 else f"{int(num)}" - formatted = formatted.rstrip("0").rstrip(".") if "." in formatted else formatted.rstrip(".") - return f"{formatted}{suffixes[idx]}" - - -def describe_col(df, col, label=None): - """Describe a column.""" - label = col if label is None else label - return { - "label": label, - "mean": format_number(df[col].mean()), - "std": format_number(df[col].std()), - "min": format_number(df[col].min()), - "25%": format_number(df[col].quantile(0.25)), - "50%": format_number(df[col].median()), - "75%": format_number(df[col].quantile(0.75)), - "max": format_number(df[col].max()), - } - - -def describe_cols(df, cols, col_labels): - """Describe columns.""" - return pd.DataFrame([describe_col(df, col, col_label) for col, col_label in zip(cols, col_labels)]) - - -def identify_pareto(df): - """Identify pareto.""" - datav = df.values - pareto = [] - for i, point in enumerate(datav): - if all(np.any(point <= other_point) for other_point in datav[:i]): - pareto.append(point) - return pd.DataFrame(pareto, columns=df.columns) - - -def find_outliers_zscore(row, threshold=3): - """Find outliers.""" - numeric_columns = [col for col, val in row.items() if pd.api.types.is_numeric_dtype(type(val))] - z_scores = np.abs((row[numeric_columns] - row[numeric_columns].mean()) / row[numeric_columns].std()) - outliers_columns = list(z_scores[z_scores > threshold].index) - return outliers_columns diff --git a/src/flowcept/analytics/data_augmentation.py b/src/flowcept/analytics/data_augmentation.py deleted file mode 100644 index d69f5860..00000000 --- a/src/flowcept/analytics/data_augmentation.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Data augmentation module.""" - -from typing import List -import h2o -import numpy as np -import pandas as pd -from h2o.automl import H2OAutoML -from typing_extensions import deprecated - -h2o.init() - - -@deprecated -def train_model( - df, - x_cols: List[str], - y_col: str, - max_models=30, - train_test_split_size=0.8, - seed=1234, -): - """Train model.""" - h2o_df = h2o.H2OFrame(df) - train, test = h2o_df.split_frame(ratios=[train_test_split_size], seed=seed) - aml = H2OAutoML(max_models=max_models, seed=seed) - - aml.train(x=x_cols, y=y_col, training_frame=train) - return aml - - -@deprecated -def augment_df_linearly(df, N, cols_to_augment, seed=1234): - """Linearly augment dataframe.""" - np.random.seed(seed) - new_df = df.copy() - new_df["original"] = 1 - - augmented_data = {} - - # Linearly interpolate values and create new rows with 'original' = False - for col in cols_to_augment: - min_val = df[col].min() - max_val = df[col].max() - new_values = np.random.uniform(min_val, max_val, N) - augmented_data[col] = new_values - - augmented_data["original"] = [0] * N - - appended_df = pd.concat([new_df, pd.DataFrame(augmented_data)], ignore_index=True) - return appended_df - - -@deprecated -def augment_data(df, N, augmentation_model: H2OAutoML, x_cols, y_col): - """Augment data.""" - new_df = augment_df_linearly(df, N, x_cols) - h2odf = h2o.H2OFrame(new_df.loc[new_df["original"] == 0][x_cols]) - h2opred = augmentation_model.predict(h2odf) - pred = np.array(h2opred.as_data_frame()["predict"]) - new_df.loc[new_df["original"] == 0, y_col] = pred - return new_df diff --git a/src/flowcept/analytics/plot.py b/src/flowcept/analytics/plot.py deleted file mode 100644 index 0319eb4e..00000000 --- a/src/flowcept/analytics/plot.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Plot module.""" - -import pandas as pd -import matplotlib.pyplot as plt -import seaborn as sns -import numpy as np -import plotly.graph_objs as go - -from flowcept.analytics.analytics_utils import format_number, identify_pareto - - -def heatmap(df: pd.DataFrame, method="kendall", figsize=(13, 10), heatmap_args={}): - """Heat map plot. - - :param figsize: - :param heatmap_args: Any other argument for the heatmap. - :param df: dataframe to plot the heatmap - :param method: Possible values: 'kendall', 'spearman', 'pearson' - :return: - """ - correlation_matrix = df.corr(method=method) - plt.figure(figsize=figsize) - sns.heatmap( - correlation_matrix, - annot=False, - cmap="coolwarm", - fmt=".1f", - vmin=-1, - vmax=1, - **heatmap_args, - ) - - -# TODO: :idea: consider finding xcol, ycol, color_col automatically based on high -# correlations for eg -def scatter2d_with_colors( - df, - x_col, - y_col, - color_col, - x_label=None, - y_label=None, - color_label=None, - xaxis_title=None, - yaxis_title=None, - plot_horizon_line=True, - horizon_quantile=0.5, - plot_pareto=True, -): - """Scatter 2D plot with colors.""" - x_label = x_col if x_label is None else x_label - y_label = y_col if y_label is None else y_label - color_label = color_col if color_label is None else color_label - - hovertemplate = ( - x_label + ": %{customdata[0]}
" + y_label + ": %{customdata[1]}
" + color_label + ": %{customdata[2]}" - ) - - fig = go.Figure() - fig.add_trace( - go.Scatter( - x=df[x_col], - y=df[y_col], - mode="markers", - name="", - customdata=df[[x_col, y_col, color_col]].map(format_number), - hovertemplate=hovertemplate, - marker=dict( - color=df[color_col], - opacity=0.8, - reversescale=False, - colorscale="reds", - colorbar=dict(orientation="v", title=color_label), - size=5, - ), - ) - ) - - if plot_horizon_line: - k = df[y_col].quantile(horizon_quantile) - y_line = np.linspace(df[x_col].min(), df[x_col].max(), len(df) * 100) - fig.add_trace( - go.Scatter( - x=y_line, - y=[k] * len(y_line), - mode="markers", - marker=dict(size=1, color="darkred", opacity=0.5), - name="", - ) - ) - - if plot_pareto: - pareto_front = identify_pareto(df[[x_col, y_col]]) - fig.add_trace( - go.Scatter( - x=pareto_front[x_col], - y=pareto_front[y_col], - mode="markers", - marker=dict(size=10, color="blue"), - name="Pareto Front", - ) - ) - - fig.update_layout(xaxis_title=xaxis_title, yaxis_title=yaxis_title) - fig.show() diff --git a/src/flowcept/cli.py b/src/flowcept/cli.py index 5d5f5f56..0a1b526b 100644 --- a/src/flowcept/cli.py +++ b/src/flowcept/cli.py @@ -868,20 +868,44 @@ def stop_redis() -> None: print(f"Failed to stop Redis: {e}") -def start_webservice(webservice_host: str = "127.0.0.1", webservice_port: str = "8008"): +def _kill_port(port: int) -> None: + """Kill any process listening on *port* (best-effort, silent on failure).""" + try: + result = subprocess.run( + ["lsof", "-ti", f"tcp:{port}"], + capture_output=True, + text=True, + ) + for pid in result.stdout.split(): + subprocess.run(["kill", pid.strip()], capture_output=True) + if result.stdout.strip(): + import time + + time.sleep(1) + except Exception: + pass + + +def start_webservice(webservice_host: str = None, webservice_port: str = None): """ Start the Flowcept FastAPI webservice locally. + Kills any process already bound to the port before starting. + Host and port default to ``web_server.host``/``web_server.port`` in + settings.yaml (or ``WEBSERVER_HOST``/``WEBSERVER_PORT`` env vars). + Parameters ---------- webservice_host : str, optional - Host interface to bind (default: 127.0.0.1). - webservice_port : int, optional - Port to bind (default: 8008). + Host interface to bind. Defaults to settings.yaml ``web_server.host``. + webservice_port : str, optional + Port to bind. Defaults to settings.yaml ``web_server.port``. """ - host = webservice_host - port = webservice_port + host = webservice_host or configs.WEBSERVER_HOST + port = webservice_port or str(configs.WEBSERVER_PORT) + _kill_port(int(port)) print(f"Starting Flowcept webservice on http://{host}:{port}") + print(f"Web UI: http://{host}:{port}/") print(f"Swagger UI: http://{host}:{port}/docs") print(f"ReDoc: http://{host}:{port}/redoc") print(f"OpenAPI JSON: http://{host}:{port}/openapi.json") @@ -897,6 +921,60 @@ def start_webservice(webservice_host: str = "127.0.0.1", webservice_port: str = uvicorn.run(app, host=host, port=int(port)) +def start_ui( + webservice_host: str = None, + webservice_port: str = None, + ui_dir: str = "ui", +): + """ + Start the Flowcept webservice and the UI dev server together. + + Kills any previously-running webservice or Vite processes first, then + launches the webservice in the background and the Vite dev server in the + foreground (Ctrl+C stops both). + Host and port default to ``web_server.host``/``web_server.port`` in + settings.yaml (or ``WEBSERVER_HOST``/``WEBSERVER_PORT`` env vars). + + Parameters + ---------- + webservice_host : str, optional + Host interface for the webservice. Defaults to settings.yaml ``web_server.host``. + webservice_port : str, optional + Port for the webservice. Defaults to settings.yaml ``web_server.port``. + ui_dir : str, optional + Path to the UI directory containing package.json (default: ui). + """ + import sys + import time + + webservice_host = webservice_host or configs.WEBSERVER_HOST + webservice_port = webservice_port or str(configs.WEBSERVER_PORT) + _kill_port(int(webservice_port)) + subprocess.run(["pkill", "-f", "flowcept.*start-webservice"], capture_output=True) + subprocess.run(["pkill", "-f", "vite"], capture_output=True) + time.sleep(1) + + ws_proc = subprocess.Popen( + [ + sys.executable, + "-m", + "flowcept.cli", + "--start-webservice", + "--webservice-host", + webservice_host, + "--webservice-port", + webservice_port, + ] + ) + print(f"Webservice started (pid {ws_proc.pid}) on http://{webservice_host}:{webservice_port}") + print(f"UI dev server starting at http://localhost:5173 (proxies /api → :{webservice_port})") + try: + subprocess.run(["npm", "run", "dev", "--prefix", ui_dir], check=False) + finally: + ws_proc.terminate() + ws_proc.wait() + + def generate_report( format: str = "markdown", output_path: str = None, @@ -950,11 +1028,12 @@ def generate_report( COMMAND_GROUPS = [ ("Basic Commands", [version, check_services, show_settings, init_settings, start_services, stop_services]), + ("Web Service Commands", [start_webservice, start_ui]), ("Consumption Commands", [start_consumption_services, stop_consumption_services, stream_messages]), ("Database Commands", [workflow_count, query, get_task]), ("Report Commands", [generate_report]), ("Agent Commands", [start_agent, agent_client, start_agent_gui]), - ("External Services", [start_mongo, start_redis, stop_redis, start_webservice]), + ("External Services", [start_mongo, start_redis, stop_redis]), ] COMMANDS = set(f for _, fs in COMMAND_GROUPS for f in fs) diff --git a/src/flowcept/commons/daos/docdb_dao/docdb_dao_base.py b/src/flowcept/commons/daos/docdb_dao/docdb_dao_base.py index fbebd9c0..71b66704 100644 --- a/src/flowcept/commons/daos/docdb_dao/docdb_dao_base.py +++ b/src/flowcept/commons/daos/docdb_dao/docdb_dao_base.py @@ -10,6 +10,7 @@ from flowcept.commons.flowcept_dataclasses.workflow_object import WorkflowObject +from flowcept.commons.flowcept_dataclasses.agent_object import AgentObject from flowcept.configs import MONGO_ENABLED, LMDB_ENABLED @@ -119,6 +120,27 @@ def insert_or_update_workflow(self, wf_obj: WorkflowObject): """ raise NotImplementedError + @abstractmethod + def insert_or_update_agent(self, agent_obj: AgentObject): + """Insert or update an agent object. + + Parameters + ---------- + agent_obj : AgentObject + The agent object to insert or update. + + Raises + ------ + NotImplementedError + This method must be implemented by subclasses. + """ + raise NotImplementedError + + @abstractmethod + def delete_agents_with_filter(self, filter) -> bool: + """Delete agent documents that match the filter.""" + raise NotImplementedError + @abstractmethod def insert_one_task(self, task_dict: Dict): """Insert a single task document. @@ -236,6 +258,30 @@ def workflow_query(self, filter, projection, limit, sort, remove_json_unserializ """ raise NotImplementedError + @abstractmethod + def agent_query(self, filter, projection, limit, sort, remove_json_unserializables): + """Query agent documents. + + Parameters + ---------- + filter : dict + Query filter to apply. + projection : dict + Fields to include or exclude in the results. + limit : int + Maximum number of documents to return. + sort : list + Sorting criteria. + remove_json_unserializables : bool + Whether to remove JSON-unserializable fields from the results. + + Raises + ------ + NotImplementedError + This method must be implemented by subclasses. + """ + raise NotImplementedError + @abstractmethod def object_query(self, filter): """Query objects based on the specified filter. diff --git a/src/flowcept/commons/daos/docdb_dao/lmdb_dao.py b/src/flowcept/commons/daos/docdb_dao/lmdb_dao.py index 0dbdd6ba..85705946 100644 --- a/src/flowcept/commons/daos/docdb_dao/lmdb_dao.py +++ b/src/flowcept/commons/daos/docdb_dao/lmdb_dao.py @@ -10,7 +10,7 @@ import json import pandas as pd -from flowcept import WorkflowObject +from flowcept import WorkflowObject, AgentObject from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO from flowcept.commons.flowcept_logger import FlowceptLogger from flowcept.configs import PERF_LOG, LMDB_SETTINGS @@ -39,11 +39,12 @@ def _open(self): path = LMDB_SETTINGS.get("path", "flowcept_lmdb") handle = LMDBDAO._shared_handles.get(path) if handle is None: - env = lmdb.open(path, map_size=10**12, max_dbs=2) + env = lmdb.open(path, map_size=10**12, max_dbs=4) handle = { "env": env, "tasks_db": env.open_db(b"tasks"), "workflows_db": env.open_db(b"workflows"), + "agents_db": env.open_db(b"agents"), "ref_count": 0, } LMDBDAO._shared_handles[path] = handle @@ -53,6 +54,7 @@ def _open(self): self._env = handle["env"] self._tasks_db = handle["tasks_db"] self._workflows_db = handle["workflows_db"] + self._agents_db = handle["agents_db"] self._initialized = True self._is_closed = False @@ -134,6 +136,30 @@ def insert_or_update_workflow(self, wf_obj: WorkflowObject): self.logger.exception(e) return False + def insert_or_update_agent(self, agent_obj: AgentObject): + """Insert or update an agent document. + + Parameters + ---------- + agent_obj : AgentObject + Agent object to insert or update. + + Returns + ------- + bool + True if the operation succeeds, False otherwise. + """ + try: + _dict = agent_obj.to_dict() + with self._env.begin(write=True, db=self._agents_db) as txn: + key = _dict.get("agent_id").encode() + value = json.dumps(_dict).encode() + txn.put(key, value) + return True + except Exception as e: + self.logger.exception(e) + return False + def delete_task_keys(self, key_name, keys_list: List[str]) -> bool: """Delete task documents by a key value list. @@ -162,6 +188,22 @@ def delete_task_keys(self, key_name, keys_list: List[str]) -> bool: self.logger.exception(e) return False + def delete_agents_with_filter(self, filter) -> bool: + """Delete agent documents that match the specified filter.""" + if self._is_closed: + self._open() + try: + with self._env.begin(write=True, db=self._agents_db) as txn: + cursor = txn.cursor() + for key, value in cursor: + entry = json.loads(value.decode()) + if LMDBDAO._match_filter(entry, filter): + cursor.delete() + return True + except Exception as e: + self.logger.exception(e) + return False + def count_tasks(self) -> int: """Count number of docs in tasks collection.""" if self._is_closed: @@ -205,8 +247,45 @@ def _match_filter(entry, filter): return True for key, value in filter.items(): - if entry.get(key) != value: - return False + if key == "$or": + if not isinstance(value, list) or not any(LMDBDAO._match_filter(entry, clause) for clause in value): + return False + elif key == "$and": + if not isinstance(value, list) or not all(LMDBDAO._match_filter(entry, clause) for clause in value): + return False + elif isinstance(value, dict): + entry_val = entry.get(key) + for op, op_val in value.items(): + if op == "$in": + if not isinstance(op_val, (list, set, tuple)) or entry_val not in op_val: + return False + elif op == "$nin": + if not isinstance(op_val, (list, set, tuple)) or entry_val in op_val: + return False + elif op == "$eq": + if entry_val != op_val: + return False + elif op == "$ne": + if entry_val == op_val: + return False + elif op == "$gt": + if entry_val is None or entry_val <= op_val: + return False + elif op == "$gte": + if entry_val is None or entry_val < op_val: + return False + elif op == "$lt": + if entry_val is None or entry_val >= op_val: + return False + elif op == "$lte": + if entry_val is None or entry_val > op_val: + return False + else: + if entry_val != value: + return False + else: + if entry.get(key) != value: + return False return True def to_df(self, collection="tasks", filter=None) -> pd.DataFrame: @@ -265,6 +344,8 @@ def query( _db = self._tasks_db elif collection == "workflows": _db = self._workflows_db + elif collection == "agents": + _db = self._agents_db else: self.logger.warning(f"LMDB does not support collection '{collection}'. Returning None.") return None @@ -364,6 +445,26 @@ def workflow_query( remove_json_unserializables=remove_json_unserializables, ) + def agent_query( + self, + filter=None, + projection=None, + limit=None, + sort=None, + aggregation=None, + remove_json_unserializables=None, + ): + """Query agents collection in the LMDB database.""" + return self.query( + collection="agents", + filter=filter, + projection=projection, + limit=limit, + sort=sort, + aggregation=aggregation, + remove_json_unserializables=remove_json_unserializables, + ) + def close(self): """Close lmdb.""" if getattr(self, "_initialized"): diff --git a/src/flowcept/commons/daos/docdb_dao/mongodb_dao.py b/src/flowcept/commons/daos/docdb_dao/mongodb_dao.py index 12207dab..920829e5 100644 --- a/src/flowcept/commons/daos/docdb_dao/mongodb_dao.py +++ b/src/flowcept/commons/daos/docdb_dao/mongodb_dao.py @@ -23,6 +23,7 @@ from flowcept.commons.flowcept_dataclasses.workflow_object import ( WorkflowObject, ) +from flowcept.commons.flowcept_dataclasses.agent_object import AgentObject from flowcept.commons.flowcept_logger import FlowceptLogger from flowcept.commons.flowcept_dataclasses.task_object import TaskObject from flowcept.commons.utils import perf_log, get_utc_now_str @@ -85,6 +86,9 @@ def __init__(self, create_indices=MONGO_CREATE_INDEX): self._wfs_collection = self._db["workflows"] self._obj_collection = self._db["objects"] self._obj_history_collection = self._db["object_history"] + self._dashboards_collection = self._db["dashboards"] + self._agents_collection = self._db["agents"] + self._node_positions_collection = self._db["node_positions"] if create_indices: self._create_indices() @@ -110,6 +114,11 @@ def _create_indices(self): if "campaign_id" not in existing_indices: self._wfs_collection.create_index("campaign_id") + # Creating agent collection indices: + existing_indices = [list(x["key"].keys())[0] for x in self._agents_collection.list_indexes()] + if AgentObject.agent_id_field() not in existing_indices: + self._agents_collection.create_index(AgentObject.agent_id_field(), unique=True) + # Creating objects collection indices: existing_indices = [list(x["key"].keys())[0] for x in self._obj_collection.list_indexes()] @@ -134,6 +143,16 @@ def _create_indices(self): if ["created_at"] not in existing_history_indices: self._obj_history_collection.create_index("created_at") + # Creating dashboards collection indices: + existing_indices = [list(x["key"].keys())[0] for x in self._dashboards_collection.list_indexes()] + if "dashboard_id" not in existing_indices: + self._dashboards_collection.create_index("dashboard_id", unique=True) + + # Creating node_positions collection indices: + existing_indices_np = [list(x["key"].keys())[0] for x in self._node_positions_collection.list_indexes()] + if "workflow_id" not in existing_indices_np: + self._node_positions_collection.create_index([("workflow_id", 1), ("graph_type", 1)], unique=True) + def _pipeline( self, filter: Dict = None, @@ -441,6 +460,15 @@ def delete_tasks_with_filter(self, filter) -> bool: self.logger.exception(e) return False + def delete_agents_with_filter(self, filter) -> bool: + """Delete agent documents that match the specified filter.""" + try: + self._agents_collection.delete_many(filter) + return True + except Exception as e: + self.logger.exception(e) + return False + def count_tasks(self) -> int: """Count number of docs in tasks collection.""" try: @@ -465,6 +493,93 @@ def count_objects(self) -> int: self.logger.exception(e) return -1 + def delete_workflow_data(self, workflow_id: str) -> dict: + """Delete all data for one workflow: tasks, objects, workflow doc, and orphaned agents. + + An agent is considered orphaned after this deletion if it has no remaining tasks + in any other workflow. + + Parameters + ---------- + workflow_id : str + The workflow identifier whose data should be deleted. + + Returns + ------- + dict + Per-collection deleted counts: + ``{"workflows": x, "tasks": y, "objects": z, "agents": a}``. + """ + agent_ids = self._tasks_collection.distinct( + "agent_id", {"workflow_id": workflow_id, "agent_id": {"$exists": True}} + ) + tasks_result = self._tasks_collection.delete_many({"workflow_id": workflow_id}) + objects_result = self._obj_collection.delete_many({"workflow_id": workflow_id}) + wfs_result = self._wfs_collection.delete_many({"workflow_id": workflow_id}) + agents_deleted = self._delete_orphaned_agents(agent_ids) + return { + "workflows": wfs_result.deleted_count, + "tasks": tasks_result.deleted_count, + "objects": objects_result.deleted_count, + "agents": agents_deleted, + } + + def _delete_orphaned_agents(self, agent_ids: list) -> int: + """Delete agents from ``agent_ids`` that have no remaining tasks. + + Parameters + ---------- + agent_ids : list + Candidate agent IDs to check and potentially remove. + + Returns + ------- + int + Number of agent documents deleted. + """ + if not agent_ids: + return 0 + orphans = [aid for aid in agent_ids if self._tasks_collection.count_documents({"agent_id": aid}) == 0] + if not orphans: + return 0 + result = self._agents_collection.delete_many({"agent_id": {"$in": orphans}}) + return result.deleted_count + + def delete_campaign_data(self, campaign_id: str) -> dict: + """Delete all data for one campaign: tasks, objects, workflow docs, and orphaned agents. + + An agent is considered orphaned after this deletion if it has no remaining tasks + in any workflow. + + Parameters + ---------- + campaign_id : str + The campaign identifier whose data should be deleted. + + Returns + ------- + dict + Per-collection deleted counts: + ``{"workflows": x, "tasks": y, "objects": z, "agents": a}``. + """ + wf_cursor = self._wfs_collection.find({"campaign_id": campaign_id}, {"workflow_id": 1}) + wf_ids = [doc["workflow_id"] for doc in wf_cursor if "workflow_id" in doc] + if not wf_ids: + return {"workflows": 0, "tasks": 0, "objects": 0, "agents": 0} + agent_ids = self._tasks_collection.distinct( + "agent_id", {"workflow_id": {"$in": wf_ids}, "agent_id": {"$exists": True}} + ) + tasks_result = self._tasks_collection.delete_many({"workflow_id": {"$in": wf_ids}}) + objects_result = self._obj_collection.delete_many({"workflow_id": {"$in": wf_ids}}) + wfs_result = self._wfs_collection.delete_many({"campaign_id": campaign_id}) + agents_deleted = self._delete_orphaned_agents(agent_ids) + return { + "workflows": wfs_result.deleted_count, + "tasks": tasks_result.deleted_count, + "objects": objects_result.deleted_count, + "agents": agents_deleted, + } + @staticmethod def _utc_now(): """Get timezone-aware UTC timestamp.""" @@ -643,6 +758,34 @@ def insert_or_update_workflow(self, workflow_obj: WorkflowObject) -> bool: self.logger.exception(e) return False + def insert_or_update_agent(self, agent_obj: AgentObject) -> bool: + """Insert or update agent.""" + _dict = agent_obj.to_dict().copy() + agent_id = _dict.pop(AgentObject.agent_id_field(), None) + if agent_id is None: + self.logger.exception("The agent identifier cannot be none.") + return False + _filter = {AgentObject.agent_id_field(): agent_id} + update_query = {} + + machine_info = _dict.pop("machine_info", None) + if machine_info is not None: + for k in machine_info: + _dict[f"machine_info.{k}"] = machine_info[k] + + update_query.update( + { + "$set": _dict, + } + ) + + try: + result = self._agents_collection.update_one(_filter, update_query, upsert=True) + return (result.upserted_id is not None) or result.raw_result["updatedExisting"] + except Exception as e: + self.logger.exception(e) + return False + def to_df(self, collection="tasks", filter=None) -> pd.DataFrame: """ Convert the contents of a MongoDB collection to a pandas DataFrame. @@ -1064,9 +1207,12 @@ def query( return self.object_query(filter, projection, limit, sort) elif collection == "object_history": return list(self._obj_history_collection.find(filter)) + elif collection == "agents": + return self.agent_query(filter, projection, limit, sort, remove_json_unserializables) else: raise Exception( - f"You used type={collection}, but MongoDB only stores tasks, workflows, objects, and object_history" + f"You used type={collection}, but MongoDB only stores " + "tasks, workflows, objects, object_history, and agents" ) def raw_task_pipeline(self, pipeline: List[Dict]): @@ -1116,6 +1262,117 @@ def raw_task_pipeline(self, pipeline: List[Dict]): self.logger.exception(e) return None + def raw_pipeline(self, pipeline: List[Dict], collection: str = "tasks"): + """ + Run a raw MongoDB aggregation pipeline on a chosen collection. + + Generalization of :meth:`raw_task_pipeline` for the other collections + (``workflows``, ``objects``, ``object_history``). + + Parameters + ---------- + pipeline : list of dict + A MongoDB aggregation pipeline represented as a list of stage documents. + collection : str, optional + Target collection name. Defaults to ``"tasks"``. + + Returns + ------- + list of dict or None + The aggregation results, or ``None`` if an error occurred. + """ + collections = { + "tasks": self._tasks_collection, + "workflows": self._wfs_collection, + "objects": self._obj_collection, + "object_history": self._obj_history_collection, + } + if collection not in collections: + raise ValueError(f"Unknown collection: {collection}. Expected one of {sorted(collections)}.") + try: + return list(collections[collection].aggregate(pipeline)) + except Exception as e: + self.logger.exception(e) + return None + + def save_dashboard(self, dashboard: Dict) -> bool: + """Insert or replace a dashboard document keyed by ``dashboard_id``. + + Parameters + ---------- + dashboard : dict + Dashboard spec document containing a ``dashboard_id`` field. + + Returns + ------- + bool + True on success, False otherwise. + """ + try: + self._dashboards_collection.replace_one({"dashboard_id": dashboard["dashboard_id"]}, dashboard, upsert=True) + return True + except Exception as e: + self.logger.exception(e) + return False + + def get_dashboard(self, dashboard_id: str) -> Dict: + """Get a dashboard document by id. + + Parameters + ---------- + dashboard_id : str + Dashboard identifier. + + Returns + ------- + dict or None + The dashboard document, or None when not found. + """ + try: + return self._dashboards_collection.find_one({"dashboard_id": dashboard_id}, projection={"_id": 0}) + except Exception as e: + self.logger.exception(e) + return None + + def list_dashboards(self, filter: Dict = None) -> List[Dict]: + """List dashboard documents. + + Parameters + ---------- + filter : dict, optional + Mongo-style filter. Defaults to all dashboards. + + Returns + ------- + list of dict + Matching dashboard documents. + """ + try: + return list(self._dashboards_collection.find(filter or {}, projection={"_id": 0})) + except Exception as e: + self.logger.exception(e) + return None + + def delete_dashboard(self, dashboard_id: str) -> bool: + """Delete a dashboard document by id. + + Parameters + ---------- + dashboard_id : str + Dashboard identifier. + + Returns + ------- + bool + True when a document was deleted, False otherwise. + """ + try: + result = self._dashboards_collection.delete_one({"dashboard_id": dashboard_id}) + return result.deleted_count > 0 + except Exception as e: + self.logger.exception(e) + return False + def task_query( self, filter: Dict = None, @@ -1175,7 +1432,12 @@ def task_query( _projection[proj_field] = 1 if remove_json_unserializables: - _projection.update({"_id": 0, "timestamp": 0}) + # Mongo only allows excluding `_id` inside an inclusion projection; excluding + # other fields (e.g., `timestamp`) is valid only in exclusion-only projections. + _projection.pop("timestamp", None) + _projection["_id"] = 0 + if projection is None: + _projection["timestamp"] = 0 try: rs = self._tasks_collection.find( filter=filter, @@ -1222,6 +1484,35 @@ def workflow_query( self.logger.exception(e) return None + def agent_query( + self, + filter: Dict = None, + projection: List[str] = None, + limit: int = 0, + sort: List[Tuple] = None, + remove_json_unserializables=True, + ) -> List[Dict]: + """Query agents collection in the MongoDB database.""" + _projection = {} + if projection is not None: + for proj_field in projection: + _projection[proj_field] = 1 + + if remove_json_unserializables: + _projection.update({"_id": 0}) + try: + rs = self._agents_collection.find( + filter=filter, + projection=_projection, + limit=limit, + sort=sort, + ) + lst = list(rs) + return lst + except Exception as e: + self.logger.exception(e) + return None + def object_query(self, filter=None, projection=None, limit=0, sort=None) -> List[dict]: """Get objects with optional projection, sort, and limit.""" try: @@ -1477,3 +1768,55 @@ def _get_children_tasks_iterative(self, current_parent, result, max_depth=999, m queue = queue[1:] else: break + + def save_node_positions(self, workflow_id: str, graph_type: str, positions: Dict) -> bool: + """Save or update node positions for a workflow graph type. + + Parameters + ---------- + workflow_id : str + Workflow identifier. + graph_type : str + Graph type: 'dataflow', 'task', or 'activity'. + positions : dict + Dict mapping node IDs to coordinates {"x": float, "y": float}. + + Returns + ------- + bool + True on success, False otherwise. + """ + try: + self._node_positions_collection.replace_one( + {"workflow_id": workflow_id, "graph_type": graph_type}, + {"workflow_id": workflow_id, "graph_type": graph_type, "positions": positions}, + upsert=True, + ) + return True + except Exception as e: + self.logger.exception(e) + return False + + def get_node_positions(self, workflow_id: str, graph_type: str) -> Dict: + """Get node positions for a workflow graph type. + + Parameters + ---------- + workflow_id : str + Workflow identifier. + graph_type : str + Graph type. + + Returns + ------- + dict + Dict mapping node IDs to coordinates. + """ + try: + doc = self._node_positions_collection.find_one( + {"workflow_id": workflow_id, "graph_type": graph_type}, projection={"_id": 0} + ) + return doc.get("positions", {}) if doc else {} + except Exception as e: + self.logger.exception(e) + return {} diff --git a/src/flowcept/commons/flowcept_dataclasses/agent_object.py b/src/flowcept/commons/flowcept_dataclasses/agent_object.py new file mode 100644 index 00000000..367ab6ff --- /dev/null +++ b/src/flowcept/commons/flowcept_dataclasses/agent_object.py @@ -0,0 +1,108 @@ +"""Agent Object module.""" + +from typing import Dict, AnyStr +import msgpack +from omegaconf import OmegaConf, DictConfig + +from flowcept.commons.utils import get_utc_now +from flowcept.commons.sanitization import sanitize_json_like +from flowcept.configs import ( + EXTRA_METADATA, +) + + +class AgentObject: + """Agent object class. + + Represents metadata and provenance details for an agent execution. + """ + + agent_id: AnyStr = None + """Unique identifier for the agent.""" + + name: AnyStr = None + """Descriptive name for the agent.""" + + user: AnyStr = None + """User who launched or owns the agent run.""" + + workflow_id: AnyStr = None + """Workflow identifier associated with the agent.""" + + campaign_id: AnyStr = None + """Campaign identifier associated with the agent.""" + + extra_metadata: Dict = None + """Optional free-form metadata for extensions not covered by other fields.""" + + def __init__(self, agent_id=None, name=None, workflow_id=None, campaign_id=None): + self.agent_id = agent_id + self.name = name + self.workflow_id = workflow_id + self.campaign_id = campaign_id + self.registered_at = get_utc_now() + + @staticmethod + def agent_id_field(): + """Get agent id.""" + return "agent_id" + + @staticmethod + def from_dict(dict_obj: Dict) -> "AgentObject": + """Convert from dictionary.""" + ag_obj = AgentObject() + for k, v in dict_obj.items(): + setattr(ag_obj, k, v) + return ag_obj + + def to_dict(self): + """Convert to dictionary.""" + result_dict = {} + for attr, value in self.__dict__.items(): + if value is not None: + result_dict[attr] = sanitize_json_like(value) if attr == "flowcept_settings" else value + result_dict["type"] = "agent" + return result_dict + + def enrich(self): + """Enrich it.""" + if self.user is None: + from flowcept.configs import LOGIN_NAME, FLOWCEPT_USER + + self.user = LOGIN_NAME or FLOWCEPT_USER + + if self.extra_metadata is None and EXTRA_METADATA is not None: + _extra_metadata = ( + OmegaConf.to_container(EXTRA_METADATA) if isinstance(EXTRA_METADATA, DictConfig) else EXTRA_METADATA + ) + self.extra_metadata = _extra_metadata + + def serialize(self): + """Serialize it.""" + return msgpack.dumps(self.to_dict()) + + @staticmethod + def deserialize(serialized_data) -> "AgentObject": + """Deserialize it.""" + dict_obj = msgpack.loads(serialized_data) + obj = AgentObject() + for k, v in dict_obj.items(): + setattr(obj, k, v) + return obj + + def __repr__(self): + """Set the repr.""" + return ( + f"AgentObject(" + f"agent_id={repr(self.agent_id)}, " + f"name={repr(self.name)}, " + f"workflow_id={repr(self.workflow_id)}, " + f"campaign_id={repr(self.campaign_id)}, " + f"registered_at={repr(self.registered_at)}, " + f"user={repr(self.user)}, " + f"extra_metadata={repr(self.extra_metadata)})" + ) + + def __str__(self): + """Set the string.""" + return self.__repr__() diff --git a/src/flowcept/commons/flowcept_dataclasses/workflow_object.py b/src/flowcept/commons/flowcept_dataclasses/workflow_object.py index a6cca8f0..b203175f 100644 --- a/src/flowcept/commons/flowcept_dataclasses/workflow_object.py +++ b/src/flowcept/commons/flowcept_dataclasses/workflow_object.py @@ -97,6 +97,7 @@ def __init__(self, workflow_id=None, name=None, used=None, generated=None): self.name = name self.used = used self.generated = generated + self.utc_timestamp = get_utc_now() @staticmethod def workflow_id_field(): diff --git a/src/flowcept/commons/vocabulary.py b/src/flowcept/commons/vocabulary.py index ba191b10..dd505753 100644 --- a/src/flowcept/commons/vocabulary.py +++ b/src/flowcept/commons/vocabulary.py @@ -64,3 +64,10 @@ class ML_Types(str, Enum): DATA_PREP = "dataprep" LEARNING = "learning" MODEL_SELECTION = "model_selection" + + +class PROV_AGENT(str, Enum): + """Provenance agent used in Flowcept.""" + + AI_MODEL_INVOCATION = "ai_model_invocation" + AGENT_TOOL = "agent_tool" diff --git a/src/flowcept/configs.py b/src/flowcept/configs.py index 4bb30a15..db9db765 100644 --- a/src/flowcept/configs.py +++ b/src/flowcept/configs.py @@ -18,10 +18,9 @@ "experiment": {}, "mq": {"enabled": False}, "kv_db": {"enabled": False}, - "web_server": {}, + "web_server": {"max_label_length": 30}, "sys_metadata": {}, "extra_metadata": {}, - "analytics": {}, "db_buffer": {}, "databases": {"mongodb": {"enabled": False}, "lmdb": {"enabled": False}}, "adapters": {}, @@ -61,9 +60,9 @@ def _get_env_bool(name: str, default=False) -> bool: SETTINGS_PATH = str(resources.files("resources").joinpath("sample_settings.yaml")) with open(SETTINGS_PATH) as f: - settings = OmegaConf.load(f) + settings = OmegaConf.to_container(OmegaConf.load(f), resolve=True) else: - settings = OmegaConf.load(SETTINGS_PATH) + settings = OmegaConf.to_container(OmegaConf.load(SETTINGS_PATH), resolve=True) # Making sure all settings are in place. keys = DEFAULT_SETTINGS.keys() - settings.keys() @@ -150,6 +149,8 @@ def _get_env_bool(name: str, default=False) -> bool: _lmdb_path_default = LMDB_SETTINGS.get("path", "flowcept_lmdb") LMDB_SETTINGS["path"] = _get_env("LMDB_PATH", _lmdb_path_default) +DBS_ENABLED = MONGO_ENABLED or LMDB_ENABLED + # if not LMDB_ENABLED and not MONGO_ENABLED: # # At least one of these variables need to be enabled. # LMDB_ENABLED = True @@ -252,14 +253,18 @@ def _get_env_bool(name: str, default=False) -> bool: ###################### settings.setdefault("web_server", {}) _webserver_settings = settings.get("web_server", {}) -WEBSERVER_HOST = _webserver_settings.get("host", "0.0.0.0") -WEBSERVER_PORT = int(_webserver_settings.get("port", 5000)) - -###################### -# ANALYTICS # -###################### - -ANALYTICS = settings.get("analytics", None) +WEBSERVER_HOST = _get_env("WEBSERVER_HOST", _webserver_settings.get("host", "127.0.0.1")) +WEBSERVER_PORT = int(_get_env("WEBSERVER_PORT", _webserver_settings.get("port", 8008))) +WEBSERVER_UI_ENABLED = _webserver_settings.get("ui_enabled", True) +WEBSERVER_CORS_ORIGINS = _webserver_settings.get("cors_origins", []) +WEBSERVER_SSE_POLL_INTERVAL = float(_webserver_settings.get("sse_poll_interval_sec", 2.0)) +WEBSERVER_SSE_MAX_BATCH = int(_webserver_settings.get("sse_max_batch", 500)) +WEBSERVER_DASHBOARDS_DIR = os.path.expanduser( + _webserver_settings.get("dashboards_dir", f"~/.{PROJECT_NAME}/dashboards") +) +WEBSERVER_MAX_LABEL_LENGTH = int( + _get_env("WEBSERVER_MAX_LABEL_LENGTH", _webserver_settings.get("max_label_length", 30)) +) #################### # INSTRUMENTATION # @@ -269,6 +274,9 @@ def _get_env_bool(name: str, default=False) -> bool: INSTRUMENTATION_ENABLED = INSTRUMENTATION.get("enabled", True) AGENT = settings.get("agent", {}) +AGENT_CHAT_ENABLED = AGENT.get("chat_enabled", True) +AGENT_CHAT_MAX_TOOL_ITERATIONS = int(AGENT.get("chat_max_tool_iterations", 5)) +AGENT_CHAT_MAX_QUERY_LIMIT = int(AGENT.get("chat_max_query_limit", 1000)) AGENT_AUDIO = _get_env_bool("AGENT_AUDIO", settings["agent"].get("audio_enabled", "false")) AGENT_HOST = _get_env("AGENT_HOST", settings["agent"].get("mcp_host", "localhost")) AGENT_PORT = int(_get_env("AGENT_PORT", settings["agent"].get("mcp_port", "8000"))) diff --git a/src/flowcept/flowcept_api/README.md b/src/flowcept/flowcept_api/README.md index 1488b444..5e6f2df2 100644 --- a/src/flowcept/flowcept_api/README.md +++ b/src/flowcept/flowcept_api/README.md @@ -6,7 +6,6 @@ Public Python-facing control and query layer. - `flowcept_controller.py`: defines `Flowcept`, the main context manager/controller. It starts/stops interceptors, persistence, workflow registration, reports, services, and utility APIs. - `db_api.py`: high-level database API exposed through `Flowcept.db`. It routes task, workflow, and object operations to the configured document DAO. -- `task_query_api.py`: dataframe-oriented task query and analysis helpers. ## Runtime Flow diff --git a/src/flowcept/flowcept_api/db_api.py b/src/flowcept/flowcept_api/db_api.py index aa42ef23..cdf240ef 100644 --- a/src/flowcept/flowcept_api/db_api.py +++ b/src/flowcept/flowcept_api/db_api.py @@ -10,6 +10,7 @@ ) from flowcept.commons.flowcept_dataclasses.task_object import TaskObject from flowcept.commons.flowcept_dataclasses.blob_object import BlobObject +from flowcept.commons.flowcept_dataclasses.agent_object import AgentObject from flowcept.commons.flowcept_logger import FlowceptLogger @@ -109,6 +110,27 @@ def insert_or_update_workflow(self, workflow_obj: WorkflowObject) -> WorkflowObj else: return workflow_obj + def insert_or_update_agent(self, agent_obj: AgentObject) -> AgentObject: + """Insert or update an agent document. + + Parameters + ---------- + agent_obj : AgentObject + Agent object to persist. + + Returns + ------- + AgentObject or None + The persisted agent object, or ``None`` on failure. + """ + self.logger.debug(f"DB API going to save agent {agent_obj}") + ret = DBAPI._dao().insert_or_update_agent(agent_obj) + if not ret: + self.logger.error("Sorry, couldn't update or insert agent.") + return None + else: + return agent_obj + def get_workflow_object(self, workflow_id) -> WorkflowObject: """Get a workflow object by workflow identifier. @@ -123,9 +145,11 @@ def get_workflow_object(self, workflow_id) -> WorkflowObject: Matching workflow object, or ``None`` when not found. """ wfobs = self.workflow_query(filter={WorkflowObject.workflow_id_field(): workflow_id}) - if wfobs is None or len(wfobs) == 0: + if wfobs is None: self.logger.error("Could not retrieve workflow with that filter.") return None + elif len(wfobs) == 0: + return None else: return WorkflowObject.from_dict(wfobs[0]) @@ -148,6 +172,67 @@ def workflow_query(self, filter) -> List[Dict]: return None return results + def get_agent_object(self, agent_id) -> AgentObject: + """Get an agent object by agent identifier. + + Parameters + ---------- + agent_id : str + Agent identifier. + + Returns + ------- + AgentObject or None + Matching agent object, or ``None`` when not found. + """ + agobs = self.agent_query(filter={AgentObject.agent_id_field(): agent_id}) + if agobs is None: + self.logger.error("Could not retrieve agent with that filter.") + return None + elif len(agobs) == 0: + return None + else: + return AgentObject.from_dict(agobs[0]) + + def agent_query(self, filter) -> List[Dict]: + """Query the ``agents`` collection. + + Parameters + ---------- + filter : dict + Mongo/DAO filter expression. + + Returns + ------- + list of dict or None + Matching agent records, or ``None`` on error. + """ + results = self.query(collection="agents", filter=filter) + if results is None: + self.logger.error("Could not retrieve agents with that filter.") + return None + return results + + def delete_agents_with_filter(self, filter) -> bool: + """Delete agents matching the filter. + + Parameters + ---------- + filter : dict + DAO filter expression. + + Returns + ------- + bool + True if successful, False otherwise. + """ + dao = DBAPI._dao() + try: + return dao.delete_agents_with_filter(filter) + except Exception as e: + self.logger.error(f"Could not delete agents with filter {filter}: {e}") + return False + def get_tasks_from_current_workflow(self): """Get tasks belonging to ``Flowcept.current_workflow_id``. @@ -255,7 +340,6 @@ def get_blob_object(self, object_id, version=None) -> BlobObject: obj_doc = None if objs is None or len(objs) == 0 else objs[0] if obj_doc is None: - self.logger.error("Could not retrieve blob object with that filter.") return None return BlobObject.from_dict(obj_doc) @@ -712,10 +796,13 @@ def query( Returns ------- - list of dict or None - Query results from the backend DAO. + list of dict + Query results from the backend DAO; empty list when nothing matches or on error. """ - return DBAPI._dao().query(filter, projection, limit, sort, aggregation, remove_json_unserializables, collection) + result = DBAPI._dao().query( + filter, projection, limit, sort, aggregation, remove_json_unserializables, collection + ) + return result or [] def save_or_update_ml_model( self, @@ -938,3 +1025,45 @@ def load_torch_model(self, model, object_id: str): model._flowcept_model_object = {k: v for k, v in doc.items() if k != "data"} return doc + + def save_node_positions(self, workflow_id: str, graph_type: str, positions: dict) -> bool: + """Save node positions for a workflow graph type. + + Parameters + ---------- + workflow_id : str + Workflow identifier. + graph_type : str + Graph type: 'dataflow', 'task', or 'activity'. + positions : dict + Dict mapping node IDs to coordinates. + + Returns + ------- + bool + True on success, False otherwise. + """ + dao = DBAPI._dao() + if hasattr(dao, "save_node_positions"): + return dao.save_node_positions(workflow_id, graph_type, positions) + return False + + def get_node_positions(self, workflow_id: str, graph_type: str) -> dict: + """Get node positions for a workflow graph type. + + Parameters + ---------- + workflow_id : str + Workflow identifier. + graph_type : str + Graph type. + + Returns + ------- + dict + Dict mapping node IDs to coordinates. + """ + dao = DBAPI._dao() + if hasattr(dao, "get_node_positions"): + return dao.get_node_positions(workflow_id, graph_type) + return {} diff --git a/src/flowcept/flowcept_api/flowcept_controller.py b/src/flowcept/flowcept_api/flowcept_controller.py index fa740435..b6d8458e 100644 --- a/src/flowcept/flowcept_api/flowcept_controller.py +++ b/src/flowcept/flowcept_api/flowcept_controller.py @@ -8,6 +8,7 @@ import flowcept from flowcept.commons.autoflush_buffer import AutoflushBuffer from flowcept.commons.daos.mq_dao.mq_dao_base import MQDao +from flowcept.commons.flowcept_dataclasses.agent_object import AgentObject from flowcept.commons.flowcept_dataclasses.workflow_object import ( WorkflowObject, ) @@ -64,6 +65,7 @@ def __init__( workflow_subtype: str = None, workflow_args: Dict = None, agent_id: str = None, + agent_name: str = None, parent_workflow_id: str = None, start_persistence=True, check_safe_stops=True, # TODO add to docstring @@ -180,6 +182,32 @@ def __init__( self.workflow_args = workflow_args self.parent_workflow_id = parent_workflow_id self.agent_id = agent_id + self.agent_name = agent_name + + if self.agent_id is not None: + from flowcept.commons.flowcept_dataclasses.agent_object import AgentObject + + agent_obj = AgentObject(agent_id=self.agent_id, name=self.agent_name) + agent_obj.enrich() + + from flowcept.configs import MONGO_ENABLED, LMDB_ENABLED + + if MONGO_ENABLED: + from flowcept.commons.daos.docdb_dao.mongodb_dao import MongoDBDAO + + try: + MongoDBDAO().insert_or_update_agent(agent_obj) + except Exception as e: + self.logger.error(f"Error storing agent in MongoDB: {e}") + + if LMDB_ENABLED: + from flowcept.commons.daos.docdb_dao.lmdb_dao import LMDBDAO + + try: + LMDBDAO().insert_or_update_agent(agent_obj) + except Exception as e: + self.logger.error(f"Error storing agent in LMDB: {e}") + should_delete_buffer_file = ( flowcept.configs.DELETE_BUFFER_FILE if delete_buffer_file is None else delete_buffer_file ) @@ -427,6 +455,27 @@ def read_buffer_file( return buffer + def save_agent( + self, + name: str | None = None, + agent_id: str | None = None, + workflow_id: str | None = None, + campaign_id: str | None = None, + ) -> str: + """Register and save an agent associated with the workflow/campaign.""" + agent_obj = AgentObject( + agent_id=agent_id, + name=name, + workflow_id=workflow_id or self.current_workflow_id, + campaign_id=campaign_id or self.campaign_id, + ) + + interceptors = self._interceptor_instances or [] + if not interceptors: + raise Exception("No active interceptors are initialized or registered on this Flowcept instance.") + interceptors[0].send_agent_message(agent_obj) + return agent_obj.agent_id + @staticmethod def generate_report( report_type: str = "workflow_card", diff --git a/src/flowcept/flowcept_api/task_query_api.py b/src/flowcept/flowcept_api/task_query_api.py deleted file mode 100644 index 28b08a1f..00000000 --- a/src/flowcept/flowcept_api/task_query_api.py +++ /dev/null @@ -1,658 +0,0 @@ -"""Task module.""" - -from collections import OrderedDict -from typing import List, Dict, Tuple -from datetime import timedelta -import json -import warnings - -import numpy as np -import pandas as pd -import pymongo -import requests - -from bson.objectid import ObjectId - -from flowcept import Flowcept -from flowcept.analytics.analytics_utils import ( - clean_dataframe as clean_df, - analyze_correlations_between, - find_outliers_zscore, -) -from flowcept.commons.flowcept_logger import FlowceptLogger -from flowcept.commons.query_utils import ( - get_doc_status, - to_datetime, - calculate_telemetry_diff_for_docs, -) -from flowcept.configs import WEBSERVER_HOST, WEBSERVER_PORT, ANALYTICS - - -class TaskQueryAPI(object): - """Task class.""" - - ASC = pymongo.ASCENDING - DESC = pymongo.DESCENDING - MINIMUM_FIRST = ASC - MAXIMUM_FIRST = DESC - - _instance: "TaskQueryAPI" = None - - def __new__(cls, *args, **kwargs) -> "TaskQueryAPI": - """Singleton creator for TaskQueryAPI.""" - if cls._instance is None: - cls._instance = super(TaskQueryAPI, cls).__new__(cls) - return cls._instance - - def __init__( - self, - with_webserver=False, - host: str = WEBSERVER_HOST, - port: int = WEBSERVER_PORT, - auth=None, - ): - if not hasattr(self, "_initialized"): - self._initialized = True - self.logger = FlowceptLogger() - self._with_webserver = with_webserver - if self._with_webserver: - warnings.warn( - "TaskQueryAPI(with_webserver=True) relies on deprecated legacy " - "package flowcept.flowcept_webserver. Use flowcept.webservice " - "(FastAPI) instead.", - DeprecationWarning, - stacklevel=2, - ) - from flowcept.flowcept_webserver.app import BASE_ROUTE - from flowcept.flowcept_webserver.resources.query_rsrc import TaskQuery - - self._host = host - self._port = port - _base_url = f"http://{self._host}:{self._port}" - self._url = f"{_base_url}{BASE_ROUTE}{TaskQuery.ROUTE}" - try: - r = requests.get(_base_url) - if r.status_code > 300: - raise Exception(r.text) - self.logger.debug("Ok, webserver is ready to receive requests.") - except Exception: - raise Exception(f"Error when accessing the webserver at {_base_url}") - - def query( - self, - filter: Dict = None, - projection: List[str] = None, - limit: int = 0, - sort: List[Tuple] = None, - aggregation: List[Tuple] = None, - remove_json_unserializables=True, - ) -> List[Dict]: - """Generate a mongo query pipeline. - Generates a MongoDB query pipeline based on the provided arguments. - - Parameters. - ---------- - filter (dict): - The filter criteria for the $match stage. - projection (list, optional): - List of fields to include in the $project stage. Defaults to None. - limit (int, optional): - The maximum number of documents to return. Defaults to 0 (no limit). - sort (list of tuples, optional): - List of (field, order) tuples specifying the sorting order. Defaults to None. - aggregation (list of tuples, optional): - List of (aggregation_operator, field_name) tuples specifying - additional aggregation operations. Defaults to None. - remove_json_unserializables: - Removes fields that are not JSON serializable. Defaults to True - - Returns - ------- - list: - A list with the result set. - - Example - ------- - Create a pipeline with a filter, projection, sorting, and aggregation. - - rs = find( - filter={"campaign_id": "mycampaign1"}, - projection=["workflow_id", "started_at", "ended_at"], - limit=10, - sort=[("workflow_id", ASC), ("end_time", DESC)], - aggregation=[("avg", "ended_at"), ("min", "started_at")] - ) - """ - if self._with_webserver: - request_data = {"filter": json.dumps(filter)} - if projection: - request_data["projection"] = json.dumps(projection) - if limit: - request_data["limit"] = limit - if sort: - request_data["sort"] = json.dumps(sort) - if aggregation: - request_data["aggregation"] = json.dumps(aggregation) - if remove_json_unserializables: - request_data["remove_json_unserializables"] = remove_json_unserializables - - r = requests.post(self._url, json=request_data) - if 200 <= r.status_code < 300: - return r.json() - else: - raise Exception(r.text) - - else: - db_api = Flowcept.db - docs = db_api.task_query( - filter, - projection, - limit, - sort, - aggregation, - remove_json_unserializables, - ) - if docs is not None: - return docs - else: - self.logger.error("Error when executing query.") - - def get_subworkflows_tasks_from_a_parent_workflow(self, parent_workflow_id: str) -> List[Dict]: - """Get subworkflows.""" - db_api = Flowcept.db - sub_wfs = db_api.workflow_query({"parent_workflow_id": parent_workflow_id}) - if not sub_wfs: - return None - tasks = [] - for sub_wf in sub_wfs: - sub_wf_tasks = self.query({"workflow_id": sub_wf["workflow_id"]}) - tasks.extend(sub_wf_tasks) - return tasks - - def df_query( - self, - filter: Dict = None, - projection: List[str] = None, - limit: int = 0, - sort: List[Tuple] = None, - aggregation: List[Tuple] = None, - remove_json_unserializables=True, - calculate_telemetry_diff=False, - shift_hours: int = 0, - clean_dataframe: bool = False, - keep_non_numeric_columns=False, - keep_only_nans_columns=False, - keep_task_id=False, - keep_telemetry_percent_columns=False, - sum_lists=False, - aggregate_telemetry=False, - ) -> pd.DataFrame: - """Get dataframe query.""" - # TODO: assert that if clean_dataframe is False, other clean_dataframe - # related args should be default. - docs = self.query( - filter, - projection, - limit, - sort, - aggregation, - remove_json_unserializables, - ) - if len(docs) == 0: - return pd.DataFrame() - - df = self._get_dataframe_from_task_docs(docs, calculate_telemetry_diff, shift_hours) - # Clean the telemetry DataFrame if specified - if clean_dataframe: - df = clean_df( - df, - keep_non_numeric_columns=keep_non_numeric_columns, - keep_only_nans_columns=keep_only_nans_columns, - keep_task_id=keep_task_id, - keep_telemetry_percent_columns=keep_telemetry_percent_columns, - sum_lists=sum_lists, - aggregate_telemetry=aggregate_telemetry, - ) - return df - - def _get_dataframe_from_task_docs( - self, - docs: [List[Dict]], - calculate_telemetry_diff=False, - shift_hours=0, - ) -> pd.DataFrame: - if docs is None: - raise Exception("Docs is none in _get_dataframe_from_task_docs") - - if calculate_telemetry_diff: - try: - docs = calculate_telemetry_diff_for_docs(docs) - except Exception as e: - self.logger.exception(e) - - try: - df = pd.json_normalize(docs) - except Exception as e: - self.logger.exception(e) - return None - - try: - df["status"] = df.apply(get_doc_status, axis=1) - except Exception as e: - self.logger.exception(e) - - try: - df = df.drop( - columns=["finished", "error", "running", "submitted"], - errors="ignore", - ) - except Exception as e: - self.logger.exception(e) - - for col in [ - "started_at", - "ended_at", - "submitted_at", - "utc_timestamp", - ]: - to_datetime(self.logger, df, col, shift_hours) - - if "_id" in df.columns: - try: - df["doc_generated_time"] = df["_id"].apply( - lambda _id: ObjectId(_id).generation_time + timedelta(hours=shift_hours) - ) - except Exception as e: - self.logger.info(e) - - try: - df["elapsed_time"] = df["ended_at"] - df["started_at"] - df["elapsed_time"] = df["elapsed_time"].apply( - lambda x: x.total_seconds() if isinstance(x, timedelta) else -1 - ) - except Exception as e: - self.logger.info(e) - - return df - - def get_errored_tasks(self, workflow_id=None, campaign_id=None, filter=None): - """Get errored tasks.""" - # TODO: implement - raise NotImplementedError() - - def get_successful_tasks(self, workflow_id=None, campaign_id=None, filter=None): - """Get successful tasks.""" - # TODO: implement - raise NotImplementedError() - - def df_get_campaign_tasks(self, campaign_id=None, filter=None): - """Get campaign tasks.""" - # TODO: implement - raise NotImplementedError() - - def df_get_top_k_tasks( - self, - sort: List[Tuple] = None, - k: int = 5, - filter: Dict = None, - clean_dataframe: bool = False, - calculate_telemetry_diff: bool = False, - keep_non_numeric_columns=False, - keep_only_nans_columns=False, - keep_task_id=False, - keep_telemetry_percent_columns=False, - sum_lists=False, - aggregate_telemetry=False, - ): - """Get top tasks. - - Retrieve the top K tasks from the (optionally telemetry-aware) - DataFrame based on specified sorting criteria. - - Parameters - ---------- - - sort (List[Tuple], optional): A list of tuples specifying sorting - criteria for columns. Each tuple should contain a column name and a - sorting order, where the sorting order can be TaskQueryAPI.ASC for - ascending or TaskQueryAPI.DESC for descending. - - - k (int, optional): The number of top tasks to retrieve. Defaults to 5. - - - filter (optional): A filter condition to apply to the DataFrame. It - should follow pymongo's query filter syntax. See: - https://www.w3schools.com/python/python_mongodb_query.asp - - - clean_telemetry_dataframe (bool, optional): If True, clean the - DataFrame using the clean_df function. - - - calculate_telemetry_diff (bool, optional): If True, calculate - telemetry differences in the DataFrame. - - Returns - ------- - pandas.DataFrame: A DataFrame containing the top K tasks - based on the specified sorting criteria. - - Raises - ------ - - Exception: If a specified column in the sorting criteria is not - present in the DataFrame. - - - Exception: If an invalid sorting order is provided. Use the - constants TaskQueryAPI.ASC or TaskQueryAPI.DESC. - """ - # Retrieve telemetry DataFrame based on filter and calculation options - df = self.df_query( - filter=filter, - calculate_telemetry_diff=calculate_telemetry_diff, - clean_dataframe=clean_dataframe, - keep_non_numeric_columns=keep_non_numeric_columns, - keep_only_nans_columns=keep_only_nans_columns, - keep_task_id=keep_task_id, - keep_telemetry_percent_columns=keep_telemetry_percent_columns, - sum_lists=sum_lists, - aggregate_telemetry=aggregate_telemetry, - ) - - # Fill NaN values in the DataFrame with np.nan - df.fillna(value=np.nan, inplace=True) - - # Clean the telemetry DataFrame if specified - if clean_dataframe: - df = clean_df( - df, - keep_non_numeric_columns=keep_non_numeric_columns, - keep_only_nans_columns=keep_only_nans_columns, - keep_task_id=keep_task_id, - keep_telemetry_percent_columns=keep_telemetry_percent_columns, - sum_lists=sum_lists, - aggregate_telemetry=aggregate_telemetry, - ) - - # Sorting criteria validation and extraction - sort_col_names, sort_col_orders = [], [] - for col_name, order in sort: - if col_name not in df.columns: - raise Exception( - f"Column {col_name} is not in the dataframe. The available columns are:\n{list(df.columns)}" - ) - if order not in {TaskQueryAPI.ASC, TaskQueryAPI.DESC}: - raise Exception("Use TaskQueryAPI.ASC or TaskQueryAPI.DESC for sorting order.") - - sort_col_names.append(col_name) - sort_col_orders.append((order == TaskQueryAPI.ASC)) - - # Sort the DataFrame based on sorting criteria and retrieve the top K rows - result_df = df.sort_values(by=sort_col_names, ascending=sort_col_orders) - result_df = result_df.head(k) - - return result_df - - def df_get_tasks_quantiles( - self, - clauses: List[Tuple], - filter=None, - sort: List[Tuple] = None, - limit: int = -1, - clean_dataframe=False, - keep_non_numeric_columns=False, - keep_only_nans_columns=False, - keep_task_id=False, - keep_telemetry_percent_columns=False, - sum_lists=False, - aggregate_telemetry=False, - calculate_telemetry_diff=False, - ) -> pd.DataFrame: - """Get tasks. - - # TODO: write docstring - :param calculate_telemetry_diff: - :param clean_dataframe: - :param filter: - :param clauses: (col_name, condition, percentile) - :param sort: (col_name, ASC or DESC) - :param limit: - :return: - """ - # TODO: :idea: think of finding the clauses, quantile threshold, and - # sort order automatically - df = self.df_query( - filter=filter, - calculate_telemetry_diff=calculate_telemetry_diff, - clean_dataframe=clean_dataframe, - keep_non_numeric_columns=keep_non_numeric_columns, - keep_only_nans_columns=keep_only_nans_columns, - keep_task_id=keep_task_id, - keep_telemetry_percent_columns=keep_telemetry_percent_columns, - sum_lists=sum_lists, - aggregate_telemetry=aggregate_telemetry, - ) - df.fillna(value=np.nan, inplace=True) - - query_parts = [] - for col_name, condition, quantile in clauses: - if col_name not in df.columns: - msg = f"Column {col_name} is not in dataframe. " - raise Exception(msg + f"The available columns are:\n{list(df.columns)}") - if 0 > quantile > 1: - raise Exception("Quantile must be 0 < float_number < 1.") - if condition not in {">", "<", ">=", "<=", "==", "!="}: - raise Exception("Wrong query format: " + condition) - quantile_val = df[col_name].quantile(quantile) - query_parts.append(f"`{col_name}` {condition} {quantile_val}") - quantiles_query = " & ".join(query_parts) - self.logger.debug(quantiles_query) - result_df = df.query(quantiles_query) - if len(result_df) == 0: - return result_df - - if sort is not None: - sort_col_names, sort_col_orders = [], [] - for col_name, order in sort: - if col_name not in result_df.columns: - msg = f"Column {col_name} is not in resulting dataframe. " - raise Exception(msg + f"Available columns are:\n{list(result_df.columns)}") - if order not in {TaskQueryAPI.ASC, TaskQueryAPI.DESC}: - raise Exception("Use TaskQueryAPI.ASC or TaskQueryAPI.DESC to express sorting order.") - - sort_col_names.append(col_name) - sort_col_orders.append((order == TaskQueryAPI.ASC)) - - result_df = result_df.sort_values(by=sort_col_names, ascending=sort_col_orders) - - if limit > 0: - result_df = result_df.head(limit) - - return result_df - - def find_interesting_tasks_based_on_correlations_generated_and_telemetry_data( - self, filter=None, correlation_threshold=0.5, top_k=50 - ): - """Find tasks.""" - return self.find_interesting_tasks_based_on_xyz( - filter=filter, - correlation_threshold=correlation_threshold, - top_k=top_k, - ) - - def find_interesting_tasks_based_on_xyz( - self, - pattern_x="^generated[.](?!responsible_ai_metadata[.]).*", # loss, acc - pattern_y="^telemetry_diff[.].*", # telemetry - pattern_z="^generated[.]responsible_ai_metadata[.].*$", # params - filter=None, - correlation_threshold=0.5, - top_k=50, - ): - """Find tasks. - - Returns the most interesting tasks for which (xy) and (xz) are highly - correlated, meaning that y is very senstive to x as well as z is very - sensitive to x. It returns a sorted dict, based on a score calculated - depending on how many high (xy) and (xz) correlations are found. - :param pattern_x: - :param pattern_y: - :param pattern_z: - :param filter: - :param correlation_threshold: - :param top_k: - :return: - """ - self.logger.warning("This is an experimental feature. Use it with carefully!") - # TODO: improve and optmize this function. - df = self.df_query(filter=filter, calculate_telemetry_diff=True) - corr_df1 = analyze_correlations_between(df, pattern_x, pattern_y) - corr_df2 = analyze_correlations_between(df, pattern_x, pattern_z) - - result_df1 = corr_df1[abs(corr_df1["correlation"]) >= correlation_threshold] - result_df1 = result_df1.iloc[result_df1["correlation"].abs().argsort()][::-1].head(top_k) - - result_df2 = corr_df2[abs(corr_df2["correlation"]) >= correlation_threshold] - result_df2 = result_df2.iloc[result_df2["correlation"].abs().argsort()][::-1].head(top_k) - cols = [] - for index, row in result_df1.iterrows(): - x_col = row["col_1"] - y_col = row["col_2"] - x_y_corr = row["correlation"] - - for index2, row2 in result_df2.iterrows(): - # Accessing individual elements in the row - x_col_df2 = row2["col_1"] - z_col = row2["col_2"] - x_z_corr = row2["correlation"] - - if x_col == x_col_df2: - cols.append( - { - "x_col": x_col, - "y_col": y_col, - "z_col": z_col, - "x_y_corr": x_y_corr, - "x_z_corr": x_z_corr, - } - ) - - dfa = pd.DataFrame(cols) - new_rows = [] - - ret = {} - - SORT_ORDERS = ANALYTICS["sort_orders"] - - for index, row in dfa.iterrows(): - clauses = [ - (row["y_col"], "<=", 0.5), - ] - xcol_sort = TaskQueryAPI.MINIMUM_FIRST - if SORT_ORDERS is not None and SORT_ORDERS[row["x_col"]] == "maximum_first": - xcol_sort = TaskQueryAPI.MAXIMUM_FIRST - - sort = [ - (row["y_col"], TaskQueryAPI.MINIMUM_FIRST), # resources - (row["x_col"], xcol_sort), # accuracy - (row["z_col"], TaskQueryAPI.MINIMUM_FIRST), # resp_ai - ] - try: - # TODO: we don't need to query the db again! this is slow!! - df = self.df_get_tasks_quantiles( - limit=1, - clauses=clauses, - filter=filter, - sort=sort, - calculate_telemetry_diff=True, - clean_dataframe=False, - ) - cols_to_proj = [ - "task_id", - row["x_col"], - row["y_col"], - row["z_col"], - ] - _dict = df[cols_to_proj].to_dict(orient="records")[0] - _dict["x_y_corr"] = row["x_y_corr"] - _dict["x_z_corr"] = row["x_z_corr"] - new_rows.append(_dict) - - tid, x_col, y_col, z_col, x, y, z, xy_corr, xz_corr = ( - _dict["task_id"], - row["x_col"], - row["y_col"], - row["z_col"], - _dict[row["x_col"]], - _dict[row["y_col"]], - _dict[row["z_col"]], - row["x_y_corr"], - row["x_z_corr"], - ) - if tid not in ret: - ret[tid] = { - "x_cols": [], - "y_cols": [], - "z_cols": [], - "score": 0, - "n_x_cols": 0, - "n_y_cols": 0, - "n_z_cols": 0, - "data": {}, - } - - if x_col not in ret[tid]["x_cols"]: - ret[tid]["x_cols"].append(x_col) - ret[tid]["n_x_cols"] += 1 - ret[tid]["score"] += 20 - if y_col not in ret[tid]["y_cols"]: - ret[tid]["y_cols"].append(y_col) - ret[tid]["n_y_cols"] += 1 - ret[tid]["score"] += 1 - if z_col not in ret[tid]["z_cols"]: - ret[tid]["z_cols"].append(z_col) - ret[tid]["n_z_cols"] += 1 - ret[tid]["score"] += 10 - - _data = ret[tid]["data"] - if x_col not in _data: - _data[x_col] = {"value": x, "y": [], "z": []} - # TODO: We are repeating values here unnecessarily! - _data[x_col]["y"].append({y_col: y, "xy_corr": xy_corr}) - _data[x_col]["z"].append({z_col: z, "xz_corr": xz_corr}) - except Exception as e: - print(e) - - scores = [] - for tid in ret: - scores.append((tid, ret[tid]["score"])) - sorted_score = sorted(scores, key=lambda _x: _x[1], reverse=True) - - sorted_return = OrderedDict() - for s in sorted_score: - sorted_return[s[0]] = ret[s[0]] - - return sorted_return - - def df_find_outliers( - self, - filter, - outlier_threshold=3, - calculate_telemetry_diff=True, - clean_dataframe: bool = False, - keep_non_numeric_columns=False, - keep_only_nans_columns=False, - keep_task_id=False, - keep_telemetry_percent_columns=False, - sum_lists=False, - aggregate_telemetry=False, - ): - """Find outliers.""" - df = self.df_query( - filter=filter, - calculate_telemetry_diff=calculate_telemetry_diff, - clean_dataframe=clean_dataframe, - keep_non_numeric_columns=keep_non_numeric_columns, - keep_only_nans_columns=keep_only_nans_columns, - keep_task_id=keep_task_id, - keep_telemetry_percent_columns=keep_telemetry_percent_columns, - sum_lists=sum_lists, - aggregate_telemetry=aggregate_telemetry, - ) - df["outlier_columns"] = df.apply(find_outliers_zscore, axis=1, threshold=outlier_threshold) - return df[df["outlier_columns"].apply(len) > 0] diff --git a/src/flowcept/flowcept_webserver/_DEPRECATED.md b/src/flowcept/flowcept_webserver/_DEPRECATED.md deleted file mode 100644 index c3662a49..00000000 --- a/src/flowcept/flowcept_webserver/_DEPRECATED.md +++ /dev/null @@ -1,7 +0,0 @@ -# Deprecated Webserver Package - -This package (`flowcept_webserver`) is legacy and deprecated. - -- New REST endpoints are implemented under `flowcept.webservice` (FastAPI). -- Avoid adding new features here. -- Keep this package only for backward compatibility during migration. diff --git a/src/flowcept/flowcept_webserver/__init__.py b/src/flowcept/flowcept_webserver/__init__.py deleted file mode 100644 index f4186b67..00000000 --- a/src/flowcept/flowcept_webserver/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Legacy webserver subpackage (deprecated).""" diff --git a/src/flowcept/flowcept_webserver/app.py b/src/flowcept/flowcept_webserver/app.py deleted file mode 100644 index 2da59109..00000000 --- a/src/flowcept/flowcept_webserver/app.py +++ /dev/null @@ -1,28 +0,0 @@ -"""App module.""" - -from flask_restful import Api -from flask import Flask - -from flowcept.configs import WEBSERVER_HOST, WEBSERVER_PORT -from flowcept.flowcept_webserver.resources.query_rsrc import TaskQuery -from flowcept.flowcept_webserver.resources.task_messages_rsrc import ( - TaskMessages, -) - - -BASE_ROUTE = "/api" -app = Flask(__name__) -api = Api(app) - -api.add_resource(TaskMessages, f"{BASE_ROUTE}/{TaskMessages.ROUTE}") -api.add_resource(TaskQuery, f"{BASE_ROUTE}/{TaskQuery.ROUTE}") - - -@app.route("/") -def liveness(): - """Livelyness string.""" - return "Server up!" - - -if __name__ == "__main__": - app.run(host=WEBSERVER_HOST, port=WEBSERVER_PORT) diff --git a/src/flowcept/flowcept_webserver/resources/__init__.py b/src/flowcept/flowcept_webserver/resources/__init__.py deleted file mode 100644 index e22c57aa..00000000 --- a/src/flowcept/flowcept_webserver/resources/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Resources subpackage.""" diff --git a/src/flowcept/flowcept_webserver/resources/query_rsrc.py b/src/flowcept/flowcept_webserver/resources/query_rsrc.py deleted file mode 100644 index cd679c07..00000000 --- a/src/flowcept/flowcept_webserver/resources/query_rsrc.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Query resources.""" - -from datetime import datetime -import json -from flask_restful import Resource, reqparse - -from flowcept.commons.daos.docdb_dao.mongodb_dao import MongoDBDAO -from flowcept.commons.utils import datetime_to_str - - -class TaskQuery(Resource): - """TaskQuery class.""" - - ROUTE = "/task_query" - - def post(self): - """Post it.""" - parser = reqparse.RequestParser() - req_args = ["filter", "projection", "sort", "limit", "aggregation"] - for arg in req_args: - parser.add_argument(arg, type=str, required=False, help=arg) - args = parser.parse_args() - - doc_args = {} - for arg in args: - if args[arg] is None: - continue - try: - doc_args[arg] = json.loads(args[arg]) - except Exception as e: - return f"Could not parse {arg} argument: {e}", 400 - - dao = MongoDBDAO() - docs = dao.task_query(**doc_args) - - # Deal with non-serializable datetimes that may come from the databas - for doc in docs: - for key, value in doc.items(): - if isinstance(value, datetime): - doc[key] = datetime_to_str(value) - - if docs is not None and len(docs): - return docs, 201 - else: - return "Could not find matching docs", 404 diff --git a/src/flowcept/flowcept_webserver/resources/task_messages_rsrc.py b/src/flowcept/flowcept_webserver/resources/task_messages_rsrc.py deleted file mode 100644 index 1eff0779..00000000 --- a/src/flowcept/flowcept_webserver/resources/task_messages_rsrc.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Module for TaskMessages object.""" - -from flask import jsonify, request -from flask_restful import Resource - -from flowcept.commons.daos.docdb_dao.mongodb_dao import MongoDBDAO - - -class TaskMessages(Resource): - """TaskMessages class.""" - - ROUTE = "/task_messages" - - def get(self): - """Get something.""" - args = request.args - task_id = args.get("task_id", None) - filter = {} - if task_id is not None: - filter = {"task_id": task_id} - - dao = MongoDBDAO() - docs = dao.task_query(filter) - if len(docs): - return jsonify(docs), 201 - else: - return "No tasks found.", 404 diff --git a/src/flowcept/flowceptor/adapters/base_interceptor.py b/src/flowcept/flowceptor/adapters/base_interceptor.py index c543251c..ff8c94ab 100644 --- a/src/flowcept/flowceptor/adapters/base_interceptor.py +++ b/src/flowcept/flowceptor/adapters/base_interceptor.py @@ -7,6 +7,7 @@ from flowcept.commons.flowcept_dataclasses.workflow_object import ( WorkflowObject, ) +from flowcept.commons.flowcept_dataclasses.agent_object import AgentObject from flowcept.configs import ( ENRICH_MESSAGES, TELEMETRY_ENABLED, @@ -83,6 +84,7 @@ def __init__(self, plugin_key=None, kind=None): self.telemetry_capture = None self._saved_workflows = set() + self._saved_agents = set() self._generated_workflow_id = False self.kind = kind @@ -148,6 +150,20 @@ def send_workflow_message(self, workflow_obj: WorkflowObject): self.intercept(workflow_obj.to_dict()) return wf_id + def send_agent_message(self, agent_obj: AgentObject): + """Send agent.""" + agent_id = agent_obj.agent_id or str(uuid4()) + agent_obj.agent_id = agent_id + if agent_id in self._saved_agents: + return + self._saved_agents.add(agent_id) + if not self._mq_dao.started: + raise Exception(f"This interceptor {id(self)} has never been started!") + if ENRICH_MESSAGES: + agent_obj.enrich() + self.intercept(agent_obj.to_dict()) + return agent_id + def intercept(self, obj_msg: Dict): """Intercept a message.""" self._mq_dao.buffer.append(obj_msg) diff --git a/src/flowcept/flowceptor/consumers/agent/base_agent_context_manager.py b/src/flowcept/flowceptor/consumers/agent/base_agent_context_manager.py index 8ef816ad..ef95b610 100644 --- a/src/flowcept/flowceptor/consumers/agent/base_agent_context_manager.py +++ b/src/flowcept/flowceptor/consumers/agent/base_agent_context_manager.py @@ -124,6 +124,8 @@ async def lifespan(self, app): self.flowcept_instance.logger.info( f"This section's workflow_id={Flowcept.current_workflow_id}, campaign_id={Flowcept.campaign_id}" ) + # Daemon consumer: the agent has no flush-on-stop obligations (persistence is off), + # and a blocked MQ listen must never prevent the hosting process from exiting. self.start(daemon=True) try: diff --git a/src/flowcept/flowceptor/consumers/document_inserter.py b/src/flowcept/flowceptor/consumers/document_inserter.py index 6f427092..fc7e32ef 100644 --- a/src/flowcept/flowceptor/consumers/document_inserter.py +++ b/src/flowcept/flowceptor/consumers/document_inserter.py @@ -12,6 +12,7 @@ from flowcept.commons.flowcept_dataclasses.workflow_object import ( WorkflowObject, ) +from flowcept.commons.flowcept_dataclasses.agent_object import AgentObject from flowcept.commons.flowcept_logger import FlowceptLogger from flowcept.commons.utils import GenericJSONDecoder from flowcept.commons.vocabulary import Status @@ -179,6 +180,15 @@ def _handle_workflow_message(self, message: Dict): for dao in self._doc_daos: dao.insert_or_update_workflow(wf_obj) + def _handle_agent_message(self, message: Dict): + message.pop("type") + self.logger.debug(f"Received following Agent msg in DocInserter:\n\t[BEGIN_MSG]{message}\n[END_MSG]\t") + if REMOVE_EMPTY_FIELDS: + remove_empty_fields_from_dict(message) + agent_obj = AgentObject.from_dict(message) + for dao in self._doc_daos: + dao.insert_or_update_agent(agent_obj) + def _handle_control_message(self, message): self.logger.info(f"I'm doc inserter {id(self)}. I received this control msg received: {message}") if message["info"] == "mq_dao_thread_stopped": @@ -284,6 +294,9 @@ def message_handler(self, msg_obj: Dict): elif msg_type == "workflow": self._handle_workflow_message(msg_obj) return True + elif msg_type == "agent": + self._handle_agent_message(msg_obj) + return True elif msg_type == "object": self.logger.debug("Ignoring object metadata message in DocumentInserter; DBAPI persists objects directly.") return True @@ -292,6 +305,9 @@ def message_handler(self, msg_obj: Dict): if "task_id" in msg_obj or "activity_id" in msg_obj: msg_obj["type"] = "task" self._handle_task_message(msg_obj) + elif "agent_id" in msg_obj: + msg_obj["type"] = "agent" + self._handle_agent_message(msg_obj) elif "name" in msg_obj or "environment_id" in msg_obj: msg_obj["type"] = "workflow" self._handle_workflow_message(msg_obj) diff --git a/src/flowcept/instrumentation/flowcept_task.py b/src/flowcept/instrumentation/flowcept_task.py index 867ea9e0..ff31dd2a 100644 --- a/src/flowcept/instrumentation/flowcept_task.py +++ b/src/flowcept/instrumentation/flowcept_task.py @@ -134,6 +134,10 @@ def decorator(func): tags = decorator_kwargs.get("tags", None) subtype = decorator_kwargs.get("subtype", None) output_names = decorator_kwargs.get("output_names", None) + agent_id = decorator_kwargs.get("agent_id", None) + decorator_kwargs.get("agent_name", None) + source_agent_id = decorator_kwargs.get("source_agent_id", None) + decorator_kwargs.get("source_agent_name", None) # --- shared helpers for sync+async wrappers ------------------------- @@ -160,6 +164,8 @@ def _common_prep(*f_args, **f_kwargs): task_obj.activity_id = func.__name__ task_obj.workflow_id = handled_args.pop("workflow_id", Flowcept.current_workflow_id) task_obj.campaign_id = handled_args.pop("campaign_id", Flowcept.campaign_id) + task_obj.agent_id = handled_args.pop("agent_id", None) or agent_id + task_obj.source_agent_id = handled_args.pop("source_agent_id", source_agent_id) task_obj.used = handled_args task_obj.tags = tags task_obj.started_at = time() @@ -203,6 +209,9 @@ def _attach_outputs(task_obj, result): if isinstance(result, (tuple, list)): if len(output_names) == len(result): named = {k: v for k, v in zip(output_names, result)} + elif len(output_names) == 1: + # single output_name: store the whole collection as-is + named = {output_names[0]: result} elif isinstance(output_names, str): named = {output_names: result} elif isinstance(output_names, (tuple, list)) and len(output_names) == 1: diff --git a/src/flowcept/report/renderers/workflow_card_markdown.py b/src/flowcept/report/renderers/workflow_card_markdown.py index 254eecdf..18b70360 100644 --- a/src/flowcept/report/renderers/workflow_card_markdown.py +++ b/src/flowcept/report/renderers/workflow_card_markdown.py @@ -98,6 +98,53 @@ def _fmt_text(value: Any, default: str = "-") -> str: return text if text else default +def _first_machine_info(workflow: Dict[str, Any]) -> Dict[str, Any]: + """Return the first captured machine_info entry for workflow infrastructure.""" + machine_info = workflow.get("machine_info") + if not isinstance(machine_info, dict): + return {} + if all(key in machine_info for key in ("platform", "cpu", "memory")): + return machine_info + for entry in machine_info.values(): + if isinstance(entry, dict): + return entry + return {} + + +def _derive_host_os(machine_info: Dict[str, Any]) -> Optional[str]: + platform_info = machine_info.get("platform") + if not isinstance(platform_info, dict): + return None + parts = [ + platform_info.get("system"), + platform_info.get("release"), + platform_info.get("machine"), + ] + text = " ".join(str(part) for part in parts if part) + return text or None + + +def _derive_compute_hardware(machine_info: Dict[str, Any]) -> Optional[str]: + parts: List[str] = [] + cpu_info = machine_info.get("cpu") + if isinstance(cpu_info, dict): + cpu_name = cpu_info.get("brand_raw") or cpu_info.get("brand") or cpu_info.get("arch") + cpu_count = cpu_info.get("count") + if cpu_name and cpu_count: + parts.append(f"{cpu_count} CPU cores ({cpu_name})") + elif cpu_name: + parts.append(str(cpu_name)) + memory_info = machine_info.get("memory") + if isinstance(memory_info, dict): + total_mem = _deep_get(memory_info, ["virtual", "total"]) + if as_float(total_mem) is not None: + parts.append(f"{_fmt_bytes(as_float(total_mem))} RAM") + gpu_info = machine_info.get("gpu") + if isinstance(gpu_info, dict) and gpu_info: + parts.append(f"{len(gpu_info)} GPU device(s)") + return "; ".join(parts) if parts else None + + def _fmt_nonzero_seconds(value: Optional[float]) -> str: """Render seconds only when strictly positive.""" if value is None or value <= 0: @@ -124,7 +171,7 @@ def _is_empty_metric(value: Any) -> bool: if value is None: return True if isinstance(value, str): - return value.strip() in {"-", "unknown", "", "- / -", "-/-"} + return value.strip() in {"-", "unknown", "", "- / -", "-/-", "~"} return False @@ -303,18 +350,15 @@ def _build_object_details_lines(objects: List[Dict[str, Any]]) -> List[str]: f"storage=`{_to_str(obj.get('storage_type'), default='-')}`, " f"size=`{_fmt_bytes(as_float(obj.get('object_size_bytes')))}" + "`)" ) - lines.append( - "
" - f"`task_id`: `{_to_str(obj.get('task_id'), default='-')}`; " - f"`workflow_id`: `{_to_str(obj.get('workflow_id'), default='-')}`; " - f"`timestamp`: `{fmt_timestamp_utc(_extract_object_timestamp(obj))}`" - ) - lines.append(f"
`sha256`: `{_to_str(obj.get('data_sha256'), default='-')}`") + lines.append(f" - task_id: `{_to_str(obj.get('task_id'), default='-')}`") + lines.append(f" - workflow_id: `{_to_str(obj.get('workflow_id'), default='-')}`") + lines.append(f" - timestamp: `{fmt_timestamp_utc(_extract_object_timestamp(obj))}`") + lines.append(f" - sha256: `{_to_str(obj.get('data_sha256'), default='-')}`") raw_tags = obj.get("tags") if isinstance(raw_tags, list) and raw_tags: tags_text = ", ".join(str(tag) for tag in raw_tags) - lines.append(f"
`tags`: `{tags_text}`") - lines.append("
`custom_metadata`:") + lines.append(f" - tags: `{tags_text}`") + lines.append(" - custom_metadata:") lines.append(" ```yaml") metadata_lines = _format_nested_metadata_lines(obj.get("custom_metadata", {})) for row in metadata_lines: @@ -1346,37 +1390,50 @@ def render_workflow_card_markdown( lines.append("") # --- Section 2: Summary --- - lines.append("## 2. Summary") - lines.append("") - lines.append(f"- **execution_id:** `{_to_str(workflow.get('workflow_id'), default='~')}`") + summary_lines: List[str] = [] + _append_summary_line(summary_lines, "execution_id", _to_str(workflow.get("workflow_id"), default="~")) if workflow.get("campaign_id") is not None: - lines.append(f"- **campaign_id:** `{_to_str(workflow.get('campaign_id'))}`") - lines.append(f"- **version:** `{_to_str(workflow.get('version'), default='~')}`") - lines.append(f"- **started_at (UTC):** `{fmt_timestamp_utc(min_start) or '~'}`") - lines.append(f"- **ended_at (UTC):** `{fmt_timestamp_utc(max_end) or '~'}`") - lines.append(f"- **duration:** `{_fmt_seconds(total_elapsed)}`") - lines.append(f"- **status:** `{_to_str(workflow.get('status'), default='~')}`") - lines.append(f"- **location:** `{_to_str(workflow.get('sys_name'), default='~')}`") - lines.append(f"- **user:** `{_to_str(workflow.get('user'), default='~')}`") + _append_summary_line(summary_lines, "campaign_id", _to_str(workflow.get("campaign_id"))) + _append_summary_line(summary_lines, "version", _to_str(workflow.get("version"), default="~")) + _append_summary_line(summary_lines, "started_at (UTC)", fmt_timestamp_utc(min_start) or "~") + _append_summary_line(summary_lines, "ended_at (UTC)", fmt_timestamp_utc(max_end) or "~") + _append_summary_line(summary_lines, "duration", _fmt_seconds(total_elapsed)) + _append_summary_line(summary_lines, "status", _to_str(workflow.get("status"), default="~")) + _append_summary_line(summary_lines, "location", _to_str(workflow.get("sys_name"), default="~")) + _append_summary_line(summary_lines, "user", _to_str(workflow.get("user"), default="~")) if workflow.get("subtype") is not None: - lines.append(f"- **Workflow Subtype:** `{_to_str(workflow.get('subtype'))}`") - lines.append(f"- **entrypoint.repository:** `{_to_str(code_repo.get('remote'), default='~')}`") - lines.append(f"- **entrypoint.branch:** `{_to_str(code_repo.get('branch'), default='~')}`") - lines.append(f"- **entrypoint.short_sha:** `{_to_str(code_repo.get('short_sha'), default='~')}`") + _append_summary_line(summary_lines, "Workflow Subtype", _to_str(workflow.get("subtype"))) + _append_summary_line(summary_lines, "entrypoint.repository", _to_str(code_repo.get("remote"), default="~")) + _append_summary_line(summary_lines, "entrypoint.branch", _to_str(code_repo.get("branch"), default="~")) + _append_summary_line(summary_lines, "entrypoint.short_sha", _to_str(code_repo.get("short_sha"), default="~")) if code_repo.get("dirty") is not None: - lines.append(f"- **entrypoint.dirty:** `{_to_str(code_repo.get('dirty'))}`") - lines.append("") + _append_summary_line(summary_lines, "entrypoint.dirty", _to_str(code_repo.get("dirty"))) + if summary_lines: + lines.append("## 2. Summary") + lines.append("") + lines.extend(summary_lines) + lines.append("") # --- Section 3: Infrastructure --- - lines.append("## 3. Infrastructure") - lines.append("") - lines.append(f"- **host_os:** `{_to_str(workflow.get('host_os'), default='~')}`") - lines.append(f"- **compute_hardware:** `{_to_str(workflow.get('compute_hardware'), default='~')}`") - lines.append(f"- **runtime_environment:** `{_to_str(workflow.get('environment_id'), default='~')}`") - lines.append(f"- **resource_manager:** `{_to_str(workflow.get('resource_manager'), default='~')}`") - lines.append(f"- **primary_software:** `{_to_str(workflow.get('primary_software'), default='~')}`") - lines.append(f"- **environment_snapshot:** `{_to_str(workflow.get('environment_snapshot'), default='~')}`") - lines.append("") + machine_info = _first_machine_info(workflow) + infra_lines: List[str] = [] + infra_values = { + "host_os": workflow.get("host_os") or _derive_host_os(machine_info), + "compute_hardware": workflow.get("compute_hardware") or _derive_compute_hardware(machine_info), + "runtime_environment": workflow.get("runtime_environment") or workflow.get("environment_id"), + "resource_manager": workflow.get("resource_manager"), + "primary_software": workflow.get("primary_software") + or f"Flowcept {workflow.get('flowcept_version') or __version__}", + "environment_snapshot": workflow.get("environment_snapshot"), + } + for key, value in infra_values.items(): + if not _is_empty_metric(_to_str(value, default="~")): + infra_lines.append(f"- **{key}:** `{value}`") + if infra_lines: + lines.append("## 3. Infrastructure") + lines.append("") + lines.extend(infra_lines) + lines.append("") # --- Section 4: Workflow Overview --- lines.append("## 4. Workflow Overview") @@ -1405,9 +1462,6 @@ def render_workflow_card_markdown( lines.append(" ```") else: lines.append(f" - `{key}`: `{_format_single_field_value(value)}`") - else: - lines.append("- **arguments:** `~`") - # significant inputs – from workflow.used used_data = workflow.get("used") if isinstance(used_data, dict) and used_data: @@ -1422,8 +1476,6 @@ def render_workflow_card_markdown( lines.append(" ```") else: lines.append(f" - `{key}`: `{_format_single_field_value(value)}`") - else: - lines.append("- **significant inputs:** `~`") # significant outputs – from workflow.generated generated_data = workflow.get("generated") @@ -1439,10 +1491,10 @@ def render_workflow_card_markdown( lines.append(" ```") else: lines.append(f" - `{key}`: `{_format_single_field_value(value)}`") - else: - lines.append("- **significant outputs:** `~`") - lines.append(f"- **observations:** `{_to_str(workflow.get('observations'), default='~')}`") + obs = workflow.get("observations") + if obs: + lines.append(f"- **observations:** `{_format_single_field_value(obs)}`") lines.append("") # 4.2 Workflow Structure @@ -1617,7 +1669,7 @@ def render_workflow_card_markdown( f"- GPU activity detected on `{gpu_device_count}` device(s); peak temperature: `{peak_text}`." ) else: - lines.append("~ *(resource telemetry was not captured)*") + lines.append("*Resource telemetry was not captured.*") lines.append("") lines.append("### 4.4 Observations") @@ -1737,9 +1789,6 @@ def render_workflow_card_markdown( lines.append("- **hosts:**") for host, count in host_counts.most_common(): lines.append(f" - `{host}`: {count} task(s)") - else: - lines.append("- **hosts:** `~`") - # inputs (used) and outputs (generated) used_fields: Dict[str, List[Any]] = defaultdict(list) gen_fields: Dict[str, List[Any]] = defaultdict(list) @@ -1769,9 +1818,6 @@ def render_workflow_card_markdown( numeric_vals = [v for v in numeric_vals if v is not None] if numeric_vals and len(numeric_vals) == len(used_fields[key]): variability_candidates.append((activity_id, f"used.{key}", max(numeric_vals) - min(numeric_vals))) - else: - lines.append("- **inputs:** `~`") - if gen_fields: activity_generated_field_counts.append((activity_id, len(gen_fields))) lines.append("- **outputs:**") @@ -1786,9 +1832,6 @@ def render_workflow_card_markdown( variability_candidates.append( (activity_id, f"generated.{key}", max(numeric_vals) - min(numeric_vals)) ) - else: - lines.append("- **outputs:** `~`") - lines.append("") activity_detail_insights: List[str] = [] @@ -1914,18 +1957,8 @@ def render_workflow_card_markdown( ) lines.extend(_build_object_details_lines(objects)) lines.append("") - lines.append("### Output Artifacts") - lines.append("") - lines.append("~ *(output artifacts captured at the activity level above)*") - lines.append("") else: - lines.append("### Input Artifacts") - lines.append("") - lines.append("~ *(no object artifacts were recorded for this run)*") - lines.append("") - lines.append("### Output Artifacts") - lines.append("") - lines.append("~ *(no object artifacts were recorded for this run)*") + lines.append("*No object artifacts were recorded for this run.*") lines.append("") has_aggregated_activity = any(int(row.get("n_tasks", 0) or 0) > 1 for row in activities) diff --git a/src/flowcept/report/service.py b/src/flowcept/report/service.py index 93f189d0..fe6ead33 100644 --- a/src/flowcept/report/service.py +++ b/src/flowcept/report/service.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import Any, Dict, List -from flowcept.report.aggregations import group_activities, summarize_objects +from flowcept.report.aggregations import group_activities, group_transformations, summarize_objects from flowcept.report.loaders import load_records_from_db, read_jsonl, split_records from flowcept.report.renderers.campaign_workflow_card_markdown import render_campaign_workflow_card_markdown from flowcept.report.renderers.workflow_card_markdown import ( @@ -39,6 +39,62 @@ def _resolve_input_mode( return "db" +def build_workflow_card( + input_jsonl_path: str | None = None, + records: List[Dict[str, Any]] | None = None, + workflow_id: str | None = None, + campaign_id: str | None = None, +) -> Dict[str, Any]: + """Build the structured workflow-card content without rendering it. + + Accepts exactly one input mode (JSONL path, pre-loaded records, or DB query + by workflow/campaign id) and returns the aggregated structures consumed by + renderers and APIs. + + Parameters + ---------- + input_jsonl_path : str, optional + Path to a Flowcept JSONL buffer file. + records : list of dict, optional + Pre-loaded Flowcept records (workflow/task/object dicts). + workflow_id : str, optional + Workflow identifier for DB query mode. + campaign_id : str, optional + Campaign identifier for DB query mode. + + Returns + ------- + dict + ``{"dataset", "transformations", "object_summary", "input_mode", "skipped_lines"}``. + """ + mode = _resolve_input_mode( + input_jsonl_path=input_jsonl_path, + records=records, + workflow_id=workflow_id, + campaign_id=campaign_id, + ) + + skipped_lines = 0 + if mode == "jsonl": + jsonl_path = Path(input_jsonl_path) # type: ignore[arg-type] + if not jsonl_path.exists(): + raise FileNotFoundError(f"Input JSONL not found: {jsonl_path}") + parsed_records, skipped_lines = read_jsonl(jsonl_path) + dataset = split_records(parsed_records) + elif mode == "records": + dataset = split_records(records or []) + else: + dataset = load_records_from_db(workflow_id=workflow_id, campaign_id=campaign_id) + + return { + "dataset": dataset, + "transformations": group_transformations(dataset.get("tasks", [])), + "object_summary": summarize_objects(dataset.get("objects", [])), + "input_mode": mode, + "skipped_lines": skipped_lines, + } + + def generate_report( report_type: str = "workflow_card", format: str = "markdown", @@ -111,7 +167,6 @@ def generate_report( output = Path(output_path) if output_path is not None else None if is_campaign: - # Campaign dataset: multiple workflow runs detected render_stats = render_campaign_workflow_card_markdown( dataset=dataset, output_path=output, diff --git a/src/flowcept/version.py b/src/flowcept/version.py index a1465a17..c79caadd 100644 --- a/src/flowcept/version.py +++ b/src/flowcept/version.py @@ -7,4 +7,4 @@ # See .github/workflows/version_bumper.py # ✋⚠️⛔❗❗❗ STOP! DANGER!!ONEONEELEVEN! Did you carefully read the warning above?! :) -__version__ = "0.10.5" +__version__ = "0.10.8" diff --git a/src/flowcept/webservice/README.md b/src/flowcept/webservice/README.md index 0c773d72..565a1c88 100644 --- a/src/flowcept/webservice/README.md +++ b/src/flowcept/webservice/README.md @@ -91,6 +91,49 @@ All API routes are mounted under: - Supported `scope`: `workflows | tasks | objects | models | datasets` - `models` and `datasets` enforce fixed base filters (`object_type=ml_model` and `object_type=dataset`) +### Campaigns (derived) + +- `GET /api/v1/campaigns` — campaign summaries derived by grouping workflows/tasks by `campaign_id` +- `GET /api/v1/campaigns/{campaign_id}` — summary + workflows + task summary +- `GET /api/v1/campaigns/{campaign_id}/workflow_card?format=json|markdown` + +### Agents (derived) + +- `GET /api/v1/agents` — agent summaries derived from task `agent_id`/`source_agent_id` +- `GET /api/v1/agents/{agent_id}` and `GET /api/v1/agents/{agent_id}/tasks` + +### Stats + +- `GET /api/v1/stats/tasks/summary?workflow_id=&campaign_id=&agent_id=&filter_json=` +- `POST /api/v1/stats/timeseries` — dot-notated (telemetry) fields over tasks for plotting +- `POST /api/v1/stats/chart_data` — declarative dashboard chart data resolver + +### Dashboards + +- `GET|POST /api/v1/dashboards`, `GET|PUT|DELETE /api/v1/dashboards/{dashboard_id}` +- Specs are validated (`schemas/dashboards.py`); stored in the `dashboards` Mongo collection, + or JSON files under `web_server.dashboards_dir` when MongoDB is disabled + +### Workflow cards + +- `GET /api/v1/workflows/{workflow_id}/workflow_card?format=json|markdown` + +### Live streams (SSE) + +- `GET /api/v1/stream/tasks?workflow_id=&campaign_id=&agent_id=&since=&poll_interval=` +- `GET /api/v1/stream/workflows?campaign_id=&since=&poll_interval=` +- Server-sent events backed by incremental DB polling; the `cursor` in each event resumes streams + +### Chat + +- `POST /api/v1/chat` — LLM chat with DB-backed provenance tools (shared with the MCP agent) +- Requires the `llm_agent` extra and the `agent` settings section; returns 503 otherwise +- `stream=true` (default) streams SSE events: `tool_call`, `tool_result`, `card`, `token`, `done` + +### Web UI + +- The built SPA (from `ui/`) is served at `/` when present (`make ui-build`); API always wins under `/api` + ## Query model Advanced `POST /query` endpoints share a common request model: diff --git a/src/flowcept/webservice/docs/API_CONTRACT.md b/src/flowcept/webservice/docs/API_CONTRACT.md index 8312f583..69991d16 100644 --- a/src/flowcept/webservice/docs/API_CONTRACT.md +++ b/src/flowcept/webservice/docs/API_CONTRACT.md @@ -104,7 +104,7 @@ Returns version history metadata sorted latest-first. Same query model as above, plus `include_data`. -### Datasets (`object_type=dataset`) +### Datasets (`type=dataset`) - `GET /api/v1/datasets` - `GET /api/v1/datasets/{object_id}` @@ -112,7 +112,7 @@ Same query model as above, plus `include_data`. - `GET /api/v1/datasets/{object_id}/download` - `POST /api/v1/datasets/query` -### Models (`object_type=ml_model`) +### Models (`type=ml_model`) - `GET /api/v1/models` - `GET /api/v1/models/{object_id}` @@ -126,13 +126,64 @@ Unified scoped read-only query endpoint. - `scope`: `workflows | tasks | objects | models | datasets` - Uses the same query body model as other `/query` routes. -- `models` and `datasets` scopes enforce fixed `object_type` filters. +- `models` and `datasets` scopes enforce fixed type filters. - Rejects unsupported filter operators. +### Campaigns (derived; no campaigns collection) + +- `GET /api/v1/campaigns` — grouped from workflows/tasks by `campaign_id` +- `GET /api/v1/campaigns/{campaign_id}` — `{campaign, workflows, task_summary}`; 404 when nothing matches +- `GET /api/v1/campaigns/{campaign_id}/workflow_card?format=json|markdown` + +### Agents (derived from task `agent_id`/`source_agent_id`) + +- `GET /api/v1/agents` +- `GET /api/v1/agents/{agent_id}` — `{agent, task_summary}` +- `GET /api/v1/agents/{agent_id}/tasks` + +### Stats + +- `GET /api/v1/stats/tasks/summary` — `{count, status_counts, activity_stats, time_range}` +- `POST /api/v1/stats/timeseries` — body `{filter, fields: [dot-paths], x, limit}` → `{rows, count}` +- `POST /api/v1/stats/chart_data` — body `{data: ChartData, context}` → `{rows, count}`; + `ChartData` is the declarative dashboard binding (`source, filter, group_by, metrics | x/y, sort, limit`) + +### Dashboards + +- `GET /api/v1/dashboards`, `POST /api/v1/dashboards` (201; server assigns `dashboard_id`) +- `GET|PUT|DELETE /api/v1/dashboards/{dashboard_id}` +- Specs validated against `schemas/dashboards.py::DashboardSpec`; card/context filters use the + same operator allowlist as `/query` + +### Workflow cards + +- `GET /api/v1/workflows/{workflow_id}/workflow_card?format=json|markdown` +- `format=json` returns `{dataset, transformations, object_summary, input_mode}` + +### Live streams (SSE) + +- `GET /api/v1/stream/tasks?workflow_id=&campaign_id=&agent_id=&since=&poll_interval=` +- `GET /api/v1/stream/workflows?campaign_id=&since=&poll_interval=` +- `text/event-stream`; events named `tasks`/`workflows` with data + `{tasks|workflows: [...], cursor: float, truncated: bool}`; pass `cursor` back as `since` to resume. + Backed by incremental DB polling (`web_server.sse_*` settings); no MQ coupling. + +### POST /api/v1/chat + +- Body: `{messages: [{role, content}], context, stream, allow_dashboard_edit}` (stateless; + client passes history) +- `stream=true`: SSE events `tool_call`, `tool_result`, `card`, `token`, `done`, `error` +- `stream=false`: one JSON `{message, tool_trace, cards}` +- LLM built from the `agent` settings section; `503` when not configured +- Tools are the shared provenance core (`flowcept.agents.tools.prov_tools`), also exposed via the + MCP agent as `query_provenance_tasks`, `list_provenance_campaigns`, etc. + ## Status codes - `200`: success +- `201`: dashboard created - `400`: malformed input or unsupported query shape - `404`: resource does not exist - `422`: request schema validation error (FastAPI/Pydantic) - `500`: unexpected internal error +- `503`: chat requested but no LLM configured/available diff --git a/src/flowcept/webservice/main.py b/src/flowcept/webservice/main.py index 96957e9f..bb9a24cc 100644 --- a/src/flowcept/webservice/main.py +++ b/src/flowcept/webservice/main.py @@ -1,14 +1,27 @@ """FastAPI entrypoint for Flowcept webservice.""" +from pathlib import Path + from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse +from fastapi.responses import FileResponse, JSONResponse -from flowcept.configs import WEBSERVER_HOST, WEBSERVER_PORT +from flowcept.commons.flowcept_logger import FlowceptLogger +from flowcept.configs import ( + WEBSERVER_CORS_ORIGINS, + WEBSERVER_HOST, + WEBSERVER_PORT, + WEBSERVER_UI_ENABLED, +) +from flowcept.webservice.routers.agents import router as agents_router +from flowcept.webservice.routers.campaigns import router as campaigns_router +from flowcept.webservice.routers.dashboards import router as dashboards_router from flowcept.webservice.routers.datasets import router as datasets_router -from flowcept.webservice.routers.health import router as health_router +from flowcept.webservice.routers.health import info_router, router as health_router from flowcept.webservice.routers.models import router as models_router from flowcept.webservice.routers.objects import router as objects_router from flowcept.webservice.routers.query import router as query_router +from flowcept.webservice.routers.stats import router as stats_router +from flowcept.webservice.routers.stream import router as stream_router from flowcept.webservice.routers.tasks import router as tasks_router from flowcept.webservice.routers.workflows import router as workflows_router @@ -25,30 +38,85 @@ def create_app() -> FastAPI: openapi_url="/openapi.json", docs_url="/docs", redoc_url="/redoc", + redirect_slashes=False, ) @app.exception_handler(ValueError) async def value_error_handler(_: Request, exc: ValueError) -> JSONResponse: return JSONResponse(status_code=400, content={"detail": str(exc)}) - @app.get("/", tags=["health"]) - def root() -> dict: - return { - "status": "up", - "service": "flowcept-webservice", - "host": WEBSERVER_HOST, - "port": WEBSERVER_PORT, - } + if WEBSERVER_CORS_ORIGINS: + from fastapi.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=WEBSERVER_CORS_ORIGINS, + allow_methods=["*"], + allow_headers=["*"], + ) app.include_router(health_router, prefix="/api/v1") + app.include_router(info_router, prefix="/api/v1") + app.include_router(campaigns_router, prefix="/api/v1") app.include_router(workflows_router, prefix="/api/v1") app.include_router(tasks_router, prefix="/api/v1") app.include_router(objects_router, prefix="/api/v1") app.include_router(datasets_router, prefix="/api/v1") app.include_router(models_router, prefix="/api/v1") + app.include_router(agents_router, prefix="/api/v1") + app.include_router(stats_router, prefix="/api/v1") + app.include_router(dashboards_router, prefix="/api/v1") + app.include_router(stream_router, prefix="/api/v1") + try: + from flowcept.webservice.routers.chat import router as chat_router + + app.include_router(chat_router, prefix="/api/v1") + except Exception as e: + FlowceptLogger().warning(f"Chat endpoint not available: {e}") app.include_router(query_router, prefix="/api/v1") + _mount_ui(app) + return app +def _mount_ui(app: FastAPI) -> None: + """Serve the built SPA when its assets are present; otherwise expose a status root.""" + ui_dir = Path(__file__).parent / "ui_build" + index_html = ui_dir / "index.html" + + if not (WEBSERVER_UI_ENABLED and index_html.exists()): + if WEBSERVER_UI_ENABLED: + FlowceptLogger().warning( + f"Web UI assets not found at {ui_dir}; serving API only. Build the UI with `make ui-build`." + ) + + @app.get("/", tags=["health"]) + def root() -> dict: + return { + "status": "up", + "service": "flowcept-webservice", + "host": WEBSERVER_HOST, + "port": WEBSERVER_PORT, + } + + return + + from fastapi.staticfiles import StaticFiles + + assets_dir = ui_dir / "assets" + if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory=assets_dir), name="assets") + + @app.get("/{full_path:path}", include_in_schema=False) + def spa_fallback(full_path: str): + reserved = ("api/", "docs", "redoc", "openapi.json", "assets/") + if full_path.startswith(reserved): + return JSONResponse(status_code=404, content={"detail": f"Not found: /{full_path}"}) + candidate = ui_dir / full_path + if full_path and candidate.is_file() and candidate.resolve().is_relative_to(ui_dir.resolve()): + return FileResponse(candidate) + return FileResponse(index_html) + + app = create_app() diff --git a/src/flowcept/webservice/routers/agents.py b/src/flowcept/webservice/routers/agents.py new file mode 100644 index 00000000..6d315082 --- /dev/null +++ b/src/flowcept/webservice/routers/agents.py @@ -0,0 +1,83 @@ +"""Agent endpoints (derived from task ``agent_id``/``source_agent_id`` fields).""" + +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, Query + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.schemas.common import ListResponse +from flowcept.webservice.services import stats +from flowcept.webservice.services.serializers import normalize_docs +from flowcept.webservice.services.sorting import sort_docs_by_first_date_field + +router = APIRouter(prefix="/agents", tags=["agents"]) + + +@router.get("", response_model=ListResponse) +def list_agents( + limit: int = Query(default=100, ge=1, le=1000), + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List derived agent summaries, most recently active first.""" + agents = stats.derive_agents(db) + agents = sort_docs_by_first_date_field(agents, ["registered_at", "last_active"]) + agents = agents[:limit] + normalized = normalize_docs(agents) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.get("/{agent_id}", response_model=Dict[str, Any]) +def get_agent(agent_id: str, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Get one agent's derived summary and per-activity task summary.""" + agents = [a for a in stats.derive_agents(db) if a["agent_id"] == agent_id] + if not agents: + raise HTTPException(status_code=404, detail=f"Agent not found: {agent_id}") + task_summary = stats.task_summary(db, {"agent_id": agent_id}) + return { + "agent": normalize_docs(agents)[0], + "task_summary": normalize_docs([task_summary])[0], + } + + +@router.get("/{agent_id}/tasks", response_model=ListResponse) +def get_agent_tasks( + agent_id: str, + limit: int = Query(default=100, ge=1, le=1000), + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List tasks executed by or sent from an agent.""" + docs = ( + db.task_query( + filter={"$or": [{"agent_id": agent_id}, {"source_agent_id": agent_id}]}, + limit=limit, + ) + or [] + ) + docs = sort_docs_by_first_date_field( + docs, + ["started_at", "utc_timestamp", "ended_at", "registered_at"], + ) + normalized = normalize_docs(docs) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.delete("/cleanup/empty", response_model=Dict[str, Any]) +def delete_empty_agents(db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Delete all agents from the database that don't have associated task_id.""" + stored_agents = db.agent_query(filter={}) or [] + deleted_count = 0 + for agent in stored_agents: + agent_id = agent.get("agent_id") + if not agent_id: + continue + tasks = db.task_query( + filter={"$or": [{"agent_id": agent_id}, {"source_agent_id": agent_id}]}, + limit=1, + ) + if not tasks: + db.delete_agents_with_filter({"agent_id": agent_id}) + deleted_count += 1 + return {"deleted_count": deleted_count} diff --git a/src/flowcept/webservice/routers/campaigns.py b/src/flowcept/webservice/routers/campaigns.py new file mode 100644 index 00000000..f21ac549 --- /dev/null +++ b/src/flowcept/webservice/routers/campaigns.py @@ -0,0 +1,78 @@ +"""Campaign endpoints (derived — campaigns exist as a grouping key, not a collection).""" + +from __future__ import annotations + +from typing import Any, Dict + +from fastapi import APIRouter, Depends, HTTPException, Query + +from fastapi.responses import Response + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.schemas.common import ListResponse +from flowcept.webservice.services import stats +from flowcept.webservice.services.reports import workflow_card_response +from flowcept.webservice.services.serializers import normalize_docs +from flowcept.webservice.services.sorting import sort_docs_by_first_date_field + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + + +@router.get("", response_model=ListResponse) +def list_campaigns( + limit: int = Query(default=100, ge=1, le=1000), + db: DBAPI = Depends(get_db_api), +) -> ListResponse: + """List derived campaign summaries, most recently active first.""" + campaigns = stats.derive_campaigns(db) + campaigns = sort_docs_by_first_date_field(campaigns, ["last_ts", "first_ts"]) + campaigns = campaigns[:limit] + normalized = normalize_docs(campaigns) + return ListResponse(items=normalized, count=len(normalized), limit=limit) + + +@router.get("/{campaign_id}", response_model=Dict[str, Any]) +def get_campaign(campaign_id: str, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Get one campaign: derived summary, its workflows, and a task summary.""" + workflows = db.workflow_query(filter={"campaign_id": campaign_id}) or [] + task_summary = stats.task_summary(db, {"campaign_id": campaign_id}) + if not workflows and task_summary["count"] == 0: + raise HTTPException(status_code=404, detail=f"Campaign not found: {campaign_id}") + + workflows = sort_docs_by_first_date_field( + workflows, + ["utc_timestamp", "created_at", "updated_at", "timestamp", "started_at", "ended_at"], + ) + summary = next( + (c for c in stats.derive_campaigns(db) if c["campaign_id"] == campaign_id), + {"campaign_id": campaign_id}, + ) + return { + "campaign": normalize_docs([summary])[0], + "workflows": normalize_docs(workflows), + "task_summary": normalize_docs([task_summary])[0], + } + + +@router.delete("/{campaign_id}", response_model=Dict[str, Any]) +def delete_campaign(campaign_id: str, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Recursively delete a campaign and all its workflows, tasks, and objects.""" + workflows = db.workflow_query(filter={"campaign_id": campaign_id}) or [] + if not workflows: + raise HTTPException(status_code=404, detail=f"Campaign not found: {campaign_id}") + counts = DBAPI._dao().delete_campaign_data(campaign_id) + return {"deleted": counts} + + +@router.get("/{campaign_id}/workflow_card") +def get_campaign_workflow_card( + campaign_id: str, + format: str = Query(default="json"), + db: DBAPI = Depends(get_db_api), +) -> Response: + """Get a campaign workflow card as structured JSON or rendered markdown.""" + workflows = db.workflow_query(filter={"campaign_id": campaign_id}) or [] + if not workflows: + raise HTTPException(status_code=404, detail=f"Campaign not found: {campaign_id}") + return workflow_card_response(format=format, campaign_id=campaign_id) diff --git a/src/flowcept/webservice/routers/chat.py b/src/flowcept/webservice/routers/chat.py new file mode 100644 index 00000000..3e687ab8 --- /dev/null +++ b/src/flowcept/webservice/routers/chat.py @@ -0,0 +1,112 @@ +"""Chat endpoint: LLM with DB-backed provenance tools, streamed over SSE or as one JSON.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field +from sse_starlette.sse import EventSourceResponse + +from flowcept.commons.flowcept_logger import FlowceptLogger +from flowcept.configs import AGENT, AGENT_CHAT_ENABLED +from flowcept.webservice.services.chat_service import run_chat + +router = APIRouter(prefix="/chat", tags=["chat"]) + + +class ChatMessage(BaseModel): + """One conversation message.""" + + role: str + content: str + + +class ChatRequest(BaseModel): + """Chat request: client-passed history plus UI context.""" + + messages: List[ChatMessage] = Field(min_length=1) + context: Optional[Dict[str, Any]] = None + stream: bool = True + allow_dashboard_edit: bool = False + + +def get_chat_llm(): + """Build the chat LLM from the existing ``agent`` settings; 503 when unavailable.""" + if not AGENT_CHAT_ENABLED: + raise HTTPException( + status_code=503, + detail=( + "Chat features are disabled. To enable them, set 'agent.chat_enabled: true' in your settings.yaml file." + ), + ) + api_key = AGENT.get("api_key") + if not api_key or api_key in ("?", "your-api-key-here"): + raise HTTPException( + status_code=503, + detail=( + "LLM service is not configured. Please edit the 'agent' section in your settings.yaml file " + "to provide a valid API key (e.g. replace 'your-api-key-here' with your real key), " + "and ensure 'service_provider' and 'model' match your LLM provider configuration." + ), + ) + try: + from flowcept.agents.agents_utils import build_llm_model + + return build_llm_model(track_tools=False) + except HTTPException: + raise + except Exception as e: + FlowceptLogger().exception(e) + raise HTTPException( + status_code=503, + detail=( + f"Could not initialize the LLM client using the configured settings: {e}. " + "Please verify your credentials, API URL, and internet connection." + ), + ) from e + + +@router.post("") +def chat(payload: ChatRequest): + """Answer a provenance chat message, optionally streaming SSE events. + + Streaming events: ``tool_call``, ``tool_result``, ``card``, ``token``, ``done``, ``error``. + Non-streaming responses collect the same events into + ``{"message", "tool_trace", "cards"}``. + """ + llm = get_chat_llm() + messages = [m.model_dump() for m in payload.messages] + + events = run_chat( + llm, + messages=messages, + context=payload.context, + allow_dashboard_edit=payload.allow_dashboard_edit, + ) + + if payload.stream: + + def sse_events(): + for event in events: + yield {"event": event["event"], "data": json.dumps(event.get("data"), default=str)} + + return EventSourceResponse(sse_events(), ping=15) + + message_parts: List[str] = [] + tool_trace: List[Dict[str, Any]] = [] + cards: List[Dict[str, Any]] = [] + error: Optional[str] = None + for event in events: + if event["event"] == "token": + message_parts.append(str(event.get("data", ""))) + elif event["event"] in ("tool_call", "tool_result"): + tool_trace.append({"event": event["event"], **(event.get("data") or {})}) + elif event["event"] == "card": + cards.append(event.get("data") or {}) + elif event["event"] == "error": + error = str(event.get("data")) + if error and not message_parts: + raise HTTPException(status_code=500, detail=error) + return {"message": "".join(message_parts), "tool_trace": tool_trace, "cards": cards} diff --git a/src/flowcept/webservice/routers/dashboards.py b/src/flowcept/webservice/routers/dashboards.py new file mode 100644 index 00000000..b7aa205a --- /dev/null +++ b/src/flowcept/webservice/routers/dashboards.py @@ -0,0 +1,116 @@ +"""Dashboard config CRUD and resolution endpoints.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, Query + +from flowcept.webservice.routers.query import _validate_filter_shape +from flowcept.webservice.schemas.common import ListResponse +from flowcept.webservice.schemas.dashboards import DashboardConfig +from flowcept.webservice.services.dashboard_store import get_dashboard_store + +router = APIRouter(prefix="/dashboards", tags=["dashboards"]) + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _validate_config_filters(config: DashboardConfig) -> None: + _validate_filter_shape(config.context) + for chart in config.charts: + if chart.data is not None: + _validate_filter_shape(chart.data.filter) + + +@router.get("/resolve", response_model=List[Dict[str, Any]]) +def resolve_dashboard( + workflow_name: Optional[str] = Query(default=None), + campaign_id: Optional[str] = Query(default=None), + store=Depends(get_dashboard_store), +) -> List[Dict[str, Any]]: + """Return merged charts for a workflow or campaign. + + For a workflow: returns charts from all ``common_workflow`` configs merged + with charts from any ``custom_workflow`` config whose ``target`` matches + ``workflow_name``. + + For a campaign: returns charts from all ``common_campaign`` configs merged + with charts from any ``custom_campaign`` config whose ``target`` matches + ``campaign_id``. + """ + if workflow_name: + common = store.list_by_type("common_workflow") + custom = [c for c in store.list_by_type("custom_workflow") if c.get("target") == workflow_name] + elif campaign_id: + common = store.list_by_type("common_campaign") + custom = [c for c in store.list_by_type("custom_campaign") if c.get("target") == campaign_id] + else: + raise HTTPException(status_code=400, detail="Provide workflow_name or campaign_id.") + + charts: List[Dict[str, Any]] = [] + for cfg in common + custom: + charts.extend(cfg.get("charts", [])) + return charts + + +@router.get("", response_model=ListResponse) +def list_dashboards( + dashboard_type: Optional[str] = Query(default=None), + store=Depends(get_dashboard_store), +) -> ListResponse: + """List all dashboard configs, optionally filtered by ``dashboard_type``.""" + if dashboard_type: + items = store.list_by_type(dashboard_type) + else: + items = store.list() + return ListResponse(items=items, count=len(items), limit=0) + + +@router.post("", response_model=Dict[str, Any], status_code=201) +def create_dashboard(config: DashboardConfig, store=Depends(get_dashboard_store)) -> Dict[str, Any]: + """Create a dashboard config; the server assigns its id and timestamps.""" + _validate_config_filters(config) + config.dashboard_id = str(uuid4()) + config.created_at = config.updated_at = _now() + doc = config.model_dump() + if not store.save(doc): + raise HTTPException(status_code=500, detail="Could not save dashboard config.") + return doc + + +@router.get("/{dashboard_id}", response_model=Dict[str, Any]) +def get_dashboard(dashboard_id: str, store=Depends(get_dashboard_store)) -> Dict[str, Any]: + """Get a dashboard config by id.""" + doc = store.get(dashboard_id) + if doc is None: + raise HTTPException(status_code=404, detail=f"Dashboard not found: {dashboard_id}") + return doc + + +@router.put("/{dashboard_id}", response_model=Dict[str, Any]) +def update_dashboard(dashboard_id: str, config: DashboardConfig, store=Depends(get_dashboard_store)) -> Dict[str, Any]: + """Replace a dashboard config, preserving its id and creation time.""" + existing = store.get(dashboard_id) + if existing is None: + raise HTTPException(status_code=404, detail=f"Dashboard not found: {dashboard_id}") + _validate_config_filters(config) + config.dashboard_id = dashboard_id + config.created_at = existing.get("created_at") + config.updated_at = _now() + doc = config.model_dump() + if not store.save(doc): + raise HTTPException(status_code=500, detail="Could not save dashboard config.") + return doc + + +@router.delete("/{dashboard_id}", response_model=Dict[str, Any]) +def delete_dashboard(dashboard_id: str, store=Depends(get_dashboard_store)) -> Dict[str, Any]: + """Delete a dashboard config by id.""" + if not store.delete(dashboard_id): + raise HTTPException(status_code=404, detail=f"Dashboard not found: {dashboard_id}") + return {"deleted": dashboard_id} diff --git a/src/flowcept/webservice/routers/health.py b/src/flowcept/webservice/routers/health.py index 2e95f38f..b1785cf7 100644 --- a/src/flowcept/webservice/routers/health.py +++ b/src/flowcept/webservice/routers/health.py @@ -2,7 +2,10 @@ from fastapi import APIRouter +from flowcept.version import __version__ + router = APIRouter(prefix="/health", tags=["health"]) +info_router = APIRouter(tags=["health"]) @router.get("/live") @@ -15,3 +18,9 @@ def live() -> dict: def ready() -> dict: """Readiness check.""" return {"status": "ready"} + + +@info_router.get("/info") +def info() -> dict: + """Service name and installed version.""" + return {"service": "flowcept", "version": __version__} diff --git a/src/flowcept/webservice/routers/objects.py b/src/flowcept/webservice/routers/objects.py index dff03e3f..f7752d6d 100644 --- a/src/flowcept/webservice/routers/objects.py +++ b/src/flowcept/webservice/routers/objects.py @@ -153,6 +153,18 @@ def get_object_history( return ListResponse(items=normalized, count=len(normalized), limit=limit) +@router.delete("/{object_id}", response_model=Dict[str, Any]) +def delete_object(object_id: str, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Delete an object and all its versions by object_id.""" + dao = DBAPI._dao() + if not hasattr(dao, "delete_object_keys"): + raise HTTPException(status_code=501, detail="Delete not supported by this DB backend.") + deleted = dao.delete_object_keys("object_id", [object_id]) + if not deleted: + raise HTTPException(status_code=404, detail=f"Object not found or could not be deleted: {object_id}") + return {"deleted": True, "object_id": object_id} + + @router.post("/query", response_model=ListResponse) def query_objects(payload: ObjectQueryRequest, db: DBAPI = Depends(get_db_api)) -> ListResponse: """Run an advanced read-only object query.""" diff --git a/src/flowcept/webservice/routers/stats.py b/src/flowcept/webservice/routers/stats.py new file mode 100644 index 00000000..f95e7b98 --- /dev/null +++ b/src/flowcept/webservice/routers/stats.py @@ -0,0 +1,89 @@ +"""Stats endpoints: task summaries, telemetry timeseries, and the dashboard chart-data resolver.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.routers.query import _validate_filter_shape +from flowcept.webservice.schemas.dashboards import ChartData +from flowcept.webservice.services import stats +from flowcept.webservice.services.serializers import normalize_docs + +router = APIRouter(prefix="/stats", tags=["stats"]) + + +class TimeseriesRequest(BaseModel): + """Request body for telemetry/field timeseries extraction.""" + + filter: Dict[str, Any] = Field(default_factory=dict) + fields: List[str] + x: str = "started_at" + limit: int = Field(default=1000, ge=1, le=5000) + + +class ChartDataRequest(BaseModel): + """Request body for the declarative chart-data resolver.""" + + data: ChartData + context: Optional[Dict[str, Any]] = None + + +def _json_filter(filter_json: Optional[str]) -> Dict[str, Any]: + if not filter_json: + return {} + try: + parsed = json.loads(filter_json) + except Exception as exc: + raise HTTPException(status_code=400, detail=f"Invalid filter JSON: {exc}") from exc + if not isinstance(parsed, dict): + raise HTTPException(status_code=400, detail="filter_json must decode to a JSON object.") + return parsed + + +@router.get("/tasks/summary", response_model=Dict[str, Any]) +def get_task_summary( + workflow_id: Optional[str] = None, + campaign_id: Optional[str] = None, + agent_id: Optional[str] = None, + filter_json: Optional[str] = None, + db: DBAPI = Depends(get_db_api), +) -> Dict[str, Any]: + """Summarize tasks (status counts, per-activity durations, time range).""" + query_filter = _json_filter(filter_json) + for key, value in (("workflow_id", workflow_id), ("campaign_id", campaign_id), ("agent_id", agent_id)): + if value is not None: + query_filter[key] = value + _validate_filter_shape(query_filter) + return normalize_docs([stats.task_summary(db, query_filter)])[0] + + +@router.post("/timeseries", response_model=Dict[str, Any]) +def post_timeseries(payload: TimeseriesRequest, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Extract plottable rows of dot-notated fields from tasks.""" + _validate_filter_shape(payload.filter) + rows = stats.telemetry_timeseries( + db, + filter=payload.filter, + fields=payload.fields, + x_field=payload.x, + limit=payload.limit, + ) + rows = normalize_docs(rows) + return {"rows": rows, "count": len(rows)} + + +@router.post("/chart_data", response_model=Dict[str, Any]) +def post_chart_data(payload: ChartDataRequest, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Resolve a declarative dashboard chart data binding into rows.""" + _validate_filter_shape(payload.data.filter) + if payload.context: + _validate_filter_shape(payload.context) + result = stats.resolve_chart_data(db, payload.data, context=payload.context) + result["rows"] = normalize_docs(result["rows"]) + return result diff --git a/src/flowcept/webservice/routers/stream.py b/src/flowcept/webservice/routers/stream.py new file mode 100644 index 00000000..5db9b8a7 --- /dev/null +++ b/src/flowcept/webservice/routers/stream.py @@ -0,0 +1,82 @@ +"""SSE endpoints streaming new/updated tasks and workflows via incremental DB polling.""" + +from __future__ import annotations + +import json +import time +from typing import Any, Dict, Optional + +import anyio +from fastapi import APIRouter, Depends, Query, Request +from sse_starlette.sse import EventSourceResponse + +from flowcept.configs import WEBSERVER_SSE_MAX_BATCH, WEBSERVER_SSE_POLL_INTERVAL +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.deps import get_db_api +from flowcept.webservice.services.serializers import normalize_docs +from flowcept.webservice.services.streaming import poll_new_docs + +router = APIRouter(prefix="/stream", tags=["stream"]) + + +def _event_stream( + request: Request, + db: DBAPI, + collection: str, + base_filter: Dict[str, Any], + since: float, + poll_interval: float, +): + """Async generator yielding one SSE event per poll that finds new documents.""" + payload_key = collection # "tasks" or "workflows" + + async def generator(): + cursor = since + while not await request.is_disconnected(): + docs, new_cursor, truncated = await anyio.to_thread.run_sync( + poll_new_docs, db, collection, base_filter, cursor, WEBSERVER_SSE_MAX_BATCH + ) + if docs: + cursor = new_cursor + yield { + "event": payload_key, + "data": json.dumps({payload_key: normalize_docs(docs), "cursor": cursor, "truncated": truncated}), + } + await anyio.sleep(poll_interval) + + return generator() + + +@router.get("/tasks") +def stream_tasks( + request: Request, + workflow_id: Optional[str] = None, + campaign_id: Optional[str] = None, + agent_id: Optional[str] = None, + since: Optional[float] = None, + poll_interval: float = Query(default=WEBSERVER_SSE_POLL_INTERVAL, ge=0.1, le=60.0), + db: DBAPI = Depends(get_db_api), +) -> EventSourceResponse: + """Stream new/updated tasks as SSE events, optionally scoped by workflow/campaign/agent.""" + base_filter: Dict[str, Any] = {} + for key, value in (("workflow_id", workflow_id), ("campaign_id", campaign_id), ("agent_id", agent_id)): + if value is not None: + base_filter[key] = value + cursor = time.time() if since is None else since + return EventSourceResponse(_event_stream(request, db, "tasks", base_filter, cursor, poll_interval), ping=15) + + +@router.get("/workflows") +def stream_workflows( + request: Request, + campaign_id: Optional[str] = None, + since: Optional[float] = None, + poll_interval: float = Query(default=WEBSERVER_SSE_POLL_INTERVAL, ge=0.1, le=60.0), + db: DBAPI = Depends(get_db_api), +) -> EventSourceResponse: + """Stream new workflows as SSE events, optionally scoped by campaign.""" + base_filter: Dict[str, Any] = {} + if campaign_id is not None: + base_filter["campaign_id"] = campaign_id + cursor = time.time() if since is None else since + return EventSourceResponse(_event_stream(request, db, "workflows", base_filter, cursor, poll_interval), ping=15) diff --git a/src/flowcept/webservice/routers/workflows.py b/src/flowcept/webservice/routers/workflows.py index 08d61a85..61a9a98f 100644 --- a/src/flowcept/webservice/routers/workflows.py +++ b/src/flowcept/webservice/routers/workflows.py @@ -12,6 +12,8 @@ from flowcept.flowcept_api.db_api import DBAPI from flowcept.webservice.deps import get_db_api from flowcept.webservice.schemas.common import ListResponse, QueryRequest +from flowcept.webservice.services.dataflow import build_dataflow +from flowcept.webservice.services.reports import workflow_card_response from flowcept.webservice.services.serializers import normalize_docs from flowcept.webservice.services.sorting import sort_docs_by_first_date_field @@ -71,6 +73,15 @@ def get_workflow(workflow_id: str, db: DBAPI = Depends(get_db_api)) -> Dict[str, return normalized[0] +@router.delete("/{workflow_id}", response_model=Dict[str, Any]) +def delete_workflow(workflow_id: str, db: DBAPI = Depends(get_db_api)) -> Dict[str, Any]: + """Recursively delete a workflow and all its tasks and objects.""" + if db.get_workflow_object(workflow_id) is None: + raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") + counts = DBAPI._dao().delete_workflow_data(workflow_id) + return {"deleted": counts} + + @router.post("/query", response_model=ListResponse) def query_workflows(payload: QueryRequest, db: DBAPI = Depends(get_db_api)) -> ListResponse: """Run an advanced read-only workflows query.""" @@ -89,6 +100,31 @@ def query_workflows(payload: QueryRequest, db: DBAPI = Depends(get_db_api)) -> L return ListResponse(items=normalized, count=len(normalized), limit=payload.limit) +@router.get("/{workflow_id}/dataflow", response_model=Dict[str, Any]) +def get_workflow_dataflow( + workflow_id: str, + db: DBAPI = Depends(get_db_api), +) -> Dict[str, Any]: + """Get the PROV-style dataflow graph derived from tasks' used/generated fields.""" + graph = build_dataflow(db, workflow_id) + if graph is None: + raise HTTPException(status_code=404, detail=f"No dataflow data for workflow: {workflow_id}") + return graph + + +@router.get("/{workflow_id}/workflow_card") +def get_workflow_card( + workflow_id: str, + format: str = Query(default="json"), + db: DBAPI = Depends(get_db_api), +) -> Response: + """Get a workflow card as structured JSON or rendered markdown.""" + wf_obj = db.get_workflow_object(workflow_id) + if wf_obj is None: + raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") + return workflow_card_response(format=format, workflow_id=workflow_id) + + @router.post("/{workflow_id}/reports/workflow-card/download") def download_workflow_card(workflow_id: str, db: DBAPI = Depends(get_db_api)) -> Response: """Generate and download a workflow card markdown file.""" @@ -123,3 +159,32 @@ def download_workflow_card(workflow_id: str, db: DBAPI = Depends(get_db_api)) -> media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) + + +@router.get("/{workflow_id}/node_positions", response_model=Dict[str, Any]) +def get_node_positions( + workflow_id: str, + graph_type: str = Query(..., description="Graph type: 'dataflow', 'task', or 'activity'"), + db: DBAPI = Depends(get_db_api), +) -> Dict[str, Any]: + """Get node positions for a workflow graph type.""" + if db.get_workflow_object(workflow_id) is None: + raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") + return db.get_node_positions(workflow_id, graph_type) + + +@router.post("/{workflow_id}/node_positions", response_model=Dict[str, Any]) +def save_node_positions( + workflow_id: str, + payload: Dict[str, Any], + db: DBAPI = Depends(get_db_api), +) -> Dict[str, Any]: + """Save node positions for a workflow graph type.""" + if db.get_workflow_object(workflow_id) is None: + raise HTTPException(status_code=404, detail=f"Workflow not found: {workflow_id}") + graph_type = payload.get("graph_type") + positions = payload.get("positions") + if not graph_type or positions is None: + raise HTTPException(status_code=400, detail="Missing graph_type or positions in payload") + success = db.save_node_positions(workflow_id, graph_type, positions) + return {"success": success} diff --git a/src/flowcept/webservice/schemas/dashboards.py b/src/flowcept/webservice/schemas/dashboards.py new file mode 100644 index 00000000..6a9d8ef7 --- /dev/null +++ b/src/flowcept/webservice/schemas/dashboards.py @@ -0,0 +1,105 @@ +"""Pydantic schemas for dashboard specs and declarative chart-data bindings. + +The spec is deliberately declarative so that LLM tools can reliably generate/modify +it and the frontend can validate and render it. + +Data model: +- A Dashboard has a type (workflow | campaign) and contains multiple charts. +- Each chart can have a data binding (ChartData) describing what to query. +- VizSpec describes how to render the query result (bar, pie, line, ...). +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Literal, Optional + +from pydantic import BaseModel, Field + +from flowcept.webservice.schemas.common import SortSpec + + +class MetricSpec(BaseModel): + """A single aggregation over a (dot-notated) field.""" + + field: str + agg: Literal["avg", "sum", "min", "max", "count"] + + +class ChartData(BaseModel): + """Declarative data binding for a chart: what to query and how to shape it.""" + + source: Literal["tasks", "workflows", "objects", "collection_sizes"] = "tasks" + filter: Dict[str, Any] = Field(default_factory=dict) + group_by: Optional[str] = None + metrics: Optional[List[MetricSpec]] = None + x: Optional[str] = None + y: Optional[List[str]] = None + sort: Optional[List[SortSpec]] = None + limit: int = Field(default=500, ge=1, le=5000) + + +class VizSpec(BaseModel): + """How a chart renders its rows.""" + + kind: Literal["line", "bar", "pie", "scatter", "area", "heatmap"] = "line" + stacked: bool = False + + +class DashboardChart(BaseModel): + """One chart inside a dashboard.""" + + chart_id: str + type: Literal["chart", "metric", "table", "markdown"] + title: str = "" + live: bool = False + refresh_interval_sec: Optional[float] = None + data: Optional[ChartData] = None + viz: Optional[VizSpec] = None + content: Optional[str] = None + + +class LayoutItem(BaseModel): + """Grid placement of a chart in a 12-column layout.""" + + chart_id: str + x: int = Field(ge=0, le=11) + y: int = Field(ge=0) + w: int = Field(ge=1, le=12) + h: int = Field(ge=1) + + +class DashboardSpec(BaseModel): + """A complete dashboard: type, context filter, charts, and layout.""" + + dashboard_id: Optional[str] = None + type: Literal["workflow", "campaign"] = "workflow" + name: str + description: str = "" + context: Dict[str, Any] = Field(default_factory=dict) + charts: List[DashboardChart] = Field(default_factory=list) + layout: List[LayoutItem] = Field(default_factory=list) + created_at: Optional[str] = None + updated_at: Optional[str] = None + + +class DashboardConfig(BaseModel): + """A dashboard configuration: one of four types (common/custom × workflow/campaign). + + ``target`` is required for custom types: + - ``custom_workflow``: the workflow **name** (not id) this config applies to. + - ``custom_campaign``: the ``campaign_id`` this config applies to. + Common types leave ``target`` null and apply to every workflow or campaign. + """ + + dashboard_id: Optional[str] = None + dashboard_type: Literal["common_workflow", "common_campaign", "custom_workflow", "custom_campaign"] = ( + "common_workflow" + ) + target: Optional[str] = None + name: str = "" + description: str = "" + context: Dict[str, Any] = Field(default_factory=dict) + charts: List[DashboardChart] = Field(default_factory=list) + layout: List[LayoutItem] = Field(default_factory=list) + created_at: Optional[str] = None + updated_at: Optional[str] = None diff --git a/src/flowcept/webservice/services/chat_service.py b/src/flowcept/webservice/services/chat_service.py new file mode 100644 index 00000000..c67b78f6 --- /dev/null +++ b/src/flowcept/webservice/services/chat_service.py @@ -0,0 +1,213 @@ +"""LLM chat orchestration for the webservice: tool-calling loop over the shared prov tools.""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Generator, List, Optional + +from flowcept.agents.prompts.chat_prompts import CHAT_SYSTEM_PROMPT +from flowcept.agents.tools import prov_tools +from flowcept.commons.flowcept_logger import FlowceptLogger +from flowcept.configs import AGENT_CHAT_MAX_TOOL_ITERATIONS + +MAX_TOOL_ITERATIONS = AGENT_CHAT_MAX_TOOL_ITERATIONS + + +def _build_langchain_tools(context: Optional[Dict[str, Any]], allow_dashboard_edit: bool): + """Wrap the shared prov tool core as langchain tools (results JSON-encoded for the LLM).""" + from langchain_core.tools import tool + + def _run(func, **kwargs) -> str: + result = func(**kwargs) + payload = result.model_dump() if hasattr(result, "model_dump") else result + return json.dumps(payload, default=str) + + def _coerce_projection(p: Any) -> Optional[List[str]]: + """Accept a list of field names or a Mongo projection dict {field: 1}.""" + if p is None: + return None + if isinstance(p, dict): + return [k for k, v in p.items() if v] + return list(p) + + def _coerce_sort(s: Any) -> Optional[List[Dict[str, Any]]]: + """Accept [{field, order}] or a Mongo sort dict {field: -1}.""" + if s is None: + return None + if isinstance(s, dict): + return [{"field": k, "order": v} for k, v in s.items()] + return list(s) + + @tool + def query_tasks( + filter: Optional[Dict[str, Any]] = None, + projection: Optional[Any] = None, + limit: int = 100, + sort: Optional[Any] = None, + ) -> str: + """Query task provenance records with a Mongo-style filter. + + projection: list of field names, or a Mongo projection dict {"field": 1}. + sort: list of {"field": "...", "order": 1|-1}, or a Mongo sort dict {"field": -1}. + """ + return _run( + prov_tools.query_tasks, + filter=filter, + projection=_coerce_projection(projection), + limit=limit, + sort=_coerce_sort(sort), + ) + + @tool + def query_workflows(filter: Optional[Dict[str, Any]] = None, limit: int = 100) -> str: + """Query workflow provenance records with a Mongo-style filter.""" + return _run(prov_tools.query_workflows, filter=filter, limit=limit) + + @tool + def get_task_summary(filter: Optional[Dict[str, Any]] = None) -> str: + """Summarize tasks: status counts, per-activity durations, and time range.""" + return _run(prov_tools.get_task_summary, filter=filter) + + @tool + def list_campaigns() -> str: + """List derived campaign summaries (campaigns group workflows and tasks).""" + return _run(prov_tools.list_campaigns) + + @tool + def list_agents() -> str: + """List derived agent summaries (agents observed in task provenance).""" + return _run(prov_tools.list_agents) + + @tool + def make_chart(card_spec: Dict[str, Any]) -> str: + """Build a chart from a declarative dashboard card spec; the UI renders the result.""" + return _run(prov_tools.make_chart, card_spec=card_spec, context=context) + + @tool + def highlight_lineage( + task_ids: Optional[Any] = None, + filter: Optional[Dict[str, Any]] = None, + ) -> str: + """Highlight the full provenance lineage (ancestors + descendants) of tasks in the Dataflow graph. + + Pass `task_ids` as a list of task ID strings, or a single task ID string. + Or use `filter` to find the seed tasks first. + The UI will dim all other nodes and visually trace the lineage chain. + Always pass a workflow_id in the filter when on a workflow page. + """ + wf_id = (context or {}).get("workflow_id") + # Coerce a bare string to a list so the LLM can pass either form. + ids: Optional[List[str]] = None + if task_ids is not None: + ids = [task_ids] if isinstance(task_ids, str) else list(task_ids) + return _run(prov_tools.highlight_lineage, task_ids=ids, filter=filter, workflow_id=wf_id) + + tools = [query_tasks, query_workflows, get_task_summary, list_campaigns, list_agents, make_chart, highlight_lineage] + + if allow_dashboard_edit: + + @tool + def get_dashboard(dashboard_id: str) -> str: + """Get a stored dashboard spec by id.""" + return _run(prov_tools.get_dashboard, dashboard_id=dashboard_id) + + @tool + def update_dashboard(dashboard_id: str, spec: Dict[str, Any]) -> str: + """Replace a stored dashboard spec with a complete revised spec.""" + return _run(prov_tools.update_dashboard, dashboard_id=dashboard_id, spec=spec) + + tools += [get_dashboard, update_dashboard] + return tools + + +def _build_messages(messages: List[Dict[str, str]], context: Optional[Dict[str, Any]]): + from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + + system = CHAT_SYSTEM_PROMPT + if context: + system += f"\nCurrent user context (scope queries with it): {json.dumps(context)}" + lc_messages = [SystemMessage(content=system)] + for message in messages: + role = message.get("role") + content = message.get("content", "") + lc_messages.append(AIMessage(content=content) if role == "assistant" else HumanMessage(content=content)) + return lc_messages + + +def run_chat( + llm, + messages: List[Dict[str, str]], + context: Optional[Dict[str, Any]] = None, + allow_dashboard_edit: bool = False, +) -> Generator[Dict[str, Any], None, None]: + """Run one chat turn as a generator of events. + + Yields dict events: ``{"event": "tool_call"|"tool_result"|"card"|"token"|"done"|"error", ...}``. + The caller decides whether to stream them (SSE) or collect them into one response. + + Parameters + ---------- + llm : Any + A langchain chat model (from ``build_llm_model``). + messages : list of dict + Conversation history, ``[{"role": "user"|"assistant", "content": "..."}]``. + context : dict, optional + UI context (e.g., ``{"workflow_id": ...}``) injected into the system prompt and charts. + allow_dashboard_edit : bool, optional + Whether dashboard-modifying tools are bound. + """ + logger = FlowceptLogger() + tools = _build_langchain_tools(context, allow_dashboard_edit) + tools_by_name = {t.name: t for t in tools} + lc_messages = _build_messages(messages, context) + + try: + bound = llm.bind_tools(tools) + except (NotImplementedError, AttributeError): + logger.warning("Chat LLM does not support tool binding; answering without tools.") + bound = None + + try: + if bound is None: + response = llm.invoke(lc_messages) + yield {"event": "token", "data": getattr(response, "content", str(response))} + yield {"event": "done"} + return + + for _ in range(MAX_TOOL_ITERATIONS): + ai_message = bound.invoke(lc_messages) + tool_calls = getattr(ai_message, "tool_calls", None) or [] + if not tool_calls: + yield {"event": "token", "data": ai_message.content} + yield {"event": "done"} + return + + lc_messages.append(ai_message) + from langchain_core.messages import ToolMessage + + for call in tool_calls: + name = call["name"] + args = call.get("args") or {} + call_id = call.get("id") or name + yield {"event": "tool_call", "data": {"name": name, "args": args}} + tool_fn = tools_by_name.get(name) + output = tool_fn.invoke(args) if tool_fn is not None else json.dumps({"error": f"Unknown tool {name}"}) + lc_messages.append(ToolMessage(content=output, tool_call_id=call_id)) + + summary: Dict[str, Any] = {"name": name} + try: + parsed = json.loads(output) + summary["code"] = parsed.get("code") + if name == "make_chart" and isinstance(parsed.get("result"), dict): + yield {"event": "card", "data": parsed["result"]} + if name == "highlight_lineage" and isinstance(parsed.get("result"), dict): + yield {"event": "ui:highlight", "data": parsed["result"]} + except Exception: + pass + yield {"event": "tool_result", "data": summary} + + yield {"event": "token", "data": "I reached the tool-call limit for this request. Please refine the question."} + yield {"event": "done"} + except Exception as e: + logger.exception(e) + yield {"event": "error", "data": str(e)} diff --git a/src/flowcept/webservice/services/dashboard_store.py b/src/flowcept/webservice/services/dashboard_store.py new file mode 100644 index 00000000..e1c8a96c --- /dev/null +++ b/src/flowcept/webservice/services/dashboard_store.py @@ -0,0 +1,156 @@ +"""Dashboard persistence: MongoDB collection when available, JSON files otherwise.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Dict, List, Optional + +from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO +from flowcept.commons.flowcept_logger import FlowceptLogger +from flowcept.configs import WEBSERVER_DASHBOARDS_DIR + +_SEED_FILE = Path(__file__).parent.parent / "ui_build" / "default_dashboard_configs.json" + + +class MongoDashboardStore: + """Dashboard store backed by the ``dashboards`` MongoDB collection.""" + + def __init__(self, dao): + self._dao = dao + + def save(self, dashboard: Dict) -> bool: + """Insert or replace a dashboard document.""" + return self._dao.save_dashboard(dashboard) + + def get(self, dashboard_id: str) -> Optional[Dict]: + """Get a dashboard document by id.""" + return self._dao.get_dashboard(dashboard_id) + + def list(self) -> List[Dict]: + """List all dashboard documents, seeding defaults if the collection is empty.""" + docs = self._dao.list_dashboards() or [] + if not docs: + docs = self._seed() + return docs + + def list_by_type(self, dashboard_type: str) -> List[Dict]: + """List dashboard documents of a specific type.""" + self.list() # ensure seeded + return self._dao.list_dashboards(filter={"dashboard_type": dashboard_type}) or [] + + def delete(self, dashboard_id: str) -> bool: + """Delete a dashboard document by id.""" + return self._dao.delete_dashboard(dashboard_id) + + def _seed(self) -> List[Dict]: + """Load default configs from the bundled JSON file and persist them.""" + if not _SEED_FILE.exists(): + FlowceptLogger().warning(f"Default dashboard configs not found at {_SEED_FILE}") + return [] + try: + with open(_SEED_FILE) as f: + configs = json.load(f) + for doc in configs: + self._dao.save_dashboard(doc) + return configs + except Exception as e: + FlowceptLogger().exception(e) + return [] + + +class FileDashboardStore: + """Dashboard store writing one JSON file per dashboard under a local directory.""" + + def __init__(self, directory: str = WEBSERVER_DASHBOARDS_DIR): + self._dir = Path(directory) + self._dir.mkdir(parents=True, exist_ok=True) + self.logger = FlowceptLogger() + + def _path(self, dashboard_id: str) -> Path: + safe = "".join(c for c in dashboard_id if c.isalnum() or c in "-_") + return self._dir / f"{safe}.json" + + def save(self, dashboard: Dict) -> bool: + """Insert or replace a dashboard JSON file.""" + try: + with open(self._path(dashboard["dashboard_id"]), "w") as handle: + json.dump(dashboard, handle, indent=2) + return True + except Exception as e: + self.logger.exception(e) + return False + + def get(self, dashboard_id: str) -> Optional[Dict]: + """Get a dashboard from its JSON file.""" + path = self._path(dashboard_id) + if not path.exists(): + return None + try: + with open(path) as handle: + return json.load(handle) + except Exception as e: + self.logger.exception(e) + return None + + def list(self) -> List[Dict]: + """List all dashboards, seeding defaults if the directory is empty.""" + docs = self._load_all() + if not docs: + docs = self._seed() + return docs + + def list_by_type(self, dashboard_type: str) -> List[Dict]: + """List dashboards of a specific type.""" + self.list() # ensure seeded + return [d for d in self._load_all() if d.get("dashboard_type") == dashboard_type] + + def delete(self, dashboard_id: str) -> bool: + """Delete a dashboard JSON file.""" + path = self._path(dashboard_id) + if not path.exists(): + return False + try: + os.remove(path) + return True + except Exception as e: + self.logger.exception(e) + return False + + def _load_all(self) -> List[Dict]: + dashboards = [] + for path in sorted(self._dir.glob("*.json")): + try: + with open(path) as handle: + dashboards.append(json.load(handle)) + except Exception as e: + self.logger.exception(e) + return dashboards + + def _seed(self) -> List[Dict]: + """Load default configs from the bundled JSON file and persist them.""" + if not _SEED_FILE.exists(): + self.logger.warning(f"Default dashboard configs not found at {_SEED_FILE}") + return [] + try: + with open(_SEED_FILE) as f: + configs = json.load(f) + for doc in configs: + self.save(doc) + return configs + except Exception as e: + self.logger.exception(e) + return [] + + +def get_dashboard_store(): + """Return the dashboard store for the configured DocDB backend. + + Mongo-backed deployments store dashboards in a ``dashboards`` collection; + other backends fall back to JSON files under ``web_server.dashboards_dir``. + """ + dao = DocumentDBDAO.get_instance(create_indices=False) + if hasattr(dao, "save_dashboard"): + return MongoDashboardStore(dao) + return FileDashboardStore() diff --git a/src/flowcept/webservice/services/dataflow.py b/src/flowcept/webservice/services/dataflow.py new file mode 100644 index 00000000..76184bfb --- /dev/null +++ b/src/flowcept/webservice/services/dataflow.py @@ -0,0 +1,268 @@ +"""Derive a W3C-PROV-style dataflow graph from tasks' ``used``/``generated`` fields. + +Node kinds follow PROV semantics: ``task`` nodes are PROV Activities and chunk +nodes are PROV Entities. Each task's ``used`` dict is packed into one "inputs" +chunk entity and its ``generated`` dict into one "outputs" chunk entity. +Chunks are deduplicated by content, so an output chunk identical to another +task's input chunk becomes a single shared node (direct lineage). Dashed +"derived" edges link a producer's output chunk to a consumer's input chunk +when they share non-trivial (key, value) pairs in temporal order. +""" + +from __future__ import annotations + +import json +import re +from typing import Any, Dict, List, Optional, Set + +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.services.stats import _to_epoch + +MAX_NODES = 400 +_TASK_PROJECTION = [ + "task_id", + "activity_id", + "used", + "generated", + "started_at", + "ended_at", + "status", + "agent_id", + "source_agent_id", + "subtype", +] + + +def _is_trivial(value: Any) -> bool: + """Values too common to imply a real producer→consumer link.""" + if value is None or isinstance(value, bool): + return True + if isinstance(value, str) and len(value) <= 1: + return True + if isinstance(value, (int, float)) and value in (0, 1, -1): + return True + return False + + +def _short(value: Any, max_len: int = 32) -> str: + text = str(value) + return text if len(text) <= max_len else text[: max_len - 1] + "…" + + +def _signature(payload: Dict[str, Any]) -> str: + """Stable content signature for a used/generated dict.""" + return json.dumps({k: repr(v) for k, v in sorted(payload.items())}, sort_keys=True) + + +def get_lineage_task_ids(db: DBAPI, workflow_id: str, seed_task_ids: List[str]) -> List[str]: + """Return all task_ids reachable (ancestors + descendants) from the seed tasks. + + Traverses the provenance graph derived from tasks' ``used``/``generated`` + fields. If the workflow has no dataflow data, returns the seeds unchanged. + + Parameters + ---------- + db : DBAPI + DB API facade. + workflow_id : str + Workflow execution id — scopes the graph traversal. + seed_task_ids : list of str + Task IDs to start the traversal from. + + Returns + ------- + list of str + All task_ids in the lineage subgraph (includes seeds). + """ + graph = build_dataflow(db, workflow_id) + if not graph: + return seed_task_ids + + fwd: Dict[str, List[str]] = {} + bwd: Dict[str, List[str]] = {} + for e in graph["edges"]: + fwd.setdefault(e["source"], []).append(e["target"]) + bwd.setdefault(e["target"], []).append(e["source"]) + + seeds: Set[str] = {f"task:{tid}" for tid in seed_task_ids} + visited: Set[str] = set(seeds) + stack = list(seeds) + while stack: + curr = stack.pop() + for adj in fwd.get(curr, []) + bwd.get(curr, []): + if adj not in visited: + visited.add(adj) + stack.append(adj) + + return [nid[5:] for nid in visited if nid.startswith("task:")] + + +def build_dataflow(db: DBAPI, workflow_id: str) -> Optional[Dict[str, Any]]: + """Build the dataflow graph for a workflow. + + Parameters + ---------- + db : DBAPI + DB API facade. + workflow_id : str + Workflow execution id. + + Returns + ------- + dict or None + ``{"level", "nodes", "edges", "truncated"}`` or None when the workflow + has no tasks with used/generated data. + """ + tasks = db.task_query(filter={"workflow_id": workflow_id}, projection=_TASK_PROJECTION) or [] + tasks = [t for t in tasks if t.get("used") or t.get("generated")] + if not tasks: + return None + tasks.sort(key=lambda t: _to_epoch(t.get("started_at")) or 0) + return _coarse(tasks) + + +def _task_node(t: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": f"task:{t['task_id']}", + "kind": "task", + "label": t.get("activity_id") or "task", + "stats": { + "task_id": t["task_id"], + "activity_id": t.get("activity_id"), + "status": t.get("status"), + "started_at": t.get("started_at"), + "ended_at": t.get("ended_at"), + "used": t.get("used") or {}, + "generated": t.get("generated") or {}, + "agent_id": t.get("agent_id"), + "source_agent_id": t.get("source_agent_id"), + "subtype": t.get("subtype"), + }, + } + + +def _flatten_payload(payload: Any) -> List[tuple[str, Any]]: + """Recursively extract flat key-value pairs from a nested structure.""" + results = [] + if isinstance(payload, dict): + for k, v in payload.items(): + if isinstance(v, (dict, list)): + results.extend(_flatten_payload(v)) + else: + results.append((k, v)) + elif isinstance(payload, list): + for item in payload: + results.extend(_flatten_payload(item)) + return results + + +def _coarse(tasks: List[Dict[str, Any]]) -> Dict[str, Any]: + nodes: List[Dict[str, Any]] = [] + edges: List[Dict[str, Any]] = [] + chunks: Dict[str, Dict[str, Any]] = {} # signature -> chunk node + truncated = False + + def _chunk(payload: Dict[str, Any], role: str) -> str: + sig = _signature(payload) + chunk = chunks.get(sig) + if chunk is None: + chunk = { + "id": f"chunk:{len(chunks)}", + "kind": "chunk", + "label": f"{len(payload)} item{'s' if len(payload) != 1 else ''}", + "stats": {"items": payload, "roles": set(), "generated_by": []}, + } + chunks[sig] = chunk + chunk["stats"]["roles"].add(role) + return chunk["id"] + + # Producer index for derived chunk→chunk edges: (key, repr(value)) -> [(task, out_chunk_id)] + producers: Dict[tuple, List[tuple]] = {} + + for t in tasks: + if len(nodes) + len(chunks) > MAX_NODES: + truncated = True + break + nodes.append(_task_node(t)) + tid = t["task_id"] + used, generated = t.get("used") or {}, t.get("generated") or {} + if used: + in_id = _chunk(used, "input") + edges.append({"source": in_id, "target": f"task:{tid}", "relation": "used"}) + if generated: + out_id = _chunk(generated, "output") + chunks[_signature(generated)]["stats"]["generated_by"].append( + { + "activity": t.get("activity_id") or "task", + "task_id": tid, + } + ) + edges.append({"source": f"task:{tid}", "target": out_id, "relation": "generated"}) + for key, value in _flatten_payload(generated): + if not _is_trivial(value): + producers.setdefault((key, repr(value)), []).append((t, out_id)) + + # Derived edges: producer's output chunk → consumer's input chunk on shared values. + seen_derived = set() + for t in tasks: + used = t.get("used") or {} + if not used: + continue + in_id = _chunk(used, "input") + t_start = _to_epoch(t.get("started_at")) + for key, value in _flatten_payload(used): + if _is_trivial(value): + continue + for producer, out_id in producers.get((key, repr(value)), ()): + if producer["task_id"] == t["task_id"] or out_id == in_id: + continue + p_end = _to_epoch(producer.get("ended_at")) + if t_start is not None and p_end is not None and p_end > t_start: + continue + if (out_id, in_id) not in seen_derived: + seen_derived.add((out_id, in_id)) + edges.append({"source": out_id, "target": in_id, "relation": "derived"}) + + # Delegation edges: delegator task -> delegatee task + for t in tasks: + source_agent_id = t.get("source_agent_id") + agent_id = t.get("agent_id") + if source_agent_id and agent_id: + delegator = None + t_start = _to_epoch(t.get("started_at")) or 0 + for s in tasks: + if s.get("agent_id") == source_agent_id: + s_start = _to_epoch(s.get("started_at")) or 0 + if s_start <= t_start: + if delegator is None or s_start > (_to_epoch(delegator.get("started_at")) or 0): + delegator = s + if delegator: + edges.append( + { + "source": f"task:{delegator['task_id']}", + "target": f"task:{t['task_id']}", + "relation": "delegation", + } + ) + + from flowcept import configs + + for chunk in chunks.values(): + roles = chunk["stats"].pop("roles") + role = "input/output" if len(roles) > 1 else next(iter(roles)) + chunk["stats"]["kind"] = role + prefix = {"input": "inputs", "output": "outputs", "input/output": "data"}[role] + keys = list(chunk["stats"]["items"].keys()) if isinstance(chunk["stats"]["items"], dict) else [] + all_positional = keys and all(re.match(r"^arg_\d+$", k) for k in keys) + if keys and not all_positional: + label = ", ".join(str(k) for k in keys) + max_len = getattr(configs, "WEBSERVER_MAX_LABEL_LENGTH", 30) + if len(label) > max_len: + chunk["label"] = f"{prefix} ({len(chunk['stats']['items'])})" + else: + chunk["label"] = label + else: + chunk["label"] = f"{prefix} ({len(chunk['stats']['items'])})" + nodes.append(chunk) + + return {"level": "coarse", "nodes": nodes, "edges": edges, "truncated": truncated} diff --git a/src/flowcept/webservice/services/reports.py b/src/flowcept/webservice/services/reports.py new file mode 100644 index 00000000..553fcc75 --- /dev/null +++ b/src/flowcept/webservice/services/reports.py @@ -0,0 +1,105 @@ +"""Workflow-card content helpers shared by workflow and campaign routers.""" + +from __future__ import annotations + +import os +import tempfile +from typing import Any, Dict, Optional + +from fastapi import HTTPException +from fastapi.responses import JSONResponse, Response + +from flowcept.report.service import build_workflow_card, generate_report +from flowcept.webservice.services.serializers import normalize_docs + + +def workflow_card_response( + format: str, + workflow_id: Optional[str] = None, + campaign_id: Optional[str] = None, +) -> Response: + """Build a workflow card as a JSON or markdown HTTP response. + + Parameters + ---------- + format : str + ``"json"`` for the structured card content, ``"markdown"`` for the rendered card. + workflow_id : str, optional + Workflow scope (exactly one of workflow_id/campaign_id must be set). + campaign_id : str, optional + Campaign scope. + + Returns + ------- + Response + ``JSONResponse`` or markdown ``Response``. + """ + if format not in ("json", "markdown", "pdf"): + raise HTTPException(status_code=400, detail=f"Unsupported format: {format}. Use json, markdown, or pdf.") + + scope = workflow_id or campaign_id + try: + if format == "json": + card = build_workflow_card(workflow_id=workflow_id, campaign_id=campaign_id) + content: Dict[str, Any] = normalize_docs( + [ + { + "dataset": card["dataset"], + "transformations": card["transformations"], + "object_summary": card["object_summary"], + "input_mode": card["input_mode"], + } + ] + )[0] + return JSONResponse(content=content) + + if format == "pdf": + if workflow_id is None or campaign_id is not None: + raise HTTPException( + status_code=400, + detail="PDF workflow cards are only supported for workflow_id scope.", + ) + fd, output_path = tempfile.mkstemp(prefix=f"workflow_card_{scope}_", suffix=".pdf") + os.close(fd) + try: + generate_report( + report_type="provenance_report", + format="pdf", + output_path=output_path, + workflow_id=workflow_id, + ) + with open(output_path, "rb") as handle: + payload = handle.read() + finally: + try: + os.remove(output_path) + except Exception: + pass + return Response( + content=payload, + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="workflow_card_{scope}.pdf"'}, + ) + + fd, output_path = tempfile.mkstemp(prefix=f"workflow_card_{scope}_", suffix=".md") + os.close(fd) + try: + generate_report( + report_type="workflow_card", + format="markdown", + output_path=output_path, + workflow_id=workflow_id, + campaign_id=campaign_id, + ) + with open(output_path, "rb") as handle: + payload = handle.read() + finally: + try: + os.remove(output_path) + except Exception: + pass + return Response(content=payload, media_type="text/markdown; charset=utf-8") + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Could not generate workflow card: {exc}") from exc diff --git a/src/flowcept/webservice/services/sorting.py b/src/flowcept/webservice/services/sorting.py index 808c2d6c..0ce408e5 100644 --- a/src/flowcept/webservice/services/sorting.py +++ b/src/flowcept/webservice/services/sorting.py @@ -25,7 +25,7 @@ def _as_sortable_number(value: Any) -> float | None: def sort_docs_by_first_date_field(docs: List[Dict[str, Any]], date_fields: List[str]) -> List[Dict[str, Any]]: - """Sort docs ascending by the first available date field from a priority list.""" + """Sort docs descending (newest first) by the first available date field from a priority list.""" if len(docs) <= 1: return docs @@ -41,8 +41,9 @@ def sort_docs_by_first_date_field(docs: List[Dict[str, Any]], date_fields: List[ return sorted( docs, key=lambda doc: ( - (0, _as_sortable_number(doc.get(chosen_field))) + (1, _as_sortable_number(doc.get(chosen_field))) if _as_sortable_number(doc.get(chosen_field)) is not None - else (1, float("inf")) + else (0, float("-inf")) ), + reverse=True, ) diff --git a/src/flowcept/webservice/services/stats.py b/src/flowcept/webservice/services/stats.py new file mode 100644 index 00000000..827a1da9 --- /dev/null +++ b/src/flowcept/webservice/services/stats.py @@ -0,0 +1,733 @@ +"""Aggregation and derivation services for webservice stats endpoints and dashboard cards. + +Mongo-backed deployments use native aggregation pipelines; other backends (e.g., LMDB) +fall back to in-Python aggregation over plain queries. +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Any, Dict, List, Optional + +from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO +from flowcept.flowcept_api.db_api import DBAPI +from flowcept.webservice.schemas.dashboards import ChartData, MetricSpec + + +def _to_epoch(value) -> Optional[float]: + """Normalize a timestamp value (float epoch-sec, epoch-ms, ISO string, or datetime) to epoch seconds.""" + if value is None: + return None + if isinstance(value, (int, float)): + # Epoch milliseconds have 13 digits; epoch seconds have 10. + return value / 1000.0 if value > 1e12 else float(value) + if isinstance(value, str): + from datetime import datetime, timezone + + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + return dt.replace(tzinfo=timezone.utc).timestamp() if dt.tzinfo is None else dt.timestamp() + except ValueError: + return None + # pymongo returns datetime objects for BSON Date fields (e.g. from $min/$max aggregations) + try: + from datetime import datetime, timezone + + if isinstance(value, datetime): + return value.replace(tzinfo=timezone.utc).timestamp() if value.tzinfo is None else value.timestamp() + except Exception: + pass + return None + + +def _mongo_dao_or_none(db: Optional[DBAPI] = None) -> Optional[DocumentDBDAO]: + """Return the DAO singleton when it supports raw aggregation pipelines, else None.""" + if db is not None and not isinstance(db, DBAPI): + return None + try: + dao = DocumentDBDAO.get_instance(create_indices=False) + return dao if hasattr(dao, "raw_pipeline") else None + except Exception: + return None + + +def get_nested(item: Dict[str, Any], field: str) -> Any: + """Read a dot-notated field value from a document.""" + current = item + for part in field.split("."): + if not isinstance(current, dict): + return None + current = current.get(part) + return current + + +def _duration(doc: Dict[str, Any]) -> Optional[float]: + started, ended = _to_epoch(doc.get("started_at")), _to_epoch(doc.get("ended_at")) + if started is not None and ended is not None: + return ended - started + return None + + +def task_summary(db: DBAPI, filter: Dict[str, Any]) -> Dict[str, Any]: + """Summarize tasks matching a filter: status counts, per-activity stats, and time range. + + Parameters + ---------- + db : DBAPI + DB API facade. + filter : dict + Mongo-style filter over the ``tasks`` collection. + + Returns + ------- + dict + ``{"count", "status_counts", "activity_stats", "time_range"}``. + """ + dao = _mongo_dao_or_none(db) + if dao is not None: + return _task_summary_mongo(dao, filter) + return _task_summary_python(db, filter) + + +def _task_summary_mongo(dao, filter: Dict[str, Any]) -> Dict[str, Any]: + match = [{"$match": filter}] if filter else [] + rows = ( + dao.raw_pipeline( + match + + [ + { + "$group": { + "_id": {"activity_id": "$activity_id", "status": "$status"}, + "count": {"$sum": 1}, + "avg_duration": {"$avg": {"$subtract": ["$ended_at", "$started_at"]}}, + "min_duration": {"$min": {"$subtract": ["$ended_at", "$started_at"]}}, + "max_duration": {"$max": {"$subtract": ["$ended_at", "$started_at"]}}, + "sum_duration": {"$sum": {"$subtract": ["$ended_at", "$started_at"]}}, + "min_started_at": {"$min": "$started_at"}, + "max_ended_at": {"$max": "$ended_at"}, + } + } + ], + collection="tasks", + ) + or [] + ) + return _merge_summary_rows( + [ + { + "activity_id": row["_id"].get("activity_id"), + "status": row["_id"].get("status"), + **{k: row.get(k) for k in row if k != "_id"}, + } + for row in rows + ] + ) + + +def _task_summary_python(db: DBAPI, filter: Dict[str, Any]) -> Dict[str, Any]: + docs = ( + db.task_query( + filter=filter, + projection=["activity_id", "status", "started_at", "ended_at"], + ) + or [] + ) + groups: Dict[tuple, Dict[str, Any]] = {} + for doc in docs: + key = (doc.get("activity_id"), doc.get("status")) + group = groups.setdefault( + key, + { + "activity_id": key[0], + "status": key[1], + "count": 0, + "durations": [], + "min_started_at": None, + "max_ended_at": None, + }, + ) + group["count"] += 1 + duration = _duration(doc) + if duration is not None: + group["durations"].append(duration) + started, ended = doc.get("started_at"), doc.get("ended_at") + if isinstance(started, (int, float)): + current = group["min_started_at"] + group["min_started_at"] = started if current is None else min(current, started) + if isinstance(ended, (int, float)): + current = group["max_ended_at"] + group["max_ended_at"] = ended if current is None else max(current, ended) + + rows = [] + for group in groups.values(): + durations = group.pop("durations") + group["avg_duration"] = sum(durations) / len(durations) if durations else None + group["min_duration"] = min(durations) if durations else None + group["max_duration"] = max(durations) if durations else None + group["sum_duration"] = sum(durations) if durations else None + rows.append(group) + return _merge_summary_rows(rows) + + +def _merge_summary_rows(rows: List[Dict[str, Any]]) -> Dict[str, Any]: + """Combine per-(activity,status) rows into the summary response shape.""" + status_counts: Dict[str, int] = defaultdict(int) + activities: Dict[str, Dict[str, Any]] = {} + total = 0 + min_started, max_ended = None, None + for row in rows: + count = row.get("count") or 0 + total += count + status = row.get("status") or "UNKNOWN" + status_counts[status] += count + + activity = activities.setdefault( + str(row.get("activity_id")), + { + "activity_id": row.get("activity_id"), + "count": 0, + "status_counts": defaultdict(int), + "avg_duration": None, + "min_duration": None, + "max_duration": None, + "sum_duration": None, + "_weighted_sum": 0.0, + "_weighted_count": 0, + }, + ) + activity["count"] += count + activity["status_counts"][status] += count + for bound, op in (("min_duration", min), ("max_duration", max)): + if row.get(bound) is not None: + current = activity[bound] + activity[bound] = row[bound] if current is None else op(current, row[bound]) + if row.get("sum_duration") is not None: + activity["sum_duration"] = (activity["sum_duration"] or 0) + row["sum_duration"] + if row.get("avg_duration") is not None: + activity["_weighted_sum"] += row["avg_duration"] * count + activity["_weighted_count"] += count + + if row.get("min_started_at") is not None: + val = _to_epoch(row["min_started_at"]) + min_started = val if min_started is None else min(min_started, val) + if row.get("max_ended_at") is not None: + val = _to_epoch(row["max_ended_at"]) + max_ended = val if max_ended is None else max(max_ended, val) + + activity_stats = [] + for activity in activities.values(): + if activity.pop("_weighted_count"): + activity["avg_duration"] = activity.pop("_weighted_sum") / activity["count"] + else: + activity.pop("_weighted_sum") + activity["status_counts"] = dict(activity["status_counts"]) + activity_stats.append(activity) + activity_stats.sort(key=lambda a: str(a["activity_id"])) + + return { + "count": total, + "status_counts": dict(status_counts), + "activity_stats": activity_stats, + "time_range": {"min_started_at": min_started, "max_ended_at": max_ended}, + } + + +def derive_campaigns(db: DBAPI) -> List[Dict[str, Any]]: + """Derive campaign summaries by grouping workflows and tasks by ``campaign_id``. + + There is no campaigns collection; campaigns exist as a grouping key. + + Returns + ------- + list of dict + One record per campaign with workflow/task counts, users, names, and time range. + """ + campaigns: Dict[str, Dict[str, Any]] = {} + + def _campaign(campaign_id: str) -> Dict[str, Any]: + return campaigns.setdefault( + campaign_id, + { + "campaign_id": campaign_id, + "workflow_count": 0, + "task_count": 0, + "users": set(), + "workflow_names": set(), + "first_ts": None, + "last_ts": None, + }, + ) + + def _expand_range(record: Dict[str, Any], *values) -> None: + for raw in values: + value = _to_epoch(raw) + if value is None: + continue + record["first_ts"] = value if record["first_ts"] is None else min(record["first_ts"], value) + record["last_ts"] = value if record["last_ts"] is None else max(record["last_ts"], value) + + dao = _mongo_dao_or_none(db) + if dao is not None: + wf_rows = ( + dao.raw_pipeline( + [ + {"$match": {"campaign_id": {"$exists": True, "$ne": None}}}, + { + "$group": { + "_id": "$campaign_id", + "workflow_count": {"$sum": 1}, + "users": {"$addToSet": "$user"}, + "workflow_names": {"$addToSet": "$name"}, + "first_ts": {"$min": "$utc_timestamp"}, + "last_ts": {"$max": "$utc_timestamp"}, + } + }, + ], + collection="workflows", + ) + or [] + ) + task_rows = ( + dao.raw_pipeline( + [ + {"$match": {"campaign_id": {"$exists": True, "$ne": None}}}, + { + "$group": { + "_id": "$campaign_id", + "task_count": {"$sum": 1}, + "first_ts": {"$min": "$started_at"}, + "last_ts": {"$max": "$ended_at"}, + } + }, + ], + collection="tasks", + ) + or [] + ) + for row in wf_rows: + record = _campaign(row["_id"]) + record["workflow_count"] = row.get("workflow_count", 0) + record["users"].update(u for u in row.get("users", []) if u) + record["workflow_names"].update(n for n in row.get("workflow_names", []) if n) + _expand_range(record, row.get("first_ts"), row.get("last_ts")) + for row in task_rows: + record = _campaign(row["_id"]) + record["task_count"] = row.get("task_count", 0) + _expand_range(record, row.get("first_ts"), row.get("last_ts")) + else: + wf_filter = {"campaign_id": {"$exists": True, "$ne": None}} + for doc in db.workflow_query(filter=wf_filter) or []: + if not doc.get("campaign_id"): + continue + record = _campaign(doc["campaign_id"]) + record["workflow_count"] += 1 + if doc.get("user"): + record["users"].add(doc["user"]) + if doc.get("name"): + record["workflow_names"].add(doc["name"]) + _expand_range(record, doc.get("utc_timestamp")) + task_docs = ( + db.task_query( + filter=wf_filter, + projection=["campaign_id", "started_at", "ended_at"], + ) + or [] + ) + for doc in task_docs: + if not doc.get("campaign_id"): + continue + record = _campaign(doc["campaign_id"]) + record["task_count"] += 1 + _expand_range(record, doc.get("started_at"), doc.get("ended_at")) + + results = [] + for record in campaigns.values(): + record["users"] = sorted(record["users"]) + record["workflow_names"] = sorted(record["workflow_names"]) + results.append(record) + results.sort( + key=lambda r: (1, r["last_ts"]) if r["last_ts"] is not None else (0, float("-inf")), + reverse=True, + ) + return results + + +def derive_agents(db: DBAPI, filter: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: + """Derive agent summaries by grouping tasks by ``agent_id`` for agents in the agents collection. + + Parameters + ---------- + db : DBAPI + DB API facade. + filter : dict, optional + Extra Mongo-style filter for querying the agents collection. + + Returns + ------- + list of dict + One record per agent with task counts, activities, and last activity time. + """ + + def to_float_ts(val): + if val is None: + return None + if isinstance(val, (int, float)): + return float(val) + from datetime import datetime as dt + + if isinstance(val, dt): + return val.timestamp() + if isinstance(val, str): + try: + return dt.fromisoformat(val.replace("Z", "+00:00")).timestamp() + except Exception: + return None + return None + + try: + stored_agents = db.agent_query(filter=filter or {}) or [] + except Exception as e: + from flowcept.commons.flowcept_logger import FlowceptLogger + + FlowceptLogger().error(f"Error querying stored agents: {e}") + stored_agents = [] + + # Filter out train_agent_id and orchestrator_agent_id + stored_agents = [a for a in stored_agents if a.get("agent_id") not in ("train_agent_id", "orchestrator_agent_id")] + + if not stored_agents: + return [] + + agent_ids = [a["agent_id"] for a in stored_agents if "agent_id" in a] + query_filter = {"agent_id": {"$in": agent_ids}} + + dao = _mongo_dao_or_none(db) + if dao is not None: + rows = ( + dao.raw_pipeline( + [ + {"$match": query_filter}, + { + "$group": { + "_id": "$agent_id", + "task_count": {"$sum": 1}, + "activities": {"$addToSet": "$activity_id"}, + "source_agent_ids": {"$addToSet": "$source_agent_id"}, + "campaign_ids": {"$addToSet": "$campaign_id"}, + "workflow_ids": {"$addToSet": "$workflow_id"}, + "last_active": {"$max": "$registered_at"}, + } + }, + ], + collection="tasks", + ) + or [] + ) + stats_map = { + row["_id"]: { + "task_count": row.get("task_count", 0), + "activities": sorted(a for a in row.get("activities", []) if a), + "source_agent_ids": sorted(s for s in row.get("source_agent_ids", []) if s), + "campaign_ids": sorted(c for c in row.get("campaign_ids", []) if c), + "workflow_ids": sorted(w for w in row.get("workflow_ids", []) if w), + "last_active": to_float_ts(row.get("last_active")), + } + for row in rows + } + else: + docs = ( + db.task_query( + filter=query_filter, + projection=[ + "agent_id", + "activity_id", + "source_agent_id", + "campaign_id", + "workflow_id", + "registered_at", + ], + ) + or [] + ) + stats_map = {} + for doc in docs: + agent_id = doc.get("agent_id") + if not agent_id: + continue + record = stats_map.setdefault( + agent_id, + { + "task_count": 0, + "activities": set(), + "source_agent_ids": set(), + "campaign_ids": set(), + "workflow_ids": set(), + "last_active": None, + }, + ) + record["task_count"] += 1 + for key, field in ( + ("activities", "activity_id"), + ("source_agent_ids", "source_agent_id"), + ("campaign_ids", "campaign_id"), + ("workflow_ids", "workflow_id"), + ): + if doc.get(field): + record[key].add(doc[field]) + ts = to_float_ts(doc.get("registered_at")) + if ts is not None: + current = record["last_active"] + record["last_active"] = ts if current is None else max(current, ts) + for record in stats_map.values(): + for key in ("activities", "source_agent_ids", "campaign_ids", "workflow_ids"): + record[key] = sorted(record[key]) + + agents = [] + for sa in stored_agents: + agent_id = sa["agent_id"] + stat = stats_map.get( + agent_id, + { + "task_count": 0, + "activities": [], + "source_agent_ids": [], + "campaign_ids": [], + "workflow_ids": [], + "last_active": None, + }, + ) + if stat["task_count"] == 0: + continue + agents.append( + { + "agent_id": agent_id, + "task_count": stat["task_count"], + "activities": stat["activities"], + "source_agent_ids": stat["source_agent_ids"], + "campaign_ids": stat["campaign_ids"], + "workflow_ids": stat["workflow_ids"], + "last_active": stat["last_active"], + "name": sa.get("name"), + "registered_at": to_float_ts(sa.get("registered_at")), + } + ) + + agents.sort( + key=lambda a: (1, a["registered_at"]) if a["registered_at"] is not None else (0, float("-inf")), + reverse=True, + ) + return agents + + +def telemetry_timeseries( + db: DBAPI, + filter: Dict[str, Any], + fields: List[str], + x_field: str = "started_at", + limit: int = 1000, +) -> List[Dict[str, Any]]: + """Extract plottable rows of dot-notated (telemetry) fields from tasks. + + Parameters + ---------- + db : DBAPI + DB API facade. + filter : dict + Mongo-style filter over tasks. + fields : list of str + Dot-notated y-value paths (e.g., ``telemetry_at_end.cpu.percent_all``). + x_field : str, optional + X-axis field (a task time field by default). + limit : int, optional + Maximum number of rows. + + Returns + ------- + list of dict + Rows of ``{x_field, task_id, activity_id, : value, ...}`` sorted by x. + """ + top_level = sorted({field.split(".")[0] for field in fields} | {x_field.split(".")[0]}) + docs = ( + db.task_query( + filter=filter, + projection=["task_id", "activity_id"] + top_level, + limit=limit, + ) + or [] + ) + rows = [] + for doc in docs: + row = { + x_field: get_nested(doc, x_field), + "task_id": doc.get("task_id"), + "activity_id": doc.get("activity_id"), + } + row.update({field: get_nested(doc, field) for field in fields}) + rows.append(row) + rows.sort(key=lambda r: (r[x_field] is None, r[x_field])) + return rows + + +def _merge_context_filter(card_filter: Dict[str, Any], context: Optional[Dict[str, Any]]) -> Dict[str, Any]: + if not context: + return dict(card_filter) + if not card_filter: + return dict(context) + return {"$and": [context, card_filter]} + + +def _resolve_collection_sizes(db: DBAPI, query_filter: Dict[str, Any]) -> List[Dict[str, Any]]: + """Return per-collection BSON byte totals for the given filter (e.g. workflow_id). + + Uses MongoDB ``$bsonSize`` on each of the three provenance collections. + Returns an empty list when Mongo is unavailable. + """ + dao = _mongo_dao_or_none(db) + if dao is None: + return [] + rows = [] + for collection in ("tasks", "objects", "workflows"): + try: + result = dao.raw_pipeline( + [ + {"$match": query_filter}, + {"$group": {"_id": None, "bytes": {"$sum": {"$bsonSize": "$$ROOT"}}}}, + ], + collection=collection, + ) + bytes_val = result[0]["bytes"] if result else 0 + except Exception: + bytes_val = 0 + rows.append({"collection": collection, "sum_bytes": bytes_val}) + return rows + + +def resolve_chart_data(db: DBAPI, data: "ChartData", context: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Resolve a declarative card data binding into plottable rows. + + This is the single data contract shared by dashboard cards, the stats router, + and LLM chart tools. + + Parameters + ---------- + db : DBAPI + DB API facade. + data : ChartData + Declarative binding (source, filter, group_by/metrics or x/y, sort, limit). + context : dict, optional + Dashboard-level filter ANDed into the card filter (e.g., ``{"campaign_id": ...}``). + + Returns + ------- + dict + ``{"rows": [...], "count": int}``. + """ + query_filter = _merge_context_filter(data.filter, context) + + if data.source == "collection_sizes": + rows = _resolve_collection_sizes(db, query_filter) + return {"rows": rows, "count": len(rows)} + + if data.group_by or data.metrics: + rows = _resolve_grouped(db, data, query_filter) + elif data.x and data.y: + rows = telemetry_timeseries(db, query_filter, fields=data.y, x_field=data.x, limit=data.limit) + else: + sort = None if data.sort is None else [(s.field, s.order) for s in data.sort] + rows = ( + db.query( + collection=data.source, + filter=query_filter, + limit=data.limit, + sort=sort, + ) + or [] + ) + rows = rows[: data.limit] + return {"rows": rows, "count": len(rows)} + + +def _resolve_grouped(db: DBAPI, data: "ChartData", query_filter: Dict[str, Any]) -> List[Dict[str, Any]]: + """Group/aggregate rows for a card, using Mongo pipelines when available.""" + metrics = data.metrics or [MetricSpec(field="", agg="count")] + dao = _mongo_dao_or_none(db) + has_elapsed_metric = any(getattr(m, "field", None) == "elapsed" for m in metrics) + if dao is not None and data.source in ("tasks", "workflows", "objects") and not has_elapsed_metric: + group_id = f"${data.group_by}" if data.group_by else None + group_stage: Dict[str, Any] = {"_id": group_id} + # MongoDB forbids dots in $group output field names; use underscores internally + # and remap back to the canonical key before returning. + mongo_key_map: Dict[str, str] = {} + for metric in metrics: + canonical = _metric_key(metric) + mongo_key = canonical.replace(".", "_") + mongo_key_map[mongo_key] = canonical + if metric.agg == "count": + group_stage[mongo_key] = {"$sum": 1} + else: + group_stage[mongo_key] = {f"${metric.agg}": f"${metric.field}"} + pipeline = ([{"$match": query_filter}] if query_filter else []) + [{"$group": group_stage}] + rows = dao.raw_pipeline(pipeline, collection=data.source) or [] + out = [] + for row in rows: + record = {data.group_by or "group": row.pop("_id")} + for mongo_key, canonical in mongo_key_map.items(): + if mongo_key in row: + record[canonical] = row.pop(mongo_key) + record.update(row) + out.append(record) + out.sort(key=lambda r: str(r.get(data.group_by or "group"))) + return out + + fields = sorted({m.field for m in metrics if m.field and m.field != "elapsed"}) + elapsed_fields = ["started_at", "ended_at"] if has_elapsed_metric else [] + top_level = sorted( + {f.split(".")[0] for f in fields} + | ({data.group_by.split(".")[0]} if data.group_by else set()) + | set(elapsed_fields) + ) + docs = ( + db.query( + collection=data.source, + filter=query_filter, + projection=top_level or None, + ) + or [] + ) + grouped: Dict[Any, List[Dict[str, Any]]] = defaultdict(list) + for doc in docs: + grouped[get_nested(doc, data.group_by) if data.group_by else None].append(doc) + out = [] + for key, docs_in_group in grouped.items(): + record = {data.group_by or "group": key} + for metric in metrics: + if metric.field == "elapsed": + # Compute elapsed as ended_at - started_at for each doc + def _elapsed(d: Dict[str, Any]) -> Optional[float]: + s, e = _to_epoch(d.get("started_at")), _to_epoch(d.get("ended_at")) + return (e - s) if s is not None and e is not None else None + + values = [v for v in (_elapsed(d) for d in docs_in_group) if v is not None] + else: + values = [ + v for v in (get_nested(d, metric.field) for d in docs_in_group) if isinstance(v, (int, float)) + ] + if metric.agg == "count": + record[_metric_key(metric)] = len(docs_in_group) + elif not values: + record[_metric_key(metric)] = None + elif metric.agg == "avg": + record[_metric_key(metric)] = sum(values) / len(values) + elif metric.agg == "sum": + record[_metric_key(metric)] = sum(values) + elif metric.agg == "min": + record[_metric_key(metric)] = min(values) + elif metric.agg == "max": + record[_metric_key(metric)] = max(values) + out.append(record) + out.sort(key=lambda r: str(r.get(data.group_by or "group"))) + return out + + +def _metric_key(metric: "MetricSpec") -> str: + return f"{metric.agg}_{metric.field}" if metric.field else metric.agg diff --git a/src/flowcept/webservice/services/streaming.py b/src/flowcept/webservice/services/streaming.py new file mode 100644 index 00000000..126c97d9 --- /dev/null +++ b/src/flowcept/webservice/services/streaming.py @@ -0,0 +1,94 @@ +"""Incremental DB polling that backs the SSE live-stream endpoints. + +Tasks are updated in place (status/ended_at), so the cursor advances over the max of +``registered_at``/``started_at``/``ended_at``/``utc_timestamp`` seen so far, and each +poll matches any of those fields beyond the cursor. Works on Mongo natively and on +equality-only backends (LMDB) by applying the time predicate in Python. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO +from flowcept.flowcept_api.db_api import DBAPI + +TASK_CURSOR_FIELDS = ("registered_at", "started_at", "ended_at", "utc_timestamp") +WORKFLOW_CURSOR_FIELDS = ("utc_timestamp", "created_at", "updated_at", "started_at", "ended_at") + + +def _supports_operators() -> bool: + dao = DocumentDBDAO.get_instance(create_indices=False) + return hasattr(dao, "raw_pipeline") + + +def _as_epoch(value: Any) -> Optional[float]: + """Convert numeric or datetime time values to epoch seconds (DB stores both kinds).""" + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, datetime): + # Mongo returns naive UTC datetimes by default. + return (value if value.tzinfo else value.replace(tzinfo=timezone.utc)).timestamp() + return None + + +def _doc_cursor(doc: Dict[str, Any], fields: Tuple[str, ...]) -> float: + values = (_as_epoch(doc.get(f)) for f in fields) + return max((v for v in values if v is not None), default=0.0) + + +def poll_new_docs( + db: DBAPI, + collection: str, + base_filter: Dict[str, Any], + cursor: float, + max_batch: int, + cursor_fields: Optional[Tuple[str, ...]] = None, +) -> Tuple[List[Dict[str, Any]], float, bool]: + """Fetch docs newer than ``cursor`` and return ``(docs, new_cursor, truncated)``. + + Parameters + ---------- + db : DBAPI + DB API facade. + collection : str + ``tasks`` or ``workflows``. + base_filter : dict + Equality filter (e.g., ``{"workflow_id": ...}``); may be empty. + cursor : float + Epoch-seconds watermark; only docs with a time field beyond it are returned. + max_batch : int + Maximum docs returned per poll. + cursor_fields : tuple of str, optional + Time fields considered for the cursor. Defaults per collection. + + Returns + ------- + tuple + ``(docs, new_cursor, truncated)``. + """ + fields = cursor_fields or (TASK_CURSOR_FIELDS if collection == "tasks" else WORKFLOW_CURSOR_FIELDS) + + if _supports_operators(): + # Time fields are floats when inserted directly and BSON dates when persisted by + # the DocumentInserter; compare against both representations. + cursor_dt = datetime.fromtimestamp(cursor, timezone.utc) + time_clause = {"$or": [{f: {"$gt": cursor}} for f in fields] + [{f: {"$gt": cursor_dt}} for f in fields]} + query_filter = {"$and": [base_filter, time_clause]} if base_filter else time_clause + docs = db.query(collection=collection, filter=query_filter) or [] + else: + docs = db.query(collection=collection, filter=base_filter or None) or [] + docs = [d for d in docs if _doc_cursor(d, fields) > cursor] + + seen: Dict[str, Dict[str, Any]] = {} + id_field = "task_id" if collection == "tasks" else "workflow_id" + for doc in docs: + key = doc.get(id_field) or str(id(doc)) + seen[key] = doc + deduped = sorted(seen.values(), key=lambda d: _doc_cursor(d, fields)) + + truncated = len(deduped) > max_batch + batch = deduped[:max_batch] + new_cursor = max((_doc_cursor(d, fields) for d in batch), default=cursor) + return batch, max(new_cursor, cursor), truncated diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 00000000..5fca3f84 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/agent/agent_tests.py b/tests/agent/agent_tests.py index 460435f7..057473b4 100644 --- a/tests/agent/agent_tests.py +++ b/tests/agent/agent_tests.py @@ -54,6 +54,53 @@ def test_loads_jsonl_buffer_when_mq_disabled(self): finally: agent.stop() + def test_mcp_db_backed_provenance_tools(self): + """The shared prov tools are exposed as MCP tools and query the real DB.""" + from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO + from flowcept.configs import MONGO_ENABLED + + if not MONGO_ENABLED: + FlowceptLogger().warning("Skipping MCP DB tools test because MongoDB is disabled.") + self.skipTest("MongoDB is disabled.") + if not Flowcept.services_alive(): + FlowceptLogger().warning("Skipping MCP DB tools test because services are not alive.") + self.skipTest("Flowcept services are not alive.") + + from uuid import uuid4 + + from flowcept.agents import flowcept_agent as agent_module + from flowcept.agents.agent_client import run_tool + from flowcept.instrumentation.task_capture import FlowceptTask + + campaign_id = f"mcp-campaign-{uuid4()}" + with Flowcept(campaign_id=campaign_id, workflow_name=f"mcp-tools-wf-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + with FlowceptTask(activity_id="mcp_seed", used={"x": 1}) as task: + task.end(generated={"y": 2}) + + deadline = 20 + while deadline > 0 and not (Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []): + sleep(0.5) + deadline -= 1 + + agent = agent_module.FlowceptAgent() + agent.start() + try: + resp = run_tool("query_provenance_tasks", kwargs={"filter": {"workflow_id": workflow_id}})[0] + tool_result = ToolResult(**json.loads(resp)) + self.assertIn(tool_result.code, {201, 301}) + items = tool_result.result["items"] + self.assertTrue(any(t["activity_id"] == "mcp_seed" for t in items)) + + resp = run_tool("list_provenance_campaigns", kwargs={})[0] + tool_result = ToolResult(**json.loads(resp)) + self.assertIn(tool_result.code, {201, 301}) + self.assertTrue(any(c["campaign_id"] == campaign_id for c in tool_result.result["items"])) + finally: + agent.stop() + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + class TestAgentInMemoryQueryTools(unittest.TestCase): class _DummyContext: @@ -131,6 +178,7 @@ def test_generate_workflow_card_tool(self): input_jsonl_path=None, ) + def test_llm_query_over_buffer(self): if not AGENT.get("api_key"): FlowceptLogger().warning("Skipping LLM agent query test because agent.api_key is not set.") diff --git a/tests/api/db_api_test.py b/tests/api/db_api_test.py index 699dbfb6..4f785c58 100644 --- a/tests/api/db_api_test.py +++ b/tests/api/db_api_test.py @@ -4,7 +4,7 @@ from flowcept.commons.flowcept_dataclasses.task_object import TaskObject from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO -from flowcept import BlobObject, Flowcept, WorkflowObject +from flowcept import BlobObject, Flowcept, WorkflowObject, AgentObject from flowcept.configs import MONGO_ENABLED from flowcept.flowceptor.telemetry_capture import TelemetryCapture @@ -39,6 +39,61 @@ def test_wf_dao(self): assert wf_obj is not None print(wf_obj) + def test_agent_dao(self): + agent_id = str(uuid4()) + agent = AgentObject(agent_id=agent_id, name="TestAgent") + agent.enrich() + + # Check registered_at is populated and is a float + assert agent.registered_at is not None + assert isinstance(agent.registered_at, float) + + assert Flowcept.db.insert_or_update_agent(agent) + + agent_obj = Flowcept.db.get_agent_object(agent_id=agent_id) + assert agent_obj is not None + assert agent_obj.name == "TestAgent" + assert agent_obj.agent_id == agent_id + assert agent_obj.registered_at == agent.registered_at + + def test_agent_dao_both_db_paths(self): + from flowcept.commons.daos.docdb_dao.mongodb_dao import MongoDBDAO + from flowcept.commons.daos.docdb_dao.lmdb_dao import LMDBDAO + from flowcept.configs import MONGO_ENABLED, LMDB_ENABLED + + agent_id = str(uuid4()) + agent = AgentObject(agent_id=agent_id, name="DBTestAgent") + agent.enrich() + + if MONGO_ENABLED: + mongo_dao = MongoDBDAO() + assert mongo_dao.insert_or_update_agent(agent) + res = mongo_dao.agent_query(filter={"agent_id": agent_id}) + assert len(res) == 1 + assert res[0]["name"] == "DBTestAgent" + assert res[0]["registered_at"] == agent.registered_at + + if LMDB_ENABLED: + lmdb_dao = LMDBDAO() + assert lmdb_dao.insert_or_update_agent(agent) + res = lmdb_dao.agent_query(filter={"agent_id": agent_id}) + assert len(res) == 1 + assert res[0]["name"] == "DBTestAgent" + assert res[0]["registered_at"] == agent.registered_at + + def test_flowcept_agent_instantiation(self): + agent_id = str(uuid4()) + agent_name = "InstantiatedAgent" + + with Flowcept(agent_id=agent_id, agent_name=agent_name, save_workflow=False, start_persistence=False): + pass + + agent_obj = Flowcept.db.get_agent_object(agent_id=agent_id) + assert agent_obj is not None + assert agent_obj.name == agent_name + assert agent_obj.agent_id == agent_id + assert agent_obj.registered_at is not None + wf2_id = str(uuid4()) print(wf2_id) @@ -488,30 +543,29 @@ def test_tasks_recursive(self): "activity_id": { "epochs_loop_iteration": [ "{'epoch': task['used']['epoch']}", - "{'model_train': ancestors[task['task_id']][-1]['task_id']}" + "{'model_train': ancestors[task['task_id']][-1]['task_id']}", ], "train_batch_iteration": [ "{'train_batch': task['used']['i'], 'train_data_path': ancestors[task['task_id']][0]['used']['train_data_path'], 'train_batch_size': ancestors[task['task_id']][0]['used']['batch_size'] }", - "{'epoch': ancestors[task['task_id']][-1]['used']['epoch']}" + "{'epoch': ancestors[task['task_id']][-1]['used']['epoch']}", ], "eval_batch_iteration": [ "{'eval_batch': task['used']['i'], 'eval_data_path': ancestors[task['task_id']][0]['used']['val_data_path'], 'train_batch_size': ancestors[task['task_id']][0]['used']['eval_batch_size'] }", - "{'epoch': ancestors[task['task_id']][-1]['used']['epoch']}" + "{'epoch': ancestors[task['task_id']][-1]['used']['epoch']}", ], }, "subtype": { "parent_forward": [ "{'model': task['activity_id']}", - "ancestors[task['task_id']][-1]['custom_provenance_id']" + "ancestors[task['task_id']][-1]['custom_provenance_id']", ], "child_forward": [ "{'module': task['activity_id']}", - "ancestors[task['task_id']][-1]['custom_provenance_id']" - ] - } + "ancestors[task['task_id']][-1]['custom_provenance_id']", + ], + }, } - d = Flowcept.db._dao().get_tasks_recursive('e9a3b567-cb56-4884-ba14-f137c0260191', mapping=mapping) - + d = Flowcept.db._dao().get_tasks_recursive("e9a3b567-cb56-4884-ba14-f137c0260191", mapping=mapping) @unittest.skipIf(not MONGO_ENABLED, "MongoDB is disabled") def test_dump(self): diff --git a/tests/api/task_query_api_test.py b/tests/api/task_query_api_test.py deleted file mode 100644 index b5210cb4..00000000 --- a/tests/api/task_query_api_test.py +++ /dev/null @@ -1,439 +0,0 @@ -import os.path -import pathlib -import unittest -import json -import random -from threading import Thread -import pytest - -pytest.skip( - "Deprecated legacy webserver tests (flowcept_webserver/TaskQueryAPI with_webserver). " - "Covered by new FastAPI tests under tests/webservice.", - allow_module_level=True, -) - -import requests -import inspect -from time import sleep -from uuid import uuid4 -from datetime import datetime, timedelta - -from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO -from flowcept.commons.flowcept_dataclasses.task_object import ( - TaskObject, -) -from flowcept.commons.vocabulary import Status -from flowcept.commons.flowcept_logger import FlowceptLogger -from flowcept.configs import WEBSERVER_PORT, WEBSERVER_HOST, MONGO_ENABLED -from flowcept.flowcept_api.task_query_api import TaskQueryAPI -from flowcept.flowcept_webserver.app import app, BASE_ROUTE -from flowcept.flowcept_webserver.resources.query_rsrc import TaskQuery -from flowcept.commons.daos.docdb_dao.mongodb_dao import MongoDBDAO -from flowcept.analytics.analytics_utils import ( - clean_dataframe, - analyze_correlations_used_vs_generated, - analyze_correlations, - analyze_correlations_used_vs_telemetry_diff, - analyze_correlations_generated_vs_telemetry_diff, - analyze_correlations_between, -) - - -def gen_mock_multi_workflow_data(size=1): - """ - Generates a multi-workflow composed of two workflows. - :param size: Maximum number of tasks to generate. The actual maximum will be 2*size because this mock data has two workflows. - :return: - """ - new_docs = [] - new_task_ids = [] - - _end = datetime.now() - - for i in range(0, size): - t1 = TaskObject() - t1.task_id = str(uuid4()) - t1.workflow_name = "generate_hyperparams" - t1.workflow_id = t1.workflow_name + str(uuid4()) - t1.adapter_id = "adapter1" - t1.used = {"ifile": "/path/a.dat", "x": random.randint(1, 100)} - t1.activity_id = "generate" - t1.generated = { - "epochs": random.randint(1, 100), - "batch_size": random.randint(16, 20), - } - - _start = _end + timedelta(minutes=i) - _end = _start + timedelta(minutes=i + 1) - - t1.started_at = int(_start.timestamp()) - t1.ended_at = int(_end.timestamp()) - t1.campaign_id = "mock_campaign" - t1.status = Status.FINISHED - t1.user = "user_test" - new_docs.append(t1.to_dict()) - new_task_ids.append(t1.task_id) - - t2 = TaskObject() - t2.task_id = str(uuid4()) - t1.adapter_id = "adapter2" - t2.workflow_name = "train" - t2.activity_id = "fit" - t2.workflow_id = t2.workflow_name + str(uuid4()) - t2.used = t1.generated - t2.generated = { - "loss": random.uniform(0.5, 50), - "accuracy": random.uniform(0.5, 0.95), - } - - _start = _end + timedelta(minutes=i) - _end = _start + timedelta(minutes=i + 1) - - t2.started_at = int(_start.timestamp()) - t2.ended_at = int(_end.timestamp()) - t2.status = Status.FINISHED - t2.campaign_id = t1.campaign_id - t2.user = t1.campaign_id - new_docs.append(t2.to_dict()) - new_task_ids.append(t2.task_id) - - return new_docs, new_task_ids - - -def gen_mock_data(size=1, with_telemetry=False): - if with_telemetry: - fname = "sample_data_with_telemetry_and_rai.json" - else: - fname = "sample_data.json" - - fpath = os.path.join(pathlib.Path(__file__).parent.resolve(), fname) - with open(fpath) as f: - docs = json.load(f) - - i = 0 - new_docs = [] - new_task_ids = [] - _end = datetime.now() - for doc in docs: - if i >= size: - break - - new_doc = doc.copy() - new_id = str(uuid4()) - new_doc["task_id"] = new_id - - _start = _end + timedelta(minutes=i) - _end = _start + timedelta(minutes=i + 1) - - new_doc["started_at"] = int(_start.timestamp()) - new_doc["ended_at"] = int(_end.timestamp()) - new_doc.pop("timestamp", None) - new_doc.pop("_id") - new_docs.append(new_doc) - new_task_ids.append(new_id) - i += 1 - - return new_docs, new_task_ids - - -@unittest.skipIf(not MONGO_ENABLED, "MongoDB is disabled") -class TaskQueryAPITest(unittest.TestCase): - URL = f"http://{WEBSERVER_HOST}:{WEBSERVER_PORT}{BASE_ROUTE}{TaskQuery.ROUTE}" - - @classmethod - def setUpClass(cls): - Thread( - target=app.run, - kwargs={"host": WEBSERVER_HOST, "port": WEBSERVER_PORT}, - daemon=True, - ).start() - sleep(2) - - def __init__(self, *args, **kwargs): - super(TaskQueryAPITest, self).__init__(*args, **kwargs) - self.logger = FlowceptLogger() - self.api = TaskQueryAPI() - - def gen_n_get_task_ids(self, generation_function, size=1, generation_args={}): - docs, task_ids = generation_function(size=size, **generation_args) - - dao = DocumentDBDAO.get_instance(create_indices=False) - init_db_count = dao.count_tasks() - dao.insert_and_update_many_tasks(docs, "task_id") - - task_ids_filter = {"task_id": {"$in": task_ids}} - return task_ids_filter, task_ids, init_db_count - - def delete_task_ids_and_assert(self, task_ids, init_db_count): - dao = DocumentDBDAO.get_instance(create_indices=False) - dao.delete_task_keys("task_id", task_ids) - final_db_count = dao.count_tasks() - assert init_db_count == final_db_count - - def test_webserver_query(self): - _filter = {"task_id": "1234"} - request_data = {"filter": json.dumps(_filter)} - - r = requests.post(TaskQueryAPITest.URL, json=request_data) - assert r.status_code == 404 - - task_ids_filter, task_ids, init_db_count = self.gen_n_get_task_ids(gen_mock_data, size=1) - request_data = {"filter": json.dumps(task_ids_filter)} - r = requests.post(TaskQueryAPITest.URL, json=request_data) - assert r.status_code == 201 - assert len(r.json()) == len(task_ids) - assert task_ids[0] == r.json()[0]["task_id"] - self.delete_task_ids_and_assert(task_ids, init_db_count) - - def test_query_api_with_webserver(self): - task_ids_filter, task_ids, init_db_count = self.gen_n_get_task_ids(gen_mock_data, size=1) - api = TaskQueryAPI(with_webserver=True) - r = api.query(task_ids_filter) - assert len(r) > 0 - assert task_ids[0] == r[0]["task_id"] - self.delete_task_ids_and_assert(task_ids, init_db_count) - - @unittest.skip("This is testing a deprecated feature.") - def test_query_api_with_and_without_webserver(self): - query_api_params = inspect.signature(TaskQueryAPI.query).parameters - doc_query_api_params = inspect.signature(MongoDBDAO.task_query).parameters - assert query_api_params == doc_query_api_params, "Function signatures do not match." - - query_api_docstring = inspect.getdoc(TaskQueryAPI.query) - doc_query_api_docstring = inspect.getdoc(MongoDBDAO.task_query) - - assert ( - query_api_docstring.strip() == doc_query_api_docstring.strip() - ), "The docstrings are not equal." - - task_ids_filter, task_ids, init_db_count = self.gen_n_get_task_ids(gen_mock_data, size=1) - - api_without = TaskQueryAPI(with_webserver=False) - res_without = api_without.query(task_ids_filter) - assert len(res_without) > 0 - assert task_ids[0] == res_without[0]["task_id"] - - api_with = TaskQueryAPI(with_webserver=True) - res_with = api_with.query(task_ids_filter) - assert len(res_with) > 0 - assert task_ids[0] == res_without[0]["task_id"] - - assert res_without == res_with - - self.delete_task_ids_and_assert(task_ids, init_db_count) - - def test_aggregation(self): - docs, task_ids = gen_mock_multi_workflow_data(size=100) - - dao = MongoDBDAO() - c0 = dao.count_tasks() - dao.insert_and_update_many_tasks(docs, indexing_key="task_id") - res = self.api.query( - aggregation=[ - ("max", "used.epochs"), - ("max", "generated.accuracy"), - ("avg", "used.batch_size"), - ] - ) - assert len(res) > 0 - for doc in res: - if doc.get("max_generated_accuracy") is not None: - assert doc["max_generated_accuracy"] > 0 - - campaign_id = docs[0]["campaign_id"] - res = self.api.query( - filter={"campaign_id": campaign_id}, - aggregation=[ - ("max", "used.epochs"), - ("max", "generated.accuracy"), - ("avg", "used.batch_size"), - ], - sort=[ - ("max_used_epochs", TaskQueryAPI.ASC), - ("ended_at", TaskQueryAPI.DESC), - ], - limit=10, - ) - assert len(res) > 0 - for doc in res: - if doc.get("max_generated_accuracy") is not None: - assert doc["max_generated_accuracy"] > 0 - - res = self.api.query( - projection=["used.batch_size"], - filter={"campaign_id": campaign_id}, - aggregation=[ - ("min", "generated.loss"), - ("max", "generated.accuracy"), - ], - sort=[ - ("ended_at", TaskQueryAPI.DESC), - ], - limit=10, - ) - assert len(res) > 1 - for doc in res: - if doc.get("max_generated_accuracy") is not None: - assert doc["max_generated_accuracy"] > 0 - - dao.delete_task_keys("task_id", task_ids) - c1 = dao.count_tasks() - assert c0 == c1 - - def test_query_df(self): - max_docs = 5 - task_ids_filter, task_ids, init_db_count = self.gen_n_get_task_ids( - gen_mock_data, size=max_docs - ) - res = self.api.df_query(task_ids_filter, remove_json_unserializables=False) - assert len(res) == max_docs - self.delete_task_ids_and_assert(task_ids, init_db_count) - - def test_query_df_telemetry(self): - max_docs = 5 - task_ids_filter, task_ids, init_db_count = self.gen_n_get_task_ids( - gen_mock_data, - size=max_docs, - generation_args={"with_telemetry": True}, - ) - df = self.api.df_query( - task_ids_filter, - remove_json_unserializables=False, - calculate_telemetry_diff=True, - ) - self.delete_task_ids_and_assert(task_ids, init_db_count) - assert len(df) == max_docs - cleaned_df = clean_dataframe(df, aggregate_telemetry=True) - assert len(df.columns) > len(cleaned_df.columns) - - def test_df_get_top_k_tasks(self): - max_docs = 100 - task_ids_filter, task_ids, init_db_count = self.gen_n_get_task_ids( - gen_mock_data, - size=max_docs, - ) - sort = [ - ("generated.loss", TaskQueryAPI.ASC), - ("used.batch_size", TaskQueryAPI.DESC), - ] - df = self.api.df_get_top_k_tasks( - filter=task_ids_filter, - calculate_telemetry_diff=False, - sort=sort, - k=10, - ) - self.delete_task_ids_and_assert(task_ids, init_db_count) - assert len(df) < max_docs - - def test_query_df_top_k_quantiles(self): - max_docs = 100 - task_ids_filter, task_ids, init_db_count = self.gen_n_get_task_ids( - gen_mock_data, - size=max_docs, - ) - clauses = [ - ("used.batch_size", ">=", 0.1), - ("generated.loss", "<=", 0.9), - ] - sort = [ - ("used.batch_size", TaskQueryAPI.ASC), - ("generated.loss", TaskQueryAPI.DESC), - ] - df = self.api.df_get_tasks_quantiles( - clauses=clauses, - filter=task_ids_filter, - sort=sort, - calculate_telemetry_diff=False, - clean_dataframe=False, - ) - self.delete_task_ids_and_assert(task_ids, init_db_count) - assert 0 < len(df) < max_docs - - def test_query_df_top_k_quantiles_sorted(self): - max_docs = 100 - task_ids_filter, task_ids, init_db_count = self.gen_n_get_task_ids( - gen_mock_data, - size=max_docs, - generation_args={"with_telemetry": True}, - ) - clauses = [ - ("telemetry_diff.process.cpu_times.user", "<", 0.5), - ] - sort = [ - ("telemetry_diff.process.cpu_times.user", TaskQueryAPI.ASC), - ("generated.loss", TaskQueryAPI.ASC), - ("generated.responsible_ai_metadata.flops", TaskQueryAPI.ASC), - ] - df = self.api.df_get_tasks_quantiles( - clauses=clauses, - filter=task_ids_filter, - sort=sort, - calculate_telemetry_diff=True, - clean_dataframe=True, - ) - self.delete_task_ids_and_assert(task_ids, init_db_count) - assert 0 < len(df) < max_docs - - def test_correlations(self): - max_docs = 9 - task_ids_filter, task_ids, init_db_count = self.gen_n_get_task_ids( - gen_mock_data, - size=max_docs, - generation_args={"with_telemetry": True}, - ) - df = self.api.df_query(task_ids_filter, calculate_telemetry_diff=True) - self.delete_task_ids_and_assert(task_ids, init_db_count) - assert len(df) == max_docs - - df = clean_dataframe(df, aggregate_telemetry=True, sum_lists=True) - - correlations_df = analyze_correlations(df) - assert len(correlations_df) - - correlations_df = analyze_correlations_between( - df, col_pattern1="generated.", col_pattern2="used." - ) - assert len(correlations_df) - - correlations_df_ = analyze_correlations_used_vs_generated(df) - assert len(correlations_df_) - assert all(correlations_df == correlations_df_) - - correlations_df = analyze_correlations_used_vs_telemetry_diff(df) - assert len(correlations_df) - - correlations_df = analyze_correlations_generated_vs_telemetry_diff(df) - - assert len(correlations_df) - - def test_find_best_tasks(self): - max_docs = 9 - task_ids_filter, task_ids, init_db_count = self.gen_n_get_task_ids( - gen_mock_data, - size=max_docs, - generation_args={"with_telemetry": True}, - ) - best_tasks = ( - self.api.find_interesting_tasks_based_on_correlations_generated_and_telemetry_data( - filter=task_ids_filter - ) - ) - assert len(best_tasks) - self.delete_task_ids_and_assert(task_ids, init_db_count) - - def test_find_outliers(self): - max_docs = 9 - task_ids_filter, task_ids, init_db_count = self.gen_n_get_task_ids( - gen_mock_data, - size=max_docs, - generation_args={"with_telemetry": True}, - ) - outliers = self.api.df_find_outliers( - outlier_threshold=5, - calculate_telemetry_diff=True, - filter=task_ids_filter, - clean_dataframe=True, - keep_task_id=True, - ) - assert len(outliers) - self.delete_task_ids_and_assert(task_ids, init_db_count) diff --git a/tests/conftest.py b/tests/conftest.py index 105d6eda..cf1f6f31 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,16 @@ from flowcept.configs import PROJECT_NAME +def pytest_addoption(parser): + """Register Flowcept-specific pytest flags.""" + parser.addoption( + "--keep-webservice-test-data", + action="store_true", + default=False, + help="Keep MongoDB data created by webservice integration tests for UI inspection.", + ) + + @pytest.hookimpl(tryfirst=True) def pytest_configure(config): """Route Flowcept logs through pytest's logging capture and CLI output.""" diff --git a/tests/doc_db_inserter/doc_db_inserter_test.py b/tests/doc_db_inserter/doc_db_inserter_test.py index 80969649..b66e9bf6 100644 --- a/tests/doc_db_inserter/doc_db_inserter_test.py +++ b/tests/doc_db_inserter/doc_db_inserter_test.py @@ -26,7 +26,11 @@ def test_task_message_without_campaign_id_does_not_require_kv_dao(): inserter._handle_task_message(message) - assert inserter.buffer == [{"task_id": "task-1", "activity_id": "activity"}] + assert len(inserter.buffer) == 1 + buffered = inserter.buffer[0] + assert buffered.get("task_id") == "task-1" + assert buffered.get("activity_id") == "activity" + assert "campaign_id" not in buffered, "campaign_id must not be injected when kv_dao is None" @unittest.skipIf(not MONGO_ENABLED, "MongoDB is disabled") diff --git a/tests/instrumentation_tests/ml_tests/single_layer_perceptron_test.py b/tests/instrumentation_tests/ml_tests/single_layer_perceptron_test.py index 46d0a8d6..19ea33ac 100644 --- a/tests/instrumentation_tests/ml_tests/single_layer_perceptron_test.py +++ b/tests/instrumentation_tests/ml_tests/single_layer_perceptron_test.py @@ -4,6 +4,7 @@ import random import pytest +from uuid import uuid4 pytest.importorskip("torch") @@ -13,7 +14,7 @@ from flowcept import Flowcept from flowcept.configs import MONGO_ENABLED -from flowcept.commons.vocabulary import ML_Types +from flowcept.commons.vocabulary import ML_Types, PROV_AGENT from flowcept.instrumentation.flowcept_task import flowcept_task, get_current_context_task_id @@ -34,9 +35,7 @@ def _set_reproducibility(seed=0): reproducibility["torch_manual_seeded"] = True reproducibility["torch_cuda_manual_seeded"] = torch.cuda.is_available() reproducibility["torch_deterministic_algorithms"] = ( - torch.are_deterministic_algorithms_enabled() - if hasattr(torch, "are_deterministic_algorithms_enabled") - else True + torch.are_deterministic_algorithms_enabled() if hasattr(torch, "are_deterministic_algorithms_enabled") else True ) reproducibility["torch_cudnn_deterministic"] = ( bool(getattr(torch.backends.cudnn, "deterministic", False)) if hasattr(torch.backends, "cudnn") else False @@ -48,10 +47,8 @@ def _set_reproducibility(seed=0): def shape_args_handler(*args, **kwargs): - """Capture tensor values as shape metadata for provenance payloads.""" def _shape_key(name): return name if name.endswith("_shape") else f"{name}_shape" - handled = {} for i, arg in enumerate(args): key = f"arg_{i}" @@ -66,7 +63,6 @@ def _shape_key(name): handled[key] = value return handled - class SingleLayerPerceptron(nn.Module): def __init__(self, input_size, **kwargs): super().__init__() @@ -123,6 +119,32 @@ def validate(model, criterion, x_val, y_val): return loss.item(), accuracy +@flowcept_task(output_names=["dataset_config", "n_configs"]) +def call_hpc_agent(agent_id=None): + dataset_config = dict(n_samples=120, split_ratio=0.8) + n_configs = 5 + return dataset_config, n_configs + +@flowcept_task(output_names=["configs"], subtype=PROV_AGENT.AGENT_TOOL) +def submit_gridsearch_job( + n_configs=5, + agent_id=None, + source_agent_id=None, +): + """Simulate submitting a training job to an HPC system.""" + from uuid import uuid4 + configs = [ + {"epochs": 2, "learning_rate": 0.01, "n_input_neurons": 1}, + {"epochs": 4, "learning_rate": 0.03, "n_input_neurons": 1}, + {"epochs": 6, "learning_rate": 0.08, "n_input_neurons": 2}, + {"epochs": 10, "learning_rate": 0.12, "n_input_neurons": 2}, + {"epochs": 14, "learning_rate": 0.20, "n_input_neurons": 2}, + ] + configs = configs[:n_configs] + assert len(configs) == n_configs + return configs + + @flowcept_task(subtype=ML_Types.LEARNING) def train_and_validate( n_input_neurons, @@ -136,6 +158,8 @@ def train_and_validate( learning_rate=0.1, config_id=None, torch_only=False, + agent_id=None, + job_id=None, ): """Train a perceptron and return validation metrics. @@ -202,28 +226,64 @@ def train_and_validate( return result +def _best_gridsearch_result(results): + return min(results, key=lambda item: float(item["result"]["best_val_loss"]))["result"] + + +def select_best_model_args_handler(results=None, **kwargs): + """Capture the selected training output as model-selection input.""" + if results is None: + return kwargs + result = _best_gridsearch_result(results) + res = { + "torch_model_object_id": result["torch_model_object_id"], + "best_val_loss": result["best_val_loss"], + "config_id": result["config_id"], + } + for k, v in kwargs.items(): + res[k] = v + return res + @flowcept_task(subtype=ML_Types.MODEL_SELECTION) -def select_best_model(workflow_id): - """Select the best model object for a workflow by minimal recorded loss.""" - best_doc = Flowcept.db.query( - collection="objects", - filter={ - "workflow_id": workflow_id, - "object_type": "ml_model", - "custom_metadata.loss": {"$exists": True}, - }, - sort=[("custom_metadata.loss", 1)], - limit=1, - )[0] - Flowcept.db.update_object_metadata( - object_id=best_doc["object_id"], - tags=["best"], - control_version=True - ) +def select_best_model(results, agent_id=None): + """Select the best model from train task outputs by minimal validation loss.""" + min_loss = float("inf") + best_model_object_id = None + best_config_id = None + + for r in results: + if isinstance(r, dict): + if "torch_model_object_id" in r: + obj_id = r["torch_model_object_id"] + elif "result" in r and isinstance(r["result"], dict) and "torch_model_object_id" in r["result"]: + obj_id = r["result"]["torch_model_object_id"] + else: + obj_id = None + else: + obj_id = r + + if not obj_id: + raise Exception("This can't happen") + + model_obj = Flowcept.db.get_ml_model(obj_id) + if model_obj and model_obj.custom_metadata: + loss = model_obj.custom_metadata.get("loss") + if loss is not None and loss < min_loss: + min_loss = loss + best_model_object_id = obj_id + best_config_id = model_obj.custom_metadata.get("config_id") + + if best_model_object_id: + Flowcept.db.update_object_metadata( + object_id=best_model_object_id, + tags=["best"], + control_version=True, + ) + return { - "selected_model_object_id": best_doc["object_id"], - "selected_loss": best_doc["custom_metadata"]["loss"], - "selected_config_id": best_doc["custom_metadata"]["config_id"], + "selected_model_object_id": best_model_object_id, + "selected_loss": min_loss, + "selected_config_id": best_config_id, } @@ -237,6 +297,57 @@ def run_training(n_samples, split_ratio, n_input_neurons, epochs): return result +def run_gridsearch_experiment(campaign_id=None): + """Run the Perceptron GridSearch scenario and return captured artifacts.""" + reproducibility = _set_reproducibility(seed=42) + + kwargs = { + "workflow_name": "Perceptron GridSearch", + "workflow_subtype": ML_Types.WORKFLOW, + "workflow_args": reproducibility, + } + if campaign_id is not None: + kwargs["campaign_id"] = campaign_id + + with Flowcept(**kwargs) as fc: + orchestrator_agent_id = fc.save_agent(name="Orchestrator") + hpc_agent_id = fc.save_agent(name="HPCAgent") + + dataset_config, n_configs = call_hpc_agent(agent_id=orchestrator_agent_id) + + configs = submit_gridsearch_job(n_configs=n_configs, agent_id=hpc_agent_id, source_agent_id=orchestrator_agent_id) + + x_train, y_train, x_val, y_val, dataset_id = get_dataset(**dataset_config) + + + results = [] + for idx, cfg in enumerate(configs, 1): + result = train_and_validate( + n_input_neurons=cfg["n_input_neurons"], + epochs=cfg["epochs"], + learning_rate=cfg["learning_rate"], + x_train=x_train, + y_train=y_train, + x_val=x_val, + y_val=y_val, + dataset_id=dataset_id, + checkpoint_check=2, + config_id=f"cfg_{idx}", + torch_only=True, + ) + results.append({"torch_model_object_id": result.get("torch_model_object_id")}) + + selected = select_best_model(results, agent_id=orchestrator_agent_id) + + return { + "workflow_id": Flowcept.current_workflow_id, + "tasks": Flowcept.db.get_tasks_from_current_workflow(), + "configs": configs, + "results": results, + "selected": selected, + } + + def assert_single_inference_shape(model, sample): """Run a single inference and validate expected output shape.""" model.eval() @@ -252,6 +363,7 @@ def asserts(tasks): dataset_task = next((t for t in tasks if t.get("activity_id") == "get_dataset"), None) assert dataset_task is not None assert dataset_task.get("subtype") == ML_Types.DATA_PREP + assert dataset_task.get("agent_id") is None generated = dataset_task.get("generated", {}) assert tuple(generated.get("x_train_shape", ())) == (96, 2) assert tuple(generated.get("y_train_shape", ())) == (96, 1) @@ -261,6 +373,13 @@ def asserts(tasks): train_task = next((t for t in tasks if t.get("activity_id") == "train_and_validate"), None) assert train_task is not None assert train_task.get("subtype") == ML_Types.LEARNING + assert train_task.get("agent_id") is None + + + + select_best_task = next((t for t in tasks if t.get("activity_id") == "select_best_model"), None) + if select_best_task is not None: + assert select_best_task.get("agent_id").startswith("orchestrator_agent_") train_generated = train_task.get("generated", {}) ml_model_object_id = train_generated.get("ml_model_object_id") torch_model_object_id = train_generated.get("torch_model_object_id") @@ -318,7 +437,6 @@ def asserts(tasks): class SingleLayerPerceptronTests(unittest.TestCase): - @unittest.skipIf(not MONGO_ENABLED, "MongoDB is disabled") def test_single_layer_perceptron_example_flow(self): params = { @@ -374,47 +492,7 @@ def test_single_layer_perceptron_gridsearch_torch_only(self): def run_gridsearch_experiment(self): """Run the grid-search scenario and return collected artifacts for assertions.""" - reproducibility = _set_reproducibility(seed=42) - configs = [ - {"epochs": 2, "learning_rate": 0.01, "n_input_neurons": 1}, - {"epochs": 4, "learning_rate": 0.03, "n_input_neurons": 1}, - {"epochs": 6, "learning_rate": 0.08, "n_input_neurons": 2}, - {"epochs": 10, "learning_rate": 0.12, "n_input_neurons": 2}, - {"epochs": 14, "learning_rate": 0.20, "n_input_neurons": 2}, - ] - - with Flowcept( - workflow_name="Perceptron GridSearch", - workflow_subtype=ML_Types.WORKFLOW, - workflow_args=reproducibility, - ): - x_train, y_train, x_val, y_val, dataset_id = get_dataset(120, 0.8) - results = [] - for idx, cfg in enumerate(configs, 1): - result = train_and_validate( - n_input_neurons=cfg["n_input_neurons"], - epochs=cfg["epochs"], - learning_rate=cfg["learning_rate"], - x_train=x_train, - y_train=y_train, - x_val=x_val, - y_val=y_val, - dataset_id=dataset_id, - checkpoint_check=2, - config_id=f"cfg_{idx}", - torch_only=True, - ) - results.append({"config": cfg, "result": result}) - - selected = select_best_model(Flowcept.current_workflow_id) - - return { - "workflow_id": Flowcept.current_workflow_id, - "tasks": Flowcept.db.get_tasks_from_current_workflow(), - "configs": configs, - "results": results, - "selected": selected, - } + return run_gridsearch_experiment() def assert_gridsearch_experiment(self, run_data): """Assert all grid-search expectations from captured run data.""" @@ -434,7 +512,7 @@ def assert_gridsearch_experiment(self, run_data): "- **select_best_model** (subtype=`model_selection`)", ], expected_content_lines=[ - "`tags`: `best`", + " - tags: `best`", ], ) @@ -492,7 +570,6 @@ def assert_provenance_reports( assert pdf_stats["report_type"] == "provenance_report" assert pdf_stats["format"] == "pdf" - def assert_model_metadata(self, reloaded_torch_model, torch_model_object_id): assert hasattr(reloaded_torch_model, "_flowcept_model_object") flowcept_model_object = reloaded_torch_model._flowcept_model_object @@ -512,6 +589,15 @@ def assert_gridsearch_tasks(self, tasks, configs): assert all(task.get("subtype") == ML_Types.LEARNING for task in learning_tasks) assert all(task.get("generated", {}).get("torch_model_object_id") is not None for task in learning_tasks) assert all("ml_model_object_id" not in task.get("generated", {}) for task in learning_tasks) + assert all(task.get("agent_id") is None for task in learning_tasks) + + submit_tasks = [t for t in tasks if t.get("activity_id") == "submit_gridsearch_job"] + assert len(submit_tasks) == 1 + # output_names=["configs"] with a list result must store under "configs", not "arg_0" + submit_generated = submit_tasks[0].get("generated", {}) + assert "configs" in submit_generated, f"Expected 'configs' key in generated, got: {list(submit_generated.keys())}" + assert "arg_0" not in submit_generated, "arg_0 should not appear when output_names is set" + model_selection_tasks = [t for t in tasks if t.get("activity_id") == "select_best_model"] assert len(model_selection_tasks) == 1 assert model_selection_tasks[0].get("subtype") == ML_Types.MODEL_SELECTION @@ -529,17 +615,33 @@ def assert_gridsearch_tasks(self, tasks, configs): def assert_gridsearch_results(self, results, selected): """Validate grid-search result quality and selected model consistency.""" assert len(results) == 5 - best_losses = [r["result"]["best_val_loss"] for r in results if r["result"]["best_val_loss"] is not None] - assert len(best_losses) == 5 - rounded_losses = {round(float(loss), 4) for loss in best_losses} + model_losses = [] + best_loss = float("inf") + best_model_object_id = None + best_cfg = None + + for r in results: + obj_id = r["torch_model_object_id"] + model_obj = Flowcept.db.get_ml_model(obj_id) + assert model_obj is not None + loss = model_obj.custom_metadata.get("loss") + assert loss is not None + model_losses.append(loss) + + if loss < best_loss: + best_loss = loss + best_model_object_id = obj_id + best_cfg = { + "n_input_neurons": model_obj.custom_metadata.get("n_input_neurons") + } + + assert len(model_losses) == 5 + rounded_losses = {round(float(loss), 4) for loss in model_losses} assert len(rounded_losses) > 1 - best_entry = min(results, key=lambda item: item["result"]["best_val_loss"]) - best_cfg = best_entry["config"] - best_model_object_id = best_entry["result"]["torch_model_object_id"] assert best_model_object_id is not None assert selected["selected_model_object_id"] == best_model_object_id - assert abs(float(selected["selected_loss"]) - float(best_entry["result"]["best_val_loss"])) < 1e-9 + assert abs(float(selected["selected_loss"]) - float(best_loss)) < 1e-9 return best_cfg, best_model_object_id def assert_gridsearch_best_model_inference(self, best_cfg, best_model_object_id): diff --git a/tests/report/report_service_test.py b/tests/report/report_service_test.py index e7692774..6a9f0c2f 100644 --- a/tests/report/report_service_test.py +++ b/tests/report/report_service_test.py @@ -327,6 +327,42 @@ def test_generate_report_hides_empty_resource_sections_and_aggregation(self): assert "## Per-activity Resource Usage" not in content assert "## Aggregation Method" not in content + def test_generate_report_derives_infrastructure_from_machine_info(self): + records = [ + { + "type": "workflow", + "workflow_id": "wf-machine-1", + "name": "machine_demo", + "flowcept_version": "0.10.5", + "machine_info": { + "interceptor-1": { + "platform": {"system": "Linux", "release": "6.8.0", "machine": "x86_64"}, + "cpu": {"brand_raw": "Test CPU", "count": 16}, + "memory": {"virtual": {"total": 17179869184}}, + } + }, + }, + { + "type": "task", + "workflow_id": "wf-machine-1", + "task_id": "t1", + "activity_id": "run", + "status": "FINISHED", + "started_at": 10.0, + "ended_at": 11.0, + }, + ] + with tempfile.TemporaryDirectory() as td: + output = Path(td) / "WORKFLOW_CARD.md" + Flowcept.generate_report(records=records, output_path=str(output)) + content = output.read_text(encoding="utf-8") + assert "- **host_os:** `Linux 6.8.0 x86_64`" in content + assert "- **compute_hardware:** `16 CPU cores (Test CPU); 16.00 GB RAM`" in content + assert "- **primary_software:** `Flowcept 0.10.5`" in content + assert "resource_manager" not in content + assert "environment_snapshot" not in content + assert "data not captured" not in content + def test_generate_report_hides_resource_sections_for_empty_telemetry_snapshots(self): records = [ { @@ -489,7 +525,8 @@ def test_generate_report_lists_up_to_five_latest_objects_per_type(self): assert "- **Models:**" in content assert "- **Datasets:**" in content - assert "`custom_metadata`:" in content + assert " - custom_metadata:" in content + assert "
" not in content assert "model-5" in content assert "model-0" not in content assert "Latest 5" not in content diff --git a/tests/webservice/test_webservice_api.py b/tests/webservice/test_webservice_api.py index 30f01b64..a9d844b6 100644 --- a/tests/webservice/test_webservice_api.py +++ b/tests/webservice/test_webservice_api.py @@ -11,6 +11,7 @@ from flowcept.commons.flowcept_dataclasses.workflow_object import WorkflowObject from flowcept.webservice.deps import get_db_api from flowcept.webservice.main import create_app +from flowcept.webservice.services.dashboard_store import get_dashboard_store class FakeDB: @@ -26,6 +27,7 @@ def __init__(self): {"task_id": "t1", "workflow_id": "wf-1", "status": "finished", "started_at": 10}, {"task_id": "t3", "workflow_id": "wf-2", "status": "finished", "started_at": 30}, ] + self.agents = [] self.objects = [ { "object_id": "o1", @@ -160,8 +162,36 @@ def query(self, **kwargs): rs = [{k: v for k, v in row.items() if k in projection} for row in rs] return rs[:limit] if limit else rs + if collection == "agents": + rs = [ag for ag in self.agents if self._matches_filter(ag, filter_)] + if sort: + for field, order in reversed(sort): + rs = sorted(rs, key=lambda item: self._nested_get(item, field), reverse=(order == -1)) + if projection: + rs = [{k: v for k, v in row.items() if k in projection} for row in rs] + return rs[:limit] if limit else rs + return [] + def delete_agents_with_filter(self, filter): + self.agents = [ag for ag in self.agents if not self._matches_filter(ag, filter)] + return True + + def agent_query( + self, + filter, + projection=None, + limit=0, + sort=None, + ): + rs = [ag for ag in self.agents if self._matches_filter(ag, filter or {})] + if sort: + for field, order in reversed(sort): + rs = sorted(rs, key=lambda item: item.get(field), reverse=(order == -1)) + if projection: + rs = [{k: v for k, v in row.items() if k in projection} for row in rs] + return rs[:limit] if limit else rs + def task_query( self, filter, @@ -212,12 +242,49 @@ def build_client() -> tuple[TestClient, FakeDB]: return TestClient(app), fake_db +class FakeDashboardStore: + """Small in-memory dashboard store for route contract tests.""" + + def __init__(self): + self.docs = {} + + def save(self, dashboard): + self.docs[dashboard["dashboard_id"]] = dashboard + return True + + def get(self, dashboard_id): + return self.docs.get(dashboard_id) + + def list(self): + return list(self.docs.values()) + + def list_by_type(self, dashboard_type): + return [doc for doc in self.docs.values() if doc.get("dashboard_type") == dashboard_type] + + def delete(self, dashboard_id): + return self.docs.pop(dashboard_id, None) is not None + + +def test_info_endpoint(): + from flowcept.version import __version__ + + client, _ = build_client() + rs = client.get("/api/v1/info") + assert rs.status_code == 200 + assert rs.json() == {"service": "flowcept", "version": __version__} + + def test_root_and_openapi_endpoints(): client, _ = build_client() root = client.get("/") assert root.status_code == 200 - assert root.json()["service"] == "flowcept-webservice" + if root.headers["content-type"].startswith("application/json"): + # No built UI assets present: root exposes the API status payload. + assert root.json()["service"] == "flowcept-webservice" + else: + # Built UI assets present: root serves the SPA index page. + assert "text/html" in root.headers["content-type"] assert client.get("/openapi.json").status_code == 200 assert client.get("/docs").status_code == 200 @@ -236,7 +303,7 @@ def test_workflows_list_get_and_query(): rs = client.get("/api/v1/workflows", params={"limit": 10}) assert rs.status_code == 200 items = rs.json()["items"] - assert [item["workflow_id"] for item in items] == ["wf-2", "wf-1"] + assert [item["workflow_id"] for item in items] == ["wf-1", "wf-2"] rs = client.get("/api/v1/workflows", params={"user": "alice", "limit": 5}) assert rs.status_code == 200 @@ -269,10 +336,38 @@ def _fake_generate_report(**kwargs): assert rs.status_code == 200 assert rs.headers["content-type"].startswith("text/markdown") - assert "attachment; filename=\"workflow_card_wf-1.md\"" == rs.headers["content-disposition"] + assert 'attachment; filename="workflow_card_wf-1.md"' == rs.headers["content-disposition"] assert "# Workflow Card" in rs.text +def test_workflow_card_pdf_download_route(): + client, _ = build_client() + + def _fake_generate_report(**kwargs): + output = kwargs["output_path"] + Path(output).write_bytes(b"%PDF-1.4\n%%EOF") + return {"output": output} + + with patch("flowcept.webservice.services.reports.generate_report", side_effect=_fake_generate_report): + rs = client.get("/api/v1/workflows/wf-1/workflow_card", params={"format": "pdf"}) + + assert rs.status_code == 200 + assert rs.headers["content-type"].startswith("application/pdf") + assert rs.headers["content-disposition"] == 'attachment; filename="workflow_card_wf-1.pdf"' + assert rs.content.startswith(b"%PDF-1.4") + + +def test_workflow_card_route_is_named_workflow_card(): + client, _ = build_client() + + openapi = client.get("/openapi.json") + assert openapi.status_code == 200 + schema = openapi.json() + paths = schema["paths"] + assert "/api/v1/workflows/{workflow_id}/workflow_card" in paths + assert "/api/v1/workflows/{workflow_id}/provenance_card" not in paths + + def test_workflows_errors(): client, _ = build_client() @@ -305,7 +400,7 @@ def test_tasks_list_get_by_workflow_and_query(): rs = client.get("/api/v1/tasks", params={"workflow_id": "wf-1", "limit": 10}) assert rs.status_code == 200 assert rs.json()["count"] == 2 - assert [item["task_id"] for item in rs.json()["items"]] == ["t1", "t2"] + assert [item["task_id"] for item in rs.json()["items"]] == ["t2", "t1"] rs = client.get("/api/v1/tasks/t1") assert rs.status_code == 200 @@ -332,6 +427,9 @@ def test_tasks_list_get_by_workflow_and_query(): def test_tasks_errors_and_validation(): client, _ = build_client() + rs = client.get("/api/v1/tasks/") + assert rs.status_code == 404 + rs = client.get("/api/v1/tasks/missing") assert rs.status_code == 404 @@ -360,7 +458,7 @@ def test_objects_list_get_version_history_and_query(): rs = client.get("/api/v1/objects", params={"limit": 10}) assert rs.status_code == 200 - assert [item["object_id"] for item in rs.json()["items"]] == ["o2", "o3", "o1"] + assert [item["object_id"] for item in rs.json()["items"]] == ["o1", "o2", "o3"] rs = client.get("/api/v1/objects/o1") assert rs.status_code == 200 @@ -574,3 +672,237 @@ def test_unified_scoped_query_rejects_unsupported_operator(): ) assert rs.status_code == 400 assert "Unsupported filter operator" in rs.json()["detail"] + + +def test_dashboard_routes_accept_charts_contract(): + app = create_app() + store = FakeDashboardStore() + app.dependency_overrides[get_dashboard_store] = lambda: store + client = TestClient(app) + + spec = { + "name": "dashboard", + "context": {"workflow_id": "wf-1"}, + "charts": [ + { + "chart_id": "c1", + "type": "chart", + "data": {"source": "tasks", "filter": {"workflow_id": "wf-1"}}, + } + ], + "layout": [{"chart_id": "c1", "x": 0, "y": 0, "w": 6, "h": 4}], + } + + rs = client.post("/api/v1/dashboards", json=spec) + assert rs.status_code == 201 + body = rs.json() + assert body["charts"][0]["chart_id"] == "c1" + assert body["layout"][0]["chart_id"] == "c1" + + +def test_agents_and_dataflow_routes(): + client, fake_db = build_client() + + fake_db.tasks = [ + { + "task_id": "t1", + "workflow_id": "wf-1", + "status": "finished", + "started_at": 10, + "agent_id": "agent-1", + "source_agent_id": "orchestrator", + "used": {"x": 1}, + "generated": {"y": 2}, + }, + { + "task_id": "t2", + "workflow_id": "wf-1", + "status": "running", + "started_at": 20, + "agent_id": "agent-2", + "used": {"y": 2}, + "generated": {"z": 3}, + }, + ] + fake_db.agents = [ + {"agent_id": "agent-1", "name": "Agent 1", "registered_at": 10}, + {"agent_id": "agent-2", "name": "Agent 2", "registered_at": 20}, + ] + + rs = client.get("/api/v1/agents") + assert rs.status_code == 200 + agents = rs.json()["items"] + assert len(agents) == 2 + agent_map = {a["agent_id"]: a for a in agents} + assert "agent-1" in agent_map + assert "agent-2" in agent_map + + rs = client.get("/api/v1/agents/agent-1") + assert rs.status_code == 200 + assert rs.json()["agent"]["agent_id"] == "agent-1" + + rs = client.get("/api/v1/workflows/wf-1/dataflow") + assert rs.status_code == 200 + dataflow = rs.json() + task_nodes = [n for n in dataflow["nodes"] if n["kind"] == "task"] + assert len(task_nodes) == 2 + for node in task_nodes: + stats = node["stats"] + assert "agent_id" in stats + assert "source_agent_id" in stats + if node["id"] == "task:t1": + assert stats["agent_id"] == "agent-1" + assert stats["source_agent_id"] == "orchestrator" + + +def test_dataflow_label_fallback(): + from flowcept import configs + + original_max = getattr(configs, "WEBSERVER_MAX_LABEL_LENGTH", 30) + + client, fake_db = build_client() + fake_db.tasks = [ + { + "task_id": "t1", + "workflow_id": "wf-1", + "status": "finished", + "started_at": 10, + "used": { + "short_key": 1, + "a_very_long_input_key_that_exceeds_ten_characters": 2, + }, + "generated": { + "another_extremely_long_output_key_name_that_exceeds_ten": 3, + }, + } + ] + + try: + configs.WEBSERVER_MAX_LABEL_LENGTH = 10 + rs = client.get("/api/v1/workflows/wf-1/dataflow") + assert rs.status_code == 200 + dataflow = rs.json() + + # Verify the chunks have fallback labels + chunks = [n for n in dataflow["nodes"] if n["kind"] == "chunk"] + assert len(chunks) == 2 + + # Since the labels are longer than 10 characters, they must fall back to "inputs (2)" and "outputs (1)" + input_chunk = next(n for n in chunks if n["stats"]["kind"] == "input") + output_chunk = next(n for n in chunks if n["stats"]["kind"] == "output") + + assert input_chunk["label"] == "inputs (2)" + assert output_chunk["label"] == "outputs (1)" + + finally: + configs.WEBSERVER_MAX_LABEL_LENGTH = original_max + + +def test_dataflow_label_no_positional_args(): + """Chunk labels must not expose raw arg_N positional-argument keys.""" + client, fake_db = build_client() + fake_db.tasks = [ + { + "task_id": "t1", + "workflow_id": "wf-1", + "status": "finished", + "started_at": 10, + "used": {"arg_0": 1, "arg_1": 2}, + "generated": {"result": 42}, + } + ] + rs = client.get("/api/v1/workflows/wf-1/dataflow") + assert rs.status_code == 200 + dataflow = rs.json() + chunks = [n for n in dataflow["nodes"] if n["kind"] == "chunk"] + assert len(chunks) == 2 + input_chunk = next(n for n in chunks if n["stats"]["kind"] == "input") + # "arg_0, arg_1" is not a useful label; should fall back to count form + assert "arg_" not in input_chunk["label"] + # Named output keys should still render as-is + output_chunk = next(n for n in chunks if n["stats"]["kind"] == "output") + assert "result" in output_chunk["label"] + + +def test_delete_empty_agents(): + client, fake_db = build_client() + fake_db.agents = [ + {"agent_id": "agent-active", "name": "Active Agent", "registered_at": 10}, + {"agent_id": "agent-empty", "name": "Empty Agent", "registered_at": 20}, + ] + fake_db.tasks = [ + { + "task_id": "t1", + "workflow_id": "wf-1", + "status": "finished", + "started_at": 10, + "agent_id": "agent-active", + } + ] + + rs = client.delete("/api/v1/agents/cleanup/empty") + assert rs.status_code == 200 + body = rs.json() + assert body["deleted_count"] == 1 + + # Verify agent-empty is deleted, and agent-active remains + agents = fake_db.agents + assert len(agents) == 1 + assert agents[0]["agent_id"] == "agent-active" + + +def test_dataflow_delegation_edge(): + client, fake_db = build_client() + + # case 1: task t2 has both source_agent_id and agent_id -> delegation edge should be created + fake_db.tasks = [ + { + "task_id": "t1", + "workflow_id": "wf-1", + "status": "finished", + "started_at": 10, + "agent_id": "orchestrator", + "used": {"x": 1}, + }, + { + "task_id": "t2", + "workflow_id": "wf-1", + "status": "finished", + "started_at": 20, + "agent_id": "agent-1", + "source_agent_id": "orchestrator", + "used": {"y": 2}, + }, + ] + rs = client.get("/api/v1/workflows/wf-1/dataflow") + assert rs.status_code == 200 + edges = rs.json()["edges"] + delegation_edges = [e for e in edges if e["relation"] == "delegation"] + assert len(delegation_edges) == 1 + assert delegation_edges[0]["source"] == "task:t1" + assert delegation_edges[0]["target"] == "task:t2" + + # case 2: task t2 has source_agent_id but NO agent_id -> delegation edge should NOT be created + fake_db.tasks = [ + { + "task_id": "t1", + "workflow_id": "wf-1", + "status": "finished", + "started_at": 10, + "agent_id": "orchestrator", + "used": {"x": 1}, + }, + { + "task_id": "t2", + "workflow_id": "wf-1", + "status": "finished", + "started_at": 20, + "source_agent_id": "orchestrator", + "used": {"y": 2}, + }, + ] + rs = client.get("/api/v1/workflows/wf-1/dataflow") + assert rs.status_code == 200 + edges = rs.json()["edges"] + delegation_edges = [e for e in edges if e["relation"] == "delegation"] + assert len(delegation_edges) == 0 diff --git a/tests/webservice/test_webservice_integration.py b/tests/webservice/test_webservice_integration.py index 802ae64b..45fb29b0 100644 --- a/tests/webservice/test_webservice_integration.py +++ b/tests/webservice/test_webservice_integration.py @@ -2,14 +2,18 @@ from __future__ import annotations +import json +import re +import threading import time from uuid import uuid4 import pytest from fastapi.testclient import TestClient -from flowcept import Flowcept, FlowceptTask +from flowcept import Flowcept, FlowceptTask, WorkflowObject from flowcept.commons.daos.docdb_dao.docdb_dao_base import DocumentDBDAO +from flowcept.commons.flowcept_dataclasses.task_object import TaskObject from flowcept.configs import MONGO_ENABLED from flowcept.webservice.main import create_app @@ -26,24 +30,94 @@ def _wait_for(condition, timeout_sec: float = 20.0, interval_sec: float = 0.25) return False -def test_webservice_end_to_end_with_flowcept_and_blob_apis(): +@pytest.fixture +def db_cleanup(request): + """Track ids a test inserts and delete them from MongoDB/LMDB afterwards, even on failure. + + Tests register ids in the yielded dict; teardown recursively deletes campaigns + (workflows + tasks + objects), then workflows, then standalone objects, and any + agents registered during the test. + """ + created = {"campaigns": [], "workflows": [], "objects": []} + dao = DocumentDBDAO.get_instance(create_indices=False) + + initial_agents = set() + if MONGO_ENABLED and hasattr(dao, "_agents_collection"): + try: + initial_agents = {a["agent_id"] for a in dao._agents_collection.find({}, {"agent_id": 1})} + except Exception: + pass + + from flowcept.configs import LMDB_ENABLED + initial_lmdb_agents = set() + if LMDB_ENABLED and hasattr(dao, "_agents_db"): + try: + with dao._env.begin(db=dao._agents_db) as txn: + with txn.cursor() as cur: + for k, _ in cur: + initial_lmdb_agents.add(k) + except Exception: + pass + + yield created + + if request.config.getoption("--keep-webservice-test-data"): + print(f"Keeping webservice test data for UI inspection: {created}") + return + + # Re-retrieve active/fresh DAO because the old one might have been closed by Flowcept.stop() + dao = DocumentDBDAO.get_instance(create_indices=False) + + for campaign_id in created["campaigns"]: + dao.delete_campaign_data(campaign_id) + for workflow_id in created["workflows"]: + dao.delete_workflow_data(workflow_id) + if created["objects"]: + dao.delete_object_keys("object_id", created["objects"]) + + # Clean up agents registered during this test + if MONGO_ENABLED and hasattr(dao, "_agents_collection"): + try: + current_agents = {a["agent_id"] for a in dao._agents_collection.find({}, {"agent_id": 1})} + new_agents = current_agents - initial_agents + if new_agents: + dao._agents_collection.delete_many({"agent_id": {"$in": list(new_agents)}}) + except Exception: + pass + + if LMDB_ENABLED and hasattr(dao, "_agents_db"): + try: + current_lmdb_agents = set() + with dao._env.begin(db=dao._agents_db) as txn: + with txn.cursor() as cur: + for k, _ in cur: + current_lmdb_agents.add(k) + new_lmdb_agents = current_lmdb_agents - initial_lmdb_agents + if new_lmdb_agents: + with dao._env.begin(write=True, db=dao._agents_db) as txn: + for k in new_lmdb_agents: + txn.delete(k) + except Exception: + pass + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_webservice_end_to_end_with_flowcept_and_blob_apis(db_cleanup): + """End-to-end: real workflow + blob objects, then exercise the read APIs.""" if not Flowcept.services_alive(): pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") - campaign_id = f"ws-campaign-{uuid4()}" + campaign_id = f"GridSearchCampaign-{uuid4()}" workflow_name = f"ws-workflow-{uuid4()}" - - workflow_id = None - generic_obj_id = None - dataset_obj_id = None - model_obj_id = None + db_cleanup["campaigns"].append(campaign_id) with Flowcept(campaign_id=campaign_id, workflow_name=workflow_name): with FlowceptTask(activity_id="ws_task", used={"x": 1}) as task: task.end(generated={"y": 2}) workflow_id = Flowcept.current_workflow_id - generic_obj_id = Flowcept.db.save_or_update_object( object=b"generic-blob-payload", object_type="artifact", @@ -67,6 +141,7 @@ def test_webservice_end_to_end_with_flowcept_and_blob_apis(): assert generic_obj_id is not None assert dataset_obj_id is not None assert model_obj_id is not None + db_cleanup["objects"].extend([generic_obj_id, dataset_obj_id, model_obj_id]) ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 1) assert ok, "Timed out waiting for persisted tasks." @@ -159,3 +234,1084 @@ def test_webservice_end_to_end_with_flowcept_and_blob_apis(): # Cleanup singleton client handles for test isolation. if DocumentDBDAO._instance is not None: DocumentDBDAO._instance.close() + + +def test_webservice_campaigns_agents_stats_and_prov_card(db_cleanup): + """End-to-end test for derived campaigns/agents, stats endpoints, and workflow cards.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-campaign-{uuid4()}" + db_cleanup["campaigns"].append(campaign_id) + workflow_name = f"ws-stats-workflow-{uuid4()}" + agent_id = f"ws-agent-{uuid4()}" + agent_name = "WSAgent" + + with Flowcept( + campaign_id=campaign_id, + workflow_name=workflow_name, + agent_id=agent_id, + agent_name=agent_name, + ): + workflow_id = Flowcept.current_workflow_id + for i in range(3): + with FlowceptTask(activity_id="preprocess", used={"i": i}) as task: + task.end(generated={"out": i * 2}) + with FlowceptTask(activity_id="train", used={"epochs": 2}, agent_id=agent_id) as task: + task.end(generated={"loss": 0.1}) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 4) + assert ok, "Timed out waiting for persisted tasks." + ok = _wait_for(lambda: Flowcept.db.get_workflow_object(workflow_id) is not None) + assert ok, "Timed out waiting for persisted workflow." + + app = create_app() + client = TestClient(app) + + # Campaigns: derived list and detail. + rs = client.get("/api/v1/campaigns") + assert rs.status_code == 200 + campaigns = {item["campaign_id"]: item for item in rs.json()["items"]} + assert campaign_id in campaigns + assert campaigns[campaign_id]["workflow_count"] >= 1 + assert campaigns[campaign_id]["task_count"] >= 4 + + rs = client.get(f"/api/v1/campaigns/{campaign_id}") + assert rs.status_code == 200 + body = rs.json() + assert any(wf["workflow_id"] == workflow_id for wf in body["workflows"]) + assert body["task_summary"]["count"] >= 4 + + rs = client.get(f"/api/v1/campaigns/non-existent-{uuid4()}") + assert rs.status_code == 404 + + # Agents: derived from task agent_id. + rs = client.get("/api/v1/agents") + assert rs.status_code == 200 + assert any(item["agent_id"] == agent_id for item in rs.json()["items"]) + + rs = client.get(f"/api/v1/agents/{agent_id}") + assert rs.status_code == 200 + assert rs.json()["agent"]["task_count"] == 1 + assert "train" in rs.json()["agent"]["activities"] + + rs = client.get(f"/api/v1/agents/{agent_id}/tasks") + assert rs.status_code == 200 + assert rs.json()["count"] == 1 + + # Stats: task summary, timeseries, and card-data resolver. + rs = client.get("/api/v1/stats/tasks/summary", params={"workflow_id": workflow_id}) + assert rs.status_code == 200 + summary = rs.json() + assert summary["count"] >= 4 + activities = {a["activity_id"]: a for a in summary["activity_stats"]} + assert activities["preprocess"]["count"] == 3 + assert activities["train"]["count"] == 1 + assert summary["time_range"]["min_started_at"] is not None + + rs = client.post( + "/api/v1/stats/timeseries", + json={"filter": {"workflow_id": workflow_id}, "fields": ["ended_at"], "x": "started_at"}, + ) + assert rs.status_code == 200 + assert rs.json()["count"] >= 4 + assert all(row["started_at"] is not None for row in rs.json()["rows"]) + + rs = client.post( + "/api/v1/stats/chart_data", + json={ + "data": { + "source": "tasks", + "group_by": "activity_id", + "metrics": [{"field": "", "agg": "count"}], + }, + "context": {"workflow_id": workflow_id}, + }, + ) + assert rs.status_code == 200 + rows = {row["activity_id"]: row for row in rs.json()["rows"]} + assert rows["preprocess"]["count"] == 3 + assert rows["train"]["count"] == 1 + + # Rejected operator must 400. + rs = client.get("/api/v1/stats/tasks/summary", params={"filter_json": '{"$where": "1"}'}) + assert rs.status_code == 400 + + # Workflow card: JSON and markdown content. + rs = client.get(f"/api/v1/workflows/{workflow_id}/workflow_card", params={"format": "json"}) + assert rs.status_code == 200 + card = rs.json() + assert card["input_mode"] == "db" + assert "transformations" in card and "dataset" in card + + rs = client.get(f"/api/v1/workflows/{workflow_id}/workflow_card", params={"format": "markdown"}) + assert rs.status_code == 200 + assert rs.headers["content-type"].startswith("text/markdown") + assert workflow_name in rs.text or workflow_id in rs.text + + rs = client.get(f"/api/v1/campaigns/{campaign_id}/workflow_card", params={"format": "markdown"}) + assert rs.status_code == 200 + + rs = client.get(f"/api/v1/workflows/{workflow_id}/workflow_card", params={"format": "pdf"}) + assert rs.status_code == 200 + assert rs.headers["content-type"].startswith("application/pdf") + + # Cleanup singleton client handles for test isolation. + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_webservice_object_versioning_and_unified_query(db_cleanup): + """End-to-end test for object version history and the unified /query/{scope} endpoint.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-campaign-{uuid4()}" + obj_id = f"ws-versioned-{uuid4()}" + db_cleanup["campaigns"].append(campaign_id) + db_cleanup["objects"].append(obj_id) + + with Flowcept(campaign_id=campaign_id, workflow_name=f"ws-version-wf-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + with FlowceptTask(activity_id="emit", used={"x": 1}) as task: + task.end(generated={"y": 1}) + for version in range(2): + Flowcept.db.save_or_update_object( + object=f"payload-v{version}".encode(), + object_id=obj_id, + object_type="ml_model", + save_data_in_collection=True, + custom_metadata={"v": version}, + control_version=True, + ) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 1) + assert ok, "Timed out waiting for persisted tasks." + + app = create_app() + client = TestClient(app) + + # Version history and per-version metadata/downloads. + rs = client.get(f"/api/v1/objects/{obj_id}/history") + assert rs.status_code == 200 + versions = sorted(item["version"] for item in rs.json()["items"]) + assert versions == [0, 1] + + rs = client.get(f"/api/v1/objects/{obj_id}/versions/0") + assert rs.status_code == 200 + assert rs.json()["custom_metadata"]["v"] == 0 + + rs = client.get(f"/api/v1/objects/{obj_id}/versions/0/download") + assert rs.status_code == 200 + assert rs.content == b"payload-v0" + + rs = client.get(f"/api/v1/objects/{obj_id}/download") + assert rs.status_code == 200 + assert rs.content == b"payload-v1" + + # Models scope sees the versioned object; include_data exposes payload. + rs = client.get(f"/api/v1/models/{obj_id}", params={"include_data": "true"}) + assert rs.status_code == 200 + assert rs.json()["object_type"] == "ml_model" + assert rs.json().get("data") + + # Unified scoped query: operators, sort, projection, limit. + rs = client.post( + "/api/v1/query/tasks", + json={ + "filter": {"workflow_id": workflow_id, "started_at": {"$exists": True}}, + "projection": ["task_id", "activity_id", "started_at"], + "sort": [{"field": "started_at", "order": -1}], + "limit": 5, + }, + ) + assert rs.status_code == 200 + assert rs.json()["count"] >= 1 + assert all("used" not in item for item in rs.json()["items"]) + + rs = client.post("/api/v1/query/models", json={"filter": {"object_id": obj_id}, "limit": 5}) + assert rs.status_code == 200 + assert all(item["object_type"] == "ml_model" for item in rs.json()["items"]) + + # Disallowed operator is rejected. + rs = client.post("/api/v1/query/tasks", json={"filter": {"$where": "1"}, "limit": 5}) + assert rs.status_code == 400 + + # Tasks by workflow + filter_json list filters. + rs = client.get(f"/api/v1/tasks/by_workflow/{workflow_id}") + assert rs.status_code == 200 + assert rs.json()["count"] >= 1 + + rs = client.get("/api/v1/tasks", params={"filter_json": f'{{"workflow_id": "{workflow_id}"}}'}) + assert rs.status_code == 200 + assert rs.json()["count"] >= 1 + + rs = client.post(f"/api/v1/workflows/{workflow_id}/reports/workflow-card/download") + assert rs.status_code == 200 + assert rs.headers["content-type"].startswith("text/markdown") + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_webservice_dashboards_crud(): + """End-to-end CRUD test for dashboards stored in the real backend.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + app = create_app() + client = TestClient(app) + + target = f"wf-name-{uuid4()}" + config = { + "dashboard_type": "custom_workflow", + "target": target, + "name": f"dash-{uuid4()}", + "charts": [ + { + "chart_id": "c1", + "type": "chart", + "title": "Tasks per activity", + "data": { + "source": "tasks", + "group_by": "activity_id", + "metrics": [{"field": "", "agg": "count"}], + }, + "viz": {"kind": "bar"}, + }, + {"chart_id": "c2", "type": "markdown", "content": "# Notes"}, + ], + } + + rs = client.post("/api/v1/dashboards", json=config) + assert rs.status_code == 201, rs.text + created = rs.json() + dashboard_id = created["dashboard_id"] + assert dashboard_id and created["created_at"] + + try: + rs = client.get(f"/api/v1/dashboards/{dashboard_id}") + assert rs.status_code == 200 + assert rs.json()["name"] == config["name"] + assert len(rs.json()["charts"]) == 2 + + rs = client.get("/api/v1/dashboards") + assert rs.status_code == 200 + assert any(d["dashboard_id"] == dashboard_id for d in rs.json()["items"]) + + rs = client.get("/api/v1/dashboards", params={"dashboard_type": "custom_workflow"}) + assert rs.status_code == 200 + assert any(d["dashboard_id"] == dashboard_id for d in rs.json()["items"]) + + # Resolution merges common_workflow charts with this workflow's custom charts. + rs = client.get("/api/v1/dashboards/resolve", params={"workflow_name": target}) + assert rs.status_code == 200 + resolved_ids = {c["chart_id"] for c in rs.json()} + assert {"c1", "c2"}.issubset(resolved_ids) + + rs = client.get("/api/v1/dashboards/resolve") + assert rs.status_code == 400 + + updated = dict(config, name="updated") + rs = client.put(f"/api/v1/dashboards/{dashboard_id}", json=updated) + assert rs.status_code == 200 + assert rs.json()["name"] == "updated" + assert rs.json()["created_at"] == created["created_at"] + assert rs.json()["updated_at"] >= created["updated_at"] + + # Validation: bad chart type, bad dashboard type, and disallowed filter operator. + bad = dict(config, charts=[{"chart_id": "x", "type": "nope"}]) + rs = client.post("/api/v1/dashboards", json=bad) + assert rs.status_code == 422 + + bad = dict(config, dashboard_type="nope") + rs = client.post("/api/v1/dashboards", json=bad) + assert rs.status_code == 422 + + bad_chart = dict(config["charts"][0]) + bad_chart["data"] = dict(bad_chart["data"], filter={"$where": "1"}) + bad = dict(config, charts=[bad_chart]) + rs = client.post("/api/v1/dashboards", json=bad) + assert rs.status_code == 400 + finally: + rs = client.delete(f"/api/v1/dashboards/{dashboard_id}") + assert rs.status_code == 200 + + rs = client.get(f"/api/v1/dashboards/{dashboard_id}") + assert rs.status_code == 404 + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def _start_real_server(app): + """Run the app on a real uvicorn server in a thread; return (server, thread, base_url).""" + import socket + + import uvicorn + from sse_starlette.sse import AppStatus + + # sse-starlette's exit Event binds to the first serving loop; reset per server (see + # FlowceptAgent._run_server for the same workaround). + AppStatus.should_exit_event = None + + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + + config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning") + server = uvicorn.Server(config) + thread = threading.Thread(target=server.run, daemon=True) + thread.start() + assert _wait_for(lambda: server.started, timeout_sec=15), "Webservice did not start." + return server, thread, f"http://127.0.0.1:{port}" + + +def _stop_real_server(server, thread): + server.should_exit = True + thread.join(timeout=10) + + +def _read_sse_events(line_iter, max_events: int, timeout_sec: float = 15.0): + """Collect up to ``max_events`` parsed SSE events from an iterator of lines.""" + events = [] + current_event, current_data = None, [] + deadline = time.time() + timeout_sec + for line in line_iter: + if time.time() > deadline: + break + if line.startswith("event:"): + current_event = line.split(":", 1)[1].strip() + elif line.startswith("data:"): + current_data.append(line.split(":", 1)[1].strip()) + elif line == "" and current_event: + events.append((current_event, json.loads("".join(current_data) or "null"))) + current_event, current_data = None, [] + if len(events) >= max_events: + break + return events + + +def test_webservice_stream_tasks_sse(db_cleanup): + """End-to-end SSE: existing tasks arrive in the first event; mid-stream inserts arrive next.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-campaign-{uuid4()}" + db_cleanup["campaigns"].append(campaign_id) + + with Flowcept(campaign_id=campaign_id, workflow_name=f"ws-sse-wf-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + with FlowceptTask(activity_id="sse_seed", used={"i": 0}) as task: + task.end(generated={"o": 0}) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 1) + assert ok, "Timed out waiting for persisted tasks." + + import httpx + + server, server_thread, base_url = _start_real_server(create_app()) + + late_task_id = f"sse-late-{uuid4()}" + + def _insert_late_task(): + time.sleep(0.8) + now = time.time() + task = TaskObject() + task.task_id = late_task_id + task.workflow_id = workflow_id + task.activity_id = "sse_late" + task.started_at = now + task.ended_at = now + task.registered_at = now + Flowcept.db.insert_or_update_task(task) + + inserter = threading.Thread(target=_insert_late_task, daemon=True) + + try: + with httpx.stream( + "GET", + f"{base_url}/api/v1/stream/tasks?workflow_id={workflow_id}&since=0&poll_interval=0.2", + timeout=httpx.Timeout(20.0), + ) as rs: + assert rs.status_code == 200 + assert rs.headers["content-type"].startswith("text/event-stream") + inserter.start() + events = _read_sse_events(rs.iter_lines(), max_events=2) + finally: + _stop_real_server(server, server_thread) + + assert len(events) == 2 + name0, payload0 = events[0] + assert name0 == "tasks" + assert any(t["activity_id"] == "sse_seed" for t in payload0["tasks"]) + assert payload0["cursor"] > 0 + + name1, payload1 = events[1] + assert name1 == "tasks" + assert any(t["task_id"] == late_task_id for t in payload1["tasks"]) + assert payload1["cursor"] >= payload0["cursor"] + + inserter.join(timeout=5) + + # Cursor semantics: since= + a fresh insert returns only the new task. + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_webservice_stream_workflows_sse(db_cleanup): + """End-to-end SSE for the workflows stream filtered by campaign.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-campaign-{uuid4()}" + db_cleanup["campaigns"].append(campaign_id) + with Flowcept(campaign_id=campaign_id, workflow_name=f"ws-sse-wf2-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + + ok = _wait_for(lambda: len(Flowcept.db.workflow_query(filter={"workflow_id": workflow_id}) or []) >= 1) + assert ok, "Timed out waiting for persisted workflow." + + import httpx + + server, server_thread, base_url = _start_real_server(create_app()) + try: + with httpx.stream( + "GET", + f"{base_url}/api/v1/stream/workflows?campaign_id={campaign_id}&since=0&poll_interval=0.2", + timeout=httpx.Timeout(20.0), + ) as rs: + assert rs.status_code == 200 + events = _read_sse_events(rs.iter_lines(), max_events=1) + finally: + _stop_real_server(server, server_thread) + + assert len(events) == 1 + name, payload = events[0] + assert name == "workflows" + assert any(w["workflow_id"] == workflow_id for w in payload["workflows"]) + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_webservice_spa_serving(tmp_path, monkeypatch): + """SPA assets are served at root with index.html fallback when present.""" + from flowcept.webservice import main as ws_main + + # Without assets: root returns the API status payload. + missing_dir = tmp_path / "no_ui" + monkeypatch.setattr(ws_main, "Path", lambda *_: missing_dir / "main.py") + client = TestClient(ws_main.create_app()) + rs = client.get("/") + assert rs.status_code == 200 + assert rs.json()["service"] == "flowcept-webservice" + + # With real assets on disk: index.html served at root and for SPA routes; API still wins. + ui_dir = tmp_path / "ui_build" + (ui_dir / "assets").mkdir(parents=True) + (ui_dir / "index.html").write_text("flowcept-ui") + (ui_dir / "assets" / "app.js").write_text("console.log('ui')") + + monkeypatch.setattr(ws_main, "Path", lambda *_: tmp_path / "main.py") + client = TestClient(ws_main.create_app()) + + rs = client.get("/") + assert rs.status_code == 200 + assert "flowcept-ui" in rs.text + + rs = client.get("/workflows/some-id") + assert "flowcept-ui" in rs.text + + rs = client.get("/assets/app.js") + assert rs.status_code == 200 + assert "console.log" in rs.text + + rs = client.get("/api/v1/health/live") + assert rs.status_code == 200 + assert rs.json() != {} + + rs = client.get("/api/v1/this/does/not/exist") + assert rs.status_code == 404 + + +def test_prov_tools_shared_core(db_cleanup): + """The shared provenance tool core (used by web chat and MCP agent) works on real data.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + from flowcept.agents.tools.prov_tools import ( + get_task_summary, + list_campaigns, + make_chart, + query_tasks, + query_workflows, + ) + + campaign_id = f"ws-campaign-{uuid4()}" + db_cleanup["campaigns"].append(campaign_id) + with Flowcept(campaign_id=campaign_id, workflow_name=f"ws-tools-wf-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + with FlowceptTask(activity_id="tool_seed", used={"x": 1}) as task: + task.end(generated={"y": 2}) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 1) + assert ok, "Timed out waiting for persisted tasks." + + result = query_tasks(filter={"workflow_id": workflow_id}, limit=10) + assert result.code in (201, 301) + assert any(t["activity_id"] == "tool_seed" for t in result.result["items"]) + + result = query_workflows(filter={"campaign_id": campaign_id}) + assert result.code in (201, 301) + assert any(w["workflow_id"] == workflow_id for w in result.result["items"]) + + result = get_task_summary(filter={"workflow_id": workflow_id}) + assert result.result["count"] >= 1 + + result = list_campaigns() + assert any(c["campaign_id"] == campaign_id for c in result.result["items"]) + + result = make_chart( + card_spec={ + "chart_id": "chat-c1", + "type": "chart", + "title": "tasks per activity", + "data": {"source": "tasks", "filter": {"workflow_id": workflow_id}, "group_by": "activity_id"}, + "viz": {"kind": "bar"}, + } + ) + assert result.code in (201, 301) + assert result.result["rows"] + assert result.result["chart"]["chart_id"] == "chat-c1" + + # Disallowed filter operators are rejected by the shared core. + result = query_tasks(filter={"$where": "1"}, limit=10) + assert result.code >= 400 + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_chat_endpoint_unavailable_without_llm(): + """POST /api/v1/chat returns 503 with a clear detail when no LLM is configured.""" + from flowcept.configs import AGENT + + api_key = AGENT.get("api_key") + if api_key and api_key != "?": + pytest.skip("An LLM is configured; the 503 path does not apply.") + + app = create_app() + client = TestClient(app) + rs = client.post("/api/v1/chat", json={"messages": [{"role": "user", "content": "hi"}]}) + assert rs.status_code == 503 + assert "LLM" in rs.json()["detail"] or "llm" in rs.json()["detail"] + + +def test_chat_endpoint_real_llm_tool_roundtrip(db_cleanup): + """Real LLM chat round-trip: the model must call a query tool and answer (env-gated).""" + from flowcept.commons.flowcept_logger import FlowceptLogger + from flowcept.configs import AGENT + + api_key = AGENT.get("api_key") + if not api_key or api_key == "?": + FlowceptLogger().warning("Skipping real-LLM chat test because agent.api_key is not set.") + pytest.skip("agent.api_key is not set.") + if not AGENT.get("service_provider") or AGENT.get("service_provider") == "?": + FlowceptLogger().warning("Skipping real-LLM chat test because agent.service_provider is not set.") + pytest.skip("agent.service_provider is not set.") + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-campaign-{uuid4()}" + db_cleanup["campaigns"].append(campaign_id) + with Flowcept(campaign_id=campaign_id, workflow_name=f"ws-chat-wf-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + for i in range(3): + with FlowceptTask(activity_id="chat_seed", used={"i": i}) as task: + task.end(generated={"o": i}) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 3) + assert ok, "Timed out waiting for persisted tasks." + + app = create_app() + client = TestClient(app) + rs = client.post( + "/api/v1/chat", + json={ + "messages": [{"role": "user", "content": "How many tasks ran in this workflow?"}], + "context": {"workflow_id": workflow_id}, + "stream": False, + }, + ) + assert rs.status_code == 200 + body = rs.json() + assert body["message"] + assert any("3" in str(part) for part in (body["message"], body.get("tool_trace", []))) + assert body.get("tool_trace"), "Expected the LLM to call at least one tool." + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_recursive_delete_workflow_and_campaign(db_cleanup): + """Recursive delete endpoints remove workflows, campaigns, and their tasks/objects.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"del-camp-{uuid4()}" + # The test deletes everything itself; this guards against mid-test failures. + db_cleanup["campaigns"].append(campaign_id) + + # Seed two workflows, one task and one object each. + wf1_id = None + wf2_id = None + with Flowcept(campaign_id=campaign_id, workflow_name=f"del-wf1-{uuid4()}"): + with FlowceptTask(activity_id="del_task", used={"x": 1}) as t1: + t1.end(generated={"y": 1}) + Flowcept.db.save_or_update_object(object=b"blob1", object_type="artifact", save_data_in_collection=True) + wf1_id = Flowcept.current_workflow_id + + with Flowcept(campaign_id=campaign_id, workflow_name=f"del-wf2-{uuid4()}"): + with FlowceptTask(activity_id="del_task", used={"x": 2}) as t2: + t2.end(generated={"y": 2}) + Flowcept.db.save_or_update_object(object=b"blob2", object_type="artifact", save_data_in_collection=True) + wf2_id = Flowcept.current_workflow_id + + assert wf1_id and wf2_id + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": wf1_id}) or []) >= 1) + assert ok, "Timed out waiting for wf1 tasks." + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": wf2_id}) or []) >= 1) + assert ok, "Timed out waiting for wf2 tasks." + + app = create_app() + client = TestClient(app) + + # Delete wf1 only. + rs = client.delete(f"/api/v1/workflows/{wf1_id}") + assert rs.status_code == 200, rs.text + body = rs.json() + assert body["deleted"]["workflows"] >= 1 + assert body["deleted"]["tasks"] >= 1 + + # wf1 tasks gone; wf2 intact. + assert not Flowcept.db.task_query(filter={"workflow_id": wf1_id}) + assert Flowcept.db.task_query(filter={"workflow_id": wf2_id}) + + # 404 on nonexistent workflow. + rs = client.delete("/api/v1/workflows/nonexistent-workflow-id") + assert rs.status_code == 404, rs.text + + # Delete entire campaign. + rs = client.delete(f"/api/v1/campaigns/{campaign_id}") + assert rs.status_code == 200, rs.text + body = rs.json() + assert body["deleted"]["workflows"] >= 1 + assert body["deleted"]["tasks"] >= 1 + + # wf2 gone. + assert not Flowcept.db.task_query(filter={"workflow_id": wf2_id}) + + # 404 on repeat. + rs = client.delete(f"/api/v1/campaigns/{campaign_id}") + assert rs.status_code == 404, rs.text + + +def test_delete_also_removes_orphan_agents(db_cleanup): + """Deleting a workflow removes agents whose tasks are all in that workflow. + + An agent that still has tasks in another workflow must NOT be deleted. + """ + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + from flowcept.commons.flowcept_dataclasses.agent_object import AgentObject + + campaign_id = f"del-agents-{uuid4()}" + db_cleanup["campaigns"].append(campaign_id) + + sole_agent_id = f"sole_agent_{uuid4()}" + shared_agent_id = f"shared_agent_{uuid4()}" + + with Flowcept(campaign_id=campaign_id, workflow_name=f"del-ag-wf1-{uuid4()}"): + with FlowceptTask(activity_id="act1", used={"x": 1}, agent_id=sole_agent_id) as t: + t.end(generated={"y": 1}) + with FlowceptTask(activity_id="act2", used={"x": 2}, agent_id=shared_agent_id) as t: + t.end(generated={"y": 2}) + wf1_id = Flowcept.current_workflow_id + + with Flowcept(campaign_id=campaign_id, workflow_name=f"del-ag-wf2-{uuid4()}"): + with FlowceptTask(activity_id="act2", used={"x": 3}, agent_id=shared_agent_id) as t: + t.end(generated={"y": 3}) + wf2_id = Flowcept.current_workflow_id + + assert wf1_id and wf2_id + + # Explicitly register agents in the agents collection. + Flowcept.db.insert_or_update_agent( + AgentObject(agent_id=sole_agent_id, name="SoleAgent", workflow_id=wf1_id, campaign_id=campaign_id) + ) + Flowcept.db.insert_or_update_agent( + AgentObject(agent_id=shared_agent_id, name="SharedAgent", workflow_id=wf1_id, campaign_id=campaign_id) + ) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": wf1_id}) or []) >= 2) + assert ok, "Timed out waiting for wf1 tasks." + + # Both agents must be registered before deleting. + agents = Flowcept.db.agent_query(filter={"agent_id": {"$in": [sole_agent_id, shared_agent_id]}}) + assert len(agents) == 2, f"Expected 2 agents, got {len(agents or [])}" + + app = create_app() + client = TestClient(app) + + # Delete wf1 — sole_agent should be removed, shared_agent should stay. + rs = client.delete(f"/api/v1/workflows/{wf1_id}") + assert rs.status_code == 200, rs.text + body = rs.json() + assert body["deleted"]["agents"] >= 1 + + remaining = Flowcept.db.agent_query(filter={"agent_id": sole_agent_id}) + assert not remaining, "sole_agent should be deleted after its only workflow was deleted" + + remaining = Flowcept.db.agent_query(filter={"agent_id": shared_agent_id}) + assert remaining, "shared_agent must NOT be deleted; it still has tasks in wf2" + + # Delete the campaign — shared_agent should now be removed. + rs = client.delete(f"/api/v1/campaigns/{campaign_id}") + assert rs.status_code == 200, rs.text + body = rs.json() + assert body["deleted"]["agents"] >= 1 + + remaining = Flowcept.db.agent_query(filter={"agent_id": shared_agent_id}) + assert not remaining, "shared_agent should be deleted after its last workflow was removed" + + +def test_agent_telemetry_timeseries(db_cleanup): + """Agent-filtered timeseries returns rows with telemetry for the agent's tasks. + + Regression: TelemetryChart on the agent page showed "No telemetry values + found" even when the same tasks showed telemetry on the workflow page. + """ + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"tel-camp-{uuid4()}" + db_cleanup["campaigns"].append(campaign_id) + agent_id = f"tel-agent-{uuid4()}" + + with Flowcept(campaign_id=campaign_id, workflow_name=f"tel-wf-{uuid4()}"): + workflow_id = Flowcept.current_workflow_id + with FlowceptTask(activity_id="tel_task", used={"x": 1}, agent_id=agent_id) as t: + t.end(generated={"y": 2}) + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= 1) + assert ok, "Timed out waiting for tasks." + + app = create_app() + client = TestClient(app) + + # Workflow timeseries returns something (baseline — at minimum started_at is set). + rs = client.post( + "/api/v1/stats/timeseries", + json={"filter": {"workflow_id": workflow_id}, "fields": ["started_at"], "x": "started_at"}, + ) + assert rs.status_code == 200 + assert rs.json()["count"] >= 1, "Workflow timeseries found no rows." + + # Agent timeseries with $or filter must also return the task. + rs = client.post( + "/api/v1/stats/timeseries", + json={ + "filter": {"$or": [{"agent_id": agent_id}, {"source_agent_id": agent_id}]}, + "fields": ["started_at"], + "x": "started_at", + }, + ) + assert rs.status_code == 200, rs.text + assert rs.json()["count"] >= 1, ( + "Agent timeseries returned 0 rows even though tasks with agent_id exist. " + "This is the bug causing 'No telemetry values found' on the agent page." + ) + + +def test_file_dashboard_store_roundtrip(tmp_path): + """FileDashboardStore (non-Mongo fallback) persists real JSON files.""" + from flowcept.webservice.services.dashboard_store import FileDashboardStore + + store = FileDashboardStore(directory=str(tmp_path)) + doc = {"dashboard_id": "d1", "name": "local", "charts": [], "layout": []} + assert store.save(doc) + assert store.get("d1")["name"] == "local" + assert any(d["dashboard_id"] == "d1" for d in store.list()) + assert store.delete("d1") + assert store.get("d1") is None + assert store.delete("d1") is False + + +def test_webservice_dataflow_graph(db_cleanup): + """PROV-style dataflow over the real Perceptron GridSearch workflow.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + from tests.instrumentation_tests.ml_tests.single_layer_perceptron_test import run_gridsearch_experiment + + campaign_id = f"GridSearchCampaign-{uuid4()}" + db_cleanup["campaigns"].append(campaign_id) + run_data = run_gridsearch_experiment(campaign_id=campaign_id) + workflow_id = run_data["workflow_id"] + learning_tasks = [t for t in run_data["tasks"] if t.get("activity_id") == "train_and_validate"] + + ok = _wait_for( + lambda: len(Flowcept.db.task_query(filter={"workflow_id": workflow_id}) or []) >= len(run_data["tasks"]) + ) + assert ok, "Timed out waiting for persisted tasks." + + app = create_app() + client = TestClient(app) + + # Coarse level (default): per-task input/output chunk entities (PROV Entity vs Activity). + from flowcept import configs + original_max = getattr(configs, "WEBSERVER_MAX_LABEL_LENGTH", 30) + try: + configs.WEBSERVER_MAX_LABEL_LENGTH = 300 + rs = client.get(f"/api/v1/workflows/{workflow_id}/dataflow") + finally: + configs.WEBSERVER_MAX_LABEL_LENGTH = original_max + + assert rs.status_code == 200, rs.text + body = rs.json() + assert body["level"] == "coarse" + task_nodes = [n for n in body["nodes"] if n["kind"] == "task"] + chunk_nodes = [n for n in body["nodes"] if n["kind"] == "chunk"] + assert len(task_nodes) == len(run_data["tasks"]) + assert {n["label"] for n in task_nodes} == { + "call_hpc_agent", + "get_dataset", + "submit_gridsearch_job", + "train_and_validate", + "select_best_model", + } + assert len([n for n in task_nodes if n["label"] == "train_and_validate"]) == len(learning_tasks) + assert any(n["stats"]["activity_id"] == "train_and_validate" for n in task_nodes) + assert any(n["stats"]["used"].get("config_id") == "cfg_1" for n in task_nodes) + assert any("best_val_loss" in n["stats"]["generated"] for n in task_nodes) + # TDD: Verify task nodes have subtype in their stats + learning_node = next(n for n in task_nodes if n["label"] == "train_and_validate") + assert learning_node["stats"].get("subtype") == "learning" + # Each task with used/generated data is represented as a PROV activity. + inputs = [c for c in chunk_nodes if c["stats"]["kind"] == "input"] + outputs = [c for c in chunk_nodes if c["stats"]["kind"] == "output"] + assert inputs and outputs + # Chunks pack the key-values; clicking in the UI shows these items. + assert any(c["stats"]["items"].get("config_id") == "cfg_1" for c in inputs) + assert any("best_val_loss" in c["stats"]["items"] for c in outputs) + assert all(c["stats"]["generated_by"] for c in outputs) + # TDD: Verify chunk labels use key names, never raw arg_N positional keys + assert any("config_id" in c["label"] for c in inputs) + assert any("best_val_loss" in c["label"] for c in outputs) + # submit_gridsearch_job outputs configs list under the key "configs" (not "arg_0") + submit_node = next(n for n in task_nodes if n["label"] == "submit_gridsearch_job") + submit_output_chunks = [ + c for c in chunk_nodes + if any(e["source"] == submit_node["id"] and e["target"] == c["id"] for e in body["edges"]) + ] + assert submit_output_chunks, "submit_gridsearch_job must have output chunks" + assert all("configs" in c["label"] for c in submit_output_chunks), ( + f"submit_gridsearch_job output chunk labels must use 'configs', got: {[c['label'] for c in submit_output_chunks]}" + ) + assert not any(re.match(r"^arg_\d+$", c["label"]) for c in chunk_nodes), ( + "No chunk label should be a raw arg_N key — positional keys must use count fallback" + ) + edges = {(e["source"], e["target"], e["relation"]) for e in body["edges"]} + for t in task_nodes: + if t["stats"]["used"]: + assert any(s.startswith("chunk:") and tgt == t["id"] and r == "used" for (s, tgt, r) in edges) + if t["stats"]["generated"]: + assert any(s == t["id"] and tgt.startswith("chunk:") and r == "generated" for (s, tgt, r) in edges) + + # TDD: Verify delegation edge exists from call_hpc_agent task node to submit_gridsearch_job task node + call_task = next(n for n in task_nodes if n["label"] == "call_hpc_agent") + submit_task = next(n for n in task_nodes if n["label"] == "submit_gridsearch_job") + assert any( + e["source"] == call_task["id"] and e["target"] == submit_task["id"] and e["relation"] == "delegation" + for e in body["edges"] + ) + + assert not any(n["kind"] == "data" for n in body["nodes"]) + + # 404 for unknown workflow. + rs = client.get("/api/v1/workflows/nonexistent-wf/dataflow") + assert rs.status_code == 404 + + +def _parse_sse(text: str) -> list: + """Parse a raw SSE response body into a list of {event, data} dicts. + + SSE separates events with \\r\\n\\r\\n (CRLF) or \\n\\n; normalise first. + """ + events = [] + # Normalise CRLF to LF so block splitting works regardless of transport encoding. + normalised = text.replace("\r\n", "\n") + for block in normalised.strip().split("\n\n"): + block = block.strip() + if not block: + continue + ev: dict = {} + for line in block.split("\n"): + if line.startswith("event:"): + ev["event"] = line[len("event:"):].strip() + elif line.startswith("data:"): + raw = line[len("data:"):].strip() + try: + ev["data"] = json.loads(raw) + except json.JSONDecodeError: + ev["data"] = raw + if ev: + events.append(ev) + return events + + +def test_chat_highlight_lineage_sse(db_cleanup): + """Full end-to-end: real LLM emits ui:highlight SSE event with the correct seed task IDs. + + Creates a two-task workflow (step_a → step_b via shared data), asks the chat + endpoint to highlight the lineage of the first task, and verifies the SSE stream + contains an ``event: ui:highlight`` entry whose ``task_ids`` includes the seed. + """ + from flowcept.commons.flowcept_logger import FlowceptLogger + from flowcept.configs import AGENT + + api_key = AGENT.get("api_key") + if not api_key or api_key in ("?", "your-api-key-here"): + FlowceptLogger().warning("Skipping real-LLM highlight test: agent.api_key is not set.") + pytest.skip("agent.api_key is not set.") + if not AGENT.get("service_provider") or AGENT.get("service_provider") == "?": + FlowceptLogger().warning("Skipping real-LLM highlight test: agent.service_provider is not set.") + pytest.skip("agent.service_provider is not set.") + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + campaign_id = f"ws-hl-camp-{uuid4()}" + db_cleanup["campaigns"].append(campaign_id) + + # Two-task lineage: step_a generates y=2; step_b uses y=2. + with Flowcept(campaign_id=campaign_id, workflow_name="hl-lineage-test"): + wf_id = Flowcept.current_workflow_id + with FlowceptTask(activity_id="step_a", used={"x": 1}) as task_a: + task_a.end(generated={"y": 2}) + step_a_id = task_a.get_id() + with FlowceptTask(activity_id="step_b", used={"y": 2}) as task_b: + task_b.end(generated={"z": 3}) + + assert step_a_id, "step_a task_id must be set after the context exits." + + ok = _wait_for(lambda: len(Flowcept.db.task_query(filter={"workflow_id": wf_id}) or []) >= 2) + assert ok, "Timed out waiting for tasks to be persisted." + + # LLMs can be non-deterministic about tool calls; retry up to 3 times. + # Each attempt recreates the app+client to avoid sse_starlette's AppStatus + # event-loop binding issue (should_exit_event is a module-level singleton that + # gets bound to the first event loop; a fresh Event() avoids the RuntimeError + # on the second call in the same process). + import asyncio + from sse_starlette.sse import AppStatus + + highlight_events = [] + last_event_names = [] + for attempt in range(3): + AppStatus.should_exit_event = asyncio.Event() + app = create_app() + client = TestClient(app) + rs = client.post( + "/api/v1/chat", + json={ + "messages": [{"role": "user", "content": f"Highlight the lineage of task {step_a_id} in the dataflow graph using the highlight_lineage tool."}], + "context": {"workflow_id": wf_id}, + "stream": True, + }, + ) + assert rs.status_code == 200, rs.text + events = _parse_sse(rs.text) + last_event_names = [e.get("event") for e in events] + highlight_events = [e for e in events if e.get("event") == "ui:highlight"] + if highlight_events: + break + FlowceptLogger().warning(f"highlight attempt {attempt + 1}: no ui:highlight yet (events={last_event_names})") + + assert highlight_events, ( + f"Expected a 'ui:highlight' SSE event in 3 attempts but got: {last_event_names}. " + "Check the system prompt and tool binding." + ) + task_ids_in_highlight = highlight_events[0]["data"].get("task_ids", []) + assert step_a_id in task_ids_in_highlight, ( + f"Expected seed task {step_a_id} in ui:highlight task_ids={task_ids_in_highlight}" + ) + + if DocumentDBDAO._instance is not None: + DocumentDBDAO._instance.close() + + +def test_node_positions_endpoint(db_cleanup): + """Test saving and loading node positions via FastAPI REST endpoints.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive (MQ/KVDB/Mongo).") + + workflow_id = f"wf-pos-test-{uuid4()}" + db_cleanup["workflows"].append(workflow_id) + + # Insert a dummy workflow so the ID exists/cleans up nicely + wf = WorkflowObject() + wf.workflow_id = workflow_id + Flowcept.db.insert_or_update_workflow(wf) + + app = create_app() + client = TestClient(app) + + # 1. Fetch positions for a non-existent position mapping (should be empty dict) + rs = client.get(f"/api/v1/workflows/{workflow_id}/node_positions?graph_type=dataflow") + assert rs.status_code == 200 + assert rs.json() == {} + + # 2. Save positions + pos_data = { + "graph_type": "dataflow", + "positions": { + "node-1": {"x": 12.5, "y": 45.6}, + "node-2": {"x": 78.9, "y": 101.2} + } + } + rs = client.post(f"/api/v1/workflows/{workflow_id}/node_positions", json=pos_data) + assert rs.status_code == 200 + assert rs.json() == {"success": True} + + # 3. Retrieve positions and verify + rs = client.get(f"/api/v1/workflows/{workflow_id}/node_positions?graph_type=dataflow") + assert rs.status_code == 200 + retrieved = rs.json() + assert retrieved["node-1"] == {"x": 12.5, "y": 45.6} + assert retrieved["node-2"] == {"x": 78.9, "y": 101.2} + + +def test_agents_without_tasks_are_not_returned(db_cleanup): + """TDD test: agents with 0 tasks must be filtered out and not listed.""" + if not Flowcept.services_alive(): + pytest.skip("Flowcept services are not alive.") + + from flowcept.commons.flowcept_dataclasses.agent_object import AgentObject + empty_agent_id = f"empty-agent-{uuid4()}" + + agent = AgentObject() + agent.agent_id = empty_agent_id + agent.name = "EmptyAgent" + Flowcept.db.insert_or_update_agent(agent) + + app = create_app() + client = TestClient(app) + + rs = client.get("/api/v1/agents") + assert rs.status_code == 200 + items = rs.json()["items"] + assert not any(item["agent_id"] == empty_agent_id for item in items) + diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 00000000..93747d53 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,227 @@ +# Flowcept Web UI + +React single-page application for browsing and analyzing Flowcept provenance data: +campaigns, workflows, tasks, artifacts (datasets/ML models), and agents — with live (SSE) +updates, per-workflow/campaign dashboards, and an embedded LLM chat that queries the +provenance database and renders charts. + +The UI is served by the Flowcept webservice (FastAPI, `src/flowcept/webservice/`). Built +assets are emitted into the Python package (`src/flowcept/webservice/ui_build/`) so released +wheels ship the UI; end users need no Node toolchain. + +**Quick start:** +```bash +make ui-install && make ui-build +flowcept --start-ui # webservice + Vite dev server; open http://localhost:8008 +``` + +--- + +## Dashboard data model + +Chart configurations are stored server-side in the MongoDB `dashboards` collection (or as +JSON files when Mongo is unavailable). They are managed via `GET/POST/PUT/DELETE /api/v1/dashboards`. + +There are four schema types: + +| Type | Applies to | Matched by | +|---|---|---| +| `common_workflow` | every workflow's Dashboard tab | — | +| `common_campaign` | every campaign's Dashboard tab | — | +| `custom_workflow` | a specific workflow (by name) | `target == workflow.name` | +| `custom_campaign` | a specific campaign | `target == campaign_id` | + +Default schemas are seeded from `src/flowcept/webservice/ui_build/default_dashboard_configs.json` +on first run. **When you edit `ui/public/default_dashboard_configs.json` you must also copy it +to `src/flowcept/webservice/ui_build/` and push the update to MongoDB:** +```bash +cp ui/public/default_dashboard_configs.json src/flowcept/webservice/ui_build/default_dashboard_configs.json +python -c " +import json +from flowcept.webservice.services.dashboard_store import get_dashboard_store +store = get_dashboard_store() +for doc in json.load(open('src/flowcept/webservice/ui_build/default_dashboard_configs.json')): + store.save(doc) +" +``` + +### Schema types + +``` +DashboardConfig + dashboard_id : string (uuid, server-assigned) + dashboard_type : "common_workflow" | "common_campaign" | "custom_workflow" | "custom_campaign" + target : string | null # workflow name or campaign_id for custom types + name : string + charts : Chart[] + +Chart + chart_id : string + type : "chart" | "metric" | "table" | "markdown" + title : string + live : bool # auto-refresh + data : ChartData # not used for markdown + viz : { kind: "bar" | "line" | "pie" | "scatter" | "area", stacked?: bool } + content : string # markdown body (markdown type only) + +ChartData + source : "tasks" | "workflows" | "objects" | "collection_sizes" + filter : {} # Mongo-style filter; ANDed with the dashboard context + group_by : string # dot-path field (e.g. "activity_id") + metrics : [{ field: string, agg: "count"|"avg"|"sum"|"min"|"max" }] + x / y : string / string[] # for scatter/line charts + limit : 1–5000 +``` + +**Context:** each chart's filter is automatically scoped to the current workflow or campaign +via `context.workflow_id` / `context.campaign_id`. + +**`collection_sizes` source:** a virtual source that returns BSON byte totals across the +`tasks`, `objects`, and `workflows` collections for the given context — used for the +"Data per collection" chart. + +**Auto-hide:** charts that return zero rows are hidden by default. Toggle pills above the +grid let users show/hide any chart. + +**Inspector:** clicking a chart pushes its raw data rows to the right-panel Inspector as a +formatted table. + +--- + +## Stack + +| Concern | Library | +|---|---| +| Build / dev server | Vite + TypeScript strict | +| UI framework | React 18 | +| Styling | Tailwind CSS 4 (dark theme via CSS variables in `src/index.css`) | +| Routing | TanStack Router (file-based, typed search params — all view state in the URL) | +| Server state | TanStack Query v5 | +| Tables | TanStack Table + Virtual (virtualized task tables) | +| Charts | Apache ECharts (`echarts/core`, tree-shaken; `components/charts/EChart.tsx` wrapper) | +| Dashboards grid | react-grid-layout v2 (drag/resize) | +| Markdown | react-markdown + remark-gfm + rehype-raw (provenance cards, chat) | +| SSE | @microsoft/fetch-event-source (supports POST for chat streaming) | +| Validation | zod (dashboard specs, route search params) | +| Ephemeral state | zustand (chat panel, inspector panel) | + +--- + +## Code layout + +``` +ui/ + vite.config.ts # dev proxy (/api → :8008), build.outDir → ../src/flowcept/webservice/ui_build + public/ + default_dashboard_configs.json # source of truth for default chart schemas + flowcept-logo.png + src/ + main.tsx # router + query client setup + index.css # Tailwind theme tokens (colors, card/prose utility classes) + api/ + client.ts # fetch wrapper for /api/v1 (apiGet/apiPost/apiPut/apiDelete) + types.ts # hand-maintained API types + queries.ts # TanStack Query hooks (useCampaigns, useWorkflow, useTasksQuery, ...) + sse.ts # useEventStream: cursor resume, backoff, tab-pause + lib/ + format.ts # toEpochSec / fmtTs / fmtDuration / fmtBytes / statusColor + stores/ + inspectorStore.ts # right-panel inspector state (task / artifact / chart data) + chatStore.ts # chat transcript + panel visibility + components/ + charts/ # EChart wrapper, GanttChart, DagView, DataflowView, StatusStrip, TelemetryChart + tables/DataTable.tsx# virtualized generic table (TanStack Table + Virtual) + markdown/ # Markdown renderer (rehype-raw for HTML in prov cards) + JsonTree.tsx # collapsible JSON tree + DeleteConfirmModal.tsx + dashboard/ + spec.ts # zod mirror of webservice schemas/dashboards.py + specToOption.ts # ChartData + rows → ECharts option + ChartRenderer.tsx # per-type chart rendering; data via POST /api/v1/stats/chart_data + routes/ + __root.tsx # app shell: sidebar + resizable panels + inspector + chat slot + index.tsx # overview page + campaigns.index.tsx / campaigns.$campaignId.tsx + workflows.index.tsx / workflows.$workflowId.tsx + tasks.$taskId.tsx + objects.index.tsx / objects.$objectId.tsx + agents.index.tsx + dashboards.index.tsx / dashboards.$dashboardId.tsx +``` + +--- + +## Running + +### Prerequisites + +- Flowcept installed with `[webservice]` extra. +- Redis + MongoDB running (`make services-mongo`). +- Node 22+ and npm for development/build only. + +### Production-style (bundled) + +```bash +make ui-install # once: npm ci --prefix ui +make ui-build # emits assets into src/flowcept/webservice/ui_build/ +flowcept --start-webservice # serves UI + API on :8008 +# open http://localhost:8008 +``` + +### Development (hot reload) + +```bash +make ui # kills old processes, starts webservice in background + Vite dev server in foreground + # UI: http://localhost:5173 (proxies /api → :8008) + # API: http://localhost:8008 +``` + +Or manually: +```bash +# terminal 1 — API: +PYTHONPATH=src python -m flowcept.cli --start-webservice + +# terminal 2 — UI dev server: +npm run dev --prefix ui +``` + +### Configurable ports + +| Environment variable | Default | Purpose | +|---|---|---| +| `WEBSERVER_HOST` | `0.0.0.0` | FastAPI bind host | +| `WEBSERVER_PORT` | `8008` | FastAPI bind port | +| `VITE_API_HOST` | `localhost` | API host the Vite proxy forwards to | +| `VITE_API_PORT` | `8008` | API port the Vite proxy forwards to | +| `VITE_DEV_PORT` | `5173` | Vite dev server listen port | + +### Enabling the chat (LLM) + +```yaml +# ~/.flowcept/settings.yaml +agent: + enabled: true + service_provider: openai # sambanova | azure | openai | google + llm_server_url: + api_key: + model: +web_server: + chat: + enabled: true + max_tool_iterations: 5 + max_query_limit: 1000 +``` + +Without this, `POST /api/v1/chat` returns 503 and the rest of the UI works normally. + +--- + +## Tests + +Integration tests (real services, no mocks) are in +`tests/webservice/test_webservice_integration.py`. + +Run with live services: +```bash +PYTHONPATH=src conda run -n flowcept python -m pytest tests/webservice/ +``` diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 00000000..56ad216d --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Flowcept + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 00000000..b4addb07 --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,5892 @@ +{ + "name": "flowcept-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "flowcept-ui", + "version": "0.1.0", + "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", + "@tanstack/react-query": "^5.62.0", + "@tanstack/react-router": "^1.95.0", + "@tanstack/react-table": "^8.20.0", + "@tanstack/react-virtual": "^3.11.0", + "@xyflow/react": "^12.11.0", + "date-fns": "^4.1.0", + "echarts": "^5.5.0", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-grid-layout": "^2.2.3", + "react-markdown": "^9.0.0", + "react-resizable-panels": "^4.11.2", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.0", + "zod": "^3.24.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.0.0", + "@tanstack/router-plugin": "^1.95.0", + "@types/node": "^25.9.2", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "openapi-typescript": "^7.4.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^3.2.6" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==", + "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@redocly/ajv": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js-replace": "^1.0.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@redocly/config": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@redocly/config/-/config-0.22.0.tgz", + "integrity": "sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@redocly/openapi-core": { + "version": "1.34.15", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.15.tgz", + "integrity": "sha512-HAwCnNyKcs5XGQqms+9t7OdAPM/5TDstmhF+0i7tdCFato2QKuYIlyWETwkXd8c5zbltr1oB+6y9NTeQLr2d6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/ajv": "8.11.2", + "@redocly/config": "0.22.0", + "colorette": "1.4.0", + "https-proxy-agent": "7.0.6", + "js-levenshtein": "1.1.6", + "js-yaml": "4.1.1", + "minimatch": "5.1.9", + "pluralize": "8.0.0", + "yaml-ast-parser": "0.0.43" + }, + "engines": { + "node": ">=18.17.0", + "npm": ">=9.5.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", + "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", + "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", + "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", + "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", + "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", + "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", + "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", + "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", + "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", + "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", + "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", + "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", + "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", + "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", + "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", + "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", + "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", + "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", + "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", + "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", + "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", + "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", + "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", + "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", + "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.20", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.20.tgz", + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || >=4.0.0 || insiders" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/history": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", + "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.15", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.15.tgz", + "integrity": "sha512-GawYz7HEjj8rTUUDoT/SemDEVm63pZUO+2mOcXHY9Jl3EwMS5gFBnPu/2UvcrwRm1jN1k79fokc0d4aFmrLatg==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.13", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz", + "integrity": "sha512-IpWnmCLvuymRfeeLNVXIzNEYBFLpd3drVIS91sqV78VTZFyldlChkOocZRCPp1B+Wnk09bcLNme8WaMU/9/9bQ==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.13", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.13.tgz", + "integrity": "sha512-+NOwEj1kO/6IGmpHRIZHasYxYWpyBQGNIZAST9aNrk9Q3YlU9SgqVnl1pbLa9qAKfeNdXQIRve0RQb/0kyDeDA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.167.17", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.167.17.tgz", + "integrity": "sha512-xtB9tB2Ws0tWR6Pi7nc3Qk9IYgoh1mQCKWjHqIl9tf6BNUpKoqniJoPAQ4+LGrK8FeZYU0o0p/qlZEyj9FAulA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.13", + "@tanstack/router-utils": "1.162.2", + "@tanstack/virtual-file-routes": "1.162.0", + "jiti": "^2.7.0", + "magic-string": "^0.30.21", + "prettier": "^3.5.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.168.18", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.168.18.tgz", + "integrity": "sha512-MofS28/axfnfnhOD2RSgJEaU882aX5RsAzhGz5Vc4XhAmvCjy919u9JrNs4QsTWFbTD1P7IJ8WFlFVsrg0pStg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.13", + "@tanstack/router-generator": "1.167.17", + "@tanstack/router-utils": "1.162.2", + "chokidar": "^5.0.0", + "unplugin": "^3.0.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.2 || ^2.0.0", + "@tanstack/react-router": "^1.170.15", + "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", + "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "vite": { + "optional": true + }, + "vite-plugin-solid": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-plugin/node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@tanstack/router-utils": { + "version": "1.162.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.162.2.tgz", + "integrity": "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.0.tgz", + "integrity": "sha512-gOxY/hFkPh/XQYhnThBHzkbkX3Ed+z/iushyz+R+JAr213aXxUDgQoTgTdrDpBSRsjFM73P/KfUyWmaF9WHMkQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.162.0.tgz", + "integrity": "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.9.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", + "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", + "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.6.tgz", + "integrity": "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.6.tgz", + "integrity": "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.6.tgz", + "integrity": "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.6.tgz", + "integrity": "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.6", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.6.tgz", + "integrity": "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.6.tgz", + "integrity": "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.6.tgz", + "integrity": "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.6", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.11.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.0.tgz", + "integrity": "sha512-na4IO33FSs2OS72hASgZDmTYwFAkef7Z74uBUVrong3ARmQQHfnRUVaCFn1kTt5LbS6pK03TbYjCPGLjLFfziA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.77", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.77", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.77.tgz", + "integrity": "sha512-qCDCMCQAAgUu8yHnhloHG9F5mwPX5E+Wl8McpYIOPSSXfzFJJoZcwOcsDiAjitVKIg2de1WmJbCHfpcvxprsgg==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", + "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/change-case": { + "version": "5.4.4", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz", + "integrity": "sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/colorette": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", + "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.4.0.tgz", + "integrity": "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.371", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", + "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz", + "integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/index-to-position": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/index-to-position/-/index-to-position-1.2.0.tgz", + "integrity": "sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isbot": { + "version": "5.1.42", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.42.tgz", + "integrity": "sha512-/SXsVh7KpPRISrD4ffrGSxnTLlUBzEQUfWIusaJPrpJ93FW1P0YEZri5vAUkFsA0m2HRUhQRQadk2wJ+EeKowQ==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-levenshtein": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", + "integrity": "sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/openapi-typescript": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/openapi-typescript/-/openapi-typescript-7.13.0.tgz", + "integrity": "sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redocly/openapi-core": "^1.34.6", + "ansi-colors": "^4.1.3", + "change-case": "^5.4.4", + "parse-json": "^8.3.0", + "supports-color": "^10.2.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "openapi-typescript": "bin/cli.js" + }, + "peerDependencies": { + "typescript": "^5.x" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz", + "integrity": "sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.26.2", + "index-to-position": "^1.1.0", + "type-fest": "^4.39.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-draggable": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.6.0.tgz", + "integrity": "sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-grid-layout": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.3.tgz", + "integrity": "sha512-OAEJHBxmfuxQfVtZwRzmsokijGlBgzYIJ7MUlLk/VSa43SaGzu15w5D0P2RDrfX5EvP9POMbL6bFrai/huDzbQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.1", + "fast-equals": "^4.0.3", + "prop-types": "^15.8.1", + "react-draggable": "^4.4.6", + "react-resizable": "^3.1.3", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">= 16.3.0", + "react-dom": ">= 16.3.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-resizable": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz", + "integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==", + "license": "MIT", + "dependencies": { + "prop-types": "15.x", + "react-draggable": "^4.5.0" + }, + "peerDependencies": { + "react": ">= 16.3", + "react-dom": ">= 16.3" + } + }, + "node_modules/react-resizable-panels": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.2.tgz", + "integrity": "sha512-+kfFbDZ8mygc7g0vxOcDzCVGuwiIUOnILqPoUHo6/uP+Mmyx6HzZU+kj1aOPDlktXuobYbr6BtQekvJwHRX4Eg==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.61.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", + "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.61.1", + "@rollup/rollup-android-arm64": "4.61.1", + "@rollup/rollup-darwin-arm64": "4.61.1", + "@rollup/rollup-darwin-x64": "4.61.1", + "@rollup/rollup-freebsd-arm64": "4.61.1", + "@rollup/rollup-freebsd-x64": "4.61.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", + "@rollup/rollup-linux-arm-musleabihf": "4.61.1", + "@rollup/rollup-linux-arm64-gnu": "4.61.1", + "@rollup/rollup-linux-arm64-musl": "4.61.1", + "@rollup/rollup-linux-loong64-gnu": "4.61.1", + "@rollup/rollup-linux-loong64-musl": "4.61.1", + "@rollup/rollup-linux-ppc64-gnu": "4.61.1", + "@rollup/rollup-linux-ppc64-musl": "4.61.1", + "@rollup/rollup-linux-riscv64-gnu": "4.61.1", + "@rollup/rollup-linux-riscv64-musl": "4.61.1", + "@rollup/rollup-linux-s390x-gnu": "4.61.1", + "@rollup/rollup-linux-x64-gnu": "4.61.1", + "@rollup/rollup-linux-x64-musl": "4.61.1", + "@rollup/rollup-openbsd-x64": "4.61.1", + "@rollup/rollup-openharmony-arm64": "4.61.1", + "@rollup/rollup-win32-arm64-msvc": "4.61.1", + "@rollup/rollup-win32-ia32-msvc": "4.61.1", + "@rollup/rollup-win32-x64-gnu": "4.61.1", + "@rollup/rollup-win32-x64-msvc": "4.61.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", + "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", + "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js-replace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", + "integrity": "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.6", + "@vitest/mocker": "3.2.6", + "@vitest/pretty-format": "^3.2.6", + "@vitest/runner": "3.2.6", + "@vitest/snapshot": "3.2.6", + "@vitest/spy": "3.2.6", + "@vitest/utils": "3.2.6", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.6", + "@vitest/ui": "3.2.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml-ast-parser": { + "version": "0.0.43", + "resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz", + "integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 00000000..862608a0 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,49 @@ +{ + "name": "flowcept-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && tsc --noEmit", + "lint": "tsc --noEmit", + "preview": "vite preview", + "test": "vitest run", + "gen-api-types": "openapi-typescript http://${VITE_API_HOST:-localhost}:${VITE_API_PORT:-5000}/openapi.json -o src/api/types.gen.ts" + }, + "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", + "@tanstack/react-query": "^5.62.0", + "@tanstack/react-router": "^1.95.0", + "@tanstack/react-table": "^8.20.0", + "@tanstack/react-virtual": "^3.11.0", + "@xyflow/react": "^12.11.0", + "date-fns": "^4.1.0", + "echarts": "^5.5.0", + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-grid-layout": "^2.2.3", + "react-markdown": "^9.0.0", + "react-resizable-panels": "^4.11.2", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.0", + "zod": "^3.24.0", + "zustand": "^5.0.0" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.0.0", + "@tanstack/router-plugin": "^1.95.0", + "@types/node": "^25.9.2", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "openapi-typescript": "^7.4.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^3.2.6" + } +} diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts new file mode 100644 index 00000000..51982945 --- /dev/null +++ b/ui/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/e2e", + fullyParallel: true, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? "github" : "list", + use: { + baseURL: "http://localhost:5173", + headless: true, + trace: "on-first-retry", + }, + projects: [ + { name: "chromium", use: { ...devices["Desktop Chrome"] } }, + ], + webServer: { + command: "npm run dev", + url: "http://localhost:5173", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/ui/public/default_dashboard_configs.json b/ui/public/default_dashboard_configs.json new file mode 100644 index 00000000..fffc087f --- /dev/null +++ b/ui/public/default_dashboard_configs.json @@ -0,0 +1,180 @@ +[ + { + "dashboard_id": "default-common-workflow", + "dashboard_type": "common_workflow", + "target": null, + "name": "Common Workflow Charts", + "charts": [ + { + "chart_id": "wf-tasks-by-activity", + "type": "chart", + "title": "Tasks by activity", + "data": { "source": "tasks", "group_by": "activity_id", "metrics": [{ "field": "", "agg": "count" }], "limit": 500 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "wf-tasks-by-host", + "type": "chart", + "title": "Tasks by host", + "data": { "source": "tasks", "group_by": "hostname", "metrics": [{ "field": "", "agg": "count" }], "limit": 500 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "wf-task-count", + "type": "metric", + "title": "Total tasks", + "data": { "source": "tasks", "metrics": [{ "field": "", "agg": "count" }], "limit": 1 } + }, + { + "chart_id": "wf-avg-duration-by-activity", + "type": "chart", + "title": "Avg task duration by activity (s)", + "data": { "source": "tasks", "group_by": "activity_id", "metrics": [{ "field": "elapsed", "agg": "avg" }], "limit": 50 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "wf-cpu-by-activity", + "type": "chart", + "title": "CPU by activity (%)", + "data": { "source": "tasks", "group_by": "activity_id", "metrics": [{ "field": "telemetry_at_end.cpu.percent_all", "agg": "avg" }], "limit": 50 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "wf-memory-by-activity", + "type": "chart", + "title": "Memory by activity", + "data": { "source": "tasks", "group_by": "activity_id", "metrics": [{ "field": "telemetry_at_end.memory.virtual.used", "agg": "avg" }], "limit": 50 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "wf-disk-by-activity", + "type": "chart", + "title": "Disk I/O by activity", + "data": { "source": "tasks", "group_by": "activity_id", "metrics": [{ "field": "telemetry_at_end.disk.io_sum.read_bytes", "agg": "sum" }, { "field": "telemetry_at_end.disk.io_sum.write_bytes", "agg": "sum" }], "limit": 50 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "wf-network-by-activity", + "type": "chart", + "title": "Network I/O by activity", + "data": { "source": "tasks", "group_by": "activity_id", "metrics": [{ "field": "telemetry_at_end.network.netio_sum.bytes_sent", "agg": "sum" }, { "field": "telemetry_at_end.network.netio_sum.bytes_recv", "agg": "sum" }], "limit": 50 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "wf-gpu-by-activity", + "type": "chart", + "title": "GPU memory by activity", + "data": { "source": "tasks", "group_by": "activity_id", "metrics": [{ "field": "telemetry_at_end.gpu.gpu_0.used", "agg": "avg" }], "limit": 50 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "wf-data-sizes-by-type", + "type": "chart", + "title": "Artifacts size by type", + "data": { "source": "objects", "group_by": "object_type", "metrics": [{ "field": "object_size_bytes", "agg": "sum" }], "limit": 50 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "wf-collection-sizes", + "type": "chart", + "title": "Data per collection", + "data": { "source": "collection_sizes", "group_by": "collection", "metrics": [{ "field": "bytes", "agg": "sum" }], "limit": 10 }, + "viz": { "kind": "bar", "stacked": false } + } + ], + "created_at": null, + "updated_at": null + }, + { + "dashboard_id": "default-common-campaign", + "dashboard_type": "common_campaign", + "target": null, + "name": "Common Campaign Charts", + "charts": [ + { + "chart_id": "camp-tasks-by-workflow", + "type": "chart", + "title": "Tasks by workflow", + "data": { "source": "tasks", "group_by": "workflow_id", "metrics": [{ "field": "", "agg": "count" }], "limit": 500 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "camp-top-activities", + "type": "chart", + "title": "Top activities", + "data": { "source": "tasks", "group_by": "activity_id", "metrics": [{ "field": "", "agg": "count" }], "limit": 20 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "camp-task-count", + "type": "metric", + "title": "Total tasks", + "data": { "source": "tasks", "metrics": [{ "field": "", "agg": "count" }], "limit": 1 } + } + ], + "created_at": null, + "updated_at": null + }, + { + "dashboard_id": "default-custom-workflow-perceptron", + "dashboard_type": "custom_workflow", + "target": "Perceptron GridSearch", + "name": "Perceptron GridSearch Charts", + "charts": [ + { + "chart_id": "wf-val-accuracy-by-config", + "type": "chart", + "title": "Val accuracy by config", + "data": { "source": "tasks", "group_by": "generated.config_id", "metrics": [{ "field": "generated.val_accuracy", "agg": "avg" }], "limit": 50 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "wf-val-loss-by-config", + "type": "chart", + "title": "Val loss by config", + "data": { "source": "tasks", "group_by": "generated.config_id", "metrics": [{ "field": "generated.val_loss", "agg": "avg" }], "limit": 50 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "wf-loss-vs-epochs", + "type": "chart", + "title": "Loss vs epochs", + "data": { "source": "tasks", "x": "used.epochs", "y": ["generated.val_loss"], "limit": 200 }, + "viz": { "kind": "line", "stacked": false } + }, + { + "chart_id": "wf-accuracy-vs-loss", + "type": "chart", + "title": "Val accuracy vs val loss", + "data": { "source": "tasks", "x": "generated.val_accuracy", "y": ["generated.val_loss"], "limit": 200 }, + "viz": { "kind": "scatter", "stacked": false } + } + ], + "created_at": null, + "updated_at": null + }, + { + "dashboard_id": "default-custom-campaign-gridsearch", + "dashboard_type": "custom_campaign", + "target": "GridSearchCampaign", + "name": "GridSearchCampaign Charts", + "charts": [ + { + "chart_id": "camp-val-accuracy-by-config", + "type": "chart", + "title": "Val accuracy by config", + "data": { "source": "tasks", "group_by": "generated.config_id", "metrics": [{ "field": "generated.val_accuracy", "agg": "avg" }], "limit": 50 }, + "viz": { "kind": "bar", "stacked": false } + }, + { + "chart_id": "camp-val-loss-by-config", + "type": "chart", + "title": "Val loss by config", + "data": { "source": "tasks", "group_by": "generated.config_id", "metrics": [{ "field": "generated.val_loss", "agg": "avg" }], "limit": 50 }, + "viz": { "kind": "bar", "stacked": false } + } + ], + "created_at": null, + "updated_at": null + } +] diff --git a/ui/public/flowcept-logo.png b/ui/public/flowcept-logo.png new file mode 100644 index 00000000..2e1262b8 Binary files /dev/null and b/ui/public/flowcept-logo.png differ diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts new file mode 100644 index 00000000..8bae797f --- /dev/null +++ b/ui/src/api/client.ts @@ -0,0 +1,55 @@ +/** Thin fetch wrapper for the Flowcept API under /api/v1. */ + +export const API_BASE = "/api/v1"; + +export class ApiError extends Error { + status: number; + constructor(status: number, detail: string) { + super(detail); + this.status = status; + } +} + +async function handle(rs: Response): Promise { + if (!rs.ok) { + let detail = rs.statusText; + try { + const body = await rs.json(); + detail = body.detail ?? JSON.stringify(body); + } catch { + /* keep statusText */ + } + throw new ApiError(rs.status, detail); + } + return rs.json() as Promise; +} + +export async function apiGet(path: string, params?: Record): Promise { + const url = new URL(API_BASE + path, window.location.origin); + for (const [k, v] of Object.entries(params ?? {})) { + if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); + } + return handle(await fetch(url)); +} + +export async function apiSend(method: string, path: string, body?: unknown): Promise { + return handle( + await fetch(API_BASE + path, { + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ); +} + +export const apiPost = (path: string, body?: unknown) => apiSend("POST", path, body); +export const apiPut = (path: string, body?: unknown) => apiSend("PUT", path, body); +export const apiDelete = (path: string) => apiSend("DELETE", path); + +export async function apiGetText(path: string, params?: Record): Promise { + const url = new URL(API_BASE + path, window.location.origin); + for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, v); + const rs = await fetch(url); + if (!rs.ok) throw new ApiError(rs.status, rs.statusText); + return rs.text(); +} diff --git a/ui/src/api/queries.ts b/ui/src/api/queries.ts new file mode 100644 index 00000000..b2f5593a --- /dev/null +++ b/ui/src/api/queries.ts @@ -0,0 +1,211 @@ +/** TanStack Query hooks for Flowcept API resources. */ + +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { apiGet, apiGetText, apiPost } from "./client"; +import { toEpochSec } from "../lib/format"; +import type { + AgentSummary, + BlobObjectDoc, + Campaign, + ListResponse, + QueryRequest, + Task, + TaskSummary, + Workflow, +} from "./types"; + +export function useInfo() { + return useQuery({ + queryKey: ["info"], + queryFn: () => apiGet<{ service: string; version: string }>("/info"), + staleTime: Infinity, + }); +} + +export function useDashboardConfigs(dashboard_type?: string) { + return useQuery({ + queryKey: ["dashboardConfigs", dashboard_type], + queryFn: () => + apiGet<{ items: Record[]; count: number }>("/dashboards", dashboard_type ? { dashboard_type } : {}), + staleTime: 30_000, + }); +} + +export function useResolveDashboard(params: { workflow_name?: string; campaign_id?: string }) { + const enabled = !!(params.workflow_name || params.campaign_id); + return useQuery({ + queryKey: ["resolveDashboard", params], + queryFn: () => apiGet[]>("/dashboards/resolve", params), + enabled, + staleTime: 30_000, + }); +} + +export function useCampaigns() { + return useQuery({ + queryKey: ["campaigns"], + queryFn: () => apiGet>("/campaigns"), + }); +} + +export function useCampaign(campaignId: string) { + return useQuery({ + queryKey: ["campaign", campaignId], + queryFn: () => + apiGet<{ campaign: Campaign; workflows: Workflow[]; task_summary: TaskSummary }>(`/campaigns/${campaignId}`), + }); +} + +export function useWorkflows(params: { campaign_id?: string; limit?: number } = {}) { + return useQuery({ + queryKey: ["workflows", params], + queryFn: () => apiGet>("/workflows", { limit: 200, ...params }), + }); +} + +export function useWorkflow(workflowId: string) { + return useQuery({ + queryKey: ["workflow", workflowId], + queryFn: () => apiGet(`/workflows/${workflowId}`), + }); +} + +export function useTasksQuery(body: QueryRequest, enabled = true) { + return useQuery({ + queryKey: ["tasks", body], + queryFn: () => apiPost>("/tasks/query", body), + enabled, + }); +} + +export function useTask(taskId: string, enabled = true) { + return useQuery({ + queryKey: ["task", taskId], + queryFn: () => apiGet(`/tasks/${taskId}`), + enabled: enabled && !!taskId, + }); +} + +export function useTaskSummary(params: { workflow_id?: string; campaign_id?: string; agent_id?: string }) { + return useQuery({ + queryKey: ["taskSummary", params], + queryFn: () => apiGet("/stats/tasks/summary", params), + }); +} + +export function useObjects(params: { workflow_id?: string; type?: string } = {}) { + const path = params.type === "ml_model" ? "/models" : params.type === "dataset" ? "/datasets" : "/objects"; + return useQuery({ + queryKey: ["objects", params], + queryFn: () => apiGet>(path, { workflow_id: params.workflow_id }), + }); +} + +export function useObject(objectId: string) { + return useQuery({ + queryKey: ["object", objectId], + queryFn: () => apiGet(`/objects/${objectId}`), + }); +} + +export function useObjectHistory(objectId: string) { + return useQuery({ + queryKey: ["objectHistory", objectId], + queryFn: () => apiGet>(`/objects/${objectId}/history`), + }); +} + +export function useAgents() { + return useQuery({ + queryKey: ["agents"], + queryFn: () => apiGet>("/agents"), + }); +} + +export function useAgent(agentId: string) { + return useQuery({ + queryKey: ["agent", agentId], + queryFn: () => apiGet<{ agent: AgentSummary; task_summary: import("./types").TaskSummary }>(`/agents/${agentId}`), + }); +} + +export function useWorkflowsWithTasks() { + return useQuery({ + queryKey: ["workflowsWithTasks"], + queryFn: async () => { + const result = await apiPost<{ rows: Record[]; count: number }>("/stats/chart_data", { + data: { + source: "tasks", + group_by: "workflow_id", + filter: { started_at: { $exists: true, $ne: null } }, + metrics: [{ field: "task_id", agg: "count" }], + limit: 5000, + }, + }); + return new Set(result.rows.map((r) => r["workflow_id"] as string)); + }, + staleTime: 30_000, + }); +} + +/** + * The single source of truth for user-facing workflow lists: only named + * workflows that have at least one task and a usable timestamp, preserving server chronology. + * Renders nothing until both queries resolve — never falls back to unfiltered data. + */ +export function useVisibleWorkflows(params: { campaign_id?: string } = {}) { + const workflows = useWorkflows(params); + const withTasks = useWorkflowsWithTasks(); + const items = useMemo(() => { + if (!workflows.data || !withTasks.data) return []; + return workflows.data.items + .filter((w) => w.name && withTasks.data.has(w.workflow_id) && toEpochSec(w.utc_timestamp) !== null) + .sort((a, b) => (toEpochSec(b.utc_timestamp) ?? 0) - (toEpochSec(a.utc_timestamp) ?? 0)); + }, [workflows.data, withTasks.data]); + return { + items, + isLoading: workflows.isLoading || withTasks.isLoading, + error: workflows.error ?? withTasks.error, + }; +} + +export interface DataflowNode { + id: string; + kind: "task" | "chunk"; + label: string; + stats: Record; +} + +export interface DataflowGraph { + level: "coarse"; + nodes: DataflowNode[]; + edges: { source: string; target: string; relation: "used" | "generated" | "derived"; key?: string }[]; + truncated: boolean; +} + +export function useDataflow(workflowId: string) { + return useQuery({ + queryKey: ["dataflow", workflowId], + queryFn: () => apiGet(`/workflows/${workflowId}/dataflow`), + staleTime: 30_000, + }); +} + +export function useProvenanceCard(scope: "workflows" | "campaigns", id: string, enabled = true) { + return useQuery({ + queryKey: ["provCard", scope, id], + queryFn: () => apiGetText(`/${scope}/${id}/workflow_card`, { format: "markdown" }), + enabled, + staleTime: 60_000, + }); +} + +export function useNodePositions(workflowId: string, graphType: string) { + return useQuery({ + queryKey: ["nodePositions", workflowId, graphType], + queryFn: () => apiGet>(`/workflows/${workflowId}/node_positions`, { graph_type: graphType }), + staleTime: 30_000, + enabled: !!workflowId, + }); +} diff --git a/ui/src/api/sse.ts b/ui/src/api/sse.ts new file mode 100644 index 00000000..6673606c --- /dev/null +++ b/ui/src/api/sse.ts @@ -0,0 +1,61 @@ +/** SSE hook for the /api/v1/stream endpoints: cursor resume, backoff reconnect, tab-pause. */ + +import { useEffect, useRef } from "react"; +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { API_BASE } from "./client"; + +interface StreamOptions { + path: string; // e.g. "/stream/tasks" + params: Record; + event: string; // e.g. "tasks" + enabled: boolean; + onBatch: (docs: T[], cursor: number) => void; +} + +export function useEventStream({ path, params, event, enabled, onBatch }: StreamOptions) { + const cursorRef = useRef(0); + const onBatchRef = useRef(onBatch); + onBatchRef.current = onBatch; + const paramsKey = JSON.stringify(params); + + useEffect(() => { + if (!enabled) return; + const ctrl = new AbortController(); + let retryMs = 1000; + + const connect = () => { + const url = new URL(API_BASE + path, window.location.origin); + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) url.searchParams.set(k, v); + } + if (cursorRef.current > 0) url.searchParams.set("since", String(cursorRef.current)); + + void fetchEventSource(url.toString(), { + signal: ctrl.signal, + openWhenHidden: false, // pause when tab hidden; resumes from cursor on visible + onmessage(msg) { + if (msg.event !== event || !msg.data) return; + try { + const payload = JSON.parse(msg.data) as Record; + const docs = (payload[event] as T[]) ?? []; + const cursor = (payload["cursor"] as number) ?? cursorRef.current; + cursorRef.current = cursor; + retryMs = 1000; + if (docs.length) onBatchRef.current(docs, cursor); + } catch { + /* ignore malformed events */ + } + }, + onerror() { + // Exponential backoff with jitter; fetchEventSource retries after the throw delay. + retryMs = Math.min(retryMs * 2, 30_000); + return retryMs + Math.random() * 500; + }, + }); + }; + + connect(); + return () => ctrl.abort(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [path, paramsKey, event, enabled]); +} diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts new file mode 100644 index 00000000..775ad3c0 --- /dev/null +++ b/ui/src/api/types.ts @@ -0,0 +1,116 @@ +/** Hand-maintained API types for the MVP (regenerable later via `npm run gen-api-types`). */ + +export interface ListResponse> { + items: T[]; + count: number; + limit: number; +} + +export interface Workflow { + workflow_id: string; + name?: string; + campaign_id?: string; + parent_workflow_id?: string; + user?: string; + utc_timestamp?: number; + flowcept_version?: string; + sys_name?: string; + environment_id?: string; + workflow_description?: string; + code_repository?: Record; + used?: Record; + generated?: Record; + custom_metadata?: Record; + [key: string]: unknown; +} + +export interface Task { + task_id: string; + workflow_id?: string; + parent_task_id?: string; + campaign_id?: string; + activity_id?: string; + agent_id?: string; + source_agent_id?: string; + status?: string; + subtype?: string; + hostname?: string; + user?: string; + started_at?: number | string; + ended_at?: number | string; + submitted_at?: number | string; + utc_timestamp?: number | string; + registered_at?: number | string; + used?: Record; + generated?: Record; + stdout?: unknown; + stderr?: unknown; + tags?: string[]; + telemetry_at_start?: Record; + telemetry_at_end?: Record; + [key: string]: unknown; +} + +export interface BlobObjectDoc { + object_id: string; + workflow_id?: string; + task_id?: string; + object_type?: string; + version?: number; + object_size_bytes?: number; + custom_metadata?: Record; + created_at?: string; + updated_at?: string; + [key: string]: unknown; +} + +export interface Campaign { + campaign_id: string; + workflow_count: number; + task_count: number; + users: string[]; + workflow_names: string[]; + first_ts?: number | null; + last_ts?: number | null; +} + +export interface AgentSummary { + agent_id: string; + name?: string; + registered_at?: number | string | null; + task_count: number; + activities: string[]; + source_agent_ids: string[]; + campaign_ids: string[]; + workflow_ids: string[]; + last_active?: number | null; +} + +export interface ActivityStat { + activity_id: string | null; + count: number; + status_counts: Record; + avg_duration?: number | null; + min_duration?: number | null; + max_duration?: number | null; + sum_duration?: number | null; +} + +export interface TaskSummary { + count: number; + status_counts: Record; + activity_stats: ActivityStat[]; + time_range: { min_started_at?: number | null; max_ended_at?: number | null }; +} + +export interface QueryRequest { + filter?: Record; + projection?: string[]; + limit?: number; + sort?: { field: string; order: 1 | -1 }[]; +} + +export interface ChartDataResult { + rows: Record[]; + count: number; +} diff --git a/ui/src/components/DeleteConfirmModal.tsx b/ui/src/components/DeleteConfirmModal.tsx new file mode 100644 index 00000000..a7051251 --- /dev/null +++ b/ui/src/components/DeleteConfirmModal.tsx @@ -0,0 +1,51 @@ +/** Modal that requires the user to type "DELETE" before confirming destructive removal. */ + +import { useState } from "react"; + +interface DeleteConfirmModalProps { + title: string; + description: string; + onConfirm: () => void; + onCancel: () => void; + loading?: boolean; +} + +export function DeleteConfirmModal({ title, description, onConfirm, onCancel, loading }: DeleteConfirmModalProps) { + const [input, setInput] = useState(""); + const ready = input === "DELETE"; + + return ( +
+
+

{title}

+

{description}

+

+ Type DELETE to confirm. +

+ setInput(e.target.value)} + autoFocus + /> +
+ + +
+
+
+ ); +} diff --git a/ui/src/components/JsonTree.tsx b/ui/src/components/JsonTree.tsx new file mode 100644 index 00000000..3bb1cb7e --- /dev/null +++ b/ui/src/components/JsonTree.tsx @@ -0,0 +1,46 @@ +/** Compact collapsible JSON viewer for used/generated/telemetry payloads. */ + +import { useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +function Entry({ name, value, depth }: { name: string; value: unknown; depth: number }) { + const [open, setOpen] = useState(depth < 1); + const isObj = value !== null && typeof value === "object"; + + if (!isObj) { + return ( +
+ {name}: + {value === null ? "null" : String(value)} +
+ ); + } + + const entries = Array.isArray(value) + ? value.map((v, i) => [String(i), v] as [string, unknown]) + : Object.entries(value as Record); + + return ( +
+ + {open && entries.map(([k, v]) => )} +
+ ); +} + +export function JsonTree({ data, name = "root" }: { data: unknown; name?: string }) { + if (data === null || data === undefined) return
; + return ( +
+ +
+ ); +} diff --git a/ui/src/components/charts/CoarseDataflowView.tsx b/ui/src/components/charts/CoarseDataflowView.tsx new file mode 100644 index 00000000..687575e5 --- /dev/null +++ b/ui/src/components/charts/CoarseDataflowView.tsx @@ -0,0 +1,404 @@ +/** + * Coarse Provenance Graph: task nodes grouped by activity_id, chunk nodes + * grouped by their generating activity. Nodes with count > 1 show a ×N + * badge and a double-border outline. Edges with count > 1 are rendered as + * two offset parallel bezier curves with a ×N label. + */ + +import "@xyflow/react/dist/style.css"; +import { useEffect } from "react"; +import { + ReactFlow, + ReactFlowProvider, + Background, + Controls, + MarkerType, + useNodesState, + useEdgesState, + Position, + getBezierPath, + type Node, + type Edge, + type EdgeProps, +} from "@xyflow/react"; +import { Bot } from "lucide-react"; +import { useDataflow } from "../../api/queries"; +import { useInspectorStore } from "../../stores/inspectorStore"; +import { coarsenGraph, type CoarseNode } from "../../lib/coarsenGraph"; +import { agentIconStyle, buildAgentNameColorMap } from "../../lib/format"; + +// W3C PROV colours — same palette as DataflowView for visual consistency. +const PROV = { + entityBg: "#FFFC87", + entityBorder: "#808080", + activityBg: "#9FB1FC", + activityBorder: "#0000FF", + text: "#11111b", +}; + +// --------------------------------------------------------------------------- +// Custom edge: double-line for aggregated connections +// --------------------------------------------------------------------------- + +function AggregatedEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerEnd, + style, + data, +}: EdgeProps) { + const count = (data as Record)?.count as number ?? 1; + const color = (style?.stroke as string) ?? "#666"; + const opacity = (style?.opacity as number) ?? 0.85; + + const baseProps = { sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition }; + const [pathMid, lx, ly] = getBezierPath(baseProps); + + if (count <= 1) { + return ( + + ); + } + + // Two parallel bezier curves with ±3.5 px vertical offset → rail-track effect. + const [pathTop] = getBezierPath({ ...baseProps, sourceY: sourceY - 3.5, targetY: targetY - 3.5 }); + const [pathBot] = getBezierPath({ ...baseProps, sourceY: sourceY + 3.5, targetY: targetY + 3.5 }); + + return ( + + + + {/* Invisible wide hit area for pointer events */} + + + ×{count} + + + ); +} + +// Defined outside the component so the reference is stable across renders. +const EDGE_TYPES = { aggregated: AggregatedEdge }; + +// --------------------------------------------------------------------------- +// Layout +// --------------------------------------------------------------------------- + +function fmtDur(sec: number): string { + if (sec < 1) return `${(sec * 1000).toFixed(0)}ms`; + if (sec < 60) return `${sec.toFixed(1)}s`; + return `${(sec / 60).toFixed(1)}m`; +} + +interface CoarseLike { + nodes: { id: string }[]; + edges: { source: string; target: string }[]; +} + +function layoutCoarse(g: CoarseLike) { + const inDegree = new Map(g.nodes.map((n) => [n.id, 0])); + const adj = new Map(g.nodes.map((n) => [n.id, []])); + for (const e of g.edges) { + adj.get(e.source)?.push(e.target); + inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1); + } + const ranks = new Map(); + const queue = g.nodes.filter((n) => (inDegree.get(n.id) ?? 0) === 0).map((n) => n.id); + for (const id of queue) ranks.set(id, 0); + let head = 0; + while (head < queue.length) { + const curr = queue[head++]; + for (const next of adj.get(curr) ?? []) { + const nextRank = (ranks.get(curr) ?? 0) + 1; + if (!ranks.has(next)) { + ranks.set(next, nextRank); + queue.push(next); + } else if (nextRank > (ranks.get(next) ?? 0) && nextRank < 50) { + ranks.set(next, nextRank); + queue.push(next); + } + } + } + for (const n of g.nodes) if (!ranks.has(n.id)) ranks.set(n.id, 0); + const rankGroups = new Map(); + for (const [id, r] of ranks) { + if (!rankGroups.has(r)) rankGroups.set(r, []); + rankGroups.get(r)!.push(id); + } + return { ranks, rankGroups }; +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + +interface Props { + workflowId: string; + height?: string | number; +} + +export function CoarseDataflowView({ workflowId, height }: Props) { + const { data: graph, isLoading, error } = useDataflow(workflowId); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + useEffect(() => { + if (!graph) { + setNodes([]); + setEdges([]); + return; + } + + const coarse = coarsenGraph(graph); + const { ranks, rankGroups } = layoutCoarse(coarse); + + // Build agent color map from ALL original task node agent IDs — same scheme as DataflowView. + const agentColorMap = buildAgentNameColorMap( + graph.nodes + .filter((n) => n.kind === "task") + .map((n) => (n.stats?.agent_id || n.stats?.source_agent_id) as string | null | undefined), + ); + // Quick lookup from original node ID → node (for agent_id extraction). + const nodeMap = new Map(graph.nodes.map((n) => [n.id, n])); + + const nextNodes: Node[] = coarse.nodes.map((n) => { + const rank = ranks.get(n.id) ?? 0; + const siblings = rankGroups.get(rank) ?? []; + const idx = siblings.indexOf(n.id); + const isEntity = n.kind !== "task"; + const isAggregated = n.count > 1; + + // Use the first original node's agent_id (matches DagView's actTasks[0] approach). + const firstOrig = nodeMap.get(n.originalIds[0]); + const agentId = n.kind === "task" + ? ((firstOrig?.stats?.agent_id || firstOrig?.stats?.source_agent_id) as string | null | undefined) + : undefined; + const hasAgent = !!agentId; + + let labelContent: React.ReactNode; + if (n.kind === "task" && isAggregated) { + const mean = n.durationStats?.mean; + labelContent = ( +
+ {n.label} + + ×{n.count}{mean != null ? ` · avg ${fmtDur(mean)}` : ""} + +
+ ); + } else if (isEntity && isAggregated) { + labelContent = ( +
+ {n.label} + ×{n.count} chunks +
+ ); + } else { + labelContent = n.label; + } + + // Wrap task label with the agent icon (same position as DataflowView / DagView). + if (hasAgent) { + labelContent = ( +
+ + {labelContent} +
+ ); + } + + const baseStyle = isEntity + ? { + background: PROV.entityBg, + color: PROV.text, + border: `1.5px solid ${PROV.entityBorder}`, + borderRadius: "50%", + padding: "10px 18px", + fontSize: 11, + textAlign: "center" as const, + } + : { + background: PROV.activityBg, + color: PROV.text, + border: `1.5px solid ${PROV.activityBorder}`, + borderRadius: 4, + padding: "8px 14px", + fontSize: 11, + textAlign: "center" as const, + }; + + return { + id: n.id, + position: { x: 250 * rank, y: 80 * idx }, + data: { label: labelContent, coarseNode: n }, + targetPosition: Position.Left, + sourcePosition: Position.Right, + style: isAggregated + ? { + ...baseStyle, + outline: `2px solid ${isEntity ? PROV.entityBorder : PROV.activityBorder}`, + outlineOffset: "3px", + } + : baseStyle, + }; + }); + + const nextEdges: Edge[] = coarse.edges.map((e, i) => ({ + id: `${e.source}->${e.target}-${i}`, + source: e.source, + target: e.target, + type: "aggregated", + data: { count: e.count }, + markerEnd: { type: MarkerType.ArrowClosed }, + style: { + opacity: 0.85, + strokeDasharray: e.relation === "derived" ? "5 4" : undefined, + }, + })); + + setNodes(nextNodes); + setEdges(nextEdges); + }, [graph, setNodes, setEdges]); + + if (isLoading) return
Loading coarse provenance graph…
; + if (error) return
No dataflow data captured for this workflow.
; + if (!graph || nodes.length === 0) return
No dataflow data captured.
; + + return ( +
+
+ + Tasks with the same activity are condensed into one node — ×N shows how many were aggregated. + {graph.truncated && ( + Graph truncated — showing the first tasks only. + )} + +
+ +
+ + { + const cn = (node.data as Record)?.coarseNode as CoarseNode | undefined; + if (!cn) return; + + // Single node: pass original DataflowNode data, same as DataflowView. + if (cn.count === 1) { + const orig = graph.nodes.find((n) => n.id === cn.originalIds[0]); + if (orig) { + useInspectorStore.getState().set({ + kind: orig.kind === "task" ? "task" : "dataflow", + data: { label: orig.label, stats: orig.stats }, + }); + } + return; + } + + // Aggregated task: show duration stats summary. + if (cn.kind === "task") { + useInspectorStore.getState().set({ + kind: "activity", + data: { + label: cn.label, + stats: { + task_count: cn.count, + task_ids: cn.originalIds, + duration_stats: cn.durationStats ?? null, + }, + }, + }); + return; + } + + // Aggregated chunk: show per-key item stats if available. + useInspectorStore.getState().set({ + kind: "dataflow", + data: { + label: cn.label, + stats: { + chunk_count: cn.count, + activities: cn.activities, + original_ids: cn.originalIds, + item_stats: cn.itemStats ?? null, + }, + }, + }); + }} + fitView + fitViewOptions={{ padding: 0.15 }} + > + + + + +
+ + {/* Legend */} +
+
+ + + data (aggregated) + + + + task activity (aggregated) + + double edge = multiple parallel connections + ×N = number of original instances +
+
+
+ ); +} diff --git a/ui/src/components/charts/DagView.tsx b/ui/src/components/charts/DagView.tsx new file mode 100644 index 00000000..741a5c0e --- /dev/null +++ b/ui/src/components/charts/DagView.tsx @@ -0,0 +1,318 @@ +/** Activity- or task-level DAG view for a workflow's tasks. */ + +import "@xyflow/react/dist/style.css"; +import { useEffect, useMemo, useState } from "react"; +import { ReactFlow, ReactFlowProvider, useReactFlow, Background, Controls, MarkerType, useNodesState, useEdgesState, Position, type Node, type Edge } from "@xyflow/react"; +import type { Task } from "../../api/types"; +import { fmtDuration, shortId, toEpochSec, agentIconStyle, buildAgentNameColorMap, applyNodePositions } from "../../lib/format"; +import { useInspectorStore } from "../../stores/inspectorStore"; +import { useHighlightStore } from "../../stores/highlightStore"; +import { TASK_NODE_STYLE } from "./graphStyles"; +import { Bot } from "lucide-react"; +import { useNodePositions } from "../../api/queries"; +import { apiPost } from "../../api/client"; + +const MAX_TASK_NODES = 150; + +interface Props { + tasks: Task[]; + /** "activity" groups tasks by activity_id (default); "task" shows one node per task. */ + mode?: "activity" | "task"; + height?: string | number; +} + +function FitViewHelper({ trigger }: { trigger: any }) { + const { fitView } = useReactFlow(); + useEffect(() => { + const timer = setTimeout(() => { + void fitView({ duration: 250, padding: 0.2 }); + }, 100); + return () => clearTimeout(timer); + }, [trigger, fitView]); + return null; +} + +export function DagView({ tasks: allTasks, mode = "activity", height }: Props) { + const tasks = useMemo(() => { + if (mode !== "task" || allTasks.length <= MAX_TASK_NODES) return allTasks; + return [...allTasks] + .sort((a, b) => (toEpochSec(a.started_at) ?? 0) - (toEpochSec(b.started_at) ?? 0)) + .slice(0, MAX_TASK_NODES); + }, [allTasks, mode]); + + const agentHighlight = useHighlightStore((s) => s.taskIds); + const workflowId = allTasks[0]?.workflow_id; + const { data: positions } = useNodePositions(workflowId || "", mode); + + const [prevTasks, setPrevTasks] = useState(allTasks); + const [prevMode, setPrevMode] = useState(mode); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + if (prevTasks !== allTasks || prevMode !== mode) { + setPrevTasks(allTasks); + setPrevMode(mode); + setNodes([]); + setEdges([]); + } + + useEffect(() => { + // Group tasks by node key: activity_id, or task_id in task mode. + const byActivity = new Map(); + for (const t of tasks) { + const id = (mode === "task" ? t.task_id : t.activity_id) ?? "unknown"; + if (!byActivity.has(id)) byActivity.set(id, []); + byActivity.get(id)!.push(t); + } + + const activities = [...byActivity.keys()]; + + // Derive edge connections from task dependencies, falling back to time-ordered chain + const hasDeps = tasks.some((t: Task) => Array.isArray((t as any).dependencies) && (t as any).dependencies.length > 0); + + const nodeKey = (t: Task) => ((mode === "task" ? t.task_id : t.activity_id) ?? "unknown"); + + const activityEdges = new Set(); + if (hasDeps) { + for (const t of tasks) { + const deps: string[] = (t as any).dependencies ?? []; + for (const depId of deps) { + const depTask = tasks.find((x: Task) => x.task_id === depId); + if (depTask && nodeKey(depTask) !== nodeKey(t)) { + activityEdges.add(`${nodeKey(depTask)}__${nodeKey(t)}`); + } + } + } + } + if (mode === "task" && activityEdges.size === 0) { + // Parent links as a secondary structure source in task mode. + for (const t of tasks) { + const parent = (t as any).parent_task_id; + if (parent && byActivity.has(parent)) activityEdges.add(`${parent}__${t.task_id}`); + } + } + + if (!hasDeps || activityEdges.size === 0) { + // Fallback: sort activities by min started_at and chain linearly + const sorted = activities.slice().sort((a, b) => { + const minA = Math.min(...(byActivity.get(a) ?? []).map((t) => toEpochSec(t.started_at) ?? Infinity)); + const minB = Math.min(...(byActivity.get(b) ?? []).map((t) => toEpochSec(t.started_at) ?? Infinity)); + return minA - minB; + }); + for (let i = 0; i < sorted.length - 1; i++) { + activityEdges.add(`${sorted[i]}__${sorted[i + 1]}`); + } + } + + // Build rank tiers: BFS from sources + const inDegree = new Map(activities.map((a) => [a, 0])); + const adj = new Map(activities.map((a) => [a, []])); + for (const edge of activityEdges) { + const [from, to] = edge.split("__"); + adj.get(from)?.push(to); + inDegree.set(to, (inDegree.get(to) ?? 0) + 1); + } + const ranks = new Map(); + const queue = activities.filter((a) => (inDegree.get(a) ?? 0) === 0); + for (const a of queue) ranks.set(a, 0); + let head = 0; + while (head < queue.length) { + const curr = queue[head++]; + for (const next of adj.get(curr) ?? []) { + const nextRank = (ranks.get(curr) ?? 0) + 1; + if (!ranks.has(next)) { + ranks.set(next, nextRank); + queue.push(next); + } else if (nextRank > (ranks.get(next) ?? 0)) { + ranks.set(next, nextRank); + } + } + } + // Assign position: x = 220 * rank, y = 90 * index within rank + const rankGroups = new Map(); + for (const [a, r] of ranks) { + if (!rankGroups.has(r)) rankGroups.set(r, []); + rankGroups.get(r)!.push(a); + } + + // Task mode without structural edges: grid columns per activity, rows per task. + const gridFallback = mode === "task" && activityEdges.size === 0; + const activityColumns = new Map(); + const rowWithinActivity = new Map(); + if (gridFallback) { + const perActivityCount = new Map(); + const sortedTasks = [...tasks].sort( + (a, b) => (toEpochSec(a.started_at) ?? 0) - (toEpochSec(b.started_at) ?? 0), + ); + for (const t of sortedTasks) { + const act = t.activity_id ?? "unknown"; + if (!activityColumns.has(act)) activityColumns.set(act, activityColumns.size); + const row = perActivityCount.get(act) ?? 0; + perActivityCount.set(act, row + 1); + rowWithinActivity.set(t.task_id, row); + } + } + + // Build a name-keyed color map: same agent type always gets the same color. + const agentColorMap = buildAgentNameColorMap( + activities.map((activity) => { + const actTasks = byActivity.get(activity) ?? []; + return actTasks[0]?.agent_id || actTasks[0]?.source_agent_id; + }), + ); + + const nextNodes: Node[] = activities.map((activity) => { + const actTasks = byActivity.get(activity) ?? []; + const statuses = actTasks.map((t) => t.status ?? ""); + let rank = ranks.get(activity) ?? 0; + const siblings = rankGroups.get(rank) ?? []; + let idx = siblings.indexOf(activity); + const labelText = + mode === "task" + ? `${actTasks[0]?.activity_id ?? "task"}\n${shortId(activity, 12)}` + : `${activity}\n(${actTasks.length})`; + const agentId = actTasks[0]?.agent_id || actTasks[0]?.source_agent_id; + const hasAgent = !!agentId; + const label = hasAgent ? ( +
+ + {labelText} +
+ ) : ( + labelText + ); + const start = Math.min(...actTasks.map((t) => toEpochSec(t.started_at) ?? Infinity)); + const end = Math.max(...actTasks.map((t) => toEpochSec(t.ended_at) ?? -Infinity)); + const duration = start !== Infinity && end !== -Infinity ? fmtDuration(end - start) : null; + if (gridFallback) { + rank = activityColumns.get(actTasks[0]?.activity_id ?? "unknown") ?? 0; + idx = rowWithinActivity.get(activity) ?? 0; + } + const stats: Record = + mode === "task" + ? { + activity_id: actTasks[0]?.activity_id ?? null, + task_id: activity, + status: actTasks[0]?.status ?? null, + started_at: actTasks[0]?.started_at ?? null, + ended_at: actTasks[0]?.ended_at ?? null, + duration, + hostname: actTasks[0]?.hostname ?? null, + parent_task_id: actTasks[0]?.parent_task_id ?? null, + used: actTasks[0]?.used ?? null, + generated: actTasks[0]?.generated ?? null, + agent_id: actTasks[0]?.agent_id ?? null, + source_agent_id: actTasks[0]?.source_agent_id ?? null, + subtype: actTasks[0]?.subtype ?? null, + } + : { + activity_id: activity, + task_count: actTasks.length, + status_counts: statuses.reduce>((acc, status) => { + if (!acc) return {}; // safety check + acc[status] = (acc[status] ?? 0) + 1; + return acc; + }, {}), + started_at: start === Infinity ? null : start, + ended_at: end === -Infinity ? null : end, + duration, + task_ids: actTasks.map((t) => t.task_id), + agent_id: actTasks[0]?.agent_id ?? null, + source_agent_id: actTasks[0]?.source_agent_id ?? null, + }; + // Dim when the agent has highlighted specific tasks and this node is not among them. + const dimmed = + agentHighlight.size > 0 && + (mode === "task" + ? !agentHighlight.has(activity) + : !actTasks.some((t) => agentHighlight.has(t.task_id))); + + return { + id: activity, + position: { x: 220 * rank, y: (mode === "task" ? 70 : 90) * idx }, + data: { label, labelText, stats } as any, + targetPosition: Position.Left, + sourcePosition: Position.Right, + style: { + ...TASK_NODE_STYLE, + fontSize: mode === "task" ? 10 : 12, + whiteSpace: "pre", + opacity: dimmed ? 0.15 : 1, + }, + }; + }); + + const nextEdges: Edge[] = [...activityEdges].map((key) => { + const [source, target] = key.split("__"); + return { id: key, source, target, type: "smoothstep", markerEnd: { type: MarkerType.ArrowClosed } }; + }); + + const nextNodesWithPositions = applyNodePositions(nextNodes, positions); + + // Update nodes state preserving previously dragged positions. + setNodes((prevNodes) => { + const prevPositions = new Map(prevNodes.map((n) => [n.id, n.position])); + return nextNodesWithPositions.map((n) => ({ + ...n, + position: prevPositions.get(n.id) || n.position, + })); + }); + setEdges(nextEdges); + }, [tasks, mode, agentHighlight, positions]); + + const handleNodeDragStop = () => { + if (!workflowId) return; + const posPayload: Record = {}; + nodes.forEach((n) => { + if (n.position) { + posPayload[n.id] = n.position; + } + }); + apiPost(`/workflows/${workflowId}/node_positions`, { + graph_type: mode, + positions: posPayload, + }).catch(console.error); + }; + + if (nodes.length === 0) return null; + + return ( +
+ {mode === "task" && allTasks.length > MAX_TASK_NODES && ( +
+ Showing the first {MAX_TASK_NODES} of {allTasks.length} tasks. +
+ )} +
+ + { + const nodeData = node.data as any; + useInspectorStore.getState().set({ + kind: mode === "task" ? "task" : "activity", + data: { label: nodeData.labelText, stats: nodeData.stats }, + }); + }} + fitView + fitViewOptions={{ padding: 0.2 }} + > + + + + + +
+
+ ); +} diff --git a/ui/src/components/charts/DataflowView.tsx b/ui/src/components/charts/DataflowView.tsx new file mode 100644 index 00000000..01a98a97 --- /dev/null +++ b/ui/src/components/charts/DataflowView.tsx @@ -0,0 +1,405 @@ +/** PROV-style dataflow graph: yellow-ellipse data entities, blue-rectangle task activities. + * + * Each task's inputs/outputs are packed into chunk entities; click nodes for details. + */ + +import "@xyflow/react/dist/style.css"; +import { useEffect, useState } from "react"; +import { ReactFlow, ReactFlowProvider, useReactFlow, Background, Controls, MarkerType, useNodesState, useEdgesState, Position, type Node, type Edge } from "@xyflow/react"; +import { useDataflow, useNodePositions, type DataflowGraph } from "../../api/queries"; +import { useInspectorStore } from "../../stores/inspectorStore"; +import { useHighlightStore } from "../../stores/highlightStore"; +import { TASK_NODE_STYLE } from "./graphStyles"; +import { Bot } from "lucide-react"; +import { agentIconStyle, buildAgentNameColorMap, applyNodePositions, filterGraphEdges } from "../../lib/format"; +import { apiPost } from "../../api/client"; + +interface Props { + workflowId: string; + height?: string | number; +} + +// W3C PROV diagram convention: Entity = yellow ellipse, Activity = blue rectangle. +const PROV = { + entityBg: "#FFFC87", + entityBorder: "#808080", + activityBg: "#9FB1FC", + activityBorder: "#0000FF", + text: "#11111b", +}; + +/** Longest-path layered layout over the directed graph. */ +function layout(graph: DataflowGraph, options: { showDelegation: boolean }) { + const visibleNodes = graph.nodes; + const visibleIds = new Set(visibleNodes.map((n) => n.id)); + const visibleEdges = filterGraphEdges( + graph.edges.filter((e) => visibleIds.has(e.source) && visibleIds.has(e.target)), + options + ); + + const inDegree = new Map(visibleNodes.map((n) => [n.id, 0])); + const adj = new Map(visibleNodes.map((n) => [n.id, []])); + for (const e of visibleEdges) { + adj.get(e.source)?.push(e.target); + inDegree.set(e.target, (inDegree.get(e.target) ?? 0) + 1); + } + + const ranks = new Map(); + const queue = visibleNodes.filter((n) => (inDegree.get(n.id) ?? 0) === 0).map((n) => n.id); + for (const id of queue) ranks.set(id, 0); + let head = 0; + while (head < queue.length) { + const curr = queue[head++]; + for (const next of adj.get(curr) ?? []) { + const nextRank = (ranks.get(curr) ?? 0) + 1; + if (!ranks.has(next)) { + ranks.set(next, nextRank); + queue.push(next); + } else if (nextRank > (ranks.get(next) ?? 0) && nextRank < 50) { + ranks.set(next, nextRank); + queue.push(next); + } + } + } + for (const n of visibleNodes) if (!ranks.has(n.id)) ranks.set(n.id, 0); + + const rankGroups = new Map(); + for (const [id, r] of ranks) { + if (!rankGroups.has(r)) rankGroups.set(r, []); + rankGroups.get(r)!.push(id); + } + return { visibleNodes, visibleEdges, ranks, rankGroups }; +} + +function FitViewHelper({ trigger }: { trigger: any }) { + const { fitView } = useReactFlow(); + useEffect(() => { + const timer = setTimeout(() => { + void fitView({ duration: 250, padding: 0.15 }); + }, 100); + return () => clearTimeout(timer); + }, [trigger, fitView]); + return null; +} + +export function DataflowView({ workflowId, height }: Props) { + const [focus, setFocus] = useState(null); + const [showDelegation, setShowDelegation] = useState(true); + const agentHighlight = useHighlightStore((s) => s.taskIds); + + const { data: graph, isLoading, error } = useDataflow(workflowId); + const { data: positions } = useNodePositions(workflowId, "dataflow"); + + const [prevWfId, setPrevWfId] = useState(workflowId); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + if (prevWfId !== workflowId) { + setPrevWfId(workflowId); + setNodes([]); + setEdges([]); + } + + useEffect(() => { + if (!graph) { + setNodes([]); + setEdges([]); + return; + } + + const { visibleNodes, visibleEdges, ranks, rankGroups } = layout(graph, { showDelegation }); + + // Seed lineage from: agent-highlighted task nodes + local click focus (combined). + const seeds = new Set(); + if (focus) seeds.add(focus); + for (const tid of agentHighlight) seeds.add(`task:${tid}`); + + let lineage: Set | null = null; + if (seeds.size > 0) { + // 1. Build a map of values for each node in the graph. + const getValues = (n: any) => { + const vals = new Set(); + const addVal = (v: any) => { + if (v === null || v === undefined) return; + if (Array.isArray(v)) { + for (const item of v) addVal(item); + } else if (typeof v === "object") { + for (const k of Object.keys(v)) addVal(v[k]); + } else { + const s = String(v); + if (s.length > 2 && s !== "true" && s !== "false") { + vals.add(s); + } + } + }; + if (n.kind === "chunk") { + addVal(n.stats?.items); + } else { + addVal(n.stats?.used); + addVal(n.stats?.generated); + } + return vals; + }; + + const nodeValuesMap = new Map>(); + const nodeMap = new Map(); + for (const n of visibleNodes) { + nodeValuesMap.set(n.id, getValues(n)); + nodeMap.set(n.id, n); + } + + // 2. Collect the union of all primitive values from the seed nodes. + const V_seed = new Set(); + for (const seed of seeds) { + const vals = nodeValuesMap.get(seed); + if (vals) { + for (const v of vals) V_seed.add(v); + } + } + + // 3. Run standard BFS/DFS guided by V_seed intersection on derived edges only. + lineage = new Set(seeds); + const fwd = new Map(); + const back = new Map(); + for (const e of visibleEdges) { + if (!fwd.has(e.source)) fwd.set(e.source, []); + fwd.get(e.source)!.push({ node: e.target, relation: e.relation }); + if (!back.has(e.target)) back.set(e.target, []); + back.get(e.target)!.push({ node: e.source, relation: e.relation }); + } + // Two separate passes to avoid cross-contamination: forward (descendants) then backward (ancestors). + for (const adj of [fwd, back]) { + const stack = [...seeds]; + while (stack.length) { + const curr = stack.pop()!; + for (const edge of adj.get(curr) ?? []) { + const next = edge.node; + const rel = edge.relation; + + let shouldTraverse = true; + if (rel === "derived") { + const nextVals = nodeValuesMap.get(next) ?? new Set(); + const intersection = new Set([...V_seed].filter((x) => nextVals.has(x))); + shouldTraverse = V_seed.size === 0 || nextVals.size === 0 || intersection.size > 0; + } + + if (shouldTraverse) { + if (!lineage.has(next)) { + lineage.add(next); + + // Add next node's values to V_seed dynamically if it's a chunk node and NOT a selector node. + const isChunk = next.startsWith("chunk:"); + if (isChunk) { + const incomingDerived = (back.get(next) ?? []).filter((e) => e.relation === "derived"); + let isSelectorChunk = false; + if (incomingDerived.length > 1) { + const sourceActivities = new Set(); + for (const e of incomingDerived) { + const srcNode = nodeMap.get(e.node); + if (srcNode && srcNode.stats?.generated_by) { + for (const g of srcNode.stats.generated_by) { + if (g.activity) { + sourceActivities.add(g.activity); + } + } + } + } + if (sourceActivities.size === 1) { + isSelectorChunk = true; + } + } + + if (!isSelectorChunk) { + const nextVals = nodeValuesMap.get(next); + if (nextVals) { + for (const v of nextVals) V_seed.add(v); + } + } + } + + stack.push(next); + } + } + } + } + } + } + + // Build a name-keyed color map: same agent type always gets the same color. + const agentColorMap = buildAgentNameColorMap( + visibleNodes.map((n) => (n.stats?.agent_id || n.stats?.source_agent_id) as string | null | undefined), + ); + + const nextNodes: Node[] = visibleNodes.map((n) => { + const rank = ranks.get(n.id) ?? 0; + const siblings = rankGroups.get(rank) ?? []; + const idx = siblings.indexOf(n.id); + const dimmed = lineage !== null && !lineage.has(n.id); + const isEntity = n.kind !== "task"; + + const agentId = (n.stats?.agent_id || n.stats?.source_agent_id) as string | null | undefined; + const hasAgent = !!agentId; + const label = hasAgent ? ( +
+ + {n.label} +
+ ) : ( + n.label + ); + + return { + id: n.id, + position: { x: 230 * rank, y: 72 * idx }, + data: { label }, + targetPosition: Position.Left, + sourcePosition: Position.Right, + style: isEntity + ? { + // PROV Entity: yellow ellipse. + background: PROV.entityBg, + color: PROV.text, + border: `1.5px solid ${PROV.entityBorder}`, + borderRadius: "50%", + padding: "10px 18px", + fontSize: 11, + textAlign: "center" as const, + opacity: dimmed ? 0.12 : 1, + } + : { + ...TASK_NODE_STYLE, + opacity: dimmed ? 0.12 : 1, + }, + }; + }); + + const nextEdges: Edge[] = visibleEdges.map((e, i) => { + const dimmed = lineage !== null && !(lineage.has(e.source) && lineage.has(e.target)); + return { + id: `${e.source}->${e.target}-${i}`, + source: e.source, + target: e.target, + type: "smoothstep", + animated: !dimmed && lineage !== null, + markerEnd: { type: MarkerType.ArrowClosed }, + style: { + opacity: dimmed ? 0.06 : 0.85, + strokeDasharray: e.relation === "derived" ? "5 4" : e.relation === "delegation" ? "1 4" : undefined, + }, + }; + }); + + const nextNodesWithPositions = applyNodePositions(nextNodes, positions); + + // Update nodes state preserving previously dragged positions. + setNodes((prevNodes) => { + const prevPositions = new Map(prevNodes.map((n) => [n.id, n.position])); + return nextNodesWithPositions.map((n) => ({ + ...n, + position: prevPositions.get(n.id) || n.position, + })); + }); + setEdges(nextEdges); + }, [graph, focus, agentHighlight, positions, showDelegation]); + + const handleNodeDragStop = () => { + const posPayload: Record = {}; + nodes.forEach((n) => { + if (n.position) { + posPayload[n.id] = n.position; + } + }); + apiPost(`/workflows/${workflowId}/node_positions`, { + graph_type: "dataflow", + positions: posPayload, + }).catch(console.error); + }; + + if (isLoading) return
Loading dataflow…
; + if (error) return
No dataflow data captured for this workflow.
; + if (!graph || nodes.length === 0) return
No dataflow data captured.
; + + return ( +
+
+ + Inputs and outputs are packed into data chunks — click a task or chunk to inspect metadata. + + {graph.truncated && ( + Graph truncated — showing the first tasks only. + )} +
+ +
+ + { + useHighlightStore.getState().clearHighlight(); + setFocus((prev) => (prev === node.id ? null : node.id)); + const selectedNode = graph.nodes.find((n) => n.id === node.id) ?? null; + if (selectedNode) { + useInspectorStore.getState().set({ + kind: selectedNode.kind === "task" ? "task" : "dataflow", + data: { label: selectedNode.label, stats: selectedNode.stats }, + }); + } + }} + onPaneClick={(e) => { + if ((e.target as HTMLElement).closest(".react-flow__node")) return; + setFocus(null); + useHighlightStore.getState().clearHighlight(); + }} + fitView + fitViewOptions={{ padding: 0.15 }} + > + + + + + +
+ +
+
+ + + data (entity) + + + + task (activity) + + ┄ derived from + ··· delegation +
+
+ +
+
+
+ ); +} diff --git a/ui/src/components/charts/EChart.tsx b/ui/src/components/charts/EChart.tsx new file mode 100644 index 00000000..fee6d60f --- /dev/null +++ b/ui/src/components/charts/EChart.tsx @@ -0,0 +1,77 @@ +/** Thin ECharts wrapper with theme defaults and auto-resize. */ + +import { useEffect, useRef } from "react"; +import * as echarts from "echarts/core"; +import { BarChart, LineChart, PieChart, ScatterChart, CustomChart, HeatmapChart } from "echarts/charts"; +import { + DatasetComponent, + DataZoomComponent, + GridComponent, + LegendComponent, + TitleComponent, + TooltipComponent, +} from "echarts/components"; +import { CanvasRenderer } from "echarts/renderers"; +import type { EChartsCoreOption, ECharts } from "echarts/core"; + +echarts.use([ + BarChart, + LineChart, + PieChart, + ScatterChart, + CustomChart, + HeatmapChart, + DatasetComponent, + DataZoomComponent, + GridComponent, + LegendComponent, + TitleComponent, + TooltipComponent, + CanvasRenderer, +]); + +const THEME_DEFAULTS: EChartsCoreOption = { + backgroundColor: "transparent", + textStyle: { color: "#8b93a7", fontSize: 11 }, + color: ["#4f8cff", "#34d399", "#fbbf24", "#f87171", "#a78bfa", "#22d3ee", "#fb923c"], +}; + +interface Props { + option: EChartsCoreOption; + height?: number | string; + onClick?: (params: { data?: unknown; name?: string }) => void; + className?: string; +} + +export function EChart({ option, height = 280, onClick, className }: Props) { + const ref = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (!ref.current) return; + const chart = echarts.init(ref.current); + chartRef.current = chart; + const observer = new ResizeObserver(() => chart.resize()); + observer.observe(ref.current); + return () => { + observer.disconnect(); + chart.dispose(); + chartRef.current = null; + }; + }, []); + + useEffect(() => { + chartRef.current?.setOption({ ...THEME_DEFAULTS, ...option }, { notMerge: true }); + }, [option]); + + useEffect(() => { + const chart = chartRef.current; + if (!chart || !onClick) return; + chart.on("click", onClick); + return () => { + chart.off("click"); + }; + }, [onClick]); + + return
; +} diff --git a/ui/src/components/charts/GanttChart.tsx b/ui/src/components/charts/GanttChart.tsx new file mode 100644 index 00000000..61f7dfd4 --- /dev/null +++ b/ui/src/components/charts/GanttChart.tsx @@ -0,0 +1,97 @@ +/** Task timeline (gantt) rendered with an ECharts custom series. */ + +import { useMemo } from "react"; +import type { Task } from "../../api/types"; +import { statusColor, fmtDuration, fmtTs, toEpochSec } from "../../lib/format"; +import { EChart } from "./EChart"; + +const MAX_BARS = 5000; + +interface Props { + tasks: Task[]; + onTaskClick?: (taskId: string) => void; +} + +export function GanttChart({ tasks, onTaskClick }: Props) { + const { option, truncated } = useMemo(() => { + const usable = tasks + .map((t) => ({ t, start: toEpochSec(t.started_at), end: toEpochSec(t.ended_at) })) + .filter((r) => r.start !== null) + .sort((a, b) => (a.start ?? 0) - (b.start ?? 0)); + const sliced = usable.slice(0, MAX_BARS); + const now = Date.now() / 1000; + const rows = sliced.map(({ t, start, end }, i) => ({ + value: [i, (start as number) * 1000, (end ?? now) * 1000], + itemStyle: { color: statusColor(t.status) }, + task: t, + })); + const opt = { + grid: { left: 8, right: 16, top: 8, bottom: 40, containLabel: false }, + xAxis: { + type: "time" as const, + axisLine: { lineStyle: { color: "#232a3b" } }, + splitLine: { lineStyle: { color: "#181d2a" } }, + }, + yAxis: { type: "value" as const, min: -1, max: Math.max(rows.length, 5), show: false, inverse: true }, + dataZoom: [ + { type: "inside" as const, filterMode: "weakFilter" as const }, + { type: "slider" as const, height: 18, bottom: 8, borderColor: "#232a3b" }, + ], + tooltip: { + formatter: (p: { data?: { task?: Task } }) => { + const t = p.data?.task; + if (!t) return ""; + const start = toEpochSec(t.started_at); + const end = toEpochSec(t.ended_at); + const dur = start !== null && end !== null ? fmtDuration(end - start) : "running"; + return [ + `${t.activity_id ?? t.task_id}`, + `status: ${t.status ?? "?"}`, + `start: ${fmtTs(t.started_at)}`, + `duration: ${dur}`, + ].join("
"); + }, + }, + series: [ + { + type: "custom" as const, + encode: { x: [1, 2], y: 0 }, + data: rows, + renderItem: ( + _params: unknown, + api: { + value: (i: number) => number; + coord: (v: number[]) => number[]; + style: () => Record; + }, + ) => { + const idx = api.value(0); + const start = api.coord([api.value(1), idx]); + const end = api.coord([api.value(2), idx]); + const h = 6; + return { + type: "rect", + shape: { x: start[0], y: start[1] - h / 2, width: Math.max(end[0] - start[0], 2), height: h }, + style: api.style(), + }; + }, + }, + ], + }; + return { option: opt, truncated: usable.length > MAX_BARS }; + }, [tasks]); + + return ( +
+ {truncated &&
Showing first {MAX_BARS} tasks.
} + { + const task = (p as { data?: { task?: Task } }).data?.task; + if (task && onTaskClick) onTaskClick(task.task_id); + }} + /> +
+ ); +} diff --git a/ui/src/components/charts/StatusStrip.tsx b/ui/src/components/charts/StatusStrip.tsx new file mode 100644 index 00000000..7d0cbdc6 --- /dev/null +++ b/ui/src/components/charts/StatusStrip.tsx @@ -0,0 +1,61 @@ +/** Horizontal stacked status counts + per-activity stats table. */ + +import type { TaskSummary } from "../../api/types"; +import { fmtDuration, statusColor } from "../../lib/format"; + +export function StatusStrip({ summary }: { summary: TaskSummary }) { + const total = summary.count || 1; + const entries = Object.entries(summary.status_counts); + return ( +
+
+
+ {entries.map(([status, count]) => ( +
+ ))} +
+
{summary.count} tasks
+
+
+ {entries.map(([status, count]) => ( + + + {status} {count} + + ))} +
+ {summary.activity_stats.length > 0 && ( + + + + + + + + + + + + + {summary.activity_stats.map((a) => ( + + + + + + + + + ))} + +
ActivityCountAvgMinMaxErrors
{a.activity_id ?? "—"}{a.count}{fmtDuration(a.avg_duration)}{fmtDuration(a.min_duration)}{fmtDuration(a.max_duration)} + {a.status_counts["ERROR"] ?? 0} +
+ )} +
+ ); +} diff --git a/ui/src/components/charts/TelemetryChart.tsx b/ui/src/components/charts/TelemetryChart.tsx new file mode 100644 index 00000000..5f00cc62 --- /dev/null +++ b/ui/src/components/charts/TelemetryChart.tsx @@ -0,0 +1,203 @@ +/** Telemetry timeseries: combined normalized chart + individual line plots. */ + +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { apiPost } from "../../api/client"; +import { toEpochSec, type TimeValue } from "../../lib/format"; +import { EChart } from "./EChart"; + +const METRICS: Record = { + "CPU %": "telemetry_at_end.cpu.percent_all", + "Memory used": "telemetry_at_end.memory.virtual.used", + "Process CPU %": "telemetry_at_end.process.cpu_percent", + "Process memory %": "telemetry_at_end.process.memory_percent", + "Disk read bytes": "telemetry_at_end.disk.io.read_bytes", + "Net bytes sent": "telemetry_at_end.network.netio.bytes_sent", +}; + +const METRIC_KEYS = Object.keys(METRICS); +const ALL_FIELDS = Object.values(METRICS); + +const AXIS_STYLE = { + axisLine: { lineStyle: { color: "#232a3b" } }, + splitLine: { lineStyle: { color: "#181d2a" } }, +}; + +function normalize(vals: number[]): number[] { + const min = Math.min(...vals); + const max = Math.max(...vals); + if (max === min) return vals.map(() => 0.5); + return vals.map((v) => (v - min) / (max - min)); +} + +export function TelemetryChart({ filter }: { filter: Record }) { + const [visibleMetrics, setVisibleMetrics] = useState>(new Set(METRIC_KEYS)); + const [selectedMetric, setSelectedMetric] = useState(METRIC_KEYS[0]); + + const { data: allData, isLoading: allLoading } = useQuery({ + queryKey: ["telemetry-all", filter], + queryFn: () => + apiPost<{ rows: Record[]; count: number }>("/stats/timeseries", { + filter, + fields: ALL_FIELDS, + x: "started_at", + limit: 2000, + }), + }); + + const field = METRICS[selectedMetric]; + const { data: singleData, isLoading: singleLoading } = useQuery({ + queryKey: ["timeseries", filter, field], + queryFn: () => + apiPost<{ rows: Record[]; count: number }>("/stats/timeseries", { + filter, + fields: [field], + x: "started_at", + limit: 2000, + }), + }); + + const anyData = (allData?.rows ?? []).some((r) => + ALL_FIELDS.some((f) => r[f] !== null && r[f] !== undefined), + ); + + const combinedOption = useMemo(() => { + const rows = allData?.rows ?? []; + const series = METRIC_KEYS.filter((m) => visibleMetrics.has(m)).map((m) => { + const f = METRICS[m]; + const pts = rows + .filter((r) => r[f] !== null && r[f] !== undefined) + .map((r) => ({ x: (toEpochSec(r["started_at"] as TimeValue) ?? 0) * 1000, y: r[f] as number })) + .filter((p) => p.x !== 0) + .sort((a, b) => a.x - b.x); + const normed = normalize(pts.map((p) => p.y)); + return { + name: m, + type: "line" as const, + showSymbol: false, + data: pts.map((p, i) => [p.x, normed[i]]), + }; + }); + return { + grid: { left: 50, right: 16, top: 28, bottom: 40 }, + legend: { textStyle: { color: "#8b93a7" }, bottom: 0, type: "scroll" as const }, + tooltip: { + trigger: "axis" as const, + formatter: (params: { seriesName: string; value: [number, number] }[]) => + params.map((p) => `${p.seriesName}: ${p.value[1].toFixed(3)}`).join("
"), + }, + xAxis: { type: "time" as const, ...AXIS_STYLE }, + yAxis: { + type: "value" as const, + min: 0, + max: 1, + ...AXIS_STYLE, + axisLabel: { formatter: (v: number) => v.toFixed(1) }, + }, + series, + }; + }, [allData, visibleMetrics]); + + const singleOption = useMemo(() => { + const rows = (singleData?.rows ?? []).filter((r) => r[field] !== null && r[field] !== undefined); + const pts = rows + .map((r) => [(toEpochSec(r["started_at"] as TimeValue) ?? 0) * 1000, r[field] as number] as [number, number]) + .filter((d) => d[0] !== 0) + .sort((a, b) => a[0] - b[0]); + return { + grid: { left: 60, right: 16, top: 16, bottom: 28 }, + tooltip: { trigger: "axis" as const }, + xAxis: { type: "time" as const, ...AXIS_STYLE }, + yAxis: { type: "value" as const, ...AXIS_STYLE }, + series: [{ name: selectedMetric, type: "line" as const, showSymbol: false, data: pts }], + }; + }, [singleData, field, selectedMetric]); + + const toggleMetric = (m: string) => + setVisibleMetrics((prev) => { + const next = new Set(prev); + next.has(m) ? next.delete(m) : next.add(m); + return next; + }); + + return ( +
+ {/* Combined normalized chart */} +
+
+ All metrics (normalized 0–1) +
+ + +
+
+
+ {METRIC_KEYS.map((m) => ( + + ))} +
+ {allLoading ? ( +
Loading…
+ ) : anyData ? ( + + ) : (allData?.rows?.length ?? 0) === 0 ? ( +
+ No tasks matched this filter. +
+ ) : ( +
+ Tasks were found but no telemetry values are present. + Ensure telemetry_capture.enable: true in your Flowcept settings. +
+ )} +
+ + {/* Individual metric detail */} + {anyData && ( +
+
Individual metric
+
+ {METRIC_KEYS.map((m) => ( + + ))} +
+ {singleLoading ? ( +
Loading…
+ ) : ( + + )} +
+ )} +
+ ); +} diff --git a/ui/src/components/charts/graphStyles.ts b/ui/src/components/charts/graphStyles.ts new file mode 100644 index 00000000..6574dd0d --- /dev/null +++ b/ui/src/components/charts/graphStyles.ts @@ -0,0 +1,11 @@ +export const TASK_NODE_STYLE = { + background: "#9FB1FC", + color: "#11111b", + border: "1.5px solid #0000FF", + borderRadius: 2, + padding: "8px 14px", + fontSize: 12, + fontWeight: 500, + textAlign: "center" as const, +}; + diff --git a/ui/src/components/chat/ChatPanel.tsx b/ui/src/components/chat/ChatPanel.tsx new file mode 100644 index 00000000..9e62cfdb --- /dev/null +++ b/ui/src/components/chat/ChatPanel.tsx @@ -0,0 +1,233 @@ +/** Provenance chat panel: streams /api/v1/chat SSE events into rich message parts. */ + +import { useEffect, useRef, useState } from "react"; +import { useRouterState } from "@tanstack/react-router"; +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { Bot, ChevronDown, Eraser, Maximize2, Minimize2, Send, Wrench } from "lucide-react"; +import type { PanelImperativeHandle } from "react-resizable-panels"; +import { API_BASE } from "../../api/client"; +import { useChatStore, type ChatMsg } from "../../stores/chatStore"; +import { useHighlightStore } from "../../stores/highlightStore"; +import { EChart } from "../charts/EChart"; +import { Markdown } from "../markdown/Markdown"; +import { specToOption } from "../dashboard/specToOption"; + +function contextHint(pathname: string): string { + const wf = pathname.match(/\/workflows\/([^/?]+)/); + if (wf) return `Queries are scoped to this workflow execution (id: ${decodeURIComponent(wf[1])}).`; + const camp = pathname.match(/\/campaigns\/([^/?]+)/); + if (camp) return `Queries are scoped to this campaign (id: ${decodeURIComponent(camp[1])}).`; + return "Queries are scoped to the page you're viewing."; +} + +function routeContext(pathname: string): Record { + const wf = pathname.match(/\/workflows\/([^/?]+)/); + if (wf) return { workflow_id: decodeURIComponent(wf[1]) }; + const camp = pathname.match(/\/campaigns\/([^/?]+)/); + if (camp) return { campaign_id: decodeURIComponent(camp[1]) }; + const dash = pathname.match(/\/dashboards\/([^/?]+)/); + if (dash) return { dashboard_id: decodeURIComponent(dash[1]) }; + return {}; +} + +interface ChatPanelProps { + panelHandle?: PanelImperativeHandle | null; +} + +export function ChatPanel({ panelHandle }: ChatPanelProps) { + const { busy, messages, setBusy, push, appendPart, reset } = useChatStore(); + const [input, setInput] = useState(""); + const [isMaximized, setIsMaximized] = useState(false); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const scrollRef = useRef(null); + + useEffect(() => { + if (!isMaximized) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsMaximized(false); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isMaximized]); + + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }); + }, [messages]); + + const send = async () => { + const text = input.trim(); + if (!text || busy) return; + setInput(""); + const history = [...messages, { role: "user", parts: [{ kind: "text", text }] } as ChatMsg]; + push({ role: "user", parts: [{ kind: "text", text }] }); + setBusy(true); + + const apiMessages = history.map((m) => ({ + role: m.role, + content: m.parts + .filter((p) => p.kind === "text") + .map((p) => (p.kind === "text" ? p.text : "")) + .join("\n"), + })); + + try { + await fetchEventSource(`${API_BASE}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + messages: apiMessages, + context: routeContext(pathname), + stream: true, + allow_dashboard_edit: pathname.startsWith("/dashboards/"), + }), + openWhenHidden: true, + onmessage(msg) { + if (!msg.event) return; + const data = msg.data ? JSON.parse(msg.data) : null; + if (msg.event === "token" && data) appendPart({ kind: "text", text: String(data) }); + if (msg.event === "tool_call" && data) appendPart({ kind: "tool", name: data.name, args: data.args }); + if (msg.event === "card" && data?.chart) appendPart({ kind: "chart", data }); + if (msg.event === "ui:highlight" && Array.isArray(data?.task_ids)) { + useHighlightStore.getState().setHighlight(data.task_ids as string[]); + appendPart({ kind: "ui_highlight", task_ids: data.task_ids as string[] }); + } + if (msg.event === "error" && data) appendPart({ kind: "text", text: `⚠️ ${data}` }); + }, + onerror(err) { + appendPart({ kind: "text", text: `⚠️ Chat request failed: ${err}` }); + throw err; + }, + }); + } catch { + /* error already surfaced in the transcript */ + } finally { + setBusy(false); + } + }; + + const containerClasses = isMaximized + ? "fixed inset-6 z-50 flex flex-col bg-surface/95 backdrop-blur-md border border-border/80 rounded-xl shadow-2xl overflow-hidden" + : "flex h-full flex-col border-t border-border bg-surface"; + + return ( + <> + {isMaximized && ( +
setIsMaximized(false)} + /> + )} +
+
+ + Flowcept Agent + +
+ + + {!isMaximized && ( + + )} +
+
+ +
+ {messages.length === 0 && ( +
+ Ask about your provenance data — e.g. "how many tasks failed?", "plot task durations per + activity". {contextHint(pathname)} +
+ )} + {messages.map((msg, i) => ( +
+
+ {msg.parts.map((part, j) => { + if (part.kind === "text") return {part.text}; + if (part.kind === "ui_highlight") + return ( +
+ + + Highlighted {part.task_ids.length} task{part.task_ids.length !== 1 ? "s" : ""} in the Provenance graph. + {" "} + + +
+ ); + if (part.kind === "tool") + return ( +
+ + ran {part.name} + +
+                          {JSON.stringify(part.args, null, 2)}
+                        
+
+ ); + return ( +
+
{part.data.chart.title || "chart"}
+ +
+ ); + })} +
+
+ ))} + {busy &&
thinking…
} +
+ +
+
+