From 727fdc88a3fbd774fd5bcec33c489176e6c1008c Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 9 Dec 2024 17:38:54 +0200 Subject: [PATCH 1/6] Revert "fix: removed items" This reverts commit 1e113e8da38ece9a49331f88b05c544b007fb55a. --- .../activity/activity-detail-card.tsx | 44 ++++ .../components/activity/activity-job-item.tsx | 208 ++++++++++++++++++ .../components/activity/activity-panel.tsx | 128 +++++++++++ .../activity/components/activity-overview.tsx | 58 +++++ .../activity/components/overview-item.tsx | 41 ++++ .../activity/execution-detail-item.tsx | 89 ++++++++ apps/dashboard/src/pages/activity-feed.tsx | 37 +++- 7 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/src/components/activity/activity-detail-card.tsx create mode 100644 apps/dashboard/src/components/activity/activity-job-item.tsx create mode 100644 apps/dashboard/src/components/activity/activity-panel.tsx create mode 100644 apps/dashboard/src/components/activity/components/activity-overview.tsx create mode 100644 apps/dashboard/src/components/activity/components/overview-item.tsx create mode 100644 apps/dashboard/src/components/activity/execution-detail-item.tsx 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 && ( +
+
+
{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..1c0ae90d140 --- /dev/null +++ b/apps/dashboard/src/components/activity/activity-job-item.tsx @@ -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; + 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)} +
+ + +
+ + {!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..e23033be07e --- /dev/null +++ b/apps/dashboard/src/components/activity/activity-panel.tsx @@ -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 ( + + + + ); + } + + 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..193cf17fe35 --- /dev/null +++ b/apps/dashboard/src/components/activity/execution-detail-item.tsx @@ -0,0 +1,89 @@ +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'; + +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); + + const formatContent = (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); + }; + + return ( +
+
+
+ +
+
+ + {detail.raw && ( +
+            {formatContent(detail.raw)}
+          
+ )} +
+
+ ); +} diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index ae0c2cb6370..da4e32e421e 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -1,5 +1,8 @@ import { DashboardLayout } from '@/components/dashboard-layout'; import { ActivityTable } from '@/components/activity/activity-table'; +import { cn } from '@/utils/ui'; +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 +23,13 @@ export function ActivityFeed() { }); }; + const handleActivityPanelSelect = (activityId: string) => { + setSearchParams((prev) => { + prev.set('activityItemId', activityId); + return prev; + }); + }; + return ( <> @@ -34,7 +44,32 @@ export function ActivityFeed() { } >
- + + + + + + {activityItemId && ( + + + + )} +
From d001829a513224bf375e6691985fe5aa1b19bc9f Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 9 Dec 2024 17:38:57 +0200 Subject: [PATCH 2/6] Revert "fix: remove unused" This reverts commit d63ad6d3824d57e7cbd853ded90d74f832f07c9b. --- .../dashboard/src/hooks/use-fetch-activity.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 apps/dashboard/src/hooks/use-fetch-activity.ts 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, + }; +} From 5d17d3fb8de9b5f14e4ade465ab46d6b144abb7c Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 10 Dec 2024 19:09:22 +0200 Subject: [PATCH 3/6] fix: merge --- apps/dashboard/src/components/activity/activity-table.tsx | 3 +-- apps/dashboard/src/pages/activity-feed.tsx | 7 ++++++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-table.tsx b/apps/dashboard/src/components/activity/activity-table.tsx index b42331ced92..9e905bd7921 100644 --- a/apps/dashboard/src/components/activity/activity-table.tsx +++ b/apps/dashboard/src/components/activity/activity-table.tsx @@ -39,7 +39,6 @@ export function ActivityTable({ const { activities, isLoading, hasMore } = useFetchActivities({ filters }); const page = parseInt(searchParams.get('page') || '0'); - const limit = parseInt(searchParams.get('limit') || '10'); const handlePageChange = (newPage: number) => { const newParams = createSearchParams({ @@ -127,7 +126,7 @@ export function ActivityTable({ - +
); } diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index da4e32e421e..97dbf688d8c 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -51,7 +51,12 @@ export function ActivityFeed() { }} className={cn('h-full flex-1', activityItemId ? 'w-[65%]' : 'w-full')} > - + {}} + /> From 2c5dceda9768c607fde24e909acbdf374114e49c Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 12 Dec 2024 09:37:31 +0200 Subject: [PATCH 4/6] fix: sync animation --- apps/dashboard/src/pages/activity-feed.tsx | 31 ++++++++++------------ 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/apps/dashboard/src/pages/activity-feed.tsx b/apps/dashboard/src/pages/activity-feed.tsx index 97dbf688d8c..dfb06104012 100644 --- a/apps/dashboard/src/pages/activity-feed.tsx +++ b/apps/dashboard/src/pages/activity-feed.tsx @@ -1,6 +1,5 @@ import { DashboardLayout } from '@/components/dashboard-layout'; import { ActivityTable } from '@/components/activity/activity-table'; -import { cn } from '@/utils/ui'; import { motion, AnimatePresence } from 'motion/react'; import { ActivityPanel } from '@/components/activity/activity-panel'; import { Badge } from '../components/primitives/badge'; @@ -45,31 +44,29 @@ export function ActivityFeed() { >
- {}} - /> + - + {activityItemId && ( From be5fc966a33aa4976b7b84d83d5b5afa9dd31a6a Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 12 Dec 2024 09:42:17 +0200 Subject: [PATCH 5/6] fix: pr comments --- .../components/activity/activity-panel.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-panel.tsx b/apps/dashboard/src/components/activity/activity-panel.tsx index e23033be07e..8d179b969d2 100644 --- a/apps/dashboard/src/components/activity/activity-panel.tsx +++ b/apps/dashboard/src/components/activity/activity-panel.tsx @@ -1,11 +1,11 @@ -import { Route } from 'lucide-react'; import { motion } from 'motion/react'; -import { RiPlayCircleLine } from 'react-icons/ri'; +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; @@ -26,7 +26,7 @@ export function ActivityPanel({ activityId, onActivitySelect }: ActivityPanelPro if (error || !activity) { return ( -
+
Failed to load activity details
@@ -45,7 +45,7 @@ export function ActivityPanel({ activityId, onActivitySelect }: ActivityPanelPro >
- + {activity.template?.name || 'Deleted workflow'} @@ -90,36 +90,36 @@ function LogsSection({ jobs }: { jobs: IActivityJob[] }): JSX.Element { function LoadingSkeleton() { return ( -
+
-
-
+ +
{[...Array(5)].map((_, i) => (
-
-
+ +
))}
-
-
+ +
{[...Array(2)].map((_, i) => (
-
-
+ +
-
+
))}
From 18831ab7ac619c994f0d872e94e9dee6cb6f9b15 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 12 Dec 2024 09:45:41 +0200 Subject: [PATCH 6/6] fix: pr comments --- .../components/activity/activity-job-item.tsx | 5 +++- .../activity/execution-detail-item.tsx | 26 ++----------------- apps/dashboard/src/utils/string.ts | 23 ++++++++++++++++ 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/apps/dashboard/src/components/activity/activity-job-item.tsx b/apps/dashboard/src/components/activity/activity-job-item.tsx index 1c0ae90d140..4502e480fda 100644 --- a/apps/dashboard/src/components/activity/activity-job-item.tsx +++ b/apps/dashboard/src/components/activity/activity-job-item.tsx @@ -10,6 +10,7 @@ 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; @@ -66,7 +67,9 @@ export function ActivityJobItem({ job, isFirst, isLast }: ActivityJobItemProps)
{getStatusMessage(job)} - {format(new Date(job.updatedAt), 'MMM d yyyy, HH:mm:ss')} + + {format(new Date(job.updatedAt), 'MMM d yyyy, HH:mm:ss')} +
diff --git a/apps/dashboard/src/components/activity/execution-detail-item.tsx b/apps/dashboard/src/components/activity/execution-detail-item.tsx index 193cf17fe35..b6759317f21 100644 --- a/apps/dashboard/src/components/activity/execution-detail-item.tsx +++ b/apps/dashboard/src/components/activity/execution-detail-item.tsx @@ -3,6 +3,7 @@ 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; @@ -38,29 +39,6 @@ function getStatusConfig(status: string) { export function ExecutionDetailItem({ detail }: ExecutionDetailItemProps) { const { icon: StatusIcon, colorClass } = getStatusConfig(detail.status); - const formatContent = (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); - }; - return (
@@ -80,7 +58,7 @@ export function ExecutionDetailItem({ detail }: ExecutionDetailItemProps) { > {detail.raw && (
-            {formatContent(detail.raw)}
+            {formatJSONString(detail.raw)}
           
)} 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); +};