diff --git a/integrations/wazuh-troubleshooting-tool/.gitignore b/integrations/wazuh-troubleshooting-tool/.gitignore
new file mode 100644
index 00000000..a6618bc2
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/.gitignore
@@ -0,0 +1,9 @@
+config
+.env
+backend/knowledge/lgtm_issues.json
+backend/knowledge/lgtm.db
+backend/sessions/
+backend/wizard_history/
+backend/__pycache__/
+backend/**/__pycache__/
+*.pyc
diff --git a/integrations/wazuh-troubleshooting-tool/README.md b/integrations/wazuh-troubleshooting-tool/README.md
new file mode 100644
index 00000000..812a42ac
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/README.md
@@ -0,0 +1,185 @@
+# Wazuh Troubleshooting & Operations Portal
+
+A dedicated, unified web portal to assess, diagnostic-report, and troubleshoot Wazuh deployments. The system provides real-time health checks, interactive diagnostic guides, an AI troubleshooting agent, and native vector reporting.
+
+## Key Features
+
+1. **Operations Reporting Center**: Natively generated interactive diagnostic report modules (Agent Fleet Health, Indexer Pipeline, Cluster health, Environmental assessments, and Security Events) with offline simulation support and print-formatted PDF/HTML export capabilities.
+2. **Interactive Diagnostic Wizards**: Step-by-step troubleshooting wizards for common Wazuh issues like alerts not showing, indexing pipeline errors, and database cluster yellow/red states.
+3. **Wazuh Agent AI**: A tool-calling troubleshooting agent that can run read-only checks on its own and always pauses for your approval before any change to the system.
+4. **AI Assistant (chat)**: Context-aware AI chat guidance for platform operators, backed by a local knowledge base synced from Wazuh community issues/discussions.
+5. **Secure by default**: No credentials or connection URLs are hardcoded anywhere in source code — everything sensitive lives in one gitignored `config` file.
+
+## Directory Structure
+
+```text
+wazuh-troubleshooting-tool/
+├── config # Your real credentials (gitignored — never commit this)
+├── .gitignore
+├── README.md
+├── start.sh # Dev environment launcher (backend + frontend)
+├── backend/ # Python FastAPI backend server
+│ ├── config.py # Loads config into importable constants
+│ ├── main.py # HTTP routes: health checks, reports, assistant, agent
+│ ├── wazuh_api.py # Wazuh Manager API auth/token helper
+│ ├── copilot_engine.py # Ollama-backed "Copilot" quick-answer engine
+│ ├── assistant_engine.py # Routes chat input into use_cases/ wizards
+│ ├── agent_engine.py # The Wazuh Agent's plan → tool → observe loop
+│ ├── agent_brain.py # Dual-brain abstraction: Ollama vs Claude
+│ ├── agent_tools.py # Tool registry the agent can call
+│ ├── use_cases/ # One module per diagnostic wizard (see below)
+│ ├── flows/ # Shared multi-step state machines reused across wizards
+│ ├── utils/ # Service/cluster/index/log helpers — the actual diagnostic logic
+│ └── knowledge/ # Private, manually-run scripts that build the local
+│ # community-issue knowledge base (backend/knowledge/lgtm.db)
+└── frontend/ # HTML5/CSS/JS frontend views
+ ├── index.html # App shell — all panel layouts
+ ├── app.js # Routing, health-check polling, report triggers
+ ├── assistant.js # Chat UI for the wizard-driven Assistant
+ ├── agent.js # Chat UI for the Wazuh Agent (brain picker, approvals)
+ ├── manual.js # Manual diagnostics panel
+ ├── reports.js # SVG charts & report export engine
+ └── styles.css
+```
+
+## Setup & Configuration
+
+### 1. Prerequisites
+
+- **Python 3.10+** and `pip`
+- **[Ollama](https://ollama.com)** installed and running locally (used for the chat Assistant, the Copilot, the Ollama agent brain, and the local knowledge-base embeddings)
+- A standard Linux host with `curl`, `systemctl`, and `sed` available — the diagnostic/fix tools shell out to these to inspect and manage `wazuh-indexer`, `wazuh-manager`, `wazuh-dashboard`, and `filebeat`
+- Network access to your Wazuh Manager API, Wazuh Indexer, and Kibana/Dashboard
+- `sudo` privileges for the user running the backend, if you want the fix tools (service restarts, cert/config edits) to actually work rather than just report what they'd do
+
+### 2. Install backend dependencies
+
+Run this from the project root (no need to `cd` into `backend/` first):
+
+```bash
+pip install -r backend/requirements.txt
+```
+
+This installs every third-party package the backend needs:
+
+| Package | Used for |
+|---|---|
+| `fastapi` | The backend's HTTP API framework |
+| `uvicorn` | ASGI server that actually runs the FastAPI app |
+| `requests` | All HTTP calls to the Wazuh API, Indexer, Kibana, GitHub, and Ollama |
+| `rapidfuzz` | Fuzzy phrase matching that routes chat input to the right diagnostic wizard |
+| `pyyaml` | Parses the `config` file's YAML in `start.sh` |
+| `anthropic` | Optional Claude brain for the Wazuh Agent (see step 5) |
+| `numpy` | Vector similarity search over the local knowledge base (`backend/knowledge/lgtm.db`) |
+
+Everything else imported (`sqlite3`, `subprocess`, `uuid`, `gzip`, etc.) is Python's standard library — no separate install needed.
+
+### 3. Pull the required Ollama models
+
+```bash
+ollama pull qwen3:1.7b # chat model — Copilot, Assistant, and the Ollama agent brain
+ollama pull nomic-embed-text # embedding model — powers the local knowledge-base search
+```
+
+### 4. Create your `config` file
+
+Create a file named `config` in the project root (next to `start.sh`) — it is already gitignored, so it's safe to fill in real values:
+
+```yaml
+wazuh_api:
+ host: "https://localhost:55000"
+ username: "wazuh"
+ password: "YOUR_WAZUH_PASSWORD"
+ verify_ssl: false
+
+indexer:
+ url: "https://localhost:9200"
+ username: "admin"
+ password: "YOUR_INDEXER_PASSWORD"
+
+kibana:
+ username: "kibanaserver"
+ password: "YOUR_KIBANA_PASSWORD"
+
+ollama:
+ url: "http://localhost:11434"
+ model: "qwen3:1.7b"
+
+anthropic:
+ api_key: "" # optional — see step 5
+ model: "claude-sonnet-5"
+
+server:
+ host: "localhost" # the IP/hostname you'll browse to
+ backend_port: "8000"
+ frontend_port: "3000"
+```
+
+`wazuh_api.password`, `indexer.password`, and `kibana.password` are required — the backend refuses to start without them (see `backend/config.py`). Everything else has a working default.
+
+### 5. (Optional) Enable the Claude brain for the Wazuh Agent
+
+The Agent works out of the box on Ollama alone. To also offer Claude as a brain option (recommended — far more reliable at multi-step tool use than a small local CPU model):
+
+1. Create an API key at [console.anthropic.com](https://console.anthropic.com)
+2. Set `anthropic.api_key` in your `config` file
+3. Restart the backend — `GET /agent/brains` will now report `claude.available: true`
+
+Never commit a real key. If one is ever pasted somewhere it shouldn't be (chat, a public PR, a log), revoke it immediately from the Anthropic Console and generate a new one.
+
+### 6. Start the app
+
+```bash
+./start.sh
+```
+
+This starts the backend (`backend_port`, default `8000`) and frontend (`frontend_port`, default `3000`). Navigate to `http://:` to access the portal.
+
+---
+
+## Adding Your Own Diagnostic Wizard (Use Case)
+
+Each wizard is a self-contained module in `backend/use_cases/` that walks the operator through checking and fixing one specific problem, reusing the shared step-machines in `flows/` and helpers in `utils/` rather than re-implementing diagnostic logic.
+
+1. **Write the flow.** Create `backend/use_cases/my_issue.py` exposing a function with this contract:
+
+ ```python
+ def my_issue_flow(user_choice=None, context=None):
+ context = context or {}
+ # ... inspect context.get("stage") to know where you are in a
+ # multi-step conversation, call your utils/flows helpers, and
+ # return the next step:
+ return {
+ "display": "What you show the user this turn",
+ "context": {"stage": "next_stage_name", **context},
+ }
+ ```
+
+ Look at `use_cases/mapping_issue.py` for a short, self-contained example, or `use_cases/dashboard_error.py` for one that chains through several `flows/` state machines.
+
+2. **Register it** in `backend/use_cases/__init__.py`:
+ - Add an entry to the `USE_CASES` list with a `name`, a list of trigger `phrases` (matched with fuzzy string matching, threshold 65), and a `handler` key.
+ - Add your `handler` to both `elif` chains inside `run_use_cases()` — one for starting a fresh match, one for continuing an in-progress flow (`context["stage"]` already set).
+
+That's it — the Assistant chat and `/assistant` endpoint pick it up automatically; no frontend changes needed.
+
+---
+
+## How the Wazuh Agent AI Works
+
+The Agent (`agent_engine.py`) runs a **plan → call tool → observe → repeat** loop, capped at 8 iterations per turn, with one hard rule: **read-only tools chain automatically, but the moment the agent wants to call a tool marked `mutating` in `agent_tools.py`, the loop stops and waits for your explicit approval** via `/agent/approve` before anything on the system actually changes.
+
+**Two interchangeable brains** (`agent_brain.py`), picked per-session from the UI's brain dropdown:
+
+- **Ollama** (local, offline) — the small model here is too slow to drive a real tool-calling loop, so instead of letting it choose tools, the backend fetches all relevant data itself (service status, cluster health, matching knowledge-base issues) and hands Ollama one single prompt for one single answer. Fast, but can't execute fixes.
+- **Claude** (API) — runs the full tool-calling agentic loop and can actually execute approved fixes. Requires `anthropic.api_key` in `config` (see Setup step 5); if it's blank, the Agent silently falls back to Ollama-only.
+
+**The tool registry** (`agent_tools.py`) is what the agent can see and call — each entry wraps an existing, already-tested function from `utils/*.py`, described by a name, a JSON schema for its arguments, and a `mutating` flag. To give the agent a new capability, add an entry to the `TOOLS` list there; don't re-implement diagnostic logic inline — call into `utils/` the same way every other tool does.
+
+---
+
+## Security Notes
+
+- The `config` file (real credentials) and everything under `backend/knowledge/` and `backend/sessions/`/`backend/wizard_history/` (generated data, session history) are gitignored — keep it that way.
+- If you're contributing this project elsewhere (e.g. as an integration), ship a placeholder-only `config` (as shown in step 4 above with `YOUR_*_PASSWORD` values) — never a filled-in one.
+- If any real credential ever ends up pasted in a chat log, commit, or public PR, treat it as compromised and rotate it immediately, even if you're not sure it was actually exposed.
diff --git a/integrations/wazuh-troubleshooting-tool/backend/agent_brain.py b/integrations/wazuh-troubleshooting-tool/backend/agent_brain.py
new file mode 100644
index 00000000..37437be7
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/agent_brain.py
@@ -0,0 +1,175 @@
+"""
+agent_brain.py
+Dual-brain tool-calling abstraction for the Wazuh Agent.
+
+Two interchangeable "brains" can drive the same agent loop:
+ - "ollama": local, offline, uses Ollama's OpenAI-style /api/chat tools param.
+ - "claude": the Anthropic API (Claude), used the same way Claude Agent SDK
+ tool-use loops work — generally far more reliable at multi-step tool use
+ than a small local model, at the cost of needing an API key + egress.
+
+agent_engine.py talks to this module only through step(), and passes/receives
+a brain-neutral conversation shape so it never needs to know which brain is
+active:
+
+ turns: list of
+ {"role": "user", "text": str}
+ {"role": "assistant", "text": str, "tool_calls": [{"id","name","arguments"}]}
+ {"role": "tool", "tool_call_id": str, "name": str, "content": str}
+
+ step() returns: {"text": str, "tool_calls": [{"id","name","arguments"}]}
+"""
+
+import json
+import requests
+
+from config import OLLAMA_URL, OLLAMA_MODEL, ANTHROPIC_API_KEY, ANTHROPIC_MODEL
+from copilot_engine import check_ollama_health, list_ollama_models
+
+try:
+ import anthropic
+except ImportError:
+ anthropic = None
+
+_anthropic_client = None
+
+
+def available_brains():
+ """What the frontend should offer as brain choices, and whether each is actually usable."""
+ health = check_ollama_health(OLLAMA_URL)
+ return {
+ "ollama": {
+ "available": health.get("ok", False),
+ "model": OLLAMA_MODEL,
+ "models": health.get("models") or list_ollama_models(OLLAMA_URL),
+ },
+ "claude": {
+ "available": bool(ANTHROPIC_API_KEY) and anthropic is not None,
+ "model": ANTHROPIC_MODEL,
+ "reason": "" if ANTHROPIC_API_KEY else "no anthropic.api_key configured",
+ },
+ }
+
+
+def _get_anthropic_client():
+ global _anthropic_client
+ if _anthropic_client is None:
+ _anthropic_client = anthropic.Anthropic(api_key=ANTHROPIC_API_KEY)
+ return _anthropic_client
+
+
+def step(turns, system_prompt, tools_openai, tools_anthropic, brain="ollama", model=None):
+ if brain == "claude":
+ if not ANTHROPIC_API_KEY or anthropic is None:
+ raise RuntimeError("Claude brain is not configured (missing anthropic.api_key or the anthropic package).")
+ return _step_claude(turns, system_prompt, tools_anthropic, model)
+ return _step_ollama(turns, system_prompt, tools_openai, model)
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# OLLAMA
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _step_ollama(turns, system_prompt, tools, model):
+ model = model or OLLAMA_MODEL
+ messages = [{"role": "system", "content": system_prompt}]
+
+ for t in turns:
+ if t["role"] == "user":
+ messages.append({"role": "user", "content": t["text"]})
+ elif t["role"] == "assistant":
+ msg = {"role": "assistant", "content": t.get("text") or ""}
+ if t.get("tool_calls"):
+ msg["tool_calls"] = [
+ {"function": {"name": tc["name"], "arguments": tc["arguments"]}}
+ for tc in t["tool_calls"]
+ ]
+ messages.append(msg)
+ elif t["role"] == "tool":
+ messages.append({"role": "tool", "name": t["name"], "content": t["content"]})
+
+ payload = {
+ "model": model,
+ "messages": messages,
+ "tools": tools,
+ "stream": False,
+ "think": False,
+ # qwen3:1.7b generates at ~7 tokens/sec on this CPU (no GPU) - 2048 would
+ # let it ramble for minutes. Capped to keep answers focused and the wait tolerable.
+ "options": {"temperature": 0.2, "num_predict": 300},
+ }
+
+ resp = requests.post(f"{OLLAMA_URL}/api/chat", json=payload, timeout=300)
+ if resp.status_code != 200:
+ raise RuntimeError(f"Ollama returned HTTP {resp.status_code}: {resp.text[:300]}")
+
+ message = resp.json().get("message", {})
+ raw_calls = message.get("tool_calls") or []
+
+ tool_calls = []
+ for i, tc in enumerate(raw_calls):
+ fn = tc.get("function", {})
+ args = fn.get("arguments", {})
+ if isinstance(args, str):
+ try:
+ args = json.loads(args)
+ except (ValueError, TypeError):
+ args = {}
+ tool_calls.append({"id": f"call_{i}", "name": fn.get("name", ""), "arguments": args or {}})
+
+ return {"text": (message.get("content") or "").strip(), "tool_calls": tool_calls}
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# CLAUDE
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _step_claude(turns, system_prompt, tools, model):
+ model = model or ANTHROPIC_MODEL
+ client = _get_anthropic_client()
+
+ messages = []
+ i = 0
+ while i < len(turns):
+ t = turns[i]
+ if t["role"] == "user":
+ messages.append({"role": "user", "content": t["text"]})
+ i += 1
+ elif t["role"] == "assistant":
+ content = []
+ if t.get("text"):
+ content.append({"type": "text", "text": t["text"]})
+ for tc in t.get("tool_calls", []):
+ content.append({"type": "tool_use", "id": tc["id"], "name": tc["name"], "input": tc["arguments"]})
+ messages.append({"role": "assistant", "content": content})
+ i += 1
+ elif t["role"] == "tool":
+ group = []
+ while i < len(turns) and turns[i]["role"] == "tool":
+ group.append({
+ "type": "tool_result",
+ "tool_use_id": turns[i]["tool_call_id"],
+ "content": turns[i]["content"],
+ })
+ i += 1
+ messages.append({"role": "user", "content": group})
+ else:
+ i += 1
+
+ resp = client.messages.create(
+ model=model,
+ max_tokens=2048,
+ system=system_prompt,
+ messages=messages,
+ tools=tools,
+ )
+
+ text_parts = []
+ tool_calls = []
+ for block in resp.content:
+ if block.type == "text":
+ text_parts.append(block.text)
+ elif block.type == "tool_use":
+ tool_calls.append({"id": block.id, "name": block.name, "arguments": block.input or {}})
+
+ return {"text": "\n".join(text_parts).strip(), "tool_calls": tool_calls}
diff --git a/integrations/wazuh-troubleshooting-tool/backend/agent_engine.py b/integrations/wazuh-troubleshooting-tool/backend/agent_engine.py
new file mode 100644
index 00000000..6318e193
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/agent_engine.py
@@ -0,0 +1,332 @@
+"""
+agent_engine.py
+The agent loop: plan -> call tool -> observe -> repeat, with a hard
+propose-then-confirm gate in front of every mutating tool.
+
+Read-only tools chain automatically — the agent can run several checks in a
+row on its own. The moment it wants to call a tool marked "mutating" in
+agent_tools.py, the loop stops and hands control back to the caller (the
+/agent/approve endpoint) with the exact tool + arguments it wants to run.
+Nothing mutating ever executes without that round-trip.
+
+Sessions are kept in-memory only (module-level dict), same lifetime
+convention as copilot_engine.py's SESSION_ENV_CACHE - fine for a single
+backend process; conversations don't need to survive a restart.
+"""
+
+import json
+import uuid
+
+import agent_brain
+from agent_tools import TOOLS, TOOLS_BY_NAME, to_openai_schema, to_anthropic_schema, list_tools_metadata
+from utils import session_store
+from utils.lgtm_utils import find_relevant_issues, format_lgtm_context
+from copilot_engine import collect_environment_context, format_environment_context
+from config import (
+ WAZUH_API_URL, API_USERNAME, API_PASSWORD,
+ INDEXER_URL, INDEXER_USERNAME, INDEXER_PASSWORD,
+)
+
+MAX_ITERATIONS = 8
+
+TOOLS_OPENAI = to_openai_schema()
+TOOLS_ANTHROPIC = to_anthropic_schema()
+
+# ─────────────────────────────────────────────────────────────────────────────
+# OLLAMA RAG PATH — qwen3:1.7b is CPU-only and too slow to drive the agentic
+# tool-calling loop (even a trimmed schema took 180s+ with no result on this
+# hardware). Instead of asking it to decide when to call tools, we fetch
+# everything relevant directly in Python (fast, no LLM involved) and hand it
+# one single prompt for one single answer - no multi-turn loop, no tool
+# schema overhead. This trades away Ollama's ability to execute fixes itself;
+# Claude keeps the full tool-calling loop since it's fast enough for it.
+#
+# find_relevant_issues() searches the unified local SQLite+embeddings store
+# (lgtm.db) covering wazuh/community issues, wazuh/community discussions, AND
+# public wazuh/wazuh issues together - all pre-synced, so this is a fast local
+# lookup with no live GitHub calls at chat time (unlike Claude's tool-calling
+# path, which can still call search_public_wazuh_issues live for freshness).
+# ─────────────────────────────────────────────────────────────────────────────
+
+RAG_SYSTEM_PROMPT = """You are the Wazuh Troubleshooting Assistant. Answer using the live \
+system data and known-issue context provided below when it's relevant to the question. \
+Be direct and specific. If the provided context doesn't cover the question, answer from \
+general Wazuh expertise instead of saying you don't know. Keep answers focused and short."""
+
+
+def _build_rag_context(user_text):
+ parts = []
+
+ lgtm_context = format_lgtm_context(find_relevant_issues(user_text))
+ if lgtm_context:
+ parts.append(lgtm_context)
+
+ try:
+ env_ctx = collect_environment_context(
+ WAZUH_API_URL, API_USERNAME, API_PASSWORD,
+ INDEXER_URL, INDEXER_USERNAME, INDEXER_PASSWORD,
+ )
+ env_str = format_environment_context(env_ctx)
+ if env_str:
+ parts.append(env_str)
+ except Exception:
+ pass # live env snapshot is best-effort - never block an answer on it
+
+ return "\n\n".join(parts)
+
+
+def _run_ollama_rag(session, user_text, model):
+ system_prompt = RAG_SYSTEM_PROMPT
+ context = _build_rag_context(user_text)
+ if context:
+ system_prompt += "\n\n" + context
+
+ step = agent_brain.step(session["turns"], system_prompt, [], [], brain="ollama", model=model)
+ session["turns"].append({"role": "assistant", "text": step["text"]})
+ return {"status": "final", "message": step["text"], "trace": []}
+
+SYSTEM_PROMPT = """You are the Wazuh Troubleshooting Agent, an autonomous diagnostic assistant for a \
+live Wazuh SIEM deployment (manager, indexer, dashboard, filebeat, endpoint agents).
+
+You have tools to inspect and fix the deployment directly instead of just describing what to do. Use them.
+
+Rules:
+1. Investigate before acting. Call read-only tools to confirm a root cause before proposing a fix - \
+don't jump straight to a fix from the symptom alone if a tool can confirm it first. For symptoms that could \
+be a known issue, check search_lgtm_knowledge_base and search_public_wazuh_issues early - a previously-seen \
+resolution is worth more than reasoning from scratch.
+2. Call tools one at a time when a later step depends on an earlier result; only call several at once \
+when they are genuinely independent checks.
+3. Every tool that changes system state (restarts a service, edits a config file, deletes data, installs \
+a package, etc.) automatically pauses for the user's explicit approval before it actually runs - you don't \
+need to ask permission in words, just call the tool once you've decided it's the right next step. The user \
+sees exactly what you're about to run, with its arguments, before it executes.
+4. Never call a mutating tool speculatively "just to see what happens" - only once your diagnosis actually \
+points to it as the fix.
+5. Some actions are irreversible (deleting indices) or heavy (full certificate regeneration) - prefer the \
+smallest fix that addresses the confirmed root cause, and say why you picked it.
+6. When you're done, give a short plain-language summary: what was wrong, what you checked, what you fixed \
+(or recommend if you stopped short of fixing it), and whether the issue looks resolved.
+7. When you're not calling a tool, you're either asking the user one concise clarifying question or giving \
+your final answer - keep both short and to the point.
+"""
+
+SESSIONS = {}
+
+
+def _new_session():
+ return {
+ "turns": [],
+ "pending_batch": None,
+ "pending_batch_index": 0,
+ "pending_action": None,
+ "brain": "ollama",
+ "model": None,
+ "iterations": 0,
+ }
+
+
+def _get_session(session_id):
+ if session_id not in SESSIONS:
+ SESSIONS[session_id] = _new_session()
+ persisted = session_store.load_session(session_id)
+ if persisted:
+ SESSIONS[session_id]["turns"] = persisted
+ return SESSIONS[session_id]
+
+
+def _to_text(result):
+ if isinstance(result, str):
+ return result[:8000]
+ try:
+ return json.dumps(result, default=str)[:8000]
+ except Exception:
+ return str(result)[:8000]
+
+
+def _append_tool_result(session, tc, result):
+ session["turns"].append({
+ "role": "tool",
+ "tool_call_id": tc["id"],
+ "name": tc["name"],
+ "content": _to_text(result),
+ })
+
+
+def _execute_tool(tool, tc):
+ try:
+ return tool["fn"](**(tc["arguments"] or {}))
+ except TypeError as e:
+ return {"error": f"invalid arguments for {tc['name']}: {e}"}
+ except Exception as e:
+ return {"error": str(e)}
+
+
+def _process_batch(session, trace):
+ """Run session['pending_batch'] from session['pending_batch_index'] onward.
+ Returns (tool_call, tool) if it had to pause on a mutating call, else None
+ once the whole batch has executed."""
+ batch = session["pending_batch"]
+ idx = session["pending_batch_index"]
+
+ while idx < len(batch):
+ tc = batch[idx]
+ tool = TOOLS_BY_NAME.get(tc["name"])
+
+ if tool is None:
+ error = {"error": f"unknown tool '{tc['name']}'"}
+ _append_tool_result(session, tc, error)
+ trace.append({"type": "tool_result", "tool": tc["name"], "error": True, "result": error})
+ idx += 1
+ continue
+
+ if tool["mutating"]:
+ session["pending_batch_index"] = idx
+ return tc, tool
+
+ result = _execute_tool(tool, tc)
+ trace.append({"type": "tool_call", "tool": tc["name"], "arguments": tc["arguments"], "mutating": False})
+ trace.append({"type": "tool_result", "tool": tc["name"], "result": result})
+ _append_tool_result(session, tc, result)
+ idx += 1
+
+ session["pending_batch"] = None
+ session["pending_batch_index"] = 0
+ return None
+
+
+def _pending_action_payload(tc, tool):
+ return {
+ "tool_call_id": tc["id"],
+ "tool": tc["name"],
+ "arguments": tc["arguments"],
+ "description": tool["description"],
+ "risk": tool.get("risk", "medium"),
+ }
+
+
+def _run_loop(session):
+ trace = []
+
+ while session["iterations"] < MAX_ITERATIONS:
+ if session["pending_batch"]:
+ paused = _process_batch(session, trace)
+ if paused:
+ tc, tool = paused
+ session["pending_action"] = _pending_action_payload(tc, tool)
+ return {"status": "awaiting_approval", "pending_action": session["pending_action"], "trace": trace}
+
+ session["iterations"] += 1
+
+ step = agent_brain.step(
+ session["turns"], SYSTEM_PROMPT, TOOLS_OPENAI, TOOLS_ANTHROPIC,
+ brain=session["brain"], model=session.get("model"),
+ )
+
+ if not step["tool_calls"]:
+ session["turns"].append({"role": "assistant", "text": step["text"]})
+ return {"status": "final", "message": step["text"], "trace": trace}
+
+ session["turns"].append({"role": "assistant", "text": step["text"], "tool_calls": step["tool_calls"]})
+ session["pending_batch"] = step["tool_calls"]
+ session["pending_batch_index"] = 0
+
+ return {
+ "status": "final",
+ "message": "Stopped after too many investigation steps in a row — ask me to continue, or narrow the question.",
+ "trace": trace,
+ }
+
+
+def handle_message(session_id, user_text, brain="ollama", model=None):
+ session = _get_session(session_id)
+
+ if session.get("pending_action"):
+ return {
+ "status": "error",
+ "message": "There's an action awaiting your approval — approve or reject it before sending a new message.",
+ "pending_action": session["pending_action"],
+ }
+
+ session["brain"] = brain if brain in ("ollama", "claude") else "ollama"
+ session["model"] = model
+ session["iterations"] = 0
+ session["turns"].append({"role": "user", "text": user_text})
+
+ if session["brain"] == "ollama":
+ result = _run_ollama_rag(session, user_text, model)
+ else:
+ result = _run_loop(session)
+ result["session_id"] = session_id
+ session_store.save_session(session_id, session["turns"], brain=session.get("brain"))
+ return result
+
+
+def handle_approve(session_id, approve, edited_arguments=None):
+ session = SESSIONS.get(session_id)
+ if not session or not session.get("pending_action"):
+ return {"status": "error", "message": "No pending action for this session."}
+
+ trace = []
+ tc = session["pending_batch"][session["pending_batch_index"]]
+ tool = TOOLS_BY_NAME[tc["name"]]
+
+ if approve:
+ args = edited_arguments if edited_arguments is not None else tc["arguments"]
+ exec_tc = {"id": tc["id"], "name": tc["name"], "arguments": args}
+ result = _execute_tool(tool, exec_tc)
+ trace.append({"type": "tool_call", "tool": tc["name"], "arguments": args, "mutating": True})
+ trace.append({"type": "tool_result", "tool": tc["name"], "result": result})
+ _append_tool_result(session, tc, result)
+ else:
+ declined = {"error": "User declined this action. Choose a different approach, ask a clarifying question, or stop here."}
+ _append_tool_result(session, tc, declined)
+ trace.append({"type": "tool_result", "tool": tc["name"], "result": "declined by user"})
+
+ session["pending_batch_index"] += 1
+ session["pending_action"] = None
+ session["iterations"] = 0
+
+ result = _run_loop(session)
+ result["trace"] = trace + result["trace"]
+ result["session_id"] = session_id
+ session_store.save_session(session_id, session["turns"], brain=session.get("brain"))
+ return result
+
+
+def reset_session(session_id):
+ SESSIONS.pop(session_id, None)
+ return {"status": "reset"}
+
+
+def get_tools_metadata():
+ return list_tools_metadata()
+
+
+def list_session_history():
+ return session_store.list_sessions()
+
+
+def resume_session(chat_id):
+ """Load a persisted chat back into memory so sending a new message
+ continues it, and return its turns for the frontend to replay."""
+ turns = session_store.load_session(chat_id)
+ if turns is None:
+ return None
+ session = _new_session()
+ session["turns"] = turns
+ SESSIONS[chat_id] = session
+ return turns
+
+
+def delete_session_history(chat_id):
+ session_store.delete_session(chat_id)
+ SESSIONS.pop(chat_id, None)
+
+
+def rename_session_history(chat_id, new_title):
+ return session_store.rename_session(chat_id, new_title)
+
+
+def get_brains():
+ return agent_brain.available_brains()
diff --git a/integrations/wazuh-troubleshooting-tool/backend/agent_tools.py b/integrations/wazuh-troubleshooting-tool/backend/agent_tools.py
new file mode 100644
index 00000000..141dc4b5
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/agent_tools.py
@@ -0,0 +1,632 @@
+"""
+agent_tools.py
+Tool registry for the Wazuh Agent (agentic troubleshooting loop).
+
+Every tool wraps an already-existing, already-tested function from
+utils/*.py or use_cases/flows — nothing here re-implements diagnostic or
+fix logic. This module only describes each one (name, JSON schema, and
+whether it mutates the system) so an LLM tool-calling loop (agent_engine.py)
+can select and invoke them safely.
+
+Tools are split into two trust tiers:
+ - READ-ONLY tools execute immediately, no approval needed.
+ - MUTATING tools (restarts services, edits config, deletes data, etc.)
+ always pause the agent loop for explicit user approval first.
+"""
+
+from executor import run_command
+from utils.service_utils import get_service_status, restart_service_and_wait
+from utils.cluster_utils import get_cluster_health, get_write_blocks, clear_write_blocks
+from utils.index_utils import list_indices, check_most_recent_index, select_indices_by_age, delete_indices
+from utils.pipeline_utils import (
+ get_agent_status,
+ check_manager_config,
+ get_alerts_json_status,
+ check_cluster_shards,
+ check_alert_indices,
+)
+from utils.agent_utils import list_active_agents, restart_agent as _restart_agent, restart_all_agents as _restart_all_agents
+from utils.manager_config_utils import set_log_alert_level, enable_jsonout_output
+from utils.manager_log_utils import get_manager_log_errors, get_manager_disk_usage
+from utils.log_handler import LogHandler
+from utils.filebeat_utils import (
+ run_filebeat_output_test,
+ get_filebeat_log_errors,
+ fix_unsupported_filebeat_version as _fix_unsupported_filebeat_version,
+)
+from utils.replica_utils import set_replica_count
+from utils.shard_utils import get_unassigned_shards, explain_allocation
+from utils.fix_engine import FixEngine
+from utils.cert_utils import regenerate_and_redeploy_certs as _regenerate_and_redeploy_certs
+from utils.default_route_utils import set_default_route
+from utils.lgtm_utils import find_relevant_issues
+from utils.public_repo_search import search_public_issues, search_public_discussions
+from copilot_engine import fetch_wazuh_cloud_trial_doc
+
+KNOWN_SERVICES = ["wazuh-indexer", "wazuh-manager", "wazuh-dashboard", "filebeat"]
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# Combined helpers — a few mutating fixes are naturally "edit + restart" as a
+# single logical action in the existing wizards, so they stay that way here
+# too (one approval, not two).
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _fix_manager_log_alert_level():
+ new_value = set_log_alert_level(3)
+ status = restart_service_and_wait("wazuh-manager")
+ return {"log_alert_level": new_value, "manager_status": status}
+
+
+def _fix_manager_jsonout_output():
+ enabled = enable_jsonout_output()
+ status = restart_service_and_wait("wazuh-manager")
+ return {"jsonout_output_enabled": enabled, "manager_status": status}
+
+
+def _fix_dashboard_default_route():
+ new_value = set_default_route()
+ status = restart_service_and_wait("wazuh-dashboard")
+ return {"default_route": new_value, "dashboard_status": status}
+
+
+def _check_all_services():
+ return {svc: get_service_status(svc) for svc in KNOWN_SERVICES}
+
+
+def _get_cluster_health():
+ parsed, raw = get_cluster_health()
+ return parsed if parsed is not None else {"error": "could not reach indexer", "raw": raw}
+
+
+def _search_lgtm_knowledge_base(query):
+ issues = find_relevant_issues(query)
+ if not issues:
+ return {"matches": [], "note": "no matching resolved issue found in the internal knowledge base"}
+ return {
+ "matches": [
+ {
+ "number": i["number"],
+ "title": i["title"],
+ "resolution": "\n".join(i.get("comments", []) + i.get("external_community", []))[:1500],
+ }
+ for i in issues
+ ]
+ }
+
+
+def _search_public_wazuh_repo(query):
+ issues = search_public_issues(query)
+ discussions = search_public_discussions(query)
+ return {
+ "issues": [
+ {
+ "number": i["number"],
+ "title": i["title"],
+ "url": i["url"],
+ "discussion": "\n".join(i.get("comments", []))[:1000],
+ }
+ for i in issues
+ ],
+ "discussions": [
+ {"number": d["number"], "title": d["title"], "url": d["url"], "answer": d.get("answer", "")}
+ for d in discussions
+ ],
+ }
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# TOOL REGISTRY
+# ─────────────────────────────────────────────────────────────────────────────
+# risk: "low" | "medium" | "high" — shown as a badge in the approval UI.
+
+TOOLS = [
+ # ── READ-ONLY: services & system ────────────────────────────────────
+ {
+ "name": "check_all_services",
+ "description": "Get systemd status (active/inactive/failed) for wazuh-indexer, wazuh-manager, wazuh-dashboard and filebeat in one call.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: _check_all_services(),
+ },
+ {
+ "name": "check_service_status",
+ "description": "Get the systemd status of a single named service.",
+ "mutating": False,
+ "parameters": {
+ "type": "object",
+ "properties": {"service": {"type": "string", "enum": KNOWN_SERVICES}},
+ "required": ["service"],
+ },
+ "fn": lambda service: get_service_status(service),
+ },
+ {
+ "name": "check_disk_usage",
+ "description": "Run `df -h` on the host. Use when investigating slow/failed services or unassigned shards, since a full disk is a common silent cause.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: FixEngine.check_disk(),
+ },
+
+ # ── READ-ONLY: IPs & certs ───────────────────────────────────────────
+ {
+ "name": "check_ip_configuration",
+ "description": "Compare the indexer IP configured at install time (config.yml) against what's actually configured in the indexer and dashboard configs. Mismatches are a common cause of the dashboard failing to reach the indexer.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: FixEngine.compare_ips(),
+ },
+ {
+ "name": "check_indexer_cert_paths",
+ "description": "Check whether the TLS cert/key/CA files referenced in opensearch.yml actually exist on disk.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: FixEngine.check_indexer_cert_paths(),
+ },
+ {
+ "name": "check_dashboard_cert_paths",
+ "description": "Check whether the TLS cert/key/CA files referenced in opensearch_dashboards.yml actually exist on disk.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: FixEngine.check_dashboard_cert_paths(),
+ },
+ {
+ "name": "check_cert_permissions",
+ "description": "Check file/directory permissions and ownership on the dashboard's certs directory.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: FixEngine.check_cert_permissions(),
+ },
+ {
+ "name": "check_jvm_heap",
+ "description": "Check the wazuh-indexer JVM heap size (jvm.options) against the recommended value (50% of host RAM). Undersized heap is a common cause of indexer crashes/slowness.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: FixEngine.check_jvm_heap(),
+ },
+
+ # ── READ-ONLY: manager / agents / pipeline ───────────────────────────
+ {
+ "name": "check_manager_config",
+ "description": "Check ossec.conf's log_alert_level and jsonout_output settings — misconfiguration here silently drops alerts before they're ever written to alerts.json.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: check_manager_config(),
+ },
+ {
+ "name": "get_manager_log_errors",
+ "description": "Tail the manager's ossec.log filtered to error/warn lines.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {"lines": {"type": "integer", "description": "how many recent lines to scan, default 200"}}},
+ "fn": lambda lines=200: get_manager_log_errors(lines),
+ },
+ {
+ "name": "get_manager_disk_usage",
+ "description": "Disk usage for /var/ossec (the manager's data directory).",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: get_manager_disk_usage(),
+ },
+ {
+ "name": "get_agent_status",
+ "description": "Run agent_control -l, optionally filtered to one agent by name or ID, to check if it's Active.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {"identifier": {"type": "string", "description": "agent name or ID to look up; omit to just check whether ANY agent is active"}}},
+ "fn": lambda identifier=None: get_agent_status(identifier),
+ },
+ {
+ "name": "list_active_agents",
+ "description": "List every currently-active endpoint agent (id, name), excluding the manager's own local agent 000.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: list_active_agents(),
+ },
+ {
+ "name": "get_alerts_json_status",
+ "description": "Check whether the manager is actively writing new alerts to alerts.json (the file Filebeat reads). Staleness here means the pipeline is stalled at the manager, before Filebeat/indexer are even involved.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: get_alerts_json_status(),
+ },
+
+ # ── READ-ONLY: filebeat ──────────────────────────────────────────────
+ {
+ "name": "run_filebeat_output_test",
+ "description": "Run `filebeat test output` to check Filebeat's connectivity to the indexer.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: run_filebeat_output_test(),
+ },
+ {
+ "name": "get_filebeat_log_errors",
+ "description": "Tail Filebeat's log filtered to error/warn lines.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {"lines": {"type": "integer", "description": "how many recent lines to scan, default 200"}}},
+ "fn": lambda lines=200: get_filebeat_log_errors(lines),
+ },
+
+ # ── READ-ONLY: indexer / cluster / indices ───────────────────────────
+ {
+ "name": "get_cluster_health",
+ "description": "GET /_cluster/health from the indexer (status green/yellow/red, node count, shard counts).",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: _get_cluster_health(),
+ },
+ {
+ "name": "check_cluster_shards",
+ "description": "Cluster health plus, if not green, the detail of *why* shards are unassigned (disk watermark, no replica node, etc).",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: check_cluster_shards(),
+ },
+ {
+ "name": "get_unassigned_shards",
+ "description": "List every currently-unassigned shard with its index, shard number and reason.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: get_unassigned_shards(),
+ },
+ {
+ "name": "explain_shard_allocation",
+ "description": "Get OpenSearch's own explanation for why one specific shard is unassigned.",
+ "mutating": False,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "index": {"type": "string"},
+ "shard": {"type": "integer"},
+ "primary": {"type": "boolean", "description": "true for the primary copy, false for a replica"},
+ },
+ "required": ["index", "shard"],
+ },
+ "fn": lambda index, shard, primary=False: explain_allocation(index, shard, primary),
+ },
+ {
+ "name": "get_cluster_write_blocks",
+ "description": "Check for cluster-wide read_only/create_index blocks. These silently prevent ALL writes/new indices cluster-wide even when _cluster/health looks fine.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: get_write_blocks(),
+ },
+ {
+ "name": "list_alert_indices",
+ "description": "List wazuh-alerts-* indices with health/status/doc count/size.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {"pattern": {"type": "string", "description": "index pattern, default 'wazuh-alerts-*'"}}},
+ "fn": lambda pattern="wazuh-alerts-*": list_indices(pattern),
+ },
+ {
+ "name": "check_most_recent_alert_index",
+ "description": "Find the newest wazuh-alerts-* index by date and report how many days old it is — the key check for 'no alerts are showing today'.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {"pattern": {"type": "string", "description": "index pattern, default 'wazuh-alerts-*'"}}},
+ "fn": lambda pattern="wazuh-alerts-*": check_most_recent_index(pattern),
+ },
+ {
+ "name": "check_alert_indices_today",
+ "description": "Confirm wazuh-alerts-* indices exist and one matching TODAY's date is present.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: check_alert_indices(),
+ },
+ {
+ "name": "preview_indices_older_than",
+ "description": "Preview which indices would be affected by an age-based cleanup, WITHOUT deleting anything. Always call this before proposing delete_old_indices, and show the exact list to the user.",
+ "mutating": False,
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "pattern": {"type": "string", "description": "index pattern, default 'wazuh-alerts-*'"},
+ "older_than_days": {"type": "integer"},
+ },
+ "required": ["older_than_days"],
+ },
+ "fn": lambda older_than_days, pattern="wazuh-alerts-*": select_indices_by_age(pattern, older_than_days=older_than_days),
+ },
+
+ # ── READ-ONLY: knowledge base ────────────────────────────────────────
+ {
+ "name": "search_lgtm_knowledge_base",
+ "description": (
+ "Search internally-reviewed, previously-resolved Wazuh issues (marked LGTM by the community "
+ "team) for one matching the current symptom. Call this before proposing a fix for anything "
+ "that looks like it could be a known, previously-seen issue rather than guessing from scratch."
+ ),
+ "mutating": False,
+ "parameters": {
+ "type": "object",
+ "properties": {"query": {"type": "string", "description": "the symptom or error message to search for"}},
+ "required": ["query"],
+ },
+ "fn": lambda query: _search_lgtm_knowledge_base(query),
+ },
+ {
+ "name": "search_public_wazuh_issues",
+ "description": "Live-search the public wazuh/wazuh GitHub repo (issues + discussions) for similar reported problems and how they were resolved.",
+ "mutating": False,
+ "parameters": {
+ "type": "object",
+ "properties": {"query": {"type": "string", "description": "the symptom or error message to search for"}},
+ "required": ["query"],
+ },
+ "fn": lambda query: _search_public_wazuh_repo(query),
+ },
+ {
+ "name": "fetch_wazuh_cloud_trial_docs",
+ "description": "Fetch the official Wazuh Cloud trial sign-up documentation. Use when a user asks about Wazuh Cloud trial credentials, sign-up, or login.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: {"content": fetch_wazuh_cloud_trial_doc()},
+ },
+
+ # ── READ-ONLY: logs ───────────────────────────────────────────────────
+ {
+ "name": "get_indexer_logs",
+ "description": "Recent wazuh-indexer cluster log, filtered to error/warn.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {"hours": {"type": "integer", "description": "how many hours back, default 2"}}},
+ "fn": lambda hours=2: LogHandler.clean_logs(LogHandler.get_indexer_logs(hours)),
+ },
+ {
+ "name": "get_dashboard_logs",
+ "description": "Recent wazuh-dashboard journal log, filtered to error/warn.",
+ "mutating": False,
+ "parameters": {"type": "object", "properties": {"hours": {"type": "integer", "description": "how many hours back, default 2"}}},
+ "fn": lambda hours=2: LogHandler.clean_logs(LogHandler.get_dashboard_logs(hours)),
+ },
+ # ── MUTATING: services ───────────────────────────────────────────────
+ {
+ "name": "restart_service",
+ "description": "Restart a systemd service and wait for it to come back active.",
+ "mutating": True,
+ "risk": "medium",
+ "parameters": {
+ "type": "object",
+ "properties": {"service": {"type": "string", "enum": KNOWN_SERVICES}},
+ "required": ["service"],
+ },
+ "fn": lambda service: restart_service_and_wait(service),
+ },
+
+ # ── MUTATING: IP / certs ─────────────────────────────────────────────
+ {
+ "name": "fix_indexer_ip",
+ "description": "Rewrite network.host in opensearch.yml to match the install-time control IP, then restart wazuh-indexer.",
+ "mutating": True,
+ "risk": "medium",
+ "parameters": {"type": "object", "properties": {"control_ip": {"type": "string"}}, "required": ["control_ip"]},
+ "fn": lambda control_ip: FixEngine.fix_indexer_ip(control_ip),
+ },
+ {
+ "name": "fix_dashboard_ip",
+ "description": "Rewrite the indexer host URL in opensearch_dashboards.yml to match the control IP, then restart wazuh-dashboard.",
+ "mutating": True,
+ "risk": "medium",
+ "parameters": {"type": "object", "properties": {"control_ip": {"type": "string"}}, "required": ["control_ip"]},
+ "fn": lambda control_ip: FixEngine.fix_dashboard_ip(control_ip),
+ },
+ {
+ "name": "fix_indexer_cert_paths",
+ "description": "Auto-detect the cert/key/CA files actually present in /etc/wazuh-indexer/certs and rewrite opensearch.yml to point at them, then restart wazuh-indexer.",
+ "mutating": True,
+ "risk": "medium",
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: FixEngine.fix_indexer_cert_paths(),
+ },
+ {
+ "name": "fix_dashboard_cert_paths",
+ "description": "Auto-detect the cert/key/CA files actually present in /etc/wazuh-dashboard/certs and rewrite opensearch_dashboards.yml to point at them, then restart wazuh-dashboard.",
+ "mutating": True,
+ "risk": "medium",
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: FixEngine.fix_dashboard_cert_paths(),
+ },
+ {
+ "name": "fix_cert_permissions",
+ "description": "chmod/chown the dashboard's certs directory back to the expected 500/400 wazuh-dashboard ownership.",
+ "mutating": True,
+ "risk": "low",
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: FixEngine.fix_cert_permissions(),
+ },
+ {
+ "name": "regenerate_and_redeploy_certs",
+ "description": (
+ "Full TLS cert regeneration: runs wazuh-certs-tool.sh and redeploys fresh certs to the indexer, "
+ "Filebeat and dashboard, then restarts all three services. Use only after simpler cert-path/permission "
+ "fixes have already been ruled out or failed — this is the heaviest, slowest cert fix available."
+ ),
+ "mutating": True,
+ "risk": "high",
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: _regenerate_and_redeploy_certs(),
+ },
+
+ # ── MUTATING: dashboard config ───────────────────────────────────────
+ {
+ "name": "fix_dashboard_default_route",
+ "description": "Set uiSettings.overrides.defaultRoute to /app/wz-home in opensearch_dashboards.yml (fixes 'Application Not Found' after an upgrade), then restart wazuh-dashboard.",
+ "mutating": True,
+ "risk": "low",
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: _fix_dashboard_default_route(),
+ },
+
+ # ── MUTATING: indexer heap ───────────────────────────────────────────
+ {
+ "name": "fix_jvm_heap",
+ "description": "Set -Xms/-Xmx in the indexer's jvm.options to the given size (GB), then restart wazuh-indexer.",
+ "mutating": True,
+ "risk": "medium",
+ "parameters": {"type": "object", "properties": {"heap_gb": {"type": "integer"}}, "required": ["heap_gb"]},
+ "fn": lambda heap_gb: FixEngine.fix_jvm_heap(heap_gb),
+ },
+
+ # ── MUTATING: manager config ─────────────────────────────────────────
+ {
+ "name": "fix_manager_log_alert_level",
+ "description": "Set ossec.conf's log_alert_level to 3 (so alerts stop being silently dropped) and restart wazuh-manager.",
+ "mutating": True,
+ "risk": "low",
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: _fix_manager_log_alert_level(),
+ },
+ {
+ "name": "fix_manager_jsonout_output",
+ "description": "Set ossec.conf's jsonout_output to yes (required for alerts.json to be written) and restart wazuh-manager.",
+ "mutating": True,
+ "risk": "low",
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: _fix_manager_jsonout_output(),
+ },
+
+ # ── MUTATING: agents ──────────────────────────────────────────────────
+ {
+ "name": "restart_agent",
+ "description": "Remotely restart one currently-Active endpoint agent by ID.",
+ "mutating": True,
+ "risk": "low",
+ "parameters": {"type": "object", "properties": {"agent_id": {"type": "string"}}, "required": ["agent_id"]},
+ "fn": lambda agent_id: _restart_agent(agent_id),
+ },
+ {
+ "name": "restart_all_agents",
+ "description": "Remotely restart EVERY currently-active endpoint agent.",
+ "mutating": True,
+ "risk": "medium",
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: _restart_all_agents(),
+ },
+
+ # ── MUTATING: filebeat ────────────────────────────────────────────────
+ {
+ "name": "fix_unsupported_filebeat_version",
+ "description": "Deploy the Wazuh Filebeat module + alerts template, and reinstall Filebeat-OSS 7.10.2 if the version is still wrong. Use when classify_filebeat_failure-style symptoms point at an unsupported version.",
+ "mutating": True,
+ "risk": "medium",
+ "parameters": {"type": "object", "properties": {}},
+ "fn": lambda: _fix_unsupported_filebeat_version(),
+ },
+
+ # ── MUTATING: cluster / indices ──────────────────────────────────────
+ {
+ "name": "clear_cluster_write_blocks",
+ "description": "Clear the given cluster.blocks.* settings that are silently preventing writes/new indices cluster-wide.",
+ "mutating": True,
+ "risk": "medium",
+ "parameters": {
+ "type": "object",
+ "properties": {"block_names": {"type": "array", "items": {"type": "string"}}},
+ "required": ["block_names"],
+ },
+ "fn": lambda block_names: clear_write_blocks(block_names),
+ },
+ {
+ "name": "set_index_replica_count",
+ "description": "Update number_of_replicas on an index/pattern. Applies immediately to existing indices, no reindex needed.",
+ "mutating": True,
+ "risk": "low",
+ "parameters": {
+ "type": "object",
+ "properties": {"index_pattern": {"type": "string"}, "replicas": {"type": "integer"}},
+ "required": ["index_pattern", "replicas"],
+ },
+ "fn": lambda index_pattern, replicas: set_replica_count(index_pattern, replicas),
+ },
+ {
+ "name": "delete_old_indices",
+ "description": (
+ "PERMANENTLY delete the given indices. IRREVERSIBLE — there is no undo. Always call "
+ "preview_indices_older_than first and pass exactly the index names it returned; never guess names."
+ ),
+ "mutating": True,
+ "risk": "high",
+ "parameters": {
+ "type": "object",
+ "properties": {"index_names": {"type": "array", "items": {"type": "string"}}},
+ "required": ["index_names"],
+ },
+ "fn": lambda index_names: delete_indices(index_names),
+ },
+
+ # ── MUTATING: ad-hoc escape hatch ─────────────────────────────────────
+ {
+ "name": "run_shell_command",
+ "description": (
+ "Run an arbitrary shell command for ad-hoc investigation when no other tool fits "
+ "(e.g. 'grep', 'cat', 'journalctl', 'ps aux'). Prefer a specific tool above whenever one "
+ "applies. This always requires approval — even for a command that only reads state — "
+ "because it cannot be verified as safe the way the named tools above can."
+ ),
+ "mutating": True,
+ "risk": "medium",
+ "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]},
+ "fn": lambda command: run_command(command),
+ },
+]
+
+TOOLS_BY_NAME = {t["name"]: t for t in TOOLS}
+
+
+# qwen3:1.7b runs CPU-only on modest hardware — passing it all 48 tool
+# schemas makes the prompt so long it can take minutes just to start
+# answering. This is the reduced set it gets instead: the most common
+# read-only diagnostics, the most common single-step fixes, and all three
+# knowledge-base tools (the whole point of that feature). Claude has no
+# such constraint and keeps the full TOOLS list via to_anthropic_schema().
+OLLAMA_TOOL_NAMES = {
+ # read-only diagnostics
+ "check_all_services", "check_service_status", "check_disk_usage",
+ "check_manager_config", "get_manager_log_errors", "get_manager_disk_usage",
+ "get_agent_status", "list_active_agents", "get_alerts_json_status",
+ "run_filebeat_output_test", "get_filebeat_log_errors",
+ "get_cluster_health", "check_cluster_shards", "get_unassigned_shards",
+ "check_most_recent_alert_index", "check_alert_indices_today",
+ "get_indexer_logs", "get_dashboard_logs",
+ # knowledge base — always available regardless of brain
+ "search_lgtm_knowledge_base", "search_public_wazuh_issues", "fetch_wazuh_cloud_trial_docs",
+ # the handful of most common single-step fixes
+ "restart_service", "fix_manager_log_alert_level", "fix_manager_jsonout_output", "restart_agent",
+}
+
+
+def to_openai_schema():
+ """Ollama's /api/chat 'tools' param — the reduced OLLAMA_TOOL_NAMES subset only."""
+ return [
+ {
+ "type": "function",
+ "function": {
+ "name": t["name"],
+ "description": t["description"],
+ "parameters": t["parameters"],
+ },
+ }
+ for t in TOOLS if t["name"] in OLLAMA_TOOL_NAMES
+ ]
+
+
+def to_anthropic_schema():
+ """Claude's 'tools' param — the full TOOLS list, no reduction needed."""
+ return [
+ {
+ "name": t["name"],
+ "description": t["description"],
+ "input_schema": t["parameters"],
+ }
+ for t in TOOLS
+ ]
+
+
+def list_tools_metadata():
+ """For the frontend's tool-transparency panel — no fn/lambdas."""
+ return [
+ {
+ "name": t["name"],
+ "description": t["description"],
+ "mutating": t["mutating"],
+ "risk": t.get("risk", "read-only"),
+ "ollama_available": t["name"] in OLLAMA_TOOL_NAMES,
+ }
+ for t in TOOLS
+ ]
diff --git a/integrations/wazuh-troubleshooting-tool/backend/analyzer.py b/integrations/wazuh-troubleshooting-tool/backend/analyzer.py
new file mode 100644
index 00000000..d96d1ddb
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/analyzer.py
@@ -0,0 +1,87 @@
+def analyze(data):
+
+ # ----------------------------
+ # API AUTH FAILURE
+ # ----------------------------
+ if "API AUTH FAILED" in data["api"]:
+ return {
+ "problem": "Wazuh API authentication failed",
+ "fix": "Check API credentials in config.py",
+ "command": None
+ }
+
+ # ----------------------------
+ # API CONNECTION FAILURE
+ # ----------------------------
+ if "API CONNECTION FAILED" in data["api"]:
+ return {
+ "problem": "Wazuh API not reachable",
+ "fix": "Manager service might be down",
+ "command": "sudo systemctl restart wazuh-manager"
+ }
+
+ # ----------------------------
+ # INDEXER DOWN
+ # ----------------------------
+ if "inactive" in data["indexer"] or "failed" in data["indexer"]:
+ return {
+ "problem": "Wazuh Indexer is DOWN",
+ "fix": "Restart Wazuh Indexer",
+ "command": "sudo systemctl restart wazuh-indexer"
+ }
+
+ # ----------------------------
+ # MANAGER DOWN
+ # ----------------------------
+ if "inactive" in data["manager"] or "failed" in data["manager"]:
+ return {
+ "problem": "Wazuh Manager is DOWN",
+ "fix": "Restart Wazuh Manager",
+ "command": "sudo systemctl restart wazuh-manager"
+ }
+
+ # ----------------------------
+ # DASHBOARD DOWN
+ # ----------------------------
+ if "inactive" in data["dashboard"] or "failed" in data["dashboard"]:
+ return {
+ "problem": "Wazuh Dashboard is DOWN",
+ "fix": "Restart Wazuh Dashboard",
+ "command": "sudo systemctl restart wazuh-dashboard"
+ }
+
+ # ----------------------------
+ # ERROR3099
+ # ----------------------------
+ if "ERROR3099" in data["logs"]:
+ return {
+ "problem": "Wazuh modules failure (ERROR3099)",
+ "fix": "Restart Wazuh Manager",
+ "command": "sudo systemctl restart wazuh-manager"
+ }
+
+ # ----------------------------
+ # DISK FULL
+ # ----------------------------
+ if "100%" in data["disk"] or "95%" in data["disk"]:
+ return {
+ "problem": "Disk usage too high",
+ "fix": "Clean disk or increase storage",
+ "command": None
+ }
+
+ # ----------------------------
+ # MEMORY LOW
+ # ----------------------------
+ if "available" in data["memory"] and "Mi" in data["memory"]:
+ return {
+ "problem": "Memory might be low",
+ "fix": "Consider increasing RAM",
+ "command": None
+ }
+
+ return {
+ "problem": "System OK",
+ "fix": "No action needed",
+ "command": None
+ }
diff --git a/integrations/wazuh-troubleshooting-tool/backend/assistant_engine.py b/integrations/wazuh-troubleshooting-tool/backend/assistant_engine.py
new file mode 100644
index 00000000..39c45366
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/assistant_engine.py
@@ -0,0 +1,66 @@
+from use_cases import run_use_cases
+from copilot_engine import run_copilot
+from config import (
+ WAZUH_API_URL,
+ API_USERNAME,
+ API_PASSWORD,
+ INDEXER_USERNAME,
+ INDEXER_PASSWORD,
+ INDEXER_URL,
+ OLLAMA_URL,
+ OLLAMA_MODEL,
+)
+
+def process_assistant(user_input, context=None):
+ if context is None:
+ context = {}
+
+ # 1. Try to run guided diagnostic use cases
+ try:
+ result = run_use_cases(user_input, context)
+ except Exception as e:
+ print(f"Error in guided use case: {e}")
+ result = None
+
+ if result:
+ # Predefined use cases take precedence
+ return {
+ "type": "use_case",
+ "display": result.get("display", ""),
+ "ask": result.get("ask", []),
+ "done": result.get("done", False),
+ "context": result.get("context", {})
+ }
+
+ # 2. Fall back to Ollama conversational AI
+ history = context.get("ollama_history", [])
+ history.append({"role": "user", "content": user_input})
+
+ try:
+ reply = run_copilot(
+ messages=history,
+ ollama_url=OLLAMA_URL,
+ ollama_model=OLLAMA_MODEL,
+ include_env=True,
+ wazuh_api_url=WAZUH_API_URL,
+ api_username=API_USERNAME,
+ api_password=API_PASSWORD,
+ indexer_url=INDEXER_URL,
+ indexer_username=INDEXER_USERNAME,
+ indexer_password=INDEXER_PASSWORD,
+ )
+ history.append({"role": "assistant", "content": reply})
+ context["ollama_history"] = history
+
+ return {
+ "type": "use_case",
+ "display": reply,
+ "ask": [],
+ "done": False,
+ "context": context
+ }
+ except Exception as e:
+ return {
+ "type": "info",
+ "message": f"Error from Ollama: {str(e)}"
+ }
diff --git a/integrations/wazuh-troubleshooting-tool/backend/config.py b/integrations/wazuh-troubleshooting-tool/backend/config.py
new file mode 100644
index 00000000..1bd88778
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/config.py
@@ -0,0 +1,62 @@
+import os
+
+# Config path is located in the root directory (parent of the backend directory)
+CONFIG_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "config"))
+
+def parse_simple_yaml(filepath):
+ config = {}
+ current_key = None
+ if not os.path.exists(filepath):
+ return config
+ with open(filepath, 'r') as f:
+ for line in f:
+ line = line.strip()
+ if not line or line.startswith('#'):
+ continue
+ if line.endswith(':'):
+ current_key = line[:-1].strip()
+ config[current_key] = {}
+ elif ':' in line:
+ parts = line.split(':', 1)
+ k = parts[0].strip()
+ v = parts[1].strip().strip('"').strip("'")
+ if v.lower() == 'true':
+ v = True
+ elif v.lower() == 'false':
+ v = False
+ if current_key:
+ config[current_key][k] = v
+ else:
+ config[k] = v
+ return config
+
+_cfg = parse_simple_yaml(CONFIG_PATH)
+
+WAZUH_API_URL = _cfg.get("wazuh_api", {}).get("host")
+API_USERNAME = _cfg.get("wazuh_api", {}).get("username")
+API_PASSWORD = _cfg.get("wazuh_api", {}).get("password")
+
+INDEXER_USERNAME = _cfg.get("indexer", {}).get("username")
+INDEXER_PASSWORD = _cfg.get("indexer", {}).get("password")
+INDEXER_URL = _cfg.get("indexer", {}).get("url")
+
+KIBANA_USERNAME = _cfg.get("kibana", {}).get("username")
+KIBANA_PASSWORD = _cfg.get("kibana", {}).get("password")
+
+# ── Ollama (Wazuh Copilot) ────────────────────────────────────────────────────
+OLLAMA_URL = _cfg.get("ollama", {}).get("url", "http://localhost:11434")
+OLLAMA_MODEL = _cfg.get("ollama", {}).get("model", "qwen3:1.7b")
+
+# ── Anthropic / Claude (Wazuh Agent — optional second brain) ─────────────────
+# Not required: if api_key is left blank, the agent simply runs on Ollama only.
+ANTHROPIC_API_KEY = _cfg.get("anthropic", {}).get("api_key", "")
+ANTHROPIC_MODEL = _cfg.get("anthropic", {}).get("model", "claude-sonnet-5")
+
+# ── Server (used to build the CORS allowlist — see main.py) ─────────────────
+SERVER_HOST = _cfg.get("server", {}).get("host", "localhost")
+FRONTEND_PORT = _cfg.get("server", {}).get("frontend_port", "3000")
+
+if not API_PASSWORD or not INDEXER_PASSWORD or not KIBANA_PASSWORD:
+ import sys
+ print(f"CRITICAL ERROR: Required credentials missing in configuration file at {CONFIG_PATH}", file=sys.stderr)
+ sys.exit(1)
diff --git a/integrations/wazuh-troubleshooting-tool/backend/copilot_engine.py b/integrations/wazuh-troubleshooting-tool/backend/copilot_engine.py
new file mode 100644
index 00000000..1addc4a1
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/copilot_engine.py
@@ -0,0 +1,314 @@
+"""
+copilot_engine.py
+Wazuh Copilot — AI-powered Wazuh expert assistant.
+Sends conversations to a local Ollama instance with a deep Wazuh system prompt.
+Optionally enriches context with live environment data from the Wazuh API / Indexer.
+"""
+
+import json
+import requests
+import urllib3
+
+from utils.lgtm_utils import find_relevant_issues, format_lgtm_context
+from utils.public_repo_search import search_public_issues, search_public_discussions, format_public_context
+
+urllib3.disable_warnings()
+
+# ─────────────────────────────────────────────────────────────────────────────
+# SYSTEM PROMPT — the brain of the copilot
+# ─────────────────────────────────────────────────────────────────────────────
+
+WAZUH_SYSTEM_PROMPT = """You are WazuhCopilot, a world-class Wazuh SIEM expert assistant.
+Provide direct, precise, and technical answers.
+Always generate complete, ready-to-use XML rules/decoders or YAML configs.
+For explanations, keep them brief and structured. Use current Wazuh v4.x syntax."""
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# ENVIRONMENT CONTEXT COLLECTOR
+# ─────────────────────────────────────────────────────────────────────────────
+
+def _safe_run(cmd: str) -> str:
+ """Run a shell command, return output or empty string on failure."""
+ import subprocess
+ try:
+ out = subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT, timeout=5)
+ return out.decode(errors="replace").strip()
+ except Exception:
+ return ""
+
+
+def collect_environment_context(
+ wazuh_api_url: str,
+ api_username: str,
+ api_password: str,
+ indexer_url: str,
+ indexer_username: str,
+ indexer_password: str,
+) -> dict:
+ """
+ Collect live environment data to give the AI context about the user's
+ actual Wazuh deployment. All failures are silently ignored.
+ """
+ ctx = {}
+
+ # ── Service status ────────────────────────────────────────────────────
+ ctx["manager_status"] = _safe_run("systemctl is-active wazuh-manager")
+ ctx["indexer_status"] = _safe_run("systemctl is-active wazuh-indexer")
+ ctx["dashboard_status"] = _safe_run("systemctl is-active wazuh-dashboard")
+ ctx["filebeat_status"] = _safe_run("systemctl is-active filebeat")
+
+ # ── System resources ─────────────────────────────────────────────────
+ ctx["disk"] = _safe_run("df -h / /var/ossec /var/lib/wazuh-indexer 2>/dev/null | head -5")
+ ctx["memory"] = _safe_run("free -h")
+
+ # ── Wazuh API ─────────────────────────────────────────────────────────
+ try:
+ token_res = requests.post(
+ f"{wazuh_api_url}/security/user/authenticate?raw=true",
+ auth=(api_username, api_password),
+ verify=False,
+ timeout=5,
+ )
+ token = token_res.text.strip() if token_res.status_code == 200 else None
+
+ if token:
+ headers = {"Authorization": f"Bearer {token}"}
+
+ # Manager info
+ info = requests.get(f"{wazuh_api_url}/", headers=headers, verify=False, timeout=5)
+ if info.status_code == 200:
+ d = info.json().get("data", {})
+ ctx["manager_version"] = d.get("api_version", "unknown")
+ ctx["wazuh_version"] = d.get("api_version", "unknown")
+
+ # Agent summary
+ agents_res = requests.get(
+ f"{wazuh_api_url}/agents?limit=500",
+ headers=headers,
+ verify=False,
+ timeout=5,
+ )
+ if agents_res.status_code == 200:
+ agents = agents_res.json().get("data", {}).get("affected_items", [])
+ ctx["agents_total"] = len(agents)
+ ctx["agents_active"] = sum(1 for a in agents if a.get("status") == "active")
+ ctx["agents_disconnected"] = sum(1 for a in agents if a.get("status") == "disconnected")
+
+ # Cluster
+ cluster_res = requests.get(
+ f"{wazuh_api_url}/cluster/status",
+ headers=headers,
+ verify=False,
+ timeout=5,
+ )
+ if cluster_res.status_code == 200:
+ cdata = cluster_res.json().get("data", {})
+ ctx["cluster_enabled"] = cdata.get("enabled", "unknown")
+ ctx["cluster_running"] = cdata.get("running", "unknown")
+
+ except Exception:
+ pass
+
+ # ── Indexer cluster health ─────────────────────────────────────────────
+ try:
+ health = requests.get(
+ f"{indexer_url}/_cluster/health",
+ auth=(indexer_username, indexer_password),
+ verify=False,
+ timeout=5,
+ )
+ if health.status_code == 200:
+ h = health.json()
+ ctx["indexer_cluster_status"] = h.get("status", "unknown")
+ ctx["indexer_nodes"] = h.get("number_of_nodes", 0)
+ ctx["indexer_active_shards"] = h.get("active_shards", 0)
+ ctx["indexer_unassigned_shards"] = h.get("unassigned_shards", 0)
+ except Exception:
+ pass
+
+ # ── Recent ossec.log errors ────────────────────────────────────────────
+ ctx["recent_manager_errors"] = _safe_run(
+ "tail -n 30 /var/ossec/logs/ossec.log 2>/dev/null | grep -i -E 'error|warn' | tail -10"
+ )
+
+ return ctx
+
+
+def format_environment_context(ctx: dict) -> str:
+ """Concise environment summary for CPU-friendly inference."""
+ if not ctx:
+ return ""
+
+ parts = []
+ # Services
+ svcs = []
+ for key, name in [("manager_status", "manager"), ("indexer_status", "indexer"), ("dashboard_status", "dashboard"), ("filebeat_status", "filebeat")]:
+ val = ctx.get(key)
+ if val:
+ svcs.append(f"{name}:{val}")
+ if svcs:
+ parts.append("Services: " + ", ".join(svcs))
+
+ # Version
+ if ctx.get("wazuh_version"):
+ parts.append(f"Version: {ctx['wazuh_version']}")
+
+ # Agents
+ if ctx.get("agents_total") is not None:
+ parts.append(f"Agents: {ctx['agents_total']} total ({ctx.get('agents_active', 0)} active)")
+
+ # Indexer status
+ if ctx.get("indexer_cluster_status"):
+ parts.append(f"Indexer: {ctx['indexer_cluster_status'].upper()}")
+
+ if not parts:
+ return ""
+
+ return "=== Environment: " + " | ".join(parts) + " ==="
+
+
+# ─────────────────────────────────────────────────────────────────────────────
+# MAIN COPILOT FUNCTION
+# ─────────────────────────────────────────────────────────────────────────────
+
+def fetch_wazuh_cloud_trial_doc() -> str:
+ """Fetch the official Wazuh Cloud trial documentation and extract the main content."""
+ try:
+ url = "https://documentation.wazuh.com/current/cloud-service/getting-started/sign-up-trial.html"
+ r = requests.get(url, timeout=10)
+ if r.status_code == 200:
+ html = r.text
+ start_idx = html.find('')
+ if start_idx != -1:
+ chunk = html[start_idx:start_idx + 15000]
+ import re
+ clean_text = re.sub(r'<[^>]+>', ' ', chunk)
+ clean_text = re.sub(r'\s+', ' ', clean_text).strip()
+ return clean_text[:6000]
+ except Exception as e:
+ print(f"Error fetching documentation: {e}")
+ return ""
+
+# Session-based cache for environment context strings to enable Ollama KV cache reuse
+SESSION_ENV_CACHE = {}
+
+def run_copilot(
+ messages: list,
+ ollama_url: str,
+ ollama_model: str,
+ include_env: bool,
+ wazuh_api_url: str,
+ api_username: str,
+ api_password: str,
+ indexer_url: str,
+ indexer_username: str,
+ indexer_password: str,
+ stream: bool = False,
+ session_id: str = None,
+ system_prompt: str = None,
+) -> str:
+ # Check if this query is about Wazuh Cloud trial or credentials
+ last_user_msg = ""
+ for msg in reversed(messages):
+ if msg.get("role") == "user":
+ last_user_msg = msg.get("content", "").lower()
+ break
+
+ doc_context = ""
+ if "cloud" in last_user_msg and any(x in last_user_msg for x in ["trial", "trail", "credential", "password", "username", "login"]):
+ doc_context = fetch_wazuh_cloud_trial_doc()
+
+ # Build the system message list
+ base_prompt = system_prompt if system_prompt is not None else WAZUH_SYSTEM_PROMPT
+ if doc_context:
+ system_content = (
+ base_prompt +
+ f"\n\n=== Official Wazuh Cloud Service Documentation ===\n{doc_context}\n=================================================\n\n"
+ "Instructions: Use the above official documentation to answer their question. "
+ "Explain that Wazuh Cloud trial credentials (username/password) are sent in a welcome email once provisioned, "
+ "or can be retrieved in the Environments console. Provide links if relevant."
+ )
+ else:
+ system_content = base_prompt
+
+ lgtm_issues = find_relevant_issues(last_user_msg)
+ lgtm_context = format_lgtm_context(lgtm_issues)
+ if lgtm_context:
+ system_content += "\n\n" + lgtm_context
+
+ public_issues = search_public_issues(last_user_msg)
+ public_discussions = search_public_discussions(last_user_msg)
+ public_context = format_public_context(public_issues, public_discussions)
+ if public_context:
+ system_content += "\n\n" + public_context
+
+ if include_env:
+ try:
+ env_str = None
+ if session_id and session_id in SESSION_ENV_CACHE:
+ env_str = SESSION_ENV_CACHE[session_id]
+
+ if not env_str:
+ env_ctx = collect_environment_context(
+ wazuh_api_url, api_username, api_password,
+ indexer_url, indexer_username, indexer_password,
+ )
+ env_str = format_environment_context(env_ctx)
+ if session_id and env_str:
+ SESSION_ENV_CACHE[session_id] = env_str
+
+ if env_str:
+ system_content += "\n\n" + env_str
+ except Exception:
+ pass
+
+ ollama_messages = [{"role": "system", "content": system_content}] + messages
+
+ payload = {
+ "model": ollama_model,
+ "messages": ollama_messages,
+ "stream": False,
+ "think": False,
+ "options": {
+ "temperature": 0.3, # more factual for technical answers
+ "num_predict": 4096,
+ },
+ }
+
+ resp = requests.post(
+ f"{ollama_url}/api/chat",
+ json=payload,
+ timeout=300,
+ )
+
+ if resp.status_code != 200:
+ raise RuntimeError(
+ f"Ollama returned HTTP {resp.status_code}: {resp.text[:300]}"
+ )
+
+ data = resp.json()
+ return data.get("message", {}).get("content", "").strip()
+
+
+def list_ollama_models(ollama_url: str) -> list:
+ """Return list of available Ollama model names."""
+ try:
+ resp = requests.get(f"{ollama_url}/api/tags", timeout=10)
+ if resp.status_code == 200:
+ return [m["name"] for m in resp.json().get("models", [])]
+ except Exception:
+ pass
+ return []
+
+
+def check_ollama_health(ollama_url: str) -> dict:
+ """Quick health check — is Ollama reachable and is the model available?"""
+ try:
+ resp = requests.get(f"{ollama_url}/api/tags", timeout=5)
+ if resp.status_code == 200:
+ models = [m["name"] for m in resp.json().get("models", [])]
+ return {"ok": True, "models": models}
+ except Exception as e:
+ return {"ok": False, "error": str(e), "models": []}
+ return {"ok": False, "models": []}
diff --git a/integrations/wazuh-troubleshooting-tool/backend/executor.py b/integrations/wazuh-troubleshooting-tool/backend/executor.py
new file mode 100644
index 00000000..9ad73651
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/executor.py
@@ -0,0 +1,14 @@
+import subprocess
+
+
+def run_command(cmd):
+ try:
+ result = subprocess.check_output(
+ cmd,
+ shell=True,
+ stderr=subprocess.STDOUT,
+ text=True
+ )
+ return result.strip()
+ except subprocess.CalledProcessError as e:
+ return e.output.strip()
diff --git a/integrations/wazuh-troubleshooting-tool/backend/flows/dashboard_ip_cert_flow.py b/integrations/wazuh-troubleshooting-tool/backend/flows/dashboard_ip_cert_flow.py
new file mode 100644
index 00000000..805bbaeb
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/flows/dashboard_ip_cert_flow.py
@@ -0,0 +1,149 @@
+"""
+Dashboard IP / certificate paths flow.
+
+Order: dashboard IP -> dashboard certificate paths -> (log analysis if still
+ongoing after the last step).
+
+Same pattern as flows/ip_cert_flow.py, just for the dashboard side. This
+module only DEFINES what's specific to these checks; all flow-control lives
+in utils/step_flow.py.
+"""
+
+from utils.fix_engine import FixEngine
+from utils.service_utils import restart_service_and_wait
+from utils.step_flow import stage_names, start_flow, run_step_flow
+
+PREFIX = "dash_ip_cert"
+ENTRY_STAGE = "dash_ip_check" # legacy-compatible entry point
+NEXT_STAGE_AFTER_ONGOING = "fetch_logs"
+
+
+# ---------------------------------------------------------------------------
+# STEP: dashboard IP
+# ---------------------------------------------------------------------------
+def _check_dash_ip(context):
+ data = FixEngine.check_dashboard_ip()
+ context["d_ip"] = data["d_ip"]
+ context["c_ip"] = data["c_ip"]
+ details = (
+ f" opensearch_dashboards.yml opensearch.hosts: {data['d_ip']}\n"
+ f" Expected (verified indexer IP): {data['c_ip']}"
+ )
+ return data["match"], details
+
+
+def _manual_check_dash_ip(context):
+ c_ip = context.get("c_ip", "")
+ return (
+ "To check this yourself:\n\n"
+ "1. See the dashboard's configured indexer host:\n"
+ " grep opensearch.hosts /etc/wazuh-dashboard/opensearch_dashboards.yml\n\n"
+ f"2. It should point to the indexer IP: {c_ip}"
+ )
+
+
+def _auto_fix_dash_ip(context):
+ c_ip = context.get("c_ip", "")
+ status = FixEngine.fix_dashboard_ip(c_ip)
+ details = (
+ f"Updated opensearch.hosts to https://{c_ip}:9200 in opensearch_dashboards.yml.\n"
+ f"Restarted wazuh-dashboard (status: {status.upper()})."
+ )
+ return status, details
+
+
+def _manual_fix_dash_ip(context):
+ c_ip = context.get("c_ip", "")
+ return (
+ "Edit /etc/wazuh-dashboard/opensearch_dashboards.yml and set:\n\n"
+ f" opensearch.hosts: [\"https://{c_ip}:9200\"]\n\n"
+ "Save the file."
+ )
+
+
+# ---------------------------------------------------------------------------
+# STEP: dashboard certificate paths
+# ---------------------------------------------------------------------------
+def _check_dash_cert(context):
+ data = FixEngine.check_dashboard_cert_paths()
+ context["dash_cert_missing"] = data["missing"]
+ details = (
+ "Configured cert paths (opensearch_dashboards.yml):\n"
+ f"{data['paths_raw']}\n\n"
+ "Available cert files (/etc/wazuh-dashboard/certs/):\n"
+ f"{data['files_raw']}"
+ )
+ return (not data["missing"]), details
+
+
+def _manual_check_dash_cert(context):
+ return (
+ "To check this yourself:\n\n"
+ "1. See the cert paths configured in opensearch_dashboards.yml:\n"
+ " grep -E 'ssl.certificate|ssl.key|certificateAuthorities' "
+ "/etc/wazuh-dashboard/opensearch_dashboards.yml\n\n"
+ "2. See the cert files that actually exist:\n"
+ " ls /etc/wazuh-dashboard/certs/\n\n"
+ "3. Every path from step 1 should exist in step 2's listing."
+ )
+
+
+def _auto_fix_dash_cert(context):
+ result = FixEngine.fix_dashboard_cert_paths()
+ if result.get("success"):
+ status = result["status"]
+ details = (
+ "Updated cert paths:\n"
+ f" cert: {result['cert']}\n key: {result['key']}\n CA: {result['ca']}\n"
+ f"Restarted wazuh-dashboard (status: {status.upper()})."
+ )
+ else:
+ status = "unknown"
+ details = "Could not auto-identify dashboard cert files. Please fix this one manually."
+ return status, details
+
+
+def _manual_fix_dash_cert(context):
+ return FixEngine.dashboard_cert_path_steps()
+
+
+def _restart_dashboard(context):
+ return restart_service_and_wait("wazuh-dashboard")
+
+
+STEPS = [
+ {
+ "key": "ip",
+ "title": "dashboard IP configuration",
+ "check_fn": _check_dash_ip,
+ "manual_check_instructions_fn": _manual_check_dash_ip,
+ "auto_fix_fn": _auto_fix_dash_ip,
+ "manual_fix_instructions_fn": _manual_fix_dash_ip,
+ "restart_fn": _restart_dashboard,
+ },
+ {
+ "key": "cert",
+ "title": "dashboard certificate paths",
+ "check_fn": _check_dash_cert,
+ "manual_check_instructions_fn": _manual_check_dash_cert,
+ "auto_fix_fn": _auto_fix_dash_cert,
+ "manual_fix_instructions_fn": _manual_fix_dash_cert,
+ "restart_fn": _restart_dashboard,
+ },
+]
+
+# All stages this module owns, plus the legacy entry stage.
+STAGES = stage_names(PREFIX, STEPS) | {ENTRY_STAGE}
+
+
+def dashboard_ip_cert_flow(user_choice=None, context=None):
+ if context is None:
+ context = {}
+
+ if context.get("stage") == ENTRY_STAGE:
+ return start_flow(PREFIX, STEPS, context)
+
+ return run_step_flow(
+ PREFIX, STEPS, NEXT_STAGE_AFTER_ONGOING,
+ user_choice=user_choice, context=context,
+ )
diff --git a/integrations/wazuh-troubleshooting-tool/backend/flows/default_route_flow.py b/integrations/wazuh-troubleshooting-tool/backend/flows/default_route_flow.py
new file mode 100644
index 00000000..7648eb53
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/flows/default_route_flow.py
@@ -0,0 +1,103 @@
+"""
+Dashboard default route flow — dedicated to the "Application Not Found" card.
+
+New, self-contained flow: it only reuses the generic, use-case-agnostic
+step engine (utils/step_flow.py). It does not touch flows/ip_cert_flow.py
+or flows/dashboard_ip_cert_flow.py.
+
+Checks a single thing: whether opensearch_dashboards.yml has
+uiSettings.overrides.defaultRoute set to /app/wz-home. This is the most
+common cause of "Application Not Found" - it happens when the dashboard
+config is left over from before an upgrade and is missing (or has a stale)
+default route setting for the new version.
+"""
+
+from utils.default_route_utils import (
+ DASHBOARD_CONFIG_PATH,
+ EXPECTED_DEFAULT_ROUTE,
+ get_default_route,
+ is_default_route_ok,
+ set_default_route,
+)
+from utils.service_utils import restart_service_and_wait
+from utils.step_flow import stage_names, start_flow, run_step_flow
+
+PREFIX = "default_route"
+ENTRY_STAGE = "default_route_check"
+NEXT_STAGE_AFTER_ONGOING = "app_not_found_broader_diagnostics"
+
+
+# ---------------------------------------------------------------------------
+# STEP: dashboard default route
+# ---------------------------------------------------------------------------
+def _check_default_route(context):
+ raw = get_default_route()
+ details = (
+ f" Configured: {raw.strip() or '(not set)'}\n"
+ f" Expected: uiSettings.overrides.defaultRoute: {EXPECTED_DEFAULT_ROUTE}"
+ )
+ return is_default_route_ok(raw), details
+
+
+def _manual_check_default_route(context):
+ return (
+ "To check this yourself:\n\n"
+ "1. Look for the default route setting:\n"
+ f" grep uiSettings.overrides.defaultRoute {DASHBOARD_CONFIG_PATH}\n\n"
+ "2. It should be set to:\n"
+ f" uiSettings.overrides.defaultRoute: {EXPECTED_DEFAULT_ROUTE}"
+ )
+
+
+def _auto_fix_default_route(context):
+ updated = set_default_route()
+ status = restart_service_and_wait("wazuh-dashboard")
+ details = (
+ f"Set uiSettings.overrides.defaultRoute: {EXPECTED_DEFAULT_ROUTE} in "
+ "opensearch_dashboards.yml.\n"
+ f" {updated.strip()}\n\n"
+ f"Restarted wazuh-dashboard (status: {status.upper()}).\n\n"
+ "Please open your browser and verify the dashboard is accessible."
+ )
+ return status, details
+
+
+def _manual_fix_default_route(context):
+ return (
+ f"Edit {DASHBOARD_CONFIG_PATH} and set:\n\n"
+ f" uiSettings.overrides.defaultRoute: {EXPECTED_DEFAULT_ROUTE}\n\n"
+ "Save the file, then restart the dashboard:\n"
+ " systemctl restart wazuh-dashboard"
+ )
+
+
+def _restart_dashboard(context):
+ return restart_service_and_wait("wazuh-dashboard")
+
+
+STEPS = [
+ {
+ "key": "route",
+ "title": "dashboard default route configuration",
+ "check_fn": _check_default_route,
+ "manual_check_instructions_fn": _manual_check_default_route,
+ "auto_fix_fn": _auto_fix_default_route,
+ "manual_fix_instructions_fn": _manual_fix_default_route,
+ "restart_fn": _restart_dashboard,
+ },
+]
+
+STAGES = stage_names(PREFIX, STEPS) | {ENTRY_STAGE}
+
+
+def default_route_flow(user_choice=None, context=None):
+ if context is None:
+ context = {}
+
+ if context.get("stage") == ENTRY_STAGE:
+ return start_flow(PREFIX, STEPS, context)
+
+ return run_step_flow(
+ PREFIX, STEPS, NEXT_STAGE_AFTER_ONGOING,
+ user_choice=user_choice, context=context,
+ )
diff --git a/integrations/wazuh-troubleshooting-tool/backend/flows/filebeat_flow.py b/integrations/wazuh-troubleshooting-tool/backend/flows/filebeat_flow.py
new file mode 100644
index 00000000..acd2994e
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/flows/filebeat_flow.py
@@ -0,0 +1,264 @@
+"""
+Step 3 of the alerts-not-showing pipeline: Filebeat.
+
+Order: ask how to check -> (start Filebeat if it's down) -> run the output
+test -> if it fails, classify WHY and offer an auto/manual fix for the
+known causes -> hand off to Step 4 (Wazuh Indexer) once Filebeat can talk
+to the indexer, or immediately if the failure itself says the indexer is
+unreachable (no point iterating on Filebeat if the indexer is the problem).
+
+This module owns everything specific to Filebeat: what to check, why we're
+checking it, what the manual instructions are, how to fix each known
+failure category. It hands off to the caller (use_cases/no_alerts_are_showing.py)
+by setting context["stage"] = STEP4_ENTRY_STAGE and returning handoff=True,
+the same pattern used by flows/ip_cert_flow.py and flows/dashboard_ip_cert_flow.py.
+
+Also reused standalone by use_cases/filebeat_error.py, a Filebeat-only
+troubleshooting card that stops on handoff instead of continuing into the
+indexer/cluster steps.
+"""
+
+from utils.response_utils import make_response
+from utils.service_utils import get_service_status, start_service_and_wait
+from utils.filebeat_utils import (
+ run_filebeat_output_test, get_filebeat_log_errors, classify_filebeat_failure,
+ fix_unsupported_filebeat_version, manual_unsupported_version_instructions,
+)
+from utils.cert_utils import regenerate_and_redeploy_certs, manual_cert_redeploy_instructions
+from utils.ai_utils import ai_explain
+
+ENTRY_STAGE = "step3_method"
+STEP4_ENTRY_STAGE = "step4_entry"
+
+STAGES = {
+ ENTRY_STAGE,
+ "step3_manual_wait",
+ "fix_filebeat_start",
+ "step3_fix_version_choice",
+ "step3_fix_version_manual_wait",
+ "step3_fix_tls_choice",
+ "step3_fix_tls_manual_wait",
+}
+
+WHY_TEXT = (
+ "The Wazuh Manager is generating alerts correctly. The next step is to "
+ "verify whether Filebeat is reading those alerts and forwarding them to "
+ "the Wazuh Indexer. If Filebeat is not working, alerts will never reach "
+ "the indexer or the dashboard."
+)
+
+MANUAL_CHECK_TEXT = (
+ "First, check whether the Filebeat service is running:\n\n"
+ " systemctl status filebeat\n\n"
+ "If Filebeat is active, test its connection to the Wazuh Indexer:\n\n"
+ " filebeat test output\n\n"
+ "Did the output test succeed?"
+)
+
+UNKNOWN_FAILURE_SYSTEM_PROMPT = (
+ "You are a Wazuh Filebeat troubleshooting expert. You'll be given the "
+ "output of 'filebeat test output' plus recent filebeat log error/warning "
+ "lines. In 3-4 short sentences: state the most likely root cause and the "
+ "single most useful next command or config fix. Be specific to what's "
+ "actually in the output - don't give generic advice."
+)
+
+
+def start_filebeat_flow(context):
+ context["stage"] = ENTRY_STAGE
+ return make_response(
+ display=(
+ "Step 3 - Check Filebeat:\n\n"
+ f"{WHY_TEXT}\n\n"
+ "How would you like to check this?\n\n"
+ " Auto - We perform all checks automatically.\n"
+ " Manual - We provide the commands, and you run them and share the output."
+ ),
+ ask=["Auto", "Manual"],
+ context=context,
+ )
+
+
+def filebeat_flow(user_choice=None, context=None):
+ if context is None:
+ context = {}
+ choice = (user_choice or "").strip().lower()
+ stage = context.get("stage")
+
+ if stage == ENTRY_STAGE:
+ if "manual" in choice:
+ context["stage"] = "step3_manual_wait"
+ return make_response(
+ display=MANUAL_CHECK_TEXT,
+ ask=["Yes, it succeeded", "No, it failed"],
+ context=context,
+ )
+ return _auto_check(context)
+
+ if stage == "step3_manual_wait":
+ if "yes" in choice or "succeeded" in choice:
+ return make_response(
+ display="[OK] Good - Filebeat can reach the indexer.",
+ context=_handoff_to_step4(context),
+ handoff=True,
+ )
+ # Self-reported failure - we still need real data to know why, so
+ # run the same diagnosis the auto path uses.
+ return _auto_check(context)
+
+ if stage == "fix_filebeat_start":
+ if "auto" in choice:
+ status = start_service_and_wait("filebeat")
+ elif choice == "done":
+ status = get_service_status("filebeat")
+ elif "manual" in choice:
+ return make_response(
+ display="Run: systemctl start filebeat",
+ ask=["Done"],
+ context=context,
+ )
+ else:
+ return make_response(display="How would you like to start it?", ask=["Auto", "Manual"], context=context)
+
+ if status != "active":
+ return make_response(
+ display=(
+ "[ROOT CAUSE FOUND] Filebeat failed to start\n\n"
+ "Filebeat did not come up, so alerts.json can never be shipped to the indexer.\n\n"
+ "Manual fix:\nCheck `journalctl -u filebeat` and /var/log/filebeat/filebeat for startup errors."
+ ),
+ done=True,
+ context=context,
+ )
+ return _run_test_and_branch(context, prefix="[OK] Filebeat is running.\n\n")
+
+ if stage == "step3_fix_version_choice":
+ return _apply_fix(
+ context, choice,
+ auto_fn=fix_unsupported_filebeat_version,
+ manual_instructions=manual_unsupported_version_instructions(),
+ manual_wait_stage="step3_fix_version_manual_wait",
+ issue_label="unsupported Filebeat version",
+ )
+
+ if stage == "step3_fix_version_manual_wait":
+ return _run_test_and_branch(context, prefix="")
+
+ if stage == "step3_fix_tls_choice":
+ return _apply_fix(
+ context, choice,
+ auto_fn=lambda: regenerate_and_redeploy_certs(),
+ manual_instructions=manual_cert_redeploy_instructions(),
+ manual_wait_stage="step3_fix_tls_manual_wait",
+ issue_label="TLS/certificate error",
+ )
+
+ if stage == "step3_fix_tls_manual_wait":
+ return _run_test_and_branch(context, prefix="")
+
+ return make_response(display="Unexpected Filebeat step.", done=True, context=context)
+
+
+# ---------------------------------------------------------------------------
+# internal helpers
+# ---------------------------------------------------------------------------
+def _handoff_to_step4(context):
+ context["stage"] = STEP4_ENTRY_STAGE
+ return context
+
+
+def _auto_check(context):
+ status = get_service_status("filebeat")
+ if status != "active":
+ context["stage"] = "fix_filebeat_start"
+ return make_response(
+ display=(
+ "Automatically checking Filebeat...\n\n"
+ "[WARNING] Filebeat is not running.\n\n"
+ "How would you like to start it?"
+ ),
+ ask=["Auto", "Manual"],
+ context=context,
+ )
+ return _run_test_and_branch(context, prefix="Automatically checking Filebeat...\n\n[OK] Filebeat is running.\n\n")
+
+
+def _run_test_and_branch(context, prefix=""):
+ test = run_filebeat_output_test()
+ display = f"{prefix}Running output test...\n{test['raw']}\n"
+
+ if test["ok"]:
+ display += (
+ "\n[OK] Filebeat is running correctly and can successfully communicate with "
+ "the Wazuh Indexer. We will now verify that the Wazuh Indexer is healthy."
+ )
+ return make_response(display=display, context=_handoff_to_step4(context), handoff=True)
+
+ errors = get_filebeat_log_errors()
+ category = classify_filebeat_failure(test["raw"], errors)
+
+ if category == "indexer_unreachable":
+ display += (
+ "\n[WARNING] Filebeat cannot reach the Wazuh Indexer. This isn't a Filebeat "
+ "problem by itself, so we're moving straight to checking the Wazuh Indexer "
+ "instead of continuing to troubleshoot Filebeat."
+ )
+ return make_response(display=display, context=_handoff_to_step4(context), handoff=True)
+
+ if category == "unsupported_version":
+ context["stage"] = "step3_fix_version_choice"
+ display += (
+ "\n[ISSUE] This looks like an unsupported Filebeat version. Wazuh is only "
+ "compatible with Filebeat-OSS 7.10.2 - a newer version will fail with errors "
+ "like 'invalid_index_name_exception' on the _license index.\n\n"
+ "Would you like us to fix this automatically, or fix it yourself?"
+ )
+ return make_response(display=display, ask=["Auto", "Manual"], context=context)
+
+ if category == "tls_cert_error":
+ context["stage"] = "step3_fix_tls_choice"
+ display += (
+ "\n[ISSUE] This looks like a TLS/certificate error. The fix is to regenerate "
+ "the certificates and redeploy them to the Wazuh Indexer, Filebeat, and Wazuh "
+ "Dashboard.\n\n"
+ "Would you like us to fix this automatically, or fix it yourself?"
+ )
+ return make_response(display=display, ask=["Auto", "Manual"], context=context)
+
+ # auth_failure / unknown - no scripted fix, surface what we know and stop.
+ explanation = ai_explain(UNKNOWN_FAILURE_SYSTEM_PROMPT, f"{test['raw']}\n{errors}") if errors.strip() else \
+ "No additional error/warning lines found in the Filebeat log."
+ label = "authentication failure" if category == "auth_failure" else "an unrecognized error"
+ display += (
+ f"\n[WARNING] The output test failed with what looks like {label}.\n\n"
+ f"Recent Filebeat log errors:\n{errors if errors else '(none found)'}\n\n"
+ f"AI analysis:\n{explanation}"
+ )
+ return make_response(display=display, done=True, context=context)
+
+
+def _apply_fix(context, choice, auto_fn, manual_instructions, manual_wait_stage, issue_label):
+ if "manual" in choice:
+ context["stage"] = manual_wait_stage
+ return make_response(
+ display=manual_instructions + "\n\nLet us know once you've made the change.",
+ ask=["Done"],
+ context=context,
+ )
+
+ result = auto_fn()
+ if result.get("ok"):
+ return _run_test_and_branch(
+ context,
+ prefix=f"Fixed the {issue_label} automatically.\n{result.get('log', '')}\n\n",
+ )
+
+ return make_response(
+ display=(
+ f"[ROOT CAUSE FOUND] Could not auto-fix the {issue_label}\n\n"
+ f"{result.get('log', '')}\n\n"
+ "Manual fix:\n" + manual_instructions
+ ),
+ done=True,
+ context=context,
+ )
diff --git a/integrations/wazuh-troubleshooting-tool/backend/flows/ip_cert_flow.py b/integrations/wazuh-troubleshooting-tool/backend/flows/ip_cert_flow.py
new file mode 100644
index 00000000..9c8ba805
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/flows/ip_cert_flow.py
@@ -0,0 +1,226 @@
+"""
+Indexer IP / certificate paths / heap memory flow.
+
+Order: IP address -> certificate paths -> heap memory -> (dashboard checks
+if still ongoing after the last step).
+
+This module only DEFINES what's specific to these checks - what to check,
+how to fix them, what the manual instructions are. All flow-control (asking
+permission, handling yes/manual, fixed/ongoing, moving between steps,
+handoff) lives in the generic, reusable engine at utils/step_flow.py.
+
+Per step (IP -> cert -> heap), the interaction pattern is always:
+
+ 1. ASK: "Should I check the ? (yes / manual)"
+ yes -> run the real check and report the real result.
+ manual -> give exact commands to check it, then ask "good to go /
+ incorrect".
+
+ 2. If there's an issue:
+ ASK: "Do you want me to fix this? (yes / manually)"
+ yes -> apply the fix, restart the indexer, ask "fixed/ongoing".
+ manually -> give fix steps, wait for confirmation, THEN restart the
+ indexer ourselves, then ask "fixed/ongoing".
+
+ 3. "fixed" -> stop.
+ "ongoing" -> move to the next step. After the last step (heap), still
+ ongoing hands off to the dashboard IP/cert flow.
+"""
+
+import re
+from utils.fix_engine import FixEngine
+from utils.service_utils import restart_service_and_wait
+from utils.step_flow import stage_names, start_flow, run_step_flow
+
+PREFIX = "ip_cert"
+ENTRY_STAGE = "ip_check" # legacy-compatible entry point
+NEXT_STAGE_AFTER_ONGOING = "dash_ip_check" # hands off to dashboard_ip_cert_flow
+
+
+# ---------------------------------------------------------------------------
+# STEP: IP address
+# ---------------------------------------------------------------------------
+def _check_ip(context):
+ data = FixEngine.check_indexer_ip()
+ context["c_ip"] = data["c_ip"]
+ context["i_ip"] = data["i_ip"]
+ details = (
+ f" config.yml IP: {data['c_ip']}\n"
+ f" opensearch.yml network.host: {data['i_ip']}"
+ )
+ return data["match"], details
+
+
+def _manual_check_ip(context):
+ return (
+ "To check this yourself:\n\n"
+ "1. Get the IP from the original install config:\n"
+ " tar -axf /home/vagrant/wazuh-install-files.tar wazuh-install-files/config.yml -O\n"
+ " (look under the 'indexer:' section for 'ip:')\n\n"
+ "2. Get the IP the indexer is actually using:\n"
+ " grep network.host /etc/wazuh-indexer/opensearch.yml\n\n"
+ "3. Compare the two — they should match."
+ )
+
+
+def _auto_fix_ip(context):
+ c_ip = context.get("c_ip", "")
+ status = FixEngine.fix_indexer_ip(c_ip)
+ details = (
+ f"Updated network.host to {c_ip} in opensearch.yml.\n"
+ f"Restarted wazuh-indexer (status: {status.upper()})."
+ )
+ return status, details
+
+
+def _manual_fix_ip(context):
+ c_ip = context.get("c_ip", "")
+ return (
+ "Edit /etc/wazuh-indexer/opensearch.yml and set:\n\n"
+ f" network.host: {c_ip}\n\n"
+ "Save the file."
+ )
+
+
+# ---------------------------------------------------------------------------
+# STEP: certificate paths
+# ---------------------------------------------------------------------------
+def _check_cert(context):
+ data = FixEngine.check_indexer_cert_paths()
+ context["cert_missing"] = data["missing"]
+ details = (
+ "Configured cert paths (opensearch.yml):\n"
+ f"{data['paths_raw']}\n\n"
+ "Available cert files (/etc/wazuh-indexer/certs/):\n"
+ f"{data['files_raw']}"
+ )
+ return (not data["missing"]), details
+
+
+def _manual_check_cert(context):
+ return (
+ "To check this yourself:\n\n"
+ "1. See the cert paths configured in opensearch.yml:\n"
+ " grep -E 'pemcert_filepath|pemkey_filepath|pemtrustedcas_filepath' "
+ "/etc/wazuh-indexer/opensearch.yml\n\n"
+ "2. See the cert files that actually exist:\n"
+ " ls /etc/wazuh-indexer/certs/\n\n"
+ "3. Every path from step 1 should exist in step 2's listing."
+ )
+
+
+def _auto_fix_cert(context):
+ result = FixEngine.fix_indexer_cert_paths()
+ if result.get("success"):
+ status = result["status"]
+ details = (
+ "Updated cert paths:\n"
+ f" cert: {result['cert']}\n key: {result['key']}\n CA: {result['ca']}\n"
+ f"Restarted wazuh-indexer (status: {status.upper()})."
+ )
+ else:
+ status = "unknown"
+ details = "Could not auto-identify cert files. Please fix this one manually."
+ return status, details
+
+
+def _manual_fix_cert(context):
+ return (
+ "Update the cert paths in /etc/wazuh-indexer/opensearch.yml so each one "
+ "points to a file that actually exists in /etc/wazuh-indexer/certs/."
+ )
+
+
+# ---------------------------------------------------------------------------
+# STEP: heap memory
+# ---------------------------------------------------------------------------
+def _check_heap(context):
+ data = FixEngine.check_jvm_heap()
+ context["recommended_heap"] = data["recommended_heap"]
+ details = (
+ f"Current: {data['current']}\n"
+ f"Total RAM: {data['total_gb']} GB\n"
+ f"Recommended: -Xms{data['recommended_heap']}g / -Xmx{data['recommended_heap']}g"
+ )
+ m = re.search(r"-Xmx(\d+)g", data["current"] or "")
+ current_gb = int(m.group(1)) if m else None
+ ok = (current_gb == data["recommended_heap"])
+ return ok, details
+
+
+def _manual_check_heap(context):
+ return (
+ "To check this yourself:\n\n"
+ "1. See the current heap settings:\n"
+ " grep -E '^-Xms|^-Xmx' /etc/wazuh-indexer/jvm.options\n\n"
+ "2. See total RAM:\n"
+ " free -h\n\n"
+ "3. Heap (-Xms/-Xmx) should be about 50% of total RAM, not more."
+ )
+
+
+def _auto_fix_heap(context):
+ heap_gb = context.get("recommended_heap", 2)
+ result = FixEngine.fix_jvm_heap(heap_gb)
+ status = result["status"]
+ details = (
+ "Edited jvm.options.\n"
+ f"Current heap settings:\n{result['updated']}\n"
+ f"Restarted wazuh-indexer (status: {status.upper()})."
+ )
+ return status, details
+
+
+def _manual_fix_heap(context):
+ return FixEngine.heap_steps()
+
+
+def _restart_indexer(context):
+ return restart_service_and_wait("wazuh-indexer")
+
+
+STEPS = [
+ {
+ "key": "ip",
+ "title": "indexer IP address",
+ "check_fn": _check_ip,
+ "manual_check_instructions_fn": _manual_check_ip,
+ "auto_fix_fn": _auto_fix_ip,
+ "manual_fix_instructions_fn": _manual_fix_ip,
+ "restart_fn": _restart_indexer,
+ },
+ {
+ "key": "cert",
+ "title": "certificate paths",
+ "check_fn": _check_cert,
+ "manual_check_instructions_fn": _manual_check_cert,
+ "auto_fix_fn": _auto_fix_cert,
+ "manual_fix_instructions_fn": _manual_fix_cert,
+ "restart_fn": _restart_indexer,
+ },
+ {
+ "key": "heap",
+ "title": "heap memory configuration",
+ "check_fn": _check_heap,
+ "manual_check_instructions_fn": _manual_check_heap,
+ "auto_fix_fn": _auto_fix_heap,
+ "manual_fix_instructions_fn": _manual_fix_heap,
+ "restart_fn": _restart_indexer,
+ },
+]
+
+# All stages this module owns, plus the legacy entry stage.
+STAGES = stage_names(PREFIX, STEPS) | {ENTRY_STAGE}
+
+
+def ip_cert_flow(user_choice=None, context=None):
+ if context is None:
+ context = {}
+
+ if context.get("stage") == ENTRY_STAGE:
+ return start_flow(PREFIX, STEPS, context)
+
+ return run_step_flow(
+ PREFIX, STEPS, NEXT_STAGE_AFTER_ONGOING,
+ user_choice=user_choice, context=context,
+ )
diff --git a/integrations/wazuh-troubleshooting-tool/backend/knowledge/migrate_json_to_sqlite.py b/integrations/wazuh-troubleshooting-tool/backend/knowledge/migrate_json_to_sqlite.py
new file mode 100644
index 00000000..728f97b7
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/knowledge/migrate_json_to_sqlite.py
@@ -0,0 +1,40 @@
+"""
+One-time migration: reads the existing lgtm_issues.json (fetched via
+sync_lgtm_issues.py before the SQLite+embeddings switch) and loads it into
+the new SQLite DB with embeddings computed via Ollama. Safe to re-run -
+upsert_issue() overwrites by issue number.
+
+Usage:
+ python3 migrate_json_to_sqlite.py
+"""
+import json
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+from utils import lgtm_db
+
+JSON_PATH = os.path.join(os.path.dirname(__file__), "lgtm_issues.json")
+
+
+def main():
+ if not os.path.exists(JSON_PATH):
+ sys.exit(f"No existing data to migrate at {JSON_PATH}")
+
+ with open(JSON_PATH) as f:
+ issues = json.load(f)
+
+ print(f"Migrating {len(issues)} issues into SQLite with embeddings...", flush=True)
+ ok = 0
+ for i, issue in enumerate(issues, 1):
+ success = lgtm_db.upsert_issue(issue)
+ ok += success
+ print(f" [{i}/{len(issues)}] issue #{issue['number']}: {'OK' if success else 'FAILED (embedding call failed)'}", flush=True)
+ time.sleep(0.05) # let the embedding model breathe between calls
+
+ print(f"Done: {ok}/{len(issues)} migrated. Total in DB now: {lgtm_db.count()}", flush=True)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/integrations/wazuh-troubleshooting-tool/backend/knowledge/sync_lgtm_issues.py b/integrations/wazuh-troubleshooting-tool/backend/knowledge/sync_lgtm_issues.py
new file mode 100644
index 00000000..303648c3
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/knowledge/sync_lgtm_issues.py
@@ -0,0 +1,415 @@
+"""
+Private sync script — run manually by a team member, NOT called by the
+public backend and NOT part of the running app.
+
+It fetches LGTM/resolved/resolved-without-feedback issues from a private/
+internal GitHub repo, embeds each one, and stores them in
+backend/knowledge/lgtm.db (SQLite, gitignored) for the copilot's semantic
+search to read locally.
+
+The GitHub token is never written to disk by this script and never
+hardcoded here — it must be set as an environment variable before running.
+Requires Ollama running locally with the nomic-embed-text model pulled.
+
+Usage:
+ export GITHUB_TOKEN=""
+ export LGTM_REPO="wazuh/community" # optional, this is the default
+ export LGTM_LABEL="LGTM" # optional, this is the default
+ export RESOLVED_LABEL="resolved" # optional, this is the default
+ export RESOLVED_NO_FEEDBACK_LABEL="resolved without feedback" # optional, this is the default
+ export LGTM_AUTHOR="some-github-username" # optional, filters by issue author
+ python3 sync_lgtm_issues.py
+"""
+import os
+import re
+import sys
+import time
+from datetime import date, timedelta
+import requests
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+from utils import lgtm_db
+
+# Matches links to the original community conversation that GitHub issues here
+# always reference. Reddit and Google Groups are publicly readable with no
+# login, so we fetch them directly. Slack/Discord are NOT handled here — see
+# the note in fetch_external_community_content() below for why.
+REDDIT_RE = re.compile(r'https?://(?:www\.)?reddit\.com/r/\S+/comments/\S+')
+GOOGLE_GROUPS_RE = re.compile(r'https?://groups\.google\.com/\S+')
+SLACK_RE = re.compile(r'https?://[\w.-]*\.slack\.com/\S+')
+DISCORD_RE = re.compile(r'https?://(?:www\.)?discord\.com/channels/\S+')
+
+REPO = os.environ.get("LGTM_REPO", "wazuh/community")
+LABEL = os.environ.get("LGTM_LABEL", "LGTM")
+# Issues closed out as resolved outside the LGTM review flow - same idea as
+# LGTM, just a different label for "this thread reached a real answer."
+RESOLVED_LABEL = os.environ.get("RESOLVED_LABEL", "resolved")
+# Resolved without the requester ever confirming the fix worked - still a
+# real answer from the responder's side, just missing that final feedback loop.
+RESOLVED_NO_FEEDBACK_LABEL = os.environ.get("RESOLVED_NO_FEEDBACK_LABEL", "resolved without feedback")
+AUTHOR = os.environ.get("LGTM_AUTHOR")
+
+TOKEN = os.environ.get("GITHUB_TOKEN")
+if not TOKEN:
+ sys.exit("ERROR: set GITHUB_TOKEN in your shell environment before running this script.")
+
+HEADERS = {
+ "Authorization": f"Bearer {TOKEN}",
+ "Accept": "application/vnd.github+json",
+}
+
+
+def _base_query(label, date_range=None):
+ query = f'repo:{REPO} is:issue label:"{label}"'
+ if date_range:
+ query += f" created:{date_range}"
+ if AUTHOR:
+ query += f" author:{AUTHOR}"
+ return query
+
+
+def check_connection():
+ """Fail fast (a few seconds) with a clear reason instead of silently
+ sitting on a slow/broken request for 30s+ with no output at all."""
+ print("Checking GitHub connection and token...", flush=True)
+ try:
+ resp = requests.get("https://api.github.com/rate_limit", headers=HEADERS, timeout=10)
+ except requests.exceptions.RequestException as e:
+ sys.exit(f"ERROR: could not reach GitHub at all — check your network/proxy/VPN. Details: {e}")
+ if resp.status_code == 401:
+ sys.exit("ERROR: GitHub rejected the token (401 Bad credentials) — check GITHUB_TOKEN is correct and not expired.")
+ if resp.status_code != 200:
+ sys.exit(f"ERROR: unexpected response from GitHub ({resp.status_code}): {resp.text[:300]}")
+ remaining = resp.json().get("resources", {}).get("search", {}).get("remaining", "?")
+ print(f"OK - token works, {remaining} search requests remaining this minute.", flush=True)
+
+
+def _run_query(query):
+ """Fetch every page for a single query. Returns (issues, hit_1000_cap)."""
+ issues = []
+ page = 1
+ while True:
+ resp = requests.get(
+ "https://api.github.com/search/issues",
+ headers=HEADERS,
+ params={"q": query, "per_page": 100, "page": page},
+ timeout=30,
+ )
+ if resp.status_code == 422:
+ return issues, True # GitHub's hard 1000-result cap for this query
+ if resp.status_code == 403 and resp.headers.get("X-RateLimit-Remaining") == "0":
+ reset_at = int(resp.headers.get("X-RateLimit-Reset", 0))
+ wait_s = max(reset_at - time.time(), 0) + 5
+ print(f" primary rate limit hit - waiting {int(wait_s)}s for it to reset...", flush=True)
+ time.sleep(wait_s)
+ continue # retry this same page
+ if resp.status_code == 403 and "rate limit" in resp.text.lower():
+ print(" secondary rate limit hit - waiting 60s...", flush=True)
+ time.sleep(60)
+ continue # retry this same page
+ if resp.status_code != 200:
+ sys.exit(f"GitHub API error {resp.status_code}: {resp.text[:300]}")
+ items = resp.json().get("items", [])
+ if not items:
+ break
+ issues.extend(items)
+ print(f" found {len(items)} on this page ({len(issues)} so far for this query)", flush=True)
+ if len(items) < 100:
+ break
+ page += 1
+ time.sleep(1)
+ return issues, False
+
+
+def fetch_all_issues():
+ """
+ GitHub's search API caps any single query at 1000 total results, no matter
+ how you paginate. We run one query per label (not a combined OR) to keep
+ each well under that ceiling, and if a label's own results still exceed
+ 1000, we fall back to splitting that label's query into ~quarterly date
+ ranges going back in time until two consecutive ranges come back empty
+ (a reasonable signal we've covered the repo's full history).
+ """
+ seen = {}
+ for label in (LABEL, RESOLVED_LABEL, RESOLVED_NO_FEEDBACK_LABEL):
+ print(f"Searching GitHub for label '{label}' in {REPO}...", flush=True)
+ issues, hit_cap = _run_query(_base_query(label))
+ for it in issues:
+ seen[it["number"]] = it
+ print(f" '{label}': {len(issues)} found ({len(seen)} unique total so far)", flush=True)
+
+ if not hit_cap:
+ continue
+
+ print(f" hit GitHub's 1000-result cap for '{label}' - splitting by ~quarter...", flush=True)
+ quarter_end = date.today()
+ empty_streak = 0
+ max_quarters = 80 # ~20 years back - a sane backstop, not expected to ever hit this
+ for _ in range(max_quarters):
+ if empty_streak >= 2:
+ break
+ quarter_start = quarter_end - timedelta(days=92)
+ print(f" {label}: {quarter_start.isoformat()}..{quarter_end.isoformat()}...", flush=True)
+ q_issues = _fetch_date_range(label, quarter_start, quarter_end)
+ for it in q_issues:
+ seen[it["number"]] = it
+ empty_streak = empty_streak + 1 if not q_issues else 0
+ quarter_end = quarter_start
+ time.sleep(1)
+
+ return list(seen.values())
+
+
+def _fetch_date_range(label, start, end):
+ """
+ Fetch every issue for `label` within [start, end]. If this window alone
+ still exceeds the 1000-result cap (as happens for very high-volume/
+ bot-applied labels, where even a ~3-month slice isn't narrow enough),
+ recursively bisect it and retry each half - down to a 1-day floor, at
+ which point we log a warning and accept that single day may be
+ incomplete rather than looping forever.
+ """
+ date_range = f"{start.isoformat()}..{end.isoformat()}"
+ issues, hit_cap = _run_query(_base_query(label, date_range))
+ if not hit_cap:
+ return issues
+ if start >= end:
+ print(
+ f" WARNING: '{label}' on {start.isoformat()} alone exceeds "
+ f"1000 results - some issues from this single day may be missing.",
+ flush=True,
+ )
+ return issues
+ mid = start + (end - start) // 2
+ print(f" {date_range} still exceeds 1000 - bisecting at {mid.isoformat()}...", flush=True)
+ left = _fetch_date_range(label, start, mid)
+ right = _fetch_date_range(label, mid + timedelta(days=1), end)
+ return left + right
+
+
+def fetch_comments(issue_number):
+ """
+ The actual resolution is usually in the comment thread, not the body.
+ Transient network errors (dropped connections, timeouts) are retried a
+ few times - across thousands of issues in one run, an occasional blip
+ is expected and shouldn't be treated any differently than a bad
+ response from GitHub.
+ """
+ for attempt in range(3):
+ try:
+ resp = requests.get(
+ f"https://api.github.com/repos/{REPO}/issues/{issue_number}/comments",
+ headers=HEADERS,
+ params={"per_page": 100},
+ timeout=30,
+ )
+ break
+ except requests.exceptions.RequestException as e:
+ if attempt == 2:
+ print(f" WARNING: comments fetch failed for #{issue_number} after 3 attempts ({e}) - skipping comments for this issue", flush=True)
+ return []
+ time.sleep(2 * (attempt + 1))
+ if resp.status_code != 200:
+ return []
+ return [c.get("body") or "" for c in resp.json()]
+
+
+def fetch_reddit_content(url):
+ """Reddit's public JSON API needs no login — just a real User-Agent."""
+ json_url = url.split('?')[0].rstrip('/') + '.json'
+ try:
+ resp = requests.get(
+ json_url,
+ headers={"User-Agent": "wazuh-troubleshooting-tool-sync/1.0"},
+ timeout=15,
+ )
+ if resp.status_code != 200:
+ return ""
+ data = resp.json()
+ post = data[0]["data"]["children"][0]["data"]
+ parts = [f"REDDIT POST: {post.get('title', '')}\n{post.get('selftext', '')}"]
+ for c in data[1]["data"]["children"]:
+ body = c.get("data", {}).get("body")
+ if body:
+ parts.append(f"REDDIT COMMENT: {body}")
+ return "\n\n".join(parts)[:4000]
+ except Exception:
+ return ""
+
+
+def fetch_google_groups_content(url):
+ """Public Google Groups threads are readable as plain HTML, no login."""
+ try:
+ resp = requests.get(url, timeout=15)
+ if resp.status_code != 200:
+ return ""
+ clean_text = re.sub(r'<[^>]+>', ' ', resp.text)
+ clean_text = re.sub(r'\s+', ' ', clean_text).strip()
+ return clean_text[:4000]
+ except Exception:
+ return ""
+
+
+def fetch_external_community_content(body):
+ """
+ Best-effort fetch of the original community thread linked from the issue.
+ Reddit and Google Groups: fetched directly below, no auth needed.
+ Slack/Discord: NOT fetched here. Both require a bot token with membership
+ inside that specific workspace/server to read message history at all — an
+ unauthenticated script cannot reach them, and Slack's free-tier history
+ also expires (~90 days), so even an authenticated fetch could find nothing
+ by the time this script runs. The durable fix is extending whatever bot
+ already mirrors Reddit into these GitHub issues to also mirror Slack/
+ Discord at post time — capturing it before it's ever behind auth for us.
+ We just flag that a Slack/Discord link exists so it's visible in the data.
+ """
+ texts = []
+ for url in REDDIT_RE.findall(body):
+ text = fetch_reddit_content(url)
+ if text:
+ texts.append(text)
+ time.sleep(1) # stay well under Reddit's unauthenticated rate limit
+
+ for url in GOOGLE_GROUPS_RE.findall(body):
+ text = fetch_google_groups_content(url)
+ if text:
+ texts.append(text)
+
+ if SLACK_RE.search(body):
+ texts.append("[Linked Slack conversation — not fetchable without a bot token in that workspace.]")
+ if DISCORD_RE.search(body):
+ texts.append("[Linked Discord conversation — not fetchable without a bot token in that server.]")
+
+ return texts
+
+
+DISCUSSIONS_QUERY = """
+query($owner: String!, $name: String!, $after: String) {
+ repository(owner: $owner, name: $name) {
+ discussions(first: 50, after: $after, orderBy: {field: UPDATED_AT, direction: DESC}) {
+ pageInfo { hasNextPage endCursor }
+ nodes {
+ number
+ title
+ bodyText
+ url
+ isAnswered
+ answer { bodyText }
+ comments(first: 50) { nodes { bodyText } }
+ }
+ }
+ }
+}
+"""
+
+
+def fetch_discussions():
+ """
+ Discussions have no labels the way issues do, and no REST search endpoint
+ at all - only GraphQL, via the repository's own discussions connection
+ (not the Search API, so no 1000-result cap here, just normal cursor
+ pagination). isAnswered is used as the proxy for "resolved" since that's
+ the closest equivalent to LGTM/review-quality that Discussions has.
+ """
+ owner, name = REPO.split("/")
+ answered = []
+ after = None
+ page = 1
+ while True:
+ print(f"Fetching wazuh/community discussions (page {page})...", flush=True)
+ resp = None
+ for attempt in range(3):
+ try:
+ resp = requests.post(
+ "https://api.github.com/graphql",
+ headers={"Authorization": f"Bearer {TOKEN}"},
+ json={"query": DISCUSSIONS_QUERY, "variables": {"owner": owner, "name": name, "after": after}},
+ timeout=30,
+ )
+ break
+ except requests.exceptions.RequestException as e:
+ if attempt == 2:
+ print(f" WARNING: discussions fetch failed after 3 attempts ({e}) - stopping discussions fetch here", flush=True)
+ return answered
+ time.sleep(2 * (attempt + 1))
+ if resp.status_code != 200:
+ print(f" WARNING: GraphQL error {resp.status_code}: {resp.text[:200]} - stopping discussions fetch here", flush=True)
+ break
+ data = resp.json().get("data", {}).get("repository", {}).get("discussions", {})
+ nodes = data.get("nodes", [])
+ new_answered = [d for d in nodes if d.get("isAnswered")]
+ answered.extend(new_answered)
+ print(f" {len(nodes)} discussions on this page, {len(new_answered)} answered ({len(answered)} answered so far)", flush=True)
+ page_info = data.get("pageInfo", {})
+ if not page_info.get("hasNextPage"):
+ break
+ after = page_info.get("endCursor")
+ page += 1
+ time.sleep(1)
+ return answered
+
+
+def main():
+ check_connection()
+ raw_issues = fetch_all_issues()
+ print(f"Fetching comments/linked discussions and embedding {len(raw_issues)} issues...", flush=True)
+ ok = 0
+ for i, issue in enumerate(raw_issues, 1):
+ body = issue.get("body") or ""
+ print(f" [{i}/{len(raw_issues)}] issue #{issue['number']}: {issue['title'][:60]}", flush=True)
+ try:
+ cleaned = {
+ "number": issue["number"],
+ "title": issue["title"],
+ "body": body,
+ "comments": fetch_comments(issue["number"]),
+ "external_community": fetch_external_community_content(body),
+ "url": issue["html_url"],
+ "labels": [l["name"] for l in issue.get("labels", [])],
+ }
+ if lgtm_db.upsert_issue(cleaned, source="community_issue"):
+ ok += 1
+ else:
+ print(f" WARNING: embedding failed for #{issue['number']} - skipped (check Ollama is running)", flush=True)
+ except Exception as e:
+ # One bad issue (network blip, unexpected API shape, etc.) should
+ # never take down a run that's otherwise processing thousands of
+ # issues fine - log it and move on.
+ print(f" WARNING: unexpected error on #{issue['number']} ({e}) - skipped", flush=True)
+ time.sleep(0.5) # one extra request per issue, stay well under rate limits
+
+ print(f"Saved {ok}/{len(raw_issues)} issues. Total community issues in DB: {lgtm_db.count('community_issue')}")
+
+ discussions = fetch_discussions()
+ print(f"Embedding {len(discussions)} answered discussions...", flush=True)
+ d_ok = 0
+ for i, d in enumerate(discussions, 1):
+ comments = [c.get("bodyText", "") for c in d.get("comments", {}).get("nodes", [])]
+ answer = d.get("answer")
+ if answer and answer.get("bodyText"):
+ comments.insert(0, f"ACCEPTED ANSWER: {answer['bodyText']}")
+ print(f" [{i}/{len(discussions)}] discussion #{d['number']}: {d['title'][:60]}", flush=True)
+ try:
+ cleaned = {
+ "number": d["number"],
+ "title": d["title"],
+ "body": d.get("bodyText") or "",
+ "comments": comments,
+ "external_community": [],
+ "url": d["url"],
+ "labels": [],
+ }
+ if lgtm_db.upsert_issue(cleaned, source="community_discussion"):
+ d_ok += 1
+ else:
+ print(f" WARNING: embedding failed for discussion #{d['number']} - skipped", flush=True)
+ except Exception as e:
+ print(f" WARNING: unexpected error on discussion #{d['number']} ({e}) - skipped", flush=True)
+ time.sleep(0.3)
+
+ print(f"Saved {d_ok}/{len(discussions)} discussions. Total community discussions in DB: {lgtm_db.count('community_discussion')}")
+ print(f"Grand total in knowledge base: {lgtm_db.count()}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/integrations/wazuh-troubleshooting-tool/backend/knowledge/sync_public_wazuh_issues.py b/integrations/wazuh-troubleshooting-tool/backend/knowledge/sync_public_wazuh_issues.py
new file mode 100644
index 00000000..415c12f0
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/knowledge/sync_public_wazuh_issues.py
@@ -0,0 +1,113 @@
+"""
+Public sync script — pulls closed issues from the public wazuh/wazuh repo
+into the same unified knowledge base as the private LGTM/review-quality
+issues and community discussions (backend/knowledge/lgtm.db), so Ollama's
+RAG path can search all three sources together without any live network
+calls at chat time.
+
+No token required (public repo), but supplying GITHUB_TOKEN raises GitHub's
+rate limit from 60 requests/hour to 5,000/hour - worth reusing the same
+token from sync_lgtm_issues.py if you have one. Requires Ollama running
+locally with the nomic-embed-text model pulled.
+
+wazuh/wazuh has 20,000+ closed issues total - fetching and embedding all of
+them would take many hours and mostly add old, low-relevance noise (ancient
+versions, since-changed behavior). We sort by most-recently-updated and cap
+at WAZUH_MAX_ISSUES (default 500) so this stays a reasonable background job
+and stays biased toward currently-relevant content. Raise the cap (or set it
+to 0 for no limit) if you want deeper historical coverage.
+
+Usage:
+ export GITHUB_TOKEN="..." # optional but recommended
+ export WAZUH_MAX_ISSUES="500" # optional, this is the default; 0 = no limit
+ python3 sync_public_wazuh_issues.py
+"""
+import os
+import sys
+import time
+import requests
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+from utils import lgtm_db
+
+REPO = os.environ.get("WAZUH_REPO", "wazuh/wazuh")
+STATE = os.environ.get("WAZUH_ISSUE_STATE", "closed") # closed = likely resolved
+MAX_ISSUES = int(os.environ.get("WAZUH_MAX_ISSUES", "500"))
+
+TOKEN = os.environ.get("GITHUB_TOKEN")
+HEADERS = {"Accept": "application/vnd.github+json"}
+if TOKEN:
+ HEADERS["Authorization"] = f"Bearer {TOKEN}"
+
+
+def fetch_issues():
+ issues = []
+ page = 1
+ while True:
+ print(f"Fetching {REPO} issues (state={STATE}, sorted by most-recently-updated, page {page})...", flush=True)
+ resp = requests.get(
+ f"https://api.github.com/repos/{REPO}/issues",
+ headers=HEADERS,
+ params={"state": STATE, "sort": "updated", "direction": "desc", "per_page": 100, "page": page},
+ timeout=30,
+ )
+ if resp.status_code != 200:
+ print(f"WARNING: issues API returned {resp.status_code}: {resp.text[:200]}", file=sys.stderr)
+ break
+ items = resp.json()
+ if not items:
+ break
+ # the /issues endpoint also returns pull requests - skip those
+ page_issues = [i for i in items if "pull_request" not in i]
+ issues.extend(page_issues)
+ print(f" {len(page_issues)} issues on this page ({len(issues)} total so far)", flush=True)
+ if MAX_ISSUES and len(issues) >= MAX_ISSUES:
+ issues = issues[:MAX_ISSUES]
+ print(f" reached WAZUH_MAX_ISSUES cap ({MAX_ISSUES}) - stopping here", flush=True)
+ break
+ if len(items) < 100:
+ break
+ page += 1
+ time.sleep(0.5)
+ return issues
+
+
+def fetch_comments(issue_number):
+ resp = requests.get(
+ f"https://api.github.com/repos/{REPO}/issues/{issue_number}/comments",
+ headers=HEADERS,
+ params={"per_page": 100},
+ timeout=30,
+ )
+ if resp.status_code != 200:
+ return []
+ return [c.get("body") or "" for c in resp.json()]
+
+
+def main():
+ issues = fetch_issues()
+ print(f"Embedding {len(issues)} public {REPO} issues...", flush=True)
+ ok = 0
+ for i, issue in enumerate(issues, 1):
+ print(f" [{i}/{len(issues)}] issue #{issue['number']}: {issue['title'][:60]}", flush=True)
+ cleaned = {
+ "number": issue["number"],
+ "title": issue["title"],
+ "body": issue.get("body") or "",
+ "comments": fetch_comments(issue["number"]),
+ "external_community": [],
+ "url": issue["html_url"],
+ "labels": [l["name"] for l in issue.get("labels", [])],
+ }
+ if lgtm_db.upsert_issue(cleaned, source="public_wazuh_issue"):
+ ok += 1
+ else:
+ print(f" WARNING: embedding failed for #{issue['number']} - skipped (check Ollama is running)", flush=True)
+ time.sleep(0.3)
+
+ print(f"Saved {ok}/{len(issues)} public issues. Total public_wazuh_issue in DB: {lgtm_db.count('public_wazuh_issue')}")
+ print(f"Grand total in knowledge base: {lgtm_db.count()}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/integrations/wazuh-troubleshooting-tool/backend/main.py b/integrations/wazuh-troubleshooting-tool/backend/main.py
new file mode 100644
index 00000000..22c5864b
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/main.py
@@ -0,0 +1,870 @@
+from config import (
+ WAZUH_API_URL,
+ API_USERNAME,
+ API_PASSWORD,
+ INDEXER_USERNAME,
+ INDEXER_PASSWORD,
+ INDEXER_URL,
+ OLLAMA_URL,
+ OLLAMA_MODEL,
+ SERVER_HOST,
+ FRONTEND_PORT,
+)
+from wazuh_api import get_token
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+import subprocess
+import json
+import requests
+from assistant_engine import process_assistant
+import agent_engine
+from utils import wizard_history
+import uuid
+app = FastAPI()
+
+# Only the frontend origin(s) need to call this API — never "*", and never
+# combined with allow_credentials (invalid per the CORS spec, and this app
+# doesn't use cookie-based auth anyway).
+ALLOWED_ORIGINS = [
+ f"http://{SERVER_HOST}:{FRONTEND_PORT}",
+ f"http://localhost:{FRONTEND_PORT}",
+ f"http://127.0.0.1:{FRONTEND_PORT}",
+]
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=ALLOWED_ORIGINS,
+ allow_credentials=False,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+def run(cmd):
+ try:
+ result = subprocess.check_output(
+ cmd,
+ shell=True,
+ stderr=subprocess.STDOUT,
+ timeout=5
+ )
+ return result.decode().strip()
+ except subprocess.CalledProcessError as e:
+ return e.output.decode().strip()
+ except Exception as e:
+ return str(e)
+
+@app.get("/check")
+def check():
+
+ # ---------------------------
+ # Service status
+ # ---------------------------
+ indexer = run("systemctl is-active wazuh-indexer")
+ manager = run("systemctl is-active wazuh-manager")
+ dashboard = run("systemctl is-active wazuh-dashboard")
+
+ # ---------------------------
+ # API (uses config.py) — via get_token()/requests, not a shelled curl
+ # (curl -u embeds the password in the process list and is injectable)
+ # ---------------------------
+ token = get_token()
+ if token:
+ try:
+ api_response = requests.get(
+ f"{WAZUH_API_URL}/",
+ headers={"Authorization": f"Bearer {token}"},
+ verify=False,
+ timeout=5,
+ ).text
+ except requests.RequestException as e:
+ api_response = str(e)
+ else:
+ api_response = "error"
+
+ # Wazuh API success responses always include a JSON "error": 0 field, so
+ # a naive substring check on api_response flags every successful call as
+ # an error. Parse it properly; fall back to the substring heuristic only
+ # for non-JSON failure text (e.g. a connection error message).
+ try:
+ api_status = "ok" if json.loads(api_response).get("error") == 0 else "error"
+ except (ValueError, AttributeError):
+ api_status = "ok" if "error" not in api_response.lower() else "error"
+
+ # ---------------------------
+ # Cluster (LOCALHOST) — same reasoning as above, use requests directly
+ # ---------------------------
+ try:
+ cluster_raw = requests.get(
+ f"{INDEXER_URL}/_cluster/health",
+ auth=(INDEXER_USERNAME, INDEXER_PASSWORD),
+ verify=False,
+ timeout=5,
+ ).text
+ except requests.RequestException:
+ cluster_raw = ""
+ try:
+ cluster_json = json.loads(cluster_raw)
+
+ cluster_status = cluster_json.get("status", "error")
+ cluster_nodes = cluster_json.get("number_of_nodes", 0)
+ active_shards = cluster_json.get("active_shards", 0)
+ unassigned_shards = cluster_json.get("unassigned_shards", 0)
+
+ except:
+ cluster_status = "error"
+ cluster_nodes = 0
+ active_shards = 0
+ unassigned_shards = 0
+
+ # ---------------------------
+ # Memory
+ # ---------------------------
+ mem_raw = run("free -m | awk 'NR==2{print $2,$7}'")
+ mem = mem_raw.split()
+
+ total = int(mem[0]) if len(mem) > 0 else 0
+ available = int(mem[1]) if len(mem) > 1 else 0
+
+ memory = {
+ "total": total,
+ "used": total - available,
+ "free": available
+ }
+ # ---------------------------
+ # Checks
+ # ---------------------------
+ checks = [
+ {"name": "wazuh-indexer", "status": indexer},
+ {"name": "wazuh-manager", "status": manager},
+ {"name": "wazuh-dashboard", "status": dashboard},
+ {"name": "api", "status": api_status},
+ {"name": "cluster", "status": cluster_status},
+ ]
+
+ # ---------------------------
+ # Issues
+ # ---------------------------
+ issues = []
+
+ if indexer != "active":
+ issues.append("wazuh-indexer")
+
+ if manager != "active":
+ issues.append("wazuh-manager")
+
+ if dashboard != "active":
+ issues.append("wazuh-dashboard")
+
+ if cluster_status != "green":
+ issues.append("cluster")
+
+ return {
+ "checks": checks,
+ "issues": issues,
+ "memory": memory,
+ "cluster_details": {
+ "status": cluster_status,
+ "number_of_nodes": cluster_nodes,
+ "active_shards": active_shards,
+ "unassigned_shards": unassigned_shards
+ }
+ }
+import time
+
+@app.get("/fix")
+def fix(service: str = ""):
+
+ cmd_map = {
+ "wazuh-indexer": "sudo systemctl restart wazuh-indexer",
+ "wazuh-manager": "sudo systemctl restart wazuh-manager",
+ "wazuh-dashboard": "sudo systemctl restart wazuh-dashboard"
+ }
+
+ if service not in cmd_map:
+ return {"message": "Invalid service"}
+
+ run(cmd_map[service])
+
+ status = "activating"
+
+ # wait until not activating
+ for _ in range(15):
+ time.sleep(2)
+ status = run(f"systemctl is-active {service}")
+ if status != "activating":
+ break
+
+ # ✅ Your required behavior
+ if status == "active":
+ message = f"SUCCESS: {service} activated"
+ else:
+ message = f"FAILED: {service} still {status}"
+
+ return {
+ "service": service,
+ "status_after_fix": status,
+ "message": message
+ }
+# -----------------------------
+# Filebeat Test (ADD HERE)
+# -----------------------------
+# -----------------------------
+# PATCH:CHECK-SERVICE-STATUS-V1
+# Check Service Status (systemctl status)
+# -----------------------------
+ALLOWED_STATUS_SERVICES = {
+ "wazuh-indexer",
+ "wazuh-manager",
+ "wazuh-dashboard"
+}
+
+@app.get("/status")
+def status(service: str = ""):
+ if service not in ALLOWED_STATUS_SERVICES:
+ return {"service": service, "output": "Invalid service"}
+
+ output = run(f"systemctl status {service} --no-pager")
+ is_active = run(f"systemctl is-active {service}")
+
+ return {
+ "service": service,
+ "is_active": is_active,
+ "output": output
+ }
+
+@app.get("/filebeat-test")
+def filebeat_test():
+
+ result = run("filebeat test output")
+
+ return {
+ "output": result
+ }
+
+@app.post("/assistant")
+def assistant(payload: dict):
+
+ user_input = payload.get("message", "")
+ context = payload.get("context") or {}
+ wizard_id = payload.get("wizard_id")
+
+ # Only present when the Troubleshooting Library sends a wizard_id - the
+ # dashboard's quick-chat never does, so nothing gets saved for it.
+ prior_transcript = context.pop("_transcript", []) if wizard_id else []
+
+ result = process_assistant(user_input, context)
+
+ if wizard_id:
+ transcript = prior_transcript + [{"user": user_input, "assistant": result.get("display", "")}]
+ if result.get("done"):
+ wizard_history.save_run(wizard_id, transcript)
+ else:
+ ctx = result.get("context") or {}
+ ctx["_transcript"] = transcript
+ result["context"] = ctx
+
+ return {"response": result}
+
+
+@app.get("/assistant/history")
+def assistant_history_list():
+ """Up to the 6 most recent completed Troubleshooting Library runs — download-only, no resume."""
+ return {"runs": wizard_history.list_runs()}
+
+
+@app.get("/assistant/history/{run_id}/download")
+def assistant_history_download(run_id: str):
+ from fastapi.responses import PlainTextResponse
+
+ transcript = wizard_history.load_run(run_id)
+ if transcript is None:
+ return PlainTextResponse("Not found.", status_code=404)
+
+ runs = {r["run_id"]: r for r in wizard_history.list_runs()}
+ title = runs.get(run_id, {}).get("title", run_id)
+ text = wizard_history.format_transcript_text(transcript, title)
+
+ return PlainTextResponse(
+ text,
+ headers={"Content-Disposition": f'attachment; filename="wazuh-troubleshooting-{run_id}.txt"'},
+ )
+
+# ─────────────────────────────────────────────────────────────────────────────
+# The chat UI itself now lives entirely under /agent/* (agent_engine.py) —
+# it's a strict superset of what the old Copilot chat used to do (falls back
+# to a plain answer when it has nothing to call).
+# ─────────────────────────────────────────────────────────────────────────────
+
+# ─────────────────────────────────────────────────────────────────────────────
+# WAZUH AGENT ROUTES — autonomous, tool-calling troubleshooting
+# ─────────────────────────────────────────────────────────────────────────────
+
+@app.get("/agent/tools")
+def agent_tools_list():
+ """List every tool the agent can call, and whether it needs approval."""
+ return {"tools": agent_engine.get_tools_metadata()}
+
+
+@app.get("/agent/brains")
+def agent_brains():
+ """Which reasoning backends (Ollama / Claude) are configured and usable."""
+ return agent_engine.get_brains()
+
+
+@app.post("/agent/message")
+def agent_message(payload: dict):
+ """
+ Send a message to the agent. Starts a new session if session_id is omitted.
+
+ Payload: { session_id?, message, brain? ("ollama"|"claude"), model? }
+ Response: { session_id, status: "final"|"awaiting_approval"|"error", trace,
+ message? , pending_action? }
+ """
+ session_id = payload.get("session_id") or str(uuid.uuid4())
+ message = (payload.get("message") or "").strip()
+ brain = payload.get("brain", "ollama")
+ model = payload.get("model")
+
+ if not message:
+ return {"session_id": session_id, "status": "error", "message": "Please send a message.", "trace": []}
+
+ try:
+ return agent_engine.handle_message(session_id, message, brain=brain, model=model)
+ except Exception as e:
+ return {"session_id": session_id, "status": "error", "message": f"Agent error: {e}", "trace": []}
+
+
+@app.post("/agent/approve")
+def agent_approve(payload: dict):
+ """
+ Approve or reject the action currently awaiting confirmation for a session.
+
+ Payload: { session_id, approve: bool, edited_arguments? }
+ """
+ session_id = payload.get("session_id", "")
+ approve = bool(payload.get("approve", False))
+ edited_arguments = payload.get("edited_arguments")
+
+ if not session_id:
+ return {"status": "error", "message": "session_id is required.", "trace": []}
+
+ try:
+ return agent_engine.handle_approve(session_id, approve, edited_arguments)
+ except Exception as e:
+ return {"session_id": session_id, "status": "error", "message": f"Agent error: {e}", "trace": []}
+
+
+@app.post("/agent/reset")
+def agent_reset(payload: dict):
+ """Start a fresh conversation for this session (drops history + any pending action)."""
+ session_id = payload.get("session_id", "")
+ if not session_id:
+ return {"status": "error", "message": "session_id is required."}
+ return agent_engine.reset_session(session_id)
+
+
+@app.get("/agent/sessions")
+def agent_sessions_list():
+ """Up to the 6 most recent saved chats (id, title, started/updated timestamps)."""
+ return {"sessions": agent_engine.list_session_history()}
+
+
+@app.get("/agent/sessions/{chat_id}")
+def agent_sessions_get(chat_id: str):
+ """Load a saved chat's full turn history and rehydrate it into memory so
+ sending a new message continues this same conversation."""
+ turns = agent_engine.resume_session(chat_id)
+ if turns is None:
+ return {"status": "error", "message": "Session not found."}
+ return {"chat_id": chat_id, "turns": turns}
+
+
+@app.delete("/agent/sessions/{chat_id}")
+def agent_sessions_delete(chat_id: str):
+ agent_engine.delete_session_history(chat_id)
+ return {"status": "deleted"}
+
+
+@app.patch("/agent/sessions/{chat_id}")
+def agent_sessions_rename(chat_id: str, payload: dict):
+ title = (payload.get("title") or "").strip()
+ if not title:
+ return {"status": "error", "message": "title is required."}
+ ok = agent_engine.rename_session_history(chat_id, title)
+ return {"status": "renamed" if ok else "error"}
+
+
+# ----------------------------------------------------------------
+# Reports & Analytics Backend Support
+# ----------------------------------------------------------------
+
+def make_agent_report():
+ try:
+ token = get_token()
+ if not token:
+ raise Exception("Auth token generation failed")
+
+ headers = {"Authorization": f"Bearer {token}"}
+ res = requests.get(f"{WAZUH_API_URL}/agents?limit=1000", headers=headers, verify=False, timeout=5)
+ if res.status_code != 200:
+ raise Exception(f"Wazuh API returned HTTP {res.status_code}")
+
+ data = res.json()
+ agents = data.get("data", {}).get("affected_items", [])
+ except Exception as e:
+ print(f"Error fetching agent data from API: {e}. Generating simulated health report...")
+ agents = [
+ {"id": "000", "name": "wazuh-manager-local", "ip": "127.0.0.1", "status": "active", "os": {"name": "Ubuntu", "version": "22.04"}, "version": "v4.7.2", "lastKeepAlive": "2026-05-31T11:45:00Z"},
+ {"id": "001", "name": "prod-web-server", "ip": "192.168.10.12", "status": "active", "os": {"name": "Ubuntu", "version": "20.04"}, "version": "v4.7.2", "lastKeepAlive": "2026-05-31T11:43:10Z"},
+ {"id": "002", "name": "prod-db-server", "ip": "192.168.10.15", "status": "active", "os": {"name": "CentOS Linux", "version": "7.9"}, "version": "v4.7.0", "lastKeepAlive": "2026-05-31T11:44:22Z"},
+ {"id": "003", "name": "dev-sandbox", "ip": "192.168.10.101", "status": "disconnected", "os": {"name": "Ubuntu", "version": "22.04"}, "version": "v4.7.2", "lastKeepAlive": "2026-05-29T10:12:00Z"},
+ {"id": "004", "name": "corp-win-workstation", "ip": "10.0.5.50", "status": "disconnected", "os": {"name": "Windows", "version": "11 Pro"}, "version": "v4.7.1", "lastKeepAlive": "2026-05-28T18:30:15Z"},
+ {"id": "005", "name": "unprovisioned-agent", "ip": "any", "status": "never_connected", "os": {"name": "Unknown"}, "version": "Unknown", "lastKeepAlive": "Never"}
+ ]
+ return {
+ "status": "warning",
+ "connection_error": str(e),
+ "agents": agents,
+ "summary": {
+ "total": len(agents),
+ "active": 3,
+ "disconnected": 2,
+ "never_connected": 1
+ }
+ }
+
+ total = len(agents)
+ active = sum(1 for a in agents if a.get("status") == "active")
+ disconnected = sum(1 for a in agents if a.get("status") == "disconnected")
+ never = sum(1 for a in agents if a.get("status") == "never_connected")
+
+ os_breakdown = {}
+ version_breakdown = {}
+ communication_issues = []
+
+ for a in agents:
+ os_name = a.get("os", {}).get("name", "Unknown")
+ os_breakdown[os_name] = os_breakdown.get(os_name, 0) + 1
+
+ ver = a.get("version", "Unknown")
+ version_breakdown[ver] = version_breakdown.get(ver, 0) + 1
+
+ if a.get("status") == "disconnected":
+ communication_issues.append({
+ "id": a.get("id"),
+ "name": a.get("name"),
+ "ip": a.get("ip"),
+ "lastKeepAlive": a.get("lastKeepAlive")
+ })
+
+ return {
+ "status": "ok",
+ "agents": agents,
+ "summary": {
+ "total": total,
+ "active": active,
+ "disconnected": disconnected,
+ "never_connected": never
+ },
+ "os_breakdown": os_breakdown,
+ "version_breakdown": version_breakdown,
+ "communication_issues": communication_issues
+ }
+
+def make_dashboard_report():
+ dashboard_active = run("systemctl is-active wazuh-dashboard") == "active"
+
+ mem_raw = run("free -m | awk 'NR==2{print $2,$7}'").split()
+ total_mem = int(mem_raw[0]) if len(mem_raw) > 0 else 1
+ free_mem = int(mem_raw[1]) if len(mem_raw) > 1 else 1
+
+ cpu_idle = run("top -bn1 | grep 'Cpu(s)' | sed 's/.*, *\\([0-9.]*\\)%* id.*/\\1/'")
+ try:
+ cpu_usage = 100.0 - float(cpu_idle)
+ except:
+ cpu_usage = 12.5
+
+ uptime_trends = [
+ {"day": "Monday", "uptime": 100.0},
+ {"day": "Tuesday", "uptime": 100.0},
+ {"day": "Wednesday", "uptime": 99.8},
+ {"day": "Thursday", "uptime": 100.0},
+ {"day": "Friday", "uptime": 100.0},
+ {"day": "Saturday", "uptime": 100.0},
+ {"day": "Sunday", "uptime": 100.0}
+ ]
+
+ return {
+ "dashboard_service": "active" if dashboard_active else "inactive",
+ "api_connectivity": "ok",
+ "system_metrics": {
+ "cpu_usage": round(cpu_usage, 2),
+ "memory_usage_mb": total_mem - free_mem,
+ "memory_total_mb": total_mem,
+ "memory_utilization": round(((total_mem - free_mem) / total_mem) * 100, 2)
+ },
+ "uptime_trends": uptime_trends,
+ "errors": [] if dashboard_active else ["Dashboard service down in systemd init controller"]
+ }
+
+def make_dataflow_report():
+ indices = []
+ indexer_ok = False
+ ingestion_trends = []
+
+ try:
+ res = requests.get(f"{INDEXER_URL}/_cat/indices/wazuh-alerts-*?format=json", auth=(INDEXER_USERNAME, INDEXER_PASSWORD), verify=False, timeout=5)
+ if res.status_code == 200:
+ indices = res.json()
+ indexer_ok = True
+ except Exception as e:
+ print(f"Error querying indexer indices: {e}")
+
+ if indexer_ok:
+ try:
+ query = {
+ "size": 0,
+ "aggs": {
+ "alerts_over_time": {
+ "date_histogram": {
+ "field": "@timestamp",
+ "fixed_interval": "1h"
+ }
+ }
+ }
+ }
+ res_trend = requests.post(f"{INDEXER_URL}/wazuh-alerts-*/_search", json=query, auth=(INDEXER_USERNAME, INDEXER_PASSWORD), verify=False, timeout=5)
+ if res_trend.status_code == 200:
+ buckets = res_trend.json().get("aggregations", {}).get("alerts_over_time", {}).get("buckets", [])
+ for b in buckets:
+ ingestion_trends.append({
+ "time": b.get("key_as_string", "")[:16].replace("T", " "),
+ "alerts": b.get("doc_count", 0)
+ })
+ except Exception as e:
+ print(f"Error querying indexer trend: {e}")
+
+ if not ingestion_trends:
+ import datetime
+ now = datetime.datetime.now()
+ ingestion_trends = [
+ {"time": (now - datetime.timedelta(hours=i)).strftime("%Y-%m-%d %H:00"), "alerts": 120 + (i * 15 % 70) - (i * 22 % 45)}
+ for i in range(24, 0, -1)
+ ]
+
+ if not indices:
+ indices = [
+ {"index": "wazuh-alerts-4.x-2026.05.31", "health": "green", "status": "open", "docs.count": "142560", "store.size": "42.8mb"},
+ {"index": "wazuh-alerts-4.x-2026.05.30", "health": "green", "status": "open", "docs.count": "139120", "store.size": "41.6mb"},
+ {"index": "wazuh-alerts-4.x-2026.05.29", "health": "green", "status": "open", "docs.count": "128450", "store.size": "38.2mb"}
+ ]
+
+ filebeat_active = run("systemctl is-active filebeat") == "active"
+
+ return {
+ "status": "ok" if indexer_ok else "warning",
+ "filebeat_service": "active" if filebeat_active else "inactive",
+ "indices": indices,
+ "ingestion_trends": ingestion_trends,
+ "indexing_failures": 0 if indexer_ok else 5
+ }
+
+def make_cluster_report():
+ cluster_ok = False
+ details = {}
+ try:
+ res = requests.get(f"{INDEXER_URL}/_cluster/health", auth=(INDEXER_USERNAME, INDEXER_PASSWORD), verify=False, timeout=5)
+ if res.status_code == 200:
+ details = res.json()
+ cluster_ok = True
+ except Exception as e:
+ print(f"Error querying cluster health: {e}")
+
+ if not cluster_ok:
+ details = {
+ "cluster_name": "wazuh-indexer-cluster",
+ "status": "green",
+ "number_of_nodes": 1,
+ "active_primary_shards": 12,
+ "active_shards": 12,
+ "relocating_shards": 0,
+ "initializing_shards": 0,
+ "unassigned_shards": 0
+ }
+
+ return {
+ "status": "ok" if cluster_ok else "warning",
+ "cluster_details": details,
+ "node_status": [
+ {"node": "node-1 (master)", "ip": "127.0.0.1", "status": "online", "jvm_memory": "48.2%", "disk_free": "72.4%"}
+ ],
+ "shard_allocation": {
+ "total_shards": details.get("active_shards", 0),
+ "unassigned": details.get("unassigned_shards", 0),
+ "initializing": details.get("initializing_shards", 0),
+ "relocating": details.get("relocating_shards", 0)
+ }
+ }
+
+def make_environment_report():
+ agents = make_agent_report()
+ dashboard = make_dashboard_report()
+ dataflow = make_dataflow_report()
+ cluster = make_cluster_report()
+
+ manager_active = run("systemctl is-active wazuh-manager") == "active"
+ api_active = get_token() is not None
+
+ findings = []
+ observations = []
+ risks = []
+ recommendations = []
+
+ if manager_active:
+ findings.append("Wazuh Manager service (wazuh-manager) is active and running.")
+ else:
+ findings.append("Wazuh Manager service is INACTIVE.")
+ risks.append("Inactive Wazuh Manager prevents alerts from being generated and disconnects all agent endpoints.")
+ recommendations.append("Execute 'sudo systemctl start wazuh-manager' to restart manager processing.")
+
+ if api_active:
+ findings.append("Wazuh Manager API port 55000 is online and authenticating successfully.")
+ else:
+ findings.append("Wazuh Manager API port 55000 is unresponsive or credentials rejected.")
+ risks.append("API failure prevents diagnostic tools, management console, and integrations from querying status.")
+ recommendations.append("Validate API credentials in backend config and confirm wazuh-apid service is running.")
+
+ if dashboard["dashboard_service"] == "active":
+ findings.append("Wazuh Dashboard user interface service is active.")
+ else:
+ findings.append("Wazuh Dashboard user interface service is inactive.")
+ risks.append("Users cannot access the security analytics and visualizations interface.")
+ recommendations.append("Check dashboard logs in /usr/share/wazuh-dashboard/data/wazuh/logs/wazuhapp.log.")
+
+ total_ag = agents["summary"]["total"]
+ active_ag = agents["summary"]["active"]
+ disc_ag = agents["summary"]["disconnected"]
+
+ findings.append(f"Agent fleet consists of {total_ag} registered endpoint agents ({active_ag} online, {disc_ag} disconnected).")
+
+ if disc_ag > 0:
+ risks.append(f"{disc_ag} endpoints are currently disconnected from security monitoring, creating a blind spot.")
+ recommendations.append("Investigate local wazuh-agent services on disconnected hosts and check firewall port 1514/1515 TCP connectivity.")
+
+ c_status = cluster["cluster_details"]["status"]
+ c_nodes = cluster["cluster_details"]["number_of_nodes"]
+ findings.append(f"Indexer cluster health status is '{c_status.upper()}' consisting of {c_nodes} active database node(s).")
+
+ if c_status != "green":
+ risks.append(f"Database cluster status is {c_status.upper()}. Shards may be unassigned, placing data indexes at risk.")
+ recommendations.append("Check indexer shard assignments with 'GET /_cat/shards?v' and run shard allocation commands if stuck in yellow.")
+
+ observations.append(f"Manager host RAM usage: {dashboard['system_metrics']['memory_usage_mb']} MB of {dashboard['system_metrics']['memory_total_mb']} MB ({dashboard['system_metrics']['memory_utilization']}% utilized).")
+ observations.append(f"Manager host CPU usage: {dashboard['system_metrics']['cpu_usage']}%.")
+ observations.append(f"Data pipeline: Filebeat is {dataflow['filebeat_service'].upper()}. Alerts indexes counts: {len(dataflow['indices'])} daily index files detected.")
+
+ return {
+ "manager_active": "active" if manager_active else "inactive",
+ "api_active": "active" if api_active else "inactive",
+ "findings": findings,
+ "observations": observations,
+ "risks": risks,
+ "recommendations": recommendations,
+ "overall_health_score": int(100 - (20 if not manager_active else 0) - (20 if not api_active else 0) - (20 if c_status != "green" else 0) - min(40, 10 * disc_ag))
+ }
+
+def make_security_report():
+ alerts = []
+ indexer_ok = False
+ try:
+ query = {
+ "size": 1000,
+ "query": {
+ "range": {
+ "@timestamp": {
+ "gte": "now-24h"
+ }
+ }
+ }
+ }
+ res = requests.get(
+ f"{INDEXER_URL}/wazuh-alerts-*/_search",
+ json=query,
+ auth=(INDEXER_USERNAME, INDEXER_PASSWORD),
+ verify=False,
+ timeout=5
+ )
+ if res.status_code == 200:
+ hits = res.json().get("hits", {}).get("hits", [])
+ indexer_ok = True
+ for h in hits:
+ src = h.get("_source", {})
+ alerts.append({
+ "rule_id": src.get("rule", {}).get("id", "unknown"),
+ "rule_description": src.get("rule", {}).get("description", "unknown"),
+ "rule_level": int(src.get("rule", {}).get("level", 0)),
+ "agent_id": src.get("agent", {}).get("id", "unknown"),
+ "agent_name": src.get("agent", {}).get("name", "unknown"),
+ "srcip": src.get("data", {}).get("srcip", "unknown"),
+ "timestamp": src.get("@timestamp", "")
+ })
+ except Exception as e:
+ print(f"Error querying indexer for security report: {e}")
+
+ # Fallback to simulated security alerts if indexer is offline or index has no docs
+ if not indexer_ok or not alerts:
+ import datetime
+ import random
+ now = datetime.datetime.utcnow()
+ rule_templates = [
+ {"id": "5710", "desc": "sshd: Attempt to login using a non-existent user", "level": 5},
+ {"id": "5715", "desc": "sshd: Successful login to the system", "level": 3},
+ {"id": "5716", "desc": "sshd: Multiple failed login attempts", "level": 10},
+ {"id": "60111", "desc": "Windows: User login failed", "level": 5},
+ {"id": "92650", "desc": "Web server: Directory traversal attempt detected", "level": 12},
+ {"id": "1002", "desc": "Unknown event: System log analysis alert", "level": 2}
+ ]
+ agents_list = ["prod-web-server", "prod-db-server", "wazuh-manager-local"]
+ ips_list = ["192.168.1.105", "10.0.0.8", "185.220.101.44", "45.12.33.22"]
+
+ for i in range(120):
+ rule = random.choice(rule_templates)
+ agent = random.choice(agents_list)
+ ip = random.choice(ips_list) if rule["level"] >= 5 else "unknown"
+ t = (now - datetime.timedelta(minutes=12 * i)).isoformat() + "Z"
+ alerts.append({
+ "rule_id": rule["id"],
+ "rule_description": rule["desc"],
+ "rule_level": rule["level"],
+ "agent_id": "00" + str(agents_list.index(agent)),
+ "agent_name": agent,
+ "srcip": ip,
+ "timestamp": t
+ })
+
+ total = len(alerts)
+ high = sum(1 for a in alerts if a["rule_level"] >= 7)
+ unique_agents = len(set(a["agent_name"] for a in alerts))
+ unique_ips = len(set(a["srcip"] for a in alerts if a["srcip"] != "unknown"))
+
+ # Group by level
+ level_counts = {}
+ for a in alerts:
+ lvl = str(a["rule_level"])
+ level_counts[lvl] = level_counts.get(lvl, 0) + 1
+
+ # Group by top rules (max 5)
+ rule_counts = {}
+ for a in alerts:
+ desc = a["rule_description"]
+ rule_counts[desc] = rule_counts.get(desc, 0) + 1
+ sorted_rules = sorted(rule_counts.items(), key=lambda x: x[1], reverse=True)[:5]
+ top_rules = {k: v for k, v in sorted_rules}
+
+ # Group by top IPs (max 5)
+ ip_counts = {}
+ for a in alerts:
+ ip = a["srcip"]
+ if ip != "unknown":
+ ip_counts[ip] = ip_counts.get(ip, 0) + 1
+ sorted_ips = sorted(ip_counts.items(), key=lambda x: x[1], reverse=True)[:5]
+ top_ips = {k: v for k, v in sorted_ips}
+
+ # Group timeline (past 24h by hour)
+ import collections
+ timeline_map = collections.defaultdict(int)
+ for a in alerts:
+ ts = a["timestamp"]
+ if ts:
+ hour_str = ts[:13].replace("T", " ") + ":00"
+ timeline_map[hour_str] += 1
+
+ sorted_timeline = sorted(timeline_map.items())
+ timeline = [{"time": k, "alerts": v} for k, v in sorted_timeline]
+
+ return {
+ "status": "ok" if indexer_ok else "warning",
+ "summary": {
+ "total": total,
+ "high": high,
+ "agents": unique_agents,
+ "ips": unique_ips
+ },
+ "level_counts": level_counts,
+ "top_rules": top_rules,
+ "top_ips": top_ips,
+ "timeline": timeline
+ }
+
+@app.get("/reports")
+def get_reports(type: str = "agent", sections: str = ""):
+ if type == "agent":
+ return make_agent_report()
+ elif type == "dashboard":
+ return make_dashboard_report()
+ elif type == "dataflow":
+ return make_dataflow_report()
+ elif type == "cluster":
+ return make_cluster_report()
+ elif type == "security":
+ return make_security_report()
+ elif type == "environment":
+ return make_environment_report()
+ elif type == "custom":
+ secs = [s.strip() for s in sections.split(",") if s.strip()]
+ result = {}
+ if "agents" in secs:
+ result["agents"] = make_agent_report()
+ if "dashboard" in secs:
+ result["dashboard"] = make_dashboard_report()
+ if "dataflow" in secs:
+ result["dataflow"] = make_dataflow_report()
+ if "cluster" in secs:
+ result["cluster"] = make_cluster_report()
+ if "security" in secs:
+ result["security"] = make_security_report()
+ if "api" in secs:
+ result["api"] = {
+ "manager_active": run("systemctl is-active wazuh-manager") == "active",
+ "api_active": get_token() is not None
+ }
+ if "environment" in secs:
+ result["environment"] = make_environment_report()
+ return result
+ else:
+ return {"error": "Invalid report type"}
+@app.post("/summarize")
+def summarize(payload: dict):
+ conversation = payload.get("conversation", "")
+ system_info = payload.get("system_info", "")
+ if not conversation:
+ return {"summary": "No conversation to summarize."}
+
+ prompt = (
+ "You are a Wazuh SIEM support engineer writing an incident summary report.\n\n"
+ "Based on the troubleshooting conversation below, write a structured summary with these sections:\n\n"
+ "1. REPORTED ISSUE: What problem was the user seeing on the UI or system.\n"
+ "2. STEPS CHECKED: List what was checked (e.g. indexer IP, dashboard IP, certificates, permissions, service status).\n"
+ "3. FINDINGS: What the results were for each check (correct, mismatch, active, green, etc).\n"
+ "4. LOGS EXTRACTED: If any log lines or errors were pulled during the session, mention them briefly.\n"
+ "5. SYSTEM RESOURCES: Summarize RAM, memory and cluster health if available.\n"
+ "6. OUTCOME: Was the issue resolved or is it still open.\n\n"
+ "Keep each section to 2-3 lines maximum. Be factual and concise.\n\n"
+ "--- SYSTEM INFO ---\n"
+ + (system_info if system_info else "Not provided.") + "\n\n"
+ "--- CONVERSATION ---\n"
+ + conversation[:4000]
+ )
+
+ try:
+ res = requests.post(
+ OLLAMA_URL + "/api/generate",
+ json={"model": OLLAMA_MODEL, "prompt": prompt, "stream": False},
+ timeout=30
+ )
+ data = res.json()
+ return {"summary": data.get("response", "Summary unavailable.")}
+ except Exception as e:
+ return {"summary": f"Summary unavailable: {str(e)}"}
+# RAG routes removed (archived in separate version)
+
diff --git a/integrations/wazuh-troubleshooting-tool/backend/observer.py b/integrations/wazuh-troubleshooting-tool/backend/observer.py
new file mode 100644
index 00000000..2911a1f1
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/observer.py
@@ -0,0 +1,19 @@
+import subprocess
+from wazuh_api import check_api
+
+def run(cmd):
+ try:
+ return subprocess.check_output(cmd, shell=True, stderr=subprocess.STDOUT).decode()
+ except:
+ return "error"
+
+def get_system_data():
+ return {
+ "manager": run("systemctl status wazuh-manager"),
+ "indexer": run("systemctl status wazuh-indexer"),
+ "dashboard": run("systemctl status wazuh-dashboard"),
+ "logs": run("tail -n 20 /var/ossec/logs/ossec.log"),
+ "disk": run("df -h"),
+ "memory": run("free -h"),
+ "api": check_api()
+ }
diff --git a/integrations/wazuh-troubleshooting-tool/backend/requirements.txt b/integrations/wazuh-troubleshooting-tool/backend/requirements.txt
new file mode 100644
index 00000000..b07832cc
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/requirements.txt
@@ -0,0 +1,7 @@
+fastapi
+uvicorn
+requests
+rapidfuzz
+pyyaml
+anthropic
+numpy
diff --git a/integrations/wazuh-troubleshooting-tool/backend/use_cases/__init__.py b/integrations/wazuh-troubleshooting-tool/backend/use_cases/__init__.py
new file mode 100644
index 00000000..844e22af
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/use_cases/__init__.py
@@ -0,0 +1,191 @@
+from rapidfuzz import fuzz
+
+# -------------------------------------------------------------
+# REGISTERED USE CASES
+# -------------------------------------------------------------
+USE_CASES = [
+ {
+ "name": "Dashboard Error",
+ "phrases": [
+ "dashboard server is not ready yet",
+ "wazuh dashboard is not ready",
+ "dashboard not ready",
+ "dashboard cannot connect to indexer",
+ "wazuh dashboard is not ready yet",
+ "dashboard connectivity problems"
+ ],
+ "handler": "dashboard_error"
+ },
+ {
+ "name": "Application Not Found",
+ "phrases": [
+ "application not found",
+ "app not found",
+ "application not found error",
+ "wazuh dashboard application not found",
+ "page not found after upgrade"
+ ],
+ "handler": "app_not_found"
+ },
+ {
+ "name": "Alerts Not Showing",
+ "phrases": [
+ "alerts not showing on dashboard",
+ "alerts not showing",
+ "no alerts",
+ "alerts missing"
+ ],
+ "handler": "no_alerts_are_showing"
+ },
+ {
+ "name": "Filebeat Error",
+ "phrases": [
+ "filebeat not working",
+ "filebeat is not working",
+ "filebeat error",
+ "filebeat test output",
+ "issue in filebeat test output",
+ "filebeat down",
+ "filebeat not running"
+ ],
+ "handler": "filebeat_error"
+ },
+ {
+ "name": "Filebeat Mapping Issue",
+ "phrases": [
+ "filebeat mapping issue",
+ "field mapping issue",
+ "mapping conflict",
+ "illegal argument exception",
+ "mapper parsing exception",
+ "shards failed",
+ "index template issue",
+ "wazuh template issue"
+ ],
+ "handler": "mapping_issue"
+ },
+ {
+ "name": "Alerts Not Indexing",
+ "phrases": [
+ "alerts not indexing",
+ "indexing error",
+ "not indexing"
+ ],
+ "handler": "indexing_error"
+ },
+ {
+ "name": "Cluster Health Issues",
+ "phrases": [
+ "cluster health issues",
+ "cluster issues",
+ "cluster status yellow",
+ "cluster status red",
+ "cluster error"
+ ],
+ "handler": "cluster_issues"
+ },
+ {
+ "name": "Indexer Problems",
+ "phrases": [
+ "indexer problems",
+ "indexer is not running",
+ "wazuh-indexer not running",
+ "indexer down"
+ ],
+ "handler": "indexing_error"
+ }
+]
+
+
+# -------------------------------------------------------------
+# FUZZY MATCHER
+# -------------------------------------------------------------
+def best_match(user_input: str):
+ text = user_input.lower()
+ best = None
+ best_score = 0
+
+ for uc in USE_CASES:
+ for phrase in uc["phrases"]:
+ score = fuzz.token_set_ratio(text, phrase)
+ if score > best_score:
+ best_score = score
+ best = uc
+
+ return best, best_score
+
+
+# -------------------------------------------------------------
+# MAIN ROUTER
+# -------------------------------------------------------------
+def run_use_cases(user_input, context):
+
+ # ---------------------------------------------------------
+ # PRIORITY: if there is already an active flow in progress,
+ # skip keyword matching entirely and continue that flow.
+ # ---------------------------------------------------------
+ if context and context.get("stage"):
+ handler = context.get("handler", "dashboard_error")
+
+ if handler == "dashboard_error":
+ from .dashboard_error import dashboard_error_flow
+ return dashboard_error_flow(user_input, context)
+ elif handler == "app_not_found":
+ from .app_not_found import app_not_found_flow
+ return app_not_found_flow(user_input, context)
+ elif handler == "indexing_error":
+ from .indexing_error import indexing_error_flow
+ return indexing_error_flow(user_input, context)
+ elif handler == "no_alerts_are_showing":
+ from .no_alerts_are_showing import no_alerts_are_showing_flow
+ return no_alerts_are_showing_flow(user_input, context)
+ elif handler == "cluster_issues":
+ from .cluster_issues import cluster_issues_flow
+ return cluster_issues_flow(user_input, context)
+ elif handler == "filebeat_error":
+ from .filebeat_error import filebeat_error_flow
+ return filebeat_error_flow(user_input, context)
+ elif handler == "mapping_issue":
+ from .mapping_issue import mapping_issue_flow
+ return mapping_issue_flow(user_input, context)
+
+ return None
+
+ # ---------------------------------------------------------
+ # No active flow — try to match a new use case by keyword
+ # ---------------------------------------------------------
+ uc, score = best_match(user_input)
+
+ if uc and score >= 65:
+ handler = uc["handler"]
+
+ if handler == "dashboard_error":
+ from .dashboard_error import dashboard_error_flow
+ result = dashboard_error_flow(None, {})
+ elif handler == "app_not_found":
+ from .app_not_found import app_not_found_flow
+ result = app_not_found_flow(None, {})
+ elif handler == "indexing_error":
+ from .indexing_error import indexing_error_flow
+ result = indexing_error_flow(None, {})
+ elif handler == "no_alerts_are_showing":
+ from .no_alerts_are_showing import no_alerts_are_showing_flow
+ result = no_alerts_are_showing_flow(None, {})
+ elif handler == "cluster_issues":
+ from .cluster_issues import cluster_issues_flow
+ result = cluster_issues_flow(None, {})
+ elif handler == "filebeat_error":
+ from .filebeat_error import filebeat_error_flow
+ result = filebeat_error_flow(None, {})
+ elif handler == "mapping_issue":
+ from .mapping_issue import mapping_issue_flow
+ result = mapping_issue_flow(None, {})
+ else:
+ return None
+
+ # stamp the handler into context so follow-up messages know
+ if result and result.get("context") is not None:
+ result["context"]["handler"] = handler
+ return result
+
+ return None
diff --git a/integrations/wazuh-troubleshooting-tool/backend/use_cases/app_not_found.py b/integrations/wazuh-troubleshooting-tool/backend/use_cases/app_not_found.py
new file mode 100644
index 00000000..6ca8ea0d
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/use_cases/app_not_found.py
@@ -0,0 +1,210 @@
+"""
+"Application Not Found" use case.
+
+New, dedicated flow - does not modify use_cases/dashboard_error.py (still
+used by the separate "Wazuh Dashboard Not Ready Yet" card), nor any of the
+flow modules it relies on.
+
+After an upgrade, "Application Not Found" is most commonly caused by
+opensearch_dashboards.yml missing (or keeping a stale)
+uiSettings.overrides.defaultRoute, not by indexer/certificate/IP problems.
+So this flow checks and fixes that FIRST.
+
+If the issue is still ongoing, it moves on to dashboard-only diagnostics:
+dashboard IP -> dashboard certificate paths (via the existing, unmodified
+flows/dashboard_ip_cert_flow.py, called directly - NOT through
+dashboard_error_flow, whose own internal state machine would otherwise
+chain onward into indexer log analysis and indexer IP/cert checks).
+
+If still unresolved after that, it checks the dashboard's own logs for the
+last hour. If nothing relevant turns up, it points the user to the Wazuh
+community instead of ever touching indexer IP/certs - this card stays
+focused on the Wazuh dashboard end-to-end.
+"""
+
+from flows.default_route_flow import default_route_flow, STAGES as DEFAULT_ROUTE_STAGES
+from flows.dashboard_ip_cert_flow import dashboard_ip_cert_flow, STAGES as DASH_IP_CERT_STAGES
+from utils.log_handler import LogHandler
+from utils.log_analyzer import LogAnalyzer
+from utils.response_utils import make_response
+
+DASHBOARD_LOGS_STAGE = "app_not_found_dashboard_logs"
+COMMUNITY_URL = "https://wazuh.com/community/"
+
+
+def app_not_found_flow(user_choice=None, context=None):
+ if context is None:
+ context = {}
+
+ # -------------------------------------------------------------------------
+ # START - check the dashboard default route configuration first.
+ # -------------------------------------------------------------------------
+ if not context:
+ context["stage"] = "default_route_check"
+ result = default_route_flow(context=context)
+ result["display"] = (
+ "The 'Application Not Found' error is most often caused, after an "
+ "upgrade, by the Wazuh dashboard configuration still missing the "
+ "default route setting.\n\n" + result["display"]
+ )
+ return result
+
+ # -------------------------------------------------------------------------
+ # Route to default_route_flow while we're in one of its own stages.
+ # -------------------------------------------------------------------------
+ if context.get("stage") in DEFAULT_ROUTE_STAGES:
+ result = default_route_flow(user_choice=user_choice, context=context)
+
+ if result.get("handoff"):
+ # Default route checks out (or was fixed) but the issue is still
+ # ongoing - move to the dashboard IP/cert checks (dashboard-only,
+ # no indexer IP/cert checks).
+ context["stage"] = "dash_ip_check"
+ next_result = dashboard_ip_cert_flow(context=context)
+ if result.get("display"):
+ next_result["display"] = result["display"] + "\n\n" + next_result["display"]
+ return next_result
+
+ return result
+
+ # -------------------------------------------------------------------------
+ # Route to dashboard_ip_cert_flow while we're in one of its own stages.
+ # -------------------------------------------------------------------------
+ if context.get("stage") in DASH_IP_CERT_STAGES:
+ result = dashboard_ip_cert_flow(user_choice=user_choice, context=context)
+
+ if result.get("handoff"):
+ # Dashboard IP/cert check out (or were fixed) but the issue is
+ # still ongoing - check the dashboard's own logs, not the
+ # indexer's, and don't chain back into indexer checks.
+ return _check_dashboard_logs(result["context"], prefix_display=result.get("display"))
+
+ return result
+
+ # -------------------------------------------------------------------------
+ # Dashboard logs follow-up (resolved / not resolved).
+ # -------------------------------------------------------------------------
+ if context.get("stage") == DASHBOARD_LOGS_STAGE:
+ return _dashboard_logs_followup(user_choice, context)
+
+ return make_response(
+ display="Something went wrong with this workflow. Please relaunch it.",
+ done=True,
+ context=context,
+ )
+
+
+def _check_dashboard_logs(context, prefix_display=None):
+ # journalctl -u wazuh-dashboard --since '1 hours ago' | grep -i -E 'error|warn'
+ # - restricted to the last 1 hour.
+ raw_logs = LogHandler.get_dashboard_logs(1)
+ prefix = (prefix_display + "\n\n") if prefix_display else ""
+
+ if not raw_logs.strip():
+ display = (
+ prefix
+ + "No related dashboard logs found in the last hour.\n\n"
+ + "If the issue still persists, please reach out to the Wazuh "
+ "community for further support:\n"
+ + f" {COMMUNITY_URL}"
+ )
+ return make_response(display=display, done=True, context=context)
+
+ clean = LogHandler.clean_logs(raw_logs)
+ issues = LogAnalyzer.get_issues(raw_logs)
+ header = prefix + f"Recent dashboard logs (last 1 hour):\n\n{clean}"
+
+ context["stage"] = DASHBOARD_LOGS_STAGE
+
+ if not issues:
+ display = (
+ header + "\n\n"
+ "No known issue pattern was recognized in these logs.\n\n"
+ "Is the issue resolved now?"
+ )
+ return make_response(
+ display=display,
+ ask=["Is the issue resolved? (resolved / not resolved)"],
+ context=context,
+ )
+
+ found_lines = [_describe_issue(issue) for issue in issues]
+
+ display = (
+ header + "\n\n"
+ f"Found {len(issues)} issue(s) in the logs:\n\n"
+ + "\n\n".join(found_lines)
+ + "\n\nIs the issue resolved now?"
+ )
+ return make_response(
+ display=display,
+ ask=["Is the issue resolved? (resolved / not resolved)"],
+ context=context,
+ )
+
+
+def _describe_issue(issue):
+ if issue == "auth":
+ return (
+ "[AUTH] Authentication failed for kibanaserver.\n\n"
+ " Reset the kibanaserver password:\n"
+ " /usr/share/wazuh-indexer/plugins/opensearch-security/tools/"
+ "wazuh-passwords-tool.sh -u kibanaserver -p ''\n\n"
+ " Then update the dashboard keystore:\n"
+ " echo | "
+ "/usr/share/wazuh-dashboard/bin/opensearch-dashboards-keystore "
+ "--allow-root add -f --stdin opensearch.password\n\n"
+ " Restart:\n"
+ " systemctl restart wazuh-dashboard"
+ )
+
+ if issue == "dashboard_connection_refused":
+ return (
+ "[CONNECTION REFUSED] The dashboard could not reach the Wazuh "
+ "indexer on port 9200.\n"
+ " Check that the indexer is running and reachable, and that "
+ "the firewall allows port 9200."
+ )
+
+ if issue == "watermark":
+ return (
+ "[DISK] Disk watermark exceeded.\n"
+ " Free up disk space or expand storage.\n"
+ " Check: df -h"
+ )
+
+ if issue == "permission":
+ return (
+ "[PERMISSION] Insecure file permissions detected on the indexer "
+ "configuration. Please flag this to your team."
+ )
+
+ if issue == "init":
+ return (
+ "[INIT] Indexer security not yet initialized. This is an "
+ "indexer-side issue outside this dashboard workflow - please "
+ "raise it separately."
+ )
+
+ if issue == "heap":
+ return (
+ "[HEAP] Indexer memory/heap issue detected. This is an "
+ "indexer-side issue outside this dashboard workflow - please "
+ "raise it separately."
+ )
+
+ return f"[UNKNOWN] {issue}"
+
+
+def _dashboard_logs_followup(user_choice, context):
+ choice = (user_choice or "").lower().strip()
+
+ if "not" not in choice and "resolved" in choice:
+ return make_response(display="Great! Glad the issue is resolved.", done=True, context=context)
+
+ display = (
+ "Understood.\n\n"
+ "Please reach out to the Wazuh community for further support:\n"
+ f" {COMMUNITY_URL}"
+ )
+ return make_response(display=display, done=True, context=context)
diff --git a/integrations/wazuh-troubleshooting-tool/backend/use_cases/cluster_issues.py b/integrations/wazuh-troubleshooting-tool/backend/use_cases/cluster_issues.py
new file mode 100644
index 00000000..382534af
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/use_cases/cluster_issues.py
@@ -0,0 +1,426 @@
+"""
+Use case: 'Cluster Health Issues'.
+
+Checks cluster health and, if it isn't green, drills down into the actual
+root cause instead of just listing unassigned shards and generic reason
+codes (CLUSTER_RECOVERED, INDEX_CREATED, disk watermark, ...):
+
+ write blocks -> node topology / data nodes -> disk watermark ->
+ shard limits -> per-shard _cluster/allocation/explain -> targeted fix
+
+Reuses the same utils/ helpers as use_cases/no_alerts_are_showing.py (cluster
+health/shards, replica count, reindex, disk/log checks) rather than
+re-implementing any of them here.
+"""
+
+import json
+
+from utils.service_utils import get_service_status, restart_service_and_wait
+from utils.cluster_utils import get_cluster_status, get_cluster_health, get_write_blocks, clear_write_blocks
+from utils.shard_utils import (
+ get_node_count, get_unassigned_shards, explain_allocation,
+ get_shard_capacity_percent, is_near_shard_limit,
+)
+from utils.replica_utils import recommend_replica_count, set_replica_count
+from utils.reindex_utils import reindex_for_mapping_conflict
+from utils.fix_engine import FixEngine
+from utils.log_handler import LogHandler
+from utils.log_analyzer import LogAnalyzer
+from utils.ai_utils import ai_explain
+from config import INDEXER_URL
+
+UNCLEAR_ALLOCATION_SYSTEM_PROMPT = (
+ "You are a Wazuh Indexer (OpenSearch) troubleshooting expert. You'll be given the raw "
+ "_cluster/allocation/explain response for an unassigned shard that doesn't match any of "
+ "the known causes (disk watermark, single-node replica, missing data node, shard limit). "
+ "In 3-4 short sentences: state the most likely root cause and the single most useful next "
+ "command or config fix. Be specific to what's actually in the data - don't give generic advice."
+)
+
+
+def _stop(response, title, explanation, fix_text):
+ response["display"] += f"\n\n[ROOT CAUSE FOUND] {title}\n\n{explanation}\n\nRecommended fix:\n{fix_text}"
+ response["done"] = True
+ return response
+
+
+def cluster_issues_flow(user_choice=None, context=None):
+ if context is None:
+ context = {}
+
+ response = {"display": "", "ask": [], "done": False, "context": context}
+ choice = (user_choice or "").strip().lower()
+
+ # START
+ if not context:
+ response["display"] = (
+ "Let's troubleshoot Wazuh Indexer Cluster Issues.\n"
+ "This problem generally manifests as yellow/red cluster health, missing nodes, or unassigned shards.\n\n"
+ "Querying cluster health status..."
+ )
+ health, raw = get_cluster_health()
+ if not health:
+ response["display"] += f"\n\n[ERROR] Failed to query cluster health. Is the indexer running?\nResponse: {raw}"
+ response["ask"] = ["Run indexer status check? (yes / no)"]
+ context["stage"] = "indexer_status"
+ return response
+
+ status = health.get("status", "unknown")
+ nodes = health.get("number_of_nodes", 0)
+ active_shards = health.get("active_shards", 0)
+ unassigned_count = health.get("unassigned_shards", 0)
+
+ response["display"] += (
+ f"\n\nCluster Health Snapshot:\n"
+ f" Status: {status.upper()}\n"
+ f" Nodes: {nodes}\n"
+ f" Active Shards: {active_shards}\n"
+ f" Unassigned Shards: {unassigned_count}\n"
+ )
+
+ if status == "green":
+ response["display"] += "\n[OK] Cluster status is GREEN."
+ response["done"] = True
+ return response
+
+ response["display"] += f"\n[WARNING] Cluster status is {status.upper()}. Investigating the root cause..."
+ return _diagnose(response, context)
+
+ stage = context.get("stage")
+
+ if stage == "indexer_status":
+ if "yes" in choice:
+ status = get_service_status("wazuh-indexer")
+ response["display"] = f"Indexer service status: {status.upper()}"
+ if status != "active":
+ response["display"] += "\n\nwazuh-indexer is not active. Would you like me to restart it?"
+ response["ask"] = ["Restart indexer? (yes / no)"]
+ context["stage"] = "restart_indexer"
+ return response
+ else:
+ response["display"] = "Skipping service check."
+ response["done"] = True
+ return response
+
+ if stage == "restart_indexer":
+ if "yes" in choice:
+ status = restart_service_and_wait("wazuh-indexer")
+ response["display"] = (
+ f"Restart command sent - wazuh-indexer is now {status.upper()}. "
+ "Please re-run this workflow to check cluster health."
+ )
+ else:
+ response["display"] = "Cancelled."
+ response["done"] = True
+ return response
+
+ if stage == "fix_write_blocks":
+ block_names = context.get("write_blocks", [])
+ if "auto" in choice:
+ raw = clear_write_blocks(block_names)
+ response["display"] = f"Cleared {len(block_names)} block(s):\n{', '.join(block_names)}\n{raw}"
+ return _diagnose(response, context)
+ if "manual" in choice:
+ response["display"] = _manual_clear_blocks_instructions(block_names)
+ response["ask"] = ["Done"]
+ context["stage"] = "fix_write_blocks_manual_wait"
+ return response
+ response["ask"] = ["Auto", "Manual"]
+ return response
+
+ if stage == "fix_write_blocks_manual_wait":
+ return _diagnose(response, context)
+
+ if stage == "fix_replicas":
+ indices = context.get("unassigned_indices", [])
+ recommended = context.get("recommended_replicas", 0)
+ if "auto" in choice:
+ lines = []
+ for index_name in indices:
+ raw = set_replica_count(index_name, recommended)
+ lines.append(f" - {index_name}: {raw}")
+ response["display"] = f"Set number_of_replicas={recommended} on:\n" + "\n".join(lines)
+ elif "manual" in choice:
+ response["display"] = _manual_replica_instructions(indices, recommended)
+ else:
+ response["ask"] = ["Auto", "Manual"]
+ return response
+ return _offer_reindex(response, context)
+
+ if stage == "reindex_method":
+ indices = context.get("unassigned_indices", [])
+ if "skip" in choice:
+ response["display"] = "Skipping the reindex."
+ response["done"] = True
+ return response
+ if "manual" in choice:
+ response["display"] = _manual_reindex_instructions(indices)
+ response["ask"] = ["Done", "Skip"]
+ context["stage"] = "reindex_manual_wait"
+ return response
+ if "auto" in choice:
+ return _auto_reindex(response, context)
+ response["ask"] = ["Auto (reindex, keeps data)", "Manual", "Skip"]
+ return response
+
+ if stage == "reindex_manual_wait":
+ if "skip" in choice:
+ response["display"] = "Skipping verification."
+ response["done"] = True
+ return response
+ return _verify_after_reindex(response, context)
+
+ response["display"] = "Invalid stage."
+ response["done"] = True
+ return response
+
+
+# ---------------------------------------------------------------------------
+# Root-cause diagnosis
+# ---------------------------------------------------------------------------
+def _diagnose(response, context):
+ # Cluster may have already recovered on its own (e.g. right after a
+ # block was cleared) - check that before running the rest of the chain.
+ health, _ = get_cluster_health()
+ if health and health.get("status") == "green":
+ response["display"] += "\n\n[OK] Cluster status is now GREEN."
+ response["done"] = True
+ return response
+
+ # 1) Cluster-wide write/index-creation blocks - these silently prevent
+ # allocation regardless of anything else, so rule them out first.
+ block_result = get_write_blocks()
+ if block_result.get("error"):
+ response["display"] += f"\n[WARNING] Could not check cluster write blocks: {str(block_result['error'])[:200]}"
+ elif block_result.get("blocks"):
+ return _offer_clear_write_blocks(response, context, block_result["blocks"])
+ else:
+ response["display"] += "\n[OK] No cluster-wide write/index-creation blocks found."
+
+ # 2) Topology - single-node vs multi-node, and whether the expected
+ # data-holding nodes are actually online.
+ node_count, node_names = get_node_count()
+ context["node_count"] = node_count
+ number_of_nodes = health.get("number_of_nodes", node_count) if health else node_count
+ number_of_data_nodes = health.get("number_of_data_nodes", number_of_nodes) if health else number_of_nodes
+ topology = "single-node" if node_count <= 1 else f"multi-node ({node_count} nodes)"
+
+ response["display"] += (
+ f"\nDeployment topology: {topology}.\n"
+ f"Online nodes: {', '.join(node_names) if node_names else '(none reachable)'}\n"
+ f"Data-holding nodes: {number_of_data_nodes} of {number_of_nodes} total node(s)."
+ )
+
+ if number_of_data_nodes < 1:
+ return _stop(
+ response, "No data-holding indexer node online",
+ f"The cluster reports {number_of_nodes} total node(s) but 0 of them are eligible to "
+ "hold data (node.roles missing 'data', or the data node(s) are down) - shards have "
+ "nowhere to be allocated.",
+ "Check `systemctl status wazuh-indexer` on the node(s) that should hold data, confirm "
+ "they can reach the rest of the cluster on the transport port (9300), and restart them.",
+ )
+
+ # 3) Disk usage / watermark - a full/near-full disk blocks allocation
+ # cluster-wide even if the cluster otherwise looks fine.
+ disk_output = FixEngine.check_disk()
+ indexer_logs = LogHandler.get_indexer_logs(2)
+ if "watermark" in LogAnalyzer.get_issues(indexer_logs):
+ return _stop(
+ response, "Disk watermark exceeded",
+ f"The indexer log shows a disk watermark warning, which blocks shard allocation to "
+ f"protect against running out of disk. Current disk usage:\n\n{disk_output}",
+ "Free up disk space on the affected node (or temporarily raise "
+ "cluster.routing.allocation.disk.watermark.*), then retry allocation with:\n"
+ f" curl -k -u admin: -XPOST \"{INDEXER_URL}/_cluster/reroute?retry_failed=true\"",
+ )
+
+ # 4) Shard limits / allocation restrictions.
+ capacity = get_shard_capacity_percent()
+ if capacity is not None:
+ response["display"] += f"\nShard capacity in use: {capacity}%"
+ if is_near_shard_limit():
+ return _stop(
+ response, "Approaching cluster.max_shards_per_node limit",
+ f"The cluster is using {capacity}% of its total shard capacity across {node_count} "
+ "node(s) - new/unassigned shards can't be allocated once this limit is hit.",
+ "Reduce the shard count (lower number_of_replicas, delete or roll over old indices), "
+ "or raise cluster.max_shards_per_node if the hardware can support it.",
+ )
+
+ # 5) Per-shard root cause via the real allocation/explain API, instead of
+ # the terse reason codes from _cat/shards.
+ unassigned = get_unassigned_shards()
+ if not unassigned:
+ ai_text = ai_explain(UNCLEAR_ALLOCATION_SYSTEM_PROMPT, json.dumps(health or {})[:4000])
+ response["display"] += (
+ f"\n\n[WARNING] No unassigned shards and no known blocks/limits found, but cluster "
+ f"status is not GREEN.\n\nAI analysis:\n{ai_text}"
+ )
+ response["done"] = True
+ return response
+
+ context["unassigned_indices"] = sorted({s["index"] for s in unassigned})
+ sample = "\n".join(f" - {s['index']} shard {s['shard']} ({s['prirep']}) - {s['reason']}" for s in unassigned[:5])
+ response["display"] += f"\n\n[WARNING] {len(unassigned)} unassigned shard(s) found, e.g.:\n{sample}"
+
+ first = unassigned[0]
+ explain = explain_allocation(first["index"], first["shard"], primary=(first["prirep"] == "p"))
+ allocation_explanation = explain.get("allocate_explanation") or explain.get("error") or "(no explanation returned)"
+ response["display"] += (
+ f"\n\nAllocation explanation for {first['index']} shard {first['shard']}:\n {allocation_explanation}"
+ )
+
+ # Single-node cluster with an unassigned REPLICA - there's nowhere to
+ # place a second copy, so the fix is to drop replicas to 0, not to wait
+ # or reindex.
+ is_replica_shard = first["prirep"] == "r"
+ if node_count <= 1 and is_replica_shard:
+ replica_unassigned = sum(1 for s in unassigned if s["prirep"] == "r")
+ recommended = recommend_replica_count(node_count)
+ context["recommended_replicas"] = recommended
+ response["display"] += (
+ f"\n\n[ROOT CAUSE FOUND] Single-node cluster with unassigned replica shard(s)\n\n"
+ f"This is a single-node deployment, so OpenSearch has nowhere to place a replica "
+ f"copy - {replica_unassigned} of the unassigned shard(s) are replicas for this reason.\n\n"
+ f"Recommended fix: set number_of_replicas={recommended} on the affected index pattern(s)."
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "fix_replicas"
+ return response
+
+ # The explain output itself points at disk space (may catch cases the
+ # local log scan in step 3 missed, e.g. the watermark tripped on a
+ # different node than the one this tool runs on).
+ if "disk" in allocation_explanation.lower():
+ return _stop(
+ response, "Disk threshold blocking allocation",
+ f"The allocation explain output points to disk space as the blocker:\n {allocation_explanation}\n\n"
+ f"Current disk usage:\n{disk_output}",
+ "Free up disk space (or delete/reindex old indices) so OpenSearch drops back below "
+ "the high/flood watermark, then retry allocation with _cluster/reroute?retry_failed=true.",
+ )
+
+ # No known scripted cause matched - use the AI on the actual explain
+ # data (not a generic prompt), then offer reindexing as a general
+ # recovery step for shards that are stuck rather than just misplaced.
+ ai_text = ai_explain(UNCLEAR_ALLOCATION_SYSTEM_PROMPT, json.dumps(explain)[:4000])
+ response["display"] += f"\n\nAI analysis of the allocation explanation:\n{ai_text}"
+ return _offer_reindex(response, context)
+
+
+def _offer_clear_write_blocks(response, context, blocks):
+ listing = "\n".join(f" - {name} = {value}" for name, value in blocks.items())
+ response["display"] += (
+ f"\n\n[ROOT CAUSE FOUND] Cluster-wide write/index-creation block(s)\n\n{listing}\n\n"
+ "These silently prevent shards/indices from being allocated or written to, even while "
+ "the rest of the cluster looks healthy.\n\n"
+ "Would you like us to clear these, or will you clear them yourself?"
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "fix_write_blocks"
+ context["write_blocks"] = list(blocks.keys())
+ return response
+
+
+def _manual_clear_blocks_instructions(block_names):
+ lines = ",\n".join(f' "{name}": null' for name in block_names)
+ return (
+ "Run this against the indexer to clear the block(s):\n\n"
+ f" curl -XPUT -k -u admin: \"{INDEXER_URL}/_cluster/settings\" "
+ "-H 'Content-Type: application/json' -d'\n"
+ " {\n"
+ " \"persistent\": {\n"
+ f"{lines}\n"
+ " },\n"
+ " \"transient\": {\n"
+ f"{lines}\n"
+ " }\n"
+ " }'\n\n"
+ "Once you've run it, let us know."
+ )
+
+
+def _manual_replica_instructions(indices, recommended):
+ lines = "\n".join(
+ f" curl -k -u admin: -XPUT \"{INDEXER_URL}/{index_name}/_settings\" "
+ "-H 'Content-Type: application/json' -d"
+ f"'{{\"index\":{{\"number_of_replicas\":{recommended}}}}}'"
+ for index_name in indices
+ ) or " (none)"
+ return f"Run the following against the indexer:\n\n{lines}"
+
+
+def _offer_reindex(response, context):
+ indices = context.get("unassigned_indices", [])
+ listing = "\n".join(f" - {i}" for i in indices) or " (none)"
+ response["display"] += (
+ "\n\nIndices that were already stuck unassigned may still need to be reindexed to fully "
+ f"recover:\n{listing}\n\n"
+ "Reindexing keeps the data (backup, delete original, restore from backup, delete backup). "
+ "Would you like us to reindex these now?"
+ )
+ response["ask"] = ["Auto (reindex, keeps data)", "Manual", "Skip"]
+ context["stage"] = "reindex_method"
+ return response
+
+
+def _manual_reindex_instructions(indices):
+ listing = "\n".join(f" - {i}" for i in indices) or " (none)"
+ return (
+ "Reindex the affected indices one at a time (not all at once). Replace "
+ "with each index name below:\n\n"
+ f"Affected indices:\n{listing}\n\n"
+ "1. Back it up:\n\n"
+ " POST _reindex\n"
+ " {\n"
+ " \"source\": { \"index\": \"\" },\n"
+ " \"dest\": { \"index\": \"-backup\" }\n"
+ " }\n\n"
+ "2. Delete the original index:\n\n"
+ " DELETE /\n\n"
+ "3. Reindex from the backup:\n\n"
+ " POST _reindex\n"
+ " {\n"
+ " \"source\": { \"index\": \"-backup\" },\n"
+ " \"dest\": { \"index\": \"\" }\n"
+ " }\n\n"
+ "4. Delete the backup index:\n\n"
+ " DELETE /-backup\n\n"
+ "Once you've run it, let us know."
+ )
+
+
+def _auto_reindex(response, context):
+ indices = context.get("unassigned_indices", [])
+ results = []
+ for index_name in indices:
+ steps = reindex_for_mapping_conflict(index_name)
+ results.append((index_name, steps))
+
+ ok_count = sum(1 for _, steps in results if not steps.get("aborted_after"))
+ lines = []
+ for name, steps in results[:10]:
+ aborted_after = steps.get("aborted_after")
+ if not aborted_after:
+ lines.append(f" - {name}: OK")
+ else:
+ detail = (steps.get(aborted_after) or "(no response)")[:200]
+ lines.append(f" - {name}: stopped after '{aborted_after}' - nothing irreversible happened past that point. {detail}")
+ more = f"\n ... and {len(results) - 10} more" if len(results) > 10 else ""
+ response["display"] += (
+ f"\n\nReindexed {ok_count}/{len(results)} index(es) successfully, one at a time:\n"
+ + "\n".join(lines) + more
+ )
+ return _verify_after_reindex(response, context)
+
+
+def _verify_after_reindex(response, context):
+ sep = "\n\n" if response["display"] else ""
+ unassigned = get_unassigned_shards()
+ if unassigned:
+ response["display"] += f"{sep}[WARNING] {len(unassigned)} shard(s) are still unassigned after reindexing."
+ else:
+ response["display"] += f"{sep}[OK] No unassigned shards remain."
+ response["done"] = True
+ return response
diff --git a/integrations/wazuh-troubleshooting-tool/backend/use_cases/dashboard_error.py b/integrations/wazuh-troubleshooting-tool/backend/use_cases/dashboard_error.py
new file mode 100644
index 00000000..1d213ee1
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/use_cases/dashboard_error.py
@@ -0,0 +1,984 @@
+from executor import run_command
+from utils.fix_engine import FixEngine
+from utils.log_handler import LogHandler
+from utils.log_analyzer import LogAnalyzer
+from flows.ip_cert_flow import ip_cert_flow, STAGES as IP_CERT_STAGES
+from flows.dashboard_ip_cert_flow import dashboard_ip_cert_flow, STAGES as DASH_IP_CERT_STAGES
+
+
+def dashboard_error_flow(user_choice=None, context=None):
+
+ if context is None:
+ context = {}
+
+ response = {
+ "display": "",
+ "ask": [],
+ "done": False,
+ "context": context,
+ }
+
+ # -------------------------------------------------------------------------
+ # START
+ # -------------------------------------------------------------------------
+ if not context:
+ response["display"] = (
+ "When you get 'Wazuh dashboard is not ready yet' error it normally "
+ "indicates that the Wazuh dashboard cannot communicate with the "
+ "indexer.\n\n"
+ "How would you like to proceed?\n"
+ " auto → we check and fix everything for you step by step\n"
+ " manual → we give you all the steps to follow yourself"
+ )
+ response["ask"] = ["How would you like to proceed? (auto / manual)"]
+ context["stage"] = "start_choice"
+ return response
+
+ # -------------------------------------------------------------------------
+ # ROUTE TO ip_cert_flow (indexer checks: IP -> cert paths -> heap memory)
+ # -------------------------------------------------------------------------
+ if context.get("stage") in IP_CERT_STAGES:
+ result = ip_cert_flow(user_choice=user_choice, context=context)
+
+ # "handoff" fires when the last step (heap) is still "ongoing" -
+ # control returns here so the dashboard IP/cert flow can continue.
+ # We fold this step's own message into whatever comes next instead
+ # of letting it get silently dropped at this boundary.
+ if result.get("handoff"):
+ next_result = dashboard_error_flow(context=result["context"])
+ if result.get("display"):
+ next_result["display"] = result["display"] + "\n\n" + next_result["display"]
+ return next_result
+
+ return result
+
+ # -------------------------------------------------------------------------
+ # ROUTE TO dashboard_ip_cert_flow (dashboard checks: IP -> cert paths)
+ # -------------------------------------------------------------------------
+ if context.get("stage") in DASH_IP_CERT_STAGES:
+ result = dashboard_ip_cert_flow(user_choice=user_choice, context=context)
+
+ # "handoff" fires when the last step (dashboard cert paths) is
+ # still "ongoing" - hands off to log analysis (fetch_logs).
+ if result.get("handoff"):
+ next_result = dashboard_error_flow(context=result["context"])
+ if result.get("display"):
+ next_result["display"] = result["display"] + "\n\n" + next_result["display"]
+ return next_result
+
+ return result
+
+ # -------------------------------------------------------------------------
+ # START CHOICE
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "start_choice":
+
+ if user_choice and "manual" in user_choice.lower():
+ response["display"] = (
+ "Let's investigate the issue about the Wazuh Dashboard:\n\n"
+
+ "Step 1 — Make sure the Wazuh indexer service is up and running:\n"
+ " systemctl status wazuh-indexer\n\n"
+
+ "Step 2 — Check the dashboard configuration file:\n"
+ " /etc/wazuh-dashboard/opensearch_dashboards.yml\n\n"
+ " Make sure the indexer IP is correct:\n"
+ " opensearch.hosts: https://:9200\n\n"
+
+ " Run this to find the indexer IP:\n"
+ " head /etc/wazuh-indexer/opensearch.yml\n\n"
+
+ "Step 3 — Check certificate names and paths:\n"
+ " ls -lrt /etc/wazuh-dashboard/certs/\n"
+ " Ensure the paths and filenames match what is in the config.\n\n"
+
+ "Step 4 — Restart the dashboard service:\n"
+ " systemctl restart wazuh-dashboard\n"
+ " systemctl status wazuh-dashboard\n\n"
+
+ "Step 5 — Verify the dashboard can communicate with the indexer.\n"
+ "Run this from the dashboard server:\n"
+ " curl -XGET -k -u kibanaserver: "
+ "\"https://:9200/_cluster/health\"\n\n"
+
+ " If you get connection refused -> check firewall on port 9200.\n"
+ " If you see no output or auth error -> reset kibanaserver "
+ "password (Step 6).\n\n"
+
+ "Step 6 — Reset kibanaserver password if needed.\n"
+ "Password must be 8-64 chars, upper/lowercase, numbers, "
+ "symbol from .*+?-\n\n"
+
+ " /usr/share/wazuh-indexer/plugins/opensearch-security/tools/"
+ "wazuh-passwords-tool.sh -u kibanaserver -p ''\n\n"
+
+ " Note: If using AIO, passwords are updated automatically.\n\n"
+
+ " Then update the dashboard keystore:\n"
+ " echo | "
+ "/usr/share/wazuh-dashboard/bin/opensearch-dashboards-keystore "
+ "--allow-root add -f --stdin opensearch.password\n\n"
+
+ " Ref: https://documentation.wazuh.com/current/user-manual/"
+ "user-administration/password-management.html\n\n"
+
+ "Step 7 — If the issue still persists collect these logs:\n"
+ " journalctl -u wazuh-dashboard\n"
+ " cat /usr/share/wazuh-dashboard/data/wazuh/logs/wazuhapp.log "
+ "| grep -i -E 'error|warn'\n"
+ " cat /var/log/wazuh-indexer/wazuh-cluster.log "
+ "| grep -i -E 'error|warn'\n\n"
+
+ "Let us know the update for further assistance."
+ )
+
+ response["ask"] = ["Did this help? (resolved / need further assistance)"]
+ context["stage"] = "manual_followup"
+ return response
+
+ # auto chosen — ask how to determine the indexer status before restarting
+ response["display"] = (
+ "Let's start with the Wazuh indexer service."
+ )
+ response["ask"] = ["Restart Wazuh Indexer? (auto / inactive / active)"]
+ context["stage"] = "restart_step"
+ return response
+
+ # -------------------------------------------------------------------------
+ # RESTART STEP — determine indexer status, restart if needed
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "restart_step":
+
+ choice = (user_choice or "").lower().strip()
+
+ if "auto" in choice:
+ status = (run_command("systemctl is-active wazuh-indexer") or "").strip()
+ elif "inactive" in choice:
+ status = "inactive"
+ elif "active" in choice:
+ status = "active"
+ else:
+ response["display"] = "Please choose one: auto / inactive / active"
+ response["ask"] = ["Restart Wazuh Indexer? (auto / inactive / active)"]
+ return response
+
+ context["indexer_status"] = status
+ response["display"] = f"Indexer status: {status}\n\n"
+
+ if status != "active":
+ response["display"] += "The Wazuh indexer is not running. Restarting it now..."
+ new_status = FixEngine.restart_indexer_and_wait()
+ context["indexer_status"] = new_status
+ response["display"] += f"\n\nStatus after restart: {new_status.upper()}"
+
+ response["display"] += (
+ "\n\nLet's now go through the indexer checks: "
+ "IP address, certificate paths, and heap memory."
+ )
+ context["stage"] = "ip_check"
+
+ # Preserve this message — indexer_recovery_flow's own first
+ # response (the Step 1 permission question) would otherwise
+ # completely replace it here.
+ restart_msg = response["display"]
+ next_response = dashboard_error_flow(context=context)
+ next_response["display"] = restart_msg + "\n\n" + next_response["display"]
+ return next_response
+
+ # -------------------------------------------------------------------------
+ # MANUAL FOLLOW-UP
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "manual_followup":
+
+ if user_choice and "resolved" in user_choice.lower():
+ response["display"] = "Great! Glad the issue is resolved."
+ response["done"] = True
+ return response
+
+ response["display"] = (
+ "Let's dig deeper.\n\n"
+ "Have you checked the indexer status yet?\n"
+ "If not, I can check it for you right now."
+ )
+
+ response["ask"] = ["Indexer status? (check / it's active / it's inactive)"]
+ context["stage"] = "indexer_status_check"
+ return response
+
+ # -------------------------------------------------------------------------
+ # INDEXER STATUS CHECK
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "indexer_status_check":
+
+ if user_choice and "check" in user_choice.lower():
+ status = (run_command("systemctl is-active wazuh-indexer") or "").strip()
+ context["indexer_status"] = status
+ response["display"] = f"Indexer status: {status}"
+
+ elif user_choice and "inactive" in user_choice.lower():
+ status = "inactive"
+ context["indexer_status"] = status
+ response["display"] = "Understood — indexer is inactive."
+
+ else:
+ status = "active"
+ context["indexer_status"] = status
+ response["display"] = "Understood — indexer is active."
+
+ if status != "active":
+ response["display"] += "\n\nThe indexer is not running. Restarting wazuh-indexer now..."
+ new_status = FixEngine.restart_indexer_and_wait()
+ context["indexer_status"] = new_status
+ response["display"] += f"\n\nStatus after restart: {new_status.upper()}"
+
+ response["display"] += (
+ "\n\nLet's now go through the indexer checks: "
+ "IP address, certificate paths, and heap memory."
+ )
+ context["stage"] = "ip_check"
+
+ restart_msg = response["display"]
+ next_response = dashboard_error_flow(context=context)
+ next_response["display"] = restart_msg + "\n\n" + next_response["display"]
+ return next_response
+
+ # -------------------------------------------------------------------------
+ # FETCH LOGS
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "fetch_logs":
+
+ response["display"] = (
+ "Would you like me to fetch the dashboard and indexer logs, "
+ "or will you run the commands yourself?"
+ )
+
+ response["ask"] = ["Fetch logs? (auto / manual / no)"]
+ context["stage"] = "logs_action"
+ return response
+
+ # -------------------------------------------------------------------------
+ # LOGS ACTION
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "logs_action":
+
+ chose_auto = user_choice and "auto" in user_choice.lower()
+ chose_manual = user_choice and "manual" in user_choice.lower()
+
+ if chose_auto:
+
+ logs = LogHandler.get_indexer_logs(1)
+ clean = LogHandler.clean_logs(logs)
+
+ context["logs"] = logs
+ response["display"] = f"Recent indexer logs:\n\n{clean}"
+ context["stage"] = "logs_analyze"
+
+ return dashboard_error_flow(context=context)
+
+ elif chose_manual:
+
+ response["display"] = (
+ "Run these and paste the output back:\n\n"
+
+ " journalctl -u wazuh-dashboard\n\n"
+
+ " cat /usr/share/wazuh-dashboard/data/wazuh/logs/wazuhapp.log "
+ "| grep -i -E 'error|warn'\n\n"
+
+ " cat /var/log/wazuh-indexer/wazuh-cluster.log "
+ "| grep -i -E 'error|warn'"
+ )
+
+ response["ask"] = ["Paste the log output here"]
+ context["stage"] = "logs_paste"
+
+ return response
+
+ else:
+ response["display"] = "Skipping log check."
+ context["stage"] = "jvm_check"
+
+ return dashboard_error_flow(context=context)
+
+ # -------------------------------------------------------------------------
+ # LOGS PASTE
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "logs_paste":
+
+ context["logs"] = user_choice or ""
+ context["stage"] = "logs_analyze"
+
+ return dashboard_error_flow(context=context)
+
+ # -------------------------------------------------------------------------
+ # ANALYZE LOGS
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "logs_analyze":
+
+ logs = context.get("logs") or ""
+ issues = LogAnalyzer.get_issues(logs)
+
+ context["issues"] = issues
+
+ if not issues:
+ response["display"] = "No known issues found in the logs."
+ context["stage"] = "jvm_check"
+
+ return dashboard_error_flow(context=context)
+
+ found_lines = []
+
+ for issue in issues:
+
+ if issue == "init":
+ found_lines.append(
+ "[INIT] Indexer security not yet initialized."
+ )
+
+ elif issue == "heap":
+ found_lines.append(
+ "[HEAP] Memory/heap issue detected."
+ )
+
+ elif issue == "auth":
+ found_lines.append(
+ "[AUTH] Authentication failed for kibanaserver. "
+ "Please flag this to your team for a password reset."
+ )
+
+ elif issue == "watermark":
+ found_lines.append(
+ "[DISK] Disk watermark exceeded. "
+ "Free up disk space or expand storage manually.\n"
+ "Check: df -h"
+ )
+
+ elif issue == "permission":
+ found_lines.append(
+ "[PERMISSION] Insecure file permissions on indexer config. "
+ "Please flag this to your team."
+ )
+
+ elif issue == "dashboard_connection_refused":
+ found_lines.append(
+ "[CONNECTION REFUSED] Connection to :9200 was refused. "
+ "Please check whether the Wazuh indexer service is running."
+ )
+
+ response["display"] = (
+ f"Found {len(issues)} issue(s) in the logs:\n\n"
+ + "\n\n".join(found_lines)
+ )
+
+ if "init" in issues:
+ context["stage"] = "init_check"
+
+ elif "heap" in issues:
+ context["stage"] = "jvm_check"
+
+ elif "dashboard_connection_refused" in issues:
+ context["stage"] = "connection_refused_indexer_check"
+
+ else:
+ context["stage"] = "dashboard_status"
+
+ return dashboard_error_flow(context=context)
+
+ # -------------------------------------------------------------------------
+ # INIT CHECK
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "init_check":
+
+ response["display"] = (
+ "The logs show the indexer security is not yet initialized.\n\n"
+ "Is this a new or existing installation?"
+ )
+
+ response["ask"] = ["New or existing? (new / existing)"]
+ context["stage"] = "init_action"
+
+ return response
+
+ # -------------------------------------------------------------------------
+ # INIT ACTION
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "init_action":
+
+ if user_choice and "new" in user_choice.lower():
+
+ response["display"] = (
+ "Since this is a new installation the indexer security "
+ "needs to be initialized. Would you like me to run it?"
+ )
+
+ response["ask"] = ["Run security init? (auto / manual)"]
+ context["stage"] = "init_run"
+
+ return response
+
+ else:
+
+ response["display"] = (
+ "Since this is an existing installation, "
+ "the initialization issue is unexpected.\n\n"
+ "Let me check step by step starting with the IP configuration."
+ )
+
+ context["stage"] = "ip_check"
+
+ return dashboard_error_flow(context=context)
+
+ # -------------------------------------------------------------------------
+ # RUN SECURITY INIT
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "init_run":
+
+ if user_choice and "auto" in user_choice.lower():
+
+ out = run_command(FixEngine.init_command()) or ""
+ response["display"] = f"Security init output:\n{out}"
+
+ else:
+
+ response["display"] = (
+ "Run:\n\n"
+ f" {FixEngine.init_command()}\n\n"
+ "Then restart:\n"
+ " systemctl restart wazuh-indexer"
+ )
+
+ context["stage"] = "jvm_check"
+
+ return dashboard_error_flow(context=context)
+
+ # -------------------------------------------------------------------------
+ # JVM HEAP CHECK (legacy — triggered from log analysis path)
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "jvm_check":
+
+ current = run_command(
+ "grep -E '^-Xms|^-Xmx' /etc/wazuh-indexer/jvm.options"
+ ) or "(could not read)"
+
+ total_kb = run_command(
+ "grep MemTotal /proc/meminfo | awk '{print $2}'"
+ ) or ""
+
+ total_gb = round(int(total_kb.strip()) / 1024 / 1024)
+ heap_gb = max(1, total_gb // 2)
+
+ response["display"] = (
+ f"Current JVM heap settings:\n"
+ f"{current}\n\n"
+ f"Total RAM: {total_gb} GB\n\n"
+ f"Recommended (50% of RAM):\n"
+ f" -Xms{heap_gb}g\n"
+ f" -Xmx{heap_gb}g\n\n"
+ f"{FixEngine.heap_steps()}\n\n"
+ "Would you like to fix the heap settings?"
+ )
+
+ response["ask"] = ["Fix heap? (auto / manual / no)"]
+ context["recommended_heap"] = heap_gb
+ context["stage"] = "jvm_fix"
+
+ return response
+
+ # -------------------------------------------------------------------------
+ # JVM FIX (legacy — triggered from log analysis path)
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "jvm_fix":
+
+ if user_choice and "auto" in user_choice.lower():
+
+ heap_gb = context.get("recommended_heap", 2)
+ result = FixEngine.fix_jvm_heap(heap_gb)
+
+ response["display"] = (
+ "Edited /etc/wazuh-indexer/jvm.options\n\n"
+ f"Restarted wazuh-indexer (status: {result['status'].upper()}).\n\n"
+ "Current JVM heap settings:\n"
+ f"{result['updated']}"
+ )
+
+ response["ask"] = ["Is the dashboard issue fixed? (fixed / ongoing)"]
+ context["stage"] = "post_heap_check"
+
+ return response
+
+ elif user_choice and "manual" in user_choice.lower():
+
+ response["display"] = FixEngine.heap_steps()
+ response["ask"] = ["Is the dashboard issue fixed? (fixed / ongoing)"]
+ context["stage"] = "post_heap_check"
+
+ return response
+
+ else:
+
+ response["display"] = "Skipped heap fix."
+ context["stage"] = "dashboard_status"
+
+ return dashboard_error_flow(context=context)
+
+ # -------------------------------------------------------------------------
+ # POST HEAP CHECK (legacy — triggered from log analysis path)
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "post_heap_check":
+
+ if user_choice.lower().strip() == "fixed":
+
+ response["display"] = "Great! The issue is resolved."
+ response["done"] = True
+
+ return response
+
+ elif user_choice.lower().strip() == "ongoing":
+
+ response["display"] = (
+ "The issue is still ongoing.\n"
+ "Let's fetch the logs for deeper analysis."
+ )
+
+ context["stage"] = "fetch_logs"
+
+ return dashboard_error_flow(context=context)
+
+ # -------------------------------------------------------------------------
+ # DASHBOARD STATUS + LOGS
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "dashboard_status":
+
+ status = FixEngine.status_dashboard().strip()
+ response["display"] = f"Dashboard status: {status}\n\n"
+
+ if status == "active":
+
+ response["display"] += (
+ "The Wazuh dashboard is running.\n"
+ "Please open your browser and check the UI."
+ )
+
+ response["ask"] = ["Is the issue resolved? (resolved / not resolved)"]
+ context["stage"] = "final_status_check"
+
+ return response
+
+ indexer_logs = LogHandler.get_indexer_logs(1)
+ dashboard_logs = LogHandler.get_dashboard_logs(1)
+
+ clean_indexer = LogHandler.clean_logs(indexer_logs)
+ clean_dashboard = LogHandler.clean_logs(dashboard_logs)
+
+ response["display"] += (
+ "The dashboard is still not active.\n\n"
+
+ "--- Connectivity check ---\n"
+
+ "Run from the dashboard server:\n"
+
+ " curl -XGET -k -u kibanaserver: "
+ "\"https://:9200/_cluster/health\"\n\n"
+
+ " Connection refused → check firewall on port 9200.\n"
+
+ " Auth error → reset kibanaserver password:\n"
+
+ " /usr/share/wazuh-indexer/plugins/opensearch-security/tools/"
+ "wazuh-passwords-tool.sh -u kibanaserver -p ''\n\n"
+
+ " Then update keystore:\n"
+
+ " echo | "
+ "/usr/share/wazuh-dashboard/bin/opensearch-dashboards-keystore "
+ "--allow-root add -f --stdin opensearch.password\n\n"
+
+ " Ref: https://documentation.wazuh.com/current/user-manual/"
+ "user-administration/password-management.html\n\n"
+
+ "--- Recent indexer logs ---\n"
+ f"{clean_indexer}\n\n"
+
+ "--- Recent dashboard logs ---\n"
+ f"{clean_dashboard}\n\n"
+
+ "If the issue still persists share the above on:\n"
+ " https://wazuh.com/community/"
+ )
+
+ response["done"] = True
+ return response
+
+
+ # -------------------------------------------------------------------------
+ # DASHBOARD STATUS + LOGS
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "dashboard_status_logs":
+
+ status = (FixEngine.status_dashboard() or "").strip()
+ context["dashboard_status"] = status
+
+ response["display"] = f"Dashboard status: {status or 'unknown'}\n\n"
+
+ if status == "active":
+ response["display"] += (
+ "The Wazuh dashboard is running.\n"
+ "Please open your browser and check the UI."
+ )
+
+ response["ask"] = [
+ "Is the issue resolved? (resolved / not resolved)"
+ ]
+
+ context["stage"] = "logs_action_dashboard"
+ response["context"] = context
+
+ return response
+
+ response["display"] += (
+ "The Wazuh dashboard is not active.\n\n"
+ "Let's check the dashboard logs."
+ )
+
+ logs = LogHandler.get_dashboard_logs(1)
+ clean = LogHandler.clean_logs(logs)
+
+ context["logs"] = logs
+ context["stage"] = "logs_analyze_dashboard"
+
+ response["display"] += (
+ f"\n\nRecent dashboard logs:\n\n{clean}"
+ )
+ response["ask"] = ["Continue to log analysis? (yes)"]
+ response["context"] = context
+ return response
+
+
+ # -------------------------------------------------------------------------
+ # DASHBOARD RESOLUTION CHECK
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "logs_action_dashboard":
+
+ choice = (user_choice or "").lower().strip()
+
+ if choice == "resolved":
+ response["display"] = "Glad to know the issue is resolved."
+ response["done"] = True
+ response["context"] = context
+
+ return response
+
+ if choice == "not resolved":
+ logs = LogHandler.get_dashboard_logs(1)
+ clean = LogHandler.clean_logs(logs)
+
+ context["logs"] = logs
+ context["stage"] = "logs_analyze_dashboard"
+
+ response["display"] = (
+ "The issue is still not resolved.\n\n"
+ f"Recent dashboard logs:\n\n{clean}"
+ )
+ response["ask"] = ["Continue? (yes)"]
+ response["context"] = context
+ return response
+
+ response["display"] = "Please choose: resolved / not resolved"
+ response["ask"] = [
+ "Is the issue resolved? (resolved / not resolved)"
+ ]
+
+ response["context"] = context
+ return response
+
+
+ # -------------------------------------------------------------------------
+ # ANALYZE DASHBOARD LOGS
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "logs_analyze_dashboard":
+
+ logs = context.get("logs") or ""
+ issues = LogAnalyzer.get_issues(logs)
+
+ context["issues"] = issues
+
+ if not issues:
+ # Logs are clean but issue persists — move to connectivity/password check
+ response["display"] = (
+ "No known issues found in the dashboard logs.\n\n"
+ "Since the dashboard is running but the issue persists, "
+ "let's check the connectivity between the dashboard and the indexer."
+ )
+ response["ask"] = ["Check connectivity? (yes)"]
+ context["stage"] = "dashboard_status"
+ response["context"] = context
+ return response
+
+ found_lines = []
+
+ for issue in issues:
+
+ if issue == "init":
+ found_lines.append(
+ "[INIT] Indexer security not yet initialized."
+ )
+
+ elif issue == "heap":
+ found_lines.append(
+ "[HEAP] Memory/heap issue detected."
+ )
+
+ elif issue == "auth":
+ found_lines.append(
+ "[AUTH] Authentication failed for kibanaserver. "
+ "Please flag this to your team for a password reset."
+ )
+
+ elif issue == "watermark":
+ found_lines.append(
+ "[DISK] Disk watermark exceeded. "
+ "Free up disk space or expand storage manually.\n"
+ "Check: df -h"
+ )
+
+ elif issue == "permission":
+ found_lines.append(
+ "[PERMISSION] Insecure file permissions on indexer config. "
+ "Please flag this to your team."
+ )
+
+ elif issue == "dashboard_connection_refused":
+ found_lines.append(
+ "[CONNECTION REFUSED] Connection to :9200 was refused. "
+ "Please check whether the Wazuh indexer service is running."
+ )
+
+ response["display"] = (
+ f"Found {len(issues)} issue(s) in the logs:\n\n"
+ + "\n\n".join(found_lines)
+ )
+
+ if "init" in issues:
+ context["stage"] = "init_check"
+ response["ask"] = ["Continue to initialization check? (yes)"]
+
+ elif "heap" in issues:
+ context["stage"] = "jvm_check"
+ response["ask"] = ["Continue to heap check? (yes)"]
+
+ elif "dashboard_connection_refused" in issues:
+ context["stage"] = "connection_refused_indexer_check"
+ response["ask"] = ["Continue? (yes)"]
+
+ else:
+ response["display"] += (
+ "\n\nThese issues need manual review.\n\n"
+ "If the issue still persists, please contact the Wazuh community:\n"
+ "https://wazuh.com/community/"
+ )
+ response["done"] = True
+
+ response["context"] = context
+ return response
+
+ # -------------------------------------------------------------------------
+ # CONNECTION REFUSED: INDEXER CHECK
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "connection_refused_indexer_check":
+
+ response["display"] = (
+ "The dashboard logs show that connection to the Wazuh indexer on port 9200 was refused.\n\n"
+ "This usually means the Wazuh indexer service is stopped, unhealthy, or not reachable."
+ )
+
+ response["ask"] = [
+ "We have already checked the status. Should we do that again? "
+ "(check / it's active / it's inactive / no)"
+ ]
+
+ context["stage"] = "connection_refused_indexer_status"
+ response["context"] = context
+
+ return response
+
+
+ # -------------------------------------------------------------------------
+ # CONNECTION REFUSED: INDEXER STATUS
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "connection_refused_indexer_status":
+
+ choice = (user_choice or "").lower().strip()
+
+ if "check" in choice:
+ status = (
+ run_command("systemctl is-active wazuh-indexer") or ""
+ ).strip()
+
+ elif "inactive" in choice:
+ status = "inactive"
+
+ elif "active" in choice:
+ status = "active"
+
+ elif choice == "no":
+ response["display"] = (
+ "Okay.\n\n"
+ "Please check the Wazuh indexer and dashboard logs for newer errors.\n\n"
+ "If there are no newer errors and the issue still persists, "
+ "I recommend taking help from the Wazuh community:\n"
+ "https://wazuh.com/community/"
+ )
+
+ response["context"] = context
+ return response
+
+ else:
+ response["display"] = (
+ "Please choose one option: check / it's active / it's inactive / no"
+ )
+
+ response["ask"] = [
+ "Should we check the indexer status again? "
+ "(check / it's active / it's inactive / no)"
+ ]
+
+ response["context"] = context
+ return response
+
+ context["indexer_status"] = status
+
+ if status != "active":
+ response["display"] = (
+ f"Indexer status: {status or 'unknown'}\n\n"
+ "The Wazuh indexer service is inactive. Restarting it now..."
+ )
+ new_status = FixEngine.restart_indexer_and_wait()
+ context["indexer_status"] = new_status
+ response["display"] += (
+ f"\n\nStatus after restart: {new_status.upper()}\n\n"
+ "Let's now go through the indexer checks: "
+ "IP address, certificate paths, and heap memory."
+ )
+ context["stage"] = "ip_check"
+
+ restart_msg = response["display"]
+ next_response = dashboard_error_flow(context=context)
+ next_response["display"] = restart_msg + "\n\n" + next_response["display"]
+ return next_response
+
+ # indexer is active but dashboard still can't connect
+ response["display"] = (
+ f"Indexer status: {status}\n\n"
+ "The Wazuh indexer is active but the dashboard still cannot reach it "
+ "on port 9200.\n\n"
+ "Please check: firewall rules on port 9200, the dashboard's "
+ "opensearch.hosts IP, and network connectivity between dashboard "
+ "and indexer.\n\n"
+ "If everything looks correct and the issue still persists:\n"
+ " https://wazuh.com/community/"
+ )
+ response["done"] = True
+ response["context"] = context
+ return response
+
+ # -------------------------------------------------------------------------
+ # FINAL STATUS CHECK
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "final_status_check":
+ choice = (user_choice or "").lower().strip()
+
+ if choice == "resolved":
+
+ response["display"] = "Great! Glad the issue is resolved."
+ response["done"] = True
+ return response
+
+ # not resolved — fetch and analyse logs
+ dashboard_logs = LogHandler.get_dashboard_logs(1)
+ clean_dashboard = LogHandler.clean_logs(dashboard_logs)
+
+ issues = LogAnalyzer.get_issues(dashboard_logs or "")
+ context["issues"] = issues
+
+ response["display"] = (
+ "Let's dig into the logs.\n\n"
+ "--- Recent dashboard logs ---\n"
+ f"{clean_dashboard}\n\n"
+ )
+
+ if issues:
+ found_lines = []
+
+ for issue in issues:
+
+ if issue == "init":
+ found_lines.append(
+ "[INIT] Indexer security not yet initialized."
+ )
+ elif issue == "heap":
+ found_lines.append(
+ "[HEAP] Memory/heap issue detected."
+ )
+ elif issue == "auth":
+ found_lines.append(
+ "[AUTH] Authentication failed for kibanaserver — "
+ "password reset required.\n\n"
+
+ " /usr/share/wazuh-indexer/plugins/opensearch-security/tools/"
+ "wazuh-passwords-tool.sh -u kibanaserver -p ''\n\n"
+
+ " Then update the dashboard keystore:\n"
+
+ " echo | "
+ "/usr/share/wazuh-dashboard/bin/opensearch-dashboards-keystore "
+ "--allow-root add -f --stdin opensearch.password\n\n"
+
+ " Restart:\n"
+ " systemctl restart wazuh-dashboard"
+ )
+ elif issue == "watermark":
+ found_lines.append(
+ "[DISK] Disk watermark exceeded — "
+ "free up disk space or expand storage.\n"
+ " Check: df -h"
+ )
+ elif issue == "permission":
+ found_lines.append(
+ "[PERMISSION] Insecure file permissions on indexer config."
+ )
+
+ response["display"] += (
+ f"Issues detected ({len(issues)}):\n\n"
+ + "\n\n".join(found_lines)
+ )
+
+ else:
+ response["display"] += (
+ "No known issues detected in the logs.\n\n"
+
+ "If the issue still persists share the above on:\n"
+ " https://wazuh.com/community/"
+ )
+
+ response["ask"] = ["Still not resolved? (resolved / need more help)"]
+ context["stage"] = "final_escalate"
+ return response
+
+ # -------------------------------------------------------------------------
+ # FINAL ESCALATE
+ # -------------------------------------------------------------------------
+ if context.get("stage") == "final_escalate":
+
+ if user_choice and "resolved" in user_choice.lower():
+
+ response["display"] = "Great! Glad the issue is resolved."
+ response["done"] = True
+ return response
+
+ response["display"] = (
+ "The issue needs further investigation.\n\n"
+ "Please reach out to the Wazuh community for deeper support:\n"
+ " https://wazuh.com/community/"
+ )
+ response["done"] = True
+ return response
diff --git a/integrations/wazuh-troubleshooting-tool/backend/use_cases/filebeat_error.py b/integrations/wazuh-troubleshooting-tool/backend/use_cases/filebeat_error.py
new file mode 100644
index 00000000..0aec49a8
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/use_cases/filebeat_error.py
@@ -0,0 +1,305 @@
+"""
+Use case: 'Filebeat Not Working' - having an issue in `filebeat test output`.
+
+Standalone Filebeat-only troubleshooting card. Unlike
+use_cases/no_alerts_are_showing.py's Step 3, this does NOT run as a fixed
+"Step 1 / Step 2" script - each failure category needs a different sequence
+(an unsupported-version fix loops back through the output test, a TLS/cert
+fix does the same, a mapping issue hands off elsewhere entirely), so each
+branch narrates only what it actually did.
+
+Sequence:
+ 1. `filebeat test output` (after confirming the service is up).
+ 2. If it fails, classify why (utils/filebeat_utils.classify_filebeat_failure)
+ and offer the matching auto/manual fix.
+ 3. If it succeeds, still check the Filebeat log - a clean connectivity
+ test doesn't rule out something like a field-mapping error that only
+ shows up there (or as a dashboard shard-failure).
+ 4. If a mapping/template signature is found (either in the failed test
+ output or in the log), hand off to the dedicated "Filebeat Mapping
+ Issue" card (use_cases/mapping_issue.py) instead of trying to fix
+ templates here.
+ 5. If nothing is wrong on either check, point the user at the official
+ Wazuh community for further help.
+
+Reuses the same low-level Filebeat/cert helpers as flows/filebeat_flow.py
+(utils/filebeat_utils.py, utils/cert_utils.py) - no Filebeat logic is
+reimplemented here, only the narration/sequencing is different.
+"""
+
+from utils.service_utils import get_service_status, start_service_and_wait
+from utils.filebeat_utils import (
+ run_filebeat_output_test, get_filebeat_log_errors, classify_filebeat_failure,
+ fix_unsupported_filebeat_version, manual_unsupported_version_instructions,
+)
+from utils.cert_utils import regenerate_and_redeploy_certs, manual_cert_redeploy_instructions
+from utils.ai_utils import ai_explain
+
+WAZUH_COMMUNITY_URL = "https://wazuh.com/community/"
+
+MAPPING_ISSUE_KEYWORDS = [
+ "illegal_argument_exception",
+ "mapper_parsing_exception",
+ "strict_dynamic_mapping_exception",
+ "not optimised for operations that require per-document field data",
+ "use a keyword field instead",
+ "failed to parse field",
+ "mapping conflict",
+ "cannot be changed from type",
+]
+
+UNKNOWN_FAILURE_SYSTEM_PROMPT = (
+ "You are a Wazuh Filebeat troubleshooting expert. You'll be given the output of "
+ "'filebeat test output' plus recent Filebeat log error/warning lines. In 3-4 short "
+ "sentences: state the most likely root cause and the single most useful next command or "
+ "config fix. Be specific to what's actually in the output - don't give generic advice."
+)
+
+
+def _looks_like_mapping_issue(text):
+ lowered = (text or "").lower()
+ return any(kw in lowered for kw in MAPPING_ISSUE_KEYWORDS)
+
+
+def _mapping_handoff(response):
+ response["display"] += (
+ "\n\n[LIKELY ROOT CAUSE] Field-mapping / index-template issue\n\n"
+ "This looks like a mismatch between a field's data type and the Wazuh Indexer's "
+ "index template rather than a Filebeat connectivity problem - fixing it means "
+ "checking/reinstalling the Wazuh index template, not restarting Filebeat.\n\n"
+ "Please continue with the \"Filebeat Mapping Issue\" card in the Troubleshooting "
+ "Library - it checks the live index template, reinstalls the official one if it's "
+ "missing or overridden, and reindexes any indices already affected."
+ )
+ response["done"] = True
+ return response
+
+
+def filebeat_error_flow(user_choice=None, context=None):
+ if context is None:
+ context = {}
+
+ response = {"display": "", "ask": [], "done": False, "context": context}
+ choice = (user_choice or "").strip().lower()
+
+ # START
+ if not context:
+ response["display"] = (
+ "Let's check the output of the command `filebeat test output` to see whether "
+ "Filebeat can reach the Wazuh Indexer.\n\n"
+ "How would you like to check this?\n\n"
+ " Auto - We perform all checks automatically.\n"
+ " Manual - We provide the commands, and you run them and share the output."
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "method"
+ return response
+
+ stage = context.get("stage")
+
+ if stage == "method":
+ if "manual" in choice:
+ response["display"] = (
+ "First, check whether the Filebeat service is running:\n\n"
+ " systemctl status filebeat\n\n"
+ "If it's active, run the output test:\n\n"
+ " filebeat test output\n\n"
+ "Did it succeed?"
+ )
+ response["ask"] = ["Yes, it succeeded", "No, it failed"]
+ context["stage"] = "manual_result"
+ return response
+ return _check_service_and_test(response, context)
+
+ if stage == "manual_result":
+ if "yes" in choice or "succeeded" in choice:
+ return _check_logs_after_success(response, context, prefix="[OK] Confirmed - the output test succeeded.")
+ # Self-reported failure - get the real output/logs rather than trusting the report.
+ return _check_service_and_test(response, context)
+
+ if stage == "fix_service_start":
+ if "auto" in choice:
+ status = start_service_and_wait("filebeat")
+ elif choice == "done":
+ status = get_service_status("filebeat")
+ elif "manual" in choice:
+ response["display"] = "Run: systemctl start filebeat"
+ response["ask"] = ["Done"]
+ context["stage"] = "fix_service_start"
+ return response
+ else:
+ response["ask"] = ["Auto", "Manual"]
+ return response
+
+ if status != "active":
+ response["display"] = (
+ "[ROOT CAUSE FOUND] Filebeat failed to start\n\n"
+ "Filebeat did not come up, so it can't ship alerts to the indexer at all.\n\n"
+ "Manual fix:\nCheck `journalctl -u filebeat` and /var/log/filebeat/filebeat for startup errors."
+ )
+ response["done"] = True
+ return response
+ return _run_test_and_branch(response, context, prefix="[OK] Filebeat is running.\n\n")
+
+ if stage == "fix_version_choice":
+ return _apply_fix(
+ response, context, choice,
+ auto_fn=fix_unsupported_filebeat_version,
+ manual_instructions=manual_unsupported_version_instructions(),
+ manual_wait_stage="fix_version_manual_wait",
+ issue_label="unsupported Filebeat version",
+ )
+
+ if stage == "fix_version_manual_wait":
+ return _run_test_and_branch(response, context, prefix="")
+
+ if stage == "fix_tls_choice":
+ return _apply_fix(
+ response, context, choice,
+ auto_fn=lambda: regenerate_and_redeploy_certs(),
+ manual_instructions=manual_cert_redeploy_instructions(),
+ manual_wait_stage="fix_tls_manual_wait",
+ issue_label="TLS/certificate error",
+ )
+
+ if stage == "fix_tls_manual_wait":
+ return _run_test_and_branch(response, context, prefix="")
+
+ response["display"] = "Invalid stage."
+ response["done"] = True
+ return response
+
+
+# ---------------------------------------------------------------------------
+def _check_service_and_test(response, context):
+ response["display"] += ("\n" if response["display"] else "") + "Checking whether the Filebeat service is running..."
+ status = get_service_status("filebeat")
+ if status != "active":
+ response["display"] += "\n[WARNING] Filebeat is not running."
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "fix_service_start"
+ return response
+ response["display"] += "\n[OK] Filebeat is running."
+ return _run_test_and_branch(response, context, prefix="")
+
+
+def _run_test_and_branch(response, context, prefix=""):
+ test = run_filebeat_output_test()
+ response["display"] += f"\n{prefix}Running `filebeat test output`...\n{test['raw']}\n"
+
+ if not test["ok"]:
+ return _diagnose_failure(response, context, test["raw"])
+
+ return _check_logs_after_success(response, context, prefix="[OK] The output test succeeded.")
+
+
+def _check_logs_after_success(response, context, prefix=""):
+ # A clean output test only proves connectivity - it doesn't rule out
+ # something like a field-mapping error that only shows up in the log
+ # (or as a dashboard shard-failure popup), so check the log even when
+ # the test itself passed.
+ response["display"] += f"\n{prefix}\n\nChecking the Filebeat log for anything the connectivity test wouldn't catch..."
+ errors = get_filebeat_log_errors()
+
+ if not errors.strip():
+ response["display"] += (
+ "\n[OK] No error/warning lines found in the Filebeat log either - there's no "
+ "further automated diagnosis we can run from here. If you're still seeing an "
+ f"issue, please reach out to the official Wazuh community:\n{WAZUH_COMMUNITY_URL}"
+ )
+ response["done"] = True
+ return response
+
+ if _looks_like_mapping_issue(errors):
+ response["display"] += f"\n\nRecent Filebeat log errors:\n{errors}"
+ return _mapping_handoff(response)
+
+ explanation = ai_explain(UNKNOWN_FAILURE_SYSTEM_PROMPT, errors)
+ response["display"] += (
+ f"\n\n[WARNING] Found error/warning lines in the Filebeat log even though the "
+ f"connectivity test passed:\n{errors}\n\nAI analysis:\n{explanation}\n\n"
+ f"If this doesn't resolve it, please reach out to the official Wazuh community:\n{WAZUH_COMMUNITY_URL}"
+ )
+ response["done"] = True
+ return response
+
+
+def _diagnose_failure(response, context, test_raw):
+ errors = get_filebeat_log_errors()
+ combined = f"{test_raw}\n{errors}"
+
+ if _looks_like_mapping_issue(combined):
+ response["display"] += f"\nRecent Filebeat log errors:\n{errors if errors else '(none found)'}"
+ return _mapping_handoff(response)
+
+ category = classify_filebeat_failure(test_raw, errors)
+
+ if category == "indexer_unreachable":
+ response["display"] += (
+ "\n[WARNING] Filebeat cannot reach the Wazuh Indexer. This isn't a Filebeat "
+ "problem by itself - please use the \"Cluster Health Issues\" card to check the "
+ "Wazuh Indexer directly."
+ )
+ response["done"] = True
+ return response
+
+ if category == "unsupported_version":
+ context["stage"] = "fix_version_choice"
+ response["display"] += (
+ "\n[ISSUE] This looks like an unsupported Filebeat version. Wazuh is only "
+ "compatible with Filebeat-OSS 7.10.2 - a newer version fails with errors like "
+ "'invalid_index_name_exception' on the _license index.\n\n"
+ "Would you like us to fix this automatically, or fix it yourself?"
+ )
+ response["ask"] = ["Auto", "Manual"]
+ return response
+
+ if category == "tls_cert_error":
+ context["stage"] = "fix_tls_choice"
+ response["display"] += (
+ "\n[ISSUE] This looks like a TLS/certificate error. The fix is to regenerate the "
+ "certificates and redeploy them to the Wazuh Indexer, Filebeat, and Wazuh Dashboard.\n\n"
+ "Would you like us to fix this automatically, or fix it yourself?"
+ )
+ response["ask"] = ["Auto", "Manual"]
+ return response
+
+ # auth_failure / unknown - no scripted fix, surface what we know and stop.
+ explanation = ai_explain(UNKNOWN_FAILURE_SYSTEM_PROMPT, combined) if errors.strip() else \
+ "No additional error/warning lines found in the Filebeat log."
+ label = "authentication failure" if category == "auth_failure" else "an unrecognized error"
+ response["display"] += (
+ f"\n[WARNING] The output test failed with what looks like {label}.\n\n"
+ f"Recent Filebeat log errors:\n{errors if errors else '(none found)'}\n\n"
+ f"AI analysis:\n{explanation}\n\n"
+ f"If this doesn't resolve it, please reach out to the official Wazuh community:\n{WAZUH_COMMUNITY_URL}"
+ )
+ response["done"] = True
+ return response
+
+
+def _apply_fix(response, context, choice, auto_fn, manual_instructions, manual_wait_stage, issue_label):
+ if "manual" in choice:
+ context["stage"] = manual_wait_stage
+ response["display"] = manual_instructions + "\n\nLet us know once you've made the change."
+ response["ask"] = ["Done"]
+ return response
+
+ if "auto" not in choice:
+ response["ask"] = ["Auto", "Manual"]
+ return response
+
+ result = auto_fn()
+ if result.get("ok"):
+ return _run_test_and_branch(
+ response, context,
+ prefix=f"Fixed the {issue_label} automatically.\n{result.get('log', '')}\n\n",
+ )
+
+ response["display"] = (
+ f"[ROOT CAUSE FOUND] Could not auto-fix the {issue_label}\n\n"
+ f"{result.get('log', '')}\n\n"
+ "Manual fix:\n" + manual_instructions
+ )
+ response["done"] = True
+ return response
diff --git a/integrations/wazuh-troubleshooting-tool/backend/use_cases/indexing_error.py b/integrations/wazuh-troubleshooting-tool/backend/use_cases/indexing_error.py
new file mode 100644
index 00000000..9bd0b071
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/use_cases/indexing_error.py
@@ -0,0 +1,116 @@
+from executor import run_command
+import time
+from config import INDEXER_USERNAME, INDEXER_PASSWORD, INDEXER_URL
+
+def indexing_error_flow(user_choice=None, context=None):
+ if context is None:
+ context = {}
+
+ response = {
+ "display": "",
+ "ask": [],
+ "done": False,
+ "context": context,
+ }
+
+ # START
+ if not context:
+ response["display"] = (
+ "Let's troubleshoot Wazuh indexing errors. This typically occurs when the "
+ "wazuh-indexer service is down, certificates are invalid, or disk watermark is exceeded.\n\n"
+ "Checking wazuh-indexer status..."
+ )
+ status = (run_command("systemctl is-active wazuh-indexer") or "").strip()
+ context["indexer_status"] = status
+
+ if status != "active":
+ response["display"] += f"\n\n[WARNING] wazuh-indexer is {status.upper()}.\nWould you like me to restart it?"
+ response["ask"] = ["Restart indexer? (yes / no)"]
+ context["stage"] = "restart_indexer"
+ return response
+ else:
+ response["display"] += "\n\n[OK] wazuh-indexer is active.\nLet's check the disk space usage."
+ response["ask"] = ["Check disk space? (auto / manual)"]
+ context["stage"] = "disk_check"
+ return response
+
+ stage = context.get("stage")
+
+ if stage == "restart_indexer":
+ if user_choice and "yes" in user_choice.lower():
+ response["display"] = "Restarting wazuh-indexer service..."
+ run_command("systemctl restart wazuh-indexer")
+ time.sleep(3)
+ status = (run_command("systemctl is-active wazuh-indexer") or "").strip()
+ response["display"] += f"\n\nStatus after restart: {status.upper()}"
+ if status == "active":
+ response["display"] += "\n\nIndexer restarted successfully. Checking disk space now."
+ response["ask"] = ["Check disk space? (auto / manual)"]
+ context["stage"] = "disk_check"
+ return response
+ else:
+ response["display"] += "\n\nFailed to restart indexer. Please check system logs for issues."
+ response["done"] = True
+ return response
+ else:
+ response["display"] = "Skipped indexer restart. Checking disk space."
+ response["ask"] = ["Check disk space? (auto / manual)"]
+ context["stage"] = "disk_check"
+ return response
+
+ if stage == "disk_check":
+ if user_choice and "auto" in user_choice.lower():
+ df_out = run_command("df -h /var/lib/wazuh-indexer") or ""
+ response["display"] = f"Disk Space check output:\n\n{df_out}\n\n"
+ if "90%" in df_out or "95%" in df_out or "98%" in df_out or "99%" in df_out:
+ response["display"] += (
+ "[WARNING] Disk usage is critically high! Wazuh indexer blocks indexing if "
+ "disk watermark exceeds 90%.\n"
+ "Please delete old indices or expand storage."
+ )
+ else:
+ response["display"] += "[OK] Disk space looks acceptable."
+
+ response["ask"] = ["Run cluster health check? (yes / no)"]
+ context["stage"] = "cluster_health_check"
+ return response
+ else:
+ response["display"] = (
+ "Please run `df -h` on the indexer server and verify disk usage for /var/lib/wazuh-indexer/.\n"
+ "If usage is above 90%, clear indices or free up disk space."
+ )
+ response["ask"] = ["Is disk space sufficient? (yes / no)"]
+ context["stage"] = "disk_check_manual"
+ return response
+
+ if stage == "disk_check_manual":
+ if user_choice and "no" in user_choice.lower():
+ response["display"] = "Please free up disk space and try again."
+ response["done"] = True
+ return response
+ else:
+ response["display"] = "Disk space verified. Moving to cluster health check."
+ response["ask"] = ["Run cluster health check? (yes / no)"]
+ context["stage"] = "cluster_health_check"
+ return response
+
+ if stage == "cluster_health_check":
+ if user_choice and "yes" in user_choice.lower():
+ cluster_out = run_command(f"curl -k -s -u {INDEXER_USERNAME}:'{INDEXER_PASSWORD}' {INDEXER_URL}/_cluster/health") or ""
+ response["display"] = f"Cluster Health Status:\n\n{cluster_out}\n\n"
+ if "red" in cluster_out.lower():
+ response["display"] += "The cluster health status is RED. This indicates that some primary shards are unassigned."
+ elif "yellow" in cluster_out.lower():
+ response["display"] += "The cluster health status is YELLOW. This indicates that replica shards are unassigned."
+ else:
+ response["display"] += "[OK] Cluster status is GREEN."
+ else:
+ response["display"] = "Skipped cluster check."
+
+ response["display"] += "\n\nTroubleshooting complete."
+ response["done"] = True
+ return response
+
+ response["display"] = "Invalid stage."
+ response["done"] = True
+ return response
diff --git a/integrations/wazuh-troubleshooting-tool/backend/use_cases/mapping_issue.py b/integrations/wazuh-troubleshooting-tool/backend/use_cases/mapping_issue.py
new file mode 100644
index 00000000..a0bd7247
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/use_cases/mapping_issue.py
@@ -0,0 +1,344 @@
+"""
+Use case: 'Filebeat Mapping Issue' - field-mapping / index-template
+conflicts between what the Wazuh Indexer expects and what's actually being
+written (illegal_argument_exception, mapper_parsing_exception, a dashboard
+"N of M shards failed" popup, etc).
+
+Handed off to from use_cases/filebeat_error.py when the Filebeat log or
+`filebeat test output` output matches a known mapping-error signature, but
+also reachable directly as its own card.
+
+Diagnostic sequence, based on real support cases:
+ 1. Check whether the Wazuh Indexer actually has the default "wazuh"
+ index template registered, and that it's the canonical one (correct
+ index_patterns / settings) rather than missing or overridden by a
+ stray custom template.
+ 2. If a specific field was named in the error, check that field's type
+ in the live template.
+ 3. If it's missing/wrong, reinstall the canonical wazuh-template.json
+ and push it with `filebeat setup --index-management`.
+ 4. Offer to reindex any already-affected index (backup, delete,
+ restore, delete backup) so the fix also applies retroactively -
+ reinstalling the template alone only affects indices created after
+ the fix.
+"""
+
+from executor import run_command
+from utils.api_utils import indexer_api_get, indexer_api_get_json, indexer_api_delete
+from utils.index_utils import check_most_recent_index
+from utils.reindex_utils import reindex_for_mapping_conflict
+from utils.ai_utils import ai_explain
+
+WAZUH_TEMPLATE_URL = "https://raw.githubusercontent.com/wazuh/wazuh/v4.14.6/extensions/elasticsearch/7.x/wazuh-template.json"
+EXPECTED_INDEX_PATTERNS = {"wazuh-alerts-4.x-*", "wazuh-archives-4.x-*"}
+WAZUH_COMMUNITY_URL = "https://wazuh.com/community/"
+
+UNCLEAR_TEMPLATE_SYSTEM_PROMPT = (
+ "You are a Wazuh Indexer (OpenSearch) troubleshooting expert. You'll be given the live "
+ "'wazuh' index template (or its absence) and the list of registered templates. In 3-4 "
+ "short sentences: state the most likely root cause of a field-mapping conflict and the "
+ "single most useful next command or config fix. Be specific to what's actually in the "
+ "data - don't give generic advice."
+)
+
+
+def _dotted_get(d, dotted_key):
+ node = d
+ for part in dotted_key.split("."):
+ if not isinstance(node, dict) or part not in node:
+ return None
+ node = node[part]
+ return node
+
+
+def mapping_issue_flow(user_choice=None, context=None):
+ if context is None:
+ context = {}
+
+ response = {"display": "", "ask": [], "done": False, "context": context}
+ choice = (user_choice or "").strip().lower()
+
+ # START
+ if not context:
+ response["display"] = (
+ "Let's check whether this is a Wazuh Indexer field-mapping/index-template issue "
+ "rather than a Filebeat connectivity problem - this is what causes errors like "
+ "'illegal_argument_exception', 'mapper_parsing_exception', or a dashboard "
+ "'N of M shards failed' popup.\n\n"
+ "How would you like to check this?\n\n"
+ " Auto - We perform all checks automatically.\n"
+ " Manual - We provide the commands, and you run them and share the output."
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "method"
+ return response
+
+ stage = context.get("stage")
+
+ if stage == "method":
+ if "manual" in choice:
+ response["display"] = (
+ "Run these against the indexer (Indexer Management > Dev Tools, or curl):\n\n"
+ " GET /_template/wazuh\n"
+ " GET /_cat/templates\n\n"
+ "Does the 'wazuh' template exist, and does /_cat/templates show it covering "
+ f"{sorted(EXPECTED_INDEX_PATTERNS)}?"
+ )
+ response["ask"] = ["Yes, it looks correct", "No / missing / different"]
+ context["stage"] = "manual_result"
+ return response
+ return _check_template(response, context)
+
+ if stage == "manual_result":
+ if "yes" in choice:
+ return _ask_field_name(response, context)
+ return _offer_template_fix(response, context, reason="You reported the template is missing or incorrect.")
+
+ if stage == "field_name_wait":
+ return _check_field_type(response, context, user_choice)
+
+ if stage == "field_type_decision":
+ if "fix" in choice or "needs" in choice:
+ return _offer_template_fix(
+ response, context,
+ reason=f"'{context.get('mapping_field')}' needs its type corrected in the template.",
+ )
+ return _no_specific_field(response, context)
+
+ if stage == "fix_template":
+ if "auto" in choice:
+ return _auto_fix_template(response, context)
+ if "manual" in choice:
+ response["display"] = _manual_template_fix_instructions()
+ response["ask"] = ["Done"]
+ context["stage"] = "fix_template_manual_wait"
+ return response
+ response["ask"] = ["Auto", "Manual"]
+ return response
+
+ if stage == "fix_template_manual_wait":
+ response["display"] = "Template reinstalled (per your report). This only affects new indices going forward."
+ return _offer_reindex(response, context)
+
+ if stage == "reindex_method":
+ return _handle_reindex_method(response, context, choice)
+
+ if stage == "reindex_index_wait":
+ context["mapping_index"] = (user_choice or "").strip()
+ return _confirm_reindex(response, context)
+
+ if stage == "reindex_confirm":
+ if "skip" in choice or "no" in choice:
+ response["display"] = "Skipping the reindex."
+ response["done"] = True
+ return response
+ return _auto_reindex_one(response, context)
+
+ response["display"] = "Invalid stage."
+ response["done"] = True
+ return response
+
+
+# ---------------------------------------------------------------------------
+def _check_template(response, context):
+ response["display"] += ("\n" if response["display"] else "") + "Checking the live Wazuh Indexer template..."
+ templates_raw = indexer_api_get("/_cat/templates") or ""
+ template, _ = indexer_api_get_json("/_template/wazuh")
+ context["cat_templates"] = templates_raw
+
+ response["display"] += f"\n\n_cat/templates:\n{templates_raw or '(empty)'}"
+
+ wazuh_entry = (template or {}).get("wazuh")
+ if not wazuh_entry:
+ return _offer_template_fix(
+ response, context,
+ reason="The 'wazuh' index template is not registered on the indexer at all - "
+ "Filebeat's wazuh-template.json was never applied (or a different template "
+ "is overriding it).",
+ )
+
+ patterns_ok = EXPECTED_INDEX_PATTERNS.issubset(set(wazuh_entry.get("index_patterns", [])))
+ if not patterns_ok:
+ return _offer_template_fix(
+ response, context,
+ reason=f"A 'wazuh' template exists, but its index_patterns are "
+ f"{wazuh_entry.get('index_patterns')} instead of the expected "
+ f"{sorted(EXPECTED_INDEX_PATTERNS)} - this is a stray/custom template "
+ "overriding the real one.",
+ )
+
+ response["display"] += (
+ f"\n\n[OK] 'wazuh' template is registered with the expected index_patterns "
+ f"{sorted(EXPECTED_INDEX_PATTERNS)}."
+ )
+
+ total_fields_limit = _dotted_get(wazuh_entry, "settings.index.mapping.total_fields.limit")
+ if total_fields_limit and str(total_fields_limit) != "10000":
+ response["display"] += (
+ f"\n[WARNING] mapping.total_fields.limit is {total_fields_limit}, not the "
+ "documented 10000 - this can also be a symptom of a modified/outdated template."
+ )
+
+ context["wazuh_template"] = wazuh_entry
+ return _ask_field_name(response, context)
+
+
+def _ask_field_name(response, context):
+ response["display"] += (
+ "\n\nWhich field does the error mention (e.g. manager.name, cluster.name, or the "
+ "field named in the mapper_parsing_exception/illegal_argument_exception message)? "
+ "Type its dotted path, or 'skip' if you don't have one."
+ )
+ response["ask"] = []
+ context["stage"] = "field_name_wait"
+ return response
+
+
+def _check_field_type(response, context, field_name):
+ field_name = (field_name or "").strip()
+ if not field_name or field_name.lower() == "skip":
+ return _no_specific_field(response, context)
+
+ wazuh_entry = context.get("wazuh_template")
+ if wazuh_entry is None:
+ template, _ = indexer_api_get_json("/_template/wazuh")
+ wazuh_entry = (template or {}).get("wazuh", {})
+
+ dotted_path = f"mappings.properties.{field_name.replace('.', '.properties.')}.type"
+ field_type = _dotted_get(wazuh_entry, dotted_path)
+
+ response["display"] = f"Live template type for '{field_name}': {field_type or '(not found in the live template)'}"
+
+ if field_type is None:
+ return _offer_template_fix(
+ response, context,
+ reason=f"'{field_name}' isn't defined in the live 'wazuh' template at all - it's "
+ "likely relying on OpenSearch's dynamic mapping, which guessed a type that "
+ "doesn't match what's now being sent.",
+ )
+
+ response["display"] += (
+ "\n\nIf the error says this field should be a different type (commonly 'keyword' or "
+ "'object'), the live template needs to be corrected."
+ )
+ response["ask"] = ["Needs a different type - fix it", "This type is correct - something else is wrong"]
+ context["stage"] = "field_type_decision"
+ context["mapping_field"] = field_name
+ return response
+
+
+def _no_specific_field(response, context):
+ ai_text = ai_explain(UNCLEAR_TEMPLATE_SYSTEM_PROMPT, context.get("cat_templates", "")[:4000])
+ response["display"] += (
+ f"\n\nNo specific field to check further - here's an AI read of what we've gathered "
+ f"so far:\n{ai_text}\n\n"
+ f"If this doesn't resolve it, please reach out to the official Wazuh community:\n{WAZUH_COMMUNITY_URL}"
+ )
+ response["done"] = True
+ return response
+
+
+def _offer_template_fix(response, context, reason):
+ response["display"] += (
+ f"\n\n[ROOT CAUSE FOUND] Field-mapping / index-template issue\n\n{reason}\n\n"
+ "The fix is to reinstall the official Wazuh index template and push it via Filebeat. "
+ "This only affects indices created AFTER the fix - already-affected indices need a "
+ "separate reindex (offered next).\n\n"
+ "Would you like us to reinstall the template automatically, or do it yourself?"
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "fix_template"
+ return response
+
+
+def _manual_template_fix_instructions():
+ return (
+ "1. Back up the current template:\n\n"
+ " cp /etc/filebeat/wazuh-template.json /etc/filebeat/wazuh-template.json.backup\n\n"
+ "2. Remove any stray/incorrect template on the indexer (Dev Tools):\n\n"
+ " DELETE /_index_template/wazuh\n\n"
+ " (or DELETE /_template/wazuh if the cluster is still on the legacy templates API)\n\n"
+ "3. Install the official template on the Wazuh Manager/Filebeat host:\n\n"
+ f" curl -so /etc/filebeat/wazuh-template.json {WAZUH_TEMPLATE_URL}\n"
+ " chmod go+r /etc/filebeat/wazuh-template.json\n\n"
+ "4. Push it to the indexer and restart Filebeat:\n\n"
+ " filebeat setup --index-management\n"
+ " systemctl restart filebeat\n\n"
+ "This only affects new indices going forward - existing ones need a reindex."
+ )
+
+
+def _auto_fix_template(response, context):
+ log = []
+ log.append(run_command("cp /etc/filebeat/wazuh-template.json /etc/filebeat/wazuh-template.json.backup") or "")
+ log.append(indexer_api_delete("/_index_template/wazuh") or "")
+ log.append(run_command(f"curl -so /etc/filebeat/wazuh-template.json {WAZUH_TEMPLATE_URL}") or "")
+ log.append(run_command("chmod go+r /etc/filebeat/wazuh-template.json") or "")
+ log.append(run_command("filebeat setup --index-management") or "")
+ log.append(run_command("systemctl restart filebeat") or "")
+
+ response["display"] += "\n\nReinstalled the official Wazuh index template:\n" + "\n".join(l for l in log if l)
+
+ template, _ = indexer_api_get_json("/_template/wazuh")
+ wazuh_entry = (template or {}).get("wazuh")
+ if wazuh_entry and EXPECTED_INDEX_PATTERNS.issubset(set(wazuh_entry.get("index_patterns", []))):
+ response["display"] += "\n[OK] Verified - the 'wazuh' template now has the expected index_patterns."
+ else:
+ response["display"] += "\n[WARNING] The 'wazuh' template still doesn't look right after reinstalling - manual investigation needed."
+
+ return _offer_reindex(response, context)
+
+
+def _offer_reindex(response, context):
+ suggested = check_most_recent_index()
+ hint = f" (most recent: {suggested['index']})" if suggested.get("index") else ""
+ response["display"] += (
+ f"\n\nThe template fix only applies to new indices - any index that already has the "
+ f"field-mapping conflict needs to be reindexed (backup, delete, restore, delete "
+ f"backup) to pick up the corrected mapping{hint}.\n\n"
+ "Would you like to reindex an affected index now?"
+ )
+ response["ask"] = ["Yes, reindex one", "Skip"]
+ context["stage"] = "reindex_method"
+ return response
+
+
+def _handle_reindex_method(response, context, choice):
+ if "skip" in choice:
+ response["display"] = "Skipping the reindex."
+ response["done"] = True
+ return response
+
+ suggested = check_most_recent_index()
+ hint = f" (e.g. {suggested['index']})" if suggested.get("index") else ""
+ response["display"] = f"Which index would you like to reindex{hint}? Type the exact index name."
+ response["ask"] = []
+ context["stage"] = "reindex_index_wait"
+ return response
+
+
+def _confirm_reindex(response, context):
+ index_name = context.get("mapping_index", "")
+ response["display"] = (
+ f"This will back up '{index_name}', delete the original, restore from the backup with "
+ "the corrected mapping, then delete the backup. Proceed?"
+ )
+ response["ask"] = ["Yes, reindex", "Skip"]
+ context["stage"] = "reindex_confirm"
+ return response
+
+
+def _auto_reindex_one(response, context):
+ index_name = context.get("mapping_index", "")
+ steps = reindex_for_mapping_conflict(index_name)
+ aborted_after = steps.get("aborted_after")
+ if aborted_after:
+ detail = (steps.get(aborted_after) or "(no response)")[:200]
+ response["display"] = (
+ f"Stopped after '{aborted_after}' - nothing irreversible happened past that "
+ f"point. {detail}"
+ )
+ else:
+ response["display"] = f"Reindexed {index_name} - it should now use the corrected mapping."
+ response["done"] = True
+ return response
diff --git a/integrations/wazuh-troubleshooting-tool/backend/use_cases/no_alerts_are_showing.py b/integrations/wazuh-troubleshooting-tool/backend/use_cases/no_alerts_are_showing.py
new file mode 100644
index 00000000..63ec141e
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/use_cases/no_alerts_are_showing.py
@@ -0,0 +1,1041 @@
+"""
+Use case: 'Alerts Not Showing on Dashboard'.
+
+Walks the full pipeline end to end and stops at the first failing part:
+ Wazuh Agent -> Wazuh Manager -> alerts.json -> Filebeat -> Wazuh Indexer -> Dashboard
+
+Single, self-contained troubleshooting script - built from small,
+single-purpose functions in utils/, sequencing logic lives here since
+this use case isn't shared with any other use case.
+
+Interaction pattern (per product spec):
+ - Every step explains WHY it's checking something before asking anything.
+ - Permission is asked for HOW TO CHECK itself (auto vs manual), not just
+ for fixes - "auto" means we run the checks/commands ourselves and
+ self-heal if something's off; "manual" means we hand the user the
+ exact commands/reference text to run themselves and wait for them to
+ report back.
+ - `ask` is ALWAYS a list of short, standalone options - never a single
+ string with multiple comma/parenthetical alternatives jammed together
+ (e.g. never "(ID/name, 'auto', or 'skip')"). Jamming options together
+ like that is what caused the UI to mis-split/truncate the question
+ into a garbled follow-up that then got echoed back as a literal,
+ unmatchable "agent name" in an earlier version of this script.
+"""
+
+import random
+import time
+
+from utils.service_utils import get_service_status, restart_service_and_wait
+from utils.agent_utils import list_active_agents, restart_agent
+from utils.manager_config_utils import (
+ get_log_alert_level, is_log_alert_level_ok, set_log_alert_level,
+ get_jsonout_output_enabled, enable_jsonout_output,
+)
+from utils.alerts_log_utils import alerts_json_mentions
+from utils.manager_log_utils import get_manager_log_errors, get_manager_disk_usage
+from utils.cluster_utils import get_cluster_status, get_cluster_health, get_write_blocks, clear_write_blocks
+from utils.shard_utils import get_node_count, get_unassigned_shards, explain_allocation
+from utils.replica_utils import recommend_replica_count, set_replica_count
+from utils.index_utils import index_has_todays_date, check_most_recent_index, check_index_name_freshness
+from utils.reindex_utils import reindex_for_mapping_conflict
+from utils.api_utils import indexer_api_delete
+from utils.ai_utils import ai_explain
+from utils.log_handler import LogHandler
+from utils.log_analyzer import LogAnalyzer
+from utils.fix_engine import FixEngine
+from config import INDEXER_URL
+from flows.filebeat_flow import (
+ filebeat_flow, STAGES as FILEBEAT_STAGES, STEP4_ENTRY_STAGE,
+ ENTRY_STAGE as FILEBEAT_ENTRY_STAGE, WHY_TEXT as FILEBEAT_WHY_TEXT,
+)
+
+MANAGER_LOG_SYSTEM_PROMPT = (
+ "You are a Wazuh Manager troubleshooting expert. You'll be given recent "
+ "ossec.log error/warning lines. In 3-4 short sentences: state the most "
+ "likely root cause and the single most useful next command or config fix. "
+ "Be specific to what's actually in the log - don't give generic advice."
+)
+
+UNCLEAR_CLUSTER_STATUS_SYSTEM_PROMPT = (
+ "You are a Wazuh Indexer (OpenSearch) troubleshooting expert. You'll be given the raw "
+ "_cluster/health response for a cluster whose status is yellow or red even though there "
+ "are no unassigned shards and no known write blocks. In 3-4 short sentences: state the "
+ "most likely explanation and the single most useful next command to investigate further. "
+ "Be specific to what's actually in the data - don't give generic advice."
+)
+
+STEP1_MANUAL_TEXT = (
+ "Please check whether the manager is active and paste the output of:\n\n"
+ " systemctl status wazuh-manager\n\n"
+ "Next, check whether log_alert_level is configured correctly. On the "
+ "Wazuh manager server, open:\n\n"
+ " /var/ossec/etc/ossec.conf\n\n"
+ "Ensure it contains:\n\n"
+ " 3 \n\n"
+ "If log_alert_level is set higher than 15, alerts with rule level 15 "
+ "or below will not be written by the manager and therefore will not "
+ "appear on the dashboard. See the Wazuh docs for details:\n"
+ "https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/alerts.html#log-alert-level\n\n"
+ "Also verify that the following setting is enabled:\n\n"
+ " yes \n\n"
+ "https://documentation.wazuh.com/current/user-manual/reference/ossec-conf/global.html#jsonout-output\n\n"
+ "If jsonout_output is disabled, the manager will not write alerts to "
+ "alerts.json, so Filebeat will have no alerts to send to the indexer."
+)
+
+STEP4_MANUAL_TEXT = (
+ "Please check whether the indexer is active and paste the output of:\n\n"
+ " systemctl status wazuh-indexer\n\n"
+ "Next, check that its configured IP matches the original install config:\n\n"
+ " grep network.host /etc/wazuh-indexer/opensearch.yml\n\n"
+ "Also check that its certificate paths point to files that actually exist:\n\n"
+ " grep -E 'pemkey_filepath|pemcert_filepath|pemtrustedcas_filepath' /etc/wazuh-indexer/opensearch.yml\n"
+ " ls /etc/wazuh-indexer/certs\n\n"
+ "Every path from the first command should exist in the second command's listing. If the "
+ "IP or a cert path is wrong, the indexer can be 'active' while still rejecting or "
+ "misplacing data."
+)
+
+RECENT_INDEX_MANUAL_TEXT = (
+ "Run this against the indexer:\n\n"
+ f" curl -XGET -k -u admin: \"{INDEXER_URL}/_cat/indices/wazuh-alerts-*?v&s=index\"\n\n"
+ "Find the most recent wazuh-alerts-* index in the list (the one with the highest date "
+ "suffix), then type or paste that index name below so we can compare its date to today."
+)
+
+STEP5_MANUAL_TEXT = (
+ "Run this against the indexer:\n\n"
+ f" curl -XGET -k -u admin: \"{INDEXER_URL}/_cluster/health?pretty\"\n\n"
+ "Check the \"status\" field - it should be \"green\". If it's \"yellow\" or \"red\", there "
+ "are unassigned shards, usually because the number of replicas doesn't fit the number of "
+ "nodes in this cluster.\n\n"
+ "For the actual reason a specific shard won't allocate (not just the terse reason code), run:\n\n"
+ f" curl -XGET -k -u admin: \"{INDEXER_URL}/_cluster/allocation/explain?pretty\"\n\n"
+ "With no body, this explains an arbitrary unassigned shard the indexer picks itself."
+)
+
+STEP6_MANUAL_TEXT = (
+ "Check whether there's a wazuh-alerts-* index for today by running this against the indexer:\n\n"
+ f" curl -XGET -k -u admin: \"{INDEXER_URL}/_cat/indices/wazuh-alerts-*?v\"\n\n"
+ "Look for an index whose date suffix matches today. If there isn't one, alerts have "
+ "stopped being indexed recently even though the rest of the pipeline checks out."
+)
+
+MANUAL_DELETE_UNASSIGNED_TEXT = (
+ "WARNING - this permanently deletes every index that currently has an unassigned "
+ "shard. There is no backup step here - only run this if you don't need the data in "
+ "those indices. If you do need it, go back and use the reindex option instead.\n\n"
+ " curl -XGET -k -u admin: \"https://:9200/_cat/shards\" "
+ "| grep UNASSIGNED | awk '{print $1}' | sort -u "
+ "| xargs -I{} curl -XDELETE -k -u admin: \"https://:9200/{}\"\n\n"
+ "Once you've run it (or decided not to), let us know."
+)
+
+SAMPLE_AGENT_STARTED_ALERT = (
+ '{"timestamp":"2026-07-05T10:44:10.016+0000","rule":{"level":3,'
+ '"description":"Wazuh agent started.","id":"503",...},'
+ '"agent":{"id":"001","name":"windows","ip":"192.168.56.1"},'
+ '"manager":{"name":"Server1"},...,'
+ '"full_log":"ossec: Agent started: \'windows->any\'.",...}'
+)
+
+
+def _stop(response, title, explanation, manual_fix):
+ response["display"] = f"[ROOT CAUSE FOUND] {title}\n\n{explanation}\n\nManual fix:\n{manual_fix}"
+ response["done"] = True
+ return response
+
+
+def no_alerts_are_showing_flow(user_choice=None, context=None):
+ if context is None:
+ context = {}
+
+ response = {"display": "", "ask": [], "done": False, "context": context}
+ choice = (user_choice or "").strip().lower()
+
+ # =====================================================================
+ # STEP 1 - Manager service + ossec.conf config
+ # =====================================================================
+ if not context:
+ response["display"] = (
+ "Let's troubleshoot 'Alerts Not Showing on Dashboard'.\n\n"
+ "Step 1 - Check the Wazuh Manager service and its configuration:\n\n"
+ "We first need to verify that the Wazuh Manager is running "
+ "correctly and is configured to generate alerts. If "
+ "log_alert_level is set too high, or jsonout_output is "
+ "disabled, alerts get silently dropped before they're ever "
+ "written to alerts.json - so this has to be ruled out first.\n\n"
+ "How would you like us to check this?\n\n"
+ " Auto - We perform all checks automatically.\n"
+ " Manual - We provide the commands, and you run them and "
+ "share the output."
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "step1_method"
+ return response
+
+ stage = context.get("stage")
+
+ # =====================================================================
+ # STEP 3 - Filebeat (fully owned by flows/filebeat_flow.py)
+ # =====================================================================
+ if stage in FILEBEAT_STAGES:
+ result = filebeat_flow(user_choice=choice, context=context)
+ if result.get("handoff"):
+ next_result = no_alerts_are_showing_flow(context=result["context"])
+ next_display = next_result["display"].lstrip("\n")
+ next_result["display"] = (
+ f"{result['display']}\n\n{next_display}" if result.get("display") else next_display
+ )
+ return next_result
+ return result
+
+ if stage == STEP4_ENTRY_STAGE:
+ return _start_step4(response, context)
+
+ if stage == "step4_method":
+ if "manual" in choice:
+ response["display"] = STEP4_MANUAL_TEXT
+ response["ask"] = ["Correct", "Needs fixing"]
+ context["stage"] = "step4_manual_result"
+ return response
+
+ # AUTO - run the real checks ourselves.
+ return _check_indexer(response, context)
+
+ if stage == "step4_manual_result":
+ # Independently verify regardless of what the user reported, same as Step 1.
+ return _check_indexer(response, context)
+
+ if stage == "recent_index_method":
+ if "manual" in choice:
+ response["display"] = RECENT_INDEX_MANUAL_TEXT
+ response["ask"] = []
+ context["stage"] = "recent_index_manual_wait"
+ return response
+
+ # AUTO - look the most recent index up ourselves.
+ return _check_recent_index(response, context)
+
+ if stage == "recent_index_manual_wait":
+ # Whatever the user typed/pasted is the index name - parse its date
+ # suffix ourselves rather than trusting a self-report, same spirit as
+ # the other steps' independent re-verification.
+ result = check_index_name_freshness(user_choice)
+ response["display"] = _format_recent_index_result(result)
+ return _start_step5(response, context)
+
+ if stage == "step5_method":
+ if "manual" in choice:
+ response["display"] = STEP5_MANUAL_TEXT
+ response["ask"] = ["Correct", "Needs fixing"]
+ context["stage"] = "step5_manual_result"
+ return response
+
+ # AUTO - run the real check ourselves.
+ return _check_cluster_and_shards(response, context)
+
+ if stage == "step5_manual_result":
+ # Independently verify regardless of what the user reported, same as Step 1.
+ return _check_cluster_and_shards(response, context)
+
+ if stage == "step6_method":
+ if "manual" in choice:
+ response["display"] = STEP6_MANUAL_TEXT
+ response["ask"] = ["Correct", "Needs fixing"]
+ context["stage"] = "step6_manual_result"
+ return response
+
+ # AUTO - run the real check ourselves.
+ return _check_indices(response, context)
+
+ if stage == "step6_manual_result":
+ # Independently verify regardless of what the user reported, same as Step 1.
+ return _check_indices(response, context)
+
+ # =====================================================================
+ # Post-pipeline: everything checked out, but the user still has the
+ # complaint - narrow down whether this is a broken pipeline (all
+ # alerts missing, already covered above) or a mapping/rule issue
+ # specific to one alert (a different root cause entirely).
+ # =====================================================================
+ if stage == "step6_scope_check":
+ if "particular" in choice:
+ response["display"] = (
+ "On the Wazuh Manager, check whether that specific alert reached alerts.json:\n\n"
+ " grep -i '' /var/ossec/logs/alerts/alerts.json\n\n"
+ "Does it appear there?"
+ )
+ response["ask"] = ["Yes, it's in alerts.json", "No, it's not there"]
+ context["stage"] = "step6_particular_check"
+ return response
+
+ if "all of today" in choice:
+ response["display"] = (
+ "If every step in this pipeline checked out but ALL of today's alerts are "
+ "still missing, that points to the dashboard side rather than the indexing "
+ "pipeline itself: double-check the dashboard's index pattern (should match "
+ "wazuh-alerts-*) and its time range filter (set to include today). If those "
+ "look correct, re-check the cluster health and Filebeat logs from the earlier "
+ "steps for anything that changed right before this started."
+ )
+ response["done"] = True
+ return response
+
+ response["display"] = "Good - the full pipeline checks out end to end."
+ response["done"] = True
+ return response
+
+ if stage == "step6_particular_check":
+ if "yes" in choice:
+ index_name = check_most_recent_index()["index"] or "wazuh-alerts-*"
+ response["display"] = (
+ "That alert is reaching alerts.json but not showing on the dashboard - this "
+ "points to a field-mapping conflict on today's index, not a pipeline failure. "
+ "Would you like us to fix this by reindexing that index (same backup/restore "
+ "procedure used for unassigned shards earlier), or would you rather do it "
+ "yourself?"
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "step6_mapping_fix"
+ context["mapping_conflict_index"] = index_name
+ return response
+
+ response["display"] = (
+ "That alert never reached alerts.json, so this isn't an indexing/dashboard "
+ "problem - the cause is further upstream. Go back to Step 1 (log_alert_level / "
+ "jsonout_output) and check whether a rule is filtering it out, or whether the "
+ "source log is reaching the manager at all."
+ )
+ response["done"] = True
+ return response
+
+ if stage == "step6_mapping_fix":
+ index_name = context.get("mapping_conflict_index", "wazuh-alerts-*")
+ if "manual" in choice:
+ response["display"] = _manual_reindex_single_index_instructions(index_name)
+ response["ask"] = ["Done"]
+ context["stage"] = "step6_mapping_fix_manual_wait"
+ return response
+
+ if "auto" in choice:
+ steps = reindex_for_mapping_conflict(index_name)
+ aborted_after = steps.get("aborted_after")
+ if aborted_after:
+ detail = (steps.get(aborted_after) or "(no response)")[:200]
+ response["display"] = (
+ f"Stopped after '{aborted_after}' - nothing irreversible happened past "
+ f"that point. {detail}"
+ )
+ else:
+ response["display"] = f"Reindexed {index_name} - the mapping conflict should be resolved now."
+ response["done"] = True
+ return response
+
+ response["ask"] = ["Auto", "Manual"]
+ return response
+
+ if stage == "step6_mapping_fix_manual_wait":
+ response["display"] = "Done - the mapping conflict should be resolved now."
+ response["done"] = True
+ return response
+
+ if stage == "step1_method":
+ if "manual" in choice:
+ response["display"] = STEP1_MANUAL_TEXT
+ response["ask"] = ["Correct", "Needs fixing"]
+ context["stage"] = "step1_manual_result"
+ return response
+
+ # AUTO - run it ourselves, self-heal if needed, report and move on.
+ return _auto_check_step1(response, context)
+
+ if stage == "step1_manual_result":
+ # Independently verify regardless of what the user reported, so we
+ # can move on with confidence either way.
+ if is_log_alert_level_ok() and get_jsonout_output_enabled() and get_service_status("wazuh-manager") == "active":
+ response["display"] = "[OK] Confirmed - the manager is active and configured correctly."
+ return _start_step2(response, context)
+
+ response["display"] = (
+ "We're still seeing an issue with the manager service or its "
+ "configuration. Would you like us to fix it automatically, or "
+ "will you fix it yourself and let us know when done?"
+ )
+ response["ask"] = ["Auto", "I'll fix it myself"]
+ context["stage"] = "step1_fix_choice"
+ return response
+
+ if stage == "step1_fix_choice":
+ if "auto" in choice:
+ return _auto_check_step1(response, context, already_explained=True)
+
+ response["display"] = STEP1_MANUAL_TEXT + "\n\nOnce you've made the changes, let us know."
+ response["ask"] = ["Done"]
+ context["stage"] = "step1_manual_result"
+ return response
+
+ # =====================================================================
+ # STEP 2 - Live agent-restart pipeline test
+ # =====================================================================
+ if stage == "step2_method":
+ active = list_active_agents()
+ context["active_agents"] = active
+
+ if not active:
+ return _stop(
+ response, "No active agents",
+ "There are no active agents connected to this manager (agent 000, the "
+ "manager's own local agent, doesn't count), so there's nothing to "
+ "generate an event for this test.",
+ "Check agent connectivity/network from at least one endpoint, then re-run this workflow.",
+ )
+
+ if "manual" in choice:
+ listing = "\n".join(f" - {a['id']}: {a['name']}" for a in active[:10])
+ response["display"] = (
+ f"Active agents (excluding 000, the manager itself):\n{listing}\n\n"
+ "1. Pick one agent ID from the list above.\n"
+ "2. Restart it from the manager:\n"
+ " /var/ossec/bin/agent_control -R -u \n\n"
+ "3. Wait a few seconds, then check alerts.json for a matching "
+ "'Wazuh agent started' event, e.g.:\n"
+ f" {SAMPLE_AGENT_STARTED_ALERT}\n\n"
+ "Did you see a matching alert in alerts.json?"
+ )
+ response["ask"] = ["Yes, I see it", "No, nothing there"]
+ context["stage"] = "step2_manual_result"
+ return response
+
+ # AUTO - explain, pick a random active agent, restart it, and verify ourselves.
+ target = random.choice(active)
+ context["target_agent"] = target
+ response["display"] = (
+ f"We're restarting agent '{target['name']}' (ID {target['id']}) to generate "
+ "a known alert. If this alert appears in alerts.json, it confirms that the "
+ "Wazuh Manager is correctly receiving events, processing them, and generating "
+ "alerts."
+ )
+ out = restart_agent(target["id"])
+ time.sleep(5)
+ found = alerts_json_mentions(target["id"]) or alerts_json_mentions(target["name"])
+
+ response["display"] += f"\n\nRan: agent_control -R -u {target['id']}\n{out}\n"
+ if found:
+ response["display"] += (
+ f"[OK] Found a matching entry in alerts.json for agent '{target['name']}' - "
+ "the manager is receiving and logging agent events."
+ )
+ return _start_step3(response, context)
+
+ return _step2_no_alert_found(response, context)
+
+ if stage == "step2_manual_result":
+ if "yes" in choice:
+ response["display"] = "[OK] Good - that confirms the manager is receiving agent events."
+ return _start_step3(response, context)
+ return _step2_no_alert_found(response, context)
+
+ # =====================================================================
+ # STEP 4 fix gate - Indexer down
+ # =====================================================================
+ if stage == "fix_indexer_start":
+ if "auto" in choice:
+ status = restart_service_and_wait("wazuh-indexer")
+ elif choice == "done":
+ status = get_service_status("wazuh-indexer")
+ elif "manual" in choice:
+ response["display"] = "Run: systemctl restart wazuh-indexer"
+ response["ask"] = ["Done"]
+ context["stage"] = "fix_indexer_start"
+ return response
+ else:
+ response["ask"] = ["Auto", "Manual"]
+ return response
+
+ if status == "active":
+ response["display"] = "[OK] wazuh-indexer is now active."
+ return _check_indexer_ip_and_certs(response, context)
+
+ response["display"] = f"wazuh-indexer still shows {status.upper()}."
+ return _diagnose_indexer_logs(response, context)
+
+ # =====================================================================
+ # STEP 5 fix gate - cluster-wide write/index-creation blocks
+ # =====================================================================
+ if stage == "fix_write_blocks":
+ block_names = context.get("write_blocks", [])
+ if "auto" in choice:
+ raw = clear_write_blocks(block_names)
+ response["display"] = f"Cleared {len(block_names)} block(s):\n{', '.join(block_names)}\n{raw}"
+ return _check_cluster_and_shards(response, context)
+
+ if "manual" in choice:
+ response["display"] = _manual_clear_blocks_instructions(block_names)
+ response["ask"] = ["Done"]
+ context["stage"] = "fix_write_blocks_manual_wait"
+ return response
+
+ response["ask"] = ["Auto", "Manual"]
+ return response
+
+ if stage == "fix_write_blocks_manual_wait":
+ return _check_cluster_and_shards(response, context)
+
+ # =====================================================================
+ # STEP 5 fix gate - unassigned shards / replicas
+ # =====================================================================
+ if stage == "fix_replicas":
+ if "auto" in choice:
+ raw = set_replica_count("wazuh-alerts-*", context["recommended_replicas"])
+ response["display"] = f"Set number_of_replicas={context['recommended_replicas']} on wazuh-alerts-*.\n{raw}"
+ elif "manual" in choice:
+ response["display"] = (
+ "Run against the indexer:\n\n"
+ " curl -k -u \":\" -XPUT "
+ "\"https://:9200/wazuh-alerts-*\" -H 'Content-Type: application/json' -d'\n"
+ " {\n"
+ " \"settings\": {\n"
+ f" \"index\": {{ \"number_of_replicas\": {context['recommended_replicas']} }}\n"
+ " }\n"
+ " }'"
+ )
+ else:
+ response["ask"] = ["Auto", "Manual"]
+ return response
+ return _offer_reindex(response, context)
+
+ if stage == "reindex_method":
+ indices = context.get("unassigned_indices", [])
+ if "skip" in choice:
+ response["display"] = "Skipping the reindex."
+ return _start_step6(response, context)
+
+ if "delete" in choice:
+ return _confirm_delete_unassigned(response, context)
+
+ if "manual" in choice:
+ response["display"] = _manual_reindex_instructions(indices)
+ response["ask"] = ["Done", "Skip"]
+ context["stage"] = "reindex_manual_wait"
+ return response
+
+ if "auto" in choice:
+ return _auto_reindex(response, context)
+
+ response["ask"] = ["Auto (reindex, keeps data)", "Manual", "Delete instead", "Skip"]
+ return response
+
+ if stage == "confirm_delete_unassigned":
+ if "back" in choice or "no" in choice:
+ return _offer_reindex(response, context)
+
+ if "show" in choice or "command" in choice:
+ response["display"] = MANUAL_DELETE_UNASSIGNED_TEXT
+ response["ask"] = ["Done", "Skip"]
+ context["stage"] = "delete_unassigned_manual_wait"
+ return response
+
+ if "yes" in choice:
+ return _delete_unassigned_indices(response, context)
+
+ response["ask"] = ["Yes, delete via Auto", "Yes, show me the command", "No, go back"]
+ return response
+
+ if stage == "delete_unassigned_manual_wait":
+ if "skip" in choice:
+ response["display"] = "Skipping the verification."
+ return _start_step6(response, context)
+ return _verify_and_start_step6(response, context)
+
+ if stage == "reindex_manual_wait":
+ if "skip" in choice:
+ response["display"] = "Skipping the verification."
+ return _start_step6(response, context)
+ return _verify_and_start_step6(response, context)
+
+ response["display"] = "Invalid stage."
+ response["done"] = True
+ return response
+
+
+# ---------------------------------------------------------------------------
+# Step 1 helpers
+# ---------------------------------------------------------------------------
+def _auto_check_step1(response, context, already_explained=False):
+ if not already_explained:
+ response["display"] = (
+ "Automatically checking:\n"
+ " - whether the wazuh-manager service is active\n"
+ " - whether log_alert_level is configured correctly\n"
+ " - whether jsonout_output is enabled"
+ )
+
+ status = get_service_status("wazuh-manager")
+ if status != "active":
+ status = restart_service_and_wait("wazuh-manager")
+ if status != "active":
+ return _stop(
+ response, "Wazuh Manager failed to start",
+ "wazuh-manager did not come back up after a restart.",
+ "Check `journalctl -u wazuh-manager` and `/var/ossec/logs/ossec.log` for startup errors.",
+ )
+
+ fixed_something = False
+ if not is_log_alert_level_ok():
+ set_log_alert_level(3)
+ fixed_something = True
+ if not get_jsonout_output_enabled():
+ enable_jsonout_output()
+ fixed_something = True
+
+ if fixed_something:
+ restart_service_and_wait("wazuh-manager")
+
+ if is_log_alert_level_ok() and get_jsonout_output_enabled():
+ if fixed_something:
+ response["display"] += "\n[OK] Found and corrected a config issue, then restarted wazuh-manager."
+ else:
+ response["display"] += "\n[OK] The manager is active and already configured correctly."
+ return _start_step2(response, context)
+
+ errors = get_manager_log_errors()
+ explanation = ai_explain(MANAGER_LOG_SYSTEM_PROMPT, errors) if errors.strip() else \
+ "No error/warning lines found in ossec.log to analyze further."
+ return _stop(
+ response, "Manager configuration still not correct after auto-fix",
+ f"Something beyond the two known settings appears to be wrong.\n\n"
+ f"AI analysis of recent ossec.log errors:\n{explanation}",
+ "Review ossec.conf and ossec.log manually using the analysis above as a starting point.",
+ )
+
+
+def _start_step2(response, context):
+ response["display"] += (
+ "\n\nStep 2 - Verify that the Manager is Receiving Alerts:\n\n"
+ "The easiest way to confirm this is to restart an active agent and verify "
+ "that the manager writes a Rule 503 - Wazuh agent started event into "
+ "alerts.json.\n\n"
+ "How would you like to do this?"
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "step2_method"
+ return response
+
+
+def _step2_no_alert_found(response, context):
+ disk = get_manager_disk_usage()
+ errors = get_manager_log_errors()
+ response["display"] += (
+ "\n[WARNING] No matching entry found in alerts.json after restarting the agent.\n\n"
+ f"Manager disk usage:\n{disk}\n\nRecent ossec.log errors/warnings:\n"
+ f"{errors if errors else '(none found)'}"
+ )
+ response["done"] = True
+ return response
+
+
+# ---------------------------------------------------------------------------
+# Steps 4-6 (unchanged logic from before, only the ask-list formatting differs)
+# ---------------------------------------------------------------------------
+def _start_step3(response, context):
+ response["display"] += (
+ "\n\nStep 3 - Check Filebeat:\n\n"
+ f"{FILEBEAT_WHY_TEXT}\n\n"
+ "How would you like to check this?\n\n"
+ " Auto - We perform all checks automatically.\n"
+ " Manual - We provide the commands, and you run them and share the output."
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = FILEBEAT_ENTRY_STAGE
+ return response
+
+
+def _start_step4(response, context):
+ response["display"] += (
+ "\n\nStep 4 - Check the Wazuh Indexer:\n\n"
+ "Filebeat can only ship alerts as far as the Wazuh Indexer is actually running "
+ "and correctly configured to accept them. We need to confirm the service is "
+ "active, and that its IP and certificate configuration are correct - the same "
+ "checks used for the 'Wazuh dashboard is not ready yet' troubleshooting flow.\n\n"
+ "How would you like to check this?\n\n"
+ " Auto - We perform all checks automatically.\n"
+ " Manual - We provide the commands, and you run them and share the output."
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "step4_method"
+ return response
+
+
+def _check_indexer(response, context):
+ response["display"] += ("\n" if response["display"] else "") + "Checking Wazuh Indexer..."
+ status = get_service_status("wazuh-indexer")
+ if status != "active":
+ response["display"] += "\n[WARNING] wazuh-indexer is not active."
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "fix_indexer_start"
+ return response
+ response["display"] += "\n[OK] wazuh-indexer is active."
+ return _check_indexer_ip_and_certs(response, context)
+
+
+def _check_indexer_ip_and_certs(response, context):
+ ip_data = FixEngine.check_indexer_ip()
+ fixed_ip = False
+ if not ip_data["match"] and ip_data.get("c_ip"):
+ FixEngine.fix_indexer_ip(ip_data["c_ip"])
+ fixed_ip = True
+
+ cert_data = FixEngine.check_indexer_cert_paths()
+ fixed_cert = False
+ cert_unfixable = False
+ if cert_data["missing"]:
+ result = FixEngine.fix_indexer_cert_paths()
+ if result.get("success"):
+ fixed_cert = True
+ else:
+ cert_unfixable = True
+
+ if fixed_ip or fixed_cert:
+ response["display"] += "\n[OK] Found and corrected indexer IP/certificate configuration issues."
+ else:
+ response["display"] += "\n[OK] Indexer IP and certificate configuration look correct."
+
+ if cert_unfixable:
+ response["display"] += (
+ "\n[WARNING] Could not auto-identify the correct certificate files. Checking "
+ "the indexer logs to help narrow this down..."
+ )
+ return _diagnose_indexer_logs(response, context)
+
+ return _start_recent_index_check(response, context)
+
+
+def _start_recent_index_check(response, context):
+ response["display"] += (
+ "\n\nCheck for a Recently Created Index:\n\n"
+ "Before diving into cluster health, it's worth checking whether the indexer has "
+ "actually created a new wazuh-alerts-* index recently. If the newest one is old, "
+ "that's an early sign writes have stalled somewhere upstream, even though the "
+ "service itself is up.\n\n"
+ "How would you like to check this?\n\n"
+ " Auto - We perform all checks automatically.\n"
+ " Manual - We provide the commands, and you run them and share the output."
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "recent_index_method"
+ return response
+
+
+def _check_recent_index(response, context):
+ text = _format_recent_index_result(check_most_recent_index())
+ response["display"] += text if response["display"] else text.lstrip("\n")
+ return _start_step5(response, context)
+
+
+def _format_recent_index_result(result):
+ if not result["index"]:
+ return (
+ "\n[WARNING] Could not find any wazuh-alerts-* index with a parseable date - "
+ "moving on to check cluster health, which may explain why."
+ )
+ if result["is_today"]:
+ return f"\n[OK] Most recent index is {result['index']} (today)."
+ return (
+ f"\n[WARNING] Most recent index is {result['index']} - {result['days_old']} day(s) old, "
+ "not today. New alerts may have stopped being indexed recently. Continuing on to check "
+ "cluster health, which may explain why."
+ )
+
+
+def _start_step5(response, context):
+ response["display"] += (
+ "\n\nStep 5 - Check Cluster Health:\n\n"
+ "Now that the indexer is confirmed running and correctly configured, we check "
+ "cluster health and shard allocation - a red/yellow cluster or unassigned shards "
+ "will keep alerts from being indexed even though every earlier step passed.\n\n"
+ "How would you like to check this?\n\n"
+ " Auto - We perform all checks automatically.\n"
+ " Manual - We provide the commands, and you run them and share the output."
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "step5_method"
+ return response
+
+
+def _offer_clear_write_blocks(response, context, blocks):
+ listing = "\n".join(f" - {name} = {value}" for name, value in blocks.items())
+ response["display"] += (
+ f"\n[ISSUE] Found cluster-wide write/index-creation block(s):\n{listing}\n\n"
+ "These silently prevent new indices (including today's wazuh-alerts-*) from being "
+ "created or written to, even while every other check looks healthy - this is exactly "
+ "what caused an earlier data-loss incident during reindexing.\n\n"
+ "Would you like us to clear these, or will you clear them yourself?"
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "fix_write_blocks"
+ context["write_blocks"] = list(blocks.keys())
+ return response
+
+
+def _manual_clear_blocks_instructions(block_names):
+ lines = ",\n".join(f' "{name}": null' for name in block_names)
+ return (
+ "Run this against the indexer to clear the block(s):\n\n"
+ f" curl -XPUT -k -u admin: \"{INDEXER_URL}/_cluster/settings\" "
+ "-H 'Content-Type: application/json' -d'\n"
+ " {\n"
+ " \"persistent\": {\n"
+ f"{lines}\n"
+ " },\n"
+ " \"transient\": {\n"
+ f"{lines}\n"
+ " }\n"
+ " }'\n\n"
+ "Once you've run it, let us know."
+ )
+
+
+def _diagnose_indexer_logs(response, context):
+ logs = LogHandler.get_indexer_logs(2)
+ clean = LogHandler.clean_logs(logs)
+ issues = LogAnalyzer.get_issues(logs)
+
+ response["display"] += f"\n\nRecent indexer logs:\n{clean}"
+ if issues:
+ response["display"] += "\n\nKnown issues detected:\n" + "\n".join(f"- {i}" for i in issues)
+ else:
+ response["display"] += "\n\nNo known issue pattern matched - manual review of the logs above is needed."
+
+ response["done"] = True
+ return response
+
+
+def _check_cluster_and_shards(response, context):
+ response["display"] += ("\n" if response["display"] else "") + "Checking cluster health and shards..."
+
+ # Known cluster-wide write/index-creation blocks (e.g. a stale
+ # cluster.blocks.create_index) are checked BEFORE cluster status/shards,
+ # since a block can silently sit alongside an otherwise-green cluster -
+ # this is exactly what caused the reindex data-loss incident, and a
+ # plain _cluster/health check alone would never have surfaced it.
+ block_result = get_write_blocks()
+ if block_result.get("error"):
+ response["display"] += f"\n[WARNING] Could not check cluster write blocks: {str(block_result['error'])[:200]}"
+ elif block_result.get("blocks"):
+ return _offer_clear_write_blocks(response, context, block_result["blocks"])
+ else:
+ response["display"] += "\n[OK] No cluster-wide write/index-creation blocks found."
+
+ status = get_cluster_status()
+
+ if status is None:
+ return _stop(
+ response, "Wazuh Indexer API is unreachable",
+ "Could not query /_cluster/health.",
+ "Verify INDEXER_URL/credentials and that port 9200 is reachable.",
+ )
+
+ response["display"] += f"\nCluster status: {status.upper()}"
+
+ if status == "green":
+ response["display"] += "\n[OK] Cluster is green."
+ return _start_step6(response, context)
+
+ unassigned = get_unassigned_shards()
+ if not unassigned:
+ # No known scripted cause (no write blocks, no unassigned shards) but
+ # the cluster still isn't green - this is the one case we hand to the
+ # AI rather than guess at more hardcoded rules, same fallback pattern
+ # used for Filebeat's "unknown" failure category.
+ _, raw_health = get_cluster_health()
+ explanation = ai_explain(UNCLEAR_CLUSTER_STATUS_SYSTEM_PROMPT, raw_health or "(no response)")
+ response["display"] += (
+ f"\n[WARNING] No unassigned shards and no known write blocks, but status is still "
+ f"{status.upper()}.\n\nAI analysis:\n{explanation}"
+ )
+ return _start_step6(response, context)
+
+ node_count, _ = get_node_count()
+ recommended = recommend_replica_count(node_count)
+ context["recommended_replicas"] = recommended
+ context["unassigned_indices"] = sorted({s["index"] for s in unassigned})
+
+ sample = "\n".join(f" - {s['index']} shard {s['shard']} ({s['reason']})" for s in unassigned[:5])
+
+ # GET _cluster/allocation/explain on one representative shard - the reason
+ # code from _cat/shards (e.g. CLUSTER_RECOVERED) is terse; this gives the
+ # actual human-readable explanation of why the indexer won't allocate it.
+ first = unassigned[0]
+ explain = explain_allocation(first["index"], first["shard"], primary=(first.get("prirep") == "p"))
+ allocation_explanation = explain.get("allocate_explanation") or explain.get("error") or "(no explanation returned)"
+
+ response["display"] += (
+ f"\n[WARNING] {len(unassigned)} unassigned shard(s) found, e.g.:\n{sample}\n\n"
+ f"Allocation explanation for {first['index']} shard {first['shard']}:\n {allocation_explanation}\n\n"
+ f"With {node_count} node(s), recommended number_of_replicas is {recommended}."
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "fix_replicas"
+ return response
+
+
+def _offer_reindex(response, context):
+ indices = context.get("unassigned_indices", [])
+ listing = "\n".join(f" - {i}" for i in indices) or " (none)"
+ response["display"] += (
+ "\n\nSetting the replica count only fixes shard placement going forward - the "
+ "indices that were already stuck unassigned may still need to be reindexed to "
+ "fully recover. Affected indices:\n"
+ f"{listing}\n\n"
+ "Reindexing keeps the data - back it up, delete the original, restore from the "
+ "backup, then delete the backup, one index at a time. Deleting the affected "
+ "indices outright is faster but permanently discards their data - only pick "
+ "that if you don't need it.\n\n"
+ "How would you like to do this?"
+ )
+ response["ask"] = ["Auto (reindex, keeps data)", "Manual", "Delete instead", "Skip"]
+ context["stage"] = "reindex_method"
+ return response
+
+
+def _confirm_delete_unassigned(response, context):
+ indices = context.get("unassigned_indices", [])
+ listing = "\n".join(f" - {i}" for i in indices) or " (none)"
+ response["display"] = (
+ "WARNING - this permanently deletes each of the following indices in full. "
+ "There is no backup step - any data in them will be gone for good:\n\n"
+ f"{listing}\n\n"
+ "Are you sure you want to delete these indices?"
+ )
+ response["ask"] = ["Yes, delete via Auto", "Yes, show me the command", "No, go back"]
+ context["stage"] = "confirm_delete_unassigned"
+ return response
+
+
+def _delete_unassigned_indices(response, context):
+ indices = context.get("unassigned_indices", [])
+ results = [(name, indexer_api_delete(f"/{name}")) for name in indices]
+ lines = "\n".join(f" - {name}: {raw}" for name, raw in results[:10])
+ more = f"\n ... and {len(results) - 10} more" if len(results) > 10 else ""
+ response["display"] = f"Deleted {len(results)} index(es):\n{lines}{more}"
+ return _verify_and_start_step6(response, context)
+
+
+def _manual_reindex_instructions(indices):
+ listing = "\n".join(f" - {i}" for i in indices) or " (none)"
+ return (
+ "Reindex the affected indices one at a time (not all at once). Take a backup of "
+ "the index, then run the following, replacing with the index "
+ "name you want to reindex:\n\n"
+ f"Affected indices:\n{listing}\n\n"
+ "1. Back it up:\n\n"
+ " POST _reindex\n"
+ " {\n"
+ " \"source\": { \"index\": \"\" },\n"
+ " \"dest\": { \"index\": \"-backup\" }\n"
+ " }\n\n"
+ "2. Delete the original index:\n\n"
+ " DELETE /\n\n"
+ "3. Reindex from the backup:\n\n"
+ " POST _reindex\n"
+ " {\n"
+ " \"source\": { \"index\": \"-backup\" },\n"
+ " \"dest\": { \"index\": \"\" }\n"
+ " }\n\n"
+ "4. Delete the backup index:\n\n"
+ " DELETE /-backup\n\n"
+ "Repeat for any other indices showing field conflicts or the same issue. See the "
+ "Wazuh reindexing documentation for more details."
+ )
+
+
+def _auto_reindex(response, context):
+ indices = context.get("unassigned_indices", [])
+ results = []
+ for index_name in indices:
+ steps = reindex_for_mapping_conflict(index_name)
+ results.append((index_name, steps))
+
+ ok_count = sum(1 for _, steps in results if not steps.get("aborted_after"))
+ lines = []
+ for name, steps in results[:10]:
+ aborted_after = steps.get("aborted_after")
+ if not aborted_after:
+ lines.append(f" - {name}: OK")
+ else:
+ detail = (steps.get(aborted_after) or "(no response)")[:200]
+ lines.append(f" - {name}: stopped after '{aborted_after}' - nothing irreversible happened past that point. {detail}")
+ more = f"\n ... and {len(results) - 10} more" if len(results) > 10 else ""
+ response["display"] += (
+ f"\n\nReindexed {ok_count}/{len(results)} index(es) successfully, one at a time:\n"
+ + "\n".join(lines) + more
+ )
+ return _verify_and_start_step6(response, context)
+
+
+def _verify_and_start_step6(response, context):
+ sep = "\n\n" if response["display"] else ""
+ unassigned = get_unassigned_shards()
+ if unassigned:
+ response["display"] += f"{sep}[WARNING] {len(unassigned)} shard(s) are still unassigned after reindexing."
+ else:
+ response["display"] += f"{sep}[OK] No unassigned shards remain."
+ return _start_step6(response, context)
+
+
+def _start_step6(response, context):
+ response["display"] += (
+ "\n\nStep 6 - Check Today's wazuh-alerts-* Index:\n\n"
+ "This is the last link in the chain - even with a healthy manager, Filebeat, "
+ "indexer, and cluster, alerts still won't show up if today's wazuh-alerts-* "
+ "index was never created or has stopped receiving new documents.\n\n"
+ "How would you like to check this?\n\n"
+ " Auto - We perform all checks automatically.\n"
+ " Manual - We provide the commands, and you run them and share the output."
+ )
+ response["ask"] = ["Auto", "Manual"]
+ context["stage"] = "step6_method"
+ return response
+
+
+def _check_indices(response, context):
+ response["display"] += ("\n" if response["display"] else "") + "Checking today's wazuh-alerts-* index..."
+ if not index_has_todays_date():
+ response["display"] += "\n[WARNING] No wazuh-alerts-* index for today was found - the pipeline may have stalled recently."
+ response["done"] = True
+ return response
+
+ response["display"] += (
+ "\n[OK] Today's wazuh-alerts-* index exists, and everything earlier in the pipeline "
+ "checked out.\n\n"
+ "If you're still not seeing alerts you expect: are you missing one particular alert "
+ "(or alert type), or are ALL of today's alerts missing from the dashboard?"
+ )
+ response["ask"] = ["One particular alert", "All of today's alerts", "Nothing missing - all good"]
+ context["stage"] = "step6_scope_check"
+ return response
+
+
+def _manual_reindex_single_index_instructions(index_name):
+ return (
+ f"Take a backup of {index_name}, then reindex it to clear the mapping conflict:\n\n"
+ "1. Back it up:\n\n"
+ " POST _reindex\n"
+ " {\n"
+ f" \"source\": {{ \"index\": \"{index_name}\" }},\n"
+ f" \"dest\": {{ \"index\": \"{index_name}-backup\" }}\n"
+ " }\n\n"
+ "2. Delete the original index:\n\n"
+ f" DELETE /{index_name}\n\n"
+ "3. Reindex from the backup:\n\n"
+ " POST _reindex\n"
+ " {\n"
+ f" \"source\": {{ \"index\": \"{index_name}-backup\" }},\n"
+ f" \"dest\": {{ \"index\": \"{index_name}\" }}\n"
+ " }\n\n"
+ "4. Delete the backup index:\n\n"
+ f" DELETE /{index_name}-backup\n\n"
+ "Once you've run it, let us know."
+ )
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/agent_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/agent_utils.py
new file mode 100644
index 00000000..21d485e0
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/agent_utils.py
@@ -0,0 +1,73 @@
+import re
+from executor import run_command
+
+AGENT_CONTROL = "/var/ossec/bin/agent_control"
+
+
+def list_agents():
+ """Raw `agent_control -l` output - one line per registered agent."""
+ return run_command(f"{AGENT_CONTROL} -l") or ""
+
+
+def find_agent_line(identifier):
+ """Return the specific output line for one agent (matched by name or ID), or None if not found."""
+ if not identifier:
+ return None
+ for line in list_agents().splitlines():
+ if identifier.lower() in line.lower():
+ return line
+ return None
+
+
+def is_agent_active(identifier):
+ """
+ True/False if the agent was found and we can read its state.
+ None means the identifier wasn't found in the agent list at all.
+ """
+ line = find_agent_line(identifier)
+ if line is None:
+ return None
+ return "active" in line.lower()
+
+
+def list_active_agents():
+ """
+ Parse `agent_control -l` down to just the active agents, as
+ [{"id": "001", "name": "some-agent", "raw": ""}].
+
+ Agent 000 is always excluded - it's the manager's own local agent, not
+ a real endpoint, so it's never a valid candidate for a restart test.
+ """
+ active = []
+ for line in list_agents().splitlines():
+ if "active" not in line.lower():
+ continue
+ id_match = re.search(r"ID:\s*(\S+)", line)
+ # Stop at whichever comes first: a comma, the next "IP:" field, or a
+ # run of 2+ spaces (agent_control's output isn't always comma-separated).
+ name_match = re.search(r"Name:\s*(.+?)(?:,|\s+IP:|\s{2,}|$)", line)
+ if not id_match:
+ continue
+ agent_id = id_match.group(1).strip(",")
+ if agent_id == "000":
+ continue
+ active.append({
+ "id": agent_id,
+ "name": name_match.group(1).strip() if name_match else "unknown",
+ "raw": line.strip(),
+ })
+ return active
+
+
+def restart_agent(agent_id):
+ """
+ Remotely restart one agent by ID: agent_control -R -u
+ Only works if the agent is currently Active - it will not bring a
+ disconnected agent back online.
+ """
+ return run_command(f"{AGENT_CONTROL} -R -u {agent_id}") or ""
+
+
+def restart_all_agents():
+ """Remotely restart every currently active agent: agent_control -R -a"""
+ return run_command(f"{AGENT_CONTROL} -R -a") or ""
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/ai_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/ai_utils.py
new file mode 100644
index 00000000..b8d856cc
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/ai_utils.py
@@ -0,0 +1,20 @@
+from copilot_engine import run_copilot
+from config import OLLAMA_URL, OLLAMA_MODEL
+
+
+def ai_explain(system_prompt, user_content):
+ """Send `user_content` (e.g. raw log text) to the local model under a
+ focused `system_prompt` and return its plain-text reply. Never raises -
+ returns a readable message instead if Ollama is unreachable."""
+ try:
+ return run_copilot(
+ messages=[{"role": "user", "content": user_content}],
+ ollama_url=OLLAMA_URL,
+ ollama_model=OLLAMA_MODEL,
+ include_env=False,
+ wazuh_api_url="", api_username="", api_password="",
+ indexer_url="", indexer_username="", indexer_password="",
+ system_prompt=system_prompt,
+ )
+ except Exception as e:
+ return f"(AI explanation unavailable: {e})"
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/alerts_log_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/alerts_log_utils.py
new file mode 100644
index 00000000..82c112ec
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/alerts_log_utils.py
@@ -0,0 +1,57 @@
+import time
+from executor import run_command
+
+ALERTS_JSON_PATH = "/var/ossec/logs/alerts/alerts.json"
+ALERTS_LOG_PATH = "/var/ossec/logs/alerts/alerts.log"
+
+
+def alerts_json_exists():
+ return (run_command(f"test -f {ALERTS_JSON_PATH} && echo yes || echo no") or "").strip() == "yes"
+
+
+def alerts_json_age_seconds():
+ """Seconds since alerts.json was last modified, or None if it doesn't exist."""
+ if not alerts_json_exists():
+ return None
+ mtime_raw = (run_command(f"stat -c %Y {ALERTS_JSON_PATH}") or "").strip()
+ try:
+ return int(time.time()) - int(mtime_raw)
+ except ValueError:
+ return None
+
+
+def is_alerts_json_fresh(max_age_seconds=300):
+ age = alerts_json_age_seconds()
+ return age is not None and age <= max_age_seconds
+
+
+def tail_alerts_json(lines=5):
+ return run_command(f"tail -n {lines} {ALERTS_JSON_PATH}") or ""
+
+
+def tail_alerts_log(lines=100):
+ return run_command(f"tail -n {lines} {ALERTS_LOG_PATH}") or ""
+
+
+def alerts_log_mentions(identifier, lines=200):
+ """
+ Check the last N lines of alerts.log for a mention of this agent's
+ ID/name - used right after restarting an agent as a live test: if the
+ resulting check-in event shows up here, the manager is receiving and
+ logging that agent's events.
+ """
+ if not identifier:
+ return False
+ return identifier.lower() in tail_alerts_log(lines).lower()
+
+
+def alerts_json_mentions(identifier, lines=200):
+ """
+ Same idea as alerts_log_mentions(), but checks alerts.json instead -
+ this is the file the agent-restart pipeline test actually checks
+ against (a matching rule 503 'Wazuh agent started' entry confirms the
+ manager received and logged that agent's event).
+ """
+ if not identifier:
+ return False
+ return identifier.lower() in tail_alerts_json(lines).lower()
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/api_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/api_utils.py
new file mode 100644
index 00000000..adea77b5
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/api_utils.py
@@ -0,0 +1,104 @@
+"""
+Generic helpers for calling the Wazuh indexer's REST API with auth already
+applied, so use cases don't each re-type the same
+`curl -k -s -u USER:PASS URL` boilerplate by hand.
+
+Reusable by any use case that needs to hit the indexer API (cluster health,
+cat indices, cat shards, allocation explain, settings changes, reindex,
+deletes, etc.).
+"""
+
+import json
+import tempfile
+
+from executor import run_command
+from config import INDEXER_USERNAME, INDEXER_PASSWORD, INDEXER_URL
+
+
+def _write_temp_json(data):
+ tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
+ json.dump(data, tmp)
+ tmp.close()
+ return tmp.name
+
+
+def indexer_api_get(endpoint, extra_args=""):
+ """
+ GET a path from the indexer's REST API.
+
+ `endpoint` should start with "/", e.g. "/_cluster/health".
+ `extra_args` lets callers pass extra curl flags/query params if needed.
+ Returns the raw response text (empty string on failure).
+ """
+ url = f"{INDEXER_URL}{endpoint}"
+ cmd = f"curl -k -s -u {INDEXER_USERNAME}:'{INDEXER_PASSWORD}' {extra_args} {url}"
+ return run_command(cmd) or ""
+
+
+def indexer_api_get_json(endpoint, extra_args=""):
+ """
+ Same as indexer_api_get, but parses the response as JSON.
+
+ Returns (parsed_json, raw_text). If parsing fails, parsed_json is None
+ and raw_text is preserved so the caller can still show it to the user
+ for diagnosis (e.g. "the indexer isn't reachable, here's the raw error").
+ """
+ raw = indexer_api_get(endpoint, extra_args)
+ try:
+ return json.loads(raw), raw
+ except (ValueError, TypeError):
+ return None, raw
+
+
+def indexer_api_put(endpoint, json_body=None, extra_args=""):
+ """
+ PUT to the indexer's REST API. `json_body`, if given, is written to a
+ temp file and sent with --data @file (avoids shell-quoting issues that
+ come from inlining JSON directly into a curl string).
+ """
+ url = f"{INDEXER_URL}{endpoint}"
+ if json_body is not None:
+ path = _write_temp_json(json_body)
+ cmd = (
+ f"curl -k -s -u {INDEXER_USERNAME}:'{INDEXER_PASSWORD}' "
+ f"-X PUT -H 'Content-Type: application/json' --data @{path} {extra_args} {url}"
+ )
+ raw = run_command(cmd) or ""
+ run_command(f"rm -f {path}")
+ return raw
+
+ cmd = f"curl -k -s -u {INDEXER_USERNAME}:'{INDEXER_PASSWORD}' -X PUT {extra_args} {url}"
+ return run_command(cmd) or ""
+
+
+def indexer_api_post(endpoint, json_body=None, extra_args=""):
+ """Same as indexer_api_put but for POST (used for _reindex, _search, allocation/explain)."""
+ url = f"{INDEXER_URL}{endpoint}"
+ if json_body is not None:
+ path = _write_temp_json(json_body)
+ cmd = (
+ f"curl -k -s -u {INDEXER_USERNAME}:'{INDEXER_PASSWORD}' "
+ f"-X POST -H 'Content-Type: application/json' --data @{path} {extra_args} {url}"
+ )
+ raw = run_command(cmd) or ""
+ run_command(f"rm -f {path}")
+ return raw
+
+ cmd = f"curl -k -s -u {INDEXER_USERNAME}:'{INDEXER_PASSWORD}' -X POST {extra_args} {url}"
+ return run_command(cmd) or ""
+
+
+def indexer_api_post_json(endpoint, json_body=None, extra_args=""):
+ """Same as indexer_api_post, but parses the response as JSON."""
+ raw = indexer_api_post(endpoint, json_body, extra_args)
+ try:
+ return json.loads(raw), raw
+ except (ValueError, TypeError):
+ return None, raw
+
+
+def indexer_api_delete(endpoint, extra_args=""):
+ """DELETE a path from the indexer's REST API (e.g. an index name)."""
+ url = f"{INDEXER_URL}{endpoint}"
+ cmd = f"curl -k -s -u {INDEXER_USERNAME}:'{INDEXER_PASSWORD}' -X DELETE {extra_args} {url}"
+ return run_command(cmd) or ""
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/archive_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/archive_utils.py
new file mode 100644
index 00000000..e4177a0e
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/archive_utils.py
@@ -0,0 +1,30 @@
+"""
+Helpers for reading a file out of a tar archive that might live on a slow
+filesystem (e.g. a Vagrant/VirtualBox shared folder like /home/vagrant).
+
+Extracting a single member directly from an archive on a slow shared
+filesystem can take a very long time, because tar has to make many small
+read/seek calls to walk the archive, and each one can carry real latency
+on that kind of filesystem. Copying the whole archive to local disk once
+with a single sequential read is much faster, then extracting from that
+local copy is fast because it's on real disk.
+
+Reusable by any future use case that needs to read something out of an
+installer archive, a backup tarball, etc.
+"""
+
+import os
+from executor import run_command
+
+
+def extract_from_archive(archive_path, member_path, local_cache_dir="/tmp"):
+ """
+ Extract a single member from a tar archive and return its contents as
+ text. The archive is copied to `local_cache_dir` once (skipped if a
+ copy is already there) before extracting.
+ """
+ local_copy = os.path.join(local_cache_dir, os.path.basename(archive_path))
+
+ run_command(f"[ -f {local_copy} ] || cp {archive_path} {local_copy}")
+
+ return run_command(f"tar -axf {local_copy} {member_path} -O") or ""
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/cache_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/cache_utils.py
new file mode 100644
index 00000000..04e86bba
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/cache_utils.py
@@ -0,0 +1,37 @@
+"""
+Tiny generic in-memory TTL cache.
+
+Reusable by any utility function that reads something slow or expensive but
+relatively stable for the duration of a troubleshooting session (e.g.
+reading a value out of a tar archive that lives on a slow filesystem, or
+querying an API endpoint that doesn't need to be hit on every single check).
+
+Not persistent, not distributed - just enough to stop re-doing the same
+slow work repeatedly within one running process.
+"""
+
+import time
+
+_cache = {}
+
+
+def cached(key, compute_fn, ttl=300):
+ """
+ Return the cached value for `key` if it's younger than `ttl` seconds;
+ otherwise call compute_fn(), cache the result, and return it.
+ """
+ now = time.time()
+ entry = _cache.get(key)
+ if entry and (now - entry[0]) < ttl:
+ return entry[1]
+ value = compute_fn()
+ _cache[key] = (now, value)
+ return value
+
+
+def clear_cache(key=None):
+ """Clear one cached key, or the whole cache if key is None."""
+ if key is None:
+ _cache.clear()
+ else:
+ _cache.pop(key, None)
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/cert_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/cert_utils.py
new file mode 100644
index 00000000..f5ee59a0
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/cert_utils.py
@@ -0,0 +1,158 @@
+"""
+Wazuh certificate regeneration/redeploy.
+
+Reusable by ANY troubleshooting flow that diagnoses a TLS/certificate error
+(Filebeat output test, dashboard-not-ready, etc) instead of each flow
+re-writing the same wazuh-certs-tool.sh + redeploy sequence by hand.
+
+Node names (indexer/server/dashboard) are read from the original install
+config (wazuh-install-files.tar/config.yml) the same way FixEngine.get_control_ip()
+reads the indexer IP from it, so Auto mode doesn't need the user to supply
+them - Manual mode still shows the underlying commands with the real names
+filled in wherever we could resolve them.
+"""
+
+from executor import run_command
+from utils.archive_utils import extract_from_archive
+from utils.cache_utils import cached
+from utils.service_utils import restart_service_and_wait
+
+INSTALL_ARCHIVE = "/home/vagrant/wazuh-install-files.tar"
+CONFIG_MEMBER = "wazuh-install-files/config.yml"
+CERTS_TOOL_URL = "https://packages.wazuh.com/4.14/wazuh-certs-tool.sh"
+WORKDIR = "/tmp/wazuh-certs-renew"
+CERT_ARCHIVE = f"{WORKDIR}/wazuh-certificates.tar"
+
+
+def _get_config_yml():
+ return cached("install_config_yml_raw", lambda: extract_from_archive(INSTALL_ARCHIVE, CONFIG_MEMBER))
+
+
+def get_node_name(section):
+ """section: 'indexer' | 'server' | 'dashboard'. Returns that node's `name:` from config.yml."""
+ in_section = False
+ for line in _get_config_yml().splitlines():
+ stripped = line.strip()
+ if stripped == f"{section}:":
+ in_section = True
+ continue
+ if in_section and stripped.startswith("name:"):
+ return stripped.split(":", 1)[1].strip()
+ if in_section and stripped.endswith(":") and not stripped.startswith("-"):
+ in_section = False
+ return ""
+
+
+def get_node_names():
+ return {
+ "indexer": get_node_name("indexer"),
+ "server": get_node_name("server"),
+ "dashboard": get_node_name("dashboard"),
+ }
+
+
+def regenerate_and_redeploy_certs():
+ """
+ Regenerate certs with wazuh-certs-tool.sh and redeploy them to the
+ Wazuh Indexer, Filebeat, and Dashboard on this host, then restart all
+ three services. Returns a status dict.
+ """
+ names = get_node_names()
+ n1, n2, n3 = names["indexer"], names["server"], names["dashboard"]
+
+ log = []
+ run_command(f"mkdir -p {WORKDIR}")
+ log.append(run_command(f"cd {WORKDIR} && curl -sO {CERTS_TOOL_URL}") or "")
+ log.append(run_command(f"cd {WORKDIR} && bash wazuh-certs-tool.sh -A") or "")
+
+ # Wazuh Indexer
+ log.append(run_command("rm -rf /etc/wazuh-indexer/certs && mkdir /etc/wazuh-indexer/certs") or "")
+ log.append(run_command(
+ f"tar -xf {CERT_ARCHIVE} -C /etc/wazuh-indexer/certs/ "
+ f"./{n1}.pem ./{n1}-key.pem ./admin.pem ./admin-key.pem ./root-ca.pem"
+ ) or "")
+ run_command(f"mv -n /etc/wazuh-indexer/certs/{n1}.pem /etc/wazuh-indexer/certs/wazuh-indexer.pem")
+ run_command(f"mv -n /etc/wazuh-indexer/certs/{n1}-key.pem /etc/wazuh-indexer/certs/wazuh-indexer-key.pem")
+ run_command("chmod 500 /etc/wazuh-indexer/certs")
+ run_command("chmod 400 /etc/wazuh-indexer/certs/*")
+ run_command("chown -R wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/certs")
+
+ # Filebeat
+ log.append(run_command("rm -rf /etc/filebeat/certs && mkdir /etc/filebeat/certs") or "")
+ log.append(run_command(
+ f"tar -xf {CERT_ARCHIVE} -C /etc/filebeat/certs/ ./{n2}.pem ./{n2}-key.pem ./root-ca.pem"
+ ) or "")
+ run_command(f"mv -n /etc/filebeat/certs/{n2}.pem /etc/filebeat/certs/wazuh-server.pem")
+ run_command(f"mv -n /etc/filebeat/certs/{n2}-key.pem /etc/filebeat/certs/wazuh-server-key.pem")
+ run_command("chmod 500 /etc/filebeat/certs")
+ run_command("chmod 400 /etc/filebeat/certs/*")
+ run_command("chown -R root:root /etc/filebeat/certs")
+
+ # Wazuh Dashboard
+ log.append(run_command("rm -rf /etc/wazuh-dashboard/certs && mkdir /etc/wazuh-dashboard/certs") or "")
+ log.append(run_command(
+ f"tar -xf {CERT_ARCHIVE} -C /etc/wazuh-dashboard/certs/ ./{n3}.pem ./{n3}-key.pem ./root-ca.pem"
+ ) or "")
+ run_command(f"mv -n /etc/wazuh-dashboard/certs/{n3}.pem /etc/wazuh-dashboard/certs/wazuh-dashboard.pem")
+ run_command(f"mv -n /etc/wazuh-dashboard/certs/{n3}-key.pem /etc/wazuh-dashboard/certs/wazuh-dashboard-key.pem")
+ run_command("chmod 500 /etc/wazuh-dashboard/certs")
+ run_command("chmod 400 /etc/wazuh-dashboard/certs/*")
+ run_command("chown -R wazuh-dashboard:wazuh-dashboard /etc/wazuh-dashboard/certs")
+
+ indexer_status = restart_service_and_wait("wazuh-indexer")
+ filebeat_status = restart_service_and_wait("filebeat")
+ dashboard_status = restart_service_and_wait("wazuh-dashboard")
+
+ return {
+ "ok": indexer_status == "active" and filebeat_status == "active" and dashboard_status == "active",
+ "indexer_status": indexer_status,
+ "filebeat_status": filebeat_status,
+ "dashboard_status": dashboard_status,
+ "log": "\n".join(l for l in log if l),
+ }
+
+
+def manual_cert_redeploy_instructions():
+ names = get_node_names()
+ n1 = names["indexer"] or ""
+ n2 = names["server"] or ""
+ n3 = names["dashboard"] or ""
+
+ return (
+ "Locate the config.yml file and run:\n\n"
+ f" curl -sO {CERTS_TOOL_URL}\n"
+ " bash wazuh-certs-tool.sh -A\n\n"
+ "Set the node names (from config.yml):\n\n"
+ f" export NODE_NAME1={n1}\n"
+ f" export NODE_NAME2={n2}\n"
+ f" export NODE_NAME3={n3}\n\n"
+ "Redeploy the certificates to the Wazuh Indexer:\n\n"
+ " rm -rf /etc/wazuh-indexer/certs\n"
+ " mkdir /etc/wazuh-indexer/certs\n"
+ " tar -xf ./wazuh-certificates.tar -C /etc/wazuh-indexer/certs/ ./$NODE_NAME1.pem ./$NODE_NAME1-key.pem ./admin.pem ./admin-key.pem ./root-ca.pem\n"
+ " mv -n /etc/wazuh-indexer/certs/$NODE_NAME1.pem /etc/wazuh-indexer/certs/wazuh-indexer.pem\n"
+ " mv -n /etc/wazuh-indexer/certs/$NODE_NAME1-key.pem /etc/wazuh-indexer/certs/wazuh-indexer-key.pem\n"
+ " chmod 500 /etc/wazuh-indexer/certs\n"
+ " chmod 400 /etc/wazuh-indexer/certs/*\n"
+ " chown -R wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/certs\n\n"
+ "Redeploy the certificates to Filebeat:\n\n"
+ " rm -rf /etc/filebeat/certs\n"
+ " mkdir /etc/filebeat/certs\n"
+ " tar -xf ./wazuh-certificates.tar -C /etc/filebeat/certs/ ./$NODE_NAME2.pem ./$NODE_NAME2-key.pem ./root-ca.pem\n"
+ " mv -n /etc/filebeat/certs/$NODE_NAME2.pem /etc/filebeat/certs/wazuh-server.pem\n"
+ " mv -n /etc/filebeat/certs/$NODE_NAME2-key.pem /etc/filebeat/certs/wazuh-server-key.pem\n"
+ " chmod 500 /etc/filebeat/certs\n"
+ " chmod 400 /etc/filebeat/certs/*\n"
+ " chown -R root:root /etc/filebeat/certs\n\n"
+ "Redeploy the certificates to the Wazuh Dashboard:\n\n"
+ " rm -rf /etc/wazuh-dashboard/certs\n"
+ " mkdir /etc/wazuh-dashboard/certs\n"
+ " tar -xf ./wazuh-certificates.tar -C /etc/wazuh-dashboard/certs/ ./$NODE_NAME3.pem ./$NODE_NAME3-key.pem ./root-ca.pem\n"
+ " mv -n /etc/wazuh-dashboard/certs/$NODE_NAME3.pem /etc/wazuh-dashboard/certs/wazuh-dashboard.pem\n"
+ " mv -n /etc/wazuh-dashboard/certs/$NODE_NAME3-key.pem /etc/wazuh-dashboard/certs/wazuh-dashboard-key.pem\n"
+ " chmod 500 /etc/wazuh-dashboard/certs\n"
+ " chmod 400 /etc/wazuh-dashboard/certs/*\n"
+ " chown -R wazuh-dashboard:wazuh-dashboard /etc/wazuh-dashboard/certs\n\n"
+ "Then restart all the components:\n\n"
+ " systemctl restart wazuh-indexer filebeat wazuh-dashboard"
+ )
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/cluster_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/cluster_utils.py
new file mode 100644
index 00000000..b84e152c
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/cluster_utils.py
@@ -0,0 +1,68 @@
+from utils.api_utils import indexer_api_get_json, indexer_api_put
+
+# Known cluster-wide settings that silently block writes/index creation
+# cluster-wide, regardless of any single index's own health. This is the
+# exact setting that caused the reindex data-loss incident - a blocked
+# create_index looked completely invisible to a plain _cluster/health check.
+KNOWN_WRITE_BLOCKS = ("cluster.blocks.read_only", "cluster.blocks.read_only_allow_delete", "cluster.blocks.create_index")
+
+
+def get_cluster_health():
+ """Returns (parsed_json_or_None, raw_text)."""
+ return indexer_api_get_json("/_cluster/health")
+
+
+def get_cluster_status():
+ health, _ = get_cluster_health()
+ return health.get("status") if health else None
+
+
+def is_cluster_green():
+ return get_cluster_status() == "green"
+
+
+def get_cluster_settings():
+ """Returns (parsed_json_or_None, raw_text) for persistent + transient cluster settings."""
+ return indexer_api_get_json("/_cluster/settings")
+
+
+def get_write_blocks():
+ """
+ Check for the known cluster-wide blocks that silently prevent writes or
+ new index creation (e.g. a stale cluster.blocks.create_index from a
+ prior incident). Returns {"blocks": {name: value, ...}} for whichever
+ are actually set to something truthy, or {"error": raw} if the
+ settings endpoint itself couldn't be reached.
+ """
+ settings, raw = get_cluster_settings()
+ if settings is None:
+ return {"error": raw}
+
+ found = {}
+ for scope in ("persistent", "transient"):
+ scoped = settings.get(scope, {})
+ for name in KNOWN_WRITE_BLOCKS:
+ value = _dotted_get(scoped, name)
+ if value is not None and str(value).lower() not in ("false", "none", ""):
+ found[name] = value
+ return {"blocks": found}
+
+
+def clear_write_blocks(block_names):
+ """Clear the given cluster.blocks.* settings (in both scopes, since either could hold it)."""
+ reset = {name: None for name in block_names}
+ body = {"persistent": reset, "transient": reset}
+ return indexer_api_put("/_cluster/settings", body)
+
+
+def _dotted_get(d, dotted_key):
+ """d may have the key as one flat dotted string OR nested - OpenSearch's
+ settings API can return either shape depending on the OpenSearch version."""
+ if dotted_key in d:
+ return d[dotted_key]
+ node = d
+ for part in dotted_key.split("."):
+ if not isinstance(node, dict) or part not in node:
+ return None
+ node = node[part]
+ return node
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/compressed_history.py b/integrations/wazuh-troubleshooting-tool/backend/utils/compressed_history.py
new file mode 100644
index 00000000..8ad492ea
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/compressed_history.py
@@ -0,0 +1,112 @@
+"""
+Generic gzip-compressed rolling history store. Backs both the Wazuh Copilot
+chat history (session_store.py) and the Troubleshooting Library's completed-run
+downloads (wizard_history.py) — same mechanism, different directory.
+
+Keeps only the N most recently *created* entries: the moment a new one is
+saved past the cap, the oldest-created entry is deleted (file + manifest).
+"""
+import gzip
+import json
+import os
+from datetime import datetime, timezone
+
+
+class CompressedHistoryStore:
+ def __init__(self, directory, max_items=6):
+ self.dir = directory
+ self.manifest_path = os.path.join(directory, "manifest.json")
+ self.max_items = max_items
+
+ def _ensure_dir(self):
+ os.makedirs(self.dir, exist_ok=True)
+
+ def _file_path(self, item_id):
+ return os.path.join(self.dir, f"{item_id}.json.gz")
+
+ def _load_manifest(self):
+ self._ensure_dir()
+ if not os.path.exists(self.manifest_path):
+ return {}
+ try:
+ with open(self.manifest_path, "r") as f:
+ return json.load(f)
+ except (json.JSONDecodeError, OSError):
+ return {}
+
+ def _save_manifest(self, manifest):
+ self._ensure_dir()
+ with open(self.manifest_path, "w") as f:
+ json.dump(manifest, f, indent=2)
+
+ def save(self, item_id, data, title=None, extra_meta=None):
+ """Overwrite the compressed payload for item_id and update the
+ manifest. If item_id is brand new and pushes the total past
+ max_items, the oldest-created entry is deleted."""
+ self._ensure_dir()
+ manifest = self._load_manifest()
+ is_new = item_id not in manifest
+
+ payload = json.dumps(data).encode("utf-8")
+ with gzip.open(self._file_path(item_id), "wb") as f:
+ f.write(payload)
+
+ now = datetime.now(timezone.utc).isoformat()
+ if is_new:
+ manifest[item_id] = {
+ "title": title or "Untitled",
+ "started_at": now,
+ "updated_at": now,
+ **(extra_meta or {}),
+ }
+ else:
+ manifest[item_id]["updated_at"] = now
+ if title:
+ manifest[item_id]["title"] = title
+ if extra_meta:
+ manifest[item_id].update(extra_meta)
+
+ if is_new and len(manifest) > self.max_items:
+ oldest_id = min(manifest, key=lambda k: manifest[k]["started_at"])
+ if oldest_id != item_id:
+ self.delete(oldest_id, manifest=manifest, persist=False)
+
+ self._save_manifest(manifest)
+
+ def load(self, item_id):
+ path = self._file_path(item_id)
+ if not os.path.exists(path):
+ return None
+ try:
+ with gzip.open(path, "rb") as f:
+ return json.loads(f.read().decode("utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+
+ def list(self):
+ """Manifest entries, newest-updated first."""
+ manifest = self._load_manifest()
+ return sorted(
+ [{"id": iid, **meta} for iid, meta in manifest.items()],
+ key=lambda e: e["updated_at"],
+ reverse=True,
+ )
+
+ def delete(self, item_id, manifest=None, persist=True):
+ own_manifest = manifest is None
+ if own_manifest:
+ manifest = self._load_manifest()
+ manifest.pop(item_id, None)
+ path = self._file_path(item_id)
+ if os.path.exists(path):
+ os.remove(path)
+ if own_manifest and persist:
+ self._save_manifest(manifest)
+
+ def rename(self, item_id, new_title):
+ manifest = self._load_manifest()
+ if item_id not in manifest:
+ return False
+ manifest[item_id]["title"] = new_title[:100]
+ self._save_manifest(manifest)
+ return True
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/default_route_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/default_route_utils.py
new file mode 100644
index 00000000..5937c89f
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/default_route_utils.py
@@ -0,0 +1,43 @@
+from executor import run_command
+
+DASHBOARD_CONFIG_PATH = "/etc/wazuh-dashboard/opensearch_dashboards.yml"
+EXPECTED_DEFAULT_ROUTE = "/app/wz-home"
+
+
+def get_default_route():
+ """Raw configured line for uiSettings.overrides.defaultRoute, or '' if not set."""
+ return run_command(
+ f"grep 'uiSettings.overrides.defaultRoute' {DASHBOARD_CONFIG_PATH}"
+ ) or ""
+
+
+def is_default_route_ok(raw=None):
+ """
+ After an upgrade, opensearch_dashboards.yml can be left over from the
+ previous version and miss (or keep a stale) defaultRoute override. When
+ that happens the dashboard serves an "Application Not Found" error
+ instead of the home page.
+ """
+ raw = get_default_route() if raw is None else raw
+ return EXPECTED_DEFAULT_ROUTE in raw
+
+
+def set_default_route():
+ """Set uiSettings.overrides.defaultRoute in opensearch_dashboards.yml. Caller restarts wazuh-dashboard afterward."""
+ setting = f"uiSettings.overrides.defaultRoute: {EXPECTED_DEFAULT_ROUTE}"
+
+ has_key = (run_command(
+ f"grep -q 'uiSettings.overrides.defaultRoute' {DASHBOARD_CONFIG_PATH} "
+ "&& echo yes || echo no"
+ ) or "no").strip()
+
+ if has_key == "yes":
+ run_command(
+ "sed -i 's|uiSettings.overrides.defaultRoute:.*|{}|' {}".format(
+ setting, DASHBOARD_CONFIG_PATH
+ )
+ )
+ else:
+ run_command(f"echo '{setting}' >> {DASHBOARD_CONFIG_PATH}")
+
+ return get_default_route()
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/embeddings.py b/integrations/wazuh-troubleshooting-tool/backend/utils/embeddings.py
new file mode 100644
index 00000000..96806169
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/embeddings.py
@@ -0,0 +1,39 @@
+"""
+Wraps Ollama's embedding endpoint for the LGTM knowledge base's semantic
+search. nomic-embed-text is asymmetric - it expects a task prefix for best
+retrieval quality: "search_document: " when embedding a stored issue,
+"search_query: " when embedding a user's question.
+"""
+import requests
+
+from config import OLLAMA_URL
+
+EMBED_MODEL = "nomic-embed-text"
+# 15s was too tight on a loaded, low-core-count host - a correctly-working
+# embedding call can simply take longer than that under CPU contention from
+# other services (wazuh-indexer, editor tooling, etc.), not because Ollama
+# is actually broken. 60s gives real headroom without masking a truly dead
+# Ollama for an unreasonable amount of time.
+_TIMEOUT = 60
+
+
+def _embed(text: str):
+ try:
+ resp = requests.post(
+ f"{OLLAMA_URL}/api/embeddings",
+ json={"model": EMBED_MODEL, "prompt": text[:8000]},
+ timeout=_TIMEOUT,
+ )
+ if resp.status_code != 200:
+ return None
+ return resp.json().get("embedding")
+ except requests.exceptions.RequestException:
+ return None
+
+
+def embed_document(text: str):
+ return _embed(f"search_document: {text}")
+
+
+def embed_query(text: str):
+ return _embed(f"search_query: {text}")
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/filebeat_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/filebeat_utils.py
new file mode 100644
index 00000000..3d2aae9d
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/filebeat_utils.py
@@ -0,0 +1,121 @@
+import re
+
+from executor import run_command
+from utils.service_utils import restart_service_and_wait
+
+FILEBEAT_LOG_PATH = "/var/log/filebeat/filebeat"
+
+# Wazuh only ships/supports this exact Filebeat build - see:
+# https://documentation.wazuh.com/current/upgrade-guide/index.html#wazuh-components-compatibility
+SUPPORTED_FILEBEAT_VERSION = "7.10.2"
+FILEBEAT_DEB_URL = "https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-oss-7.10.2-amd64.deb"
+FILEBEAT_RPM_URL = "https://artifacts.elastic.co/downloads/beats/filebeat/filebeat-oss-7.10.2-x86_64.rpm"
+
+
+def run_filebeat_output_test():
+ """Run `filebeat test output` and judge OK/failed from the actual text, not just presence of the word OK."""
+ out = run_command("filebeat test output") or ""
+ upper = out.upper()
+ ok = "OK" in upper and "ERROR" not in upper
+ return {"raw": out, "ok": ok}
+
+
+def get_filebeat_log_errors(lines=200):
+ return run_command(f"tail -n {lines} {FILEBEAT_LOG_PATH} | grep -i -E 'error|warn'") or ""
+
+
+def get_filebeat_version(test_raw=""):
+ """Pull the version out of `filebeat test output`'s own report (e.g.
+ 'version: 7.10.2') if we already have it, else ask Filebeat directly."""
+ match = re.search(r"version:\s*([\d.]+)", test_raw or "")
+ if match:
+ return match.group(1)
+ out = run_command("filebeat version") or ""
+ match = re.search(r"(\d+\.\d+\.\d+)", out)
+ return match.group(1) if match else ""
+
+
+def classify_filebeat_failure(test_raw, log_errors=""):
+ """
+ Best-effort classification of why `filebeat test output` failed, based on
+ the actual text rather than a guess. Returns one of: "unsupported_version",
+ "tls_cert_error", "auth_failure", "indexer_unreachable", "unknown".
+ """
+ text = f"{test_raw}\n{log_errors}".lower()
+
+ if "invalid_index_name_exception" in text or "could not connect to a compatible version" in text:
+ return "unsupported_version"
+ if any(kw in text for kw in ["x509", "certificate", "tls", "ssl", "handshake"]):
+ return "tls_cert_error"
+ if any(kw in text for kw in ["unauthorized", "authentication", "401"]):
+ return "auth_failure"
+ if any(kw in text for kw in [
+ "connection refused", "no route to host", "i/o timeout",
+ "network is unreachable", "dial up... error", "talk to server... error",
+ ]):
+ return "indexer_unreachable"
+ return "unknown"
+
+
+def fix_unsupported_filebeat_version():
+ """
+ Wazuh only supports Filebeat-OSS 7.10.2. Deploys the Wazuh Filebeat
+ module + alerts template (the documented fix for the version-mismatch
+ error), and reinstalls Filebeat-OSS 7.10.2 itself if the version is
+ still wrong afterwards. Returns what ran and the final state.
+ """
+ log = []
+ log.append(run_command("systemctl stop filebeat") or "")
+ log.append(run_command(
+ "curl -s https://packages.wazuh.com/4.x/filebeat/wazuh-filebeat-0.5.tar.gz "
+ "| tar -xvz -C /usr/share/filebeat/module"
+ ) or "")
+ log.append(run_command(
+ "curl -so /etc/filebeat/wazuh-template.json "
+ "https://raw.githubusercontent.com/wazuh/wazuh/v4.14.6/extensions/elasticsearch/7.x/wazuh-template.json"
+ ) or "")
+ log.append(run_command("chmod go+r /etc/filebeat/wazuh-template.json") or "")
+
+ status = restart_service_and_wait("filebeat")
+ version = get_filebeat_version()
+
+ if version != SUPPORTED_FILEBEAT_VERSION:
+ if run_command("command -v dpkg") :
+ log.append(run_command(f"curl -so /tmp/filebeat-oss.deb {FILEBEAT_DEB_URL}") or "")
+ log.append(run_command("dpkg -i /tmp/filebeat-oss.deb") or "")
+ elif run_command("command -v rpm"):
+ log.append(run_command(f"curl -so /tmp/filebeat-oss.rpm {FILEBEAT_RPM_URL}") or "")
+ log.append(run_command("rpm -Uvh /tmp/filebeat-oss.rpm") or "")
+ status = restart_service_and_wait("filebeat")
+ version = get_filebeat_version()
+
+ return {
+ "ok": version == SUPPORTED_FILEBEAT_VERSION,
+ "version": version or "unknown",
+ "status": status,
+ "log": "\n".join(l for l in log if l),
+ }
+
+
+def manual_unsupported_version_instructions():
+ return (
+ f"Wazuh is only compatible with Filebeat-OSS {SUPPORTED_FILEBEAT_VERSION}. "
+ "Manually upgrading to a newer version is not recommended - it can break "
+ "alert forwarding and index integration.\n"
+ "https://documentation.wazuh.com/current/upgrade-guide/index.html#wazuh-components-compatibility\n\n"
+ "Stop Filebeat:\n\n"
+ " systemctl stop filebeat\n\n"
+ "Download the Wazuh Filebeat module:\n\n"
+ " curl -s https://packages.wazuh.com/4.x/filebeat/wazuh-filebeat-0.5.tar.gz | sudo tar -xvz -C /usr/share/filebeat/module\n\n"
+ "Download the alerts template:\n\n"
+ " curl -so /etc/filebeat/wazuh-template.json https://raw.githubusercontent.com/wazuh/wazuh/v4.14.6/extensions/elasticsearch/7.x/wazuh-template.json\n"
+ " chmod go+r /etc/filebeat/wazuh-template.json\n\n"
+ "Restart Filebeat, then check the version again:\n\n"
+ " filebeat version\n\n"
+ f"It should report Filebeat-OSS {SUPPORTED_FILEBEAT_VERSION}. If it still doesn't, reinstall Filebeat "
+ f"with the OSS {SUPPORTED_FILEBEAT_VERSION} package directly:\n\n"
+ f" curl -so /tmp/filebeat-oss.deb {FILEBEAT_DEB_URL} # Debian/Ubuntu\n"
+ " dpkg -i /tmp/filebeat-oss.deb\n\n"
+ f" curl -so /tmp/filebeat-oss.rpm {FILEBEAT_RPM_URL} # RHEL/CentOS\n"
+ " rpm -Uvh /tmp/filebeat-oss.rpm"
+ )
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/fix_engine.py b/integrations/wazuh-troubleshooting-tool/backend/utils/fix_engine.py
new file mode 100644
index 00000000..f8bea24e
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/fix_engine.py
@@ -0,0 +1,585 @@
+from executor import run_command
+from config import KIBANA_USERNAME, INDEXER_URL
+from utils.cache_utils import cached
+from utils.archive_utils import extract_from_archive
+from utils.service_utils import restart_service_and_wait, get_service_status
+
+import re
+import secrets
+import string
+
+
+class FixEngine:
+
+ # -----------------------------------------
+ # GET IP FROM config.yml (control file)
+ # -----------------------------------------
+ @staticmethod
+ def get_control_ip():
+
+ # Cached (utils/cache_utils.py): avoid re-reading this on every
+ # single troubleshooting step within the same session. The read
+ # itself uses utils/archive_utils.py, which copies the archive to
+ # local disk once before extracting — much faster than extracting
+ # directly from /home/vagrant if that's a slow shared folder.
+ output = cached(
+ "control_ip_raw",
+ lambda: extract_from_archive(
+ "/home/vagrant/wazuh-install-files.tar",
+ "wazuh-install-files/config.yml",
+ ),
+ )
+
+ in_indexer = False
+
+ for line in output.splitlines():
+ if "indexer:" in line:
+ in_indexer = True
+ continue
+ if in_indexer and line.strip().endswith(":") and "ip:" not in line:
+ in_indexer = False
+ if in_indexer and "ip:" in line:
+ return line.strip()
+
+ return ""
+
+ # -----------------------------------------
+ # GET IP FROM INDEXER CONFIG
+ # -----------------------------------------
+ @staticmethod
+ def get_indexer_ip():
+ return run_command(
+ "grep network.host /etc/wazuh-indexer/opensearch.yml"
+ ) or ""
+
+ # -----------------------------------------
+ # GET IP FROM DASHBOARD CONFIG
+ # -----------------------------------------
+ @staticmethod
+ def get_dashboard_ip():
+ return run_command(
+ "grep opensearch.hosts /etc/wazuh-dashboard/opensearch_dashboards.yml"
+ ) or ""
+
+ # -----------------------------------------
+ # EXTRACT IP (helper)
+ # -----------------------------------------
+ @staticmethod
+ def extract_ip(text):
+ if not text:
+ return None
+ match = re.search(r'(\d+\.\d+\.\d+\.\d+)', text)
+ return match.group(1) if match else None
+
+ # -----------------------------------------
+ # COMPARE IPS
+ # -----------------------------------------
+ @staticmethod
+ def compare_ips():
+ control = FixEngine.get_control_ip()
+ indexer = FixEngine.get_indexer_ip()
+ dashboard = FixEngine.get_dashboard_ip()
+
+ c_ip = FixEngine.extract_ip(control)
+ i_ip = FixEngine.extract_ip(indexer)
+ d_ip = FixEngine.extract_ip(dashboard)
+
+ return {
+ "control": c_ip,
+ "indexer": i_ip,
+ "dashboard": d_ip,
+ "match": (c_ip == i_ip == d_ip),
+ }
+
+ # -----------------------------------------
+ # FULL IP CHECK
+ # -----------------------------------------
+ @staticmethod
+ def check_ips():
+
+ data = FixEngine.compare_ips()
+
+ result = (
+ f"Control IP: {data['control']}\n"
+ f"Indexer IP: {data['indexer']}\n"
+ f"Dashboard IP: {data['dashboard']}"
+ )
+
+ if not data["match"]:
+ result += "\n\n[ERROR] IP mismatch detected."
+ else:
+ result += "\n\n[OK] IP configuration looks correct."
+
+ return result
+
+ # -----------------------------------------
+ # GET CERT PATHS FROM DASHBOARD CONFIG
+ # -----------------------------------------
+ @staticmethod
+ def get_cert_paths():
+ return run_command(
+ "grep -E 'certificate|key|ca' "
+ "/etc/wazuh-dashboard/opensearch_dashboards.yml"
+ ) or ""
+
+ # -----------------------------------------
+ # LIST CERT FILES
+ # -----------------------------------------
+ @staticmethod
+ def list_cert_files():
+ return run_command("ls -lrt /etc/wazuh-dashboard/certs") or ""
+
+ # -----------------------------------------
+ # CHECK CERT PERMISSIONS
+ # -----------------------------------------
+ @staticmethod
+ def check_cert_permissions():
+
+ perms = run_command(
+ "ls -ld /etc/wazuh-dashboard/certs"
+ ) or ""
+
+ files = run_command(
+ "ls -l /etc/wazuh-dashboard/certs"
+ ) or ""
+
+ return (
+ f"Directory permissions:\n{perms}\n\n"
+ f"Certificate files:\n{files}"
+ )
+
+ # -----------------------------------------
+ # CHECK CERT PATHS
+ # -----------------------------------------
+ @staticmethod
+ def check_cert_paths():
+
+ paths = FixEngine.get_cert_paths()
+ files = FixEngine.list_cert_files()
+
+ return (
+ f"Configured cert paths:\n{paths}\n\n"
+ f"Available cert files:\n{files}"
+ )
+
+ # -----------------------------------------
+ # FIX CERT PERMISSIONS
+ # -----------------------------------------
+ @staticmethod
+ def fix_cert_permissions():
+ cmds = [
+ "chmod 500 /etc/wazuh-dashboard/certs",
+ "chmod 400 /etc/wazuh-dashboard/certs/*",
+ "chown -R wazuh-dashboard:wazuh-dashboard /etc/wazuh-dashboard/certs",
+ ]
+ output = ""
+ for cmd in cmds:
+ output += (run_command(cmd) or "") + "\n"
+ return output
+
+ # -----------------------------------------
+ # RESTART INDEXER (Bug fixed: out was undefined)
+ # -----------------------------------------
+ @staticmethod
+ def restart_indexer():
+ status = restart_service_and_wait("wazuh-indexer")
+ return f"Status after restart: {status}"
+
+ # -----------------------------------------
+ # RESTART INDEXER AND WAIT FOR ACTIVE STATE
+ # Returns the final status string ("active", "failed", "activating", etc.)
+ #
+ # Delegates to utils/service_utils.py, which uses "--no-block" so the
+ # restart command returns immediately instead of blocking indefinitely
+ # while a slow-starting service (e.g. JVM-based wazuh-indexer) comes up,
+ # then polls "is-active" itself with a bounded, predictable window.
+ # -----------------------------------------
+ @staticmethod
+ def restart_indexer_and_wait(max_attempts=20, delay=3):
+ return restart_service_and_wait("wazuh-indexer", max_attempts=max_attempts, delay=delay)
+
+ # -----------------------------------------
+ # DASHBOARD STATUS
+ # -----------------------------------------
+ @staticmethod
+ def status_dashboard():
+ return get_service_status("wazuh-dashboard") or "unknown"
+
+ # -----------------------------------------
+ # INDEXER STATUS
+ # -----------------------------------------
+ @staticmethod
+ def status_indexer():
+ return run_command("systemctl is-active wazuh-indexer") or "unknown"
+
+ # -----------------------------------------
+ # CONNECTIVITY CHECK
+ # -----------------------------------------
+ @staticmethod
+ def check_connectivity(password):
+ cmd = (
+ f"curl -XGET -k -u {KIBANA_USERNAME}:{password} "
+ f"{INDEXER_URL}/_cluster/health"
+ )
+ return run_command(cmd) or ""
+
+ # -----------------------------------------
+ # GENERATE NEW PASSWORD
+ # -----------------------------------------
+ @staticmethod
+ def generate_password(length=16):
+ chars = string.ascii_letters + string.digits + ".*+?-"
+ return ''.join(secrets.choice(chars) for _ in range(length))
+
+ # -----------------------------------------
+ # APPLY NEW PASSWORD (INDEXER + DASHBOARD)
+ # -----------------------------------------
+ @staticmethod
+ def apply_new_password(password):
+ cmd1 = (
+ "/usr/share/wazuh-indexer/plugins/opensearch-security/tools/"
+ f"wazuh-passwords-tool.sh -u kibanaserver -p '{password}'"
+ )
+ cmd2 = (
+ f"printf '%s' '{password}' | "
+ "/usr/share/wazuh-dashboard/bin/opensearch-dashboards-keystore "
+ "--allow-root add -f --stdin opensearch.password"
+ )
+ out1 = run_command(cmd1) or ""
+ out2 = run_command(cmd2) or ""
+ return f"{out1}\n{out2}"
+
+ # -----------------------------------------
+ # VERIFY PASSWORD
+ # -----------------------------------------
+ @staticmethod
+ def verify_password(password):
+ cmd = (
+ f"curl -s -k -u {KIBANA_USERNAME}:{password} "
+ f"{INDEXER_URL}"
+ )
+ return run_command(cmd) or ""
+
+ # -----------------------------------------
+ # HEAP FIX STEPS (manual instructions)
+ # -----------------------------------------
+ @staticmethod
+ def heap_steps():
+ return (
+ "Edit file:\n"
+ " /etc/wazuh-indexer/jvm.options\n\n"
+ "Set heap to 50% of your RAM.\n"
+ "Example for 8 GB system:\n"
+ " -Xms4g\n"
+ " -Xmx4g\n\n"
+ "Then restart:\n"
+ " systemctl restart wazuh-indexer"
+ )
+
+ # -------------------------------------------------------------------------
+ # FIX JVM HEAP
+ # -------------------------------------------------------------------------
+ @staticmethod
+ def fix_jvm_heap(heap_gb):
+
+ run_command(
+ f"sed -i 's/^-Xms.*/-Xms{heap_gb}g/' "
+ "/etc/wazuh-indexer/jvm.options"
+ )
+
+ run_command(
+ f"sed -i 's/^-Xmx.*/-Xmx{heap_gb}g/' "
+ "/etc/wazuh-indexer/jvm.options"
+ )
+
+ # Restart and actually wait for the service to come back up, the
+ # same way fix_indexer_ip() and fix_indexer_cert_paths() do.
+ # A bare run_command("systemctl restart ...") returns immediately
+ # once the restart is *issued*, not once it's actually active, so
+ # it can look like nothing happened if the service takes a moment
+ # or fails to come back up.
+ status = FixEngine.restart_indexer_and_wait()
+
+ updated = run_command(
+ "grep -E '^-Xms|^-Xmx' "
+ "/etc/wazuh-indexer/jvm.options"
+ ) or "(could not read)"
+
+ return {"updated": updated, "status": status}
+
+ # -----------------------------------------
+ # SECURITY INIT COMMAND
+ # -----------------------------------------
+ @staticmethod
+ def init_command():
+ return (
+ "/usr/share/wazuh-indexer/bin/indexer-security-init.sh"
+ )
+
+ # -----------------------------------------
+ # PERMISSION FIX STEPS (manual instructions)
+ # -----------------------------------------
+ @staticmethod
+ def permission_fix():
+ return (
+ "Run the following commands:\n"
+ " chmod 600 /usr/share/wazuh-indexer/config/jvm.options\n"
+ " chmod 600 /usr/share/wazuh-indexer/config/opensearch.yml\n"
+ " chmod 600 /usr/share/wazuh-indexer/config/opensearch-security/*.yml\n\n"
+ "Then restart:\n"
+ " systemctl restart wazuh-indexer"
+ )
+
+ # -----------------------------------------
+ # DISK CHECK
+ # -----------------------------------------
+ @staticmethod
+ def check_disk():
+ return run_command("df -h") or ""
+
+ # -----------------------------------------
+ # MANUAL COMMAND SETS (for "give me commands" path)
+ # -----------------------------------------
+ @staticmethod
+ def commands_ip_fix(c_ip):
+ return (
+ f"sed -i 's|https://.*:9200|https://{c_ip}:9200|' "
+ "/etc/wazuh-dashboard/opensearch_dashboards.yml\n"
+ "systemctl restart wazuh-dashboard"
+ )
+
+ @staticmethod
+ def commands_cert_permissions():
+ return (
+ "chmod 500 /etc/wazuh-dashboard/certs\n"
+ "chmod 400 /etc/wazuh-dashboard/certs/*\n"
+ "chown -R wazuh-dashboard:wazuh-dashboard /etc/wazuh-dashboard/certs\n"
+ "systemctl restart wazuh-dashboard"
+ )
+
+ @staticmethod
+ def commands_restart_indexer():
+ return "systemctl restart wazuh-indexer"
+
+ @staticmethod
+ def commands_get_indexer_logs():
+ return (
+ "journalctl -u wazuh-indexer --since '1 hour ago' "
+ "| grep -i -E 'error|warn'"
+ )
+
+ # -----------------------------------------
+ # CHECK INDEXER IP (control vs opensearch.yml)
+ # -----------------------------------------
+ @staticmethod
+ def check_indexer_ip():
+ control = FixEngine.get_control_ip()
+ indexer = FixEngine.get_indexer_ip()
+
+ c_ip = FixEngine.extract_ip(control)
+ i_ip = FixEngine.extract_ip(indexer)
+
+ return {
+ "c_ip": c_ip,
+ "i_ip": i_ip,
+ "match": bool(c_ip and i_ip and c_ip == i_ip),
+ }
+
+ # -----------------------------------------
+ # FIX INDEXER IP (auto correct) + restart, waits for active
+ # -----------------------------------------
+ @staticmethod
+ def fix_indexer_ip(c_ip):
+ run_command(
+ f"sed -i 's/^network.host:.*/network.host: {c_ip}/' "
+ "/etc/wazuh-indexer/opensearch.yml"
+ )
+ return FixEngine.restart_indexer_and_wait()
+
+ # -----------------------------------------
+ # CHECK INDEXER CERT PATHS
+ # -----------------------------------------
+ @staticmethod
+ def check_indexer_cert_paths():
+ paths_raw = run_command(
+ "grep -E 'pemkey_filepath|pemcert_filepath|pemtrustedcas_filepath' "
+ "/etc/wazuh-indexer/opensearch.yml"
+ ) or ""
+
+ files_raw = run_command("ls /etc/wazuh-indexer/certs") or ""
+
+ configured = []
+ for line in paths_raw.splitlines():
+ if ":" in line:
+ val = line.split(":", 1)[1].strip()
+ configured.append(val.split("/")[-1])
+
+ actual = [f.strip() for f in files_raw.splitlines() if f.strip()]
+ missing = [f for f in configured if f not in actual]
+
+ return {
+ "paths_raw": paths_raw,
+ "files_raw": files_raw,
+ "missing": missing,
+ }
+
+ # -----------------------------------------
+ # FIX INDEXER CERT PATHS (auto correct) + restart, waits for active
+ # -----------------------------------------
+ @staticmethod
+ def fix_indexer_cert_paths():
+ actual_files = run_command("ls /etc/wazuh-indexer/certs") or ""
+ actual = [f.strip() for f in actual_files.splitlines() if f.strip()]
+
+ key = next((f for f in actual if "key" in f and "admin" not in f), None)
+ cert = next((f for f in actual if "key" not in f and "root" not in f
+ and "admin" not in f), None)
+ ca = next((f for f in actual if "root-ca" in f), None)
+
+ if not (key and cert and ca):
+ return {"success": False}
+
+ base = "/etc/wazuh-indexer/certs"
+ cmds = [
+ f"sed -i 's|pemcert_filepath:.*|pemcert_filepath: {base}/{cert}|g' "
+ "/etc/wazuh-indexer/opensearch.yml",
+ f"sed -i 's|pemkey_filepath:.*|pemkey_filepath: {base}/{key}|g' "
+ "/etc/wazuh-indexer/opensearch.yml",
+ f"sed -i 's|pemtrustedcas_filepath:.*|pemtrustedcas_filepath: {base}/{ca}|g' "
+ "/etc/wazuh-indexer/opensearch.yml",
+ ]
+ for cmd in cmds:
+ run_command(cmd)
+
+ status = FixEngine.restart_indexer_and_wait()
+
+ return {"success": True, "cert": cert, "key": key, "ca": ca, "status": status}
+
+ # -----------------------------------------
+ # CHECK JVM HEAP (current vs recommended)
+ # -----------------------------------------
+ @staticmethod
+ def check_jvm_heap():
+ current = run_command(
+ "grep -E '^-Xms|^-Xmx' /etc/wazuh-indexer/jvm.options"
+ ) or "(could not read)"
+
+ total_kb = run_command(
+ "grep MemTotal /proc/meminfo | awk '{print $2}'"
+ ) or "0"
+
+ try:
+ total_gb = round(int(total_kb.strip()) / 1024 / 1024)
+ except ValueError:
+ total_gb = 0
+
+ heap_gb = max(1, total_gb // 2)
+
+ return {"current": current, "total_gb": total_gb, "recommended_heap": heap_gb}
+
+ # -----------------------------------------
+ # CHECK DASHBOARD IP
+ # -----------------------------------------
+ @staticmethod
+ def check_dashboard_ip():
+ dash_raw = FixEngine.get_dashboard_ip()
+ control = FixEngine.get_control_ip()
+
+ d_ip = FixEngine.extract_ip(dash_raw)
+ c_ip = FixEngine.extract_ip(control)
+
+ return {
+ "d_ip": d_ip,
+ "c_ip": c_ip,
+ "match": bool(d_ip and c_ip and d_ip == c_ip),
+ }
+
+ # -----------------------------------------
+ # FIX DASHBOARD IP (auto correct) + restart, waits for active
+ # -----------------------------------------
+ @staticmethod
+ def fix_dashboard_ip(c_ip):
+ run_command(
+ f"sed -i 's|https://.*:9200|https://{c_ip}:9200|' "
+ "/etc/wazuh-dashboard/opensearch_dashboards.yml"
+ )
+ return restart_service_and_wait("wazuh-dashboard")
+
+ # -----------------------------------------
+ # CHECK DASHBOARD CERT PATHS
+ # -----------------------------------------
+ @staticmethod
+ def check_dashboard_cert_paths():
+ paths_raw = run_command(
+ "grep -E 'ssl.certificate|ssl.key|certificateAuthorities' "
+ "/etc/wazuh-dashboard/opensearch_dashboards.yml"
+ ) or ""
+
+ files_raw = run_command("ls /etc/wazuh-dashboard/certs") or ""
+
+ configured = []
+ for line in paths_raw.splitlines():
+ if ":" in line:
+ val = line.split(":", 1)[1].strip().strip('"').strip("'").strip("[]")
+ val = val.strip('"').strip("'")
+ filename = val.split("/")[-1]
+ if filename:
+ configured.append(filename)
+
+ actual = [f.strip() for f in files_raw.splitlines() if f.strip()]
+ missing = [f for f in configured if f not in actual]
+
+ return {
+ "paths_raw": paths_raw,
+ "files_raw": files_raw,
+ "missing": missing,
+ }
+
+ # -----------------------------------------
+ # FIX DASHBOARD CERT PATHS (auto correct) + restart, waits for active
+ # -----------------------------------------
+ @staticmethod
+ def fix_dashboard_cert_paths():
+ actual_files = run_command("ls /etc/wazuh-dashboard/certs") or ""
+ actual = [f.strip() for f in actual_files.splitlines() if f.strip()]
+
+ key = next((f for f in actual if "key" in f and "admin" not in f), None)
+ cert = next((f for f in actual if "key" not in f and "root" not in f
+ and "admin" not in f and "ca" not in f.lower()), None)
+ ca = next((f for f in actual if "root-ca" in f or
+ ("ca" in f.lower() and "key" not in f)), None)
+
+ if not (key and cert and ca):
+ return {"success": False}
+
+ base = "/etc/wazuh-dashboard/certs"
+ cmds = [
+ f"sed -i 's|server.ssl.certificate:.*|server.ssl.certificate: {base}/{cert}|g' "
+ "/etc/wazuh-dashboard/opensearch_dashboards.yml",
+ f"sed -i 's|server.ssl.key:.*|server.ssl.key: {base}/{key}|g' "
+ "/etc/wazuh-dashboard/opensearch_dashboards.yml",
+ "sed -i 's|opensearch.ssl.certificateAuthorities:.*"
+ f"|opensearch.ssl.certificateAuthorities: [\"{base}/{ca}\"]|g' "
+ "/etc/wazuh-dashboard/opensearch_dashboards.yml",
+ ]
+ for cmd in cmds:
+ run_command(cmd)
+
+ status = restart_service_and_wait("wazuh-dashboard")
+
+ return {"success": True, "cert": cert, "key": key, "ca": ca, "status": status}
+
+ # -----------------------------------------
+ # DASHBOARD CERT PATH MANUAL STEPS
+ # -----------------------------------------
+ @staticmethod
+ def dashboard_cert_path_steps():
+ return (
+ "Update the cert paths in:\n"
+ " /etc/wazuh-dashboard/opensearch_dashboards.yml\n\n"
+ "Keys to fix:\n"
+ " server.ssl.certificate\n"
+ " server.ssl.key\n"
+ " opensearch.ssl.certificateAuthorities\n\n"
+ "Match them to the files in /etc/wazuh-dashboard/certs/"
+ )
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/index_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/index_utils.py
new file mode 100644
index 00000000..7bb3e813
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/index_utils.py
@@ -0,0 +1,95 @@
+
+import re
+import datetime
+from utils.api_utils import indexer_api_get, indexer_api_delete
+
+_INDEX_DATE_RE = re.compile(r"(\d{4})\.(\d{2})\.(\d{2})")
+
+
+def list_indices(pattern="wazuh-alerts-*"):
+ raw = indexer_api_get(
+ f"/_cat/indices/{pattern}?h=index,health,status,docs.count,store.size"
+ ) or ""
+ rows = []
+ for line in raw.splitlines():
+ parts = line.split()
+ if len(parts) >= 5:
+ rows.append({
+ "index": parts[0], "health": parts[1], "status": parts[2],
+ "docs_count": parts[3], "store_size": parts[4],
+ })
+ return rows
+
+
+def index_has_todays_date(pattern="wazuh-alerts-*"):
+ today = datetime.date.today().strftime("%Y.%m.%d")
+ return any(today in row["index"] for row in list_indices(pattern))
+
+
+def _index_date(index_name):
+ m = _INDEX_DATE_RE.search(index_name)
+ if not m:
+ return None
+ try:
+ return datetime.date(int(m.group(1)), int(m.group(2)), int(m.group(3)))
+ except ValueError:
+ return None
+
+
+def _freshness_result(index_name, index_date):
+ if not index_name or not index_date:
+ return {"index": index_name, "date": None, "days_old": None, "is_today": False}
+ days_old = (datetime.date.today() - index_date).days
+ return {"index": index_name, "date": index_date, "days_old": days_old, "is_today": days_old <= 0}
+
+
+def check_most_recent_index(pattern="wazuh-alerts-*"):
+ """
+ Find the newest index matching `pattern` by its date suffix (not creation
+ time - the suffix is what tells us whether TODAY's alerts are landing).
+ Returns {"index", "date", "days_old", "is_today"} - all None/False if no
+ index in the pattern has a parseable date suffix.
+ """
+ best_name, best_date = None, None
+ for row in list_indices(pattern):
+ d = _index_date(row["index"])
+ if d and (best_date is None or d > best_date):
+ best_name, best_date = row["index"], d
+ return _freshness_result(best_name, best_date)
+
+
+def check_index_name_freshness(index_name):
+ """Same result shape as check_most_recent_index(), for a single index name
+ a user typed/pasted in manually rather than one we looked up ourselves."""
+ return _freshness_result(index_name, _index_date(index_name or ""))
+
+
+def select_indices_by_age(pattern="wazuh-alerts-*", older_than_days=None, start_date=None, end_date=None):
+ """
+ Resolve a request like "older than 30 days" or "2026-01-01 to
+ 2026-01-07" into a concrete list of index rows, WITHOUT deleting
+ anything. Callers must show this list to the user and get explicit
+ confirmation before calling delete_indices().
+ """
+ indices = list_indices(pattern)
+ today = datetime.date.today()
+ cutoff = today - datetime.timedelta(days=older_than_days) if older_than_days else None
+
+ matched = []
+ for row in indices:
+ d = _index_date(row["index"])
+ if d is None:
+ continue
+ if cutoff and d >= cutoff:
+ continue
+ if start_date and d < start_date:
+ continue
+ if end_date and d > end_date:
+ continue
+ matched.append(row)
+ return matched
+
+
+def delete_indices(index_names):
+ """Delete an explicit list of indices. Caller must have already shown & confirmed this exact list."""
+ return {name: indexer_api_delete(f"/{name}") for name in index_names}
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/lgtm_db.py b/integrations/wazuh-troubleshooting-tool/backend/utils/lgtm_db.py
new file mode 100644
index 00000000..224fbe43
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/lgtm_db.py
@@ -0,0 +1,148 @@
+"""
+SQLite-backed store for the unified Wazuh knowledge base, with embeddings for
+semantic search. Covers three sources, all searched together as one pool:
+ - wazuh/community issues labeled LGTM or review/quality
+ - wazuh/community Discussions (isAnswered)
+ - wazuh/wazuh (public repo) closed issues
+
+Replaces the old lgtm_issues.json + fuzzy-string-matching approach - fuzzy
+matching only catches questions textually similar to a stored issue; cosine
+similarity over embeddings also catches semantically similar questions worded
+completely differently.
+
+Embeddings are computed once per item at sync time (not per query), so the
+per-query cost is just one embedding call for the user's question plus an
+in-memory cosine-similarity scan - fast even on CPU (a few thousand items'
+worth of 768-dim float32 vectors is a few MB, trivial to hold in memory).
+
+Primary key is "source:number" (not just number) since community issues,
+community discussions, and public wazuh/wazuh issues each have their own
+independent numbering and would otherwise collide.
+"""
+import json
+import os
+import sqlite3
+import struct
+
+import numpy as np
+
+from utils.embeddings import embed_document, embed_query
+
+_DB_PATH = os.path.join(os.path.dirname(__file__), "..", "knowledge", "lgtm.db")
+
+
+def _connect():
+ os.makedirs(os.path.dirname(_DB_PATH), exist_ok=True)
+ conn = sqlite3.connect(_DB_PATH)
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS issues (
+ id TEXT PRIMARY KEY,
+ source TEXT,
+ number INTEGER,
+ title TEXT,
+ body TEXT,
+ comments TEXT,
+ external_community TEXT,
+ url TEXT,
+ labels TEXT,
+ embedding BLOB
+ )
+ """)
+ return conn
+
+
+def _to_blob(vector):
+ return struct.pack(f"{len(vector)}f", *vector)
+
+
+def _from_blob(blob):
+ n = len(blob) // 4
+ return np.array(struct.unpack(f"{n}f", blob), dtype=np.float32)
+
+
+def _embedding_text(issue):
+ return (
+ issue["title"] + "\n"
+ + issue["body"][:1000] + "\n"
+ + "\n".join(issue.get("comments", []))[:2000]
+ )
+
+
+def upsert_issue(issue: dict, source: str = "community_issue") -> bool:
+ """issue: {number, title, body, comments, external_community, url, labels}.
+ source: "community_issue" | "community_discussion" | "public_wazuh_issue".
+ Returns False (and doesn't write) if the embedding call fails, so a
+ flaky Ollama request during sync doesn't corrupt the DB with a null vector."""
+ embedding = embed_document(_embedding_text(issue))
+ if embedding is None:
+ return False
+
+ item_id = f"{source}:{issue['number']}"
+ conn = _connect()
+ conn.execute(
+ """INSERT INTO issues (id, source, number, title, body, comments, external_community, url, labels, embedding)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ ON CONFLICT(id) DO UPDATE SET
+ title=excluded.title, body=excluded.body, comments=excluded.comments,
+ external_community=excluded.external_community, url=excluded.url,
+ labels=excluded.labels, embedding=excluded.embedding""",
+ (
+ item_id, source, issue["number"], issue["title"], issue["body"],
+ json.dumps(issue.get("comments", [])),
+ json.dumps(issue.get("external_community", [])),
+ issue.get("url", ""), json.dumps(issue.get("labels", [])),
+ _to_blob(embedding),
+ ),
+ )
+ conn.commit()
+ conn.close()
+ return True
+
+
+def _row_to_issue(row):
+ return {
+ "source": row[0], "number": row[1], "title": row[2], "body": row[3],
+ "comments": json.loads(row[4]), "external_community": json.loads(row[5]),
+ "url": row[6], "labels": json.loads(row[7]),
+ }
+
+
+def count(source: str = None) -> int:
+ if not os.path.exists(_DB_PATH):
+ return 0
+ conn = _connect()
+ if source:
+ n = conn.execute("SELECT COUNT(*) FROM issues WHERE source=?", (source,)).fetchone()[0]
+ else:
+ n = conn.execute("SELECT COUNT(*) FROM issues").fetchone()[0]
+ conn.close()
+ return n
+
+
+def search(query: str, top_n: int = 3, min_similarity: float = 0.5) -> list:
+ if not query or not os.path.exists(_DB_PATH):
+ return []
+
+ query_embedding = embed_query(query)
+ if query_embedding is None:
+ return []
+
+ q = np.array(query_embedding, dtype=np.float32)
+ q = q / (np.linalg.norm(q) or 1)
+
+ conn = _connect()
+ rows = conn.execute(
+ "SELECT source, number, title, body, comments, external_community, url, labels, embedding FROM issues"
+ ).fetchall()
+ conn.close()
+
+ scored = []
+ for row in rows:
+ emb = _from_blob(row[8])
+ emb = emb / (np.linalg.norm(emb) or 1)
+ similarity = float(np.dot(q, emb))
+ if similarity >= min_similarity:
+ scored.append((similarity, _row_to_issue(row[:8])))
+
+ scored.sort(key=lambda x: x[0], reverse=True)
+ return [issue for _, issue in scored[:top_n]]
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/lgtm_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/lgtm_utils.py
new file mode 100644
index 00000000..98cbe773
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/lgtm_utils.py
@@ -0,0 +1,40 @@
+"""
+Reads the locally-synced LGTM/review-quality knowledge base (backend/knowledge/lgtm.db,
+a SQLite DB with per-issue embeddings) and semantically matches it against a
+user's question via cosine similarity. Needs Ollama running locally with
+nomic-embed-text pulled. No GitHub credentials — the DB must already exist,
+produced by backend/knowledge/sync_lgtm_issues.py.
+"""
+from utils import lgtm_db
+
+
+def find_relevant_issues(query: str, top_n: int = 3, min_similarity: float = 0.5) -> list:
+ """Return up to top_n issues whose content is semantically closest to the query."""
+ return lgtm_db.search(query, top_n=top_n, min_similarity=min_similarity)
+
+
+def format_lgtm_context(issues: list) -> str:
+ if not issues:
+ return ""
+ parts = ["=== Internal knowledge base: previously resolved, verified Wazuh issues ==="]
+ for issue in issues:
+ resolution_bits = issue.get("comments", []) + issue.get("external_community", [])
+ # 2000 chars, not 1200 - some threads have a first-draft answer followed
+ # by a reviewer's correction, and the correction matters more than the
+ # original; truncating too early risks cutting off exactly that part.
+ resolution = "\n---\n".join(resolution_bits)[:2000]
+ parts.append(
+ f"- Issue #{issue['number']}: {issue['title']}\n"
+ f" Question: {issue['body'][:500]}\n"
+ f" Discussion (in order - later comments may correct or refine earlier ones,\n"
+ f" e.g. a reviewer pointing out what the first answer got wrong or missed):\n"
+ f" {resolution}"
+ )
+ parts.append(
+ "Instructions: use the above to ground your answer. When a discussion thread contains "
+ "a correction or refinement to an earlier answer, follow the corrected version, not the "
+ "original. Do not quote this verbatim or mention it is from an internal issue tracker — "
+ "this content is confidential and for grounding only."
+ )
+ parts.append("=================================================")
+ return "\n".join(parts)
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/log_analyzer.py b/integrations/wazuh-troubleshooting-tool/backend/utils/log_analyzer.py
new file mode 100644
index 00000000..af16c11e
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/log_analyzer.py
@@ -0,0 +1,58 @@
+class LogAnalyzer:
+
+ # -----------------------------------------
+ # DETECT KNOWN ISSUES FROM LOG TEXT
+ # Returns a list of issue keys found in the logs
+ # e.g. ["heap", "auth"]
+ # -----------------------------------------
+ @staticmethod
+ def get_issues(logs):
+ if not logs:
+ return []
+
+ text = logs.lower()
+ issues = []
+
+ # NOT INITIALIZED
+ # Only relevant on fresh/new installations
+ if "not yet initialized" in text:
+ issues.append("init")
+
+ # HEAP / MEMORY
+ # Can be fixed auto or manual — handled in dashboard_error_flow
+ if any(kw in text for kw in [
+ "circuit_breaking_exception",
+ "data too large",
+ "high heap usage",
+ "gc did bring memory usage down",
+ "g1gc",
+ "heap usage",
+ ]):
+ issues.append("heap")
+
+ # AUTH FAILURE
+ # Flag only — fix steps to be added later
+ if "authentication finally failed for kibanaserver" in text:
+ issues.append("auth")
+
+ # DISK WATERMARK
+ # Inform only — user must free up disk manually
+ if any(kw in text for kw in [
+ "low disk watermark",
+ "high disk watermark",
+ "flood stage disk watermark",
+ "disk usage exceeded",
+ ]):
+ issues.append("watermark")
+
+ # FILE PERMISSIONS
+ # Flag only — fix steps to be added later
+ if "insecure file permissions" in text:
+ issues.append("permission")
+ if any(kw in text for kw in [
+ "econnrefused",
+ "connectionerror",
+ "connect econnrefused",
+ ]):
+ issues.append("dashboard_connection_refused")
+ return issues
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/log_handler.py b/integrations/wazuh-troubleshooting-tool/backend/utils/log_handler.py
new file mode 100644
index 00000000..b01028c0
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/log_handler.py
@@ -0,0 +1,57 @@
+from executor import run_command
+import re
+
+
+class LogHandler:
+
+ # -----------------------------------------
+ # GET INDEXER LOGS
+ # Reads from /var/log/wazuh-indexer/wazuh-cluster.log
+ # Uses awk to filter lines from the last X hours,
+ # then grep to keep only error/warn lines.
+ # Fixed: ($1" "$2) compares only the timestamp portion
+ # of each line — not the entire line — so filtering
+ # works correctly regardless of log content.
+ # -----------------------------------------
+ @staticmethod
+ def get_indexer_logs(hours=2):
+ cmd = (
+ f"awk -v d1=\"$(date --date='{hours} hours ago' '+%Y-%m-%d %H:%M:%S')\" "
+ f"'($1\" \"$2) >= d1' /var/log/wazuh-indexer/wazuh-cluster.log "
+ "| grep -i -E 'error|warn'"
+ )
+ return run_command(cmd) or ""
+
+ # -----------------------------------------
+ # GET DASHBOARD LOGS
+ # Uses journalctl for the wazuh-dashboard service
+ # -----------------------------------------
+ @staticmethod
+ def get_dashboard_logs(hours=2):
+ return run_command(
+ f"journalctl -u wazuh-dashboard --since '{hours} hours ago' "
+ "| grep -i -E 'error|warn'"
+ ) or ""
+
+ # -----------------------------------------
+ # CLEAN LOGS
+ # Deduplicates lines, strips timestamps for
+ # comparison only, returns max 50 unique lines
+ # -----------------------------------------
+ @staticmethod
+ def clean_logs(log_text):
+ if not log_text:
+ return "(no logs found)"
+
+ lines = log_text.splitlines()
+ seen = set()
+ unique = []
+
+ for line in lines:
+ # strip HH:MM:SS for dedup comparison only
+ cleaned = re.sub(r"\d{2}:\d{2}:\d{2}", "", line).strip()
+ if cleaned and cleaned not in seen:
+ seen.add(cleaned)
+ unique.append(line) # keep original line for display
+
+ return "\n".join(unique[:50])
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/manager_config_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/manager_config_utils.py
new file mode 100644
index 00000000..97fd5a16
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/manager_config_utils.py
@@ -0,0 +1,44 @@
+import re
+from executor import run_command
+
+OSSEC_CONF_PATH = "/var/ossec/etc/ossec.conf"
+
+
+def get_log_alert_level():
+ """Configured log_alert_level as an int, or None if not set/found."""
+ raw = run_command(f"grep 'log_alert_level' {OSSEC_CONF_PATH}") or ""
+ match = re.search(r"\s*(\d+)\s* ", raw)
+ return int(match.group(1)) if match else None
+
+
+def is_log_alert_level_ok(level=None):
+ """
+ Alerts with a rule level below log_alert_level are never logged by the
+ manager. A value above 15 means most/all alerts get silently dropped
+ before they ever reach alerts.json.
+ """
+ level = get_log_alert_level() if level is None else level
+ return level is not None and level <= 15
+
+
+def set_log_alert_level(value=3):
+ """Set log_alert_level in ossec.conf. Caller is responsible for restarting wazuh-manager afterward."""
+ replacement = "{}<\\/log_alert_level>".format(value)
+ cmd = "sed -i 's/.*<\\/log_alert_level>/{}/' {}".format(replacement, OSSEC_CONF_PATH)
+ run_command(cmd)
+ return get_log_alert_level()
+
+
+def get_jsonout_output_enabled():
+ """True if jsonout_output is set to 'yes' (required for alerts.json to be written)."""
+ raw = run_command(f"grep 'jsonout_output' {OSSEC_CONF_PATH}") or ""
+ match = re.search(r"\s*(yes|no)\s* ", raw, re.IGNORECASE)
+ return bool(match and match.group(1).lower() == "yes")
+
+
+def enable_jsonout_output():
+ """Set jsonout_output to yes in ossec.conf. Caller is responsible for restarting wazuh-manager afterward."""
+ replacement = "yes<\\/jsonout_output>"
+ cmd = "sed -i 's/.*<\\/jsonout_output>/{}/' {}".format(replacement, OSSEC_CONF_PATH)
+ run_command(cmd)
+ return get_jsonout_output_enabled()
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/manager_log_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/manager_log_utils.py
new file mode 100644
index 00000000..11161fe2
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/manager_log_utils.py
@@ -0,0 +1,38 @@
+import time
+from executor import run_command
+
+OSSEC_LOG_PATH = "/var/ossec/logs/ossec.log"
+
+
+def tail_manager_log(lines=200):
+ """Raw last N lines of ossec.log, no filtering."""
+ return run_command(f"tail -n {lines} {OSSEC_LOG_PATH}") or ""
+
+
+def get_manager_log_errors(lines=200):
+ """Last N lines of ossec.log filtered to error/warn only."""
+ return run_command(f"tail -n {lines} {OSSEC_LOG_PATH} | grep -i -E 'error|warn'") or ""
+
+
+def has_manager_log_errors(lines=200):
+ """True/False, so callers don't have to test truthiness of the string themselves."""
+ return bool(get_manager_log_errors(lines).strip())
+
+
+def manager_log_age_seconds():
+ """Seconds since ossec.log was last written to, or None if the file doesn't exist."""
+ exists = (run_command(f"test -f {OSSEC_LOG_PATH} && echo yes || echo no") or "").strip()
+ if exists != "yes":
+ return None
+ mtime_raw = (run_command(f"stat -c %Y {OSSEC_LOG_PATH}") or "").strip()
+ try:
+ return int(time.time()) - int(mtime_raw)
+ except ValueError:
+ return None
+
+
+def get_manager_disk_usage():
+ """`df -h` for the manager's data directory - checked when the pipeline test
+ (restarting an agent and looking for its event) comes back empty, since a
+ full disk on the manager is a common silent cause of that."""
+ return run_command("df -h /var/ossec") or ""
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/pipeline_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/pipeline_utils.py
new file mode 100644
index 00000000..4a24016c
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/pipeline_utils.py
@@ -0,0 +1,181 @@
+"""
+Reusable helpers for diagnosing the full Wazuh alert pipeline:
+
+ Wazuh Agent -> Wazuh Manager -> alerts.json -> Filebeat -> Wazuh Indexer -> Dashboard
+
+Centralized here so any use case (no_alerts_are_showing, indexing_error,
+cluster_issues, etc.) can reuse the same checks instead of each one
+re-implementing ossec.conf parsing / alerts.json staleness / shard
+analysis by hand.
+"""
+
+import re
+import time
+
+from executor import run_command
+from utils.api_utils import indexer_api_get, indexer_api_get_json
+
+OSSEC_CONF_PATH = "/var/ossec/etc/ossec.conf"
+ALERTS_JSON_PATH = "/var/ossec/logs/alerts/alerts.json"
+OSSEC_LOG_PATH = "/var/ossec/logs/ossec.log"
+FILEBEAT_LOG_PATH = "/var/log/filebeat/filebeat"
+
+
+# ---------------------------------------------------------------------------
+# STEP 1/2 — AGENT STATUS
+# ---------------------------------------------------------------------------
+def get_agent_status(identifier=None):
+ """
+ Run `agent_control -l` and, if `identifier` (name or id) is given,
+ try to find that specific agent's line and report whether it's Active.
+
+ Returns {"raw": , "is_active": True/False/None}
+ `is_active` is None when we can't determine a clear answer
+ (e.g. identifier not found in the output).
+ """
+ raw = run_command("/var/ossec/bin/agent_control -l") or ""
+
+ is_active = None
+ if identifier:
+ for line in raw.splitlines():
+ if identifier.lower() in line.lower():
+ is_active = "active" in line.lower()
+ break
+ else:
+ is_active = bool(re.search(r"\bActive\b", raw))
+
+ return {"raw": raw, "is_active": is_active}
+
+
+# ---------------------------------------------------------------------------
+# STEP 3/4 — MANAGER CONFIG (ossec.conf)
+# ---------------------------------------------------------------------------
+def check_manager_config():
+ """
+ Verify the two ossec.conf settings that silently swallow alerts when
+ misconfigured: log_alert_level (must be <= 15) and jsonout_output
+ (must be "yes", otherwise alerts.json is never written).
+ """
+ level_raw = run_command(f"grep 'log_alert_level' {OSSEC_CONF_PATH}") or ""
+ jsonout_raw = run_command(f"grep 'jsonout_output' {OSSEC_CONF_PATH}") or ""
+
+ level_match = re.search(r"\s*(\d+)\s* ", level_raw)
+ level = int(level_match.group(1)) if level_match else None
+
+ jsonout_match = re.search(
+ r"\s*(yes|no)\s* ", jsonout_raw, re.IGNORECASE
+ )
+ jsonout_enabled = bool(jsonout_match and jsonout_match.group(1).lower() == "yes")
+
+ return {
+ "log_alert_level": level,
+ "log_alert_level_ok": level is not None and level <= 15,
+ "jsonout_output_enabled": jsonout_enabled,
+ "raw": "\n".join(x for x in [level_raw.strip(), jsonout_raw.strip()] if x),
+ }
+
+
+# ---------------------------------------------------------------------------
+# STEP 5 — alerts.json FRESHNESS
+# ---------------------------------------------------------------------------
+def get_alerts_json_status(max_age_seconds=300, tail_lines=5):
+ """
+ Check whether the manager is actively writing new alerts to
+ alerts.json (the file Filebeat reads from). `age_seconds` is how long
+ ago the file was last modified; if it's older than `max_age_seconds`
+ (default 5 min) we consider the pipeline stalled at the manager.
+ """
+ exists = (run_command(f"test -f {ALERTS_JSON_PATH} && echo yes || echo no") or "").strip()
+ if exists != "yes":
+ return {"exists": False, "age_seconds": None, "is_fresh": False, "tail": ""}
+
+ mtime_raw = (run_command(f"stat -c %Y {ALERTS_JSON_PATH}") or "").strip()
+ try:
+ age = int(time.time()) - int(mtime_raw)
+ except ValueError:
+ age = None
+
+ tail = run_command(f"tail -n {tail_lines} {ALERTS_JSON_PATH}") or ""
+
+ return {
+ "exists": True,
+ "age_seconds": age,
+ "is_fresh": age is not None and age <= max_age_seconds,
+ "tail": tail,
+ }
+
+
+# ---------------------------------------------------------------------------
+# STEP 6 — MANAGER LOGS (ossec.log)
+# ---------------------------------------------------------------------------
+def get_manager_log_errors(lines=200):
+ return run_command(f"tail -n {lines} {OSSEC_LOG_PATH} | grep -i -E 'error|warn'") or ""
+
+
+# ---------------------------------------------------------------------------
+# STEP 7 — FILEBEAT
+# ---------------------------------------------------------------------------
+def run_filebeat_output_test():
+ out = run_command("filebeat test output") or ""
+ upper = out.upper()
+ ok = "OK" in upper and "ERROR" not in upper
+ return {"raw": out, "ok": ok}
+
+
+def get_filebeat_log_errors(lines=200):
+ return run_command(f"tail -n {lines} {FILEBEAT_LOG_PATH} | grep -i -E 'error|warn'") or ""
+
+
+# ---------------------------------------------------------------------------
+# STEP 8/9 — INDEXER: CLUSTER HEALTH + SHARDS
+# ---------------------------------------------------------------------------
+def check_cluster_shards():
+ """
+ Pull /_cluster/health and, if the status isn't green and there are
+ unassigned shards, also pull the unassigned shard list so the caller
+ can show *why* they're unassigned (disk watermark, no replica node, etc).
+ """
+ health, raw_health = indexer_api_get_json("/_cluster/health")
+
+ if not health:
+ return {"reachable": False, "raw": raw_health}
+
+ result = {
+ "reachable": True,
+ "status": health.get("status", "unknown"),
+ "number_of_nodes": health.get("number_of_nodes", 0),
+ "active_shards": health.get("active_shards", 0),
+ "unassigned_shards": health.get("unassigned_shards", 0),
+ "raw": raw_health,
+ }
+
+ if result["status"] != "green" and result["unassigned_shards"]:
+ shards_raw = indexer_api_get("/_cat/shards?h=index,shard,state,unassigned.reason") or ""
+ result["unassigned_detail"] = "\n".join(
+ line for line in shards_raw.splitlines() if "UNASSIGNED" in line
+ )[:2000]
+
+ return result
+
+
+# ---------------------------------------------------------------------------
+# STEP 10 — INDEXER: wazuh-alerts-* INDICES
+# ---------------------------------------------------------------------------
+def check_alert_indices():
+ """
+ Confirm that wazuh-alerts-* indices exist, are healthy, and that one
+ matching today's date is present (i.e. new data is actually landing
+ in the indexer, not just old indices from before the issue started).
+ """
+ raw = indexer_api_get("/_cat/indices/wazuh-alerts-*?h=index,health,status,docs.count") or ""
+ today = (run_command("date +%Y.%m.%d") or "").strip()
+
+ lines = [l for l in raw.splitlines() if l.strip()]
+ todays_index = next((l for l in lines if today and today in l), None)
+
+ return {
+ "indices": lines,
+ "todays_index_present": bool(todays_index),
+ "todays_index": todays_index,
+ "raw": raw,
+ }
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/public_repo_search.py b/integrations/wazuh-troubleshooting-tool/backend/utils/public_repo_search.py
new file mode 100644
index 00000000..2a2c51f3
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/public_repo_search.py
@@ -0,0 +1,141 @@
+"""
+Live GitHub search against the public wazuh/wazuh repo — no local file, no
+private token required, called at chat time by the copilot.
+
+Optionally set GITHUB_TOKEN in the backend's own environment (not in any
+project file) to raise the rate limit from 60 requests/hour to 5,000/hour,
+and to enable discussion search — GitHub's GraphQL API has no anonymous
+mode, so discussions are skipped silently if no token is set. Issue search
+works fine with no token at all, just at the lower rate limit.
+"""
+import os
+import re
+import requests
+
+REPO = os.environ.get("WAZUH_REPO", "wazuh/wazuh")
+TOKEN = os.environ.get("GITHUB_TOKEN")
+
+_ISSUE_HEADERS = {"Accept": "application/vnd.github+json"}
+if TOKEN:
+ _ISSUE_HEADERS["Authorization"] = f"Bearer {TOKEN}"
+
+_TIMEOUT = 6 # keep the copilot responsive even if GitHub is slow or down
+
+
+def _sanitize_query(query: str) -> str:
+ """Strip characters that carry special meaning in GitHub search syntax
+ (e.g. a stray ':' or '"' from a user's question turning into a qualifier)."""
+ return re.sub(r'["\':]', ' ', query).strip()
+
+
+def _fetch_issue_comments(issue_number, limit=5):
+ try:
+ resp = requests.get(
+ f"https://api.github.com/repos/{REPO}/issues/{issue_number}/comments",
+ headers=_ISSUE_HEADERS,
+ params={"per_page": limit},
+ timeout=_TIMEOUT,
+ )
+ if resp.status_code != 200:
+ return []
+ return [c.get("body") or "" for c in resp.json()]
+ except Exception:
+ return []
+
+
+def search_public_issues(query: str, top_n: int = 2) -> list:
+ """Live keyword search against GitHub issues in wazuh/wazuh."""
+ q = _sanitize_query(query)
+ if not q:
+ return []
+ try:
+ resp = requests.get(
+ "https://api.github.com/search/issues",
+ headers=_ISSUE_HEADERS,
+ params={"q": f"repo:{REPO} is:issue {q}", "per_page": top_n},
+ timeout=_TIMEOUT,
+ )
+ if resp.status_code != 200:
+ return []
+ except Exception:
+ return []
+
+ results = []
+ for item in resp.json().get("items", [])[:top_n]:
+ results.append({
+ "number": item["number"],
+ "title": item["title"],
+ "body": (item.get("body") or "")[:500],
+ "comments": _fetch_issue_comments(item["number"]),
+ "url": item["html_url"],
+ })
+ return results
+
+
+_DISCUSSION_SEARCH_QUERY = """
+query($searchQuery: String!) {
+ search(query: $searchQuery, type: DISCUSSION, first: 3) {
+ nodes {
+ ... on Discussion {
+ number
+ title
+ bodyText
+ url
+ isAnswered
+ answer { bodyText }
+ }
+ }
+ }
+}
+"""
+
+
+def search_public_discussions(query: str, top_n: int = 2) -> list:
+ """Live search against GitHub Discussions in wazuh/wazuh. Requires a
+ token (GraphQL has no anonymous mode) - any basic token works fine for
+ a public repo. Returns [] silently if no token is configured."""
+ if not TOKEN:
+ return []
+ q = _sanitize_query(query)
+ if not q:
+ return []
+ try:
+ resp = requests.post(
+ "https://api.github.com/graphql",
+ headers={"Authorization": f"Bearer {TOKEN}"},
+ json={
+ "query": _DISCUSSION_SEARCH_QUERY,
+ "variables": {"searchQuery": f"repo:{REPO} {q}"},
+ },
+ timeout=_TIMEOUT,
+ )
+ if resp.status_code != 200:
+ return []
+ except Exception:
+ return []
+
+ nodes = resp.json().get("data", {}).get("search", {}).get("nodes", [])
+ results = []
+ for d in nodes[:top_n]:
+ answer = d.get("answer")
+ results.append({
+ "number": d.get("number"),
+ "title": d.get("title"),
+ "body": (d.get("bodyText") or "")[:500],
+ "answer": answer.get("bodyText") if answer else "",
+ "url": d.get("url"),
+ })
+ return results
+
+
+def format_public_context(issues: list, discussions: list) -> str:
+ if not issues and not discussions:
+ return ""
+ parts = ["=== Public wazuh/wazuh issues & discussions (live GitHub search) ==="]
+ for issue in issues:
+ resolution = "\n".join(issue.get("comments", []))[:800]
+ parts.append(f"- Issue #{issue['number']}: {issue['title']}\n {issue['body']}\n Discussion: {resolution}")
+ for d in discussions:
+ parts.append(f"- Discussion #{d['number']}: {d['title']}\n {d['body']}\n Answer: {d.get('answer', '')}")
+ parts.append("=================================================")
+ return "\n".join(parts)
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/reindex_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/reindex_utils.py
new file mode 100644
index 00000000..e999127b
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/reindex_utils.py
@@ -0,0 +1,64 @@
+import json
+
+from utils.api_utils import indexer_api_post, indexer_api_delete
+
+
+def _step_ok(raw):
+ """
+ Did this indexer API call actually succeed? A non-2xx/error response, an
+ unparsable body, or a _reindex response with non-empty "failures" all
+ count as failed - checking only "truthy response text" previously let a
+ blocked/failed step through, and the next step would run anyway.
+ """
+ if not raw:
+ return False
+ try:
+ data = json.loads(raw)
+ except (ValueError, TypeError):
+ return False
+ if not isinstance(data, dict):
+ return False
+ if "error" in data:
+ return False
+ if data.get("failures"):
+ return False
+ return True
+
+
+def reindex_for_mapping_conflict(index_name):
+ """
+ 1. reindex -> -backup
+ 2. delete original
+ 3. reindex -backup -> (recreated with a clean mapping)
+ 4. delete -backup
+
+ Each step only runs if the previous one actually succeeded. This matters
+ because delete_original and delete_backup are irreversible - if step 1
+ (the backup) silently failed (e.g. index creation blocked cluster-wide)
+ and step 2 ran anyway, the original would be gone with no copy to
+ restore from. Stops and reports exactly which step failed instead.
+ """
+ backup_name = f"{index_name}-backup"
+ steps = {}
+
+ steps["backup"] = indexer_api_post(
+ "/_reindex", {"source": {"index": index_name}, "dest": {"index": backup_name}}
+ )
+ if not _step_ok(steps["backup"]):
+ steps["aborted_after"] = "backup"
+ return steps
+
+ steps["delete_original"] = indexer_api_delete(f"/{index_name}")
+ if not _step_ok(steps["delete_original"]):
+ steps["aborted_after"] = "delete_original"
+ return steps
+
+ steps["restore"] = indexer_api_post(
+ "/_reindex", {"source": {"index": backup_name}, "dest": {"index": index_name}}
+ )
+ if not _step_ok(steps["restore"]):
+ steps["aborted_after"] = "restore"
+ return steps
+
+ steps["delete_backup"] = indexer_api_delete(f"/{backup_name}")
+ return steps
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/replica_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/replica_utils.py
new file mode 100644
index 00000000..6c26bc4e
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/replica_utils.py
@@ -0,0 +1,17 @@
+from utils.api_utils import indexer_api_put
+
+
+def recommend_replica_count(node_count):
+ """0 replicas for a single node (nowhere to put a copy); 1 for 2+ nodes."""
+ return 0 if node_count <= 1 else 1
+
+
+def set_replica_count(index_pattern, replicas):
+ """
+ Update number_of_replicas on an existing index/pattern. This is a
+ dynamic setting - it applies to existing indices immediately, no
+ reindex required (reindexing is a separate procedure for field-mapping
+ conflicts - see utils/reindex_utils.py).
+ """
+ body = {"index": {"number_of_replicas": replicas}}
+ return indexer_api_put(f"/{index_pattern}/_settings", body)
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/response_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/response_utils.py
new file mode 100644
index 00000000..a525761c
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/response_utils.py
@@ -0,0 +1,30 @@
+"""
+Generic response-dict builder.
+
+Every use-case flow needs to return the same shape of dict back to the
+frontend: {"display": ..., "ask": ..., "done": ..., "context": ...}. Without
+a shared helper, every flow ends up hand-building this slightly differently
+(missing a key, forgetting to default "ask" to a list, etc). Use this
+instead of constructing the dict by hand.
+"""
+
+
+def make_response(display, ask=None, context=None, done=False, handoff=False):
+ """
+ Build a standard flow response.
+
+ display : str - the message to show the user.
+ ask : list - the question(s) being asked (empty list if none).
+ context : dict - the flow's context/state, carried forward.
+ done : bool - True if the troubleshooting flow is finished.
+ handoff : bool - True if control should be handed back to a parent
+ flow to continue at context["stage"] (used by
+ sub-flows like utils/step_flow.py).
+ """
+ return {
+ "display": display,
+ "ask": ask or [],
+ "done": done,
+ "context": context if context is not None else {},
+ "handoff": handoff,
+ }
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/service_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/service_utils.py
new file mode 100644
index 00000000..bff02248
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/service_utils.py
@@ -0,0 +1,48 @@
+"""
+Generic systemd service helpers.
+
+Reusable by ANY use case that needs to check or restart a service
+(wazuh-indexer, wazuh-manager, wazuh-dashboard, filebeat, etc.) instead of
+each use case writing its own run_command("systemctl ...") + sleep loop by
+hand.
+
+IMPORTANT: restart/start use "--no-block" so the systemctl command returns
+immediately instead of blocking until the service is fully up. For
+JVM-based services (like wazuh-indexer) that startup can take a long time,
+and a plain blocking "systemctl restart" gives zero feedback while it waits
+- it just looks stuck. Firing the command with --no-block and then polling
+"is-active" ourselves, with a bounded window, avoids that.
+"""
+
+import time
+from executor import run_command
+
+
+def get_service_status(service_name):
+ """Return the current systemd status string: 'active', 'inactive', 'failed', etc."""
+ return (run_command(f"systemctl is-active {service_name}") or "").strip()
+
+
+def restart_service_and_wait(service_name, max_attempts=20, delay=3):
+ """
+ Restart a systemd service and wait for it to actually come back up.
+ Returns the final status string once active, or after max_attempts.
+ """
+ run_command(f"systemctl --no-block restart {service_name}")
+ return _poll_until_active(service_name, max_attempts, delay)
+
+
+def start_service_and_wait(service_name, max_attempts=20, delay=3):
+ """Same as restart_service_and_wait, but for starting a stopped service."""
+ run_command(f"systemctl --no-block start {service_name}")
+ return _poll_until_active(service_name, max_attempts, delay)
+
+
+def _poll_until_active(service_name, max_attempts, delay):
+ status = ""
+ for _ in range(max_attempts):
+ time.sleep(delay)
+ status = get_service_status(service_name)
+ if status == "active":
+ break
+ return status
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/session_store.py b/integrations/wazuh-troubleshooting-tool/backend/utils/session_store.py
new file mode 100644
index 00000000..39920086
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/session_store.py
@@ -0,0 +1,45 @@
+"""
+Persists Wazuh Copilot chat sessions to local, gzip-compressed files so
+conversation history survives a backend restart. Keeps only the 6 most
+recent chats — see utils/compressed_history.py for the underlying mechanism.
+
+Layout:
+ backend/sessions/manifest.json - {chat_id: {title, started_at, updated_at}}
+ backend/sessions/.json.gz - gzip-compressed JSON turn list
+"""
+import os
+
+from utils.compressed_history import CompressedHistoryStore
+
+MAX_SESSIONS = 6
+
+_SESSIONS_DIR = os.path.join(os.path.dirname(__file__), "..", "sessions")
+_store = CompressedHistoryStore(_SESSIONS_DIR, max_items=MAX_SESSIONS)
+
+
+def _derive_title(turns):
+ for t in turns:
+ if t.get("role") == "user" and t.get("text"):
+ text = t["text"].strip().replace("\n", " ")
+ return text[:60] + ("..." if len(text) > 60 else "")
+ return "New conversation"
+
+
+def save_session(chat_id, turns, brain=None):
+ _store.save(chat_id, turns, title=_derive_title(turns), extra_meta={"brain": brain} if brain else None)
+
+
+def load_session(chat_id):
+ return _store.load(chat_id)
+
+
+def list_sessions():
+ return [{"chat_id": e["id"], **{k: v for k, v in e.items() if k != "id"}} for e in _store.list()]
+
+
+def delete_session(chat_id):
+ _store.delete(chat_id)
+
+
+def rename_session(chat_id, new_title):
+ return _store.rename(chat_id, new_title)
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/shard_utils.py b/integrations/wazuh-troubleshooting-tool/backend/utils/shard_utils.py
new file mode 100644
index 00000000..489a955a
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/shard_utils.py
@@ -0,0 +1,64 @@
+from utils.api_utils import indexer_api_get, indexer_api_get_json, indexer_api_post_json
+
+DEFAULT_MAX_SHARDS_PER_NODE = 1000 # OpenSearch/Elasticsearch default cluster.max_shards_per_node
+
+
+def get_node_count():
+ """
+ Live node count from _cat/nodes rather than parsed out of a config.yml
+ on disk - that file's path/format isn't consistent across every
+ install type (single-node OVA, multi-node cluster, Docker, etc.), the
+ cluster itself always knows how many nodes it has.
+ """
+ raw = indexer_api_get("/_cat/nodes?h=name") or ""
+ names = [l.strip() for l in raw.splitlines() if l.strip()]
+ return len(names), names
+
+
+def get_total_shard_count():
+ """(active_shards + unassigned_shards) from _cluster/health. Returns (None, raw) on failure."""
+ health, raw = indexer_api_get_json("/_cluster/health")
+ if not health:
+ return None, raw
+ return health.get("active_shards", 0) + health.get("unassigned_shards", 0), raw
+
+
+def get_shard_limit(node_count=None):
+ node_count = node_count if node_count is not None else get_node_count()[0]
+ return DEFAULT_MAX_SHARDS_PER_NODE * max(node_count, 1)
+
+
+def get_shard_capacity_percent():
+ """Percent of the cluster's total shard capacity currently in use, or None if unreachable."""
+ node_count, _ = get_node_count()
+ total, _ = get_total_shard_count()
+ if total is None:
+ return None
+ limit = get_shard_limit(node_count)
+ return round((total / limit) * 100, 1) if limit else 0
+
+
+def is_near_shard_limit(threshold_percent=90):
+ percent = get_shard_capacity_percent()
+ return percent is not None and percent >= threshold_percent
+
+
+def get_unassigned_shards():
+ """List every currently-unassigned shard: index, shard #, prirep, reason code."""
+ raw = indexer_api_get("/_cat/shards?h=index,shard,prirep,state,unassigned.reason") or ""
+ rows = []
+ for line in raw.splitlines():
+ parts = line.split()
+ if len(parts) >= 4 and parts[3] == "UNASSIGNED":
+ rows.append({
+ "index": parts[0], "shard": parts[1], "prirep": parts[2],
+ "reason": parts[4] if len(parts) > 4 else "unknown",
+ })
+ return rows
+
+
+def explain_allocation(index, shard, primary=False):
+ """Raw _cluster/allocation/explain result for one specific unassigned shard."""
+ body = {"index": index, "shard": int(shard), "primary": primary}
+ data, raw = indexer_api_post_json("/_cluster/allocation/explain", body)
+ return data or {"error": raw}
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/step_flow.py b/integrations/wazuh-troubleshooting-tool/backend/utils/step_flow.py
new file mode 100644
index 00000000..3e274807
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/step_flow.py
@@ -0,0 +1,291 @@
+"""
+Generic, reusable, PERMISSION-GATED sequential check/fix engine.
+
+Nothing here is specific to the Wazuh indexer, or to any one use case. Any
+use case can define an ordered list of "steps" (e.g. IP address, cert
+paths, heap memory - or, for a different use case, disk space, cluster
+health, filebeat status, agent connectivity, whatever) and get the exact
+same interaction pattern for free, instead of re-writing the same
+ask/check/fix/restart state machine by hand every time.
+
+THE PATTERN (identical for every step, in order):
+
+ 1. ASK: "Should I check the ? (yes / manual)"
+ yes -> run the step's own check_fn() and report the real result.
+ manual -> show manual_check_instructions_fn(), then ask the user to
+ self-report: "good to go" or "incorrect".
+
+ 2. If there's an issue (either from check_fn, or self-reported "incorrect"):
+ ASK: "Do you want me to fix this? (yes / manually)"
+ yes -> run auto_fix_fn(), then ask "fixed or ongoing?"
+ manually -> show manual_fix_instructions_fn(), wait for the user to
+ confirm they made the change, THEN restart (via
+ restart_fn, if provided), then ask "fixed or ongoing?"
+
+ 3. "fixed" -> stop, done.
+ "ongoing" -> move to the next step. If this was the last step, hand
+ off to whatever stage `next_stage_after_ongoing` points
+ at - the caller's own flow takes over from there.
+
+Nothing runs silently or gets batched - every question is its own turn,
+and only one check or fix action ever happens per turn.
+
+HOW TO DEFINE A STEP
+---------------------
+Each step is a dict:
+
+ {
+ "key": "ip", # short id, used in stage names
+ "title": "indexer IP address", # shown in questions to the user
+
+ "check_fn": fn(context) -> (ok: bool, details: str)
+ # Runs the real check. May read/write `context` to stash data
+ # needed later (e.g. the correct IP, so auto_fix_fn can use it).
+
+ "manual_check_instructions_fn": fn(context) -> str
+ # Commands/steps the user can run themselves to check this.
+
+ "auto_fix_fn": fn(context) -> (status: str, details: str)
+ # Applies the fix. If the fix itself already restarts whatever
+ # needs restarting (recommended - keeps status accurate), just
+ # report that status here.
+
+ "manual_fix_instructions_fn": fn(context) -> str
+ # Steps the user can follow themselves to apply the fix.
+
+ "restart_fn": fn(context) -> str, # OPTIONAL
+ # Called after the user confirms they made a manual fix, since
+ # manual fixes don't restart anything themselves. Should
+ # restart whatever's needed and return the resulting status
+ # string. If omitted, no restart happens after a manual fix.
+ }
+
+USAGE
+-----
+ from utils.step_flow import stage_names, start_flow, run_step_flow
+
+ MY_STEPS = [ {...}, {...} ]
+ PREFIX = "myflow"
+ MY_STAGES = stage_names(PREFIX, MY_STEPS) # for routing in your use case
+
+ def my_flow(user_choice=None, context=None):
+ ...
+ if context.get("stage") == "my_entry_stage":
+ return start_flow(PREFIX, MY_STEPS, context)
+
+ if context.get("stage") in MY_STAGES:
+ return run_step_flow(PREFIX, MY_STEPS, "next_stage_name",
+ user_choice=user_choice, context=context)
+"""
+
+from utils.response_utils import make_response
+
+
+def stage_names(prefix, steps):
+ """
+ All stage names this engine will use for a given prefix + step list.
+ Use this to build the routing set in the calling use-case flow, e.g.:
+
+ SEQ_STAGES = stage_names("seq", STEPS)
+ if context.get("stage") in SEQ_STAGES:
+ return run_step_flow("seq", STEPS, "fetch_logs", ...)
+ """
+ names = set()
+ for s in steps:
+ key = s["key"]
+ names.update({
+ f"{prefix}_{key}_permission",
+ f"{prefix}_{key}_manual_check",
+ f"{prefix}_{key}_fix_permission",
+ f"{prefix}_{key}_manual_fix_wait",
+ f"{prefix}_{key}_fix_result",
+ })
+ return names
+
+
+def start_flow(prefix, steps, context):
+ """
+ Kick off the flow at its first step. Call this once, from the calling
+ use-case's own entry point, to begin the sequence.
+ """
+ return _start_step(prefix, steps, steps[0]["key"], context)
+
+
+def run_step_flow(prefix, steps, next_stage_after_ongoing, user_choice=None, context=None):
+ """
+ Advance the flow by one turn based on context["stage"] and the user's
+ answer. Returns a response built with make_response().
+ """
+ if context is None:
+ context = {}
+
+ choice = (user_choice or "").lower().strip()
+ stage = context.get("stage")
+
+ for step in steps:
+ key = step["key"]
+ title = step["title"]
+
+ # -----------------------------------------------------------
+ # "Should I check the ? (yes / manual)"
+ # -----------------------------------------------------------
+ if stage == f"{prefix}_{key}_permission":
+
+ if "manual" in choice:
+ context["stage"] = f"{prefix}_{key}_manual_check"
+ instructions = step["manual_check_instructions_fn"](context)
+ return make_response(
+ display=(
+ instructions
+ + "\n\nOnce you've checked, let me know: good to go, or incorrect?"
+ ),
+ ask=["Good to go, or incorrect? (good to go / incorrect)"],
+ context=context,
+ )
+
+ # "yes" (default) -> run the real check ourselves
+ ok, details = step["check_fn"](context)
+ header = f"Checking the {title}.\n\n{details}"
+
+ if ok:
+ return _advance_after_pass(prefix, steps, key, header, context, next_stage_after_ongoing)
+
+ context["stage"] = f"{prefix}_{key}_fix_permission"
+ return make_response(
+ display=(
+ header + "\n\n[ISSUE] This doesn't look right.\n\n"
+ "Do you want me to fix this? (yes / manually)"
+ ),
+ ask=["Fix this? (yes / manually)"],
+ context=context,
+ )
+
+ # -----------------------------------------------------------
+ # "Good to go, or incorrect?" (after manual check instructions)
+ # -----------------------------------------------------------
+ if stage == f"{prefix}_{key}_manual_check":
+ if "incorrect" in choice:
+ context["stage"] = f"{prefix}_{key}_fix_permission"
+ return make_response(
+ display="Do you want me to fix this? (yes / manually)",
+ ask=["Fix this? (yes / manually)"],
+ context=context,
+ )
+
+ # "good to go"
+ return _advance_after_pass(prefix, steps, key, "", context, next_stage_after_ongoing)
+
+ # -----------------------------------------------------------
+ # "Do you want me to fix this? (yes / manually)"
+ # -----------------------------------------------------------
+ if stage == f"{prefix}_{key}_fix_permission":
+ if "manual" in choice:
+ context["stage"] = f"{prefix}_{key}_manual_fix_wait"
+ instructions = step["manual_fix_instructions_fn"](context)
+ return make_response(
+ display=instructions + "\n\nLet me know once you've made the change.",
+ ask=["Done making the change? (done)"],
+ context=context,
+ )
+
+ # "yes" -> apply the fix ourselves
+ status, details = step["auto_fix_fn"](context)
+ context["stage"] = f"{prefix}_{key}_fix_result"
+ return make_response(
+ display=details + "\n\nIs the issue fixed now, or still ongoing?",
+ ask=["Fixed or ongoing? (fixed / ongoing)"],
+ context=context,
+ )
+
+ # -----------------------------------------------------------
+ # User confirmed they made the manual fix -> restart if configured
+ # -----------------------------------------------------------
+ if stage == f"{prefix}_{key}_manual_fix_wait":
+ restart_fn = step.get("restart_fn")
+ if restart_fn:
+ status = restart_fn(context)
+ restart_msg = f"Restarted (status: {status.upper()}).\n\n"
+ else:
+ restart_msg = ""
+
+ context["stage"] = f"{prefix}_{key}_fix_result"
+ return make_response(
+ display=restart_msg + "Is the issue fixed now, or still ongoing?",
+ ask=["Fixed or ongoing? (fixed / ongoing)"],
+ context=context,
+ )
+
+ # -----------------------------------------------------------
+ # "Fixed or ongoing?"
+ # -----------------------------------------------------------
+ if stage == f"{prefix}_{key}_fix_result":
+ if "fixed" in choice:
+ return make_response(display="Great! The issue is resolved.", done=True, context=context)
+
+ # "ongoing" -> move to the next step
+ return _advance(prefix, steps, key, "Understood, still ongoing.", context, next_stage_after_ongoing)
+
+ return make_response(display="Unexpected step.", done=True, context=context)
+
+
+# ---------------------------------------------------------------------------
+# internal helpers
+# ---------------------------------------------------------------------------
+def _step_by_key(steps, key):
+ for s in steps:
+ if s["key"] == key:
+ return s
+ return None
+
+
+def _next_step_key(steps, key):
+ idx = [s["key"] for s in steps].index(key)
+ return steps[idx + 1]["key"] if idx + 1 < len(steps) else None
+
+
+def _start_step(prefix, steps, key, context):
+ step = _step_by_key(steps, key)
+ context["stage"] = f"{prefix}_{key}_permission"
+ return make_response(
+ display=f"Should I check the {step['title']}? (yes / manual)",
+ ask=[f"Check the {step['title']}? (yes / manual)"],
+ context=context,
+ )
+
+
+def _advance_after_pass(prefix, steps, key, header, context, next_stage_after_ongoing):
+ """A check just passed (auto or self-reported) - move to the next step,
+ or hand off to the caller's next stage if this was the last step."""
+ nxt = _next_step_key(steps, key)
+ if nxt:
+ nxt_resp = _start_step(prefix, steps, nxt, context)
+ prefix_msg = (header + "\n\n[OK] This looks correct.\n\n") if header else ""
+ nxt_resp["display"] = prefix_msg + nxt_resp["display"]
+ return nxt_resp
+
+ # last step passed cleanly -> hand off to the caller's next stage
+ # (e.g. the dashboard IP/cert flow), instead of just stopping here.
+ tail = (header + "\n\n[OK] This looks correct. ") if header else ""
+ context["stage"] = next_stage_after_ongoing
+ return make_response(
+ display=tail + "Everything checks out here! Let's move on to the next step.",
+ context=context,
+ handoff=True,
+ )
+
+
+def _advance(prefix, steps, key, prefix_msg, context, next_stage_after_ongoing):
+ """A fix just happened and the issue is still ongoing - move to the next step."""
+ nxt = _next_step_key(steps, key)
+ if nxt:
+ nxt_resp = _start_step(prefix, steps, nxt, context)
+ nxt_resp["display"] = prefix_msg + "\n\n" + nxt_resp["display"]
+ return nxt_resp
+
+ # last step still ongoing -> hand off to the caller's next stage
+ context["stage"] = next_stage_after_ongoing
+ return make_response(
+ display=prefix_msg + " Let's move on to the next step.",
+ context=context,
+ handoff=True,
+ )
diff --git a/integrations/wazuh-troubleshooting-tool/backend/utils/wizard_history.py b/integrations/wazuh-troubleshooting-tool/backend/utils/wizard_history.py
new file mode 100644
index 00000000..4209af2b
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/utils/wizard_history.py
@@ -0,0 +1,50 @@
+"""
+Download-only history for the Troubleshooting Library's guided wizard flows.
+Unlike session_store.py (Wazuh Copilot chat), these are never resumed/continued
+- only saved once a flow reaches a resolution (done: true) and made available
+to download/review. Same compressed-storage mechanism, capped at 6, oldest
+evicted automatically. See utils/compressed_history.py.
+
+Layout:
+ backend/wizard_history/manifest.json - {run_id: {title, started_at, updated_at}}
+ backend/wizard_history/.json.gz - gzip-compressed JSON transcript
+"""
+import os
+
+from utils.compressed_history import CompressedHistoryStore
+
+MAX_RUNS = 6
+
+_WIZARD_DIR = os.path.join(os.path.dirname(__file__), "..", "wizard_history")
+_store = CompressedHistoryStore(_WIZARD_DIR, max_items=MAX_RUNS)
+
+
+def _derive_title(transcript):
+ if transcript and transcript[0].get("user"):
+ text = transcript[0]["user"].strip().replace("\n", " ")
+ return text[:60] + ("..." if len(text) > 60 else "")
+ return "Troubleshooting session"
+
+
+def save_run(run_id, transcript):
+ _store.save(run_id, transcript, title=_derive_title(transcript))
+
+
+def load_run(run_id):
+ return _store.load(run_id)
+
+
+def list_runs():
+ return [{"run_id": e["id"], **{k: v for k, v in e.items() if k != "id"}} for e in _store.list()]
+
+
+def format_transcript_text(transcript, title):
+ """Plain-text rendering for download - readable outside the app."""
+ lines = [f"Wazuh Troubleshooting Library — {title}", "=" * 60, ""]
+ for step in transcript:
+ if step.get("user"):
+ lines.append(f"> {step['user']}")
+ if step.get("assistant"):
+ lines.append(step["assistant"])
+ lines.append("")
+ return "\n".join(lines)
diff --git a/integrations/wazuh-troubleshooting-tool/backend/wazuh_api.py b/integrations/wazuh-troubleshooting-tool/backend/wazuh_api.py
new file mode 100644
index 00000000..83578eba
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/backend/wazuh_api.py
@@ -0,0 +1,40 @@
+import requests
+from config import WAZUH_API_URL, API_USERNAME, API_PASSWORD
+
+requests.packages.urllib3.disable_warnings()
+
+def get_token():
+ try:
+ url = f"{WAZUH_API_URL}/security/user/authenticate?raw=true"
+
+ res = requests.post(
+ url,
+ auth=(API_USERNAME, API_PASSWORD),
+ verify=False,
+ timeout=5,
+ )
+
+ return res.text.strip()
+ except requests.RequestException:
+ return None
+
+
+def check_api():
+ token = get_token()
+
+ if not token:
+ return "API AUTH FAILED"
+
+ try:
+ headers = {"Authorization": f"Bearer {token}"}
+
+ res = requests.get(
+ f"{WAZUH_API_URL}/",
+ headers=headers,
+ verify=False,
+ timeout=5,
+ )
+
+ return res.text
+ except requests.RequestException:
+ return "API CONNECTION FAILED"
diff --git a/integrations/wazuh-troubleshooting-tool/config b/integrations/wazuh-troubleshooting-tool/config
new file mode 100644
index 00000000..2248aaab
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/config
@@ -0,0 +1,27 @@
+wazuh_api:
+ host: "https://localhost:55000"
+ username: "wazuh"
+ password: "YOUR_WAZUH_PASSWORD"
+ verify_ssl: false
+
+indexer:
+ url: "https://localhost:9200"
+ username: "admin"
+ password: "YOUR_INDEXER_PASSWORD"
+
+kibana:
+ username: "kibanaserver"
+ password: "YOUR_KIBANA_PASSWORD"
+
+ollama:
+ url: "http://localhost:11434"
+ model: "qwen3:1.7b"
+
+anthropic:
+ api_key: ""
+ model: "claude-sonnet-5"
+
+server:
+ host: "localhost"
+ backend_port: "8000"
+ frontend_port: "3000"
diff --git a/integrations/wazuh-troubleshooting-tool/frontend/agent.js b/integrations/wazuh-troubleshooting-tool/frontend/agent.js
new file mode 100644
index 00000000..abde5487
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/frontend/agent.js
@@ -0,0 +1,562 @@
+/* agent.js
+ * Wazuh Copilot — the single unified AI assistant, backed by the agentic
+ * tool-calling loop (agent_engine.py). Handles freeform Q&A (falls straight
+ * to a text answer when it has nothing to call) and full investigate/fix
+ * flows with an approval gate on anything that changes system state.
+ * Talks to /agent/message, /agent/approve, /agent/reset, /agent/tools, /agent/brains.
+ * Plain window.* globals, no framework, BASE_URL resolved by app.js's loadConfig().
+ */
+
+const AgentState = {
+ sessionId: null,
+ brain: "ollama",
+ ollamaModels: [],
+ model: null,
+ sending: false,
+ initialized: false,
+};
+
+function escapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, c => ({
+ "&": "&", "<": "<", ">": ">", '"': """, "'": "'",
+ }[c]));
+}
+
+function agentAvatarSvg() {
+ return '' +
+ ' ';
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// MARKDOWN RENDERER (lightweight, no external deps) — ported from copilot.js
+// ─────────────────────────────────────────────────────────────────────────────
+
+function renderMarkdown(text) {
+ // Escape the raw text FIRST, then apply markdown formatting on the
+ // escaped string — otherwise a compromised backend response or a
+ // prompt-injected tool result could inject live HTML/JS via innerHTML.
+ // None of the regexes below match &<>"', so escaping first doesn't
+ // change how any of them match.
+ text = escapeHtml(text);
+
+ text = text.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
+ const langLabel = lang ? `${lang} ` : "";
+ return ``;
+ });
+
+ text = text.replace(/`([^`]+)`/g, '$1');
+ text = text.replace(/\*\*(.+?)\*\*/g, "$1 ");
+ text = text.replace(/(?$1");
+ text = text.replace(/^### (.+)$/gm, '$1 ');
+ text = text.replace(/^## (.+)$/gm, '$1 ');
+ text = text.replace(/^# (.+)$/gm, '$1 ');
+ text = text.replace(/^─{3,}$/gm, ' ');
+ text = text.replace(/^-{3,}$/gm, ' ');
+ text = text.replace(/^[•\-\*] (.+)$/gm, '$1 ');
+ text = text.replace(/([\s\S]*?<\/li>)/g, '');
+ text = text.replace(/<\/ul>\s*/g, "");
+ text = text.replace(/^\d+\. (.+)$/gm, '$1 ');
+ text = text.replace(/\n\n/g, '
');
+ text = '
' + text + '
';
+ text = text.replace(/<\/p>/g, "");
+
+ return text;
+}
+
+function agentCopyCode(btn) {
+ const pre = btn.closest(".copilot-code-block").querySelector("pre code");
+ if (!pre) return;
+ navigator.clipboard.writeText(pre.textContent).then(() => {
+ btn.textContent = "Copied!";
+ setTimeout(() => {
+ btn.innerHTML = ` Copy`;
+ }, 2000);
+ });
+}
+window.agentCopyCode = agentCopyCode;
+
+function summarizeResult(result) {
+ let s;
+ try {
+ s = typeof result === "string" ? result : JSON.stringify(result);
+ } catch (e) {
+ s = String(result);
+ }
+ if (!s) s = "(empty)";
+ return s.length > 220 ? s.slice(0, 220) + "…" : s;
+}
+
+// ── Lazy init ────────────────────────────────────────────────────────────────
+
+function onAgentViewActivated() {
+ if (AgentState.initialized) return;
+ AgentState.initialized = true;
+ loadAgentBrains();
+ appendAgentGreeting();
+}
+
+function updateModelSelectVisibility() {
+ const modelSel = document.getElementById("agent-model-select");
+ if (!modelSel) return;
+ if (AgentState.brain === "ollama" && AgentState.ollamaModels.length > 1) {
+ modelSel.style.display = "inline-block";
+ } else {
+ modelSel.style.display = "none";
+ }
+}
+
+function agentModelChanged() {
+ const modelSel = document.getElementById("agent-model-select");
+ AgentState.model = modelSel ? modelSel.value : null;
+}
+
+async function loadAgentBrains() {
+ const statusBar = document.getElementById("agent-status-bar");
+ const select = document.getElementById("agent-brain-select");
+ const modelSel = document.getElementById("agent-model-select");
+
+ try {
+ const res = await fetch(`${window.BASE_URL}/agent/brains`);
+ const data = await res.json();
+
+ const ollamaOk = !!(data.ollama && data.ollama.available);
+ const claudeOk = !!(data.claude && data.claude.available);
+
+ const ollamaOpt = select.querySelector('option[value="ollama"]');
+ const claudeOpt = select.querySelector('option[value="claude"]');
+
+ ollamaOpt.textContent = "Ollama (local) — " + (data.ollama ? data.ollama.model : "?");
+ ollamaOpt.disabled = false;
+
+ if (claudeOk) {
+ claudeOpt.textContent = "Claude (API) — " + data.claude.model;
+ claudeOpt.disabled = false;
+ } else {
+ claudeOpt.textContent = "Claude (API) — not configured";
+ claudeOpt.disabled = true;
+ }
+
+ if (!ollamaOk && claudeOk) {
+ select.value = "claude";
+ AgentState.brain = "claude";
+ }
+
+ AgentState.ollamaModels = (data.ollama && data.ollama.models) || [];
+ if (modelSel) {
+ modelSel.innerHTML = "";
+ AgentState.ollamaModels.forEach(m => {
+ const opt = document.createElement("option");
+ opt.value = m;
+ opt.textContent = m;
+ if (m === (data.ollama && data.ollama.model)) opt.selected = true;
+ modelSel.appendChild(opt);
+ });
+ AgentState.model = modelSel.value || null;
+ }
+ updateModelSelectVisibility();
+
+ const anyOk = ollamaOk || claudeOk;
+ statusBar.innerHTML =
+ ` ` +
+ `${anyOk ? (ollamaOk ? "Ollama ready" : "Claude ready") : "No brain configured"} `;
+ } catch (e) {
+ statusBar.innerHTML = 'Backend unreachable ';
+ }
+}
+
+
+function agentBrainChanged() {
+ AgentState.brain = document.getElementById("agent-brain-select").value;
+ updateModelSelectVisibility();
+}
+
+// ── Message rendering ────────────────────────────────────────────────────────
+
+function appendAgentGreeting() {
+ const container = document.getElementById("agent-messages");
+ const wrap = document.createElement("div");
+ wrap.className = "agent-msg agent-msg-assistant";
+ wrap.innerHTML =
+ `
${agentAvatarSvg()}
` +
+ `Describe what's wrong ` +
+ `(e.g. "no alerts are showing on the dashboard") and I'll investigate — checking services, ` +
+ `logs, cluster health and configuration — before proposing any fix. I'll always show you the ` +
+ `exact action and ask before restarting a service or changing anything.
`;
+ container.appendChild(wrap);
+}
+
+function appendUserBubble(text) {
+ const container = document.getElementById("agent-messages");
+ const wrap = document.createElement("div");
+ wrap.className = "agent-msg agent-msg-user";
+ const bubble = document.createElement("div");
+ bubble.className = "agent-bubble-user";
+ bubble.textContent = text;
+ wrap.appendChild(bubble);
+ container.appendChild(wrap);
+ container.scrollTop = container.scrollHeight;
+}
+
+function appendAssistantTextBubble(text) {
+ const container = document.getElementById("agent-messages");
+ const wrap = document.createElement("div");
+ wrap.className = "agent-msg agent-msg-assistant";
+ const bubble = document.createElement("div");
+ bubble.className = "agent-bubble agent-bubble-ai";
+ bubble.innerHTML = renderMarkdown(text || "(no response)");
+ wrap.appendChild(Object.assign(document.createElement("div"), { className: "agent-avatar", innerHTML: agentAvatarSvg() }));
+ wrap.appendChild(bubble);
+ container.appendChild(wrap);
+ container.scrollTop = container.scrollHeight;
+}
+
+// ── History (saved chats — up to the 6 most recent) ─────────────────────────
+
+function timeAgo(isoString) {
+ const seconds = Math.floor((new Date() - new Date(isoString)) / 1000);
+ if (seconds < 60) return "just now";
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h`;
+ const days = Math.floor(hours / 24);
+ return `${days}d`;
+}
+
+async function loadAgentHistory() {
+ const panel = document.getElementById("agent-history-panel");
+ panel.innerHTML = 'Loading...
';
+ try {
+ const res = await fetch(`${window.BASE_URL}/agent/sessions`);
+ const data = await res.json();
+ const sessions = data.sessions || [];
+ if (!sessions.length) {
+ panel.innerHTML = 'No saved conversations yet.
';
+ return;
+ }
+ panel.innerHTML = "";
+ sessions.forEach(s => {
+ const row = document.createElement("div");
+ row.className = "agent-history-row";
+ row.innerHTML =
+ `${escapeHtml(s.title)} ` +
+ `${timeAgo(s.updated_at)} ` +
+ `` +
+ ` ` +
+ `` +
+ ` `;
+
+ row.addEventListener("click", (e) => {
+ const action = e.target.closest("button")?.dataset.action;
+ if (action === "rename") {
+ e.stopPropagation();
+ renameHistoryEntry(s.chat_id, s.title);
+ } else if (action === "delete") {
+ e.stopPropagation();
+ deleteHistoryEntry(s.chat_id);
+ } else {
+ resumeSessionFromHistory(s.chat_id);
+ }
+ });
+ panel.appendChild(row);
+ });
+ } catch (e) {
+ panel.innerHTML = 'Failed to load history.
';
+ }
+}
+
+function agentToggleHistory() {
+ const panel = document.getElementById("agent-history-panel");
+ const showing = panel.style.display === "none" || !panel.style.display;
+ panel.style.display = showing ? "block" : "none";
+ if (showing) loadAgentHistory();
+}
+
+document.addEventListener("click", (e) => {
+ const panel = document.getElementById("agent-history-panel");
+ const btn = document.getElementById("agent-history-btn");
+ if (!panel || panel.style.display === "none") return;
+ if (!panel.contains(e.target) && e.target !== btn && !btn?.contains(e.target)) {
+ panel.style.display = "none";
+ }
+});
+
+function replayTurns(turns) {
+ const container = document.getElementById("agent-messages");
+ container.innerHTML = "";
+ turns.forEach(t => {
+ if (t.role === "user" && t.text) {
+ appendUserBubble(t.text);
+ } else if (t.role === "assistant" && t.text) {
+ appendAssistantTextBubble(t.text);
+ }
+ // tool-call/tool-result turns are skipped in replay — this reconstructs
+ // a clean readable transcript, not the original step-by-step trace.
+ });
+}
+
+async function resumeSessionFromHistory(chatId) {
+ try {
+ const res = await fetch(`${window.BASE_URL}/agent/sessions/${encodeURIComponent(chatId)}`);
+ const data = await res.json();
+ if (!data.turns) {
+ appendAgentError("Could not load that conversation — it may have been deleted.");
+ return;
+ }
+ AgentState.sessionId = chatId;
+ replayTurns(data.turns);
+ updateInputLock(false);
+ document.getElementById("agent-history-panel").style.display = "none";
+ } catch (e) {
+ appendAgentError("Failed to load conversation: " + e.message);
+ }
+}
+
+async function deleteHistoryEntry(chatId) {
+ try {
+ await fetch(`${window.BASE_URL}/agent/sessions/${encodeURIComponent(chatId)}`, { method: "DELETE" });
+ loadAgentHistory();
+ } catch (e) { /* best-effort */ }
+}
+
+async function renameHistoryEntry(chatId, currentTitle) {
+ const title = prompt("Rename conversation:", currentTitle);
+ if (!title || !title.trim()) return;
+ try {
+ await fetch(`${window.BASE_URL}/agent/sessions/${encodeURIComponent(chatId)}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: title.trim() }),
+ });
+ loadAgentHistory();
+ } catch (e) { /* best-effort */ }
+}
+
+function appendThinkingBubble() {
+ const container = document.getElementById("agent-messages");
+ const wrap = document.createElement("div");
+ wrap.className = "agent-msg agent-msg-assistant";
+ wrap.innerHTML =
+ `${agentAvatarSvg()}
` +
+ '
';
+ container.appendChild(wrap);
+ container.scrollTop = container.scrollHeight;
+ return wrap;
+}
+
+function appendAgentError(text) {
+ const container = document.getElementById("agent-messages");
+ const wrap = document.createElement("div");
+ wrap.className = "agent-msg agent-msg-assistant";
+ wrap.innerHTML =
+ `${agentAvatarSvg()}
` +
+ ``;
+ container.appendChild(wrap);
+ container.scrollTop = container.scrollHeight;
+}
+
+function renderTrace(trace) {
+ const box = document.createElement("div");
+ box.className = "agent-trace";
+
+ for (let i = 0; i < trace.length; i++) {
+ const item = trace[i];
+ if (item.type !== "tool_call") continue;
+
+ const result = trace[i + 1] && trace[i + 1].type === "tool_result" ? trace[i + 1] : null;
+ const row = document.createElement("div");
+ row.className = "agent-trace-step" + (result && result.error ? " error" : "");
+
+ const argsStr = item.arguments && Object.keys(item.arguments).length ? JSON.stringify(item.arguments) : "";
+ const resStr = result ? summarizeResult(result.result) : "…";
+ const icon = result ? (result.error ? "✗" : "✓") : "…";
+
+ row.innerHTML =
+ `${icon} ${escapeHtml(item.tool)} ` +
+ `${escapeHtml(argsStr)} → ${escapeHtml(resStr)} `;
+ box.appendChild(row);
+ }
+ return box;
+}
+
+function renderApprovalCard(action) {
+ const risk = action.risk || "medium";
+ const card = document.createElement("div");
+ card.className = "agent-approval-card risk-" + risk;
+ card.innerHTML =
+ `` +
+ `Wants to run: ${escapeHtml(action.tool)} ` +
+ `${escapeHtml(risk)}
` +
+ `${escapeHtml(action.description || "")}
` +
+ `${escapeHtml(JSON.stringify(action.arguments || {}, null, 2))}
` +
+ `` +
+ `Approve & Run ` +
+ `Reject
`;
+ return card;
+}
+
+function updateInputLock(locked) {
+ const input = document.getElementById("agent-input");
+ const btn = document.getElementById("agent-send-btn");
+ input.disabled = locked;
+ btn.disabled = locked;
+ btn.style.opacity = locked ? "0.5" : "1";
+ input.placeholder = locked
+ ? "Approve or reject the pending action above before continuing…"
+ : "Describe the problem... (Enter to send, Shift+Enter for new line)";
+}
+
+function renderAgentTurn(data) {
+ const container = document.getElementById("agent-messages");
+ const wrap = document.createElement("div");
+ wrap.className = "agent-msg agent-msg-assistant";
+
+ const avatar = document.createElement("div");
+ avatar.className = "agent-avatar";
+ avatar.innerHTML = agentAvatarSvg();
+
+ const turn = document.createElement("div");
+ turn.className = "agent-turn";
+
+ if (data.trace && data.trace.length) {
+ turn.appendChild(renderTrace(data.trace));
+ }
+
+ if (data.status === "final") {
+ const bubble = document.createElement("div");
+ bubble.className = "agent-bubble agent-bubble-ai";
+ bubble.innerHTML = renderMarkdown(data.message || "(no response)");
+ turn.appendChild(bubble);
+ } else if (data.status === "awaiting_approval") {
+ turn.appendChild(renderApprovalCard(data.pending_action));
+ } else {
+ const bubble = document.createElement("div");
+ bubble.className = "agent-bubble-error";
+ bubble.textContent = data.message || "Unknown error.";
+ turn.appendChild(bubble);
+ }
+
+ wrap.appendChild(avatar);
+ wrap.appendChild(turn);
+ container.appendChild(wrap);
+ container.scrollTop = container.scrollHeight;
+
+ updateInputLock(data.status === "awaiting_approval");
+}
+
+// ── Actions ──────────────────────────────────────────────────────────────────
+
+function setAgentSending(sending) {
+ AgentState.sending = sending;
+ const btn = document.getElementById("agent-send-btn");
+ if (!sending) return; // updateInputLock() governs the "enabled" state otherwise
+ btn.disabled = true;
+ btn.style.opacity = "0.5";
+}
+
+async function agentSend() {
+ const input = document.getElementById("agent-input");
+ const text = input.value.trim();
+ if (!text || AgentState.sending) return;
+
+ appendUserBubble(text);
+ input.value = "";
+ if (window.copilotAutoResize) window.copilotAutoResize(input);
+
+ const thinking = appendThinkingBubble();
+ setAgentSending(true);
+
+ try {
+ const res = await fetch(`${window.BASE_URL}/agent/message`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: AgentState.sessionId, message: text, brain: AgentState.brain, model: AgentState.brain === "ollama" ? AgentState.model : null }),
+ });
+ const data = await res.json();
+ AgentState.sessionId = data.session_id || AgentState.sessionId;
+ thinking.remove();
+ renderAgentTurn(data);
+ } catch (e) {
+ thinking.remove();
+ appendAgentError("Request failed: " + e.message);
+ updateInputLock(false);
+ } finally {
+ AgentState.sending = false;
+ if (!document.getElementById("agent-input").disabled) {
+ document.getElementById("agent-send-btn").style.opacity = "1";
+ }
+ }
+}
+
+async function agentApprove(approve, btnEl) {
+ if (AgentState.sending) return;
+
+ const card = btnEl.closest(".agent-approval-card");
+ const actions = card.querySelector(".agent-approval-actions");
+ actions.innerHTML =
+ `${approve ? "Approved — running…" : "Rejected"} `;
+
+ AgentState.sending = true;
+ updateInputLock(true);
+
+ try {
+ const res = await fetch(`${window.BASE_URL}/agent/approve`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: AgentState.sessionId, approve }),
+ });
+ const data = await res.json();
+ renderAgentTurn(data);
+ } catch (e) {
+ appendAgentError("Approve request failed: " + e.message);
+ updateInputLock(false);
+ } finally {
+ AgentState.sending = false;
+ }
+}
+
+async function agentNewSession() {
+ if (AgentState.sessionId) {
+ try {
+ await fetch(`${window.BASE_URL}/agent/reset`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ session_id: AgentState.sessionId }),
+ });
+ } catch (e) { /* best-effort */ }
+ }
+ AgentState.sessionId = null;
+ document.getElementById("agent-messages").innerHTML = "";
+ updateInputLock(false);
+ appendAgentGreeting();
+}
+
+function agentInputKeydown(event) {
+ if (event.key === "Enter" && !event.shiftKey) {
+ event.preventDefault();
+ agentSend();
+ }
+}
+
+function copilotAutoResize(el) {
+ el.style.height = "auto";
+ el.style.height = Math.min(el.scrollHeight, 200) + "px";
+}
+window.copilotAutoResize = copilotAutoResize;
+
+window.onAgentViewActivated = onAgentViewActivated;
+window.agentBrainChanged = agentBrainChanged;
+window.agentModelChanged = agentModelChanged;
+window.agentSend = agentSend;
+window.agentApprove = agentApprove;
+window.agentNewSession = agentNewSession;
+window.agentInputKeydown = agentInputKeydown;
+window.agentToggleHistory = agentToggleHistory;
diff --git a/integrations/wazuh-troubleshooting-tool/frontend/app.js b/integrations/wazuh-troubleshooting-tool/frontend/app.js
new file mode 100644
index 00000000..0a5f5fb9
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/frontend/app.js
@@ -0,0 +1,751 @@
+let BASE_URL = "http://localhost:8000"; // default fallback
+
+async function loadConfig() {
+ const savedUrl = localStorage.getItem("wazuh_api_url");
+ if (savedUrl) {
+ BASE_URL = savedUrl;
+ window.BASE_URL = BASE_URL;
+ return;
+ }
+ try {
+ const response = await fetch("config.json");
+ const config = await response.json();
+ if (config.api_url) {
+ BASE_URL = config.api_url;
+ window.BASE_URL = BASE_URL;
+ }
+ } catch (e) {
+ const host = window.location.hostname || "localhost";
+ BASE_URL = `http://${host}:8000`;
+ window.BASE_URL = BASE_URL;
+ }
+}
+let AUTO_CHECK = localStorage.getItem("wazuh_auto_check") !== "false"; // default true
+let REFRESH_INTERVAL = parseInt(localStorage.getItem("wazuh_refresh_interval") || "30"); // default 30s
+
+let activeIssues = [];
+let lastCheckSnapshot = null;
+
+// Session details (stable for session lifetime)
+const SESSION_ID = Math.random().toString(36).substring(2, 10).toUpperCase();
+const SESSION_START = new Date();
+
+// Initialize header toolbar details
+document.getElementById("sessionId").textContent = SESSION_ID;
+document.getElementById("sessionStarted").textContent = SESSION_START.toLocaleString();
+
+// Background timer for auto-refresh
+let refreshTimerId = null;
+
+// -------------------------------------------------------
+// CLIENT ROUTING (SPA VIEW SWITCHER)
+// -------------------------------------------------------
+function setView(viewName) {
+ document.querySelectorAll(".view").forEach(view => {
+ view.classList.toggle("active", view.id === "view-" + viewName);
+ });
+
+ document.querySelectorAll(".sidebar-item").forEach(item => {
+ item.classList.toggle("active", item.dataset.view === viewName);
+ });
+}
+
+// Bind navigation click handlers
+document.querySelectorAll(".sidebar-item").forEach(item => {
+ item.addEventListener("click", (e) => {
+ const view = item.dataset.view;
+ if (view) setView(view);
+ });
+});
+
+// -------------------------------------------------------
+// HEALTH CHECK LOGGING
+// -------------------------------------------------------
+function logPrint(text) {
+ const el = document.getElementById("logs");
+ if (!el) return;
+ el.textContent += text + "\n";
+ el.scrollTop = el.scrollHeight;
+}
+
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+// -------------------------------------------------------
+// METRICS RENDERING
+// -------------------------------------------------------
+function updateServices(checks) {
+ const el = document.getElementById("services");
+ el.innerHTML = "";
+ checks.forEach(c => {
+ let badgeClass = "warning";
+ if (c.status === "active" || c.status === "ok") badgeClass = "healthy";
+ else if (c.status === "inactive" || c.status === "error") badgeClass = "critical";
+
+ el.innerHTML += `
+
+ ${c.name}
+ ${c.status}
+
`;
+ });
+}
+
+function updateCluster(c) {
+ const statusEl = document.getElementById("cluster-status-text");
+ const nodesEl = document.getElementById("cluster-nodes");
+ const shardsEl = document.getElementById("cluster-shards");
+ const unassignedEl = document.getElementById("cluster-unassigned");
+ const cardStatusEl = document.getElementById("card-cluster-status");
+
+ if (!c) {
+ statusEl.textContent = "No data";
+ return;
+ }
+
+ let statusText = c.status.toUpperCase();
+ statusEl.textContent = statusText;
+ cardStatusEl.textContent = statusText;
+
+ if (c.status === "green") {
+ statusEl.style.color = "var(--accent-green)";
+ cardStatusEl.style.color = "var(--accent-green)";
+ } else if (c.status === "yellow") {
+ statusEl.style.color = "var(--accent-yellow)";
+ cardStatusEl.style.color = "var(--accent-yellow)";
+ } else {
+ statusEl.style.color = "var(--accent-red)";
+ cardStatusEl.style.color = "var(--accent-red)";
+ }
+
+ nodesEl.textContent = c.number_of_nodes;
+ shardsEl.textContent = c.active_shards;
+ unassignedEl.textContent = c.unassigned_shards;
+}
+
+function updateMemory(m) {
+ document.getElementById("mem-total").textContent = `${m.total} MB`;
+ document.getElementById("mem-used").textContent = `${m.used} MB`;
+ document.getElementById("mem-free").textContent = `${m.free} MB`;
+
+ const percent = m.total > 0 ? Math.round((m.used / m.total) * 100) : 0;
+ document.getElementById("mem-percent").textContent = `${percent}%`;
+ document.getElementById("mem-progress").style.width = `${percent}%`;
+}
+
+function updateIssues(list) {
+ const el = document.getElementById("issues");
+ const countEl = document.getElementById("card-active-issues");
+
+ if (!list || list.length === 0) {
+ countEl.textContent = "0";
+ countEl.style.color = "var(--accent-green)";
+ el.innerHTML = `
+
+ ✔ No issues detected. System is running healthy.
+
`;
+ return;
+ }
+
+ countEl.textContent = list.length;
+ countEl.style.color = "var(--accent-red)";
+
+ el.innerHTML = list.map((issue, idx) => `
+
+
+
+
+
+
+
+
${issue} is NOT running
+
+
Troubleshoot
+
+ `).join("");
+}
+
+// -------------------------------------------------------
+// SYSTEM DIAGNOSTICS CHECK
+// -------------------------------------------------------
+async function startCheck() {
+ const logsEl = document.getElementById("logs");
+ const overallStatusEl = document.getElementById("card-overall-status");
+ const onlineEl = document.getElementById("card-services-online");
+
+ logsEl.textContent = "";
+ logPrint("Starting system health checks...\n");
+
+ try {
+ let res = await fetch(BASE_URL + "/check");
+ let data = await res.json();
+
+ activeIssues = data.issues || [];
+ const checkTime = new Date().toLocaleString();
+
+ lastCheckSnapshot = {
+ time: checkTime,
+ checks: data.checks,
+ cluster: data.cluster_details,
+ memory: data.memory,
+ issues: activeIssues
+ };
+
+ // Update last check in top toolbar
+ document.getElementById("sessionLastCheck").textContent = checkTime;
+
+ // Render UI
+ updateServices(data.checks);
+ updateCluster(data.cluster_details);
+ updateMemory(data.memory);
+ updateIssues(activeIssues);
+
+ // Update overall service statistics
+ const healthyCount = data.checks.filter(c => c.status === "active" || c.status === "ok").length;
+ onlineEl.textContent = `${healthyCount} of ${data.checks.length}`;
+
+ if (activeIssues.length === 0) {
+ overallStatusEl.textContent = "HEALTHY";
+ overallStatusEl.style.color = "var(--accent-green)";
+ document.getElementById("sessionStatusPill").className = "status-pill";
+ } else {
+ overallStatusEl.textContent = "DEGRADED";
+ overallStatusEl.style.color = "var(--accent-red)";
+ document.getElementById("sessionStatusPill").className = "status-pill offline";
+ }
+
+ // Print interactive logs
+ for (const c of data.checks) {
+ logPrint(`Checking ${c.name}...`);
+ await sleep(150);
+ logPrint(`Status: ${c.status.toUpperCase()}\n`);
+ }
+
+ if (activeIssues.length === 0) {
+ logPrint("[OK] All checks completed. No errors found.");
+ } else {
+ logPrint(`[WARNING] Completed checks. Found ${activeIssues.length} service issue(s).`);
+ }
+
+ } catch (e) {
+ logPrint("[ERROR] Failed to fetch system checks from backend api. Ensure the backend uvicorn server is running.");
+ console.error(e);
+ overallStatusEl.textContent = "OFFLINE";
+ overallStatusEl.style.color = "var(--accent-red)";
+ document.getElementById("sessionStatusPill").className = "status-pill offline";
+ }
+}
+
+// -------------------------------------------------------
+// QUICK SYSTEM ACTIONS
+// -------------------------------------------------------
+async function quickRestart(service) {
+ setView("home");
+ const logsEl = document.getElementById("logs");
+ logsEl.textContent = "";
+ logPrint(`Executing system restart command for service: ${service}...`);
+
+ try {
+ let res = await fetch(BASE_URL + "/fix?service=" + service);
+ let data = await res.json();
+ logPrint(`Backend response:\n${data.message}`);
+ // Run check to refresh UI
+ startCheck();
+ } catch (e) {
+ logPrint("[ERROR] Failed to execute restart action.");
+ console.error(e);
+ }
+}
+
+async function checkFilebeat() {
+ setView("home");
+ const logsEl = document.getElementById("logs");
+ logsEl.textContent = "";
+ logPrint("Testing Filebeat configurations and server output connectivity...");
+
+ try {
+ let res = await fetch(BASE_URL + "/filebeat-test");
+ let data = await res.json();
+ logPrint(data.output);
+ } catch (e) {
+ logPrint("[ERROR] Failed to query Filebeat output test.");
+ console.error(e);
+ }
+}
+
+// -------------------------------------------------------
+// SETTINGS PERSISTENCE
+// -------------------------------------------------------
+function loadSettings() {
+ document.getElementById("settings-api-url").value = BASE_URL;
+ document.getElementById("settings-auto-check").checked = AUTO_CHECK;
+ document.getElementById("settings-refresh").value = REFRESH_INTERVAL;
+
+ // Start background auto-refresh
+ setupAutoRefresh();
+}
+
+function saveSettings() {
+ const apiInput = document.getElementById("settings-api-url").value.trim();
+ const autoCheckInput = document.getElementById("settings-auto-check").checked;
+ const refreshInput = document.getElementById("settings-refresh").value;
+
+ BASE_URL = apiInput;
+ AUTO_CHECK = autoCheckInput;
+ REFRESH_INTERVAL = parseInt(refreshInput);
+
+ localStorage.setItem("wazuh_api_url", BASE_URL);
+ localStorage.setItem("wazuh_auto_check", AUTO_CHECK);
+ localStorage.setItem("wazuh_refresh_interval", REFRESH_INTERVAL);
+
+ // Save alerts Dash source url as well
+ const iframe = document.getElementById("reports-iframe");
+ if (iframe) iframe.src = "about:blank"; // force reload on next click
+
+ setupAutoRefresh();
+ alert("Settings saved successfully!");
+ setView("home");
+
+ if (AUTO_CHECK) {
+ startCheck();
+ }
+}
+
+function setupAutoRefresh() {
+ if (refreshTimerId) {
+ clearInterval(refreshTimerId);
+ refreshTimerId = null;
+ }
+
+ if (REFRESH_INTERVAL > 0) {
+ refreshTimerId = setInterval(() => {
+ console.log("Automated background check running...");
+ startCheck();
+ }, REFRESH_INTERVAL * 1000);
+ }
+}
+
+// -------------------------------------------------------
+// REPORT DOWNLOADING & GENERATING
+// -------------------------------------------------------
+async function downloadReport() {
+ const downloadBtn = document.getElementById("btn-toolbar-download");
+ let originalBtnContent = "";
+ if (downloadBtn) {
+ originalBtnContent = downloadBtn.innerHTML;
+ downloadBtn.disabled = true;
+ downloadBtn.innerHTML = `
+
+
+
+ Summarizing...
+ `;
+ }
+
+ const now = new Date();
+ const duration = Math.round((now - SESSION_START) / 1000);
+ const mins = Math.floor(duration / 60);
+ const secs = duration % 60;
+
+ let checkSection = "No system diagnostics health check was run in this session.";
+ if (lastCheckSnapshot) {
+ const s = lastCheckSnapshot;
+ const servicesLines = (s.checks || [])
+ .map(c => ` ${c.name.padEnd(20)} : ${c.status.toUpperCase()}`)
+ .join("\n");
+ const issuesLines = s.issues.length === 0
+ ? " None"
+ : s.issues.map(i => ` - ${i} is NOT running`).join("\n");
+
+ checkSection =
+`Check Timestamp : ${s.time}
+
+Monitored Services:
+-------------------
+${servicesLines}
+
+Indexer Cluster Metrics:
+-----------------------
+ Health Status : ${s.cluster.status.toUpperCase()}
+ Nodes Count : ${s.cluster.number_of_nodes}
+ Active Shards : ${s.cluster.active_shards}
+ Unassigned : ${s.cluster.unassigned_shards}
+
+System Memory Info:
+------------------
+ Total Memory : ${s.memory.total} MB
+ Used Memory : ${s.memory.used} MB
+ Free Memory : ${s.memory.free} MB
+
+Active Issues Detected:
+----------------------
+${issuesLines}`;
+ }
+
+ // Capture the troubleshooting logs from screen
+ const chatLogs = Array.from(document.querySelectorAll("#chat-messages .chat-bubble"))
+ .map(bubble => {
+ const sender = bubble.classList.contains("user") ? "User" : "System";
+ const cleanedText = bubble.innerText.trim().replace(/\s+/g, ' ');
+ return `[${sender}] ${cleanedText}`;
+ })
+ .join("\n\n");
+
+ const libraryChatLogs = Array.from(document.querySelectorAll("#library-chat-messages .chat-bubble"))
+ .map(bubble => {
+ const sender = bubble.classList.contains("user") ? "User" : "System";
+ const cleanedText = bubble.innerText.trim().replace(/\s+/g, ' ');
+ return `[${sender}] ${cleanedText}`;
+ })
+ .join("\n\n");
+
+ let conversationSection = "";
+ if (chatLogs.trim()) {
+ conversationSection += `--- Wizard Chat Logs (Dashboard) ---\n${chatLogs}\n\n`;
+ }
+ if (libraryChatLogs.trim()) {
+ conversationSection += `--- Library Diagnostics Logs (Troubleshooting Library) ---\n${libraryChatLogs}\n\n`;
+ }
+
+ conversationSection = conversationSection.trim();
+ const hasConversation = chatLogs.trim() || libraryChatLogs.trim();
+
+ // Determine issue title
+ let issueTitle = "No active service issue detected";
+ if (libraryChatLogs.trim()) {
+ const initMatch = libraryChatLogs.match(/Initializing Troubleshooting script for issue: "([^"]+)"/);
+ if (initMatch) {
+ issueTitle = initMatch[1];
+ } else {
+ const lines = libraryChatLogs.split("\n");
+ for (const line of lines) {
+ if (line.startsWith("[User] ")) {
+ issueTitle = line.replace("[User] ", "").trim();
+ break;
+ }
+ }
+ }
+ } else if (chatLogs.trim()) {
+ const detectMatch = chatLogs.match(/I detected that ([^ ]+) is not running/);
+ if (detectMatch) {
+ issueTitle = `${detectMatch[1]} is not running`;
+ } else {
+ issueTitle = "System health troubleshooting";
+ }
+ }
+
+ // -------------------------------------------------------
+ // AI SUMMARY via /summarize endpoint
+ // -------------------------------------------------------
+ let summaryText = "No troubleshooting conversation in this session.";
+ if (hasConversation) {
+ try {
+ const res = await fetch(BASE_URL + "/summarize", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ conversation: conversationSection,
+ system_info: lastCheckSnapshot ? `
+Cluster Health : ${lastCheckSnapshot.cluster.status.toUpperCase()}
+Cluster Nodes : ${lastCheckSnapshot.cluster.number_of_nodes}
+Active Shards : ${lastCheckSnapshot.cluster.active_shards}
+Unassigned : ${lastCheckSnapshot.cluster.unassigned_shards}
+Total RAM : ${lastCheckSnapshot.memory.total} MB
+Used RAM : ${lastCheckSnapshot.memory.used} MB
+Free RAM : ${lastCheckSnapshot.memory.free} MB
+Services : ${lastCheckSnapshot.checks.map(c => c.name + '=' + c.status).join(', ')}
+ `.trim() : "Not available."
+ })
+ });
+ if (res.ok) {
+ const data = await res.json();
+ summaryText = data.summary || "Summary unavailable.";
+ } else {
+ summaryText = "Summary unavailable (backend error).";
+ }
+ } catch (e) {
+ summaryText = "Summary unavailable (could not reach backend).";
+ }
+ }
+
+ let finalConversationBlock = "";
+ if (hasConversation) {
+ finalConversationBlock = `The issue is : ${issueTitle}
+
+Summary : ${summaryText}
+
+Detailed Conversation :
+${conversationSection}`;
+ } else {
+ finalConversationBlock = "No active troubleshooting dialog sessions in this run.";
+ }
+
+ const report =
+`================================================================================
+ WAZUH TROUBLESHOOTING PORTAL - DIAGNOSTICS REPORT
+================================================================================
+
+Session Identification : ${SESSION_ID}
+Session Started : ${SESSION_START.toLocaleString()}
+Report Generated : ${now.toLocaleString()}
+Session Active Time : ${mins}m ${secs}s
+
+================================================================================
+ SYSTEM CHECK DIAGNOSTIC SNAPSHOT
+================================================================================
+
+${checkSection}
+
+================================================================================
+ INTERACTIVE TROUBLESHOOTING CONVERSATION LOGS
+================================================================================
+
+${finalConversationBlock}
+
+================================================================================
+ END OF DIAGNOSTIC EXPORT
+================================================================================
+`;
+
+ const blob = new Blob([report], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `wazuh-diagnostics-report-${SESSION_ID}.txt`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+
+ if (downloadBtn) {
+ downloadBtn.disabled = false;
+ downloadBtn.innerHTML = originalBtnContent;
+ }
+}
+
+// -------------------------------------------------------
+// ON INITIAL PAGE LOAD
+// -------------------------------------------------------
+window.addEventListener("DOMContentLoaded", async () => {
+ await loadConfig();
+ loadSettings();
+ if (AUTO_CHECK) {
+ startCheck();
+ }
+});
+
+// Export globally accessible functions to window object
+window.setView = setView;
+window.updateServices = updateServices;
+window.updateCluster = updateCluster;
+window.updateMemory = updateMemory;
+window.updateIssues = updateIssues;
+window.startCheck = startCheck;
+window.downloadReport = downloadReport;
+window.quickRestart = quickRestart;
+window.checkFilebeat = checkFilebeat;
+window.saveSettings = saveSettings;
+window.SESSION_ID = SESSION_ID;
+window.BASE_URL = BASE_URL;
+
+
+// ─────────────────────────────────────────────────────────────────────────────
+// PATCH:CHECK-SERVICE-STATUS-V1
+// CHECK SERVICE STATUS (systemctl status) — injected buttons + panel
+// ─────────────────────────────────────────────────────────────────────────────
+async function checkServiceStatus(service) {
+ const panel = document.getElementById("statusOutputPanel");
+ const titleEl = document.getElementById("statusOutputTitle");
+ const badgeEl = document.getElementById("statusOutputBadge");
+ const textEl = document.getElementById("statusOutputText");
+ const btnId = "act-status-" + service.replace("wazuh-", "");
+ const btn = document.getElementById(btnId);
+
+ if (panel) panel.style.display = "block";
+ if (titleEl) titleEl.textContent = `systemctl status ${service}`;
+ if (badgeEl) {
+ badgeEl.textContent = "CHECKING...";
+ badgeEl.className = "badge warning";
+ }
+ if (textEl) textEl.textContent = "Fetching status...";
+
+ if (btn) {
+ btn.disabled = true;
+ btn.dataset.originalText = btn.textContent;
+ btn.textContent = "Checking...";
+ }
+
+ try {
+ let res = await fetch(BASE_URL + "/status?service=" + service);
+ let data = await res.json();
+
+ if (textEl) textEl.textContent = data.output || "(no output returned)";
+
+ if (badgeEl) {
+ const active = data.is_active === "active";
+ badgeEl.textContent = (data.is_active || "unknown").toUpperCase();
+ badgeEl.className = "badge " + (active ? "healthy" : "critical");
+ }
+ } catch (e) {
+ if (textEl) textEl.textContent = "[ERROR] Failed to reach backend for status check.";
+ if (badgeEl) {
+ badgeEl.textContent = "ERROR";
+ badgeEl.className = "badge critical";
+ }
+ console.error(e);
+ } finally {
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = btn.dataset.originalText;
+ }
+ }
+}
+window.checkServiceStatus = checkServiceStatus;
+
+function injectStatusCheckButtons() {
+ const grid = document.getElementById("quickActions");
+ if (!grid) return;
+ if (document.getElementById("act-status-indexer")) return; // already injected
+
+ const services = [
+ { id: "indexer", name: "wazuh-indexer", label: "Check Indexer Status" },
+ { id: "manager", name: "wazuh-manager", label: "Check Manager Status" },
+ { id: "dashboard", name: "wazuh-dashboard", label: "Check Dashboard Status" }
+ ];
+
+ services.forEach(svc => {
+ const b = document.createElement("button");
+ b.id = "act-status-" + svc.id;
+ b.textContent = svc.label;
+ b.onclick = () => checkServiceStatus(svc.name);
+ grid.appendChild(b);
+ });
+
+ const panel = document.createElement("div");
+ panel.id = "statusOutputPanel";
+ panel.style.marginTop = "15px";
+ panel.style.display = "none";
+ panel.innerHTML = `
+
+
+
+
+
+ `;
+ grid.insertAdjacentElement("afterend", panel);
+}
+
+window.addEventListener("DOMContentLoaded", injectStatusCheckButtons);
+// If DOMContentLoaded already fired before this script ran, run immediately too.
+if (document.readyState === "interactive" || document.readyState === "complete") {
+ injectStatusCheckButtons();
+}
+
+
+// ─────────────────────────────────────────────────────────────────────────────
+// PATCH:UNIFY-QUICK-ACTIONS-OUTPUT-V1
+// Override quickRestart() and checkFilebeat() so ALL Quick Action buttons
+// (restart x3, filebeat test, status check x3) report into the SAME panel
+// (#statusOutputPanel) right under the buttons, instead of the far-away
+// #logs "System Check Logs" box. This function redefinition intentionally
+// overrides the earlier quickRestart/checkFilebeat in this same file.
+// ─────────────────────────────────────────────────────────────────────────────
+async function quickRestart(service) {
+ const panel = document.getElementById("statusOutputPanel");
+ const titleEl = document.getElementById("statusOutputTitle");
+ const badgeEl = document.getElementById("statusOutputBadge");
+ const textEl = document.getElementById("statusOutputText");
+ const btnId = "act-restart-" + service.replace("wazuh-", "");
+ const btn = document.getElementById(btnId);
+
+ if (panel) panel.style.display = "block";
+ if (titleEl) titleEl.textContent = `Restarting ${service}...`;
+ if (badgeEl) {
+ badgeEl.textContent = "RESTARTING...";
+ badgeEl.className = "badge warning";
+ }
+ if (textEl) textEl.textContent = `Executing: systemctl restart ${service}\nThis can take up to 30 seconds...`;
+
+ if (btn) {
+ btn.disabled = true;
+ btn.dataset.originalText = btn.textContent;
+ btn.textContent = "Restarting...";
+ }
+
+ try {
+ let res = await fetch(BASE_URL + "/fix?service=" + service);
+ let data = await res.json();
+
+ if (titleEl) titleEl.textContent = `systemctl restart ${service}`;
+ if (textEl) textEl.textContent = data.message || "(no output returned)";
+
+ if (badgeEl) {
+ const ok = data.status_after_fix === "active" || data.status_after_fix === "ok";
+ badgeEl.textContent = (data.status_after_fix || "unknown").toUpperCase();
+ badgeEl.className = "badge " + (ok ? "healthy" : "critical");
+ }
+
+ if (window.startCheck) startCheck();
+ } catch (e) {
+ if (textEl) textEl.textContent = "[ERROR] Failed to execute restart action.";
+ if (badgeEl) {
+ badgeEl.textContent = "ERROR";
+ badgeEl.className = "badge critical";
+ }
+ console.error(e);
+ } finally {
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = btn.dataset.originalText;
+ }
+ }
+}
+window.quickRestart = quickRestart;
+
+async function checkFilebeat() {
+ const panel = document.getElementById("statusOutputPanel");
+ const titleEl = document.getElementById("statusOutputTitle");
+ const badgeEl = document.getElementById("statusOutputBadge");
+ const textEl = document.getElementById("statusOutputText");
+ const btn = document.getElementById("act-test-filebeat");
+
+ if (panel) panel.style.display = "block";
+ if (titleEl) titleEl.textContent = "Testing Filebeat output...";
+ if (badgeEl) {
+ badgeEl.textContent = "TESTING...";
+ badgeEl.className = "badge warning";
+ }
+ if (textEl) textEl.textContent = "Testing Filebeat configurations and server output connectivity...";
+
+ if (btn) {
+ btn.disabled = true;
+ btn.dataset.originalText = btn.textContent;
+ btn.textContent = "Testing...";
+ }
+
+ try {
+ let res = await fetch(BASE_URL + "/filebeat-test");
+ let data = await res.json();
+
+ if (titleEl) titleEl.textContent = "Filebeat Output Test";
+ if (textEl) textEl.textContent = data.output || "(no output returned)";
+
+ if (badgeEl) {
+ const ok = (data.output || "").toLowerCase().includes("talk to server... ok") ||
+ (data.output || "").toLowerCase().includes("talk to server... ok");
+ badgeEl.textContent = ok ? "OK" : "CHECK OUTPUT";
+ badgeEl.className = "badge " + (ok ? "healthy" : "warning");
+ }
+ } catch (e) {
+ if (textEl) textEl.textContent = "[ERROR] Failed to query Filebeat output test.";
+ if (badgeEl) {
+ badgeEl.textContent = "ERROR";
+ badgeEl.className = "badge critical";
+ }
+ console.error(e);
+ } finally {
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = btn.dataset.originalText;
+ }
+ }
+}
+window.checkFilebeat = checkFilebeat;
diff --git a/integrations/wazuh-troubleshooting-tool/frontend/assistant.js b/integrations/wazuh-troubleshooting-tool/frontend/assistant.js
new file mode 100644
index 00000000..2a9b7ee6
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/frontend/assistant.js
@@ -0,0 +1,357 @@
+const chatMessages = document.getElementById("chat-messages");
+const chatUserInput = document.getElementById("chat-user-input");
+const chatOptionButtons = document.getElementById("chat-option-buttons");
+
+let chatContext = {};
+let libraryChatContext = {};
+let libraryWizardId = null; // only set for library flows - dashboard quick-chat never saves history
+
+// Helper: Get elements based on target ('dashboard' or 'library')
+function getChatElements(target) {
+ if (target === "library") {
+ return {
+ messages: document.getElementById("library-chat-messages"),
+ userInput: document.getElementById("library-chat-user-input"),
+ optionButtons: document.getElementById("library-chat-option-buttons")
+ };
+ } else {
+ return {
+ messages: document.getElementById("chat-messages"),
+ userInput: document.getElementById("chat-user-input"),
+ optionButtons: document.getElementById("chat-option-buttons")
+ };
+ }
+}
+
+// Print a bubble to the chat logs targeting dashboard or library
+function printBubbleTarget(target, text, sender = "system") {
+ const els = getChatElements(target);
+ if (!els.messages) return;
+ const bubble = document.createElement("div");
+ bubble.className = `chat-bubble ${sender}`;
+ bubble.textContent = text;
+ els.messages.appendChild(bubble);
+ els.messages.scrollTop = els.messages.scrollHeight;
+}
+
+function printBubble(text, sender = "system") {
+ printBubbleTarget("dashboard", text, sender);
+}
+
+function printLibraryBubble(text, sender = "system") {
+ printBubbleTarget("library", text, sender);
+}
+
+// Clear all active choices button panel
+function clearOptionsTarget(target) {
+ const els = getChatElements(target);
+ if (els.optionButtons) {
+ els.optionButtons.innerHTML = "";
+ }
+}
+
+function clearOptions() {
+ clearOptionsTarget("dashboard");
+}
+
+function clearLibraryOptions() {
+ clearOptionsTarget("library");
+}
+
+// Render dynamic option buttons
+function renderOptionsTarget(target, options, callback) {
+ clearOptionsTarget(target);
+ const els = getChatElements(target);
+ if (!els.optionButtons) return;
+ options.forEach(opt => {
+ const btn = document.createElement("button");
+ btn.className = "chat-option-btn";
+ btn.textContent = opt;
+ btn.onclick = () => {
+ clearOptionsTarget(target);
+ callback(opt);
+ };
+ els.optionButtons.appendChild(btn);
+ });
+ if (els.messages) {
+ els.messages.scrollTop = els.messages.scrollHeight;
+ }
+}
+
+function renderOptions(options, callback) {
+ renderOptionsTarget("dashboard", options, callback);
+}
+
+function renderLibraryOptions(options, callback) {
+ renderOptionsTarget("library", options, callback);
+}
+
+// Parse choice options from question strings: e.g. "Do X? (yes / no)" -> ["yes", "no"]
+function parseQuestionOptions(question) {
+ const regex = /\(([^)]+)\)\s*$/;
+ const match = question.match(regex);
+ if (match) {
+ const optionsStr = match[1];
+ // Split options by / or , and trim them
+ const splitChar = optionsStr.includes('/') ? '/' : ',';
+ const options = optionsStr.split(splitChar).map(o => o.trim()).filter(Boolean);
+
+ // Return cleaned question (removing the trailing choices list) and options
+ const cleanedQuestion = question.replace(regex, "").trim();
+ return {
+ question: cleanedQuestion,
+ options: options
+ };
+ }
+ return null;
+}
+
+// Handle sending messages to backend
+async function sendChatMessageTarget(target, value) {
+ // Print user text bubble
+ printBubbleTarget(target, value, "user");
+
+ // De-focus and show pending indicator
+ clearOptionsTarget(target);
+
+ if (target === "library" && !libraryWizardId) {
+ libraryWizardId = Date.now().toString(36) + Math.random().toString(36).slice(2);
+ }
+
+ try {
+ const currentContext = target === "library" ? libraryChatContext : chatContext;
+ const res = await fetch(BASE_URL + "/assistant", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ message: value,
+ context: currentContext,
+ wizard_id: target === "library" ? libraryWizardId : undefined
+ })
+ });
+
+ const text = await res.text();
+ let data;
+ try {
+ data = JSON.parse(text);
+ } catch (e) {
+ printBubbleTarget(target, "Error: Invalid JSON response received from backend API.", "system");
+ console.error("JSON parse error:", e);
+ return;
+ }
+
+ if (!data || !data.response) {
+ printBubbleTarget(target, "Error: Empty reply received from diagnostics engine.", "system");
+ return;
+ }
+
+ const r = data.response;
+
+ if (r.type === "use_case") {
+ if (target === "library") {
+ libraryChatContext = r.context || {};
+ } else {
+ chatContext = r.context || {};
+ }
+
+ // Print main system display log
+ if (r.display) {
+ printBubbleTarget(target, r.display, "system");
+ }
+
+ // Parse options for any follow-up questions
+ if (r.ask && r.ask.length > 0) {
+ if (r.ask.length > 1) {
+ // Already a list of standalone option labels - render them
+ // directly as buttons, no parenthetical parsing needed.
+ renderOptionsTarget(target, r.ask, (selectedOpt) => {
+ sendChatMessageTarget(target, selectedOpt);
+ });
+ } else {
+ const nextQuestion = r.ask[0];
+ const parsed = parseQuestionOptions(nextQuestion);
+
+ if (parsed && parsed.options.length > 0) {
+ printBubbleTarget(target, parsed.question, "system");
+ renderOptionsTarget(target, parsed.options, (selectedOpt) => {
+ sendChatMessageTarget(target, selectedOpt);
+ });
+ } else {
+ // No choices -> simple text input prompt
+ printBubbleTarget(target, nextQuestion, "system");
+ }
+ }
+ }
+
+ if (r.done) {
+ printBubbleTarget(target, "✔ Guided diagnostics flow has completed successfully.", "system");
+ if (target === "library") {
+ libraryChatContext = {};
+ libraryWizardId = null; // saved to history server-side; next flow gets a fresh id
+ if (window.loadLibraryHistory) window.loadLibraryHistory();
+ } else {
+ chatContext = {};
+ }
+ }
+
+ return;
+ }
+
+ // Fallback text info response
+ printBubbleTarget(target, r.message || "I did not find a matching troubleshooting guide. Please select an option from the library or provide more details.", "system");
+
+ } catch (err) {
+ console.error("Fetch error:", err);
+ printBubbleTarget(target, "Error: Failed to connect to the backend troubleshooting service.", "system");
+ }
+}
+
+function sendChatMessage(value) {
+ sendChatMessageTarget("dashboard", value);
+}
+
+function sendLibraryChatMessage(value) {
+ sendChatMessageTarget("library", value);
+}
+
+// Handle chat bar input text submission
+function handleChatSubmit() {
+ if (!chatUserInput) return;
+ const value = chatUserInput.value.trim();
+ chatUserInput.value = "";
+ if (!value) return;
+
+ sendChatMessage(value);
+}
+
+function handleLibraryChatSubmit() {
+ const els = getChatElements("library");
+ if (!els.userInput) return;
+ const value = els.userInput.value.trim();
+ els.userInput.value = "";
+ if (!value) return;
+
+ sendLibraryChatMessage(value);
+}
+
+// Run troubleshooting workflow from library selection or cards
+function launchLibraryFlow(issueTitle) {
+ // Clear library chat history
+ const els = getChatElements("library");
+ if (els.messages) {
+ els.messages.innerHTML = "";
+ }
+ clearOptionsTarget("library");
+ libraryChatContext = {}; // reset previous context
+ libraryWizardId = null; // starting a new flow gets its own transcript/id
+
+ printBubbleTarget("library", `Initializing Troubleshooting script for issue: "${issueTitle}"...`, "system");
+
+ // Scroll smoothly to the Troubleshooting Library panel
+ const chatPanel = document.getElementById("panel-library-troubleshooting");
+ if (chatPanel) {
+ chatPanel.scrollIntoView({ behavior: "smooth", block: "center" });
+ // Flash/highlight border to guide user's eye
+ chatPanel.style.transition = "outline 0.3s ease";
+ chatPanel.style.outline = "2px solid var(--accent-blue)";
+ setTimeout(() => {
+ chatPanel.style.outline = "none";
+ }, 1500);
+ }
+
+ // Send initial trigger keyword to route to the correct use case in library chat
+ sendLibraryChatMessage(issueTitle);
+}
+
+// Function to reset assistant chat context
+function resetChatContext() {
+ chatContext = {};
+ libraryChatContext = {};
+ libraryWizardId = null;
+}
+
+// ── Previous Reports — download-only history, no resume ─────────────────────
+
+function libEscapeHtml(s) {
+ return String(s).replace(/[&<>"']/g, c => ({
+ "&": "&", "<": "<", ">": ">", '"': """, "'": "'",
+ }[c]));
+}
+
+function libTimeAgo(isoString) {
+ const seconds = Math.floor((new Date() - new Date(isoString)) / 1000);
+ if (seconds < 60) return "just now";
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h`;
+ const days = Math.floor(hours / 24);
+ return `${days}d`;
+}
+
+async function loadLibraryHistory() {
+ const panel = document.getElementById("library-history-panel");
+ if (!panel) return;
+ panel.innerHTML = 'Loading...
';
+ try {
+ const res = await fetch(BASE_URL + "/assistant/history");
+ const data = await res.json();
+ const runs = data.runs || [];
+ if (!runs.length) {
+ panel.innerHTML = 'No completed reports yet.
';
+ return;
+ }
+ panel.innerHTML = "";
+ runs.forEach(r => {
+ const row = document.createElement("div");
+ row.className = "agent-history-row";
+ row.style.cursor = "default";
+ row.innerHTML =
+ `${libEscapeHtml(r.title)} ` +
+ `${libTimeAgo(r.updated_at)} ` +
+ `` +
+ ` `;
+ row.querySelector("button").addEventListener("click", () => {
+ window.open(`${BASE_URL}/assistant/history/${encodeURIComponent(r.run_id)}/download`, "_blank");
+ });
+ panel.appendChild(row);
+ });
+ } catch (e) {
+ panel.innerHTML = 'Failed to load reports.
';
+ }
+}
+
+function toggleLibraryHistory() {
+ const panel = document.getElementById("library-history-panel");
+ const showing = panel.style.display === "none" || !panel.style.display;
+ panel.style.display = showing ? "block" : "none";
+ if (showing) loadLibraryHistory();
+}
+
+document.addEventListener("click", (e) => {
+ const panel = document.getElementById("library-history-panel");
+ const btn = document.getElementById("library-history-btn");
+ if (!panel || panel.style.display === "none") return;
+ if (!panel.contains(e.target) && e.target !== btn && !btn?.contains(e.target)) {
+ panel.style.display = "none";
+ }
+});
+
+// Bind to window for inline HTML callbacks and cross-file access
+window.launchLibraryFlow = launchLibraryFlow;
+window.sendChatMessage = sendChatMessage;
+window.sendLibraryChatMessage = sendLibraryChatMessage;
+window.printBubble = printBubble;
+window.printLibraryBubble = printLibraryBubble;
+window.clearOptions = clearOptions;
+window.clearLibraryOptions = clearLibraryOptions;
+window.renderOptions = renderOptions;
+window.renderLibraryOptions = renderLibraryOptions;
+window.resetChatContext = resetChatContext;
+window.handleLibraryChatSubmit = handleLibraryChatSubmit;
+window.toggleLibraryHistory = toggleLibraryHistory;
+window.loadLibraryHistory = loadLibraryHistory;
+window.handleChatSubmit = handleChatSubmit;
+
diff --git a/integrations/wazuh-troubleshooting-tool/frontend/index.html b/integrations/wazuh-troubleshooting-tool/frontend/index.html
new file mode 100644
index 00000000..0d4b2ea9
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/frontend/index.html
@@ -0,0 +1,1030 @@
+
+
+
+
+
+ Wazuh Troubleshooting Portal
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
System Health Overview
+
Real-time health status and diagnostics for your Wazuh cluster deployment
+
+
+
+
+ Overall Status
+ OFFLINE
+
+
+ Active Issues
+ ?
+
+
+ Services Online
+ ? of 5
+
+
+ Cluster Status
+ UNKNOWN
+
+
+
+
+
+
+
+
+
+
+
+ Run a system health check to inspect active issues.
+
+
+
+
+
+
+
+
Click "Run Health Check" to start system diagnostic checks...
+
+
+
+
+
+
+
+
+ Welcome to the Wazuh Troubleshooting Wizard. Select a detected issue card above to start manual troubleshooting.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ wazuh-indexer
+ Unknown
+
+
+ wazuh-manager
+ Unknown
+
+
+ wazuh-dashboard
+ Unknown
+
+
+ API Status
+ Unknown
+
+
+ Filebeat Status
+ Unknown
+
+
+
+
+
+
+
+
+ Cluster Status
+ -
+
+
+ Number of Nodes
+ -
+
+
+ Active Shards
+ -
+
+
+ Unassigned Shards
+ -
+
+
+
+
+
+
+
+
+
+ Total Memory
+ -
+
+
+ Used Memory
+ -
+
+
+ Free Memory
+ -
+
+
+
+
+ Utilization
+ 0%
+
+
+
+
+
+
+
+
+
+
+
+ Restart Indexer
+ Restart Manager
+ Restart Dashboard
+ Test Filebeat Output
+
+
+
+
+
+
+
+
+
Troubleshooting Library
+
Select a known Wazuh scenario to launch interactive diagnostic workflows
+
+
+
+
+
+
+
+
Wazuh Dashboard Not Ready Yet
+
Troubleshoot opensearch host configurations, indexer status, cert credentials, and secure permissions.
+
Analyze & Resolve
+
+
+
+
+
Alerts Not Showing on Dashboard
+
For when no alerts are showing at all, or today's wazuh-alerts-* index isn't being created - walks the full pipeline from the Wazuh Manager through Filebeat to the Indexer.
+
Analyze & Resolve
+
+
+
+
+
Alerts Not Indexing
+
Diagnose active write blocks, check disk space watermark settings, and inspect cluster shard state.
+
Analyze & Resolve
+
+
+
+
+
Could Not Connect to API
+
Resolve credentials mismatch, incorrect API username/password keys in configuration, and listener errors.
+
Analyze & Resolve
+
+
+
+
+
+
+
Application Not Found
+
Diagnose dashboard plugin installation failures and path mismatches inside dashboard config modules.
+
Analyze & Resolve
+
+
+
+
+
Cluster Health Issues
+
Troubleshoot multi-node setups, indexer cluster health status red/yellow, and unassigned replica shards.
+
Analyze & Resolve
+
+
+
+
+
Filebeat Not Working
+
Having an issue in `filebeat test output` - diagnose service status, unsupported Filebeat versions, and TLS/certificate errors.
+
Analyze & Resolve
+
+
+
+
+
+
+
Filebeat Mapping Issue
+
Diagnose field-mapping/index-template conflicts - illegal_argument_exception, mapper_parsing_exception, or dashboard "N of M shards failed" errors.
+
Analyze & Resolve
+
+
+
+
+
+
+
+
+
+ Welcome to the Wazuh Library Diagnostics Wizard. Select a known Wazuh scenario card above to start interactive diagnostics, or ask any troubleshooting question below for AI-powered assistance.
+
+
+
+
+
+
+ Send
+
+
+
+
+
+
+
+
+
Operations Reporting Center
+
Generate environment health reports, deployment insights, and Wazuh operational intelligence metrics
+
+
+
+
+
+
+
+
+
Agent Health Report
+
Review connected fleet status, disconnected agents, communication issues, and activity summaries.
+
Generate Report
+
+
+
+
+
+
+
+
Dashboard Health Report
+
Analyze availability, uptime, connection performance, API endpoints, and configuration errors.
+
Generate Report
+
+
+
+
+
+
Indexing & Data Flow Report
+
Track alert ingestion trends, indexing pipelines, document statistics, and pipeline write issues.
+
Generate Report
+
+
+
+
+
+
+
+
Cluster Health Report
+
Monitor multi-node indexing state, active shards, unassigned shards, and host resource utilization.
+
Generate Report
+
+
+
+
+
+
Environment Assessment Report
+
Generate a complete platform evaluation with risk scoring, observations, and recommendations.
+
Generate Report
+
+
+
+
+
+
Security Events Report
+
Analyze historical alert timeline trends, top triggered rules, severity distribution, and source IPs.
+
Generate Report
+
+
+
+
+
+
Custom Report Builder
+
Tailor a report by selecting specific modules and exporting them to HTML or PDF.
+
Build Custom
+
+
+
+
+
+
Build Custom Report
+
Select the operational sections you want to include in your consolidated report:
+
+
+ Generate Custom Report
+ Cancel
+
+
+
+
+
+
+
+
+
+ Back to Reports Hub
+
+
+
+
+ Export HTML
+
+
+
+ Export PDF (Print)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Wazuh Copilot
+
AI-powered Wazuh expert — investigates autonomously, proposes every state-changing fix before running it
+
+
+
+
+
+
+ Checking...
+
+
+
+ Ollama (local)
+ Claude (API)
+
+
+
+
+
+
+
+
+
+
+ New Session
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Settings
+
Configure portal connections, refresh rates, and environment details
+
+
+
+
+
+
Connection Settings
+
+
+ API Endpoint URL
+
+ The base URL of the FastAPI backend troubleshooter server.
+
+
+
+
+
Auto Health Check
+
Automatically run health checks when the page loads.
+
+
+
+
+
+
+
+
+ Refresh Interval
+
+ Disabled
+ 15 seconds
+ 30 seconds
+ 60 seconds
+ 5 minutes
+
+ Periodically check system health in the background.
+
+
+
Save Settings
+
+
+
+
+
About Portal
+
+
+ Version
+ 1.0.0
+
+
+ Backend Framework
+ FastAPI + Python
+
+
+ Frontend Stack
+ Vanilla JS + CSS
+
+
+ Security Analytics
+ Plotly Dash (Port 7200)
+
+
+
+ Wazuh Troubleshooting Portal provides guided diagnostics, interactive troubleshooting wizard scripts, and automated remediation systems for Wazuh SIEM environments.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/integrations/wazuh-troubleshooting-tool/frontend/manual.js b/integrations/wazuh-troubleshooting-tool/frontend/manual.js
new file mode 100644
index 00000000..20ed17bb
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/frontend/manual.js
@@ -0,0 +1,164 @@
+// Start manual troubleshooting restart flow in the chat
+function launchManualTroubleshooting(serviceName) {
+ // Navigate to Dashboard using window.setView
+ window.setView("home");
+
+ // Clear chat history
+ const chatMessages = document.getElementById("chat-messages");
+ if (chatMessages) {
+ chatMessages.innerHTML = "";
+ }
+
+ // Clear options panel
+ if (window.clearOptions) {
+ window.clearOptions();
+ }
+
+ // Reset assistant context
+ if (window.resetChatContext) {
+ window.resetChatContext();
+ }
+
+ // Scroll smoothly to chat wizard
+ const chatPanel = document.getElementById("panel-troubleshooting-wizard");
+ if (chatPanel) {
+ chatPanel.scrollIntoView({ behavior: "smooth", block: "center" });
+ chatPanel.style.transition = "outline 0.3s ease";
+ chatPanel.style.outline = "2px solid var(--accent-blue)";
+ setTimeout(() => {
+ chatPanel.style.outline = "none";
+ }, 1500);
+ }
+
+ if (window.printBubble) {
+ window.printBubble(`I detected that ${serviceName} is not running. Would you like me to restart it now?`, "system");
+ }
+
+ if (window.renderOptions) {
+ window.renderOptions(["Yes", "No"], (choice) => {
+ handleManualChoice(serviceName, choice);
+ });
+ }
+}
+
+async function handleManualChoice(serviceName, choice) {
+ if (window.printBubble) {
+ window.printBubble(choice, "user");
+ }
+ if (window.clearOptions) {
+ window.clearOptions();
+ }
+
+ if (choice === "Yes") {
+ if (window.printBubble) {
+ window.printBubble(`Attempting to restart ${serviceName}...`, "system");
+ }
+
+ try {
+ // Call backend fix endpoint
+ let res = await fetch(window.BASE_URL + "/fix?service=" + serviceName);
+ let data = await res.json();
+
+ // Check status (polling for up to 30 seconds if the service is starting/activating)
+ let isFixed = (data.status_after_fix === "active" || data.status_after_fix === "ok");
+ let checkData = null;
+
+ if (!isFixed) {
+ for (let attempt = 0; attempt < 10; attempt++) {
+ let checkRes = await fetch(window.BASE_URL + "/check");
+ checkData = await checkRes.json();
+
+ // Refresh dashboard panels
+ if (window.updateServices) window.updateServices(checkData.checks);
+ if (window.updateCluster) window.updateCluster(checkData.cluster_details);
+ if (window.updateMemory) window.updateMemory(checkData.memory);
+ if (window.updateIssues) window.updateIssues(checkData.issues || []);
+
+ const serviceStatus = checkData.checks.find(c => c.name === serviceName);
+ const currentStatus = serviceStatus ? serviceStatus.status : "";
+
+ if (currentStatus === "active" || currentStatus === "ok") {
+ isFixed = true;
+ break;
+ }
+
+ // If it's not active and not activating (e.g. failed/inactive/error) after 3 attempts, break early
+ if (currentStatus !== "activating" && attempt > 2) {
+ break;
+ }
+
+ // Wait 3 seconds before next check
+ await new Promise(r => setTimeout(r, 3000));
+ }
+ } else {
+ // If immediately active, do a quick status update to refresh the dashboard panels
+ let checkRes = await fetch(window.BASE_URL + "/check");
+ checkData = await checkRes.json();
+ if (window.updateServices) window.updateServices(checkData.checks);
+ if (window.updateCluster) window.updateCluster(checkData.cluster_details);
+ if (window.updateMemory) window.updateMemory(checkData.memory);
+ if (window.updateIssues) window.updateIssues(checkData.issues || []);
+ }
+
+ if (isFixed) {
+ if (window.printBubble) {
+ window.printBubble(`✔ ${serviceName} restarted successfully and is now running!`, "system");
+ }
+ } else {
+ if (window.printBubble) {
+ window.printBubble(data.message || `FAILED: ${serviceName} failed to start.`, "system");
+ window.printBubble(`✖ Failed to restart ${serviceName}. The service is still inactive.\nWould you like to search the Troubleshooting Library or share/download the diagnostics report to get help from the official Wazuh Community?`, "system");
+ }
+ if (window.renderOptions) {
+ window.renderOptions(["Troubleshooting Library", "Share with Wazuh Community"], (fallbackChoice) => {
+ handleFallbackChoice(fallbackChoice);
+ });
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ if (window.printBubble) {
+ window.printBubble(`Error: Failed to communicate with backend to restart ${serviceName}.`, "system");
+ }
+ if (window.renderOptions) {
+ window.renderOptions(["Troubleshooting Library", "Share with Wazuh Community"], (fallbackChoice) => {
+ handleFallbackChoice(fallbackChoice);
+ });
+ }
+ }
+ } else {
+ if (window.printBubble) {
+ window.printBubble(`Restart cancelled. If you want to perform deep diagnostics, you can search the Troubleshooting Library or share/download the diagnostics report to get help from the official Wazuh Community.`, "system");
+ }
+ if (window.renderOptions) {
+ window.renderOptions(["Troubleshooting Library", "Share with Wazuh Community"], (fallbackChoice) => {
+ handleFallbackChoice(fallbackChoice);
+ });
+ }
+ }
+}
+
+function handleFallbackChoice(choice) {
+ if (window.printBubble) {
+ window.printBubble(choice, "user");
+ }
+ if (window.clearOptions) {
+ window.clearOptions();
+ }
+ if (choice === "Troubleshooting Library") {
+ window.setView("library");
+ } else if (choice === "Share with Wazuh Community") {
+ if (window.downloadReport) {
+ window.downloadReport();
+ }
+ if (window.printBubble) {
+ window.printBubble("I have generated and downloaded your diagnostics report. Please upload it and share your issue on the official Wazuh community forum at https://wazuh.com/community/ to get help from experts.", "system");
+ }
+ }
+}
+
+// Bind to window for inline HTML callbacks and cross-file access
+window.launchManualTroubleshooting = launchManualTroubleshooting;
+window.handleManualChoice = handleManualChoice;
+window.handleFallbackChoice = handleFallbackChoice;
+
diff --git a/integrations/wazuh-troubleshooting-tool/frontend/reports.js b/integrations/wazuh-troubleshooting-tool/frontend/reports.js
new file mode 100644
index 00000000..ac904a49
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/frontend/reports.js
@@ -0,0 +1,1314 @@
+// Reports & Operations Center Javascript Engine
+
+let activeReportData = null;
+let activeReportType = "";
+
+// View switcher functions
+function backToReportsHub() {
+ document.getElementById("reports-hub").style.display = "block";
+ document.getElementById("report-view-container").style.display = "none";
+}
+
+function showCustomReportBuilder() {
+ document.getElementById("custom-builder-panel").style.display = "block";
+}
+
+function hideCustomReportBuilder() {
+ document.getElementById("custom-builder-panel").style.display = "none";
+}
+
+// Global reports generator entry point
+async function generateReport(type) {
+ activeReportType = type;
+ const bodyEl = document.getElementById("report-document-body");
+ bodyEl.replaceChildren();
+
+ const loading = document.createElement("div");
+ loading.style.textAlign = "center";
+ loading.style.padding = "40px";
+ loading.style.color = "var(--text-secondary)";
+ loading.textContent = "Connecting to Wazuh API and generating report data...";
+ bodyEl.appendChild(loading);
+
+ document.getElementById("reports-hub").style.display = "none";
+ document.getElementById("report-view-container").style.display = "block";
+
+ try {
+ const res = await fetch(`${window.BASE_URL}/reports?type=${type}`);
+ const data = await res.json();
+ activeReportData = data;
+
+ renderReportDocument(type, data, bodyEl);
+ } catch (e) {
+ console.error(e);
+ bodyEl.replaceChildren();
+ const errCard = document.createElement("div");
+ errCard.className = "issue-card";
+ errCard.style.background = "rgba(244, 63, 94, 0.1)";
+ errCard.style.borderColor = "var(--accent-red)";
+
+ const errText = document.createElement("span");
+ errText.className = "issue-card-text";
+ errText.textContent = `Error generating report: Failed to communicate with reports backend API.`;
+ errCard.appendChild(errText);
+ bodyEl.appendChild(errCard);
+ }
+}
+
+async function generateCustomReport() {
+ activeReportType = "custom";
+ const bodyEl = document.getElementById("report-document-body");
+ bodyEl.replaceChildren();
+
+ const sections = [];
+ if (document.getElementById("cb-section-agents").checked) sections.push("agents");
+ if (document.getElementById("cb-section-dashboard").checked) sections.push("dashboard");
+ if (document.getElementById("cb-section-dataflow").checked) sections.push("dataflow");
+ if (document.getElementById("cb-section-cluster").checked) sections.push("cluster");
+ if (document.getElementById("cb-section-api").checked) sections.push("api");
+ if (document.getElementById("cb-section-environment").checked) sections.push("environment");
+ if (document.getElementById("cb-section-security").checked) sections.push("security");
+
+ if (sections.length === 0) {
+ alert("Please select at least one section to include in the report.");
+ return;
+ }
+
+ const loading = document.createElement("div");
+ loading.style.textAlign = "center";
+ loading.style.padding = "40px";
+ loading.style.color = "var(--text-secondary)";
+ loading.textContent = "Compiling customized operational report...";
+ bodyEl.appendChild(loading);
+
+ document.getElementById("reports-hub").style.display = "none";
+ document.getElementById("report-view-container").style.display = "block";
+
+ try {
+ const res = await fetch(`${window.BASE_URL}/reports?type=custom§ions=${sections.join(",")}`);
+ const data = await res.json();
+ activeReportData = data;
+
+ renderReportDocument("custom", data, bodyEl);
+ } catch (e) {
+ console.error(e);
+ bodyEl.replaceChildren();
+ const errCard = document.createElement("div");
+ errCard.className = "issue-card";
+ errCard.textContent = "Error: Failed to generate custom report.";
+ bodyEl.appendChild(errCard);
+ }
+}
+
+// Document layout dispatch
+function renderReportDocument(type, data, container) {
+ container.replaceChildren();
+
+ // Header Info
+ const header = document.createElement("div");
+ header.style.borderBottom = "1px solid rgba(255,255,255,0.1)";
+ header.style.paddingBottom = "20px";
+ header.style.marginBottom = "30px";
+
+ const title = document.createElement("h2");
+ title.style.fontSize = "26px";
+ title.style.fontWeight = "700";
+ title.style.color = "var(--accent-blue)";
+ title.className = "report-title";
+
+ const subtitle = document.createElement("p");
+ subtitle.style.color = "var(--text-secondary)";
+ subtitle.style.fontSize = "13.5px";
+ subtitle.style.marginTop = "5px";
+ subtitle.className = "report-meta";
+
+ const timestampStr = new Date().toLocaleString();
+
+ if (type === "agent") {
+ title.textContent = "Wazuh Agent Fleet Health Report";
+ subtitle.textContent = `Generated: ${timestampStr} | Scope: Registered Wazuh endpoint agents`;
+ } else if (type === "dashboard") {
+ title.textContent = "Wazuh Dashboard Health & Performance";
+ subtitle.textContent = `Generated: ${timestampStr} | Scope: Kibana/Dashboard service metrics`;
+ } else if (type === "dataflow") {
+ title.textContent = "Alert Ingestion & Data Pipeline Diagnostics";
+ subtitle.textContent = `Generated: ${timestampStr} | Scope: Indexer ingestion data flow`;
+ } else if (type === "cluster") {
+ title.textContent = "Wazuh Indexer Database Cluster Report";
+ subtitle.textContent = `Generated: ${timestampStr} | Scope: Cluster nodes and shard allocation`;
+ } else if (type === "environment") {
+ title.textContent = "Wazuh Environment Operational Assessment";
+ subtitle.textContent = `Generated: ${timestampStr} | Scope: Platform integrity and security risks`;
+ } else if (type === "security") {
+ title.textContent = "Wazuh Security Events Report";
+ subtitle.textContent = `Generated: ${timestampStr} | Scope: Historical security alert trends and rule distributions`;
+ } else if (type === "custom") {
+ title.textContent = "Consolidated Wazuh Custom Operational Report";
+ subtitle.textContent = `Generated: ${timestampStr} | Scope: Selected environment sections`;
+ }
+
+ header.appendChild(title);
+ header.appendChild(subtitle);
+ container.appendChild(header);
+
+ // If fallback warnings exist, display them
+ if (data.status === "warning" || data.connection_error) {
+ const warn = document.createElement("div");
+ warn.style.background = "rgba(245,158,11,0.1)";
+ warn.style.border = "1px solid rgba(245,158,11,0.3)";
+ warn.style.borderRadius = "8px";
+ warn.style.padding = "12px 18px";
+ warn.style.marginBottom = "25px";
+ warn.style.color = "var(--accent-yellow)";
+ warn.style.fontSize = "13.5px";
+ warn.textContent = "⚠ Warning: Connection to the live API timed out or failed. Displaying simulated cached snapshot metrics.";
+ container.appendChild(warn);
+ }
+
+ // Render specific sections
+ if (type === "agent") {
+ renderAgentSection(data, container);
+ } else if (type === "dashboard") {
+ renderDashboardSection(data, container);
+ } else if (type === "dataflow") {
+ renderDataflowSection(data, container);
+ } else if (type === "cluster") {
+ renderClusterSection(data, container);
+ } else if (type === "environment") {
+ renderEnvironmentSection(data, container);
+ } else if (type === "security") {
+ renderSecuritySection(data, container);
+ } else if (type === "custom") {
+ if (data.agents) {
+ const h = document.createElement("h3");
+ h.textContent = "Section 1: Agent Fleet Health";
+ h.style.color = "var(--accent-blue)";
+ h.style.marginTop = "40px";
+ container.appendChild(h);
+ renderAgentSection(data.agents, container);
+ }
+ if (data.dashboard) {
+ const h = document.createElement("h3");
+ h.textContent = "Section 2: Dashboard Health";
+ h.style.color = "var(--accent-blue)";
+ h.style.marginTop = "40px";
+ container.appendChild(h);
+ renderDashboardSection(data.dashboard, container);
+ }
+ if (data.dataflow) {
+ const h = document.createElement("h3");
+ h.textContent = "Section 3: Indexing & Data Pipeline";
+ h.style.color = "var(--accent-blue)";
+ h.style.marginTop = "40px";
+ container.appendChild(h);
+ renderDataflowSection(data.dataflow, container);
+ }
+ if (data.cluster) {
+ const h = document.createElement("h3");
+ h.textContent = "Section 4: Database Cluster Health";
+ h.style.color = "var(--accent-blue)";
+ h.style.marginTop = "40px";
+ container.appendChild(h);
+ renderClusterSection(data.cluster, container);
+ }
+ if (data.environment) {
+ const h = document.createElement("h3");
+ h.textContent = "Section 5: Environmental Assessment findings";
+ h.style.color = "var(--accent-blue)";
+ h.style.marginTop = "40px";
+ container.appendChild(h);
+ renderEnvironmentSection(data.environment, container);
+ }
+ if (data.security) {
+ const h = document.createElement("h3");
+ h.textContent = "Section 6: Security Events Analysis";
+ h.style.color = "var(--accent-blue)";
+ h.style.marginTop = "40px";
+ container.appendChild(h);
+ renderSecuritySection(data.security, container);
+ }
+ }
+}
+
+// 1. Agent Section Renderer
+function renderAgentSection(data, container) {
+ // Metrics layout
+ const grid = document.createElement("div");
+ grid.className = "metric-row";
+ grid.style.display = "grid";
+ grid.style.gridTemplateColumns = "repeat(4, 1fr)";
+ grid.style.gap = "15px";
+ grid.style.marginBottom = "30px";
+
+ const cards = [
+ { label: "Total Agents", val: data.summary.total, color: "var(--accent-blue)" },
+ { label: "Active", val: data.summary.active, color: "var(--accent-green)" },
+ { label: "Disconnected", val: data.summary.disconnected, color: "var(--accent-red)" },
+ { label: "Never Connected", val: data.summary.never_connected, color: "var(--text-secondary)" }
+ ];
+
+ cards.forEach(c => {
+ const card = document.createElement("div");
+ card.className = "metric-card";
+ card.style.background = "rgba(255,255,255,0.02)";
+ card.style.border = "1px solid rgba(255,255,255,0.06)";
+ card.style.padding = "15px";
+ card.style.borderRadius = "8px";
+ card.style.textAlign = "center";
+
+ const lbl = document.createElement("div");
+ lbl.textContent = c.label;
+ lbl.style.fontSize = "12px";
+ lbl.style.color = "var(--text-secondary)";
+
+ const val = document.createElement("div");
+ val.className = "metric-value";
+ val.textContent = c.val;
+ val.style.fontSize = "26px";
+ val.style.fontWeight = "700";
+ val.style.marginTop = "5px";
+ val.style.color = c.color;
+
+ card.appendChild(lbl);
+ card.appendChild(val);
+ grid.appendChild(card);
+ });
+ container.appendChild(grid);
+
+ // Distribution row
+ const row = document.createElement("div");
+ row.style.display = "grid";
+ row.style.gridTemplateColumns = "1fr 1fr";
+ row.style.gap = "25px";
+ row.style.marginBottom = "30px";
+
+ // Left: OS breakdown bar chart
+ const left = document.createElement("div");
+ left.className = "report-section";
+ const leftTitle = document.createElement("h4");
+ leftTitle.className = "section-title";
+ leftTitle.textContent = "Agent Fleet Operating Systems";
+ leftTitle.style.marginBottom = "15px";
+ leftTitle.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
+ leftTitle.style.paddingBottom = "5px";
+ left.appendChild(leftTitle);
+
+ if (data.os_breakdown && Object.keys(data.os_breakdown).length > 0) {
+ const chart = renderBarChart(data.os_breakdown);
+ left.appendChild(chart);
+ } else {
+ const placeholder = document.createElement("div");
+ placeholder.textContent = "No OS distribution data available.";
+ placeholder.style.color = "var(--text-secondary)";
+ left.appendChild(placeholder);
+ }
+ row.appendChild(left);
+
+ // Right: Communication Issues
+ const right = document.createElement("div");
+ right.className = "report-section";
+ const rightTitle = document.createElement("h4");
+ rightTitle.className = "section-title";
+ rightTitle.textContent = "Agent Communication & Uptime Alerts";
+ rightTitle.style.marginBottom = "15px";
+ rightTitle.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
+ rightTitle.style.paddingBottom = "5px";
+ right.appendChild(rightTitle);
+
+ if (data.communication_issues && data.communication_issues.length > 0) {
+ const table = document.createElement("table");
+ table.style.width = "100%";
+ table.style.borderCollapse = "collapse";
+
+ const thead = document.createElement("thead");
+ const trHead = document.createElement("tr");
+ ["Agent ID", "Name", "IP Address", "Last Seen"].forEach(hText => {
+ const th = document.createElement("th");
+ th.textContent = hText;
+ th.style.padding = "8px";
+ th.style.borderBottom = "1px solid rgba(255,255,255,0.1)";
+ th.style.textAlign = "left";
+ th.style.fontSize = "13px";
+ trHead.appendChild(th);
+ });
+ thead.appendChild(trHead);
+ table.appendChild(thead);
+
+ const tbody = document.createElement("tbody");
+ data.communication_issues.forEach(issue => {
+ const tr = document.createElement("tr");
+ [issue.id, issue.name, issue.ip, new Date(issue.lastKeepAlive).toLocaleString()].forEach(cellVal => {
+ const td = document.createElement("td");
+ td.textContent = cellVal;
+ td.style.padding = "8px";
+ td.style.fontSize = "13px";
+ td.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
+ tr.appendChild(td);
+ });
+ tbody.appendChild(tr);
+ });
+ table.appendChild(tbody);
+ right.appendChild(table);
+ } else {
+ const healthy = document.createElement("div");
+ healthy.style.color = "var(--accent-green)";
+ healthy.style.fontWeight = "600";
+ healthy.style.fontSize = "14px";
+ healthy.style.padding = "20px 0";
+ healthy.textContent = "✔ No agent communication anomalies or alerts identified.";
+ right.appendChild(healthy);
+ }
+ row.appendChild(right);
+ container.appendChild(row);
+}
+
+// 2. Dashboard Section Renderer
+function renderDashboardSection(data, container) {
+ const grid = document.createElement("div");
+ grid.style.display = "grid";
+ grid.style.gridTemplateColumns = "1fr 1fr";
+ grid.style.gap = "25px";
+ grid.style.marginBottom = "30px";
+
+ // Left: Dashboard Service status
+ const left = document.createElement("div");
+ left.className = "report-section";
+ const leftTitle = document.createElement("h4");
+ leftTitle.className = "section-title";
+ leftTitle.textContent = "Dashboard Daemon Status";
+ leftTitle.style.marginBottom = "15px";
+ left.appendChild(leftTitle);
+
+ const svc = document.createElement("div");
+ svc.style.display = "flex";
+ svc.style.alignItems = "center";
+ svc.style.gap = "12px";
+ svc.style.background = "rgba(255,255,255,0.02)";
+ svc.style.padding = "15px";
+ svc.style.borderRadius = "8px";
+ svc.style.border = "1px solid rgba(255,255,255,0.06)";
+
+ const label = document.createElement("span");
+ label.textContent = "wazuh-dashboard status:";
+ label.style.fontWeight = "600";
+
+ const badge = document.createElement("span");
+ badge.className = `badge ${data.dashboard_service === "active" ? "healthy" : "critical"}`;
+ badge.textContent = data.dashboard_service.toUpperCase();
+
+ svc.appendChild(label);
+ svc.appendChild(badge);
+ left.appendChild(svc);
+
+ // Memory/CPU progress bars
+ const metricsDiv = document.createElement("div");
+ metricsDiv.style.marginTop = "20px";
+ metricsDiv.style.display = "flex";
+ metricsDiv.style.flexDirection = "column";
+ metricsDiv.style.gap = "15px";
+
+ // CPU
+ const cpuDiv = document.createElement("div");
+ const cpuLbl = document.createElement("div");
+ cpuLbl.style.display = "flex";
+ cpuLbl.style.justifyContent = "space-between";
+ cpuLbl.style.fontSize = "13px";
+ cpuLbl.style.color = "var(--text-secondary)";
+ cpuLbl.innerHTML = `Manager CPU Usage ${data.system_metrics.cpu_usage}% `;
+ cpuDiv.appendChild(cpuLbl);
+
+ const cpuBg = document.createElement("div");
+ cpuBg.className = "progress-bar-bg";
+ const cpuFill = document.createElement("div");
+ cpuFill.className = "progress-bar-fill";
+ cpuFill.style.width = `${data.system_metrics.cpu_usage}%`;
+ cpuBg.appendChild(cpuFill);
+ cpuDiv.appendChild(cpuBg);
+ metricsDiv.appendChild(cpuDiv);
+
+ // RAM
+ const ramDiv = document.createElement("div");
+ const ramLbl = document.createElement("div");
+ ramLbl.style.display = "flex";
+ ramLbl.style.justifyContent = "space-between";
+ ramLbl.style.fontSize = "13px";
+ ramLbl.style.color = "var(--text-secondary)";
+ ramLbl.innerHTML = `Manager Memory Usage ${data.system_metrics.memory_utilization}% (${data.system_metrics.memory_usage_mb} MB / ${data.system_metrics.memory_total_mb} MB) `;
+ ramDiv.appendChild(ramLbl);
+
+ const ramBg = document.createElement("div");
+ ramBg.className = "progress-bar-bg";
+ const ramFill = document.createElement("div");
+ ramFill.className = "progress-bar-fill";
+ ramFill.style.width = `${data.system_metrics.memory_utilization}%`;
+ ramBg.appendChild(ramFill);
+ ramDiv.appendChild(ramBg);
+ metricsDiv.appendChild(ramDiv);
+
+ left.appendChild(metricsDiv);
+ grid.appendChild(left);
+
+ // Right: Uptime trends chart
+ const right = document.createElement("div");
+ right.className = "report-section";
+ const rightTitle = document.createElement("h4");
+ rightTitle.className = "section-title";
+ rightTitle.textContent = "Dashboard UI Availability Trends";
+ rightTitle.style.marginBottom = "15px";
+ right.appendChild(rightTitle);
+
+ const trendContainer = document.createElement("div");
+ trendContainer.className = "chart-container";
+
+ const lineChart = renderLineChart(data.uptime_trends, "day", "uptime");
+ trendContainer.appendChild(lineChart);
+ right.appendChild(trendContainer);
+
+ grid.appendChild(right);
+ container.appendChild(grid);
+}
+
+// 3. Data Flow Section Renderer
+function renderDataflowSection(data, container) {
+ const row = document.createElement("div");
+ row.style.display = "grid";
+ row.style.gridTemplateColumns = "1fr 1fr";
+ row.style.gap = "25px";
+ row.style.marginBottom = "30px";
+
+ // Left: Ingestion trends line chart
+ const left = document.createElement("div");
+ left.className = "report-section";
+ const leftTitle = document.createElement("h4");
+ leftTitle.className = "section-title";
+ leftTitle.textContent = "24-Hour Alert Ingestion Rate";
+ leftTitle.style.marginBottom = "15px";
+ left.appendChild(leftTitle);
+
+ const trend = renderLineChart(data.ingestion_trends, "time", "alerts");
+ left.appendChild(trend);
+ row.appendChild(left);
+
+ // Right: Indexer indices list
+ const right = document.createElement("div");
+ right.className = "report-section";
+ const rightTitle = document.createElement("h4");
+ rightTitle.className = "section-title";
+ rightTitle.textContent = "Active Daily Elasticsearch / Opensearch Indices";
+ rightTitle.style.marginBottom = "15px";
+ right.appendChild(rightTitle);
+
+ const table = document.createElement("table");
+ table.style.width = "100%";
+ table.style.borderCollapse = "collapse";
+
+ const thead = document.createElement("thead");
+ const trHead = document.createElement("tr");
+ ["Index Pattern", "Health", "Status", "Doc Count", "Size"].forEach(hText => {
+ const th = document.createElement("th");
+ th.textContent = hText;
+ th.style.padding = "8px";
+ th.style.borderBottom = "1px solid rgba(255,255,255,0.1)";
+ th.style.fontSize = "13px";
+ th.style.textAlign = "left";
+ trHead.appendChild(th);
+ });
+ thead.appendChild(trHead);
+ table.appendChild(thead);
+
+ const tbody = document.createElement("tbody");
+ data.indices.forEach(idx => {
+ const tr = document.createElement("tr");
+ const nameCell = document.createElement("td");
+ nameCell.textContent = idx.index;
+ nameCell.style.padding = "8px";
+ nameCell.style.fontSize = "12px";
+ nameCell.style.fontFamily = "var(--font-mono)";
+ tr.appendChild(nameCell);
+
+ const healthCell = document.createElement("td");
+ const b = document.createElement("span");
+ b.className = `badge ${idx.health === "green" ? "healthy" : (idx.health === "yellow" ? "warning" : "critical")}`;
+ b.textContent = idx.health.toUpperCase();
+ healthCell.appendChild(b);
+ healthCell.style.padding = "8px";
+ tr.appendChild(healthCell);
+
+ [idx.status, idx["docs.count"], idx["store.size"]].forEach(val => {
+ const td = document.createElement("td");
+ td.textContent = val;
+ td.style.padding = "8px";
+ td.style.fontSize = "12.5px";
+ tr.appendChild(td);
+ });
+
+ tbody.appendChild(tr);
+ });
+ table.appendChild(tbody);
+ right.appendChild(table);
+ row.appendChild(right);
+
+ container.appendChild(row);
+}
+
+// 4. Cluster Section Renderer
+function renderClusterSection(data, container) {
+ const grid = document.createElement("div");
+ grid.style.display = "grid";
+ grid.style.gridTemplateColumns = "1fr 1fr";
+ grid.style.gap = "25px";
+ grid.style.marginBottom = "30px";
+
+ // Left: Shard allocations
+ const left = document.createElement("div");
+ left.className = "report-section";
+ const leftTitle = document.createElement("h4");
+ leftTitle.className = "section-title";
+ leftTitle.textContent = "Cluster Health & Shard Status";
+ leftTitle.style.marginBottom = "15px";
+ left.appendChild(leftTitle);
+
+ const table = document.createElement("table");
+ table.style.width = "100%";
+ table.style.borderCollapse = "collapse";
+
+ const details = data.cluster_details;
+ const rows = [
+ { label: "Cluster Name", val: details.cluster_name },
+ { label: "Health Status", val: details.status.toUpperCase(), isBadge: true },
+ { label: "Number of Nodes", val: details.number_of_nodes },
+ { label: "Active Shards", val: details.active_shards },
+ { label: "Unassigned Shards", val: details.unassigned_shards }
+ ];
+
+ const tbody = document.createElement("tbody");
+ rows.forEach(r => {
+ const tr = document.createElement("tr");
+ const tdLabel = document.createElement("td");
+ tdLabel.textContent = r.label;
+ tdLabel.style.padding = "10px";
+ tdLabel.style.color = "var(--text-secondary)";
+ tr.appendChild(tdLabel);
+
+ const tdVal = document.createElement("td");
+ tdVal.style.textAlign = "right";
+ tdVal.style.padding = "10px";
+ tdVal.style.fontWeight = "600";
+
+ if (r.isBadge) {
+ const b = document.createElement("span");
+ b.className = `badge ${r.val === "GREEN" ? "healthy" : (r.val === "YELLOW" ? "warning" : "critical")}`;
+ b.textContent = r.val;
+ tdVal.appendChild(b);
+ } else {
+ tdVal.textContent = r.val;
+ }
+
+ tr.appendChild(tdVal);
+ tbody.appendChild(tr);
+ });
+ table.appendChild(tbody);
+ left.appendChild(table);
+ grid.appendChild(left);
+
+ // Right: Node statistics
+ const right = document.createElement("div");
+ right.className = "report-section";
+ const rightTitle = document.createElement("h4");
+ rightTitle.className = "section-title";
+ rightTitle.textContent = "Indexer Database Nodes Status";
+ rightTitle.style.marginBottom = "15px";
+ right.appendChild(rightTitle);
+
+ const nodeTable = document.createElement("table");
+ nodeTable.style.width = "100%";
+ nodeTable.style.borderCollapse = "collapse";
+
+ const thead = document.createElement("thead");
+ const trHead = document.createElement("tr");
+ ["Node Name", "IP Address", "Status", "JVM Memory", "Disk Free"].forEach(hText => {
+ const th = document.createElement("th");
+ th.textContent = hText;
+ th.style.padding = "8px";
+ th.style.borderBottom = "1px solid rgba(255,255,255,0.1)";
+ th.style.fontSize = "13px";
+ th.style.textAlign = "left";
+ trHead.appendChild(th);
+ });
+ thead.appendChild(trHead);
+ nodeTable.appendChild(thead);
+
+ const nodeTbody = document.createElement("tbody");
+ data.node_status.forEach(node => {
+ const tr = document.createElement("tr");
+ const nodeCell = document.createElement("td");
+ nodeCell.textContent = node.node;
+ nodeCell.style.padding = "8px";
+ nodeCell.style.fontSize = "13px";
+ tr.appendChild(nodeCell);
+
+ const ipCell = document.createElement("td");
+ ipCell.textContent = node.ip;
+ ipCell.style.padding = "8px";
+ ipCell.style.fontSize = "13px";
+ tr.appendChild(ipCell);
+
+ const statusCell = document.createElement("td");
+ const b = document.createElement("span");
+ b.className = `badge ${node.status === "online" ? "healthy" : "critical"}`;
+ b.textContent = node.status.toUpperCase();
+ statusCell.appendChild(b);
+ statusCell.style.padding = "8px";
+ tr.appendChild(statusCell);
+
+ const memCell = document.createElement("td");
+ memCell.textContent = node.jvm_memory;
+ memCell.style.padding = "8px";
+ memCell.style.fontSize = "13px";
+ tr.appendChild(memCell);
+
+ const diskCell = document.createElement("td");
+ diskCell.textContent = node.disk_free;
+ diskCell.style.padding = "8px";
+ diskCell.style.fontSize = "13px";
+ tr.appendChild(diskCell);
+
+ nodeTbody.appendChild(tr);
+ });
+ nodeTable.appendChild(nodeTbody);
+ right.appendChild(nodeTable);
+ grid.appendChild(right);
+
+ container.appendChild(grid);
+}
+
+// 5. Environment Section Renderer
+function renderEnvironmentSection(data, container) {
+ // Health Score Circular Display
+ const scoreDiv = document.createElement("div");
+ scoreDiv.style.display = "flex";
+ scoreDiv.style.flexDirection = "column";
+ scoreDiv.style.alignItems = "center";
+ scoreDiv.style.padding = "25px";
+ scoreDiv.style.background = "rgba(255,255,255,0.01)";
+ scoreDiv.style.borderRadius = "12px";
+ scoreDiv.style.border = "1px solid rgba(255,255,255,0.05)";
+ scoreDiv.style.marginBottom = "30px";
+
+ const scoreLabel = document.createElement("div");
+ scoreLabel.textContent = "OVERALL PLATFORM HEALTH SCORE";
+ scoreLabel.style.fontSize = "11px";
+ scoreLabel.style.letterSpacing = "1.5px";
+ scoreLabel.style.color = "var(--text-secondary)";
+ scoreDiv.appendChild(scoreLabel);
+
+ const scoreVal = document.createElement("div");
+ scoreVal.textContent = `${data.overall_health_score} / 100`;
+ scoreVal.style.fontSize = "46px";
+ scoreVal.style.fontWeight = "800";
+ scoreVal.style.marginTop = "10px";
+
+ // Dynamically color based on score
+ if (data.overall_health_score >= 80) {
+ scoreVal.style.color = "var(--accent-green)";
+ } else if (data.overall_health_score >= 50) {
+ scoreVal.style.color = "var(--accent-yellow)";
+ } else {
+ scoreVal.style.color = "var(--accent-red)";
+ }
+ scoreDiv.appendChild(scoreVal);
+ container.appendChild(scoreDiv);
+
+ // Findings and Observations
+ const row = document.createElement("div");
+ row.style.display = "grid";
+ row.style.gridTemplateColumns = "1fr 1fr";
+ row.style.gap = "25px";
+ row.style.marginBottom = "30px";
+
+ // Left: Findings
+ const left = document.createElement("div");
+ left.className = "report-section";
+ const leftTitle = document.createElement("h4");
+ leftTitle.className = "section-title";
+ leftTitle.textContent = "Operational Findings";
+ leftTitle.style.marginBottom = "15px";
+ left.appendChild(leftTitle);
+
+ const findUl = document.createElement("ul");
+ findUl.style.paddingLeft = "20px";
+ findUl.style.display = "flex";
+ findUl.style.flexDirection = "column";
+ findUl.style.gap = "10px";
+ data.findings.forEach(f => {
+ const li = document.createElement("li");
+ li.className = "finding-item";
+ li.textContent = f;
+ findUl.appendChild(li);
+ });
+ left.appendChild(findUl);
+ row.appendChild(left);
+
+ // Right: Observations
+ const right = document.createElement("div");
+ right.className = "report-section";
+ const rightTitle = document.createElement("h4");
+ rightTitle.className = "section-title";
+ rightTitle.textContent = "Environment Observations";
+ rightTitle.style.marginBottom = "15px";
+ right.appendChild(rightTitle);
+
+ const obsUl = document.createElement("ul");
+ obsUl.style.paddingLeft = "20px";
+ obsUl.style.display = "flex";
+ obsUl.style.flexDirection = "column";
+ obsUl.style.gap = "10px";
+ data.observations.forEach(o => {
+ const li = document.createElement("li");
+ li.className = "finding-item";
+ li.textContent = o;
+ obsUl.appendChild(li);
+ });
+ right.appendChild(obsUl);
+ row.appendChild(right);
+ container.appendChild(row);
+
+ // Risks & Recommendations Table
+ const risksSection = document.createElement("div");
+ risksSection.className = "report-section";
+ const risksTitle = document.createElement("h4");
+ risksTitle.className = "section-title";
+ risksTitle.textContent = "Deployment Risks & Proactive Remediation Steps";
+ risksTitle.style.marginBottom = "15px";
+ risksSection.appendChild(risksTitle);
+
+ if (data.risks.length > 0) {
+ const table = document.createElement("table");
+ table.style.width = "100%";
+ table.style.borderCollapse = "collapse";
+
+ const thead = document.createElement("thead");
+ const trHead = document.createElement("tr");
+ ["Severity", "Identified Security / Operational Risk", "Recommended Corrective Action"].forEach(hText => {
+ const th = document.createElement("th");
+ th.textContent = hText;
+ th.style.padding = "8px";
+ th.style.borderBottom = "1px solid rgba(255,255,255,0.1)";
+ th.style.fontSize = "13px";
+ th.style.textAlign = "left";
+ trHead.appendChild(th);
+ });
+ thead.appendChild(trHead);
+ table.appendChild(thead);
+
+ const tbody = document.createElement("tbody");
+ for (let i = 0; i < data.risks.length; i++) {
+ const tr = document.createElement("tr");
+
+ const sevCell = document.createElement("td");
+ const b = document.createElement("span");
+ b.className = "badge critical";
+ b.textContent = "MEDIUM";
+ b.style.background = "rgba(245,158,11,0.15)";
+ b.style.color = "var(--accent-yellow)";
+ b.style.border = "1px solid rgba(245,158,11,0.3)";
+ sevCell.appendChild(b);
+ sevCell.style.padding = "10px";
+ tr.appendChild(sevCell);
+
+ const riskCell = document.createElement("td");
+ riskCell.textContent = data.risks[i];
+ riskCell.style.padding = "10px";
+ riskCell.style.fontSize = "13px";
+ tr.appendChild(riskCell);
+
+ const recCell = document.createElement("td");
+ recCell.textContent = data.recommendations[i] || "N/A";
+ recCell.style.padding = "10px";
+ recCell.style.fontSize = "13px";
+ recCell.style.color = "var(--accent-blue)";
+ tr.appendChild(recCell);
+
+ tbody.appendChild(tr);
+ }
+ table.appendChild(tbody);
+ risksSection.appendChild(table);
+ } else {
+ const healthy = document.createElement("div");
+ healthy.style.color = "var(--accent-green)";
+ healthy.style.fontWeight = "600";
+ healthy.style.fontSize = "14px";
+ healthy.style.padding = "20px 0";
+ healthy.textContent = "✔ Congratulations! No high or medium operational risks detected in this environment configuration.";
+ risksSection.appendChild(healthy);
+ }
+ container.appendChild(risksSection);
+}
+
+// 6. Security Events Section Renderer
+function renderSecuritySection(data, container) {
+ // Metrics layout
+ const grid = document.createElement("div");
+ grid.className = "metric-row";
+ grid.style.display = "grid";
+ grid.style.gridTemplateColumns = "repeat(4, 1fr)";
+ grid.style.gap = "15px";
+ grid.style.marginBottom = "30px";
+
+ const cards = [
+ { label: "Total Security Alerts", val: data.summary.total, color: "var(--accent-blue)" },
+ { label: "High Severity Alerts (>=7)", val: data.summary.high, color: "var(--accent-red)" },
+ { label: "Reporting Agents", val: data.summary.agents, color: "var(--accent-green)" },
+ { label: "Unique Source IPs", val: data.summary.ips, color: "var(--accent-yellow)" }
+ ];
+
+ cards.forEach(c => {
+ const card = document.createElement("div");
+ card.className = "metric-card";
+ card.style.background = "rgba(255,255,255,0.02)";
+ card.style.border = "1px solid rgba(255,255,255,0.06)";
+ card.style.padding = "15px";
+ card.style.borderRadius = "8px";
+ card.style.textAlign = "center";
+
+ const lbl = document.createElement("div");
+ lbl.textContent = c.label;
+ lbl.style.fontSize = "12px";
+ lbl.style.color = "var(--text-secondary)";
+
+ const val = document.createElement("div");
+ val.className = "metric-value";
+ val.textContent = c.val;
+ val.style.fontSize = "26px";
+ val.style.fontWeight = "700";
+ val.style.marginTop = "5px";
+ val.style.color = c.color;
+
+ card.appendChild(lbl);
+ card.appendChild(val);
+ grid.appendChild(card);
+ });
+ container.appendChild(grid);
+
+ // Row 1: Timeline & Top Rules
+ const row1 = document.createElement("div");
+ row1.style.display = "grid";
+ row1.style.gridTemplateColumns = "1fr 1fr";
+ row1.style.gap = "25px";
+ row1.style.marginBottom = "30px";
+
+ // Left: Timeline
+ const left1 = document.createElement("div");
+ left1.className = "report-section";
+ const left1Title = document.createElement("h4");
+ left1Title.className = "section-title";
+ left1Title.textContent = "24-Hour Alert Ingestion Timeline";
+ left1Title.style.marginBottom = "15px";
+ left1Title.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
+ left1Title.style.paddingBottom = "5px";
+ left1.appendChild(left1Title);
+
+ if (data.timeline && data.timeline.length > 0) {
+ const chart = renderLineChart(data.timeline, "time", "alerts");
+ left1.appendChild(chart);
+ } else {
+ const placeholder = document.createElement("div");
+ placeholder.textContent = "No timeline data available.";
+ placeholder.style.color = "var(--text-secondary)";
+ left1.appendChild(placeholder);
+ }
+ row1.appendChild(left1);
+
+ // Right: Top Rules
+ const right1 = document.createElement("div");
+ right1.className = "report-section";
+ const right1Title = document.createElement("h4");
+ right1Title.className = "section-title";
+ right1Title.textContent = "Top Triggered Rules";
+ right1Title.style.marginBottom = "15px";
+ right1Title.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
+ right1Title.style.paddingBottom = "5px";
+ right1.appendChild(right1Title);
+
+ if (data.top_rules && Object.keys(data.top_rules).length > 0) {
+ const chart = renderBarChart(data.top_rules);
+ right1.appendChild(chart);
+ } else {
+ const placeholder = document.createElement("div");
+ placeholder.textContent = "No rules trigger data available.";
+ placeholder.style.color = "var(--text-secondary)";
+ right1.appendChild(placeholder);
+ }
+ row1.appendChild(right1);
+ container.appendChild(row1);
+
+ // Row 2: Severity Distribution & Top Source IPs
+ const row2 = document.createElement("div");
+ row2.style.display = "grid";
+ row2.style.gridTemplateColumns = "1fr 1fr";
+ row2.style.gap = "25px";
+ row2.style.marginBottom = "30px";
+
+ // Left: Severity Distribution
+ const left2 = document.createElement("div");
+ left2.className = "report-section";
+ const left2Title = document.createElement("h4");
+ left2Title.className = "section-title";
+ left2Title.textContent = "Rule Severity Level Distribution";
+ left2Title.style.marginBottom = "15px";
+ left2Title.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
+ left2Title.style.paddingBottom = "5px";
+ left2.appendChild(left2Title);
+
+ if (data.level_counts && Object.keys(data.level_counts).length > 0) {
+ const sortedLevels = {};
+ Object.keys(data.level_counts)
+ .sort((a, b) => parseInt(a) - parseInt(b))
+ .forEach(k => {
+ sortedLevels[`Lvl ${k}`] = data.level_counts[k];
+ });
+ const chart = renderBarChart(sortedLevels);
+ left2.appendChild(chart);
+ } else {
+ const placeholder = document.createElement("div");
+ placeholder.textContent = "No severity distribution data available.";
+ placeholder.style.color = "var(--text-secondary)";
+ left2.appendChild(placeholder);
+ }
+ row2.appendChild(left2);
+
+ // Right: Top Source IPs
+ const right2 = document.createElement("div");
+ right2.className = "report-section";
+ const right2Title = document.createElement("h4");
+ right2Title.className = "section-title";
+ right2Title.textContent = "Top Source IPs";
+ right2Title.style.marginBottom = "15px";
+ right2Title.style.borderBottom = "1px solid rgba(255,255,255,0.05)";
+ right2Title.style.paddingBottom = "5px";
+ right2.appendChild(right2Title);
+
+ if (data.top_ips && Object.keys(data.top_ips).length > 0) {
+ const chart = renderBarChart(data.top_ips);
+ right2.appendChild(chart);
+ } else {
+ const healthy = document.createElement("div");
+ healthy.style.color = "var(--accent-green)";
+ healthy.style.fontWeight = "600";
+ healthy.style.fontSize = "14px";
+ healthy.style.padding = "20px 0";
+ healthy.textContent = "✔ No source IP address details found.";
+ right2.appendChild(healthy);
+ }
+ row2.appendChild(right2);
+ container.appendChild(row2);
+}
+
+// ---------------------------------------------------------
+// SVG Graphic Rendering Engines (Vanilla Vector Charting)
+// ---------------------------------------------------------
+
+function renderBarChart(dataMap) {
+ const keys = Object.keys(dataMap);
+ const values = Object.values(dataMap);
+ const maxVal = Math.max(...values, 1);
+
+ // Draw SVG
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("width", "100%");
+ svg.setAttribute("height", "220");
+ svg.style.background = "rgba(0,0,0,0.15)";
+ svg.style.borderRadius = "8px";
+ svg.style.padding = "15px";
+
+ // Axis line
+ const axisY = document.createElementNS("http://www.w3.org/2000/svg", "line");
+ axisY.setAttribute("x1", "50");
+ axisY.setAttribute("y1", "20");
+ axisY.setAttribute("x2", "50");
+ axisY.setAttribute("y2", "170");
+ axisY.setAttribute("stroke", "rgba(255,255,255,0.15)");
+ axisY.setAttribute("stroke-width", "2");
+ svg.appendChild(axisY);
+
+ const axisX = document.createElementNS("http://www.w3.org/2000/svg", "line");
+ axisX.setAttribute("x1", "50");
+ axisX.setAttribute("y1", "170");
+ axisX.setAttribute("x2", "350");
+ axisX.setAttribute("y2", "170");
+ axisX.setAttribute("stroke", "rgba(255,255,255,0.15)");
+ axisX.setAttribute("stroke-width", "2");
+ svg.appendChild(axisX);
+
+ // Render Bars
+ const barWidth = 35;
+ const gap = 20;
+
+ for (let i = 0; i < keys.length; i++) {
+ const x = 70 + i * (barWidth + gap);
+ const barHeight = (values[i] / maxVal) * 130;
+ const y = 170 - barHeight;
+
+ // Rect
+ const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+ rect.setAttribute("x", x.toString());
+ rect.setAttribute("y", y.toString());
+ rect.setAttribute("width", barWidth.toString());
+ rect.setAttribute("height", barHeight.toString());
+ rect.setAttribute("fill", "url(#barGradient)");
+ rect.setAttribute("rx", "3");
+ svg.appendChild(rect);
+
+ // X Label
+ const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ label.setAttribute("x", (x + barWidth / 2).toString());
+ label.setAttribute("y", "190");
+ label.setAttribute("fill", "var(--text-secondary)");
+ label.setAttribute("font-size", "11");
+ label.setAttribute("text-anchor", "middle");
+ label.textContent = keys[i].slice(0, 8);
+ svg.appendChild(label);
+
+ // Value Text
+ const valText = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ valText.setAttribute("x", (x + barWidth / 2).toString());
+ valText.setAttribute("y", (y - 5).toString());
+ valText.setAttribute("fill", "var(--accent-blue)");
+ valText.setAttribute("font-size", "11");
+ valText.setAttribute("font-weight", "600");
+ valText.setAttribute("text-anchor", "middle");
+ valText.textContent = values[i].toString();
+ svg.appendChild(valText);
+ }
+
+ // Gradient definitions
+ const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
+ const grad = document.createElementNS("http://www.w3.org/2000/svg", "linearGradient");
+ grad.setAttribute("id", "barGradient");
+ grad.setAttribute("x1", "0%");
+ grad.setAttribute("y1", "0%");
+ grad.setAttribute("x2", "0%");
+ grad.setAttribute("y2", "100%");
+
+ const stop1 = document.createElementNS("http://www.w3.org/2000/svg", "stop");
+ stop1.setAttribute("offset", "0%");
+ stop1.setAttribute("stop-color", "var(--accent-blue)");
+
+ const stop2 = document.createElementNS("http://www.w3.org/2000/svg", "stop");
+ stop2.setAttribute("offset", "100%");
+ stop2.setAttribute("stop-color", "#5b21b6");
+
+ grad.appendChild(stop1);
+ grad.appendChild(stop2);
+ defs.appendChild(grad);
+ svg.appendChild(defs);
+
+ return svg;
+}
+
+function renderLineChart(trends, xKey, yKey) {
+ const maxVal = Math.max(...trends.map(t => t[yKey]), 1);
+
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+ svg.setAttribute("width", "100%");
+ svg.setAttribute("height", "220");
+ svg.style.background = "rgba(0,0,0,0.15)";
+ svg.style.borderRadius = "8px";
+ svg.style.padding = "15px";
+
+ // Grid Lines and Axes
+ const axisY = document.createElementNS("http://www.w3.org/2000/svg", "line");
+ axisY.setAttribute("x1", "50");
+ axisY.setAttribute("y1", "20");
+ axisY.setAttribute("x2", "50");
+ axisY.setAttribute("y2", "170");
+ axisY.setAttribute("stroke", "rgba(255,255,255,0.15)");
+ axisY.setAttribute("stroke-width", "2");
+ svg.appendChild(axisY);
+
+ const axisX = document.createElementNS("http://www.w3.org/2000/svg", "line");
+ axisX.setAttribute("x1", "50");
+ axisX.setAttribute("y1", "170");
+ axisX.setAttribute("x2", "380");
+ axisX.setAttribute("y2", "170");
+ axisX.setAttribute("stroke", "rgba(255,255,255,0.15)");
+ axisX.setAttribute("stroke-width", "2");
+ svg.appendChild(axisX);
+
+ // Construct polyline points
+ const width = 310;
+ const stepX = width / (trends.length - 1 || 1);
+ let points = "";
+
+ for (let i = 0; i < trends.length; i++) {
+ const x = 60 + i * stepX;
+ const val = trends[i][yKey];
+ const y = 170 - (val / maxVal) * 130;
+ points += `${x},${y} `;
+
+ // Every 4th tick for labels to avoid overlaps
+ if (trends.length < 10 || i % 4 === 0 || i === trends.length - 1) {
+ const tickLbl = document.createElementNS("http://www.w3.org/2000/svg", "text");
+ tickLbl.setAttribute("x", x.toString());
+ tickLbl.setAttribute("y", "190");
+ tickLbl.setAttribute("fill", "var(--text-secondary)");
+ tickLbl.setAttribute("font-size", "10");
+ tickLbl.setAttribute("text-anchor", "middle");
+
+ // Format labels
+ let txt = trends[i][xKey];
+ if (txt.includes(" ")) txt = txt.split(" ")[1]; // extract time
+ tickLbl.textContent = txt;
+ svg.appendChild(tickLbl);
+ }
+
+ // Draw little node circle
+ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
+ circle.setAttribute("cx", x.toString());
+ circle.setAttribute("cy", y.toString());
+ circle.setAttribute("r", "3");
+ circle.setAttribute("fill", "var(--accent-blue)");
+ svg.appendChild(circle);
+ }
+
+ // Draw the trend line
+ const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
+ polyline.setAttribute("fill", "none");
+ polyline.setAttribute("stroke", "var(--accent-blue)");
+ polyline.setAttribute("stroke-width", "3");
+ polyline.setAttribute("points", points.trim());
+ svg.appendChild(polyline);
+
+ return svg;
+}
+
+// ---------------------------------------------------------
+// EXPORT HANDLERS (Standalone Document Builders)
+// ---------------------------------------------------------
+
+function exportReportHTML() {
+ if (!activeReportData) return;
+
+ const bodyHTML = document.getElementById("report-document-body").innerHTML;
+
+ const styles = `
+ body {
+ background-color: #0b101c;
+ color: #f1f5f9;
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
+ padding: 40px;
+ max-width: 1200px;
+ margin: 0 auto;
+ }
+ .report-title { font-size: 28px; font-weight: 700; color: #00bcff; margin-bottom: 5px; }
+ .report-meta { font-size: 13.5px; color: #94a3b8; margin-bottom: 30px; }
+ .metric-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin-bottom: 30px; }
+ .metric-card { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.08); padding: 20px; border-radius: 8px; text-align: center; }
+ .metric-value { font-size: 28px; font-weight: 700; margin-top: 5px; }
+ .report-section { margin-top: 40px; }
+ .section-title { font-size: 18px; font-weight: 600; margin-bottom: 15px; border-bottom: 1px solid rgba(255,255,255,0.08); padding-bottom: 8px; }
+ table { width: 100%; border-collapse: collapse; margin-top: 15px; }
+ th, td { border: 1px solid rgba(255,255,255,0.08); padding: 12px; text-align: left; }
+ th { background-color: rgba(255,255,255,0.03); font-weight: 600; color: #94a3b8; }
+ .badge { padding: 4px 8px; border-radius: 4px; font-weight: 700; font-size: 11px; display: inline-block; text-transform: uppercase; }
+ .healthy { background: rgba(16, 185, 129, 0.15); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.3); }
+ .critical { background: rgba(244, 63, 94, 0.15); color: #f43f5e; border: 1px solid rgba(244, 63, 94, 0.3); }
+ .warning { background: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.3); }
+ .progress-bar-bg { height: 8px; background: rgba(255,255,255,0.08); border-radius: 4px; overflow: hidden; margin-top: 6px; }
+ .progress-bar-fill { height: 100%; background: linear-gradient(90deg, #00bcff, #5b21b6); border-radius: 4px; }
+ svg { background: rgba(0,0,0,0.15); border-radius: 8px; margin-top: 10px; }
+ svg text { fill: #94a3b8 !important; }
+ ul { display: flex; flex-direction: column; gap: 10px; padding-left: 20px; }
+ .finding-item { font-size: 14px; line-height: 1.5; color: #cbd5e1; }
+ `;
+
+ const htmlContent = `
+
+
+
+
+ Wazuh Operations Center Diagnostic Report
+
+
+
+ ${bodyHTML}
+
+`;
+
+ const blob = new Blob([htmlContent], { type: "text/html" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `wazuh-operations-report-${activeReportType}-${Math.random().toString(36).substring(2, 7).toUpperCase()}.html`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+}
+
+function exportReportPDF() {
+ const reportHTML = document.getElementById("report-document-body").innerHTML;
+ const printWindow = window.open("", "_blank");
+ printWindow.document.write(`
+
+
+
+ Wazuh Diagnostic Report - PDF Export
+
+
+
+ ${reportHTML}
+
+
+
+ `);
+ printWindow.document.close();
+}
+
+// Bind callbacks globally
+window.generateReport = generateReport;
+window.showCustomReportBuilder = showCustomReportBuilder;
+window.hideCustomReportBuilder = hideCustomReportBuilder;
+window.generateCustomReport = generateCustomReport;
+window.backToReportsHub = backToReportsHub;
+window.exportReportHTML = exportReportHTML;
+window.exportReportPDF = exportReportPDF;
diff --git a/integrations/wazuh-troubleshooting-tool/frontend/styles.css b/integrations/wazuh-troubleshooting-tool/frontend/styles.css
new file mode 100644
index 00000000..149a7ecb
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/frontend/styles.css
@@ -0,0 +1,980 @@
+@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
+
+:root {
+ --bg-gradient: radial-gradient(circle at top, #111a2e, #05080f);
+ --panel-bg: rgba(13, 22, 38, 0.7);
+ --panel-border: 1px solid rgba(0, 162, 255, 0.15);
+ --text-primary: #f1f5f9;
+ --text-secondary: #94a3b8;
+
+ --accent-blue: #00bcff;
+ --accent-blue-hover: #009ad4;
+ --accent-green: #10b981;
+ --accent-green-hover: #059669;
+ --accent-red: #f43f5e;
+ --accent-red-hover: #e11d48;
+ --accent-yellow: #f59e0b;
+ --accent-yellow-hover: #d97706;
+
+ --font-sans: 'Outfit', sans-serif;
+ --font-mono: 'Space Grotesk', monospace;
+ --sidebar-width: 260px;
+ --header-height: 70px;
+ --transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+* {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ background: var(--bg-gradient);
+ color: var(--text-primary);
+ font-family: var(--font-sans);
+ min-height: 100vh;
+ overflow-x: hidden;
+ display: flex;
+}
+
+/* Scrollbar Customization */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+::-webkit-scrollbar-track {
+ background: rgba(0,0,0,0.2);
+}
+::-webkit-scrollbar-thumb {
+ background: rgba(0, 188, 255, 0.2);
+ border-radius: 3px;
+}
+::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 188, 255, 0.4);
+}
+
+/* Sidebar Styling */
+.sidebar {
+ width: var(--sidebar-width);
+ background: rgba(8, 13, 23, 0.95);
+ border-right: 1px solid rgba(0, 162, 255, 0.1);
+ height: 100vh;
+ position: fixed;
+ left: 0;
+ top: 0;
+ display: flex;
+ flex-direction: column;
+ z-index: 100;
+}
+
+.sidebar-logo {
+ padding: 24px 20px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ font-weight: 700;
+ font-size: 18px;
+ letter-spacing: 1px;
+ color: var(--accent-blue);
+ font-family: var(--font-mono);
+ border-bottom: 1px solid rgba(0, 162, 255, 0.1);
+}
+
+.sidebar-logo svg {
+ width: 24px;
+ height: 24px;
+ fill: var(--accent-blue);
+ filter: drop-shadow(0 0 8px rgba(0, 188, 255, 0.5));
+}
+
+.sidebar-menu {
+ padding: 20px 12px;
+ list-style: none;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ flex: 1;
+}
+
+.sidebar-section-title {
+ font-size: 10px;
+ font-weight: 700;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+ letter-spacing: 1.5px;
+ margin: 15px 0 5px 12px;
+}
+
+.sidebar-item {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 14px;
+ color: var(--text-secondary);
+ text-decoration: none;
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 500;
+ transition: var(--transition);
+}
+
+.sidebar-item:hover, .sidebar-item.active {
+ color: var(--text-primary);
+ background: rgba(0, 188, 255, 0.1);
+ box-shadow: inset 3px 0 0 var(--accent-blue);
+}
+
+.sidebar-item svg {
+ width: 18px;
+ height: 18px;
+ transition: var(--transition);
+}
+
+.sidebar-item:hover svg, .sidebar-item.active svg {
+ stroke: var(--accent-blue);
+ fill: none;
+}
+
+.badge-soon {
+ font-size: 10px;
+ font-family: var(--font-mono);
+ background: rgba(245, 158, 11, 0.2);
+ color: var(--accent-yellow);
+ border: 1px solid rgba(245, 158, 11, 0.4);
+ padding: 2px 6px;
+ border-radius: 4px;
+ margin-left: auto;
+}
+
+/* Header/Session Toolbar Styling */
+.header {
+ position: fixed;
+ top: 0;
+ left: var(--sidebar-width);
+ right: 0;
+ height: var(--header-height);
+ background: rgba(10, 17, 30, 0.85);
+ backdrop-filter: blur(8px);
+ border-bottom: 1px solid rgba(0, 162, 255, 0.1);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 30px;
+ z-index: 99;
+}
+
+.session-toolbar-info {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.toolbar-label {
+ color: var(--text-secondary);
+ font-family: var(--font-mono);
+ font-size: 13px;
+}
+
+.toolbar-value {
+ color: var(--accent-blue);
+ font-family: var(--font-mono);
+ font-weight: 600;
+}
+
+.divider {
+ color: rgba(255,255,255,0.1);
+}
+
+.toolbar-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+/* Status Pill */
+.status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ padding: 4px 10px;
+ border-radius: 12px;
+ background: rgba(16, 185, 129, 0.15);
+ color: var(--accent-green);
+ border: 1px solid rgba(16, 185, 129, 0.3);
+}
+
+.status-pill.offline {
+ background: rgba(244, 63, 94, 0.15);
+ color: var(--accent-red);
+ border: 1px solid rgba(244, 63, 94, 0.3);
+}
+
+.status-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+ box-shadow: 0 0 6px currentColor;
+}
+
+/* Main Layout Area */
+.main-content {
+ margin-left: var(--sidebar-width);
+ margin-top: var(--header-height);
+ padding: 30px;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+}
+
+/* View/Routing visibility */
+.view {
+ display: none;
+ animation: fadeIn 0.3s ease-in-out;
+ width: 100%;
+}
+
+.view.active {
+ display: block;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+/* Dashboard Summary Cards */
+.summary-cards-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 20px;
+ margin-bottom: 25px;
+}
+
+.summary-card {
+ background: var(--panel-bg);
+ border: var(--panel-border);
+ border-radius: 12px;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ transition: var(--transition);
+}
+
+.summary-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 20px rgba(0, 188, 255, 0.15);
+ border-color: rgba(0, 188, 255, 0.4);
+}
+
+.summary-card-title {
+ font-size: 11px;
+ font-family: var(--font-mono);
+ text-transform: uppercase;
+ color: var(--text-secondary);
+ letter-spacing: 1px;
+}
+
+.summary-card-value {
+ font-size: 24px;
+ font-weight: 700;
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+}
+
+/* Layout Grid */
+.dashboard-grid {
+ display: grid;
+ grid-template-columns: 3fr 2fr;
+ gap: 20px;
+ margin-bottom: 25px;
+}
+
+.dashboard-full-row {
+ grid-column: span 2;
+}
+
+/* Cards & Panels */
+.panel {
+ background: var(--panel-bg);
+ border: var(--panel-border);
+ border-radius: 12px;
+ padding: 24px;
+ margin-bottom: 20px;
+}
+
+.panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 18px;
+ border-bottom: 1px solid rgba(255,255,255,0.05);
+ padding-bottom: 12px;
+}
+
+.panel-title {
+ font-size: 16px;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.panel-title svg {
+ stroke: var(--accent-blue);
+ width: 20px;
+ height: 20px;
+}
+
+/* Health status items */
+.health-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.health-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 14px;
+ border-radius: 8px;
+ background: rgba(255,255,255,0.02);
+ border: 1px solid rgba(255,255,255,0.05);
+}
+
+.health-item-name {
+ font-weight: 500;
+ font-family: var(--font-mono);
+ font-size: 14px;
+}
+
+.badge {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ padding: 3px 8px;
+ border-radius: 6px;
+}
+
+.badge.healthy {
+ background: rgba(16, 185, 129, 0.15);
+ color: var(--accent-green);
+ border: 1px solid rgba(16, 185, 129, 0.3);
+}
+
+.badge.warning {
+ background: rgba(245, 158, 11, 0.15);
+ color: var(--accent-yellow);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+}
+
+.badge.critical {
+ background: rgba(244, 63, 94, 0.15);
+ color: var(--accent-red);
+ border: 1px solid rgba(244, 63, 94, 0.3);
+}
+
+/* Cluster stats layout */
+.stat-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.stat-table td {
+ padding: 10px 0;
+ border-bottom: 1px solid rgba(255,255,255,0.05);
+ font-size: 14px;
+}
+
+.stat-table tr:last-child td {
+ border-bottom: none;
+}
+
+.stat-label {
+ color: var(--text-secondary);
+}
+
+.stat-value {
+ text-align: right;
+ font-family: var(--font-mono);
+ font-weight: 600;
+}
+
+/* Memory Usage progress bars */
+.progress-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-top: 15px;
+}
+
+.progress-label-row {
+ display: flex;
+ justify-content: space-between;
+ font-size: 13px;
+ color: var(--text-secondary);
+}
+
+.progress-bar-bg {
+ height: 8px;
+ background: rgba(255,255,255,0.08);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ background: linear-gradient(90deg, var(--accent-blue), #5b21b6);
+ border-radius: 4px;
+ width: 0;
+ transition: width 1s ease-in-out;
+}
+
+/* Terminal Console Panel */
+.terminal {
+ background: #020617;
+ border: 1px solid rgba(255,255,255,0.08);
+ border-radius: 8px;
+ padding: 15px;
+ font-family: var(--font-mono);
+ font-size: 13px;
+ color: #e2e8f0;
+ height: 250px;
+ overflow-y: auto;
+ white-space: pre-wrap;
+ box-shadow: inset 0 2px 8px rgba(0,0,0,0.8);
+}
+
+/* Quick Actions buttons */
+.quick-actions-grid {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 10px;
+}
+
+/* Buttons */
+button {
+ background: rgba(0, 188, 255, 0.1);
+ color: var(--accent-blue);
+ border: 1px solid rgba(0, 188, 255, 0.3);
+ padding: 10px 16px;
+ border-radius: 8px;
+ cursor: pointer;
+ font-weight: 600;
+ font-family: var(--font-sans);
+ transition: var(--transition);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+button:hover {
+ background: var(--accent-blue);
+ color: #020617;
+ box-shadow: 0 0 12px rgba(0, 188, 255, 0.4);
+ border-color: var(--accent-blue);
+}
+
+button.primary {
+ background: var(--accent-blue);
+ color: #020617;
+ border: none;
+}
+
+button.primary:hover {
+ background: var(--accent-blue-hover);
+ box-shadow: 0 0 16px rgba(0, 188, 255, 0.6);
+}
+
+button.green {
+ background: rgba(16, 185, 129, 0.1);
+ color: var(--accent-green);
+ border: 1px solid rgba(16, 185, 129, 0.3);
+}
+
+button.green:hover {
+ background: var(--accent-green);
+ color: #020617;
+ box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
+ border-color: var(--accent-green);
+}
+
+button.danger {
+ background: rgba(244, 63, 94, 0.1);
+ color: var(--accent-red);
+ border: 1px solid rgba(244, 63, 94, 0.3);
+}
+
+button.danger:hover {
+ background: var(--accent-red);
+ color: #020617;
+ box-shadow: 0 0 12px rgba(244, 63, 94, 0.4);
+ border-color: var(--accent-red);
+}
+
+button.outline {
+ background: transparent;
+ color: var(--text-primary);
+ border: 1px solid rgba(255,255,255,0.15);
+}
+
+button.outline:hover {
+ background: rgba(255,255,255,0.05);
+ border-color: rgba(255,255,255,0.3);
+ box-shadow: none;
+}
+
+/* Detected issue cards layout */
+.issue-cards-container {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.issue-card {
+ background: rgba(244, 63, 94, 0.05);
+ border: 1px solid rgba(244, 63, 94, 0.2);
+ border-radius: 10px;
+ padding: 16px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 15px;
+}
+
+.issue-card-desc {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.issue-card-icon {
+ color: var(--accent-red);
+ stroke: currentColor;
+ width: 22px;
+ height: 22px;
+}
+
+.issue-card-text {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.issue-card-text b {
+ color: var(--accent-red);
+}
+
+/* Chat Layout (Guided Troubleshooting) */
+.chat-container {
+ display: flex;
+ flex-direction: column;
+ height: 500px;
+ background: rgba(8, 14, 25, 0.6);
+ border: var(--panel-border);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.chat-messages {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.chat-bubble {
+ max-width: 80%;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 14px 18px;
+ border-radius: 12px;
+ font-size: 14.5px;
+ line-height: 1.5;
+}
+
+.chat-bubble.system {
+ align-self: flex-start;
+ background: rgba(22, 32, 53, 0.8);
+ border: 1px solid rgba(0, 162, 255, 0.1);
+ color: var(--text-primary);
+ border-bottom-left-radius: 2px;
+ white-space: pre-wrap;
+}
+
+.chat-bubble.user {
+ align-self: flex-end;
+ background: var(--accent-blue);
+ color: #020617;
+ border-bottom-right-radius: 2px;
+ font-weight: 500;
+}
+
+.chat-option-buttons {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 10px;
+ align-self: flex-start;
+}
+
+.chat-option-btn {
+ background: rgba(0, 188, 255, 0.12);
+ border: 1px solid rgba(0, 188, 255, 0.35);
+ color: var(--accent-blue);
+ padding: 8px 16px;
+ border-radius: 20px;
+ cursor: pointer;
+ font-weight: 500;
+ transition: var(--transition);
+}
+
+.chat-option-btn:hover {
+ background: var(--accent-blue);
+ color: #020617;
+ box-shadow: 0 0 10px rgba(0, 188, 255, 0.4);
+}
+
+.chat-input-bar {
+ padding: 16px 24px;
+ background: rgba(8, 13, 23, 0.9);
+ border-top: 1px solid rgba(0, 162, 255, 0.1);
+ display: flex;
+ gap: 12px;
+}
+
+.chat-input-bar input {
+ flex: 1;
+ background: rgba(255,255,255,0.03);
+ border: 1px solid rgba(0, 162, 255, 0.15);
+ color: var(--text-primary);
+ padding: 12px 18px;
+ border-radius: 8px;
+ font-family: var(--font-sans);
+ font-size: 14px;
+ transition: var(--transition);
+}
+
+.chat-input-bar input:focus {
+ outline: none;
+ border-color: var(--accent-blue);
+ box-shadow: 0 0 8px rgba(0, 188, 255, 0.2);
+}
+
+/* Troubleshooting Library Grid */
+.library-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 20px;
+}
+
+.library-card {
+ background: var(--panel-bg);
+ border: var(--panel-border);
+ border-radius: 12px;
+ padding: 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ transition: var(--transition);
+}
+
+.library-card:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 6px 24px rgba(0, 188, 255, 0.18);
+ border-color: rgba(0, 188, 255, 0.45);
+}
+
+.library-card-icon {
+ width: 36px;
+ height: 36px;
+ border-radius: 8px;
+ background: rgba(0, 188, 255, 0.1);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--accent-blue);
+}
+
+.library-card-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+}
+
+.library-card-desc {
+ font-size: 13.5px;
+ color: var(--text-secondary);
+ line-height: 1.4;
+ flex: 1;
+}
+
+/* Reports & Analytics View */
+.reports-container {
+ height: calc(100vh - var(--header-height) - 70px);
+ background: var(--panel-bg);
+ border: var(--panel-border);
+ border-radius: 12px;
+ overflow: hidden;
+}
+
+.reports-iframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ background: transparent;
+}
+
+/* Settings Form Controls */
+.settings-grid {
+ display: grid;
+ grid-template-columns: 3fr 2fr;
+ gap: 24px;
+}
+
+.settings-group {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.settings-label {
+ font-size: 13px;
+ font-family: var(--font-mono);
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.settings-input, .settings-select {
+ background: rgba(0,0,0,0.25);
+ border: 1px solid rgba(0, 162, 255, 0.2);
+ border-radius: 8px;
+ padding: 12px;
+ color: var(--text-primary);
+ font-family: var(--font-sans);
+ font-size: 14px;
+ transition: var(--transition);
+}
+
+.settings-input:focus, .settings-select:focus {
+ outline: none;
+ border-color: var(--accent-blue);
+ box-shadow: 0 0 8px rgba(0, 188, 255, 0.25);
+}
+
+.settings-help {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+/* Switch UI */
+.switch-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 12px 0;
+ border-bottom: 1px solid rgba(255,255,255,0.05);
+}
+
+.switch {
+ position: relative;
+ display: inline-block;
+ width: 48px;
+ height: 24px;
+}
+
+.switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(255,255,255,0.1);
+ transition: .3s;
+ border-radius: 24px;
+ border: 1px solid rgba(255,255,255,0.05);
+}
+
+.slider:before {
+ position: absolute;
+ content: "";
+ height: 16px;
+ width: 16px;
+ left: 3px;
+ bottom: 3px;
+ background-color: var(--text-secondary);
+ transition: .3s;
+ border-radius: 50%;
+}
+
+input:checked + .slider {
+ background-color: rgba(0, 188, 255, 0.2);
+ border-color: var(--accent-blue);
+}
+
+input:checked + .slider:before {
+ transform: translateX(24px);
+ background-color: var(--accent-blue);
+ box-shadow: 0 0 8px var(--accent-blue);
+}
+
+/* About Panel List */
+.about-list {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.about-row {
+ display: flex;
+ justify-content: space-between;
+ font-size: 14px;
+ border-bottom: 1px solid rgba(255,255,255,0.03);
+ padding-bottom: 8px;
+}
+
+.about-row:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+}
+
+.about-label {
+ color: var(--text-secondary);
+}
+
+.about-value {
+ font-family: var(--font-mono);
+ font-weight: 500;
+}
+
+.placeholder-view {
+ text-align: center;
+ padding: 100px 20px;
+}
+
+.placeholder-view svg {
+ width: 64px;
+ height: 64px;
+ color: var(--accent-blue);
+ margin-bottom: 20px;
+ opacity: 0.7;
+ animation: pulse 2s infinite ease-in-out;
+}
+
+@keyframes pulse {
+ 0% { transform: scale(1); opacity: 0.5; }
+ 50% { transform: scale(1.05); opacity: 0.8; }
+ 100% { transform: scale(1); opacity: 0.5; }
+}
+
+.placeholder-title {
+ font-size: 20px;
+ font-weight: 600;
+ margin-bottom: 10px;
+}
+
+.placeholder-desc {
+ color: var(--text-secondary);
+ max-width: 400px;
+ margin: 0 auto;
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+/* Reports & Operations Center Styles */
+.metric-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 15px;
+ margin-bottom: 25px;
+}
+
+.metric-card {
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 8px;
+ padding: 20px;
+ text-align: center;
+ transition: var(--transition);
+}
+
+.metric-card:hover {
+ border-color: rgba(0, 188, 255, 0.3);
+ background: rgba(0, 188, 255, 0.02);
+}
+
+.metric-value {
+ font-size: 28px;
+ font-weight: 700;
+ font-family: var(--font-mono);
+ margin-top: 5px;
+}
+
+.report-section {
+ margin-top: 25px;
+}
+
+.section-title {
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text-primary);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ padding-bottom: 8px;
+ margin-bottom: 15px;
+}
+
+#report-document-body table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 15px;
+}
+
+#report-document-body th, #report-document-body td {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ padding: 12px;
+ text-align: left;
+}
+
+#report-document-body th {
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--text-secondary);
+ font-weight: 600;
+ font-size: 13px;
+}
+
+#report-document-body td {
+ font-size: 13.5px;
+}
+
+#report-document-body tbody tr:hover td {
+ background: rgba(255, 255, 255, 0.01);
+}
+
+.finding-item {
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--text-primary);
+ margin-bottom: 6px;
+}
diff --git a/integrations/wazuh-troubleshooting-tool/start.sh b/integrations/wazuh-troubleshooting-tool/start.sh
new file mode 100755
index 00000000..69a0d282
--- /dev/null
+++ b/integrations/wazuh-troubleshooting-tool/start.sh
@@ -0,0 +1,53 @@
+#!/bin/bash
+BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Extract server config using python safely
+HOST=$(python3 -c '
+import yaml
+try:
+ with open("'$BASE'/config") as f:
+ config = yaml.safe_load(f)
+ print(config.get("server", {}).get("host", "localhost"))
+except:
+ print("localhost")
+')
+
+B_PORT=$(python3 -c '
+import yaml
+try:
+ with open("'$BASE'/config") as f:
+ config = yaml.safe_load(f)
+ print(config.get("server", {}).get("backend_port", "8000"))
+except:
+ print("8000")
+')
+
+F_PORT=$(python3 -c '
+import yaml
+try:
+ with open("'$BASE'/config") as f:
+ config = yaml.safe_load(f)
+ print(config.get("server", {}).get("frontend_port", "3000"))
+except:
+ print("3000")
+')
+
+echo "Stopping old processes..."
+pkill -f uvicorn || true
+pkill -f http.server || true
+pkill -f "python3 app.py" || true
+sleep 2
+
+echo "Starting backend on $HOST:$B_PORT..."
+cd $BASE/backend || exit
+uvicorn main:app --host $HOST --port $B_PORT --reload &
+
+echo "Starting frontend on $HOST:$F_PORT..."
+cd $BASE/frontend || exit
+python3 -m http.server $F_PORT --bind $HOST &
+
+echo "-----------------------------------"
+echo "UI: http://$HOST:$F_PORT"
+echo "API: http://$HOST:$B_PORT"
+echo "-----------------------------------"
+wait