Feat: Add SSE-powered Team Huddle feed and Activity page
This commit is contained in:
@@ -4,9 +4,11 @@ Provides REST API access to agent orchestration functionality
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
|
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
from middleware.auth_middleware import get_current_user
|
from middleware.auth_middleware import get_current_user
|
||||||
from utils.logger_utils import get_service_logger
|
from utils.logger_utils import get_service_logger
|
||||||
@@ -19,13 +21,119 @@ from services.intelligence.agents.performance_monitor import PerformanceMetric,
|
|||||||
from services.database import get_db
|
from services.database import get_db
|
||||||
from services.agent_activity_service import AgentActivityService
|
from services.agent_activity_service import AgentActivityService
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from models.agent_activity_models import AgentProfile
|
from models.agent_activity_models import AgentProfile, AgentRun, AgentEvent, AgentAlert, AgentApprovalRequest
|
||||||
from services.intelligence.agents.team_catalog import AGENT_TEAM_CATALOG, get_agent_catalog_entry
|
from services.intelligence.agents.team_catalog import AGENT_TEAM_CATALOG, get_agent_catalog_entry
|
||||||
|
|
||||||
logger = get_service_logger(__name__)
|
logger = get_service_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/agents", tags=["Autonomous Agents"])
|
router = APIRouter(prefix="/api/agents", tags=["Autonomous Agents"])
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_run(run: AgentRun) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": run.id,
|
||||||
|
"user_id": run.user_id,
|
||||||
|
"agent_type": run.agent_type,
|
||||||
|
"status": run.status,
|
||||||
|
"success": run.success,
|
||||||
|
"error_message": run.error_message,
|
||||||
|
"result_summary": run.result_summary,
|
||||||
|
"mlflow_run_id": run.mlflow_run_id,
|
||||||
|
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||||
|
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_event(event: AgentEvent) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": event.id,
|
||||||
|
"run_id": event.run_id,
|
||||||
|
"agent_type": event.agent_type,
|
||||||
|
"event_type": event.event_type,
|
||||||
|
"severity": event.severity,
|
||||||
|
"message": event.message,
|
||||||
|
"payload": event.payload,
|
||||||
|
"created_at": event.created_at.isoformat() if event.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_alert(alert: AgentAlert) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": alert.id,
|
||||||
|
"source": alert.source,
|
||||||
|
"type": alert.alert_type,
|
||||||
|
"severity": alert.severity,
|
||||||
|
"title": alert.title,
|
||||||
|
"message": alert.message,
|
||||||
|
"cta_path": alert.cta_path,
|
||||||
|
"payload": alert.payload,
|
||||||
|
"created_at": alert.created_at.isoformat() if alert.created_at else None,
|
||||||
|
"read_at": alert.read_at.isoformat() if alert.read_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_approval(approval: AgentApprovalRequest) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": approval.id,
|
||||||
|
"status": approval.status,
|
||||||
|
"decision": approval.decision,
|
||||||
|
"action_id": approval.action_id,
|
||||||
|
"action_type": approval.action_type,
|
||||||
|
"agent_type": approval.agent_type,
|
||||||
|
"target_resource": approval.target_resource,
|
||||||
|
"risk_level": approval.risk_level,
|
||||||
|
"payload": approval.payload,
|
||||||
|
"created_at": approval.created_at.isoformat() if approval.created_at else None,
|
||||||
|
"decided_at": approval.decided_at.isoformat() if approval.decided_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_huddle_snapshot(
|
||||||
|
db: Session,
|
||||||
|
user_id: str,
|
||||||
|
since_run_id: int = 0,
|
||||||
|
since_event_id: int = 0,
|
||||||
|
since_alert_id: int = 0,
|
||||||
|
since_approval_id: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
runs_query = db.query(AgentRun).filter(AgentRun.user_id == user_id)
|
||||||
|
events_query = db.query(AgentEvent).filter(AgentEvent.user_id == user_id)
|
||||||
|
alerts_query = db.query(AgentAlert).filter(AgentAlert.user_id == user_id)
|
||||||
|
approvals_query = db.query(AgentApprovalRequest).filter(AgentApprovalRequest.user_id == user_id)
|
||||||
|
|
||||||
|
if since_run_id > 0:
|
||||||
|
runs_query = runs_query.filter(AgentRun.id > since_run_id)
|
||||||
|
if since_event_id > 0:
|
||||||
|
events_query = events_query.filter(AgentEvent.id > since_event_id)
|
||||||
|
if since_alert_id > 0:
|
||||||
|
alerts_query = alerts_query.filter(AgentAlert.id > since_alert_id)
|
||||||
|
if since_approval_id > 0:
|
||||||
|
approvals_query = approvals_query.filter(AgentApprovalRequest.id > since_approval_id)
|
||||||
|
|
||||||
|
runs = runs_query.order_by(AgentRun.id.desc()).limit(limit).all()
|
||||||
|
events = events_query.order_by(AgentEvent.id.desc()).limit(limit * 2).all()
|
||||||
|
alerts = alerts_query.order_by(AgentAlert.id.desc()).limit(limit).all()
|
||||||
|
approvals = approvals_query.order_by(AgentApprovalRequest.id.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
runs_sorted = list(reversed(runs))
|
||||||
|
events_sorted = list(reversed(events))
|
||||||
|
alerts_sorted = list(reversed(alerts))
|
||||||
|
approvals_sorted = list(reversed(approvals))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"runs": [_serialize_run(r) for r in runs_sorted],
|
||||||
|
"events": [_serialize_event(e) for e in events_sorted],
|
||||||
|
"alerts": [_serialize_alert(a) for a in alerts_sorted],
|
||||||
|
"approvals": [_serialize_approval(a) for a in approvals_sorted],
|
||||||
|
"cursor": {
|
||||||
|
"run_id": max([since_run_id] + [r.id for r in runs_sorted]),
|
||||||
|
"event_id": max([since_event_id] + [e.id for e in events_sorted]),
|
||||||
|
"alert_id": max([since_alert_id] + [a.id for a in alerts_sorted]),
|
||||||
|
"approval_id": max([since_approval_id] + [a.id for a in approvals_sorted]),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
@router.get("/team")
|
@router.get("/team")
|
||||||
async def get_agent_team_endpoint(
|
async def get_agent_team_endpoint(
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
@@ -627,6 +735,142 @@ async def get_agent_approvals_endpoint(
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/huddle/feed")
|
||||||
|
async def get_agent_huddle_feed_endpoint(
|
||||||
|
since_run_id: int = 0,
|
||||||
|
since_event_id: int = 0,
|
||||||
|
since_alert_id: int = 0,
|
||||||
|
since_approval_id: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
user_id = str(current_user.get("id"))
|
||||||
|
payload = _build_huddle_snapshot(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
since_run_id=max(0, int(since_run_id)),
|
||||||
|
since_event_id=max(0, int(since_event_id)),
|
||||||
|
since_alert_id=max(0, int(since_alert_id)),
|
||||||
|
since_approval_id=max(0, int(since_approval_id)),
|
||||||
|
limit=max(1, min(int(limit), 200)),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"data": payload,
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"user_id": user_id,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting huddle feed for user {current_user.get('id')}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/huddle/stream")
|
||||||
|
async def stream_agent_huddle_endpoint(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
user_id = str(current_user.get("id"))
|
||||||
|
|
||||||
|
async def event_generator():
|
||||||
|
cursor = {"run_id": 0, "event_id": 0, "alert_id": 0, "approval_id": 0}
|
||||||
|
run_signatures: Dict[int, str] = {}
|
||||||
|
|
||||||
|
initial_snapshot = _build_huddle_snapshot(db=db, user_id=user_id, limit=50)
|
||||||
|
cursor.update(initial_snapshot.get("cursor") or {})
|
||||||
|
for run in initial_snapshot.get("runs", []):
|
||||||
|
run_signatures[int(run.get("id") or 0)] = json.dumps(
|
||||||
|
{
|
||||||
|
"status": run.get("status"),
|
||||||
|
"success": run.get("success"),
|
||||||
|
"finished_at": run.get("finished_at"),
|
||||||
|
"error_message": run.get("error_message"),
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
yield f"event: snapshot\ndata: {json.dumps(initial_snapshot)}\n\n"
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
delta = _build_huddle_snapshot(
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
since_run_id=int(cursor.get("run_id", 0)),
|
||||||
|
since_event_id=int(cursor.get("event_id", 0)),
|
||||||
|
since_alert_id=int(cursor.get("alert_id", 0)),
|
||||||
|
since_approval_id=int(cursor.get("approval_id", 0)),
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_runs = (
|
||||||
|
db.query(AgentRun)
|
||||||
|
.filter(AgentRun.user_id == user_id)
|
||||||
|
.order_by(AgentRun.id.desc())
|
||||||
|
.limit(100)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
lifecycle_updates: List[Dict[str, Any]] = []
|
||||||
|
for run in recent_runs:
|
||||||
|
signature = json.dumps(
|
||||||
|
{
|
||||||
|
"status": run.status,
|
||||||
|
"success": run.success,
|
||||||
|
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||||
|
"error_message": run.error_message,
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
previous = run_signatures.get(run.id)
|
||||||
|
if previous != signature:
|
||||||
|
lifecycle_updates.append(_serialize_run(run))
|
||||||
|
run_signatures[run.id] = signature
|
||||||
|
|
||||||
|
if len(run_signatures) > 300:
|
||||||
|
keep_ids = {r.id for r in recent_runs}
|
||||||
|
run_signatures = {k: v for k, v in run_signatures.items() if k in keep_ids}
|
||||||
|
|
||||||
|
has_changes = bool(
|
||||||
|
delta.get("events")
|
||||||
|
or delta.get("alerts")
|
||||||
|
or delta.get("approvals")
|
||||||
|
or lifecycle_updates
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_changes:
|
||||||
|
if delta.get("cursor"):
|
||||||
|
cursor.update(delta["cursor"])
|
||||||
|
event_payload = {
|
||||||
|
"runs": lifecycle_updates,
|
||||||
|
"events": delta.get("events", []),
|
||||||
|
"alerts": delta.get("alerts", []),
|
||||||
|
"approvals": delta.get("approvals", []),
|
||||||
|
"cursor": cursor,
|
||||||
|
"ts": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
yield f"event: delta\ndata: {json.dumps(event_payload)}\n\n"
|
||||||
|
else:
|
||||||
|
yield f"event: heartbeat\ndata: {json.dumps({'ts': datetime.utcnow().isoformat()})}\n\n"
|
||||||
|
|
||||||
|
await asyncio.sleep(2.5)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as stream_error:
|
||||||
|
logger.warning(f"Huddle stream loop error for user {user_id}: {stream_error}")
|
||||||
|
error_payload = {"message": "stream_error", "ts": datetime.utcnow().isoformat()}
|
||||||
|
yield f"event: error\ndata: {json.dumps(error_payload)}\n\n"
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
}
|
||||||
|
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/approvals/{approval_id}/decision")
|
@router.post("/approvals/{approval_id}/decision")
|
||||||
async def decide_agent_approval_endpoint(
|
async def decide_agent_approval_endpoint(
|
||||||
approval_id: int,
|
approval_id: int,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import IntentResearchTest from './pages/IntentResearchTest';
|
|||||||
import SchedulerDashboard from './pages/SchedulerDashboard';
|
import SchedulerDashboard from './pages/SchedulerDashboard';
|
||||||
import BillingPage from './pages/BillingPage';
|
import BillingPage from './pages/BillingPage';
|
||||||
import ApprovalsPage from './pages/ApprovalsPage';
|
import ApprovalsPage from './pages/ApprovalsPage';
|
||||||
|
import TeamActivityPage from './pages/TeamActivityPage';
|
||||||
import StripeDisputesDashboard from './pages/StripeDisputesDashboard';
|
import StripeDisputesDashboard from './pages/StripeDisputesDashboard';
|
||||||
import TeamActivityPage from './pages/TeamActivityPage';
|
import TeamActivityPage from './pages/TeamActivityPage';
|
||||||
import ProtectedRoute from './components/shared/ProtectedRoute';
|
import ProtectedRoute from './components/shared/ProtectedRoute';
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -12,8 +11,7 @@ import {
|
|||||||
ListItemText,
|
ListItemText,
|
||||||
Divider,
|
Divider,
|
||||||
IconButton,
|
IconButton,
|
||||||
Tooltip,
|
Tooltip
|
||||||
CircularProgress
|
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Psychology as StrategyIcon,
|
Psychology as StrategyIcon,
|
||||||
@@ -21,239 +19,61 @@ import {
|
|||||||
Search as SeoIcon,
|
Search as SeoIcon,
|
||||||
Campaign as SocialIcon,
|
Campaign as SocialIcon,
|
||||||
CompareArrows as CompetitorIcon,
|
CompareArrows as CompetitorIcon,
|
||||||
SmartToy as AgentIcon,
|
Refresh as RefreshIcon
|
||||||
Refresh as RefreshIcon,
|
|
||||||
WarningAmber as WarningIcon
|
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { apiClient } from '../../../api/client';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { useAgentHuddleFeed } from '../../../hooks/useAgentHuddleFeed';
|
||||||
|
|
||||||
interface AgentCard {
|
const ICON_BY_AGENT: Record<string, React.ElementType> = {
|
||||||
id: string;
|
strategy: StrategyIcon,
|
||||||
name: string;
|
content: ContentIcon,
|
||||||
role: string;
|
seo: SeoIcon,
|
||||||
status: 'active' | 'thinking' | 'idle' | 'offline';
|
social: SocialIcon,
|
||||||
current_activity: string;
|
competitor: CompetitorIcon,
|
||||||
icon: React.ElementType;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AgentMeta {
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AGENT_META: Record<string, AgentMeta> = {
|
|
||||||
strategy: {
|
|
||||||
name: 'Strategy Architect',
|
|
||||||
role: 'Team Lead',
|
|
||||||
icon: StrategyIcon,
|
|
||||||
color: '#6366f1'
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
name: 'Content Strategist',
|
|
||||||
role: 'Creative',
|
|
||||||
icon: ContentIcon,
|
|
||||||
color: '#10b981'
|
|
||||||
},
|
|
||||||
seo: {
|
|
||||||
name: 'SEO Specialist',
|
|
||||||
role: 'Technical',
|
|
||||||
icon: SeoIcon,
|
|
||||||
color: '#f59e0b'
|
|
||||||
},
|
|
||||||
social: {
|
|
||||||
name: 'Social Manager',
|
|
||||||
role: 'Engagement',
|
|
||||||
icon: SocialIcon,
|
|
||||||
color: '#ec4899'
|
|
||||||
},
|
|
||||||
competitor: {
|
|
||||||
name: 'Competitor Analyst',
|
|
||||||
role: 'Intelligence',
|
|
||||||
icon: CompetitorIcon,
|
|
||||||
color: '#ef4444'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeAgentKey = (rawKey: string): string => {
|
|
||||||
const key = (rawKey || '').toLowerCase();
|
|
||||||
|
|
||||||
if (key.includes('strategy')) return 'strategy';
|
|
||||||
if (key.includes('content')) return 'content';
|
|
||||||
if (key.includes('seo') || key.includes('search')) return 'seo';
|
|
||||||
if (key.includes('social')) return 'social';
|
|
||||||
if (key.includes('competitor') || key.includes('competition')) return 'competitor';
|
|
||||||
|
|
||||||
return key;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeStatus = (status: string): AgentCard['status'] => {
|
|
||||||
const value = (status || '').toLowerCase();
|
|
||||||
|
|
||||||
if (value === 'active' || value === 'thinking' || value === 'idle' || value === 'offline') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === 'running' || value === 'busy') return 'active';
|
|
||||||
if (value === 'processing' || value === 'analyzing') return 'thinking';
|
|
||||||
if (value === 'inactive' || value === 'stopped') return 'offline';
|
|
||||||
|
|
||||||
return 'idle';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const TeamHuddleWidget: React.FC = () => {
|
const TeamHuddleWidget: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const { runs, connectionMode, lastHeartbeatAt } = useAgentHuddleFeed();
|
||||||
const [agents, setAgents] = React.useState<AgentCard[]>([]);
|
|
||||||
const [loading, setLoading] = React.useState(true);
|
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
|
||||||
const [refreshing, setRefreshing] = React.useState(false);
|
|
||||||
const [alertCount, setAlertCount] = React.useState(0);
|
|
||||||
|
|
||||||
const fetchTeamData = React.useCallback(async (isRefresh = false) => {
|
// Create rows for the widget from the feed runs
|
||||||
if (isRefresh) {
|
// Note: events are not directly used in the simple widget view, but available if needed
|
||||||
setRefreshing(true);
|
const rows = React.useMemo(() => {
|
||||||
} else {
|
return runs.slice(0, 5).map((run) => {
|
||||||
setLoading(true);
|
const agentType = String(run.agent_type || 'strategy');
|
||||||
}
|
// Simple heuristic for icon mapping
|
||||||
|
let IconComponent = StrategyIcon;
|
||||||
setError(null);
|
for (const key in ICON_BY_AGENT) {
|
||||||
|
if (agentType.toLowerCase().includes(key)) {
|
||||||
try {
|
IconComponent = ICON_BY_AGENT[key];
|
||||||
const [statusResp, runsResp, alertsResp] = await Promise.allSettled([
|
break;
|
||||||
apiClient.get('/api/agents/status'),
|
}
|
||||||
apiClient.get('/api/agents/runs', { params: { limit: 20 } }),
|
|
||||||
apiClient.get('/api/agents/alerts', { params: { unread_only: true, limit: 20 } })
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (statusResp.status !== 'fulfilled') {
|
|
||||||
throw new Error('Unable to load team status');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusPayload = statusResp.value?.data?.data?.agents || statusResp.value?.data?.agents || [];
|
const status = run.status === 'running' ? 'thinking' : run.success === false ? 'offline' : 'active';
|
||||||
const runsPayload = runsResp.status === 'fulfilled'
|
return {
|
||||||
? (runsResp.value?.data?.data?.runs || runsResp.value?.data?.runs || [])
|
id: run.id,
|
||||||
: [];
|
name: agentType.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
|
||||||
const alertsPayload = alertsResp.status === 'fulfilled'
|
role: run.status || 'active',
|
||||||
? (alertsResp.value?.data?.data?.alerts || alertsResp.value?.data?.alerts || [])
|
status,
|
||||||
: [];
|
current_activity: run.result_summary || run.error_message || 'Awaiting next update',
|
||||||
|
icon: IconComponent,
|
||||||
const runByAgent = new Map<string, string>();
|
};
|
||||||
runsPayload.forEach((run: Record<string, unknown>) => {
|
});
|
||||||
const key = normalizeAgentKey((run.agent_key || run.agent || run.agent_id || '') as string);
|
}, [runs]);
|
||||||
if (!key || runByAgent.has(key)) return;
|
|
||||||
|
|
||||||
runByAgent.set(
|
|
||||||
key,
|
|
||||||
(run.summary || run.context || run.activity || run.status_message || 'Working on recent tasks') as string
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const normalizedAgents: AgentCard[] = statusPayload.map((agent: Record<string, unknown>, index: number) => {
|
|
||||||
const rawKey = (agent.key || agent.agent_key || agent.id || agent.name || `agent-${index}`) as string;
|
|
||||||
const normalizedKey = normalizeAgentKey(rawKey);
|
|
||||||
const meta = AGENT_META[normalizedKey] || {
|
|
||||||
name: (agent.display_name || agent.name || rawKey) as string,
|
|
||||||
role: 'AI Agent',
|
|
||||||
icon: AgentIcon,
|
|
||||||
color: '#64748b'
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: String(rawKey),
|
|
||||||
name: (agent.display_name || meta.name) as string,
|
|
||||||
role: (agent.role || meta.role) as string,
|
|
||||||
status: normalizeStatus((agent.status || agent.operational_status || '') as string),
|
|
||||||
current_activity:
|
|
||||||
(agent.current_activity ||
|
|
||||||
runByAgent.get(normalizedKey) ||
|
|
||||||
agent.last_message ||
|
|
||||||
'Waiting for next instruction') as string,
|
|
||||||
icon: meta.icon,
|
|
||||||
color: meta.color
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
setAgents(normalizedAgents);
|
|
||||||
setAlertCount(Array.isArray(alertsPayload) ? alertsPayload.length : 0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load team huddle data:', err);
|
|
||||||
setError('Could not load team huddle data. Please try again.');
|
|
||||||
setAgents([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
fetchTeamData();
|
|
||||||
}, [fetchTeamData]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper elevation={0} sx={{ p: 2, borderRadius: 3, border: '1px solid', borderColor: 'divider', height: '100%', background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)' }}>
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
p: 2,
|
|
||||||
borderRadius: 3,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
height: '100%',
|
|
||||||
background: 'linear-gradient(180deg, #ffffff 0%, #f8fafc 100%)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
|
||||||
<Box display="flex" alignItems="center" gap={1}>
|
<Box display="flex" alignItems="center" gap={1}>
|
||||||
<Typography variant="h6" fontWeight={700} color="text.primary">
|
<Typography variant="h6" fontWeight={700} color="text.primary">Team Huddle</Typography>
|
||||||
Team Huddle
|
<Chip label={connectionMode === 'sse' ? 'Live' : connectionMode === 'polling' ? 'Polling' : 'Connecting'} size="small" color={connectionMode === 'sse' ? 'success' : 'warning'} sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }} />
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
label="Live"
|
|
||||||
size="small"
|
|
||||||
color="success"
|
|
||||||
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }}
|
|
||||||
/>
|
|
||||||
{alertCount > 0 && (
|
|
||||||
<Chip
|
|
||||||
icon={<WarningIcon fontSize="small" />}
|
|
||||||
label={`${alertCount} alert${alertCount > 1 ? 's' : ''}`}
|
|
||||||
size="small"
|
|
||||||
color="warning"
|
|
||||||
sx={{ height: 20, fontSize: '0.65rem', fontWeight: 700 }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
<Tooltip title="Refresh Team Status">
|
|
||||||
<span>
|
|
||||||
<IconButton size="small" onClick={() => fetchTeamData(true)} disabled={loading || refreshing}>
|
|
||||||
{refreshing ? <CircularProgress size={16} /> : <RefreshIcon fontSize="small" />}
|
|
||||||
</IconButton>
|
|
||||||
</span>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
<Tooltip title={lastHeartbeatAt ? `Heartbeat ${new Date(lastHeartbeatAt).toLocaleTimeString()}` : 'Waiting for heartbeat'}>
|
||||||
|
<IconButton size="small"><RefreshIcon fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{loading ? (
|
{rows.length === 0 ? (
|
||||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight={180}>
|
|
||||||
<CircularProgress size={24} />
|
|
||||||
</Box>
|
|
||||||
) : error ? (
|
|
||||||
<Box display="flex" flexDirection="column" alignItems="center" justifyContent="center" minHeight={180} gap={1}>
|
|
||||||
<Typography variant="body2" color="error.main" textAlign="center">
|
|
||||||
{error}
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
color="primary"
|
|
||||||
sx={{ fontWeight: 600, cursor: 'pointer', '&:hover': { textDecoration: 'underline' } }}
|
|
||||||
onClick={() => fetchTeamData(true)}
|
|
||||||
>
|
|
||||||
Retry
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
) : agents.length === 0 ? (
|
|
||||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight={180}>
|
<Box display="flex" justifyContent="center" alignItems="center" minHeight={180}>
|
||||||
<Typography variant="body2" color="text.secondary" textAlign="center">
|
<Typography variant="body2" color="text.secondary" textAlign="center">
|
||||||
No active agent activity right now.
|
No active agent activity right now.
|
||||||
@@ -261,76 +81,16 @@ const TeamHuddleWidget: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<List disablePadding>
|
<List disablePadding>
|
||||||
{agents.map((agent, index) => (
|
{rows.map((agent, index) => (
|
||||||
<React.Fragment key={agent.id}>
|
<React.Fragment key={agent.id}>
|
||||||
{index > 0 && <Divider variant="inset" component="li" sx={{ my: 1, ml: 7 }} />}
|
{index > 0 && <Divider variant="inset" component="li" sx={{ my: 1, ml: 7 }} />}
|
||||||
<ListItem
|
<ListItem alignItems="flex-start" disableGutters sx={{ py: 0.5 }}>
|
||||||
alignItems="flex-start"
|
|
||||||
disableGutters
|
|
||||||
sx={{ py: 0.5 }}
|
|
||||||
secondaryAction={
|
|
||||||
<Tooltip title={agent.status}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: '50%',
|
|
||||||
bgcolor:
|
|
||||||
agent.status === 'active' ? '#22c55e' :
|
|
||||||
agent.status === 'thinking' ? '#3b82f6' :
|
|
||||||
agent.status === 'offline' ? '#cbd5e1' :
|
|
||||||
'#94a3b8',
|
|
||||||
boxShadow: agent.status === 'active' ? '0 0 0 2px rgba(34, 197, 94, 0.2)' : 'none',
|
|
||||||
animation: agent.status === 'thinking' ? 'pulse 1.5s infinite' : 'none',
|
|
||||||
'@keyframes pulse': {
|
|
||||||
'0%': { opacity: 1, transform: 'scale(1)' },
|
|
||||||
'50%': { opacity: 0.6, transform: 'scale(1.2)' },
|
|
||||||
'100%': { opacity: 1, transform: 'scale(1)' }
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItemAvatar>
|
<ListItemAvatar>
|
||||||
<Avatar
|
<Avatar sx={{ bgcolor: '#eef2ff', color: '#6366f1', width: 40, height: 40 }}><agent.icon fontSize="small" /></Avatar>
|
||||||
sx={{
|
|
||||||
bgcolor: `${agent.color}15`,
|
|
||||||
color: agent.color,
|
|
||||||
width: 40,
|
|
||||||
height: 40
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<agent.icon fontSize="small" />
|
|
||||||
</Avatar>
|
|
||||||
</ListItemAvatar>
|
</ListItemAvatar>
|
||||||
<ListItemText
|
<ListItemText
|
||||||
primary={
|
primary={<Box display="flex" alignItems="center" gap={1}><Typography variant="subtitle2" fontWeight={600}>{agent.name}</Typography><Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', border: '1px solid #e2e8f0', px: 0.5, borderRadius: 1 }}>{agent.role}</Typography></Box>}
|
||||||
<Box display="flex" alignItems="center" gap={1}>
|
secondary={<Typography variant="body2" color="text.secondary" sx={{ display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical', overflow: 'hidden', fontSize: '0.75rem', mt: 0.25 }}>{agent.current_activity}</Typography>}
|
||||||
<Typography variant="subtitle2" fontWeight={600}>
|
|
||||||
{agent.name}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem', border: '1px solid #e2e8f0', px: 0.5, borderRadius: 1 }}>
|
|
||||||
{agent.role}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
secondary={
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{
|
|
||||||
display: '-webkit-box',
|
|
||||||
WebkitLineClamp: 1,
|
|
||||||
WebkitBoxOrient: 'vertical',
|
|
||||||
overflow: 'hidden',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
mt: 0.25
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{agent.current_activity}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
@@ -339,21 +99,7 @@ const TeamHuddleWidget: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Box mt={2} pt={2} borderTop="1px solid #eee" display="flex" justifyContent="center">
|
<Box mt={2} pt={2} borderTop="1px solid #eee" display="flex" justifyContent="center">
|
||||||
<Typography
|
<Typography component={RouterLink} to="/team-activity" variant="caption" color="primary" sx={{ fontWeight: 600, textDecoration: 'none', '&:hover': { textDecoration: 'underline' } }}>
|
||||||
component="button"
|
|
||||||
type="button"
|
|
||||||
variant="caption"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => navigate('/team-activity')}
|
|
||||||
sx={{
|
|
||||||
border: 0,
|
|
||||||
bgcolor: 'transparent',
|
|
||||||
p: 0,
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: 'pointer',
|
|
||||||
'&:hover': { textDecoration: 'underline' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
View Full Team Activity
|
View Full Team Activity
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
167
frontend/src/hooks/useAgentHuddleFeed.ts
Normal file
167
frontend/src/hooks/useAgentHuddleFeed.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { apiClient, getApiUrl, getAuthTokenGetter } from '../api/client';
|
||||||
|
|
||||||
|
export interface AgentRunItem { id: number; agent_type?: string; status?: string; success?: boolean | null; result_summary?: string | null; finished_at?: string | null; }
|
||||||
|
export interface AgentEventItem { id: number; agent_type?: string; event_type?: string; message?: string | null; created_at?: string | null; }
|
||||||
|
export interface AgentAlertItem { id: number; title?: string; message?: string; severity?: string; read_at?: string | null; }
|
||||||
|
export interface AgentApprovalItem { id: number; action_type?: string; status?: string; risk_level?: number; created_at?: string | null; }
|
||||||
|
|
||||||
|
interface Cursor { run_id: number; event_id: number; alert_id: number; approval_id: number; }
|
||||||
|
interface FeedPayload {
|
||||||
|
runs: AgentRunItem[];
|
||||||
|
events: AgentEventItem[];
|
||||||
|
alerts: AgentAlertItem[];
|
||||||
|
approvals: AgentApprovalItem[];
|
||||||
|
cursor: Cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CURSOR: Cursor = { run_id: 0, event_id: 0, alert_id: 0, approval_id: 0 };
|
||||||
|
const BASE_BACKOFF_MS = 1500;
|
||||||
|
const MAX_BACKOFF_MS = 20000;
|
||||||
|
|
||||||
|
const mergeById = <T extends { id: number }>(prev: T[], incoming: T[], limit = 100): T[] => {
|
||||||
|
if (!incoming.length) return prev;
|
||||||
|
const byId = new Map<number, T>();
|
||||||
|
[...prev, ...incoming].forEach((item) => byId.set(item.id, item));
|
||||||
|
return Array.from(byId.values()).sort((a, b) => b.id - a.id).slice(0, limit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSseLines = (raw: string): Array<{ event: string; data: string }> => {
|
||||||
|
return raw
|
||||||
|
.split('\n\n')
|
||||||
|
.map((block) => block.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((block) => {
|
||||||
|
const lines = block.split('\n');
|
||||||
|
const event = (lines.find((line) => line.startsWith('event:')) || 'event: message').replace('event:', '').trim();
|
||||||
|
const data = lines
|
||||||
|
.filter((line) => line.startsWith('data:'))
|
||||||
|
.map((line) => line.replace('data:', '').trim())
|
||||||
|
.join('');
|
||||||
|
return { event, data };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAgentHuddleFeed = () => {
|
||||||
|
const [feed, setFeed] = useState<FeedPayload>({ runs: [], events: [], alerts: [], approvals: [], cursor: DEFAULT_CURSOR });
|
||||||
|
const [connectionMode, setConnectionMode] = useState<'connecting' | 'sse' | 'polling'>('connecting');
|
||||||
|
const [lastHeartbeatAt, setLastHeartbeatAt] = useState<number | null>(null);
|
||||||
|
const stopRef = useRef(false);
|
||||||
|
const reconnectAttemptRef = useRef(0);
|
||||||
|
const cursorRef = useRef<Cursor>(DEFAULT_CURSOR);
|
||||||
|
|
||||||
|
const applyPayload = useCallback((payload: Partial<FeedPayload>, replace = false) => {
|
||||||
|
setFeed((prev) => ({
|
||||||
|
runs: replace ? (payload.runs || []) : mergeById(prev.runs, payload.runs || []),
|
||||||
|
events: replace ? (payload.events || []) : mergeById(prev.events, payload.events || []),
|
||||||
|
alerts: replace ? (payload.alerts || []) : mergeById(prev.alerts, payload.alerts || []),
|
||||||
|
approvals: replace ? (payload.approvals || []) : mergeById(prev.approvals, payload.approvals || []),
|
||||||
|
cursor: {
|
||||||
|
run_id: payload.cursor?.run_id ?? prev.cursor.run_id,
|
||||||
|
event_id: payload.cursor?.event_id ?? prev.cursor.event_id,
|
||||||
|
alert_id: payload.cursor?.alert_id ?? prev.cursor.alert_id,
|
||||||
|
approval_id: payload.cursor?.approval_id ?? prev.cursor.approval_id,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (payload.cursor) {
|
||||||
|
cursorRef.current = {
|
||||||
|
run_id: payload.cursor.run_id ?? cursorRef.current.run_id,
|
||||||
|
event_id: payload.cursor.event_id ?? cursorRef.current.event_id,
|
||||||
|
alert_id: payload.cursor.alert_id ?? cursorRef.current.alert_id,
|
||||||
|
approval_id: payload.cursor.approval_id ?? cursorRef.current.approval_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSnapshot = useCallback(async (cursor?: Cursor) => {
|
||||||
|
const resp = await apiClient.get('/api/agents/huddle/feed', { params: cursor || {} });
|
||||||
|
const data = resp?.data?.data as FeedPayload;
|
||||||
|
applyPayload(data, !cursor);
|
||||||
|
return data;
|
||||||
|
}, [applyPayload]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
stopRef.current = false;
|
||||||
|
let pollingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
setConnectionMode('polling');
|
||||||
|
if (pollingTimer) clearInterval(pollingTimer);
|
||||||
|
pollingTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await loadSnapshot(cursorRef.current);
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}, 7000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = async () => {
|
||||||
|
try {
|
||||||
|
setConnectionMode('connecting');
|
||||||
|
await loadSnapshot();
|
||||||
|
const tokenGetter = getAuthTokenGetter();
|
||||||
|
const token = tokenGetter ? await tokenGetter() : null;
|
||||||
|
if (!token) throw new Error('No auth token available for SSE stream');
|
||||||
|
|
||||||
|
const response = await fetch(`${getApiUrl()}/api/agents/huddle/stream`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok || !response.body) {
|
||||||
|
throw new Error(`SSE stream unavailable (${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectAttemptRef.current = 0;
|
||||||
|
setConnectionMode('sse');
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder('utf-8');
|
||||||
|
let buffer = '';
|
||||||
|
|
||||||
|
while (!stopRef.current) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
throw new Error('SSE stream ended');
|
||||||
|
}
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const chunks = buffer.split('\n\n');
|
||||||
|
buffer = chunks.pop() || '';
|
||||||
|
|
||||||
|
for (const packet of parseSseLines(chunks.join('\n\n'))) {
|
||||||
|
if (!packet.data) continue;
|
||||||
|
if (packet.event === 'heartbeat') {
|
||||||
|
setLastHeartbeatAt(Date.now());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const payload = JSON.parse(packet.data);
|
||||||
|
if (packet.event === 'snapshot') {
|
||||||
|
applyPayload(payload, true);
|
||||||
|
}
|
||||||
|
if (packet.event === 'delta') {
|
||||||
|
applyPayload(payload, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
reconnectAttemptRef.current += 1;
|
||||||
|
if (reconnectAttemptRef.current >= 3) {
|
||||||
|
startPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sleepMs = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * (2 ** reconnectAttemptRef.current));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
||||||
|
if (!stopRef.current) connect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopRef.current = true;
|
||||||
|
if (pollingTimer) clearInterval(pollingTimer);
|
||||||
|
};
|
||||||
|
}, [applyPayload, loadSnapshot]);
|
||||||
|
|
||||||
|
return useMemo(() => ({ ...feed, connectionMode, lastHeartbeatAt }), [feed, connectionMode, lastHeartbeatAt]);
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
<<<<<<< HEAD
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -107,3 +108,70 @@ export default function TeamActivityPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
=======
|
||||||
|
import { Box, Card, CardContent, Chip, Divider, Grid, List, ListItem, ListItemText, Typography } from '@mui/material';
|
||||||
|
import { useAgentHuddleFeed } from '../hooks/useAgentHuddleFeed';
|
||||||
|
|
||||||
|
const TeamActivityPage: React.FC = () => {
|
||||||
|
const { runs, events, alerts, approvals, connectionMode } = useAgentHuddleFeed();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||||
|
<Typography variant="h5" sx={{ fontWeight: 700 }}>Team Activity</Typography>
|
||||||
|
<Chip label={connectionMode === 'sse' ? 'Live stream' : 'Polling fallback'} color={connectionMode === 'sse' ? 'success' : 'warning'} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card><CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Run lifecycle updates</Typography>
|
||||||
|
<List dense>
|
||||||
|
{runs.slice(0, 20).map((run) => (
|
||||||
|
<ListItem key={run.id}><ListItemText primary={`${run.agent_type || 'agent'} · ${run.status}`} secondary={run.result_summary || run.finished_at || 'In progress'} /></ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent></Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card><CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>New events</Typography>
|
||||||
|
<List dense>
|
||||||
|
{events.slice(0, 20).map((event) => (
|
||||||
|
<ListItem key={event.id}><ListItemText primary={`${event.agent_type || 'agent'} · ${event.event_type}`} secondary={event.message || event.created_at} /></ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent></Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12}><Divider /></Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card><CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Alert deltas</Typography>
|
||||||
|
<List dense>
|
||||||
|
{alerts.slice(0, 20).map((alert) => (
|
||||||
|
<ListItem key={alert.id}><ListItemText primary={alert.title || 'Alert'} secondary={alert.message} /></ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent></Card>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={12} md={6}>
|
||||||
|
<Card><CardContent>
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>Approval deltas</Typography>
|
||||||
|
<List dense>
|
||||||
|
{approvals.slice(0, 20).map((approval) => (
|
||||||
|
<ListItem key={approval.id}><ListItemText primary={`${approval.action_type || 'Action'} · ${approval.status}`} secondary={approval.created_at} /></ListItem>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</CardContent></Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TeamActivityPage;
|
||||||
|
>>>>>>> pr-368
|
||||||
|
|||||||
Reference in New Issue
Block a user