Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dashboard): Activity feed panel #7238

Merged
merged 11 commits into from
Dec 12, 2024
44 changes: 44 additions & 0 deletions apps/dashboard/src/components/activity/activity-detail-card.tsx
Original file line number Diff line number Diff line change
@@ -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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have the accordion primitive that we can use for the nice animation. However, I don't know if it will require doing some updates.


return (
<div className="border-1 w-full overflow-hidden rounded-lg border border-neutral-100">
<div
className={cn('group flex w-full items-center px-3 py-2 hover:bg-neutral-50', expandable && 'cursor-pointer')}
onClick={expandable ? () => setInternalOpen(!internalOpen) : undefined}
>
<span className="text-foreground-950 flex-1 text-left text-xs font-medium">{title}</span>
<div className="flex items-center gap-2 pl-3">
{timestamp && (
<span className="text-xs text-[#717784] opacity-0 transition-opacity group-hover:opacity-100">
{timestamp}
SokratisVidros marked this conversation as resolved.
Show resolved Hide resolved
</span>
)}
{expandable && (
<ChevronDown className={cn('h-4 w-4 text-[#717784] transition-transform', isExpanded && 'rotate-180')} />
)}
</div>
</div>
{isExpanded && children && (
<div className="border-t border-neutral-200 bg-neutral-50 p-3">
<div className="text-foreground-600 text-xs">
<div className="overflow-x-auto">{children}</div>
</div>
</div>
)}
</div>
);
}
208 changes: 208 additions & 0 deletions apps/dashboard/src/components/activity/activity-job-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
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';

interface ActivityJobItemProps {
job: IActivityJob;
scopsy marked this conversation as resolved.
Show resolved Hide resolved
isFirst: boolean;
isLast: boolean;
}

export function ActivityJobItem({ job, isFirst, isLast }: ActivityJobItemProps) {
const [isExpanded, setIsExpanded] = useState(false);

return (
<div className="relative flex items-center gap-1">
<div
className={cn(
'absolute left-[11px] h-[calc(100%+24px)] w-[1px] bg-neutral-200',
isFirst ? 'top-[50%]' : 'top-0',
isLast ? 'h-[50%]' : 'h-[calc(100%+24px)]',
isFirst && isLast && 'bg-transparent'
)}
/>

<JobStatusIndicator status={job.status} />

<Card className="border-1 flex-1 border border-neutral-200 p-1 shadow-[0px_1px_2px_0px_rgba(10,13,20,0.03)]">
<CardHeader
className="flex flex-row items-center justify-between bg-white p-2 px-1 hover:cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-1.5">
<div
className={`h-5 w-5 rounded-full border opacity-40 border-${STEP_TYPE_TO_COLOR[job.type as keyof typeof STEP_TYPE_TO_COLOR]}`}
>
<div
className={`h-full w-full rounded-full bg-neutral-50 text-${STEP_TYPE_TO_COLOR[job.type as keyof typeof STEP_TYPE_TO_COLOR]} flex items-center justify-center`}
>
{getJobIcon(job.type)}
</div>
</div>
<span className="text-foreground-950 text-xs capitalize">{formatJobType(job.type)}</span>
</div>

<Button
variant="ghost"
size="sm"
className="text-foreground-600 !mt-0 h-5 gap-0 p-0 leading-[12px] hover:bg-transparent"
>
Show more
<ChevronDown className={cn('h-4 w-4 transition-transform', isExpanded && 'rotate-180')} />
</Button>
</CardHeader>

{!isExpanded && (
<CardContent className="rounded-lg bg-neutral-50 p-2">
<div className="flex items-center justify-between">
<span className="text-foreground-400 max-w-[300px] truncate pr-2 text-xs">{getStatusMessage(job)}</span>
<Badge variant="soft" className="bg-foreground-50 shrink-0 px-2 py-0.5 text-[11px] leading-3">
{format(new Date(job.updatedAt), 'MMM d yyyy, HH:mm:ss')}
scopsy marked this conversation as resolved.
Show resolved Hide resolved
</Badge>
</div>
</CardContent>
)}

{isExpanded && <JobDetails job={job} />}
</Card>
</div>
);
}

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 <Icon className="h-3.5 w-3.5" />;
}

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 (
<div className="border-t border-neutral-100 p-4">
<div className="flex flex-col gap-4">
{job.executionDetails && job.executionDetails.length > 0 && (
<div className="flex flex-col gap-2">
{job.executionDetails.map((detail, index) => (
<ExecutionDetailItem key={index} detail={detail} />
))}
</div>
)}
{/*
TODO: Missing backend support for digest events widget
{job.type === 'digest' && job.digest?.events && (
<ActivityDetailCard title="Digest Events" expandable={true} open>
<div className="min-w-0 max-w-full font-mono">
{job.digest.events.map((event: DigestEvent, index: number) => (
<div key={index} className="group flex items-center gap-2 rounded-sm px-1 py-1.5 hover:bg-neutral-100">
<RiCheckboxCircleLine className="text-success h-4 w-4 shrink-0" />
<div className="flex items-center gap-2 truncate">
<span className="truncate text-xs text-neutral-500">{event.type}</span>
<span className="text-xs text-neutral-400">
{`${format(new Date(job.updatedAt), 'HH:mm')} UTC`}
</span>
</div>
</div>
))}
</div>
</ActivityDetailCard>
)} */}
Comment on lines +172 to +189
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently backend support is lacking the ability to show the transactionIds of the digested events and we only store the payload there

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How much effort is to enrich the API accordingly?

</div>
</div>
);
}

