diff --git a/src/client/components/ActivityDetail.tsx b/src/client/components/ActivityDetail.tsx index 27d541dd..ccb4a527 100644 --- a/src/client/components/ActivityDetail.tsx +++ b/src/client/components/ActivityDetail.tsx @@ -1,27 +1,23 @@ -import React, { ComponentProps, useState, Fragment, useEffect } from "react" -import { Accordion, For, Span, Code, Alert, Stack, Text, Box, AbsoluteCenter, Button, Clipboard, Separator, HStack, Flex, Badge, IconButton, Container, Icon, useAccordionItemContext, Skeleton, SkeletonText, Collapsible, BadgeProps } from '@chakra-ui/react'; -import { CLIENT_DEAD_QUEUE, ComponentType, QUEUE_NAMES, Second } from "../../core/Atomic"; +import React, { useState, Fragment, useEffect } from "react" +import { Accordion, Span, Code, Alert, Stack, Box, Separator, HStack, Flex, useAccordionItemContext, Skeleton, SkeletonText, Collapsible, BadgeProps } from '@chakra-ui/react'; +import { CLIENT_DEAD_QUEUE, ComponentType, Second } from "../../core/Atomic"; import { PlayData } from "./PlayData"; import { ErrorAlert } from "./ErrorAlert"; -import { AiOutlineExclamationCircle } from "react-icons/ai"; import { ActivityTimeline } from "./ActivityTimeline"; import { ExpandCollapse } from "./ExpandCollapse"; -import { MsSseEvent, PlayApiCommon, PlayApiCommonDetailed, SortPlaysBy, SortPlaysByProps } from "../../core/Api"; -import { InfiniteData, QueryFunctionContext, QueryOptions, queryOptions, SuspenseQueriesOptions, useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; -import ky from 'ky'; -import { baseUrl } from "../utils"; +import { MsSseEvent, PlayApiCommonDetailed, SortPlaysByProps } from "../../core/Api"; +import { InfiniteData, useQuery, useQueryClient } from '@tanstack/react-query'; import { ShortDateDisplay } from "./DateDisplay"; import { TextMuted } from "./TextMuted"; -import { VscDebugRestart } from "react-icons/vsc"; import { EphemeralElement, PlayStateBadge } from "./Badges"; import { MarkOptional } from "ts-essentials"; -import { QueryPlaysOpts, QueryPlaysOptsJson } from "../../backend/common/database/drizzle/repositories/PlayRepository"; +import { QueryPlaysOptsJson } from "../../backend/common/database/drizzle/repositories/PlayRepository"; import { tanQueries } from "../queries"; import { PaginatedResponse } from "../../backend/common/database/drizzle/repositories/BaseRepository"; import { LuChevronRight } from "react-icons/lu"; import { useSSEContext, useSSEEvent } from "@flamefrontend/sse-runtime-react"; -import { DebugCopy, InsertedIcon, RetryButton, UpdatedIcon } from "./icons/ChakraIcons"; -import dayjs from "dayjs"; +import { DebugCopy, ExclamationCircleIcon, ExclamationTriangleIcon, InsertedIcon, RetryButton, UpdatedIcon } from "./icons/ChakraIcons"; +import { activityTimelineHasIssue } from "../utils/ComponentUtils"; type UseActivityQueryOptions = { msQuery?: QueryPlaysOptsJson @@ -238,9 +234,18 @@ export const ActivityDetails = (props: ActivityDetailProps) => { const [collapsibleOpen, setCollapsibleOpen] = useState(undefined); + let timelineStatusIcon: React.JSX.Element | undefined; + const timelineIssue = activityTimelineHasIssue(activity); + if(timelineIssue === 'error') { + timelineStatusIcon = ; + } else if(timelineIssue === 'warn') { + timelineStatusIcon = ; + } + return ( - + {/* */} + {error !== undefined && error !== null ? : null} @@ -257,9 +262,7 @@ export const ActivityDetails = (props: ActivityDetailProps) => { - Timeline {error !== undefined && error !== null ? ( - - ) : null} + Timeline {timelineStatusIcon} { } } + let transformVerb: string = 'Transformed Play'; + + const transformIssue = activityTransformHasIssue(props.activity); + let transformResult: React.JSX.Element | undefined; + if(transformIssue === 'error') { + transformVerb = 'Transforming Play'; + transformResult = resulted in an error; + } else if(transformIssue === 'warn') { + transformVerb = 'Transforming Play'; + transformResult = resulted in warnings; + } + return ( @@ -158,7 +170,7 @@ export const ActivityTimeline = (props: ActivityDetailProps) => { Transformed Play using configured Rules} + indicator={{transformVerb} using configured Rules{transformResult}} defaultOpen={collapsibleOpen} timeline> diff --git a/src/client/components/TransformSteps.tsx b/src/client/components/TransformSteps.tsx index fdde0c2d..a2864271 100644 --- a/src/client/components/TransformSteps.tsx +++ b/src/client/components/TransformSteps.tsx @@ -1,4 +1,4 @@ -import { ComponentProps, Fragment, useMemo } from "react" +import React, { ComponentProps, Fragment, useMemo } from "react" import { Timeline, Icon, Span, Stack, Heading, Box, Text } from '@chakra-ui/react'; import { JsonPlayObject, LifecycleStep } from "../../core/Atomic"; import { PlayData } from "./PlayData"; diff --git a/src/client/components/icons/ChakraIcons.tsx b/src/client/components/icons/ChakraIcons.tsx index b5457182..04a2d388 100644 --- a/src/client/components/icons/ChakraIcons.tsx +++ b/src/client/components/icons/ChakraIcons.tsx @@ -1,6 +1,8 @@ import { LuChevronRight, LuChevronLeft, + LuCircleAlert, + LuTriangleAlert, LuActivity, LuGithub, LuTerminal, @@ -45,7 +47,13 @@ export const makeIconButton = (Icon: IconType) => (props: PropsWithChildren ); } -export const makeChakraIcon = (IconComponent: IconType) => (props: ComponentProps & { iconProps?: IconBaseProps }) => +export const makeChakraIcon = (IconComponent: IconType) => (props: ComponentProps & { iconProps?: IconBaseProps }) => { + const { + iconProps, + ...rest + } = props; + return +} export const ChevronRight = LuChevronRight; export const ChevronRightButton = (props: ComponentProps) => ( @@ -163,4 +171,8 @@ export const InsertedIcon = makeChakraIcon(MdOutlineFiberNew); export const UpdatedIcon = makeChakraIcon(LuCircleArrowUp); export const CopyIcon = makeChakraIcon(LuCopy); -export const CopyIconButton = makeIconButton(LuCopy); \ No newline at end of file +export const CopyIconButton = makeIconButton(LuCopy); + +export const ExclamationCircleIcon = makeChakraIcon(LuCircleAlert); + +export const ExclamationTriangleIcon = makeChakraIcon(LuTriangleAlert); \ No newline at end of file diff --git a/src/client/utils/ComponentUtils.tsx b/src/client/utils/ComponentUtils.tsx index ad617e98..4fcf76d5 100644 --- a/src/client/utils/ComponentUtils.tsx +++ b/src/client/utils/ComponentUtils.tsx @@ -1,4 +1,6 @@ import { Card, HTMLChakraProps } from '@chakra-ui/react'; +import { PlayApiCommon, PlayApiCommonDetailed } from '../../core/Api'; +import { QUEUE_STATUS_COMPLETED, QUEUE_STATUS_FAILED } from '../../core/Atomic'; export const cardHeaderSeparator: Card.HeaderProps = { borderBottomWidth: "1px", @@ -8,4 +10,51 @@ export const cardHeaderSeparator: Card.HeaderProps = { export const timelineTextFormatting: HTMLChakraProps<"span"> = { textAlign: "left", textWrap: "balance" +} + +export const activityTransformHasIssue = (activity: PlayApiCommon): 'warn' | 'error' | undefined => { + const { + play: { + lifecycle = [], + } = {}, + } = activity; + for(const step of lifecycle) { + if(step.flowKnownState === 'prereq') { + return 'warn'; + } + if(step.flowKnownState === 'skip') { + continue; + } + if(step.error !== undefined && step.error !== null && Object.keys(step.error).length > 0) { + return 'error'; + } + } + return undefined; +} + +export const activityTimelineHasIssue = (activity: PlayApiCommonDetailed): 'warn' | 'error' | undefined => { + const { + queueStates = [], + play: { + scrobble: { + error: scrobbleError, + warnings: scrobbleWarnings = [] + } = {}, + } = {}, + } = activity; + const transformIssue = activityTransformHasIssue(activity); + if(transformIssue !== undefined) { + return transformIssue; + } + if (scrobbleError !== undefined) { + return 'error'; + } + if(scrobbleWarnings.length > 0) { + return 'warn'; + } + if (queueStates.some(x => x.queueStatus === QUEUE_STATUS_FAILED) && !queueStates.some(x => x.queueStatus === QUEUE_STATUS_COMPLETED)) { + return 'error'; + } + + return undefined; } \ No newline at end of file diff --git a/src/core/tests/utils/fixtures.ts b/src/core/tests/utils/fixtures.ts index f021978f..93298006 100644 --- a/src/core/tests/utils/fixtures.ts +++ b/src/core/tests/utils/fixtures.ts @@ -191,23 +191,23 @@ export const generateLifecycleStep = (play: PlayObject, opts: GenerateLifecycleO if(error === true) { step.flowResult = 'stop'; step.flowReason = 'Error encountered while transforming'; - step.error = new Error('Failed to do something', {cause: new Error('Oops it borked.')}); + step.error = serializeError(new Error('Failed to do something', {cause: new Error('Oops it borked.')})); } else if(error === 'prereq') { step.flowResult = 'stop'; step.flowKnownState = 'prereq'; step.flowReason = 'Transform could not be completed due to prerequisite failure'; - step.error = mergeSimpleError(new StagePrerequisiteError('No matches returned from Musicbrainz API', {shortStack: true, cause: new SimpleError('Results were empty')})); + step.error = serializeError(mergeSimpleError(new StagePrerequisiteError('No matches returned from Musicbrainz API', {shortStack: true, cause: new SimpleError('Results were empty')}))); } else if(error === 'stop') { step.flowResult = 'stop'; } else if(error === 'continuewitherror') { step.flowResult = 'continue'; step.flowReason = 'Transform encountered an error but continuing due to onFailure: continue'; - step.error = mergeSimpleError(new SkipTransformStageError('An error that was an okay to continue with')); + step.error = serializeError(mergeSimpleError(new SkipTransformStageError('An error that was an okay to continue with'))); } else { step.flowResult = 'continue'; step.flowKnownState = 'skip'; step.flowReason = `Stage ${name} was skipped`; - step.error = mergeSimpleError(new SkipTransformStageError('No desired MBIDs were missing', {shortStack: true})); + step.error = serializeError(mergeSimpleError(new SkipTransformStageError('No desired MBIDs were missing', {shortStack: true}))); } return [step, play];