-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(dashboard): Activity feed panel (#7238)
- Loading branch information
Showing
9 changed files
with
631 additions
and
1 deletion.
There are no files selected for viewing
44 changes: 44 additions & 0 deletions
44
apps/dashboard/src/components/activity/activity-detail-card.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
211
apps/dashboard/src/components/activity/activity-job-item.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
128
apps/dashboard/src/components/activity/activity-panel.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
Oops, something went wrong.