diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 000000000..43e26b028 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "trigger": { + "command": "npx", + "args": [ + "trigger.dev@latest", + "mcp", + "--dev-only" + ] + } + } +} \ No newline at end of file diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts index 878377adc..0174f9ace 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/connect-cloud.ts @@ -4,7 +4,7 @@ import { encrypt } from '@/lib/encryption'; import { getIntegrationHandler } from '@comp/integrations'; import { db } from '@db'; import { revalidatePath } from 'next/cache'; -import { headers } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import { z } from 'zod'; import { authActionClient } from '../../../../../actions/safe-action'; import { runTests } from './run-tests'; @@ -100,7 +100,11 @@ export const connectCloudAction = authActionClient } // Trigger immediate scan - await runTests(); + const runResult = await runTests(); + + if (runResult.success && runResult.publicAccessToken) { + (await cookies()).set('publicAccessToken', runResult.publicAccessToken); + } // Revalidate the path const headersList = await headers(); @@ -110,6 +114,13 @@ export const connectCloudAction = authActionClient return { success: true, + trigger: runResult.success + ? { + taskId: runResult.taskId ?? undefined, + publicAccessToken: runResult.publicAccessToken ?? undefined, + } + : undefined, + runErrors: runResult.success ? undefined : (runResult.errors ?? undefined), }; } catch (error) { console.error('Failed to connect cloud provider:', error); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/create-trigger-token.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/create-trigger-token.ts new file mode 100644 index 000000000..6e1dc60db --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/actions/create-trigger-token.ts @@ -0,0 +1,44 @@ +'use server'; + +import { auth as betterAuth } from '@/utils/auth'; +import { auth } from '@trigger.dev/sdk'; +import { headers } from 'next/headers'; + +export const createTriggerToken = async () => { + const session = await betterAuth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + return { + success: false, + error: 'Unauthorized', + }; + } + + const orgId = session.session?.activeOrganizationId; + if (!orgId) { + return { + success: false, + error: 'No active organization', + }; + } + + try { + const token = await auth.createTriggerPublicToken('run-integration-tests', { + multipleUse: true, + expirationTime: '1hr', + }); + + return { + success: true, + token, + }; + } catch (error) { + console.error('Error creating trigger token:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to create trigger token', + }; + } +}; diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx index fb44d3d06..479770906 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/CloudConnectionCard.tsx @@ -18,6 +18,11 @@ interface CloudField { type?: string; } +type TriggerInfo = { + taskId?: string; + publicAccessToken?: string; +}; + interface CloudConnectionCardProps { cloudProvider: 'aws' | 'gcp' | 'azure'; name: string; @@ -27,7 +32,7 @@ interface CloudConnectionCardProps { guideUrl?: string; color?: string; logoUrl?: string; - onSuccess?: () => void; + onSuccess?: (trigger?: TriggerInfo) => void; } export function CloudConnectionCard({ @@ -83,7 +88,11 @@ export function CloudConnectionCard({ if (result?.data?.success) { toast.success(`${name} connected! Running initial scan...`); setCredentials({}); - onSuccess?.(); + onSuccess?.(result.data?.trigger); + + if (result.data?.runErrors && result.data.runErrors.length > 0) { + toast.error(result.data.runErrors[0] || 'Initial scan reported an issue'); + } } else { toast.error(result?.data?.error || 'Failed to connect'); } @@ -99,13 +108,11 @@ export function CloudConnectionCard({
-
+
{logoUrl && ( - {`${shortName} + {`${shortName} )}
diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx index b47a93d72..ca8988a78 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx @@ -117,12 +117,18 @@ const PROVIDER_FIELDS: Record<'aws' | 'gcp' | 'azure', ProviderFieldWithOptions[ ], }; +type TriggerInfo = { + taskId?: string; + publicAccessToken?: string; +}; + interface EmptyStateProps { onBack?: () => void; connectedProviders?: string[]; + onConnected?: (trigger?: TriggerInfo) => void; } -export function EmptyState({ onBack, connectedProviders = [] }: EmptyStateProps = {}) { +export function EmptyState({ onBack, connectedProviders = [], onConnected }: EmptyStateProps = {}) { const [step, setStep] = useState('choose'); const [selectedProvider, setSelectedProvider] = useState(null); const [credentials, setCredentials] = useState>({}); @@ -224,6 +230,12 @@ export function EmptyState({ onBack, connectedProviders = [] }: EmptyStateProps if (result?.data?.success) { setStep('success'); + if (result.data?.trigger) { + onConnected?.(result.data.trigger); + } + if (result.data?.runErrors && result.data.runErrors.length > 0) { + toast.error(result.data.runErrors[0] || 'Initial scan reported an issue'); + } // If user already has clouds, automatically return to results after 2 seconds if (onBack) { setTimeout(() => { diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx index d7a68a281..d8a5c301e 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ResultsView.tsx @@ -2,9 +2,9 @@ import { Button } from '@comp/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import { useRealtimeRun } from '@trigger.dev/react-hooks'; -import { CheckCircle2, Info, Loader2, RefreshCw, X } from 'lucide-react'; -import { useEffect, useState } from 'react'; +import type { AnyRealtimeRun } from '@trigger.dev/sdk'; +import { AlertTriangle, CheckCircle2, Info, Loader2, RefreshCw, X } from 'lucide-react'; +import { useMemo, useState } from 'react'; import { FindingsTable } from './FindingsTable'; interface Finding { @@ -19,75 +19,39 @@ interface Finding { interface ResultsViewProps { findings: Finding[]; - scanTaskId: string | null; - scanAccessToken: string | null; onRunScan: () => Promise; isScanning: boolean; + run: AnyRealtimeRun | undefined; } const severityOrder = { critical: 0, high: 1, medium: 2, low: 3, info: 4 }; -// Helper function to extract clean error messages from cloud provider errors -function extractCleanErrorMessage(errorMessage: string): string { - try { - // Try to parse as JSON (GCP returns JSON blob) - const parsed = JSON.parse(errorMessage); - - // GCP error structure: { error: { message: "actual message" } } - if (parsed.error?.message) { - return parsed.error.message; - } - } catch { - // Not JSON, return original - } - - return errorMessage; -} - -export function ResultsView({ - findings, - scanTaskId, - scanAccessToken, - onRunScan, - isScanning, -}: ResultsViewProps) { - // Track scan status with Trigger.dev hooks - const { run } = useRealtimeRun(scanTaskId || '', { - enabled: !!scanTaskId && !!scanAccessToken, - accessToken: scanAccessToken || undefined, - }); - - const scanCompleted = run?.status === 'COMPLETED'; - const scanFailed = - run?.status === 'FAILED' || run?.status === 'CRASHED' || run?.status === 'SYSTEM_FAILURE'; +export function ResultsView({ findings, onRunScan, isScanning, run }: ResultsViewProps) { const [selectedStatus, setSelectedStatus] = useState('all'); const [selectedSeverity, setSelectedSeverity] = useState('all'); - const [showSuccessBanner, setShowSuccessBanner] = useState(false); - const [showErrorBanner, setShowErrorBanner] = useState(false); - - // Show success banner when scan completes, auto-hide after 5 seconds - useEffect(() => { - if (scanCompleted) { - setShowSuccessBanner(true); - const timer = setTimeout(() => { - setShowSuccessBanner(false); - }, 5000); - return () => clearTimeout(timer); - } - }, [scanCompleted]); - - // Auto-dismiss error banner after 30 seconds - useEffect(() => { - if (scanFailed) { - setShowErrorBanner(true); - const timer = setTimeout(() => { - setShowErrorBanner(false); - }, 30000); - return () => clearTimeout(timer); - } - }, [scanFailed]); - - // Get unique statuses and severities + + const isCompleted = run?.status === 'COMPLETED'; + const isFailed = + run?.status === 'FAILED' || + run?.status === 'CRASHED' || + run?.status === 'SYSTEM_FAILURE' || + run?.status === 'TIMED_OUT' || + run?.status === 'CANCELED'; + + const runOutput = + run?.output && typeof run.output === 'object' && 'success' in run.output + ? (run.output as { + success: boolean; + errors?: string[]; + failedIntegrations?: Array<{ name: string; error: string }>; + }) + : null; + + const hasOutputErrors = runOutput && !runOutput.success; + const outputErrorMessages = hasOutputErrors + ? (runOutput.errors ?? runOutput.failedIntegrations?.map((i) => `${i.name}: ${i.error}`) ?? []) + : []; + const uniqueStatuses = Array.from( new Set(findings.map((f) => f.status).filter(Boolean) as string[]), ); @@ -95,27 +59,28 @@ export function ResultsView({ new Set(findings.map((f) => f.severity).filter(Boolean) as string[]), ); - // Filter findings const filteredFindings = findings.filter((finding) => { const matchesStatus = selectedStatus === 'all' || finding.status === selectedStatus; const matchesSeverity = selectedSeverity === 'all' || finding.severity === selectedSeverity; return matchesStatus && matchesSeverity; }); - // Sort findings by severity (always) - const sortedFindings = [...filteredFindings].sort((a, b) => { - const severityA = a.severity - ? (severityOrder[a.severity.toLowerCase() as keyof typeof severityOrder] ?? 999) - : 999; - const severityB = b.severity - ? (severityOrder[b.severity.toLowerCase() as keyof typeof severityOrder] ?? 999) - : 999; - return severityA - severityB; - }); + const sortedFindings = useMemo( + () => + [...filteredFindings].sort((a, b) => { + const severityA = a.severity + ? (severityOrder[a.severity.toLowerCase() as keyof typeof severityOrder] ?? 999) + : 999; + const severityB = b.severity + ? (severityOrder[b.severity.toLowerCase() as keyof typeof severityOrder] ?? 999) + : 999; + return severityA - severityB; + }), + [filteredFindings], + ); return (
- {/* Scan Status Banner */} {isScanning && (
@@ -128,58 +93,62 @@ export function ResultsView({
)} - {showSuccessBanner && scanCompleted && !isScanning && ( + {isCompleted && !isScanning && !hasOutputErrors && (

Scan completed

Results updated successfully

-
)} - {/* Propagation delay info banner - only when scan succeeds but returns empty output */} - {scanCompleted && findings.length === 0 && !isScanning && !scanFailed && ( -
- -
-

Initial scan complete

-

- Security findings may take 24-48 hours to appear after enabling cloud security services. Check back later. -

+ {hasOutputErrors && !isScanning && ( +
+ +
+

Scan completed with errors

+
    + {outputErrorMessages.slice(0, 5).map((message, index) => ( +
  • • {message}
  • + ))} + {outputErrorMessages.length === 0 && ( +
  • Encountered an unknown error while processing integration results.
  • + )} +
)} - {showErrorBanner && scanFailed && !isScanning && ( + {isFailed && !isScanning && (

Scan failed

- {extractCleanErrorMessage(run?.error?.message || 'An error occurred during the scan. Please try again.')} + {typeof run?.error === 'object' && run.error && 'message' in run.error + ? String(run.error.message) + : 'An error occurred during the scan. Please try again.'} +

+
+
+ )} + + {isCompleted && findings.length === 0 && !isScanning && !hasOutputErrors && ( +
+ +
+

+ Initial scan complete +

+

+ Security findings may take 24-48 hours to appear after enabling cloud security + services. Check back later.

-
)} - {/* Filters and Run Scan Button */}
{findings.length > 0 ? (
@@ -227,7 +196,6 @@ export function ResultsView({
- {/* Results Table */} {sortedFindings.length > 0 ? ( ) : findings.length > 0 ? ( diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx index 7928547de..70ff646b7 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx @@ -1,15 +1,15 @@ 'use client'; +import type { runIntegrationTests } from '@/jobs/tasks/integration/run-integration-tests'; import { Button } from '@comp/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs'; import { Integration } from '@db'; -import { useRealtimeRun } from '@trigger.dev/react-hooks'; +import { useRealtimeTaskTrigger } from '@trigger.dev/react-hooks'; import { Plus, Settings } from 'lucide-react'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import useSWR from 'swr'; -import { runTests } from '../actions/run-tests'; -import { ChatPlaceholder } from './ChatPlaceholder'; +import type { IntegrationRunOutput } from '../types'; import { CloudSettingsModal } from './CloudSettingsModal'; import { EmptyState } from './EmptyState'; import { ResultsView } from './ResultsView'; @@ -30,74 +30,130 @@ interface Finding { interface TestsLayoutProps { initialFindings: Finding[]; initialProviders: Integration[]; + triggerToken: string; + orgId: string; } -const fetcher = async (url: string) => { - const res = await fetch(url); - if (!res.ok) throw new Error('Failed to fetch'); - return res.json(); +type SupportedProviderId = 'aws' | 'gcp' | 'azure'; +type SupportedIntegration = Integration & { integrationId: SupportedProviderId }; + +const SUPPORTED_PROVIDER_IDS: readonly SupportedProviderId[] = ['aws', 'gcp', 'azure']; + +const isSupportedProviderId = (id: string): id is SupportedProviderId => + SUPPORTED_PROVIDER_IDS.includes(id as SupportedProviderId); + +const isIntegrationRunOutput = (value: unknown): value is IntegrationRunOutput => { + if (typeof value !== 'object' || value === null) { + return false; + } + return typeof (value as { success?: unknown }).success === 'boolean'; }; -export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutProps) { - const [scanTaskId, setScanTaskId] = useState(null); - const [scanAccessToken, setScanAccessToken] = useState(null); +export function TestsLayout({ + initialFindings, + initialProviders, + triggerToken, + orgId, +}: TestsLayoutProps) { const [showSettings, setShowSettings] = useState(false); const [viewingResults, setViewingResults] = useState(true); - // Use SWR for real-time updates const { data: findings = initialFindings, mutate: mutateFindings } = useSWR( '/api/cloud-tests/findings', - fetcher, + async (url) => { + const res = await fetch(url); + if (!res.ok) throw new Error('Failed to fetch'); + return res.json(); + }, { fallbackData: initialFindings, - refreshInterval: 5000, // Refresh every 5 seconds when scanning + refreshInterval: 5000, revalidateOnFocus: true, }, ); const { data: providers = initialProviders, mutate: mutateProviders } = useSWR( '/api/cloud-tests/providers', - fetcher, + async (url) => { + const res = await fetch(url); + if (!res.ok) throw new Error('Failed to fetch'); + return res.json(); + }, { fallbackData: initialProviders, revalidateOnFocus: true, }, ); - const connectedProviders = (providers || []).filter((p) => - ['aws', 'gcp', 'azure'].includes(p.integrationId), + const connectedProviders = providers.filter((p): p is SupportedIntegration => + isSupportedProviderId(p.integrationId), + ); + + const { submit, run, error, isLoading } = useRealtimeTaskTrigger( + 'run-integration-tests', + { + accessToken: triggerToken, + }, ); - // Track scan run status with access token - const { run: scanRun } = useRealtimeRun(scanTaskId || '', { - enabled: !!scanTaskId && !!scanAccessToken, - accessToken: scanAccessToken || undefined, - }); + const isCompleted = run?.status === 'COMPLETED'; + const isFailed = + run?.status === 'FAILED' || + run?.status === 'CRASHED' || + run?.status === 'SYSTEM_FAILURE' || + run?.status === 'TIMED_OUT' || + run?.status === 'CANCELED' || + run?.status === 'EXPIRED'; + + const isTerminal = isCompleted || isFailed; + const isScanning = Boolean(run && !isTerminal) || isLoading; + + const runOutput = isCompleted && isIntegrationRunOutput(run?.output) ? run.output : null; - // Auto-refresh findings when scan completes useEffect(() => { - if (scanRun?.status === 'COMPLETED') { - mutateFindings(); + if (!run || !isTerminal) { + return; + } + + void mutateFindings(); + + if (runOutput && !runOutput.success) { + const errorMessage = + runOutput.errors?.[0] ?? + runOutput.failedIntegrations?.[0]?.error ?? + 'Scan completed with errors'; + toast.error(errorMessage); + return; + } + + if (isFailed || run.error) { + const errorMessage = + typeof run.error === 'object' && run.error && 'message' in run.error + ? String(run.error.message) + : typeof run.error === 'string' + ? run.error + : 'Scan failed. Please try again.'; + toast.error(errorMessage); + return; + } + + if (isCompleted) { toast.success('Scan completed! Results updated.'); - } else if (scanRun?.status === 'FAILED' || scanRun?.status === 'CRASHED') { - toast.error('Scan failed. Please try again.'); } - }, [scanRun?.status, mutateFindings]); + }, [run, isTerminal, isFailed, isCompleted, runOutput, mutateFindings]); const handleRunScan = async (): Promise => { + if (!orgId) { + toast.error('No active organization'); + return null; + } + try { - const result = await runTests(); - if (result.success && result.taskId && result.publicAccessToken) { - setScanTaskId(result.taskId); - setScanAccessToken(result.publicAccessToken); - toast.success('Scan started! Checking your cloud infrastructure...'); - return result.taskId; - } else { - toast.error(result.errors?.[0] || 'Failed to start scan'); - return null; - } + await submit({ organizationId: orgId }); + toast.message('Scan started. Checking your cloud infrastructure...'); + return run?.id || null; } catch (error) { - console.error(error); + console.error('🚀 Submit error:', error); toast.error('Failed to start scan. Please try again.'); return null; } @@ -109,37 +165,41 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr setViewingResults(true); }; - // First-time user: No clouds connected OR user wants to add a cloud + const handleCloudConnected = async () => { + mutateProviders(); + mutateFindings(); + + if (orgId) { + await submit({ organizationId: orgId }); + toast.message('Scan started. Checking your cloud infrastructure...'); + } + + setViewingResults(true); + }; + if (connectedProviders.length === 0 || !viewingResults) { return ( 0 ? () => setViewingResults(true) : undefined} connectedProviders={connectedProviders.map((p) => p.integrationId)} + onConnected={handleCloudConnected} /> ); } - // Group findings by cloud provider - const findingsByProvider = (findings || []).reduce( - (acc, finding) => { - const provider = finding.integration.integrationId; - if (!acc[provider]) { - acc[provider] = []; - } - acc[provider].push(finding); - return acc; - }, - {} as Record, - ); + const findingsByProvider = findings.reduce>((acc, finding) => { + const bucket = acc[finding.integration.integrationId] ?? []; + bucket.push(finding); + acc[finding.integration.integrationId] = bucket; + return acc; + }, {}); - // Single cloud user (primary use case) if (connectedProviders.length === 1) { const provider = connectedProviders[0]; - const providerFindings = findingsByProvider[provider.integrationId] || []; + const providerFindings = findingsByProvider[provider.integrationId] ?? []; return (
- {/* Header */}

Cloud Security Tests

@@ -154,7 +214,7 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr )}
- {connectedProviders.length < 3 && ( + {connectedProviders.length < SUPPORTED_PROVIDER_IDS.length && (
- {/* Split-screen layout: Results (70%) + Chat placeholder (30%) */} -
-
- -
- {/*
- -
*/} -
+ - {/* Settings Modal */} ({ - id: p.integrationId as 'aws' | 'gcp' | 'azure', + id: p.integrationId, name: p.name, fields: getProviderFields(p.integrationId), }))} @@ -204,12 +247,10 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr ); } - // Multi-cloud user (<5%) - const defaultTab = connectedProviders[0]?.integrationId || 'aws'; + const defaultTab = connectedProviders[0]?.integrationId ?? 'aws'; return (
- {/* Header */}

Cloud Security Tests

@@ -223,7 +264,7 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr )}
- {connectedProviders.length < 3 && ( + {connectedProviders.length < SUPPORTED_PROVIDER_IDS.length && (
- {/* Tabs for multiple clouds */} {connectedProviders.map((provider) => ( @@ -246,7 +286,7 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr {connectedProviders.map((provider) => { - const providerFindings = findingsByProvider[provider.integrationId] || []; + const providerFindings = findingsByProvider[provider.integrationId] ?? []; return ( - {/* Split-screen layout */} -
-
- -
-
- -
-
+
); })}
- {/* Settings Modal */} ({ - id: p.integrationId as 'aws' | 'gcp' | 'azure', + id: p.integrationId, name: p.name, fields: getProviderFields(p.integrationId), }))} @@ -296,8 +319,7 @@ export function TestsLayout({ initialFindings, initialProviders }: TestsLayoutPr ); } -// Helper function to get provider fields for settings modal -function getProviderFields(providerId: string) { +function getProviderFields(providerId: SupportedProviderId) { switch (providerId) { case 'aws': return [ @@ -317,7 +339,5 @@ function getProviderFields(providerId: string) { { id: 'AZURE_CLIENT_SECRET', label: 'Client Secret' }, { id: 'AZURE_SUBSCRIPTION_ID', label: 'Subscription ID' }, ]; - default: - return []; } } diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/page.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/page.tsx index 1f3e47b85..41fc245d1 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/page.tsx @@ -1,4 +1,5 @@ -import { auth } from '@/utils/auth'; +import { auth as betterAuth } from '@/utils/auth'; +import { auth } from '@trigger.dev/sdk'; import { db } from '@db'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; @@ -6,7 +7,7 @@ import { TestsLayout } from './components/TestsLayout'; export default async function CloudTestsPage({ params }: { params: Promise<{ orgId: string }> }) { const { orgId } = await params; - const session = await auth.api.getSession({ + const session = await betterAuth.api.getSession({ headers: await headers(), }); @@ -59,5 +60,17 @@ export default async function CloudTestsPage({ params }: { params: Promise<{ org }, })) || []; - return ; + const triggerToken = await auth.createTriggerPublicToken('run-integration-tests', { + multipleUse: true, + expirationTime: '1hr', + }); + + return ( + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/status.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/status.ts new file mode 100644 index 000000000..1f763722b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/status.ts @@ -0,0 +1,74 @@ +export type RunStatus = + | 'WAITING_FOR_DEPLOY' + | 'QUEUED' + | 'EXECUTING' + | 'REATTEMPTING' + | 'FROZEN' + | 'COMPLETED' + | 'CANCELED' + | 'FAILED' + | 'CRASHED' + | 'INTERRUPTED' + | 'SYSTEM_FAILURE' + | 'DELAYED' + | 'EXPIRED' + | 'TIMED_OUT'; + +const RUN_STATUS_VALUES_INTERNAL: readonly RunStatus[] = [ + 'WAITING_FOR_DEPLOY', + 'QUEUED', + 'EXECUTING', + 'REATTEMPTING', + 'FROZEN', + 'COMPLETED', + 'CANCELED', + 'FAILED', + 'CRASHED', + 'INTERRUPTED', + 'SYSTEM_FAILURE', + 'DELAYED', + 'EXPIRED', + 'TIMED_OUT', +]; + +export const RUN_STATUS_VALUES = RUN_STATUS_VALUES_INTERNAL; + +const KNOWN_STATUS_SET: ReadonlySet = new Set(RUN_STATUS_VALUES_INTERNAL); + +const TERMINAL_STATUS_SET: ReadonlySet = new Set([ + 'COMPLETED', + 'CANCELED', + 'FAILED', + 'CRASHED', + 'SYSTEM_FAILURE', + 'INTERRUPTED', + 'EXPIRED', + 'TIMED_OUT', +]); + +const SUCCESS_STATUS_SET: ReadonlySet = new Set(['COMPLETED']); + +const FAILURE_STATUS_SET: ReadonlySet = new Set([ + 'FAILED', + 'CRASHED', + 'SYSTEM_FAILURE', + 'TIMED_OUT', + 'EXPIRED', + 'INTERRUPTED', + 'CANCELED', +]); + +export const isRunStatus = (status: unknown): status is RunStatus => + typeof status === 'string' && KNOWN_STATUS_SET.has(status as RunStatus); + +export const isTerminalRunStatus = (status: unknown): status is RunStatus => + isRunStatus(status) && TERMINAL_STATUS_SET.has(status); + +export const isActiveRunStatus = (status: unknown): status is RunStatus => + isRunStatus(status) && !TERMINAL_STATUS_SET.has(status); + +export const isSuccessfulRunStatus = (status: unknown): status is RunStatus => + isRunStatus(status) && SUCCESS_STATUS_SET.has(status); + +export const isFailureRunStatus = (status: unknown): status is RunStatus => + isRunStatus(status) && FAILURE_STATUS_SET.has(status); diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/types.ts b/apps/app/src/app/(app)/[orgId]/cloud-tests/types.ts new file mode 100644 index 000000000..345c3af4e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/types.ts @@ -0,0 +1,17 @@ +export type FailedIntegration = { + id: string; + integrationId: string; + name: string; + error: string; +}; + +export type IntegrationRunOutput = { + success: boolean; + organizationId: string; + integrationsCount: number; + batchHandleId?: string; + errors?: string[]; + failedIntegrations?: FailedIntegration[]; +}; + + diff --git a/apps/app/src/jobs/tasks/integration/run-integration-tests.ts b/apps/app/src/jobs/tasks/integration/run-integration-tests.ts index 989061546..1b5fcecc8 100644 --- a/apps/app/src/jobs/tasks/integration/run-integration-tests.ts +++ b/apps/app/src/jobs/tasks/integration/run-integration-tests.ts @@ -4,8 +4,8 @@ import { sendIntegrationResults } from './integration-results'; export const runIntegrationTests = task({ id: 'run-integration-tests', - run: async (payload: { organizationId: string }) => { - const { organizationId } = payload; + run: async (payload: { organizationId: string; forceFailure?: boolean }) => { + const { organizationId, forceFailure = false } = payload; logger.info(`Running integration tests for organization: ${organizationId}`); @@ -40,7 +40,35 @@ export const runIntegrationTests = task({ }; } - logger.info(`Found ${integrations.length} integrations to test for organization: ${organizationId}`); + logger.info( + `Found ${integrations.length} integrations to test for organization: ${organizationId}`, + ); + + if (forceFailure) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + + const forcedFailureMessage = + 'Test failure: intentionally failing integration job for UI verification'; + + const forcedFailedIntegrations = integrations.map((integration) => ({ + id: integration.id, + integrationId: integration.integrationId, + name: integration.name, + error: forcedFailureMessage, + })); + + logger.warn( + `Force-failing integration tests for organization ${organizationId}: ${forcedFailureMessage}`, + ); + + return { + success: false, + organizationId, + integrationsCount: integrations.length, + errors: [forcedFailureMessage], + failedIntegrations: forcedFailedIntegrations, + }; + } const batchItems = integrations.map((integration) => ({ payload: { @@ -58,22 +86,51 @@ export const runIntegrationTests = task({ try { const batchHandle = await sendIntegrationResults.batchTriggerAndWait(batchItems); - // Check if any child runs failed - const failedRuns = batchHandle.runs.filter((run) => !run.ok); + const failedIntegrations: Array<{ + id: string; + integrationId: string; + name: string; + error: string; + }> = []; + + batchHandle.runs.forEach((run, index) => { + if (run.ok) { + return; + } - if (failedRuns.length > 0) { - const errorMessages = failedRuns - .map((run) => { - const errorMsg = run.error instanceof Error ? run.error.message : String(run.error); - return errorMsg; - }) - .join('; '); + const integration = integrations[index]; + const errorValue = run.error; + const errorMessage = + errorValue instanceof Error ? errorValue.message : String(errorValue ?? 'Unknown error'); - logger.error(`Integration tests failed for organization ${organizationId}: ${errorMessages}`); - throw new Error(errorMessages); + failedIntegrations.push({ + id: integration.id, + integrationId: integration.integrationId, + name: integration.name, + error: errorMessage, + }); + }); + + if (failedIntegrations.length > 0) { + const errorMessages = failedIntegrations.map(({ error }) => error).join('; '); + + logger.warn( + `Integration tests completed with errors for organization ${organizationId}: ${errorMessages}`, + ); + + return { + success: false, + organizationId, + integrationsCount: integrations.length, + batchHandleId: batchHandle.id, + errors: failedIntegrations.map(({ error }) => error), + failedIntegrations, + }; } - logger.info(`Successfully completed batch integration tests for organization: ${organizationId}`); + logger.info( + `Successfully completed batch integration tests for organization: ${organizationId}`, + ); return { success: true, @@ -82,8 +139,18 @@ export const runIntegrationTests = task({ batchHandleId: batchHandle.id, }; } catch (error) { - logger.error(`Failed to run integration tests for organization ${organizationId}: ${error}`); - throw error; + const errorMessage = error instanceof Error ? error.message : String(error); + + logger.error( + `Failed to run integration tests for organization ${organizationId}: ${errorMessage}`, + ); + + return { + success: false, + organizationId, + integrationsCount: integrations.length, + errors: [errorMessage], + }; } }, }); diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx index a362ec4db..d3f4a5006 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx +++ b/apps/portal/src/app/(app)/(home)/[orgId]/components/tasks/DeviceAgentAccordionItem.tsx @@ -6,8 +6,9 @@ import { Button } from '@comp/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; import { cn } from '@comp/ui/cn'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; import type { Member } from '@db'; -import { CheckCircle2, Circle, Download, Loader2, XCircle } from 'lucide-react'; +import { CheckCircle2, Circle, Download, HelpCircle, Loader2, XCircle } from 'lucide-react'; import Image from 'next/image'; import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -32,10 +33,20 @@ export function DeviceAgentAccordionItem({ [detectedOS], ); + const mdmEnabledStatus = useMemo(() => { + return { + id: 'mdm', + response: host?.mdm.enrollment_status === 'On' ? 'pass' : 'fail', + name: 'MDM Enabled', + }; + }, [host]); + const hasInstalledAgent = host !== null; - const allPoliciesPass = - fleetPolicies.length === 0 || fleetPolicies.every((policy) => policy.response === 'pass'); - const isCompleted = hasInstalledAgent && allPoliciesPass; + const failedPoliciesCount = useMemo(() => { + return fleetPolicies.filter((policy) => policy.response !== 'pass').length + (!isMacOS || mdmEnabledStatus.response === 'pass' ? 0 : 1); + }, [fleetPolicies, mdmEnabledStatus, isMacOS]); + + const isCompleted = hasInstalledAgent && failedPoliciesCount === 0; const handleDownload = async () => { setIsDownloading(true); @@ -130,9 +141,9 @@ export function DeviceAgentAccordionItem({ Download and install Comp AI Device Agent - {hasInstalledAgent && !allPoliciesPass && ( + {hasInstalledAgent && failedPoliciesCount > 0 && ( - {fleetPolicies.filter((p) => p.response !== 'pass').length} policies failing + {failedPoliciesCount} policies failing )}
@@ -245,28 +256,81 @@ export function DeviceAgentAccordionItem({ {fleetPolicies.length > 0 ? ( - fleetPolicies.map((policy) => ( -
-

{policy.name}

- {policy.response === 'pass' ? ( -
- - Pass -
- ) : ( -
- - Fail + <> + {fleetPolicies.map((policy) => ( +
+

{policy.name}

+ {policy.response === 'pass' ? ( +
+ + Pass +
+ ) : ( +
+ + Fail +
+ )} +
+ ))} + {isMacOS && ( +
+
+

{mdmEnabledStatus.name}

+ {mdmEnabledStatus.response === 'fail' && host?.id && ( + + + + + + +

+ There are additional steps required to enable MDM. Please check{' '} + + this documentation + + . +

+
+
+
+ )}
- )} -
- )) + {mdmEnabledStatus.response === 'pass' ? ( +
+ + Pass +
+ ) : ( +
+ + Fail +
+ )} +
+ )} + ) : (

No policies configured for this device. diff --git a/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts b/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts index 4a8541205..ea337dc9b 100644 --- a/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts +++ b/apps/portal/src/app/(app)/(home)/[orgId]/types/index.ts @@ -66,7 +66,7 @@ export interface Host { gigs_total_disk_space: number; disk_encryption_enabled: boolean; issues: object; - mdm: object; + mdm: MDM; refetch_critical_queries_until: string | null; last_restarted_at: string; policies: FleetPolicy[]; @@ -80,3 +80,12 @@ export interface Host { display_text: string; display_name: string; } + +export type MDM = { + connected_to_fleet: boolean; + dep_profile_error: boolean; + encryption_key_available: boolean; + enrollment_status: "Off" | "On"; + name?: string; + server_url?: string; +}; \ No newline at end of file