diff --git a/app/data/action/integrations/_helpers.py b/app/data/action/integrations/_helpers.py index 9f65c509..5ba60c2c 100644 --- a/app/data/action/integrations/_helpers.py +++ b/app/data/action/integrations/_helpers.py @@ -147,12 +147,21 @@ async def run_client( raw = method(**kwargs) if asyncio.iscoroutine(raw): raw = await raw - return _shape_result( + result = _shape_result( raw, unwrap_envelope=unwrap_envelope, success_message=success_message, fail_message=fail_message, ) + if result.get("status") != "error": + try: + from app.ui_layer.metrics.collector import MetricsCollector + collector = MetricsCollector.get_instance() + if collector: + collector.record_integration_call(integration) + except Exception: + pass + return result except Exception as e: return {"status": "error", "message": str(e)} @@ -187,12 +196,21 @@ def run_client_sync( "status": "error", "message": f"{method_name!r} is async — use run_client (await) instead", } - return _shape_result( + result = _shape_result( raw, unwrap_envelope=unwrap_envelope, success_message=success_message, fail_message=fail_message, ) + if result.get("status") != "error": + try: + from app.ui_layer.metrics.collector import MetricsCollector + collector = MetricsCollector.get_instance() + if collector: + collector.record_integration_call(integration) + except Exception: + pass + return result except Exception as e: return {"status": "error", "message": str(e)} @@ -243,6 +261,13 @@ async def with_client( result = fn(client, *args, **kwargs) if asyncio.iscoroutine(result): result = await result + try: + from app.ui_layer.metrics.collector import MetricsCollector + collector = MetricsCollector.get_instance() + if collector: + collector.record_integration_call(integration) + except Exception: + pass return {"status": "success", "result": result} except Exception as e: return {"status": "error", "message": str(e)} diff --git a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css index a9502b63..5d4f42d6 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css +++ b/app/ui_layer/browser/frontend/src/components/ui/StatusIndicator.module.css @@ -16,7 +16,7 @@ .running, .thinking, .working { - color: #FF4F18; + color: var(--text-primary); } .completed { diff --git a/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx b/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx index 3a5dd717..38f552e3 100644 --- a/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Dashboard/DashboardPage.tsx @@ -12,10 +12,10 @@ import { Timer, PlayCircle, Hammer, - Wrench, Bot, Building2, - Hash + Hash, + Globe } from 'lucide-react' import { useWebSocket } from '../../contexts/WebSocketContext' import { Badge, StatusIndicator } from '../../components/ui' @@ -113,9 +113,10 @@ export function DashboardPage() { const [tokenPeriod, setTokenPeriod] = useState('total') const [usagePeriod, setUsagePeriod] = useState('total') - // Expand/collapse state for top tools/skills lists + // Expand/collapse state for top tools/skills/integrations lists const [showAllTools, setShowAllTools] = useState(false) const [showAllSkills, setShowAllSkills] = useState(false) + const [showAllIntegrations, setShowAllIntegrations] = useState(false) // Request filtered metrics when period changes (for all periods including 'total') const handlePeriodChange = useCallback(( @@ -218,6 +219,11 @@ export function DashboardPage() { const skillTotalInvocations = metrics?.skill?.totalInvocations ?? 0 const topSkills = metrics?.skill?.topSkills ?? [] + // Integration metrics + const integrationConnected = metrics?.integration?.connectedIntegrations ?? 0 + const integrationTotalCalls = metrics?.integration?.totalCalls ?? 0 + const topIntegrations = metrics?.integration?.topIntegrations ?? [] + // Model metrics const modelProvider = metrics?.model?.provider ?? '' const modelId = metrics?.model?.modelId ?? '' @@ -575,6 +581,52 @@ export function DashboardPage() { + {/* Integrations Panel */} +
+
+ +

Integrations

+
+
+
+
+ + {integrationConnected} + Connected +
+
+ + {integrationTotalCalls} + Total Calls +
+
+
+
Top Integrations
+ {topIntegrations.length > 0 ? ( +
+ {(showAllIntegrations ? topIntegrations : topIntegrations.slice(0, 3)).map((intg, index) => ( +
+ #{index + 1} + {intg.name} + {intg.count} +
+ ))} + {topIntegrations.length > 3 && ( + + )} +
+ ) : ( +
No usage yet
+ )} +
+
+
+ {/* Model Information Panel */}
diff --git a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css index 8cfcfa12..9a8de6bd 100644 --- a/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Onboarding/OnboardingPage.module.css @@ -42,8 +42,8 @@ } .stepDot.active { - background: #FF4F18; - border-color: #FF4F18; + background: var(--text-primary); + border-color: var(--text-primary); color: var(--color-white); } @@ -82,7 +82,7 @@ } .stepConnector.active { - background: #FF4F18; + background: var(--text-primary); } /* Main Content */ diff --git a/app/ui_layer/browser/frontend/src/types/index.ts b/app/ui_layer/browser/frontend/src/types/index.ts index c2ccca1c..7b0906df 100644 --- a/app/ui_layer/browser/frontend/src/types/index.ts +++ b/app/ui_layer/browser/frontend/src/types/index.ts @@ -309,6 +309,13 @@ export interface ModelMetrics { modelName: string } +export interface IntegrationMetrics { + totalIntegrations: number + connectedIntegrations: number + totalCalls: number + topIntegrations: UsageCount[] +} + export interface DashboardMetrics { uptimeSeconds: number timestamp: number @@ -320,6 +327,7 @@ export interface DashboardMetrics { usage: UsageMetrics mcp: MCPMetrics skill: SkillMetrics + integration: IntegrationMetrics model: ModelMetrics } diff --git a/app/ui_layer/metrics/collector.py b/app/ui_layer/metrics/collector.py index 97a25c7d..c1c7ed60 100644 --- a/app/ui_layer/metrics/collector.py +++ b/app/ui_layer/metrics/collector.py @@ -228,6 +228,27 @@ class SkillMetrics: top_skills: List[UsageCount] = field(default_factory=list) +@dataclass +class IntegrationInfo: + """Information about an integration.""" + + name: str + connected: bool + description: str = "" + icon: str = "" + + +@dataclass +class IntegrationMetrics: + """Integration metrics.""" + + total_integrations: int = 0 + connected_integrations: int = 0 + total_calls: int = 0 + integrations: List[IntegrationInfo] = field(default_factory=list) + top_integrations: List[UsageCount] = field(default_factory=list) + + @dataclass class ModelMetrics: """Current model information.""" @@ -269,6 +290,9 @@ class DashboardMetrics: # Skills skill: SkillMetrics = field(default_factory=SkillMetrics) + # Integrations + integration: IntegrationMetrics = field(default_factory=IntegrationMetrics) + # Model info model: ModelMetrics = field(default_factory=ModelMetrics) @@ -367,6 +391,15 @@ def to_dict(self) -> Dict[str, Any]: "modelId": self.model.model_id, "modelName": self.model.model_name, }, + "integration": { + "totalIntegrations": self.integration.total_integrations, + "connectedIntegrations": self.integration.connected_integrations, + "totalCalls": self.integration.total_calls, + "topIntegrations": [ + {"name": i.name, "count": i.count} + for i in self.integration.top_integrations + ], + }, } @@ -468,10 +501,15 @@ def __init__(self, agent: Optional["AgentBase"] = None) -> None: self._skill_usage: Dict[str, int] = defaultdict(int) self._skill_total_invocations: int = 0 + # Integration usage tracking + self._integration_usage: Dict[str, int] = defaultdict(int) + self._integration_total_calls: int = 0 + # Storage references for historical data self._usage_storage = None self._task_storage = None self._skill_storage = None + self._integration_storage = None self._init_storage() def _init_storage(self) -> None: @@ -491,7 +529,14 @@ def _init_storage(self) -> None: self._skill_storage = get_skill_storage() except Exception: pass + try: + from app.usage.integration_storage import get_integration_storage + + self._integration_storage = get_integration_storage() + except Exception: + pass self._load_skill_metrics() + self._load_integration_metrics() def _load_skill_metrics(self) -> None: """Restore skill invocation counts from persistent storage on startup.""" @@ -689,6 +734,42 @@ def get_top_skills(self, limit: int = 3) -> List[Tuple[str, int]]: ) return sorted_skills[:limit] + # ───────────────────────────────────────────────────────────────────── + # Integration Usage Tracking + # ───────────────────────────────────────────────────────────────────── + + def _load_integration_metrics(self) -> None: + """Restore integration call counts from persistent storage on startup.""" + if not self._integration_storage: + return + try: + totals = self._integration_storage.get_integration_totals() + with self._lock: + for integration_name, count in totals.items(): + self._integration_usage[integration_name] = count + self._integration_total_calls = sum(totals.values()) + except Exception: + pass + + def record_integration_call(self, integration_name: str) -> None: + """Record a successful integration call.""" + with self._lock: + self._integration_usage[integration_name] += 1 + self._integration_total_calls += 1 + if self._integration_storage: + try: + self._integration_storage.insert_call(integration_name) + except Exception: + pass + + def get_top_integrations(self, limit: int = 3) -> List[Tuple[str, int]]: + """Get top N most used integrations.""" + with self._lock: + sorted_integrations = sorted( + self._integration_usage.items(), key=lambda x: x[1], reverse=True + ) + return sorted_integrations[:limit] + # ───────────────────────────────────────────────────────────────────── # System Metrics # ───────────────────────────────────────────────────────────────────── @@ -888,6 +969,44 @@ def _get_skill_metrics(self) -> SkillMetrics: except Exception: return SkillMetrics() + def _get_integration_metrics(self) -> IntegrationMetrics: + """Get integration metrics.""" + try: + from craftos_integrations import list_integrations_sync + + integrations_data = list_integrations_sync() + integrations = [] + connected = 0 + + for intg in integrations_data: + is_connected = intg.get("connected", False) + if is_connected: + connected += 1 + integrations.append( + IntegrationInfo( + name=intg.get("name", intg.get("id", "")), + connected=is_connected, + description=intg.get("description", ""), + icon=intg.get("icon", ""), + ) + ) + + # Get top integrations usage + top_integrations = [ + UsageCount(name=name, count=count) + for name, count in self.get_top_integrations(3) + ] + + return IntegrationMetrics( + total_integrations=len(integrations), + connected_integrations=connected, + total_calls=self._integration_total_calls, + integrations=integrations, + top_integrations=top_integrations, + ) + except Exception: + return IntegrationMetrics() + # ───────────────────────────────────────────────────────────────────── # Model Metrics # ───────────────────────────────────────────────────────────────────── @@ -1033,6 +1152,7 @@ def get_metrics(self) -> DashboardMetrics: thread_pool_metrics = self._get_thread_pool_metrics() mcp_metrics = self._get_mcp_metrics() skill_metrics = self._get_skill_metrics() + integration_metrics = self._get_integration_metrics() model_metrics = self._get_model_metrics() return DashboardMetrics( @@ -1070,6 +1190,7 @@ def get_metrics(self) -> DashboardMetrics: ), mcp=mcp_metrics, skill=skill_metrics, + integration=integration_metrics, model=model_metrics, ) diff --git a/app/usage/integration_storage.py b/app/usage/integration_storage.py new file mode 100644 index 00000000..d5b0b39a --- /dev/null +++ b/app/usage/integration_storage.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +""" +app.usage.integration_storage + +SQLite-based storage for integration call events. +Provides local persistence for integration usage history across restarts. +""" + +from __future__ import annotations + +import logging +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Dict, Optional + +try: + from app.logger import logger +except Exception: + logger = logging.getLogger(__name__) + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + + +class IntegrationStorage: + """ + SQLite-based storage for integration call events. + + Provides local persistence for integration usage history. + Events are stored in a SQLite database in app/data/.usage/integrations.db. + """ + + def __init__(self, db_path: Optional[str] = None): + if db_path is None: + from app.config import APP_DATA_PATH + + usage_dir = Path(APP_DATA_PATH) / ".usage" + usage_dir.mkdir(parents=True, exist_ok=True) + db_path = str(usage_dir / "integrations.db") + + self._db_path = db_path + self._init_db() + logger.info(f"[IntegrationStorage] Initialized at {self._db_path}") + + def _init_db(self) -> None: + """Initialize the database schema.""" + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS integration_calls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + integration_name TEXT NOT NULL + ) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_integration_timestamp + ON integration_calls(timestamp) + """) + cursor.execute(""" + CREATE INDEX IF NOT EXISTS idx_integration_name + ON integration_calls(integration_name) + """) + conn.commit() + + def insert_call(self, integration_name: str) -> int: + """ + Record a single integration call. + + Args: + integration_name: Name of the integration that was called. + + Returns: + The row ID of the inserted record. + """ + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO integration_calls (timestamp, integration_name) VALUES (?, ?)", + (datetime.now().isoformat(), integration_name), + ) + conn.commit() + return cursor.lastrowid + + def get_integration_totals(self) -> Dict[str, int]: + """ + Get all-time call counts grouped by integration name. + + Returns: + Dict mapping integration_name -> total call count. + """ + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT integration_name, COUNT(*) FROM integration_calls GROUP BY integration_name + """) + return {row[0]: row[1] for row in cursor.fetchall()} + + def get_stats(self) -> Dict: + """Get storage statistics.""" + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM integration_calls") + total = cursor.fetchone()[0] + cursor.execute("SELECT MIN(timestamp), MAX(timestamp) FROM integration_calls") + row = cursor.fetchone() + return { + "db_path": self._db_path, + "total_calls": total, + "earliest": row[0] if row[0] else None, + "latest": row[1] if row[1] else None, + } + + def clear_calls(self) -> int: + """Clear all call records. Returns number deleted.""" + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute("SELECT COUNT(*) FROM integration_calls") + count = cursor.fetchone()[0] + cursor.execute("DELETE FROM integration_calls") + conn.commit() + logger.info(f"[IntegrationStorage] Cleared {count} call records") + return count + + +# Global storage instance +_integration_storage: Optional[IntegrationStorage] = None + + +def get_integration_storage() -> IntegrationStorage: + """Get the global integration storage instance.""" + global _integration_storage + if _integration_storage is None: + _integration_storage = IntegrationStorage() + return _integration_storage diff --git a/requirements.txt b/requirements.txt index 87661cc8..f9f25eeb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -51,4 +51,4 @@ pdfplumber pypdfium2 pdfminer.six pymupdf -pypdf \ No newline at end of file +pypdf