diff --git a/apps/api/src/admin-organizations/admin-organizations.controller.ts b/apps/api/src/admin-organizations/admin-organizations.controller.ts index 6f51e9fcae..523a6a4742 100644 --- a/apps/api/src/admin-organizations/admin-organizations.controller.ts +++ b/apps/api/src/admin-organizations/admin-organizations.controller.ts @@ -42,6 +42,29 @@ export class AdminOrganizationsController { }); } + @Get('activity') + @ApiOperation({ summary: 'Organization activity report - shows last session per org (platform admin)' }) + @ApiQuery({ name: 'inactiveDays', required: false, description: 'Filter orgs with no session in N days (default: 90)' }) + @ApiQuery({ name: 'hasAccess', required: false, description: 'Filter by hasAccess (true/false)' }) + @ApiQuery({ name: 'onboarded', required: false, description: 'Filter by onboardingCompleted (true/false)' }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'limit', required: false }) + async activity( + @Query('inactiveDays') inactiveDays?: string, + @Query('hasAccess') hasAccess?: string, + @Query('onboarded') onboarded?: string, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.service.getOrgActivity({ + inactiveDays: Math.max(0, Number.isFinite(parseInt(inactiveDays ?? '90', 10)) ? parseInt(inactiveDays ?? '90', 10) : 90), + hasAccess: hasAccess === 'true' ? true : hasAccess === 'false' ? false : undefined, + onboarded: onboarded === 'true' ? true : onboarded === 'false' ? false : undefined, + page: Math.max(1, parseInt(page || '1', 10) || 1), + limit: Math.min(100, Math.max(1, parseInt(limit || '50', 10) || 50)), + }); + } + @Get(':id') @ApiOperation({ summary: 'Get organization details (platform admin)' }) async get(@Param('id') id: string) { diff --git a/apps/api/src/admin-organizations/admin-organizations.service.ts b/apps/api/src/admin-organizations/admin-organizations.service.ts index 57dbf78ca5..46a3239de4 100644 --- a/apps/api/src/admin-organizations/admin-organizations.service.ts +++ b/apps/api/src/admin-organizations/admin-organizations.service.ts @@ -100,6 +100,119 @@ export class AdminOrganizationsService { }; } + async getOrgActivity(options: { + inactiveDays: number; + hasAccess?: boolean; + onboarded?: boolean; + page: number; + limit: number; + }) { + const { inactiveDays, hasAccess, onboarded, page, limit } = options; + const skip = (page - 1) * limit; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - inactiveDays); + + const where: Record = {}; + if (hasAccess !== undefined) where.hasAccess = hasAccess; + if (onboarded !== undefined) where.onboardingCompleted = onboarded; + + const [organizations, total] = await Promise.all([ + db.organization.findMany({ + where, + select: { + id: true, + name: true, + createdAt: true, + hasAccess: true, + onboardingCompleted: true, + _count: { select: { members: true, tasks: true, policy: true, auditLog: true } }, + members: { + where: { deactivated: false }, + select: { + role: true, + user: { + select: { + id: true, + name: true, + email: true, + sessions: { + orderBy: { updatedAt: 'desc' as const }, + take: 1, + select: { updatedAt: true }, + }, + }, + }, + }, + }, + auditLog: { + orderBy: { timestamp: 'desc' as const }, + take: 1, + select: { timestamp: true }, + }, + }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + db.organization.count({ where }), + ]); + + // Post-process to find last activity per org + const data = organizations.map((org) => { + let lastSession: Date | null = null; + let owner: { id: string; name: string; email: string } | null = null; + + for (const member of org.members) { + const sess = member.user?.sessions?.[0]?.updatedAt; + if (sess && (!lastSession || sess > lastSession)) { + lastSession = sess; + } + if (member.role?.includes('owner') && !owner) { + owner = { id: member.user.id, name: member.user.name, email: member.user.email }; + } + } + + const lastAuditLog = org.auditLog?.[0]?.timestamp ?? null; + const lastActivity = [lastSession, lastAuditLog] + .filter(Boolean) + .sort((a, b) => (b as Date).getTime() - (a as Date).getTime())[0] as Date | undefined; + + const isActive = lastActivity ? lastActivity >= cutoff : false; + + return { + id: org.id, + name: org.name, + createdAt: org.createdAt, + hasAccess: org.hasAccess, + onboardingCompleted: org.onboardingCompleted, + memberCount: org._count.members, + taskCount: org._count.tasks, + policyCount: org._count.policy, + auditLogCount: org._count.auditLog, + owner, + lastSession: lastSession?.toISOString() ?? null, + lastAuditLog: lastAuditLog ? (lastAuditLog as Date).toISOString() : null, + lastActivity: lastActivity?.toISOString() ?? null, + isActive, + }; + }); + + const activeCount = data.filter((d) => d.isActive).length; + const inactiveCount = data.filter((d) => !d.isActive).length; + + return { + data, + total, + page, + limit, + summary: { + inactiveDays, + activeInPage: activeCount, + inactiveInPage: inactiveCount, + }, + }; + } + async getOrganization(id: string) { const org = await db.organization.findUnique({ where: { id }, diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts index dc6a7176b8..c4c63d8dd4 100644 --- a/apps/api/src/browserbase/browserbase.service.ts +++ b/apps/api/src/browserbase/browserbase.service.ts @@ -15,6 +15,10 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; const BROWSER_WIDTH = 1440; const BROWSER_HEIGHT = 900; +/** Stagehand v3 requires 'provider/model' format. */ +const STAGEHAND_MODEL = 'anthropic/claude-sonnet-4-6'; +const STAGEHAND_CUA_MODEL = 'anthropic/claude-sonnet-4-6'; + const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const PENDING_CONTEXT_ID = '__PENDING__'; @@ -205,7 +209,7 @@ export class BrowserbaseService { projectId: this.getProjectId(), browserbaseSessionID: sessionId, model: { - modelName: 'claude-3-7-sonnet-latest', + modelName: STAGEHAND_MODEL, apiKey: process.env.ANTHROPIC_API_KEY, }, verbose: 1, @@ -784,7 +788,7 @@ export class BrowserbaseService { .agent({ cua: true, model: { - modelName: 'anthropic/claude-3-7-sonnet-latest', + modelName: STAGEHAND_CUA_MODEL, apiKey: process.env.ANTHROPIC_API_KEY, }, }) diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx index 821c7d425a..fd529b5cff 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/BrowserAutomations.tsx @@ -130,6 +130,8 @@ export function BrowserAutomations({ taskId, isManualTask = false }: BrowserAuto onRun={execution.runAutomation} onCreateClick={isManualTask ? undefined : () => setDialogState({ open: true, mode: 'create' })} onEditClick={(automation) => setDialogState({ open: true, mode: 'edit', automation })} + onDelete={automations.deleteAutomation} + onToggleEnabled={automations.toggleAutomation} /> void; onRun: () => void; onEdit: () => void; + onDelete: () => void; + onToggleEnabled: (enabled: boolean) => void; } export function AutomationItem({ @@ -25,23 +36,30 @@ export function AutomationItem({ onToggleExpand, onRun, onEdit, + onDelete, + onToggleEnabled, }: AutomationItemProps) { + const [confirmDelete, setConfirmDelete] = useState(false); const runs: BrowserAutomationRun[] = automation.runs || []; const latestRun = runs[0]; // status dot const hasFailed = latestRun?.status === 'failed'; const isCompleted = latestRun?.status === 'completed'; - const dotColor = hasFailed - ? 'bg-destructive shadow-[0_0_8px_rgba(255,0,0,0.3)]' - : isCompleted - ? 'bg-primary shadow-[0_0_8px_rgba(0,77,64,0.4)]' - : 'bg-muted-foreground'; + const isDisabled = !automation.isEnabled; + const dotColor = isDisabled + ? 'bg-muted-foreground/40' + : hasFailed + ? 'bg-destructive shadow-[0_0_8px_rgba(255,0,0,0.3)]' + : isCompleted + ? 'bg-primary shadow-[0_0_8px_rgba(0,77,64,0.4)]' + : 'bg-muted-foreground'; return (
-

- {automation.name} -

+
+

+ {automation.name} +

+ {isDisabled && ( + + Paused + + )} +
{latestRun ? (

Last ran {formatDistanceToNow(new Date(latestRun.createdAt), { addSuffix: true })} @@ -63,15 +88,75 @@ export function AutomationItem({ )}

-
+
{!readOnly && ( - )} {!readOnly && ( - + )} + + {!readOnly && ( + confirmDelete ? ( +
+ + +
+ ) : ( + + ) + )} + + {!readOnly && ( +