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 {