Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
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, Loader2, RefreshCw, X } from 'lucide-react';
import { CheckCircle2, Info, Loader2, RefreshCw, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { FindingsTable } from './FindingsTable';

Expand All @@ -27,6 +27,23 @@ interface ResultsViewProps {

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,
Expand All @@ -46,6 +63,7 @@ export function ResultsView({
const [selectedStatus, setSelectedStatus] = useState<string>('all');
const [selectedSeverity, setSelectedSeverity] = useState<string>('all');
const [showSuccessBanner, setShowSuccessBanner] = useState(false);
const [showErrorBanner, setShowErrorBanner] = useState(false);

// Show success banner when scan completes, auto-hide after 5 seconds
useEffect(() => {
Expand All @@ -58,6 +76,17 @@ export function ResultsView({
}
}, [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 uniqueStatuses = Array.from(
new Set(findings.map((f) => f.status).filter(Boolean) as string[]),
Expand Down Expand Up @@ -117,15 +146,36 @@ export function ResultsView({
</div>
)}

{scanFailed && !isScanning && (
{/* Propagation delay info banner - only when scan succeeds but returns empty output */}
{scanCompleted && findings.length === 0 && !isScanning && !scanFailed && (
<div className="bg-blue-50 dark:bg-blue-950/20 flex items-center gap-3 rounded-lg border border-blue-200 dark:border-blue-900 p-4">
<Info className="text-blue-600 dark:text-blue-400 h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-blue-900 dark:text-blue-100 text-sm font-medium">Initial scan complete</p>
<p className="text-muted-foreground text-xs">
Security findings may take 24-48 hours to appear after enabling cloud security services. Check back later.
</p>
</div>
</div>
)}

{showErrorBanner && scanFailed && !isScanning && (
<div className="bg-destructive/10 flex items-center gap-3 rounded-lg border border-destructive/20 p-4">
<X className="text-destructive h-5 w-5 flex-shrink-0" />
<div className="flex-1">
<p className="text-destructive text-sm font-medium">Scan failed</p>
<p className="text-muted-foreground text-xs">
{run?.error?.message || 'An error occurred during the scan. Please try again.'}
{extractCleanErrorMessage(run?.error?.message || 'An error occurred during the scan. Please try again.')}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowErrorBanner(false)}
className="text-muted-foreground hover:text-foreground h-auto p-1"
>
<X className="h-4 w-4" />
</Button>
</div>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export function PolicyOverview({
toast.info('Regeneration started');
}}
title="Regenerate Policy"
description="This will regenerate the policy content and mark it for review. Continue?"
description="This will regenerate the policy content. Continue?"
confirmText="Regenerate"
confirmIcon={<Icons.AI className="h-4 w-4" />}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
'use server';

import { authActionClient } from '@/actions/safe-action';
import { generateFullPolicies } from '@/jobs/tasks/onboarding/generate-full-policies';
import { tasks } from '@trigger.dev/sdk';
import { z } from 'zod';

export const regenerateFullPoliciesAction = authActionClient
.inputSchema(z.object({}))
.metadata({
name: 'regenerate-full-policies',
track: {
event: 'regenerate-full-policies',
channel: 'server',
},
})
.action(async ({ ctx }) => {
const { session } = ctx;

if (!session?.activeOrganizationId) {
throw new Error('No active organization');
}

await tasks.trigger<typeof generateFullPolicies>('generate-full-policies', {
organizationId: session.activeOrganizationId,
});

// Revalidation handled by safe-action middleware using x-pathname header
return { success: true };
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
'use client';

import { Button } from '@comp/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@comp/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@comp/ui/dropdown-menu';
import { Icons } from '@comp/ui/icons';
import { useAction } from 'next-safe-action/hooks';
import { useState } from 'react';
import { toast } from 'sonner';
import { regenerateFullPoliciesAction } from '../actions/regenerate-full-policies';

export function FullPolicyHeaderActions() {
const [isRegenerateConfirmOpen, setRegenerateConfirmOpen] = useState(false);

const regenerate = useAction(regenerateFullPoliciesAction, {
onSuccess: () => {
toast.success('Policy regeneration started. This may take a few minutes.');
setRegenerateConfirmOpen(false);
},
onError: (error) => {
toast.error(error.error.serverError || 'Failed to regenerate policies');
},
});

const handleRegenerate = async () => {
await regenerate.execute({});
};

return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="icon" variant="ghost" className="m-0 size-auto p-2">
<Icons.Settings className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setRegenerateConfirmOpen(true)}>
<Icons.AI className="mr-2 h-4 w-4" /> Regenerate all policies
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

{/* Regenerate Confirmation Dialog */}
<Dialog open={isRegenerateConfirmOpen} onOpenChange={setRegenerateConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Regenerate All Policies</DialogTitle>
<DialogDescription>
This will generate new policy content for all policies using your org context and
frameworks. Continue?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRegenerateConfirmOpen(false)}
disabled={regenerate.status === 'executing'}
>
Cancel
</Button>
<Button onClick={handleRegenerate} disabled={regenerate.status === 'executing'}>
{regenerate.status === 'executing' ? 'Working…' : 'Confirm'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
6 changes: 5 additions & 1 deletion apps/app/src/app/(app)/[orgId]/policies/all/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb';
import { getValidFilters } from '@/lib/data-table';
import type { SearchParams } from '@/types';
import type { Metadata } from 'next';
import { FullPolicyHeaderActions } from './components/FullPolicyHeaderActions';
import { PoliciesTable } from './components/policies-table';
import { getPolicies } from './data/queries';
import { searchParamsCache } from './data/validations';
Expand All @@ -23,7 +24,10 @@ export default async function PoliciesPage({ ...props }: PolicyTableProps) {
]);

return (
<PageWithBreadcrumb breadcrumbs={[{ label: 'Policies', current: true }]}>
<PageWithBreadcrumb
breadcrumbs={[{ label: 'Policies', current: true }]}
headerRight={<FullPolicyHeaderActions />}
>
<PoliciesTable promises={promises} />
</PageWithBreadcrumb>
);
Expand Down
52 changes: 52 additions & 0 deletions apps/app/src/jobs/tasks/onboarding/generate-full-policies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { db } from '@db';
import { logger, queue, task } from '@trigger.dev/sdk';
import { getOrganizationContext, triggerPolicyUpdates } from './onboard-organization-helpers';

// v4 queues must be declared in advance
const generateFullPoliciesQueue = queue({
name: 'generate-full-policies',
concurrencyLimit: 100,
});

export const generateFullPolicies = task({
id: 'generate-full-policies',
queue: generateFullPoliciesQueue,
retry: {
maxAttempts: 3,
},
run: async (payload: { organizationId: string }) => {
logger.info(`Starting full policy generation for organization ${payload.organizationId}`);

try {
// Get organization context
const { questionsAndAnswers } = await getOrganizationContext(payload.organizationId);

// Get frameworks
const frameworkInstances = await db.frameworkInstance.findMany({
where: {
organizationId: payload.organizationId,
},
});

const frameworks = await db.frameworkEditorFramework.findMany({
where: {
id: {
in: frameworkInstances.map((instance) => instance.frameworkId),
},
},
});

// Trigger policy updates for all policies
await triggerPolicyUpdates(payload.organizationId, questionsAndAnswers, frameworks);

logger.info(
`Successfully triggered policy updates for organization ${payload.organizationId}`,
);
} catch (error) {
logger.error(`Error during policy generation for organization ${payload.organizationId}:`, {
error: error instanceof Error ? error.message : String(error),
});
throw error;
}
},
});
Loading