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.

Files changed (153) hide show
  1. foundry_mcp/__init__.py +13 -0
  2. foundry_mcp/cli/__init__.py +67 -0
  3. foundry_mcp/cli/__main__.py +9 -0
  4. foundry_mcp/cli/agent.py +96 -0
  5. foundry_mcp/cli/commands/__init__.py +37 -0
  6. foundry_mcp/cli/commands/cache.py +137 -0
  7. foundry_mcp/cli/commands/dashboard.py +148 -0
  8. foundry_mcp/cli/commands/dev.py +446 -0
  9. foundry_mcp/cli/commands/journal.py +377 -0
  10. foundry_mcp/cli/commands/lifecycle.py +274 -0
  11. foundry_mcp/cli/commands/modify.py +824 -0
  12. foundry_mcp/cli/commands/plan.py +640 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +667 -0
  15. foundry_mcp/cli/commands/session.py +472 -0
  16. foundry_mcp/cli/commands/specs.py +686 -0
  17. foundry_mcp/cli/commands/tasks.py +807 -0
  18. foundry_mcp/cli/commands/testing.py +676 -0
  19. foundry_mcp/cli/commands/validate.py +982 -0
  20. foundry_mcp/cli/config.py +98 -0
  21. foundry_mcp/cli/context.py +298 -0
  22. foundry_mcp/cli/logging.py +212 -0
  23. foundry_mcp/cli/main.py +44 -0
  24. foundry_mcp/cli/output.py +122 -0
  25. foundry_mcp/cli/registry.py +110 -0
  26. foundry_mcp/cli/resilience.py +178 -0
  27. foundry_mcp/cli/transcript.py +217 -0
  28. foundry_mcp/config.py +1454 -0
  29. foundry_mcp/core/__init__.py +144 -0
  30. foundry_mcp/core/ai_consultation.py +1773 -0
  31. foundry_mcp/core/batch_operations.py +1202 -0
  32. foundry_mcp/core/cache.py +195 -0
  33. foundry_mcp/core/capabilities.py +446 -0
  34. foundry_mcp/core/concurrency.py +898 -0
  35. foundry_mcp/core/context.py +540 -0
  36. foundry_mcp/core/discovery.py +1603 -0
  37. foundry_mcp/core/error_collection.py +728 -0
  38. foundry_mcp/core/error_store.py +592 -0
  39. foundry_mcp/core/health.py +749 -0
  40. foundry_mcp/core/intake.py +933 -0
  41. foundry_mcp/core/journal.py +700 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1376 -0
  44. foundry_mcp/core/llm_patterns.py +510 -0
  45. foundry_mcp/core/llm_provider.py +1569 -0
  46. foundry_mcp/core/logging_config.py +374 -0
  47. foundry_mcp/core/metrics_persistence.py +584 -0
  48. foundry_mcp/core/metrics_registry.py +327 -0
  49. foundry_mcp/core/metrics_store.py +641 -0
  50. foundry_mcp/core/modifications.py +224 -0
  51. foundry_mcp/core/naming.py +146 -0
  52. foundry_mcp/core/observability.py +1216 -0
  53. foundry_mcp/core/otel.py +452 -0
  54. foundry_mcp/core/otel_stubs.py +264 -0
  55. foundry_mcp/core/pagination.py +255 -0
  56. foundry_mcp/core/progress.py +387 -0
  57. foundry_mcp/core/prometheus.py +564 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +691 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +515 -0
  61. foundry_mcp/core/prompts/plan_review.py +627 -0
  62. foundry_mcp/core/providers/__init__.py +237 -0
  63. foundry_mcp/core/providers/base.py +515 -0
  64. foundry_mcp/core/providers/claude.py +472 -0
  65. foundry_mcp/core/providers/codex.py +637 -0
  66. foundry_mcp/core/providers/cursor_agent.py +630 -0
  67. foundry_mcp/core/providers/detectors.py +515 -0
  68. foundry_mcp/core/providers/gemini.py +426 -0
  69. foundry_mcp/core/providers/opencode.py +718 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +308 -0
  71. foundry_mcp/core/providers/package-lock.json +24 -0
  72. foundry_mcp/core/providers/package.json +25 -0
  73. foundry_mcp/core/providers/registry.py +607 -0
  74. foundry_mcp/core/providers/test_provider.py +171 -0
  75. foundry_mcp/core/providers/validation.py +857 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/research/__init__.py +68 -0
  78. foundry_mcp/core/research/memory.py +528 -0
  79. foundry_mcp/core/research/models.py +1234 -0
  80. foundry_mcp/core/research/providers/__init__.py +40 -0
  81. foundry_mcp/core/research/providers/base.py +242 -0
  82. foundry_mcp/core/research/providers/google.py +507 -0
  83. foundry_mcp/core/research/providers/perplexity.py +442 -0
  84. foundry_mcp/core/research/providers/semantic_scholar.py +544 -0
  85. foundry_mcp/core/research/providers/tavily.py +383 -0
  86. foundry_mcp/core/research/workflows/__init__.py +25 -0
  87. foundry_mcp/core/research/workflows/base.py +298 -0
  88. foundry_mcp/core/research/workflows/chat.py +271 -0
  89. foundry_mcp/core/research/workflows/consensus.py +539 -0
  90. foundry_mcp/core/research/workflows/deep_research.py +4142 -0
  91. foundry_mcp/core/research/workflows/ideate.py +682 -0
  92. foundry_mcp/core/research/workflows/thinkdeep.py +405 -0
  93. foundry_mcp/core/resilience.py +600 -0
  94. foundry_mcp/core/responses.py +1624 -0
  95. foundry_mcp/core/review.py +366 -0
  96. foundry_mcp/core/security.py +438 -0
  97. foundry_mcp/core/spec.py +4119 -0
  98. foundry_mcp/core/task.py +2463 -0
  99. foundry_mcp/core/testing.py +839 -0
  100. foundry_mcp/core/validation.py +2357 -0
  101. foundry_mcp/dashboard/__init__.py +32 -0
  102. foundry_mcp/dashboard/app.py +119 -0
  103. foundry_mcp/dashboard/components/__init__.py +17 -0
  104. foundry_mcp/dashboard/components/cards.py +88 -0
  105. foundry_mcp/dashboard/components/charts.py +177 -0
  106. foundry_mcp/dashboard/components/filters.py +136 -0
  107. foundry_mcp/dashboard/components/tables.py +195 -0
  108. foundry_mcp/dashboard/data/__init__.py +11 -0
  109. foundry_mcp/dashboard/data/stores.py +433 -0
  110. foundry_mcp/dashboard/launcher.py +300 -0
  111. foundry_mcp/dashboard/views/__init__.py +12 -0
  112. foundry_mcp/dashboard/views/errors.py +217 -0
  113. foundry_mcp/dashboard/views/metrics.py +164 -0
  114. foundry_mcp/dashboard/views/overview.py +96 -0
  115. foundry_mcp/dashboard/views/providers.py +83 -0
  116. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  117. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  118. foundry_mcp/prompts/__init__.py +9 -0
  119. foundry_mcp/prompts/workflows.py +525 -0
  120. foundry_mcp/resources/__init__.py +9 -0
  121. foundry_mcp/resources/specs.py +591 -0
  122. foundry_mcp/schemas/__init__.py +38 -0
  123. foundry_mcp/schemas/intake-schema.json +89 -0
  124. foundry_mcp/schemas/sdd-spec-schema.json +414 -0
  125. foundry_mcp/server.py +150 -0
  126. foundry_mcp/tools/__init__.py +10 -0
  127. foundry_mcp/tools/unified/__init__.py +92 -0
  128. foundry_mcp/tools/unified/authoring.py +3620 -0
  129. foundry_mcp/tools/unified/context_helpers.py +98 -0
  130. foundry_mcp/tools/unified/documentation_helpers.py +268 -0
  131. foundry_mcp/tools/unified/environment.py +1341 -0
  132. foundry_mcp/tools/unified/error.py +479 -0
  133. foundry_mcp/tools/unified/health.py +225 -0
  134. foundry_mcp/tools/unified/journal.py +841 -0
  135. foundry_mcp/tools/unified/lifecycle.py +640 -0
  136. foundry_mcp/tools/unified/metrics.py +777 -0
  137. foundry_mcp/tools/unified/plan.py +876 -0
  138. foundry_mcp/tools/unified/pr.py +294 -0
  139. foundry_mcp/tools/unified/provider.py +589 -0
  140. foundry_mcp/tools/unified/research.py +1283 -0
  141. foundry_mcp/tools/unified/review.py +1042 -0
  142. foundry_mcp/tools/unified/review_helpers.py +314 -0
  143. foundry_mcp/tools/unified/router.py +102 -0
  144. foundry_mcp/tools/unified/server.py +565 -0
  145. foundry_mcp/tools/unified/spec.py +1283 -0
  146. foundry_mcp/tools/unified/task.py +3846 -0
  147. foundry_mcp/tools/unified/test.py +431 -0
  148. foundry_mcp/tools/unified/verification.py +520 -0
  149. foundry_mcp-0.8.22.dist-info/METADATA +344 -0
  150. foundry_mcp-0.8.22.dist-info/RECORD +153 -0
  151. foundry_mcp-0.8.22.dist-info/WHEEL +4 -0
  152. foundry_mcp-0.8.22.dist-info/entry_points.txt +3 -0
  153. 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,12 @@
1
+ """Dashboard view modules.
2
+
3
+ Each view module provides a `render()` function that renders the page content.
4
+ """
5
+
6
+ __all__ = [
7
+ "overview",
8
+ "errors",
9
+ "metrics",
10
+ "providers",
11
+ "sdd_workflow",
12
+ ]
@@ -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)")