-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
Changes from 5 commits
7cf8a5c
0e91351
727fdc8
d001829
56531d6
7b7a75a
5d17d3f
7ea6489
2c5dced
be5fc96
18831ab
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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} | ||
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> | ||
); | ||
} |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> | ||
); | ||
} |
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() { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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> | ||
); | ||
} |
There was a problem hiding this comment.
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.