foundry-mcp 0.3.3__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.
Files changed (135) hide show
  1. foundry_mcp/__init__.py +7 -0
  2. foundry_mcp/cli/__init__.py +80 -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 +633 -0
  13. foundry_mcp/cli/commands/pr.py +393 -0
  14. foundry_mcp/cli/commands/review.py +652 -0
  15. foundry_mcp/cli/commands/session.py +479 -0
  16. foundry_mcp/cli/commands/specs.py +856 -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 +259 -0
  22. foundry_mcp/cli/flags.py +266 -0
  23. foundry_mcp/cli/logging.py +212 -0
  24. foundry_mcp/cli/main.py +44 -0
  25. foundry_mcp/cli/output.py +122 -0
  26. foundry_mcp/cli/registry.py +110 -0
  27. foundry_mcp/cli/resilience.py +178 -0
  28. foundry_mcp/cli/transcript.py +217 -0
  29. foundry_mcp/config.py +850 -0
  30. foundry_mcp/core/__init__.py +144 -0
  31. foundry_mcp/core/ai_consultation.py +1636 -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/feature_flags.py +592 -0
  40. foundry_mcp/core/health.py +749 -0
  41. foundry_mcp/core/journal.py +694 -0
  42. foundry_mcp/core/lifecycle.py +412 -0
  43. foundry_mcp/core/llm_config.py +1350 -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 +123 -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 +317 -0
  57. foundry_mcp/core/prometheus.py +577 -0
  58. foundry_mcp/core/prompts/__init__.py +464 -0
  59. foundry_mcp/core/prompts/fidelity_review.py +546 -0
  60. foundry_mcp/core/prompts/markdown_plan_review.py +511 -0
  61. foundry_mcp/core/prompts/plan_review.py +623 -0
  62. foundry_mcp/core/providers/__init__.py +225 -0
  63. foundry_mcp/core/providers/base.py +476 -0
  64. foundry_mcp/core/providers/claude.py +460 -0
  65. foundry_mcp/core/providers/codex.py +619 -0
  66. foundry_mcp/core/providers/cursor_agent.py +642 -0
  67. foundry_mcp/core/providers/detectors.py +488 -0
  68. foundry_mcp/core/providers/gemini.py +405 -0
  69. foundry_mcp/core/providers/opencode.py +616 -0
  70. foundry_mcp/core/providers/opencode_wrapper.js +302 -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 +729 -0
  76. foundry_mcp/core/rate_limit.py +427 -0
  77. foundry_mcp/core/resilience.py +600 -0
  78. foundry_mcp/core/responses.py +934 -0
  79. foundry_mcp/core/review.py +366 -0
  80. foundry_mcp/core/security.py +438 -0
  81. foundry_mcp/core/spec.py +1650 -0
  82. foundry_mcp/core/task.py +1289 -0
  83. foundry_mcp/core/testing.py +450 -0
  84. foundry_mcp/core/validation.py +2081 -0
  85. foundry_mcp/dashboard/__init__.py +32 -0
  86. foundry_mcp/dashboard/app.py +119 -0
  87. foundry_mcp/dashboard/components/__init__.py +17 -0
  88. foundry_mcp/dashboard/components/cards.py +88 -0
  89. foundry_mcp/dashboard/components/charts.py +234 -0
  90. foundry_mcp/dashboard/components/filters.py +136 -0
  91. foundry_mcp/dashboard/components/tables.py +195 -0
  92. foundry_mcp/dashboard/data/__init__.py +11 -0
  93. foundry_mcp/dashboard/data/stores.py +433 -0
  94. foundry_mcp/dashboard/launcher.py +289 -0
  95. foundry_mcp/dashboard/views/__init__.py +12 -0
  96. foundry_mcp/dashboard/views/errors.py +217 -0
  97. foundry_mcp/dashboard/views/metrics.py +174 -0
  98. foundry_mcp/dashboard/views/overview.py +160 -0
  99. foundry_mcp/dashboard/views/providers.py +83 -0
  100. foundry_mcp/dashboard/views/sdd_workflow.py +255 -0
  101. foundry_mcp/dashboard/views/tool_usage.py +139 -0
  102. foundry_mcp/prompts/__init__.py +9 -0
  103. foundry_mcp/prompts/workflows.py +525 -0
  104. foundry_mcp/resources/__init__.py +9 -0
  105. foundry_mcp/resources/specs.py +591 -0
  106. foundry_mcp/schemas/__init__.py +38 -0
  107. foundry_mcp/schemas/sdd-spec-schema.json +386 -0
  108. foundry_mcp/server.py +164 -0
  109. foundry_mcp/tools/__init__.py +10 -0
  110. foundry_mcp/tools/unified/__init__.py +71 -0
  111. foundry_mcp/tools/unified/authoring.py +1487 -0
  112. foundry_mcp/tools/unified/context_helpers.py +98 -0
  113. foundry_mcp/tools/unified/documentation_helpers.py +198 -0
  114. foundry_mcp/tools/unified/environment.py +939 -0
  115. foundry_mcp/tools/unified/error.py +462 -0
  116. foundry_mcp/tools/unified/health.py +225 -0
  117. foundry_mcp/tools/unified/journal.py +841 -0
  118. foundry_mcp/tools/unified/lifecycle.py +632 -0
  119. foundry_mcp/tools/unified/metrics.py +777 -0
  120. foundry_mcp/tools/unified/plan.py +745 -0
  121. foundry_mcp/tools/unified/pr.py +294 -0
  122. foundry_mcp/tools/unified/provider.py +629 -0
  123. foundry_mcp/tools/unified/review.py +685 -0
  124. foundry_mcp/tools/unified/review_helpers.py +299 -0
  125. foundry_mcp/tools/unified/router.py +102 -0
  126. foundry_mcp/tools/unified/server.py +580 -0
  127. foundry_mcp/tools/unified/spec.py +808 -0
  128. foundry_mcp/tools/unified/task.py +2202 -0
  129. foundry_mcp/tools/unified/test.py +370 -0
  130. foundry_mcp/tools/unified/verification.py +520 -0
  131. foundry_mcp-0.3.3.dist-info/METADATA +337 -0
  132. foundry_mcp-0.3.3.dist-info/RECORD +135 -0
  133. foundry_mcp-0.3.3.dist-info/WHEEL +4 -0
  134. foundry_mcp-0.3.3.dist-info/entry_points.txt +3 -0
  135. foundry_mcp-0.3.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,289 @@
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
+ try:
130
+ # Start subprocess
131
+ _dashboard_process = subprocess.Popen(
132
+ cmd,
133
+ stdout=subprocess.PIPE,
134
+ stderr=subprocess.PIPE,
135
+ env=env,
136
+ )
137
+
138
+ # Brief wait to check for immediate failures
139
+ time.sleep(1)
140
+ poll = _dashboard_process.poll()
141
+ if poll is not None:
142
+ stderr = _dashboard_process.stderr.read().decode() if _dashboard_process.stderr else ""
143
+ return {
144
+ "success": False,
145
+ "message": f"Dashboard failed to start (exit code {poll}): {stderr}",
146
+ }
147
+
148
+ pid = _dashboard_process.pid
149
+ _save_pid(pid)
150
+ logger.info("Dashboard started at http://%s:%s (pid=%s)", host, port, pid)
151
+
152
+ return {
153
+ "success": True,
154
+ "url": f"http://{host}:{port}",
155
+ "pid": pid,
156
+ "message": "Dashboard started successfully",
157
+ }
158
+
159
+ except FileNotFoundError:
160
+ return {
161
+ "success": False,
162
+ "message": "Streamlit not installed. Install with: pip install foundry-mcp[dashboard]",
163
+ }
164
+ except Exception as e:
165
+ logger.exception("Failed to start dashboard")
166
+ return {
167
+ "success": False,
168
+ "message": f"Failed to start dashboard: {e}",
169
+ }
170
+
171
+
172
+ def stop_dashboard() -> dict:
173
+ """Stop the running dashboard server.
174
+
175
+ Returns:
176
+ dict with:
177
+ - success: bool
178
+ - message: Status message
179
+ """
180
+ global _dashboard_process
181
+
182
+ # First check in-memory process reference
183
+ if _dashboard_process is not None:
184
+ try:
185
+ _dashboard_process.terminate()
186
+ _dashboard_process.wait(timeout=5)
187
+ pid = _dashboard_process.pid
188
+ _dashboard_process = None
189
+ _clear_pid()
190
+
191
+ logger.info("Dashboard stopped (pid=%s)", pid)
192
+
193
+ return {
194
+ "success": True,
195
+ "message": f"Dashboard stopped (pid={pid})",
196
+ }
197
+
198
+ except subprocess.TimeoutExpired:
199
+ _dashboard_process.kill()
200
+ _dashboard_process = None
201
+ _clear_pid()
202
+ return {
203
+ "success": True,
204
+ "message": "Dashboard killed (did not terminate gracefully)",
205
+ }
206
+ except Exception as e:
207
+ return {
208
+ "success": False,
209
+ "message": f"Failed to stop dashboard: {e}",
210
+ }
211
+
212
+ # Fall back to PID file (for cross-process stop)
213
+ pid = _load_pid()
214
+ if pid is None:
215
+ return {
216
+ "success": False,
217
+ "message": "No dashboard process to stop",
218
+ }
219
+
220
+ if not _is_process_running(pid):
221
+ _clear_pid()
222
+ return {
223
+ "success": False,
224
+ "message": f"Dashboard process (pid={pid}) not running, cleaned up stale PID file",
225
+ }
226
+
227
+ try:
228
+ os.kill(pid, signal.SIGTERM)
229
+
230
+ # Wait for process to terminate
231
+ for _ in range(50): # 5 seconds total
232
+ time.sleep(0.1)
233
+ if not _is_process_running(pid):
234
+ break
235
+ else:
236
+ # Force kill if still running
237
+ os.kill(pid, signal.SIGKILL)
238
+
239
+ _clear_pid()
240
+ logger.info("Dashboard stopped (pid=%s)", pid)
241
+
242
+ return {
243
+ "success": True,
244
+ "message": f"Dashboard stopped (pid={pid})",
245
+ }
246
+
247
+ except ProcessLookupError:
248
+ _clear_pid()
249
+ return {
250
+ "success": True,
251
+ "message": f"Dashboard process (pid={pid}) already terminated",
252
+ }
253
+ except Exception as e:
254
+ return {
255
+ "success": False,
256
+ "message": f"Failed to stop dashboard: {e}",
257
+ }
258
+
259
+
260
+ def get_dashboard_status() -> dict:
261
+ """Check if dashboard is running.
262
+
263
+ Returns:
264
+ dict with:
265
+ - running: bool
266
+ - pid: Process ID (if running)
267
+ - exit_code: Exit code (if not running)
268
+ """
269
+ global _dashboard_process
270
+
271
+ # First check in-memory process reference
272
+ if _dashboard_process is not None:
273
+ poll = _dashboard_process.poll()
274
+ if poll is not None:
275
+ _clear_pid()
276
+ return {"running": False, "exit_code": poll}
277
+ return {"running": True, "pid": _dashboard_process.pid}
278
+
279
+ # Fall back to PID file (for cross-process status)
280
+ pid = _load_pid()
281
+ if pid is None:
282
+ return {"running": False}
283
+
284
+ if _is_process_running(pid):
285
+ return {"running": True, "pid": pid}
286
+
287
+ # Process not running but PID file exists - clean up
288
+ _clear_pid()
289
+ 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,174 @@
1
+ """Metrics page - time-series 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.charts import line_chart, empty_chart
7
+ from foundry_mcp.dashboard.components.cards import kpi_row
8
+ from foundry_mcp.dashboard.data.stores import (
9
+ get_metrics_list,
10
+ get_metrics_timeseries,
11
+ get_metrics_summary,
12
+ get_top_tool_actions,
13
+ )
14
+
15
+ # Try importing pandas
16
+ try:
17
+ import pandas as pd
18
+ PANDAS_AVAILABLE = True
19
+ except ImportError:
20
+ PANDAS_AVAILABLE = False
21
+ pd = None
22
+
23
+
24
+ def render():
25
+ """Render the Metrics page."""
26
+ st.header("Metrics")
27
+
28
+ # Get available metrics
29
+ metrics_list = get_metrics_list()
30
+
31
+ if not metrics_list:
32
+ st.warning("No metrics available. Metrics persistence may be disabled.")
33
+ st.info("Enable metrics persistence in foundry-mcp.toml under [metrics_persistence]")
34
+ return
35
+
36
+ # Metric selector and time range
37
+ col1, col2 = st.columns([2, 1])
38
+
39
+ with col1:
40
+ metric_names = [m.get("metric_name", "unknown") for m in metrics_list]
41
+ selected_metric = st.selectbox(
42
+ "Select Metric",
43
+ options=metric_names,
44
+ key="metrics_selector",
45
+ )
46
+
47
+ with col2:
48
+ hours = time_range_filter(key="metrics_time_range", default="24h")
49
+
50
+ st.divider()
51
+
52
+ if selected_metric:
53
+ # Get summary statistics
54
+ summary = get_metrics_summary(selected_metric, since_hours=hours)
55
+
56
+ # Summary cards
57
+ st.subheader("Summary Statistics")
58
+ if summary.get("enabled"):
59
+ # Handle None values (returned when no data exists)
60
+ min_val = summary.get("min") if summary.get("min") is not None else 0
61
+ max_val = summary.get("max") if summary.get("max") is not None else 0
62
+ avg_val = summary.get("avg") if summary.get("avg") is not None else 0
63
+ sum_val = summary.get("sum") if summary.get("sum") is not None else 0
64
+
65
+ kpi_row(
66
+ [
67
+ {"label": "Count", "value": summary.get("count", 0)},
68
+ {"label": "Min", "value": f"{min_val:.2f}"},
69
+ {"label": "Max", "value": f"{max_val:.2f}"},
70
+ {"label": "Average", "value": f"{avg_val:.2f}"},
71
+ {"label": "Sum", "value": f"{sum_val:.2f}"},
72
+ ],
73
+ columns=5,
74
+ )
75
+ else:
76
+ st.info("Summary not available")
77
+
78
+ st.divider()
79
+
80
+ # Time-series chart
81
+ st.subheader(f"Time Series: {selected_metric}")
82
+ timeseries_df = get_metrics_timeseries(selected_metric, since_hours=hours)
83
+
84
+ # Check if we have data, if not try longer time ranges
85
+ display_df = timeseries_df
86
+ time_range_note = None
87
+
88
+ if display_df is None or display_df.empty:
89
+ # Try progressively longer time ranges
90
+ for fallback_hours, label in [(168, "7 days"), (720, "30 days"), (8760, "1 year")]:
91
+ if fallback_hours > hours:
92
+ display_df = get_metrics_timeseries(selected_metric, since_hours=fallback_hours)
93
+ if display_df is not None and not display_df.empty:
94
+ time_range_note = f"No data in selected range - showing last {label}"
95
+ break
96
+
97
+ if display_df is not None and not display_df.empty:
98
+ if time_range_note:
99
+ st.caption(time_range_note)
100
+ line_chart(
101
+ display_df,
102
+ x="timestamp",
103
+ y="value",
104
+ title=None,
105
+ height=400,
106
+ )
107
+
108
+ # Data table
109
+ with st.expander("View Raw Data"):
110
+ st.dataframe(
111
+ display_df,
112
+ use_container_width=True,
113
+ hide_index=True,
114
+ )
115
+
116
+ # Export
117
+ col1, col2 = st.columns(2)
118
+ with col1:
119
+ csv = display_df.to_csv(index=False)
120
+ st.download_button(
121
+ label="Download CSV",
122
+ data=csv,
123
+ file_name=f"{selected_metric}_export.csv",
124
+ mime="text/csv",
125
+ )
126
+ with col2:
127
+ json_data = display_df.to_json(orient="records")
128
+ st.download_button(
129
+ label="Download JSON",
130
+ data=json_data,
131
+ file_name=f"{selected_metric}_export.json",
132
+ mime="application/json",
133
+ )
134
+ else:
135
+ empty_chart(f"No data available for {selected_metric} (all time)")
136
+
137
+ # Tool Action Breakdown
138
+ st.divider()
139
+ st.subheader("Top Tool Actions")
140
+ top_actions = get_top_tool_actions(since_hours=hours, top_n=10)
141
+
142
+ if top_actions:
143
+ col1, col2 = st.columns(2)
144
+ for i, item in enumerate(top_actions):
145
+ tool = item.get("tool", "unknown")
146
+ action = item.get("action", "")
147
+ count = int(item.get("count", 0))
148
+ display_name = f"{tool}.{action}" if action and action != "(no action)" else tool
149
+ target_col = col1 if i < 5 else col2
150
+ with target_col:
151
+ with st.container(border=True):
152
+ st.markdown(f"**{display_name}**")
153
+ st.caption(f"Invocations: {count:,}")
154
+ else:
155
+ st.info("No tool action data available for selected time range")
156
+
157
+ # Metrics catalog
158
+ st.divider()
159
+ st.subheader("Available Metrics")
160
+
161
+ if PANDAS_AVAILABLE:
162
+ metrics_df = pd.DataFrame(metrics_list)
163
+ st.dataframe(
164
+ metrics_df,
165
+ use_container_width=True,
166
+ hide_index=True,
167
+ column_config={
168
+ "metric_name": st.column_config.TextColumn("Metric", width="medium"),
169
+ "count": st.column_config.NumberColumn("Records", width="small"),
170
+ },
171
+ )
172
+ else:
173
+ for m in metrics_list:
174
+ st.text(f"- {m.get('metric_name', 'unknown')} ({m.get('count', 0)} records)")