foundry-mcp 0.8.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of foundry-mcp might be problematic. Click here for more details.
- foundry_mcp/__init__.py +13 -0
- foundry_mcp/cli/__init__.py +67 -0
- foundry_mcp/cli/__main__.py +9 -0
- foundry_mcp/cli/agent.py +96 -0
- foundry_mcp/cli/commands/__init__.py +37 -0
- foundry_mcp/cli/commands/cache.py +137 -0
- foundry_mcp/cli/commands/dashboard.py +148 -0
- foundry_mcp/cli/commands/dev.py +446 -0
- foundry_mcp/cli/commands/journal.py +377 -0
- foundry_mcp/cli/commands/lifecycle.py +274 -0
- foundry_mcp/cli/commands/modify.py +824 -0
- foundry_mcp/cli/commands/plan.py +640 -0
- foundry_mcp/cli/commands/pr.py +393 -0
- foundry_mcp/cli/commands/review.py +667 -0
- foundry_mcp/cli/commands/session.py +472 -0
- foundry_mcp/cli/commands/specs.py +686 -0
- foundry_mcp/cli/commands/tasks.py +807 -0
- foundry_mcp/cli/commands/testing.py +676 -0
- foundry_mcp/cli/commands/validate.py +982 -0
- foundry_mcp/cli/config.py +98 -0
- foundry_mcp/cli/context.py +298 -0
- foundry_mcp/cli/logging.py +212 -0
- foundry_mcp/cli/main.py +44 -0
- foundry_mcp/cli/output.py +122 -0
- foundry_mcp/cli/registry.py +110 -0
- foundry_mcp/cli/resilience.py +178 -0
- foundry_mcp/cli/transcript.py +217 -0
- foundry_mcp/config.py +1454 -0
- foundry_mcp/core/__init__.py +144 -0
- foundry_mcp/core/ai_consultation.py +1773 -0
- foundry_mcp/core/batch_operations.py +1202 -0
- foundry_mcp/core/cache.py +195 -0
- foundry_mcp/core/capabilities.py +446 -0
- foundry_mcp/core/concurrency.py +898 -0
- foundry_mcp/core/context.py +540 -0
- foundry_mcp/core/discovery.py +1603 -0
- foundry_mcp/core/error_collection.py +728 -0
- foundry_mcp/core/error_store.py +592 -0
- foundry_mcp/core/health.py +749 -0
- foundry_mcp/core/intake.py +933 -0
- foundry_mcp/core/journal.py +700 -0
- foundry_mcp/core/lifecycle.py +412 -0
- foundry_mcp/core/llm_config.py +1376 -0
- foundry_mcp/core/llm_patterns.py +510 -0
- foundry_mcp/core/llm_provider.py +1569 -0
- foundry_mcp/core/logging_config.py +374 -0
- foundry_mcp/core/metrics_persistence.py +584 -0
- foundry_mcp/core/metrics_registry.py +327 -0
- foundry_mcp/core/metrics_store.py +641 -0
- foundry_mcp/core/modifications.py +224 -0
- foundry_mcp/core/naming.py +146 -0
- foundry_mcp/core/observability.py +1216 -0
- foundry_mcp/core/otel.py +452 -0
- foundry_mcp/core/otel_stubs.py +264 -0
- foundry_mcp/core/pagination.py +255 -0
- foundry_mcp/core/progress.py +387 -0
- foundry_mcp/core/prometheus.py +564 -0
- foundry_mcp/core/prompts/__init__.py +464 -0
- foundry_mcp/core/prompts/fidelity_review.py +691 -0
- foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
- foundry_mcp/core/prompts/plan_review.py +627 -0
- foundry_mcp/core/providers/__init__.py +237 -0
- foundry_mcp/core/providers/base.py +515 -0
- foundry_mcp/core/providers/claude.py +472 -0
- foundry_mcp/core/providers/codex.py +637 -0
- foundry_mcp/core/providers/cursor_agent.py +630 -0
- foundry_mcp/core/providers/detectors.py +515 -0
- foundry_mcp/core/providers/gemini.py +426 -0
- foundry_mcp/core/providers/opencode.py +718 -0
- foundry_mcp/core/providers/opencode_wrapper.js +308 -0
- foundry_mcp/core/providers/package-lock.json +24 -0
- foundry_mcp/core/providers/package.json +25 -0
- foundry_mcp/core/providers/registry.py +607 -0
- foundry_mcp/core/providers/test_provider.py +171 -0
- foundry_mcp/core/providers/validation.py +857 -0
- foundry_mcp/core/rate_limit.py +427 -0
- foundry_mcp/core/research/__init__.py +68 -0
- foundry_mcp/core/research/memory.py +528 -0
- foundry_mcp/core/research/models.py +1234 -0
- foundry_mcp/core/research/providers/__init__.py +40 -0
- foundry_mcp/core/research/providers/base.py +242 -0
- foundry_mcp/core/research/providers/google.py +507 -0
- foundry_mcp/core/research/providers/perplexity.py +442 -0
- foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
- foundry_mcp/core/research/providers/tavily.py +383 -0
- foundry_mcp/core/research/workflows/__init__.py +25 -0
- foundry_mcp/core/research/workflows/base.py +298 -0
- foundry_mcp/core/research/workflows/chat.py +271 -0
- foundry_mcp/core/research/workflows/consensus.py +539 -0
- foundry_mcp/core/research/workflows/deep_research.py +4142 -0
- foundry_mcp/core/research/workflows/ideate.py +682 -0
- foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
- foundry_mcp/core/resilience.py +600 -0
- foundry_mcp/core/responses.py +1624 -0
- foundry_mcp/core/review.py +366 -0
- foundry_mcp/core/security.py +438 -0
- foundry_mcp/core/spec.py +4119 -0
- foundry_mcp/core/task.py +2463 -0
- foundry_mcp/core/testing.py +839 -0
- foundry_mcp/core/validation.py +2357 -0
- foundry_mcp/dashboard/__init__.py +32 -0
- foundry_mcp/dashboard/app.py +119 -0
- foundry_mcp/dashboard/components/__init__.py +17 -0
- foundry_mcp/dashboard/components/cards.py +88 -0
- foundry_mcp/dashboard/components/charts.py +177 -0
- foundry_mcp/dashboard/components/filters.py +136 -0
- foundry_mcp/dashboard/components/tables.py +195 -0
- foundry_mcp/dashboard/data/__init__.py +11 -0
- foundry_mcp/dashboard/data/stores.py +433 -0
- foundry_mcp/dashboard/launcher.py +300 -0
- foundry_mcp/dashboard/views/__init__.py +12 -0
- foundry_mcp/dashboard/views/errors.py +217 -0
- foundry_mcp/dashboard/views/metrics.py +164 -0
- foundry_mcp/dashboard/views/overview.py +96 -0
- foundry_mcp/dashboard/views/providers.py +83 -0
- foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
- foundry_mcp/dashboard/views/tool_usage.py +139 -0
- foundry_mcp/prompts/__init__.py +9 -0
- foundry_mcp/prompts/workflows.py +525 -0
- foundry_mcp/resources/__init__.py +9 -0
- foundry_mcp/resources/specs.py +591 -0
- foundry_mcp/schemas/__init__.py +38 -0
- foundry_mcp/schemas/intake-schema.json +89 -0
- foundry_mcp/schemas/sdd-spec-schema.json +414 -0
- foundry_mcp/server.py +150 -0
- foundry_mcp/tools/__init__.py +10 -0
- foundry_mcp/tools/unified/__init__.py +92 -0
- foundry_mcp/tools/unified/authoring.py +3620 -0
- foundry_mcp/tools/unified/context_helpers.py +98 -0
- foundry_mcp/tools/unified/documentation_helpers.py +268 -0
- foundry_mcp/tools/unified/environment.py +1341 -0
- foundry_mcp/tools/unified/error.py +479 -0
- foundry_mcp/tools/unified/health.py +225 -0
- foundry_mcp/tools/unified/journal.py +841 -0
- foundry_mcp/tools/unified/lifecycle.py +640 -0
- foundry_mcp/tools/unified/metrics.py +777 -0
- foundry_mcp/tools/unified/plan.py +876 -0
- foundry_mcp/tools/unified/pr.py +294 -0
- foundry_mcp/tools/unified/provider.py +589 -0
- foundry_mcp/tools/unified/research.py +1283 -0
- foundry_mcp/tools/unified/review.py +1042 -0
- foundry_mcp/tools/unified/review_helpers.py +314 -0
- foundry_mcp/tools/unified/router.py +102 -0
- foundry_mcp/tools/unified/server.py +565 -0
- foundry_mcp/tools/unified/spec.py +1283 -0
- foundry_mcp/tools/unified/task.py +3846 -0
- foundry_mcp/tools/unified/test.py +431 -0
- foundry_mcp/tools/unified/verification.py +520 -0
- foundry_mcp-0.8.22.dist-info/METADATA +344 -0
- foundry_mcp-0.8.22.dist-info/RECORD +153 -0
- foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
- foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
- foundry_mcp-0.8.22.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Dashboard launcher with subprocess management.
|
|
2
|
+
|
|
3
|
+
Manages the Streamlit server as a subprocess, allowing the dashboard to be
|
|
4
|
+
started and stopped from the CLI or programmatically.
|
|
5
|
+
|
|
6
|
+
Uses a PID file to track the dashboard process across CLI invocations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import signal
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Global process reference (for in-process use)
|
|
21
|
+
_dashboard_process: Optional[subprocess.Popen] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_pid_file() -> Path:
|
|
25
|
+
"""Get path to the dashboard PID file."""
|
|
26
|
+
pid_dir = Path.home() / ".foundry-mcp"
|
|
27
|
+
pid_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
return pid_dir / "dashboard.pid"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _save_pid(pid: int) -> None:
|
|
32
|
+
"""Save PID to file."""
|
|
33
|
+
_get_pid_file().write_text(str(pid))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_pid() -> Optional[int]:
|
|
37
|
+
"""Load PID from file, return None if not found or invalid."""
|
|
38
|
+
pid_file = _get_pid_file()
|
|
39
|
+
if not pid_file.exists():
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
return int(pid_file.read_text().strip())
|
|
43
|
+
except (ValueError, OSError):
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _clear_pid() -> None:
|
|
48
|
+
"""Remove the PID file."""
|
|
49
|
+
pid_file = _get_pid_file()
|
|
50
|
+
if pid_file.exists():
|
|
51
|
+
pid_file.unlink()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _is_process_running(pid: int) -> bool:
|
|
55
|
+
"""Check if a process with given PID is running."""
|
|
56
|
+
try:
|
|
57
|
+
os.kill(pid, 0) # Signal 0 doesn't kill, just checks existence
|
|
58
|
+
return True
|
|
59
|
+
except (OSError, ProcessLookupError):
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def launch_dashboard(
|
|
64
|
+
host: str = "127.0.0.1",
|
|
65
|
+
port: int = 8501,
|
|
66
|
+
open_browser: bool = True,
|
|
67
|
+
) -> dict:
|
|
68
|
+
"""Launch the Streamlit dashboard server.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
host: Host to bind to (default: localhost only)
|
|
72
|
+
port: Port to run on (default: 8501)
|
|
73
|
+
open_browser: Whether to open browser automatically
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
dict with:
|
|
77
|
+
- success: bool
|
|
78
|
+
- url: Dashboard URL
|
|
79
|
+
- pid: Process ID
|
|
80
|
+
- message: Status message
|
|
81
|
+
"""
|
|
82
|
+
global _dashboard_process
|
|
83
|
+
|
|
84
|
+
# Check if already running
|
|
85
|
+
status = get_dashboard_status()
|
|
86
|
+
if status.get("running"):
|
|
87
|
+
return {
|
|
88
|
+
"success": True,
|
|
89
|
+
"url": f"http://{host}:{port}",
|
|
90
|
+
"pid": status["pid"],
|
|
91
|
+
"message": "Dashboard already running",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Path to the main Streamlit app
|
|
95
|
+
app_path = Path(__file__).parent / "app.py"
|
|
96
|
+
|
|
97
|
+
if not app_path.exists():
|
|
98
|
+
return {
|
|
99
|
+
"success": False,
|
|
100
|
+
"message": f"Dashboard app not found at {app_path}",
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
# Build Streamlit command
|
|
104
|
+
cmd = [
|
|
105
|
+
sys.executable,
|
|
106
|
+
"-m",
|
|
107
|
+
"streamlit",
|
|
108
|
+
"run",
|
|
109
|
+
str(app_path),
|
|
110
|
+
"--server.port",
|
|
111
|
+
str(port),
|
|
112
|
+
"--server.address",
|
|
113
|
+
host,
|
|
114
|
+
"--browser.gatherUsageStats",
|
|
115
|
+
"false",
|
|
116
|
+
"--theme.base",
|
|
117
|
+
"dark",
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
if not open_browser:
|
|
121
|
+
cmd.extend(["--server.headless", "true"])
|
|
122
|
+
|
|
123
|
+
# Environment with dashboard mode flag
|
|
124
|
+
env = {
|
|
125
|
+
**os.environ,
|
|
126
|
+
"FOUNDRY_MCP_DASHBOARD_MODE": "1",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# Pass config file path to dashboard subprocess so it can find error/metrics storage
|
|
130
|
+
# If FOUNDRY_MCP_CONFIG_FILE is already set, it will be inherited from os.environ
|
|
131
|
+
# Otherwise, find and pass the config file path explicitly
|
|
132
|
+
if "FOUNDRY_MCP_CONFIG_FILE" not in env:
|
|
133
|
+
for config_name in ["foundry-mcp.toml", ".foundry-mcp.toml"]:
|
|
134
|
+
config_path = Path(config_name).resolve()
|
|
135
|
+
if config_path.exists():
|
|
136
|
+
env["FOUNDRY_MCP_CONFIG_FILE"] = str(config_path)
|
|
137
|
+
logger.debug("Passing config file to dashboard: %s", config_path)
|
|
138
|
+
break
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
# Start subprocess
|
|
142
|
+
_dashboard_process = subprocess.Popen(
|
|
143
|
+
cmd,
|
|
144
|
+
stdout=subprocess.PIPE,
|
|
145
|
+
stderr=subprocess.PIPE,
|
|
146
|
+
env=env,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Brief wait to check for immediate failures
|
|
150
|
+
time.sleep(1)
|
|
151
|
+
poll = _dashboard_process.poll()
|
|
152
|
+
if poll is not None:
|
|
153
|
+
stderr = _dashboard_process.stderr.read().decode() if _dashboard_process.stderr else ""
|
|
154
|
+
return {
|
|
155
|
+
"success": False,
|
|
156
|
+
"message": f"Dashboard failed to start (exit code {poll}): {stderr}",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
pid = _dashboard_process.pid
|
|
160
|
+
_save_pid(pid)
|
|
161
|
+
logger.info("Dashboard started at http://%s:%s (pid=%s)", host, port, pid)
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
"success": True,
|
|
165
|
+
"url": f"http://{host}:{port}",
|
|
166
|
+
"pid": pid,
|
|
167
|
+
"message": "Dashboard started successfully",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
except FileNotFoundError:
|
|
171
|
+
return {
|
|
172
|
+
"success": False,
|
|
173
|
+
"message": "Streamlit not installed. Install with: pip install foundry-mcp[dashboard]",
|
|
174
|
+
}
|
|
175
|
+
except Exception as e:
|
|
176
|
+
logger.exception("Failed to start dashboard")
|
|
177
|
+
return {
|
|
178
|
+
"success": False,
|
|
179
|
+
"message": f"Failed to start dashboard: {e}",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def stop_dashboard() -> dict:
|
|
184
|
+
"""Stop the running dashboard server.
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
dict with:
|
|
188
|
+
- success: bool
|
|
189
|
+
- message: Status message
|
|
190
|
+
"""
|
|
191
|
+
global _dashboard_process
|
|
192
|
+
|
|
193
|
+
# First check in-memory process reference
|
|
194
|
+
if _dashboard_process is not None:
|
|
195
|
+
try:
|
|
196
|
+
_dashboard_process.terminate()
|
|
197
|
+
_dashboard_process.wait(timeout=5)
|
|
198
|
+
pid = _dashboard_process.pid
|
|
199
|
+
_dashboard_process = None
|
|
200
|
+
_clear_pid()
|
|
201
|
+
|
|
202
|
+
logger.info("Dashboard stopped (pid=%s)", pid)
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
"success": True,
|
|
206
|
+
"message": f"Dashboard stopped (pid={pid})",
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
except subprocess.TimeoutExpired:
|
|
210
|
+
_dashboard_process.kill()
|
|
211
|
+
_dashboard_process = None
|
|
212
|
+
_clear_pid()
|
|
213
|
+
return {
|
|
214
|
+
"success": True,
|
|
215
|
+
"message": "Dashboard killed (did not terminate gracefully)",
|
|
216
|
+
}
|
|
217
|
+
except Exception as e:
|
|
218
|
+
return {
|
|
219
|
+
"success": False,
|
|
220
|
+
"message": f"Failed to stop dashboard: {e}",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# Fall back to PID file (for cross-process stop)
|
|
224
|
+
pid = _load_pid()
|
|
225
|
+
if pid is None:
|
|
226
|
+
return {
|
|
227
|
+
"success": False,
|
|
228
|
+
"message": "No dashboard process to stop",
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if not _is_process_running(pid):
|
|
232
|
+
_clear_pid()
|
|
233
|
+
return {
|
|
234
|
+
"success": False,
|
|
235
|
+
"message": f"Dashboard process (pid={pid}) not running, cleaned up stale PID file",
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
os.kill(pid, signal.SIGTERM)
|
|
240
|
+
|
|
241
|
+
# Wait for process to terminate
|
|
242
|
+
for _ in range(50): # 5 seconds total
|
|
243
|
+
time.sleep(0.1)
|
|
244
|
+
if not _is_process_running(pid):
|
|
245
|
+
break
|
|
246
|
+
else:
|
|
247
|
+
# Force kill if still running
|
|
248
|
+
os.kill(pid, signal.SIGKILL)
|
|
249
|
+
|
|
250
|
+
_clear_pid()
|
|
251
|
+
logger.info("Dashboard stopped (pid=%s)", pid)
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
"success": True,
|
|
255
|
+
"message": f"Dashboard stopped (pid={pid})",
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
except ProcessLookupError:
|
|
259
|
+
_clear_pid()
|
|
260
|
+
return {
|
|
261
|
+
"success": True,
|
|
262
|
+
"message": f"Dashboard process (pid={pid}) already terminated",
|
|
263
|
+
}
|
|
264
|
+
except Exception as e:
|
|
265
|
+
return {
|
|
266
|
+
"success": False,
|
|
267
|
+
"message": f"Failed to stop dashboard: {e}",
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def get_dashboard_status() -> dict:
|
|
272
|
+
"""Check if dashboard is running.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
dict with:
|
|
276
|
+
- running: bool
|
|
277
|
+
- pid: Process ID (if running)
|
|
278
|
+
- exit_code: Exit code (if not running)
|
|
279
|
+
"""
|
|
280
|
+
global _dashboard_process
|
|
281
|
+
|
|
282
|
+
# First check in-memory process reference
|
|
283
|
+
if _dashboard_process is not None:
|
|
284
|
+
poll = _dashboard_process.poll()
|
|
285
|
+
if poll is not None:
|
|
286
|
+
_clear_pid()
|
|
287
|
+
return {"running": False, "exit_code": poll}
|
|
288
|
+
return {"running": True, "pid": _dashboard_process.pid}
|
|
289
|
+
|
|
290
|
+
# Fall back to PID file (for cross-process status)
|
|
291
|
+
pid = _load_pid()
|
|
292
|
+
if pid is None:
|
|
293
|
+
return {"running": False}
|
|
294
|
+
|
|
295
|
+
if _is_process_running(pid):
|
|
296
|
+
return {"running": True, "pid": pid}
|
|
297
|
+
|
|
298
|
+
# Process not running but PID file exists - clean up
|
|
299
|
+
_clear_pid()
|
|
300
|
+
return {"running": False}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Errors page - filterable error list with patterns and details."""
|
|
2
|
+
|
|
3
|
+
import streamlit as st
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from foundry_mcp.dashboard.components.filters import (
|
|
8
|
+
time_range_filter,
|
|
9
|
+
text_filter,
|
|
10
|
+
filter_row,
|
|
11
|
+
)
|
|
12
|
+
from foundry_mcp.dashboard.components.tables import (
|
|
13
|
+
error_table_config,
|
|
14
|
+
paginated_table,
|
|
15
|
+
)
|
|
16
|
+
from foundry_mcp.dashboard.components.charts import treemap_chart, pie_chart
|
|
17
|
+
from foundry_mcp.dashboard.data.stores import (
|
|
18
|
+
get_errors,
|
|
19
|
+
get_error_stats,
|
|
20
|
+
get_error_patterns,
|
|
21
|
+
get_error_by_id,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Try importing pandas
|
|
25
|
+
pd: Any
|
|
26
|
+
try:
|
|
27
|
+
import pandas as pd
|
|
28
|
+
|
|
29
|
+
PANDAS_AVAILABLE = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
PANDAS_AVAILABLE = False
|
|
32
|
+
pd = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def render():
|
|
36
|
+
"""Render the Errors page."""
|
|
37
|
+
st.header("Errors")
|
|
38
|
+
|
|
39
|
+
# Check if error collection is enabled
|
|
40
|
+
stats = get_error_stats()
|
|
41
|
+
if not stats.get("enabled"):
|
|
42
|
+
st.warning(
|
|
43
|
+
"Error collection is disabled. Enable it in foundry-mcp.toml under [error_collection]"
|
|
44
|
+
)
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
# Filters
|
|
48
|
+
st.subheader("Filters")
|
|
49
|
+
cols = filter_row(4)
|
|
50
|
+
|
|
51
|
+
with cols[0]:
|
|
52
|
+
hours = time_range_filter(key="error_time_range", default="24h")
|
|
53
|
+
with cols[1]:
|
|
54
|
+
tool_filter = text_filter(
|
|
55
|
+
"Tool Name", key="error_tool", placeholder="e.g., spec"
|
|
56
|
+
)
|
|
57
|
+
with cols[2]:
|
|
58
|
+
code_filter = text_filter(
|
|
59
|
+
"Error Code", key="error_code", placeholder="e.g., VALIDATION_ERROR"
|
|
60
|
+
)
|
|
61
|
+
with cols[3]:
|
|
62
|
+
# Show stats - use total_errors from stats (single source of truth)
|
|
63
|
+
st.metric("Total Errors", stats.get("total_errors", 0))
|
|
64
|
+
|
|
65
|
+
st.divider()
|
|
66
|
+
|
|
67
|
+
# Get filtered errors
|
|
68
|
+
errors_df = get_errors(
|
|
69
|
+
tool_name=tool_filter if tool_filter else None,
|
|
70
|
+
error_code=code_filter if code_filter else None,
|
|
71
|
+
since_hours=hours,
|
|
72
|
+
limit=500,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Main content tabs
|
|
76
|
+
tab1, tab2, tab3 = st.tabs(["Error List", "Patterns", "Analysis"])
|
|
77
|
+
|
|
78
|
+
with tab1:
|
|
79
|
+
st.subheader("Error List")
|
|
80
|
+
if errors_df is not None and not errors_df.empty:
|
|
81
|
+
# Show table with selection
|
|
82
|
+
st.caption(f"Showing {len(errors_df)} errors")
|
|
83
|
+
|
|
84
|
+
# Paginated table
|
|
85
|
+
paginated_table(
|
|
86
|
+
errors_df,
|
|
87
|
+
page_size=25,
|
|
88
|
+
key="errors_page",
|
|
89
|
+
columns=error_table_config(),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Error detail expander
|
|
93
|
+
st.subheader("Error Details")
|
|
94
|
+
selected_id = st.text_input(
|
|
95
|
+
"Enter Error ID to view details",
|
|
96
|
+
key="error_detail_id",
|
|
97
|
+
placeholder="Click an error ID above and paste here",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if selected_id:
|
|
101
|
+
error = get_error_by_id(selected_id)
|
|
102
|
+
if error:
|
|
103
|
+
with st.expander(f"Error: {selected_id}", expanded=True):
|
|
104
|
+
col1, col2 = st.columns(2)
|
|
105
|
+
with col1:
|
|
106
|
+
st.markdown("**Tool:** " + error.get("tool_name", "N/A"))
|
|
107
|
+
st.markdown("**Code:** " + error.get("error_code", "N/A"))
|
|
108
|
+
st.markdown("**Type:** " + error.get("error_type", "N/A"))
|
|
109
|
+
st.markdown(
|
|
110
|
+
"**Time:** " + str(error.get("timestamp", "N/A"))
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
with col2:
|
|
114
|
+
st.markdown(
|
|
115
|
+
"**Fingerprint:** "
|
|
116
|
+
+ error.get("fingerprint", "N/A")[:20]
|
|
117
|
+
+ "..."
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
st.markdown("**Message:**")
|
|
121
|
+
st.text(error.get("message", "No message"))
|
|
122
|
+
|
|
123
|
+
if error.get("stack_trace"):
|
|
124
|
+
st.markdown("**Stack Trace:**")
|
|
125
|
+
st.code(error["stack_trace"], language="python")
|
|
126
|
+
|
|
127
|
+
if error.get("context"):
|
|
128
|
+
st.markdown("**Context:**")
|
|
129
|
+
st.json(error["context"])
|
|
130
|
+
else:
|
|
131
|
+
st.warning(f"Error {selected_id} not found")
|
|
132
|
+
else:
|
|
133
|
+
st.info("No errors found matching the filters")
|
|
134
|
+
|
|
135
|
+
with tab2:
|
|
136
|
+
st.subheader("Error Patterns")
|
|
137
|
+
patterns = get_error_patterns(min_count=2)
|
|
138
|
+
|
|
139
|
+
if patterns and PANDAS_AVAILABLE and pd is not None:
|
|
140
|
+
# Create DataFrame for visualization
|
|
141
|
+
patterns_df = pd.DataFrame(patterns)
|
|
142
|
+
|
|
143
|
+
# Treemap of patterns
|
|
144
|
+
if "tool_name" in patterns_df.columns and "count" in patterns_df.columns:
|
|
145
|
+
treemap_chart(
|
|
146
|
+
patterns_df,
|
|
147
|
+
path=["tool_name", "error_code"]
|
|
148
|
+
if "error_code" in patterns_df.columns
|
|
149
|
+
else ["tool_name"],
|
|
150
|
+
values="count",
|
|
151
|
+
title="Error Distribution by Tool",
|
|
152
|
+
height=400,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Pattern details table
|
|
156
|
+
st.subheader("Pattern Details")
|
|
157
|
+
st.dataframe(
|
|
158
|
+
patterns_df,
|
|
159
|
+
use_container_width=True,
|
|
160
|
+
hide_index=True,
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
st.info("No recurring patterns detected (minimum 2 occurrences required)")
|
|
164
|
+
|
|
165
|
+
with tab3:
|
|
166
|
+
st.subheader("Error Analysis")
|
|
167
|
+
|
|
168
|
+
if errors_df is not None and not errors_df.empty and PANDAS_AVAILABLE:
|
|
169
|
+
col1, col2 = st.columns(2)
|
|
170
|
+
|
|
171
|
+
with col1:
|
|
172
|
+
# Errors by tool
|
|
173
|
+
if "tool_name" in errors_df.columns:
|
|
174
|
+
tool_counts = errors_df["tool_name"].value_counts().reset_index()
|
|
175
|
+
tool_counts.columns = ["tool_name", "count"]
|
|
176
|
+
pie_chart(
|
|
177
|
+
tool_counts,
|
|
178
|
+
values="count",
|
|
179
|
+
names="tool_name",
|
|
180
|
+
title="Errors by Tool",
|
|
181
|
+
height=300,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
with col2:
|
|
185
|
+
# Errors by code
|
|
186
|
+
if "error_code" in errors_df.columns:
|
|
187
|
+
code_counts = errors_df["error_code"].value_counts().reset_index()
|
|
188
|
+
code_counts.columns = ["error_code", "count"]
|
|
189
|
+
pie_chart(
|
|
190
|
+
code_counts,
|
|
191
|
+
values="count",
|
|
192
|
+
names="error_code",
|
|
193
|
+
title="Errors by Code",
|
|
194
|
+
height=300,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Export button
|
|
198
|
+
st.divider()
|
|
199
|
+
col1, col2 = st.columns(2)
|
|
200
|
+
with col1:
|
|
201
|
+
csv = errors_df.to_csv(index=False)
|
|
202
|
+
st.download_button(
|
|
203
|
+
label="Download CSV",
|
|
204
|
+
data=csv,
|
|
205
|
+
file_name="errors_export.csv",
|
|
206
|
+
mime="text/csv",
|
|
207
|
+
)
|
|
208
|
+
with col2:
|
|
209
|
+
json_data = errors_df.to_json(orient="records")
|
|
210
|
+
st.download_button(
|
|
211
|
+
label="Download JSON",
|
|
212
|
+
data=json_data,
|
|
213
|
+
file_name="errors_export.json",
|
|
214
|
+
mime="application/json",
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
st.info("No error data available for analysis")
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Metrics page - viewer with summaries."""
|
|
2
|
+
|
|
3
|
+
import streamlit as st
|
|
4
|
+
|
|
5
|
+
from foundry_mcp.dashboard.components.filters import time_range_filter
|
|
6
|
+
from foundry_mcp.dashboard.components.cards import kpi_row
|
|
7
|
+
from foundry_mcp.dashboard.data.stores import (
|
|
8
|
+
get_metrics_list,
|
|
9
|
+
get_metrics_timeseries,
|
|
10
|
+
get_metrics_summary,
|
|
11
|
+
get_top_tool_actions,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Try importing pandas
|
|
15
|
+
try:
|
|
16
|
+
import pandas as pd
|
|
17
|
+
PANDAS_AVAILABLE = True
|
|
18
|
+
except ImportError:
|
|
19
|
+
PANDAS_AVAILABLE = False
|
|
20
|
+
pd = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render():
|
|
24
|
+
"""Render the Metrics page."""
|
|
25
|
+
st.header("Metrics")
|
|
26
|
+
|
|
27
|
+
# Get available metrics
|
|
28
|
+
metrics_list = get_metrics_list()
|
|
29
|
+
|
|
30
|
+
if not metrics_list:
|
|
31
|
+
st.warning("No metrics available. Metrics persistence may be disabled.")
|
|
32
|
+
st.info("Enable metrics persistence in foundry-mcp.toml under [metrics_persistence]")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
# Metric selector and time range
|
|
36
|
+
col1, col2 = st.columns([2, 1])
|
|
37
|
+
|
|
38
|
+
with col1:
|
|
39
|
+
metric_names = [m.get("metric_name", "unknown") for m in metrics_list]
|
|
40
|
+
selected_metric = st.selectbox(
|
|
41
|
+
"Select Metric",
|
|
42
|
+
options=metric_names,
|
|
43
|
+
key="metrics_selector",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
with col2:
|
|
47
|
+
hours = time_range_filter(key="metrics_time_range", default="24h")
|
|
48
|
+
|
|
49
|
+
st.divider()
|
|
50
|
+
|
|
51
|
+
if selected_metric:
|
|
52
|
+
# Get summary statistics
|
|
53
|
+
summary = get_metrics_summary(selected_metric, since_hours=hours)
|
|
54
|
+
|
|
55
|
+
# Summary cards
|
|
56
|
+
st.subheader("Summary Statistics")
|
|
57
|
+
if summary.get("enabled"):
|
|
58
|
+
# Handle None values (returned when no data exists)
|
|
59
|
+
min_val = summary.get("min") if summary.get("min") is not None else 0
|
|
60
|
+
max_val = summary.get("max") if summary.get("max") is not None else 0
|
|
61
|
+
avg_val = summary.get("avg") if summary.get("avg") is not None else 0
|
|
62
|
+
sum_val = summary.get("sum") if summary.get("sum") is not None else 0
|
|
63
|
+
|
|
64
|
+
kpi_row(
|
|
65
|
+
[
|
|
66
|
+
{"label": "Count", "value": summary.get("count", 0)},
|
|
67
|
+
{"label": "Min", "value": f"{min_val:.2f}"},
|
|
68
|
+
{"label": "Max", "value": f"{max_val:.2f}"},
|
|
69
|
+
{"label": "Average", "value": f"{avg_val:.2f}"},
|
|
70
|
+
{"label": "Sum", "value": f"{sum_val:.2f}"},
|
|
71
|
+
],
|
|
72
|
+
columns=5,
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
st.info("Summary not available")
|
|
76
|
+
|
|
77
|
+
st.divider()
|
|
78
|
+
|
|
79
|
+
# Data table
|
|
80
|
+
st.subheader(f"Data: {selected_metric}")
|
|
81
|
+
timeseries_df = get_metrics_timeseries(selected_metric, since_hours=hours)
|
|
82
|
+
|
|
83
|
+
# Check if we have data, if not try longer time ranges
|
|
84
|
+
display_df = timeseries_df
|
|
85
|
+
time_range_note = None
|
|
86
|
+
|
|
87
|
+
if display_df is None or display_df.empty:
|
|
88
|
+
# Try progressively longer time ranges
|
|
89
|
+
for fallback_hours, label in [(168, "7 days"), (720, "30 days"), (8760, "1 year")]:
|
|
90
|
+
if fallback_hours > hours:
|
|
91
|
+
display_df = get_metrics_timeseries(selected_metric, since_hours=fallback_hours)
|
|
92
|
+
if display_df is not None and not display_df.empty:
|
|
93
|
+
time_range_note = f"No data in selected range - showing last {label}"
|
|
94
|
+
break
|
|
95
|
+
|
|
96
|
+
if display_df is not None and not display_df.empty:
|
|
97
|
+
if time_range_note:
|
|
98
|
+
st.caption(time_range_note)
|
|
99
|
+
|
|
100
|
+
st.dataframe(
|
|
101
|
+
display_df,
|
|
102
|
+
use_container_width=True,
|
|
103
|
+
hide_index=True,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Export
|
|
107
|
+
col1, col2 = st.columns(2)
|
|
108
|
+
with col1:
|
|
109
|
+
csv = display_df.to_csv(index=False)
|
|
110
|
+
st.download_button(
|
|
111
|
+
label="Download CSV",
|
|
112
|
+
data=csv,
|
|
113
|
+
file_name=f"{selected_metric}_export.csv",
|
|
114
|
+
mime="text/csv",
|
|
115
|
+
)
|
|
116
|
+
with col2:
|
|
117
|
+
json_data = display_df.to_json(orient="records")
|
|
118
|
+
st.download_button(
|
|
119
|
+
label="Download JSON",
|
|
120
|
+
data=json_data,
|
|
121
|
+
file_name=f"{selected_metric}_export.json",
|
|
122
|
+
mime="application/json",
|
|
123
|
+
)
|
|
124
|
+
else:
|
|
125
|
+
st.info(f"No data available for {selected_metric}")
|
|
126
|
+
|
|
127
|
+
# Tool Action Breakdown
|
|
128
|
+
st.divider()
|
|
129
|
+
st.subheader("Top Tool Actions")
|
|
130
|
+
top_actions = get_top_tool_actions(since_hours=hours, top_n=10)
|
|
131
|
+
|
|
132
|
+
if top_actions:
|
|
133
|
+
col1, col2 = st.columns(2)
|
|
134
|
+
for i, item in enumerate(top_actions):
|
|
135
|
+
tool = item.get("tool", "unknown")
|
|
136
|
+
action = item.get("action", "")
|
|
137
|
+
count = int(item.get("count", 0))
|
|
138
|
+
display_name = f"{tool}.{action}" if action and action != "(no action)" else tool
|
|
139
|
+
target_col = col1 if i < 5 else col2
|
|
140
|
+
with target_col:
|
|
141
|
+
with st.container(border=True):
|
|
142
|
+
st.markdown(f"**{display_name}**")
|
|
143
|
+
st.caption(f"Invocations: {count:,}")
|
|
144
|
+
else:
|
|
145
|
+
st.info("No tool action data available for selected time range")
|
|
146
|
+
|
|
147
|
+
# Metrics catalog
|
|
148
|
+
st.divider()
|
|
149
|
+
st.subheader("Available Metrics")
|
|
150
|
+
|
|
151
|
+
if PANDAS_AVAILABLE:
|
|
152
|
+
metrics_df = pd.DataFrame(metrics_list)
|
|
153
|
+
st.dataframe(
|
|
154
|
+
metrics_df,
|
|
155
|
+
use_container_width=True,
|
|
156
|
+
hide_index=True,
|
|
157
|
+
column_config={
|
|
158
|
+
"metric_name": st.column_config.TextColumn("Metric", width="medium"),
|
|
159
|
+
"count": st.column_config.NumberColumn("Records", width="small"),
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
for m in metrics_list:
|
|
164
|
+
st.text(f"- {m.get('metric_name', 'unknown')} ({m.get('count', 0)} records)")
|