Skip to content

Commit

Permalink
feat(dashboard): Activity feed panel (#7238)
Browse files Browse the repository at this point in the history
  • Loading branch information
scopsy authored Dec 12, 2024
2 parents 2a0b8bd + 18831ab commit c2cdd53
Show file tree
Hide file tree
Showing 9 changed files with 631 additions and 1 deletion.
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;

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}
</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>
);
}
211 changes: 211 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,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 (
<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">
<TimeDisplayHoverCard date={new Date(job.updatedAt)}>
{format(new Date(job.updatedAt), 'MMM d yyyy, HH:mm:ss')}
</TimeDisplayHoverCard>
</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>
)} */}
</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 { 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 (
<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 border-t border-neutral-200">
<div className="text-foreground-600 text-sm">Failed to load activity details</div>
</div>
</motion.div>
);
}

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">
<RiRouteFill className="h-3 w-3" />
<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() {
return (
<div>
<div className="flex items-center gap-2 border-b border-t border-neutral-200 border-b-neutral-100 p-2">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-[20px] w-32" />
</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">
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-32" />
</div>
))}
</div>
</div>

<div className="flex items-center gap-2 border-b border-t border-neutral-100 p-2">
<Skeleton className="h-3 w-3 rounded-full" />
<Skeleton className="h-4 w-16" />
</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">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-24" />
</div>
<Skeleton className="h-16 w-full" />
</div>
))}
</div>
</div>
);
}
Loading

0 comments on commit c2cdd53

Please sign in to comment.