From e6c2989c647518c2fa1bc132b0e15511e2ec1de8 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 24 May 2026 22:38:26 -0500 Subject: [PATCH] main dashboard --- backend/app/api/activity.py | 59 ++++- backend/app/schemas/activity_events.py | 9 + frontend/src/app/dashboard/page.tsx | 106 +++++--- .../app/git-projects/repositories/page.tsx | 92 ++++++- frontend/src/app/globals.css | 11 + .../dashboard/DashboardInfoBlock.tsx | 41 +++- .../dashboard/DashboardMetricCard.tsx | 19 +- .../components/dashboard/DashboardSection.tsx | 37 ++- .../dashboard/PendingApprovalsSection.tsx | 9 +- .../dashboard/RecentActivitySection.tsx | 45 +++- .../components/dashboard/SessionsSection.tsx | 28 ++- frontend/src/components/dashboard/tokens.ts | 67 ++++-- .../src/components/git/ForgejoHeatmap.tsx | 10 +- .../git/ForgejoRepositoriesTable.tsx | 226 +++++++++++++++--- .../organisms/AgentActivityTicker.tsx | 92 +++++++ .../components/templates/DashboardShell.tsx | 2 + 16 files changed, 728 insertions(+), 125 deletions(-) create mode 100644 frontend/src/components/organisms/AgentActivityTicker.tsx diff --git a/backend/app/api/activity.py b/backend/app/api/activity.py index 190eac0..12b40af 100644 --- a/backend/app/api/activity.py +++ b/backend/app/api/activity.py @@ -22,7 +22,7 @@ from app.models.activity_events import ActivityEvent from app.models.agents import Agent from app.models.boards import Board from app.models.tasks import Task -from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead +from app.schemas.activity_events import ActivityEventRead, ActivityTaskCommentFeedItemRead, ActivityTickerItem from app.schemas.pagination import DefaultLimitOffsetPage from app.services.organizations import ( OrganizationContext, @@ -242,6 +242,63 @@ async def _fetch_task_comment_events( return _coerce_task_comment_rows(list(await session.exec(statement))) +def _ticker_source(event: ActivityEvent, agent: Agent | None) -> str: + if agent is not None: + return agent.name + parts = event.event_type.replace(".", " ").replace("_", " ").split() + return " ".join(p.capitalize() for p in parts) + + +@router.get("/ticker", response_model=list[ActivityTickerItem]) +async def get_activity_ticker( + limit: int = Query(default=20, ge=1, le=50), + session: AsyncSession = SESSION_DEP, + ctx: OrganizationContext = ORG_MEMBER_DEP, +) -> list[ActivityTickerItem]: + """Return recent activity items shaped for the navbar ticker.""" + board_ids = await list_accessible_board_ids(session, member=ctx.member, write=False) + + statement = ( + select(ActivityEvent, Agent) + .outerjoin(Agent, col(ActivityEvent.agent_id) == col(Agent.id)) + .outerjoin(Task, col(ActivityEvent.task_id) == col(Task.id)) + .where(func.length(func.trim(col(ActivityEvent.message))) > 0) + .order_by(desc(col(ActivityEvent.created_at))) + .limit(limit) + ) + + if board_ids: + statement = statement.where( + or_( + col(ActivityEvent.board_id).in_(board_ids), + and_( + col(ActivityEvent.board_id).is_(None), + col(Task.board_id).in_(board_ids), + ), + ) + ) + else: + statement = statement.where(col(ActivityEvent.id).is_(None)) + + rows = (await session.exec(statement)).all() + items: list[ActivityTickerItem] = [] + for row in rows: + event: ActivityEvent = row[0] + agent: Agent | None = row[1] + msg = (event.message or "").strip() + if not msg: + continue + items.append( + ActivityTickerItem( + id=event.id, + source=_ticker_source(event, agent), + message=msg[:200], + created_at=event.created_at, + ) + ) + return items + + @router.get("", response_model=DefaultLimitOffsetPage[ActivityEventRead]) async def list_activity( session: AsyncSession = SESSION_DEP, diff --git a/backend/app/schemas/activity_events.py b/backend/app/schemas/activity_events.py index 6d5df20..176c5d6 100644 --- a/backend/app/schemas/activity_events.py +++ b/backend/app/schemas/activity_events.py @@ -24,6 +24,15 @@ class ActivityEventRead(SQLModel): created_at: datetime +class ActivityTickerItem(SQLModel): + """Single item returned by the activity ticker endpoint.""" + + id: UUID + source: str + message: str + created_at: datetime + + class ActivityTaskCommentFeedItemRead(SQLModel): """Denormalized task-comment feed item enriched with task and board fields.""" diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index d7dda3c..e3f254b 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -24,7 +24,6 @@ import { ForgejoHeatmap } from "@/components/git/ForgejoHeatmap"; import { DashboardMetricCard, DashboardInfoBlock, - DashboardEmptyState, PendingApprovalsSection, SessionsSection, RecentActivitySection, @@ -435,7 +434,6 @@ const toSessionSummaries = ( }); }; - export default function DashboardPage() { const router = useRouter(); const { isSignedIn } = useAuth(); @@ -520,9 +518,9 @@ export default function DashboardPage() { () => selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES ? null - : forgejoRepositories.find( + : (forgejoRepositories.find( (repository) => repository.id === selectedForgejoRepositoryId, - ) ?? null, + ) ?? null), [forgejoRepositories, selectedForgejoRepositoryId], ); const scopedForgejoRepositories = useMemo( @@ -532,7 +530,11 @@ export default function DashboardPage() { : selectedForgejoRepository ? [selectedForgejoRepository] : [], - [forgejoRepositories, selectedForgejoRepository, selectedForgejoRepositoryId], + [ + forgejoRepositories, + selectedForgejoRepository, + selectedForgejoRepositoryId, + ], ); const forgejoOrganizationId = useMemo( () => @@ -565,24 +567,33 @@ export default function DashboardPage() { isSignedIn && !forgejoRepositoriesQuery.isLoading && !forgejoRepositoriesQuery.error && - ( - selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES - ? forgejoOrganizationId - : selectedForgejoRepository - ), + (selectedForgejoRepositoryId === ALL_FORGEJO_REPOSITORIES + ? forgejoOrganizationId + : selectedForgejoRepository), ), refetchInterval: FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, refetchOnMount: "always", queryFn: () => { if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) { - return getForgejoMetrics({ repository_id: selectedForgejoRepositoryId }); + return getForgejoMetrics({ + repository_id: selectedForgejoRepositoryId, + }); } if (!forgejoOrganizationId) return Promise.resolve(null); return getForgejoMetrics({ organization_id: forgejoOrganizationId }); }, }); - const forgejoHeatmapQuery = useQuery<{ days: ForgejoHeatmapDay[]; max_count: number; total_additions: number; total_deletions: number; has_line_stats: boolean } | null, Error>({ + const forgejoHeatmapQuery = useQuery< + { + days: ForgejoHeatmapDay[]; + max_count: number; + total_additions: number; + total_deletions: number; + has_line_stats: boolean; + } | null, + Error + >({ queryKey: ["dashboard", "forgejo", "heatmap", forgejoOrganizationId], enabled: Boolean( isSignedIn && @@ -591,7 +602,9 @@ export default function DashboardPage() { !forgejoRepositoriesQuery.error, ), refetchInterval: (query) => - query.state.data?.has_line_stats === false ? 3_000 : FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, + query.state.data?.has_line_stats === false + ? 3_000 + : FORGEJO_DASHBOARD_REFETCH_INTERVAL_MS, refetchOnMount: "always", queryFn: () => { if (!forgejoOrganizationId) return Promise.resolve(null); @@ -863,7 +876,8 @@ export default function DashboardPage() { refetchInterval: 60_000, refetchOnMount: "always", queryFn: async () => { - const credentialsRes = await listProviderCredentialsApiV1ProviderCredentialsGet(); + const credentialsRes = + await listProviderCredentialsApiV1ProviderCredentialsGet(); if (credentialsRes.status !== 200) return []; const credentials = credentialsRes.data ?? []; @@ -881,9 +895,8 @@ export default function DashboardPage() { providerId, ), ) - .filter( - (cred): cred is (typeof credentials)[number] => - Boolean(cred?.active && cred.has_session_key), + .filter((cred): cred is (typeof credentials)[number] => + Boolean(cred?.active && cred.has_session_key), ); const settled = await Promise.allSettled( selectedCredentials.map((cred) => @@ -899,7 +912,8 @@ export default function DashboardPage() { const { cred, res } = item.value; if (res.status !== 200) continue; const usage = res.data; - const accountLabel = cred.display_name || cred.account_key || cred.provider; + const accountLabel = + cred.display_name || cred.account_key || cred.provider; const subscriptionWindows = usage.subscription_windows ?? []; for (const window of subscriptionWindows) { windows.push({ @@ -925,7 +939,9 @@ export default function DashboardPage() { confidence: "low", provider: usage.provider, gatewayLabel: accountLabel, - note: usage.error ?? "No subscription usage returned for this session key.", + note: + usage.error ?? + "No subscription usage returned for this session key.", }); } } @@ -935,7 +951,9 @@ export default function DashboardPage() { const statuslineUsageWindows = providerUsageQuery.data ?? []; const statuslineProviders = new Set( statuslineUsageWindows - .filter((window) => window.pctUsed !== null || window.remainingMs !== null) + .filter( + (window) => window.pctUsed !== null || window.remainingMs !== null, + ) .map((window) => window.provider), ); const providerUsageWindows = [ @@ -943,8 +961,8 @@ export default function DashboardPage() { ...(credentialUsageQuery.data ?? []).filter( (window) => !( - window.key.includes("subscription_unavailable") - && statuslineProviders.has(window.provider) + window.key.includes("subscription_unavailable") && + statuslineProviders.has(window.provider) ), ), ]; @@ -958,8 +976,10 @@ export default function DashboardPage() { refetchOnMount: "always", queryFn: () => { if (!primaryGatewayId) return Promise.resolve(null); - return getGatewayHealthApiV1GatewaysGatewayIdHealthGet(primaryGatewayId).then( - (r) => (r.status === 200 ? (r.data as SystemHealthResponse) : null), + return getGatewayHealthApiV1GatewaysGatewayIdHealthGet( + primaryGatewayId, + ).then((r) => + r.status === 200 ? (r.data as SystemHealthResponse) : null, ); }, }); @@ -979,7 +999,10 @@ export default function DashboardPage() { // Build a session-id → TopSession lookup for enriching session summaries const topSessionById = useMemo(() => { - const map = new Map(); + const map = new Map< + string, + { costUsd: number; totalTokens: number; model: string | null } + >(); for (const s of runtimeUsage?.topSessions ?? []) { if (s.session_id) { map.set(s.session_id, { @@ -999,7 +1022,9 @@ export default function DashboardPage() { const sourceLabel = snapshot.gatewayUrl || snapshot.boardName; return toSessionSummaries(snapshot.sessions, snapshot.mainSession).map( (session) => { - const enrichment = topSessionById.get(session.key) ?? topSessionById.get(`${snapshot.gatewayId}:${session.key}`); + const enrichment = + topSessionById.get(session.key) ?? + topSessionById.get(`${snapshot.gatewayId}:${session.key}`); return { ...session, key: `${snapshot.gatewayId}:${session.key}`, @@ -1209,7 +1234,6 @@ export default function DashboardPage() { ]; const pendingApprovalItems = metrics?.pending_approvals.items ?? []; const pendingApprovalsTotal = metrics?.pending_approvals.total ?? 0; - const hasPendingApprovals = pendingApprovalItems.length > 0; const activityFeedHref = "/activity"; const forgejoIssueMetrics = forgejoMetricsQuery.data ?? null; const forgejoIssueMetricsError = @@ -1297,8 +1321,12 @@ export default function DashboardPage() { -
-
+
+