interface JobStatusIndicatorProps {
status: JobStatusEnum;
}

function JobStatusIndicator({ status }: JobStatusIndicatorProps) {
const { icon: Icon, animationClass } = JOB_STATUS_CONFIG[status] || JOB_STATUS_CONFIG[JobStatusEnum.PENDING];

return (
<div className="relative flex-shrink-0">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-white shadow-[0px_1px_2px_0px_rgba(10,13,20,0.03)]">
<div className={`text-${getJobColor(status)} flex items-center justify-center`}>
<Icon className={cn('h-4 w-4', animationClass)} />
</div>
</div>
</div>
);
}
128 changes: 128 additions & 0 deletions apps/dashboard/src/components/activity/activity-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { Route } from 'lucide-react';
import { motion } from 'motion/react';
import { RiPlayCircleLine } 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';

export interface ActivityPanelProps {
activityId: string;
onActivitySelect: (activityId: string) => void;
}

export function ActivityPanel({ activityId, onActivitySelect }: ActivityPanelProps) {
const { activity, isPending, error } = useFetchActivity({ activityId });

if (isPending) {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.1 }}>
<LoadingSkeleton />
</motion.div>
);
}

if (error || !activity) {
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.3 }}>
<div className="flex h-96 items-center justify-center">
<div className="text-foreground-600 text-sm">Failed to load activity details</div>
</div>
</motion.div>
);
}
scopsy marked this conversation as resolved.
Show resolved Hide resolved

const isMerged = activity.jobs.some((job) => job.status === 'merged');

return (
<motion.div
key={activityId}
initial={{ opacity: 0.7 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="h-full"
>
<div>
<div className="flex items-center gap-2 border-b border-t border-neutral-200 border-b-neutral-100 p-2 px-3">
<Route className="h-3 w-3" />
scopsy marked this conversation as resolved.
Show resolved Hide resolved
<span className="text-foreground-950 text-sm font-medium">
{activity.template?.name || 'Deleted workflow'}
</span>
</div>
<ActivityOverview activity={activity} />

<div className="flex items-center gap-2 border-b border-t border-neutral-100 p-2 px-3">
<RiPlayCircleLine className="h-3 w-3" />
<span className="text-foreground-950 text-sm font-medium">Logs</span>
</div>

{isMerged && (
<div className="px-3 py-3">
<InlineToast
ctaClassName="text-foreground-950"
variant={'tip'}
ctaLabel="View Execution"
onCtaClick={() => {
if (activity._digestedNotificationId) {
onActivitySelect(activity._digestedNotificationId);
}
}}
description="Remaining execution has been merged to an active Digest of an existing workflow execution."
/>
</div>
)}
<LogsSection jobs={activity.jobs} />
</div>
</motion.div>
);
}

function LogsSection({ jobs }: { jobs: IActivityJob[] }): JSX.Element {
return (
<div className="flex flex-col gap-6 bg-white p-3">
{jobs.map((job, index) => (
<ActivityJobItem key={job._id} job={job} isFirst={index === 0} isLast={index === jobs.length - 1} />
))}
</div>
);
}

function LoadingSkeleton() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we have it as a separate component, then whenever we update the layout of the panel or change anything in there, we will need to "remember" to make the same adjustments for the skeleton. Ideally, we should reuse the same layout component or have skeletons embedded into the ActivityPanel.

return (
<div className="animate-pulse">
<div className="flex items-center gap-2 border-b border-t border-neutral-200 border-b-neutral-100 p-2">
<div className="h-3 w-3 rounded-full bg-neutral-200" />
scopsy marked this conversation as resolved.
Show resolved Hide resolved
<div className="h-[20px] w-32 rounded bg-neutral-200" />
</div>

<div className="px-3 py-2">
<div className="flex flex-col gap-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="h-3 w-24 rounded bg-neutral-200" />
<div className="h-3 w-32 rounded bg-neutral-200" />
</div>
))}
</div>
</div>

<div className="flex items-center gap-2 border-b border-t border-neutral-100 p-2">
<div className="h-3 w-3 rounded-full bg-neutral-200" />
<div className="h-4 w-16 rounded bg-neutral-200" />
</div>

<div className="flex flex-col gap-6 bg-white p-3">
{[...Array(2)].map((_, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="h-4 w-40 rounded bg-neutral-200" />
<div className="h-4 w-24 rounded bg-neutral-200" />
</div>
<div className="h-16 w-full rounded bg-neutral-100" />
</div>
))}
</div>
</div>
);
}
Loading
Loading