diff --git a/src/client/components/ActivityTimeline.tsx b/src/client/components/ActivityTimeline.tsx index 28bbf6d4..d92f337b 100644 --- a/src/client/components/ActivityTimeline.tsx +++ b/src/client/components/ActivityTimeline.tsx @@ -1,6 +1,6 @@ import React, { Fragment } from "react" import { Timeline, Icon, Span, Card, Tabs, SkeletonCircle, SkeletonText, HTMLChakraProps } from '@chakra-ui/react'; -import { CLIENT_DEAD_QUEUE, CLIENT_INGRESS_QUEUE, ComponentType, QUEUE_STATUS_COMPLETED, QUEUE_STATUS_FAILED, QUEUE_STATUS_QUEUED } from "../../core/Atomic"; +import { CLIENT_DEAD_QUEUE, CLIENT_INGRESS_QUEUE, ComponentType, JsonPlayObject, LifecycleStep, PlayMatchResult, QUEUE_STATUS_COMPLETED, QUEUE_STATUS_FAILED, QUEUE_STATUS_QUEUED, ScrobbleResult } from "../../core/Atomic"; import { PlayData } from "./PlayData"; import { ErrorAlert } from "./ErrorAlert"; import { IoMdCodeDownload } from "react-icons/io"; @@ -11,7 +11,7 @@ import { capitalize } from "../../core/StringUtils"; import { shortTodayAwareFormat } from "../../core/TimeUtils"; import dayjs, { Dayjs } from "dayjs"; import { ChakraCodeBlockShort } from "./CodeBlock"; -import { TransformSteps } from "./TransformSteps"; +import { diffElements, TransformSteps } from "./TransformSteps"; import { ScrobbleMatchResult } from "./ScrobbleMatchResult"; import { ScrobbleActionResult } from "./ScrobbleActionResult"; import { MSCollapsible } from "./MSCollapsible"; @@ -21,6 +21,7 @@ import { PlayApiCommonDetailed, QueueStateApi } from "../../core/Api"; import { MSErrorBoundary } from "./ErrorBoundary"; import { activityTransformHasIssue, timelineTextFormatting } from "../utils/ComponentUtils"; import { CheckIcon, TimelineIndicatorIconQueued } from "./icons/ChakraIcons"; +import { sortByNewestDate } from "../../core/PlayUtils"; export interface ActivityDetailProps { @@ -29,8 +30,7 @@ export interface ActivityDetailProps { componentType?: ComponentType } -const TimelineLoading = () => { - return ( +const TimelineLoading = () => ( @@ -49,10 +49,242 @@ const TimelineLoading = () => { - ); + ) + +const QueuedCreatedItem = (props: { dead?: boolean, datetime: string }) => ( + + + + + + + + + + {props.dead ? 'Dead ' : ''}Queued at {shortTodayAwareFormat(dayjs(props.datetime))} + + + +) + +const NewItem = (props: Pick) => { + const { + activity: { + play, + input, + seenAt, + } = {}, + collapsibleOpen, + componentType + } = props; + const { + meta: { + source, + } = {}, + } = play; + const { + play: original, + data: ogInput + } = input || {}; + + return ( + + + + + + + + + + + + + {componentType === 'source' ? 'Discovered' : 'Recieved'} new Play from {capitalize(source)} at {shortTodayAwareFormat(dayjs(seenAt))} + } + defaultOpen={collapsibleOpen} + timeline + disableUntil="md"> + + + + + Play + {ogInput !== undefined ? Source Data : null} + + + + + {ogInput !== undefined ? ( + + ) : null} + + + + + + + + ) +} + +const TransformsItem = (props: Pick & { steps: LifecycleStep[], original: JsonPlayObject }) => { + const { + steps = [], + collapsibleOpen, + original + } = props; + let transformVerb: string = 'Transformed Play'; + + const transformIssue = activityTransformHasIssue(steps); + 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 ( + + + + + + + + + + + {transformVerb} using configured Rules for {steps[0].hook} {transformResult}} + defaultOpen={collapsibleOpen} + timeline> + + + + + + + + + + ) } -const QueueTimelineItem = (props: {queueState: QueueStateApi, collapsibleOpen: boolean, /*isBefore?: Dayjs, isAfter?: Dayjs*/}) => { +const NoTransformsItem = () => ( + + + + + + + + + + + + Play was not transformed because no Transform Rules were used/configured. + + + +) + +const ScrobbleMatchItem = (props: Pick & { match: PlayMatchResult }) => { + const { + match, + collapsibleOpen + } = props; + + return ( + + + + + + + + + + + + Found {match.match ? a duplicate Scrobble : 'no duplicate Scrobbles'}} + defaultOpen={collapsibleOpen} + disableUntil="md" + timeline> + + + + + + + + + + ) + +} + +const ScrobbleResponseItem = (props: Pick & { scrobble: ScrobbleResult }) => { + const { + scrobble: { + payload, + error, + warnings = [] + } = {}, + scrobble, + collapsibleOpen + } = props; + + let scrobbleSummary: React.JSX.Element; + const scrobbleIconProps: Record = { + color: 'green.focusRing' + }; + if (payload !== undefined) { + if (error !== undefined) { + scrobbleSummary = Scrobble attempt to Client resulted in an error. + } else if (warnings.length > 0) { + scrobbleSummary = Scrobbled to Client but response has warnings.; + scrobbleIconProps.orange = 'orange.focusRing'; + } else { + scrobbleSummary = Scrobbled to Client successfully.; + } + } + + return ( + + + + + {error !== undefined ? : ( + + + + )} + + + + + + + + + + + + + + + ) +} + +const QueueTimelineItem = (props: {queueState: QueueStateApi, collapsibleOpen: boolean}) => { const { queueState, collapsibleOpen, @@ -126,22 +358,8 @@ const QueueTimelineItem = (props: {queueState: QueueStateApi, collapsibleOpen: b } } -const TimeFiltered = (props: {datetime: Dayjs, isBefore?: Dayjs, isAfter?: Dayjs, children: React.ReactNode}) => { - const { - isBefore, - isAfter, - datetime, - children - } = props; - if(isBefore !== undefined && !datetime.isBefore(isBefore)) { - return null; - } - if(isAfter !== undefined && !datetime.isAfter(isAfter)) { - return null; - } - - return children; -} +type TransformStepsTimelineData = {id: 'transform-steps', dt: Dayjs, steps: LifecycleStep[], original?: JsonPlayObject}; +type TimelineData = {id: string, dt: Dayjs}; export const ActivityTimeline = (props: ActivityDetailProps) => { @@ -160,217 +378,93 @@ export const ActivityTimeline = (props: ActivityDetailProps) => { componentType } = props; const { - data: { - playDate - }, - meta: { - source, - } = {}, lifecycle: steps = [], scrobble: { match, payload, - error, - warnings = [] + createdAt: scrobbleResultCreatedAt } = {}, scrobble, } = play; const { play: original, - data: ogInput } = input || {}; - let scrobbleSummary: React.JSX.Element; - const scrobbleIconProps: Record = { - color: 'green.focusRing' - }; - if (payload !== undefined) { - if (error !== undefined) { - scrobbleSummary = Scrobble attempt to Client resulted in an error. - } else if (warnings.length > 0) { - scrobbleSummary = Scrobbled to Client but response has warnings.; - scrobbleIconProps.orange = 'orange.focusRing'; - } else { - scrobbleSummary = Scrobbled to Client successfully.; - } - } - - let transformVerb: string = 'Transformed Play'; + const timelineItems: (TimelineData|TransformStepsTimelineData)[] = [ + {id: 'new', dt: dayjs(seenAt)}, + ]; - 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; - } - - let queueItem: React.JSX.Element | undefined, - queueDateTime: Dayjs | undefined, - deadItem: React.JSX.Element | undefined, - deadDateTime: Dayjs | undefined; const ingressQueue = queueStates.find(x => x.queueName === CLIENT_INGRESS_QUEUE); if(ingressQueue !== undefined) { - queueDateTime = dayjs(ingressQueue.updatedAt); - queueItem = + timelineItems.push({id: 'queue-ingress-created', dt: dayjs(ingressQueue.createdAt)}); + timelineItems.push({id: 'queue-ingress-updated', dt: dayjs(ingressQueue.updatedAt)}); } const deadqueue = queueStates.find(x => x.queueName === CLIENT_DEAD_QUEUE); if(deadqueue !== undefined) { - deadDateTime = dayjs(deadqueue.updatedAt); - deadItem = + timelineItems.push({id: 'queue-dead-created', dt: dayjs(deadqueue.createdAt)}); + timelineItems.push({id: 'queue-dead-updated', dt: dayjs(deadqueue.updatedAt)}); + } + + if(match !== undefined) { + timelineItems.push({id: 'scrobble-match', dt: dayjs(match.createdAt)}); + } + if(payload !== undefined) { + timelineItems.push({id: 'scrobble-response', dt: dayjs(scrobbleResultCreatedAt ?? match.createdAt)}) + } + + // group transforms by hook + const transformGroups: Record = steps.length === 0 ? {} : steps.reduce((acc, curr) => { + if(acc[curr.hook] === undefined) { + acc[curr.hook] = []; + } + return {...acc, [curr.hook]: [...acc[curr.hook], curr]}; + }, {}); + + let lastTransformedPlay = original; + for(const [_,v] of Object.entries(transformGroups)) { + const d: TransformStepsTimelineData = {id: 'transform-steps', dt: dayjs(v[0].createdAt), steps: v, original: lastTransformedPlay}; + const [__, finalPlay] = diffElements(lastTransformedPlay, v); + lastTransformedPlay = finalPlay; + timelineItems.push(d); } - const stepsDateTime = steps.length > 0 ? dayjs(steps[0].createdAt) : undefined; - // TODO add actual timestamp to scrobble action so we can make this more accurate - const scrobbleDateTime = match !== undefined ? dayjs(match.createdAt) : undefined; + timelineItems.sort((a, b) => sortByNewestDate(b.dt, a.dt)); + + const timelineElements: React.JSX.Element[] = timelineItems.map((x) => { + const timelineKey = `${x.id}-${x.dt.unix()}`; + switch(x.id) { + case 'new': + { + const newElm = ; + if(steps.length === 0) { + return [newElm, ]; + } + return newElm; + } + case 'queue-ingress-created': + case 'queue-dead-created': + return ; + case 'queue-ingress-updated': + return ; + case 'queue-dead-updated': + return ; + case 'scrobble-match': + return ; + case 'scrobble-response': + return ; + case 'transform-steps': + { + const val = x as TransformStepsTimelineData; + return + } + } + return undefined; + }).flat(); return ( - - - - - - - - - - - - - {componentType === 'source' ? 'Discovered' : 'Recieved'} new Play from {capitalize(source)} at {shortTodayAwareFormat(dayjs(seenAt))} - } - defaultOpen={collapsibleOpen} - timeline - disableUntil="md"> - - - - - Play - {ogInput !== undefined ? Source Data : null} - - - - - {ogInput !== undefined ? ( - - ) : null } - - - - - - - - {queueItem && stepsDateTime && {queueItem}} - {deadItem && stepsDateTime && {deadItem}} - {steps.length > 0 ? ( - - - - - - - - - - - - {transformVerb} using configured Rules{transformResult}} - defaultOpen={collapsibleOpen} - timeline> - - - - - - - - - - ) : ( - - - - - - - - - - - - Play was not transformed because no Transform Rules were used/configured. - - - - )} - {queueItem && {queueItem}} - {deadItem && {deadItem}} - {match !== undefined ? ( - - - - - - - - - - - - Found {match.match ? a duplicate Scrobble : 'no duplicate Scrobbles'}} - defaultOpen={collapsibleOpen} - disableUntil="md" - timeline> - - - - - - - - - - ) : null} - {payload !== undefined ? ( - - - - - {error !== undefined ? : ( - - - - )} - - - - - - - - - - - - - - - ) : null} - {queueItem && {queueItem}} - {deadItem && {deadItem}} + {timelineElements} ) diff --git a/src/client/components/TransformSteps.tsx b/src/client/components/TransformSteps.tsx index a2864271..2f9bcb25 100644 --- a/src/client/components/TransformSteps.tsx +++ b/src/client/components/TransformSteps.tsx @@ -19,7 +19,7 @@ export interface LifeycleStepsTimelineProps extends MSCollapsibleExternalProps { original: JsonPlayObject } -const diffElements = (original: JsonPlayObject, steps: LifecycleStep[]): [React.JSX.Element[], JsonPlayObject?] => { +export const diffElements = (original: JsonPlayObject, steps: LifecycleStep[]): [React.JSX.Element[], JsonPlayObject?] => { let currentPlay: JsonPlayObject = structuredClone(original); // JSON.parse(JSON.stringify(original)); let patchFailed = false; @@ -105,7 +105,7 @@ export const TransformSteps = (props: LifeycleStepsTimelineProps) => { iconProps: Record, summary: React.JSX.Element, alertStatus: "error" | "info" | "warning" | "success" | "neutral"; - if(error === undefined) { + if(error === undefined && error !== null) { timelineIcon = ; iconProps = flowResult === 'continue' ? {color: "green.focusRing"} : {color: "red.focusRing"}; summary = was completed{flowResult === 'stop' ? and stopped due to onSuccess condition : null}; @@ -151,7 +151,7 @@ export const TransformSteps = (props: LifeycleStepsTimelineProps) => { defaultOpen={collapsibleOpen} disableUntil="md" timeline> - {error !== undefined ? : null} + {error !== undefined && error !== null ? : null} {diffs[index] !== null ? ( diff --git a/src/client/utils/ComponentUtils.tsx b/src/client/utils/ComponentUtils.tsx index 4fcf76d5..6a67334f 100644 --- a/src/client/utils/ComponentUtils.tsx +++ b/src/client/utils/ComponentUtils.tsx @@ -1,6 +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'; +import { LifecycleStep, QUEUE_STATUS_COMPLETED, QUEUE_STATUS_FAILED } from '../../core/Atomic'; export const cardHeaderSeparator: Card.HeaderProps = { borderBottomWidth: "1px", @@ -12,13 +12,8 @@ export const timelineTextFormatting: HTMLChakraProps<"span"> = { textWrap: "balance" } -export const activityTransformHasIssue = (activity: PlayApiCommon): 'warn' | 'error' | undefined => { - const { - play: { - lifecycle = [], - } = {}, - } = activity; - for(const step of lifecycle) { +export const activityTransformHasIssue = (steps: LifecycleStep[]): 'warn' | 'error' | undefined => { + for(const step of steps) { if(step.flowKnownState === 'prereq') { return 'warn'; } @@ -36,13 +31,14 @@ export const activityTimelineHasIssue = (activity: PlayApiCommonDetailed): 'warn const { queueStates = [], play: { + lifecycle = [], scrobble: { error: scrobbleError, warnings: scrobbleWarnings = [] } = {}, } = {}, } = activity; - const transformIssue = activityTransformHasIssue(activity); + const transformIssue = activityTransformHasIssue(lifecycle); if(transformIssue !== undefined) { return transformIssue; } diff --git a/src/core/Api.ts b/src/core/Api.ts index 3064ec0b..a13ec267 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -34,6 +34,7 @@ export interface QueueStateApi { retries: number error?: ErrorLike updatedAt: string + createdAt: string } export interface PlayApiCommonDetailed extends PlayApiCommon {