diff --git a/apps/dashboard/src/components/activity/activity-detail-card.tsx b/apps/dashboard/src/components/activity/activity-detail-card.tsx
new file mode 100644
index 00000000000..c9438902e6d
--- /dev/null
+++ b/apps/dashboard/src/components/activity/activity-detail-card.tsx
@@ -0,0 +1,44 @@
+import { cn } from '@/utils/ui';
+import { ChevronDown } from 'lucide-react';
+import { ReactNode, useState } from 'react';
+
+interface ActivityDetailCardProps {
+ title: ReactNode;
+ timestamp?: string;
+ expandable?: boolean;
+ open?: boolean;
+ children?: ReactNode;
+}
+
+export function ActivityDetailCard({ title, timestamp, expandable = false, open, children }: ActivityDetailCardProps) {
+ const [internalOpen, setInternalOpen] = useState(false);
+ const isExpanded = open ?? internalOpen;
+
+ return (
+
+
setInternalOpen(!internalOpen) : undefined}
+ >
+
{title}
+
+ {timestamp && (
+
+ {timestamp}
+
+ )}
+ {expandable && (
+
+ )}
+
+
+ {isExpanded && children && (
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/components/activity/activity-job-item.tsx b/apps/dashboard/src/components/activity/activity-job-item.tsx
new file mode 100644
index 00000000000..4502e480fda
--- /dev/null
+++ b/apps/dashboard/src/components/activity/activity-job-item.tsx
@@ -0,0 +1,211 @@
+import { Route, ChevronDown } from 'lucide-react';
+import { IActivityJob, IDelayRegularMetadata, IDigestRegularMetadata, JobStatusEnum, StepTypeEnum } from '@novu/shared';
+import { Button } from '@/components/primitives/button';
+import { Badge } from '@/components/primitives/badge';
+import { Card, CardContent, CardHeader } from '../primitives/card';
+import { format } from 'date-fns';
+import { useState } from 'react';
+import { cn } from '@/utils/ui';
+import { ExecutionDetailItem } from './execution-detail-item';
+import { STEP_TYPE_TO_ICON } from '../icons/utils';
+import { STEP_TYPE_TO_COLOR } from '../../utils/color';
+import { JOB_STATUS_CONFIG } from './constants';
+import { TimeDisplayHoverCard } from '../time-display-hover-card';
+
+interface ActivityJobItemProps {
+ job: IActivityJob;
+ isFirst: boolean;
+ isLast: boolean;
+}
+
+export function ActivityJobItem({ job, isFirst, isLast }: ActivityJobItemProps) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ return (
+
+
+
+
+
+
+ setIsExpanded(!isExpanded)}
+ >
+
+
+
+ {getJobIcon(job.type)}
+
+
+
{formatJobType(job.type)}
+
+
+
+ Show more
+
+
+
+
+ {!isExpanded && (
+
+
+ {getStatusMessage(job)}
+
+
+ {format(new Date(job.updatedAt), 'MMM d yyyy, HH:mm:ss')}
+
+
+
+
+ )}
+
+ {isExpanded && }
+
+
+ );
+}
+
+function formatJobType(type?: StepTypeEnum): string {
+ return type?.replace(/_/g, ' ') || '';
+}
+
+function getStatusMessage(job: IActivityJob): string {
+ if (job.status === JobStatusEnum.MERGED) {
+ return 'Step merged with another execution';
+ }
+
+ if (job.status === JobStatusEnum.FAILED && job.executionDetails?.length > 0) {
+ return job.executionDetails[job.executionDetails.length - 1].detail || 'Step execution failed';
+ }
+
+ switch (job.type?.toLowerCase()) {
+ case StepTypeEnum.DIGEST:
+ if (job.status === JobStatusEnum.COMPLETED) {
+ return `Digested ${job.digest?.events?.length ?? 0} events for ${(job.digest as IDigestRegularMetadata)?.amount ?? 0} ${
+ (job.digest as IDigestRegularMetadata)?.unit ?? ''
+ }`;
+ }
+ if (job.status === JobStatusEnum.DELAYED) {
+ return `Collecting Digest events for ${(job.digest as IDigestRegularMetadata)?.amount ?? 0} ${
+ (job.digest as IDigestRegularMetadata)?.unit ?? ''
+ }`;
+ }
+ return 'Digest failed';
+
+ case StepTypeEnum.DELAY:
+ if (job.status === JobStatusEnum.COMPLETED) {
+ return 'Delay completed';
+ }
+
+ if (job.status === JobStatusEnum.DELAYED) {
+ return (
+ 'Waiting for ' +
+ (job.digest as IDelayRegularMetadata)?.amount +
+ ' ' +
+ (job.digest as IDelayRegularMetadata)?.unit
+ );
+ }
+
+ return 'Delay failed';
+
+ default:
+ if (job.status === JobStatusEnum.COMPLETED) {
+ return 'Message sent successfully';
+ }
+ if (job.status === JobStatusEnum.PENDING) {
+ return 'Sending message';
+ }
+
+ return '';
+ }
+}
+
+function getJobIcon(type?: StepTypeEnum) {
+ const Icon = STEP_TYPE_TO_ICON[type?.toLowerCase() as keyof typeof STEP_TYPE_TO_ICON] ?? Route;
+
+ return ;
+}
+
+function getJobColor(status: JobStatusEnum) {
+ switch (status) {
+ case JobStatusEnum.COMPLETED:
+ return 'success';
+ case JobStatusEnum.FAILED:
+ return 'destructive';
+ case JobStatusEnum.DELAYED:
+ return 'warning';
+ case JobStatusEnum.MERGED:
+ return 'neutral-300';
+ default:
+ return 'neutral-300';
+ }
+}
+
+function JobDetails({ job }: { job: IActivityJob }) {
+ return (
+
+
+ {job.executionDetails && job.executionDetails.length > 0 && (
+
+ {job.executionDetails.map((detail, index) => (
+
+ ))}
+
+ )}
+ {/*
+ TODO: Missing backend support for digest events widget
+ {job.type === 'digest' && job.digest?.events && (
+
+
+ {job.digest.events.map((event: DigestEvent, index: number) => (
+
+
+
+ {event.type}
+
+ {`${format(new Date(job.updatedAt), 'HH:mm')} UTC`}
+
+
+
+ ))}
+
+
+ )} */}
+
+
+ );
+}
+
+interface JobStatusIndicatorProps {
+ status: JobStatusEnum;
+}
+
+function JobStatusIndicator({ status }: JobStatusIndicatorProps) {
+ const { icon: Icon, animationClass } = JOB_STATUS_CONFIG[status] || JOB_STATUS_CONFIG[JobStatusEnum.PENDING];
+
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/components/activity/activity-panel.tsx b/apps/dashboard/src/components/activity/activity-panel.tsx
new file mode 100644
index 00000000000..8d179b969d2
--- /dev/null
+++ b/apps/dashboard/src/components/activity/activity-panel.tsx
@@ -0,0 +1,128 @@
+import { motion } from 'motion/react';
+import { RiPlayCircleLine, RiRouteFill } from 'react-icons/ri';
+import { ActivityJobItem } from './activity-job-item';
+import { InlineToast } from '../primitives/inline-toast';
+import { useFetchActivity } from '@/hooks/use-fetch-activity';
+import { ActivityOverview } from './components/activity-overview';
+import { IActivityJob } from '@novu/shared';
+import { Skeleton } from '../primitives/skeleton';
+
+export interface ActivityPanelProps {
+ activityId: string;
+ onActivitySelect: (activityId: string) => void;
+}
+
+export function ActivityPanel({ activityId, onActivitySelect }: ActivityPanelProps) {
+ const { activity, isPending, error } = useFetchActivity({ activityId });
+
+ if (isPending) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !activity) {
+ return (
+
+
+
Failed to load activity details
+
+
+ );
+ }
+
+ const isMerged = activity.jobs.some((job) => job.status === 'merged');
+
+ return (
+
+
+
+
+
+ {activity.template?.name || 'Deleted workflow'}
+
+
+
+
+
+
+ Logs
+
+
+ {isMerged && (
+
+ {
+ if (activity._digestedNotificationId) {
+ onActivitySelect(activity._digestedNotificationId);
+ }
+ }}
+ description="Remaining execution has been merged to an active Digest of an existing workflow execution."
+ />
+
+ )}
+
+
+
+ );
+}
+
+function LogsSection({ jobs }: { jobs: IActivityJob[] }): JSX.Element {
+ return (
+
+ {jobs.map((job, index) => (
+
+ ))}
+
+ );
+}
+
+function LoadingSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+ {[...Array(5)].map((_, i) => (
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {[...Array(2)].map((_, i) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/activity/components/activity-overview.tsx b/apps/dashboard/src/components/activity/components/activity-overview.tsx
new file mode 100644
index 00000000000..56b81e47bf4
--- /dev/null
+++ b/apps/dashboard/src/components/activity/components/activity-overview.tsx
@@ -0,0 +1,58 @@
+import { format } from 'date-fns';
+import { Link } from 'react-router-dom';
+import { cn } from '@/utils/ui';
+import { buildRoute, ROUTES } from '@/utils/routes';
+import { useEnvironment } from '@/context/environment/hooks';
+import { TimeDisplayHoverCard } from '@/components/time-display-hover-card';
+import { OverviewItem } from './overview-item';
+import { IActivity } from '@novu/shared';
+import { JOB_STATUS_CONFIG } from '../constants';
+
+export interface ActivityOverviewProps {
+ activity: IActivity;
+}
+
+export function ActivityOverview({ activity }: ActivityOverviewProps) {
+ const { currentEnvironment } = useEnvironment();
+ const status = activity.jobs[activity?.jobs?.length - 1]?.status;
+
+ const workflowPath = buildRoute(ROUTES.EDIT_WORKFLOW, {
+ environmentSlug: currentEnvironment?.slug ?? '',
+ workflowSlug: activity?.template?._id ?? '',
+ });
+
+ return (
+
+
+
+
+ {activity.template?.name || 'Deleted workflow'}
+
+
+
+
+
+
+
+
+
+
+ {format(new Date(activity.createdAt), 'MMM d yyyy, HH:mm:ss')}
+
+
+
+
+
+
+ {status || 'QUEUED'}
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/activity/components/overview-item.tsx b/apps/dashboard/src/components/activity/components/overview-item.tsx
new file mode 100644
index 00000000000..77a6c43c6ea
--- /dev/null
+++ b/apps/dashboard/src/components/activity/components/overview-item.tsx
@@ -0,0 +1,41 @@
+import { Copy } from 'lucide-react';
+import { ReactNode } from 'react';
+import { cn } from '@/utils/ui';
+import { CopyButton } from '@/components/primitives/copy-button';
+
+interface OverviewItemProps {
+ label: string;
+ value?: string;
+ className?: string;
+ isMonospace?: boolean;
+ isCopyable?: boolean;
+ children?: ReactNode;
+}
+
+export function OverviewItem({
+ label,
+ value,
+ className = '',
+ isMonospace = true,
+ isCopyable = false,
+ children,
+}: OverviewItemProps) {
+ return (
+
+
{label}
+
+ {isCopyable && value && (
+
+
+
+ )}
+ {children || {value} }
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/activity/execution-detail-item.tsx b/apps/dashboard/src/components/activity/execution-detail-item.tsx
new file mode 100644
index 00000000000..b6759317f21
--- /dev/null
+++ b/apps/dashboard/src/components/activity/execution-detail-item.tsx
@@ -0,0 +1,67 @@
+import { RiErrorWarningLine, RiCheckboxCircleLine, RiLoader3Line } from 'react-icons/ri';
+import { format } from 'date-fns';
+import { cn } from '@/utils/ui';
+import { ActivityDetailCard } from './activity-detail-card';
+import { IExecutionDetail } from '@novu/shared';
+import { formatJSONString } from '../../utils/string';
+
+interface ExecutionDetailItemProps {
+ detail: IExecutionDetail;
+}
+
+function getStatusConfig(status: string) {
+ switch (status.toLowerCase()) {
+ case 'success':
+ return {
+ icon: RiCheckboxCircleLine,
+ colorClass: 'text-success',
+ };
+ case 'failed':
+ case 'error':
+ return {
+ icon: RiErrorWarningLine,
+ colorClass: 'text-destructive',
+ };
+ case 'pending':
+ case 'queued':
+ return {
+ icon: RiLoader3Line,
+ colorClass: 'text-neutral-300',
+ };
+ default:
+ return {
+ icon: RiCheckboxCircleLine,
+ colorClass: 'text-success',
+ };
+ }
+}
+
+export function ExecutionDetailItem({ detail }: ExecutionDetailItemProps) {
+ const { icon: StatusIcon, colorClass } = getStatusConfig(detail.status);
+
+ return (
+
+
+
+ {detail.raw && (
+
+ {formatJSONString(detail.raw)}
+
+ )}
+
+
+ );
+}
diff --git a/apps/dashboard/src/hooks/use-fetch-activity.ts b/apps/dashboard/src/hooks/use-fetch-activity.ts
new file mode 100644
index 00000000000..fb5fa67fdd7
--- /dev/null
+++ b/apps/dashboard/src/hooks/use-fetch-activity.ts
@@ -0,0 +1,21 @@
+import { useQuery } from '@tanstack/react-query';
+import { QueryKeys } from '@/utils/query-keys';
+import type { IActivity } from '@novu/shared';
+import { useEnvironment } from '@/context/environment/hooks';
+import { getNotification } from '@/api/activity';
+
+export function useFetchActivity({ activityId }: { activityId?: string }) {
+ const { currentEnvironment } = useEnvironment();
+
+ const { data, isPending, error } = useQuery<{ data: IActivity }>({
+ queryKey: [QueryKeys.fetchActivity, currentEnvironment?._id, activityId],
+ queryFn: () => getNotification(activityId!, currentEnvironment!),
+ enabled: !!currentEnvironment?._id && !!activityId,
+ });
+
+ return {
+ activity: data?.data,
+ isPending,
+ error,
+ };
+}
diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx
index ae0c2cb6370..dfb06104012 100644
--- a/apps/dashboard/src/pages/activity-feed.tsx
+++ b/apps/dashboard/src/pages/activity-feed.tsx
@@ -1,5 +1,7 @@
import { DashboardLayout } from '@/components/dashboard-layout';
import { ActivityTable } from '@/components/activity/activity-table';
+import { motion, AnimatePresence } from 'motion/react';
+import { ActivityPanel } from '@/components/activity/activity-panel';
import { Badge } from '../components/primitives/badge';
import { useSearchParams } from 'react-router-dom';
import { IActivity } from '@novu/shared';
@@ -20,6 +22,13 @@ export function ActivityFeed() {
});
};
+ const handleActivityPanelSelect = (activityId: string) => {
+ setSearchParams((prev) => {
+ prev.set('activityItemId', activityId);
+ return prev;
+ });
+ };
+
return (
<>
@@ -34,7 +43,35 @@ export function ActivityFeed() {
}
>
-
+
+
+
+
+
+ {activityItemId && (
+
+
+
+ )}
+
>
diff --git a/apps/dashboard/src/utils/string.ts b/apps/dashboard/src/utils/string.ts
index 45c8e8e431f..523e6343cb8 100644
--- a/apps/dashboard/src/utils/string.ts
+++ b/apps/dashboard/src/utils/string.ts
@@ -1,3 +1,26 @@
export const capitalize = (str: string) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
+
+export const formatJSONString = (raw: unknown): string => {
+ if (typeof raw === 'string') {
+ try {
+ const parsed = JSON.parse(raw);
+ return JSON.stringify(parsed, null, 2)
+ .split('\n')
+ .map((line) => line.trimEnd())
+ .join('\n');
+ } catch {
+ return raw;
+ }
+ }
+
+ if (typeof raw === 'object') {
+ return JSON.stringify(raw, null, 2)
+ .split('\n')
+ .map((line) => line.trimEnd())
+ .join('\n');
+ }
+
+ return String(raw);
+};