feat(ui): Better error/warning indicators for timeline

This commit is contained in:
FoxxMD 2026-07-07 14:22:55 +00:00
parent 97b6042768
commit e6130eb5eb
6 changed files with 102 additions and 26 deletions

View file

@ -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 = <ExclamationCircleIcon size="sm" color="red.focusRing"/>;
} else if(timelineIssue === 'warn') {
timelineStatusIcon = <ExclamationTriangleIcon size="sm" color="yellow.focusRing"/>;
}
return (
<Stack gap="2">
<ActivityErrorSummary activity={props.activity}/>
{/* <ActivityErrorSummary activity={props.activity}/> */}
{error !== undefined && error !== null ? <ErrorAlert error={error}/> : null}
<Accordion.Root width="full" variant="enclosed" collapsible multiple>
<Accordion.Item value="info">
<Accordion.ItemTrigger>
@ -257,9 +262,7 @@ export const ActivityDetails = (props: ActivityDetailProps) => {
<Flex justify="flex-start">
<Accordion.ItemTrigger>
<Accordion.ItemIndicator />
Timeline {error !== undefined && error !== null ? (<Icon size="sm" color="red.focusRing">
<AiOutlineExclamationCircle />
</Icon>) : null}
Timeline {timelineStatusIcon}
</Accordion.ItemTrigger>
<Stack style={{
paddingBlock: "var(--accordion-padding-y)",

View file

@ -1,4 +1,4 @@
import { ComponentProps, useState, Fragment } from "react"
import React, { ComponentProps, useState, Fragment } from "react"
import { Accordion, Timeline, Icon, Span, Stack, Heading, Card, Box, Tabs, Skeleton, SkeletonCircle, SkeletonText, HTMLChakraProps } from '@chakra-ui/react';
import { ComponentType, ErrorLike, JsonPlayObject, PlayActivity } from "../../core/Atomic";
import { PlayData } from "./PlayData";
@ -21,7 +21,7 @@ import { TimelineErrorIcon } from "./timeline/TimelineIcon";
import { Muted } from "./Typography";
import { PlayApiCommonDetailed } from "../../core/Api";
import { MSErrorBoundary } from "./ErrorBoundary";
import { timelineTextFormatting } from "../utils/ComponentUtils";
import { activityTransformHasIssue, timelineTextFormatting } from "../utils/ComponentUtils";
export interface ActivityDetailProps {
@ -104,6 +104,18 @@ export const ActivityTimeline = (props: ActivityDetailProps) => {
}
}
let transformVerb: string = 'Transformed Play';
const transformIssue = activityTransformHasIssue(props.activity);
let transformResult: React.JSX.Element | undefined;
if(transformIssue === 'error') {
transformVerb = 'Transforming Play';
transformResult = <Span> resulted in <Span color="red.solid">an error</Span></Span>;
} else if(transformIssue === 'warn') {
transformVerb = 'Transforming Play';
transformResult = <Span> resulted in <Span color="orange.solid">warnings</Span></Span>;
}
return (
<MSErrorBoundary>
<Timeline.Root variant="subtle" size="lg">
@ -158,7 +170,7 @@ export const ActivityTimeline = (props: ActivityDetailProps) => {
<Timeline.Content gap="4">
<Timeline.Title>
<MSCollapsible
indicator={<Span {...timelineTextFormatting}>Transformed Play <Span color="fg.muted">using configured Rules</Span></Span>}
indicator={<Span {...timelineTextFormatting}>{transformVerb} <Span color="fg.muted">using configured Rules</Span>{transformResult}</Span>}
defaultOpen={collapsibleOpen}
timeline>
<Card.Root bgColor="bg.muted" size="sm">

View file

@ -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";

View file

@ -1,6 +1,8 @@
import {
LuChevronRight,
LuChevronLeft,
LuCircleAlert,
LuTriangleAlert,
LuActivity,
LuGithub,
LuTerminal,
@ -45,7 +47,13 @@ export const makeIconButton = (Icon: IconType) => (props: PropsWithChildren<Comp
</IconButton>
);
}
export const makeChakraIcon = (IconComponent: IconType) => (props: ComponentProps<typeof Icon> & { iconProps?: IconBaseProps }) => <Icon {...props}><IconComponent {...props.iconProps}/></Icon>
export const makeChakraIcon = (IconComponent: IconType) => (props: ComponentProps<typeof Icon> & { iconProps?: IconBaseProps }) => {
const {
iconProps,
...rest
} = props;
return <Icon {...rest}><IconComponent {...iconProps}/></Icon>
}
export const ChevronRight = LuChevronRight;
export const ChevronRightButton = (props: ComponentProps<typeof IconButton>) => (
@ -163,4 +171,8 @@ export const InsertedIcon = makeChakraIcon(MdOutlineFiberNew);
export const UpdatedIcon = makeChakraIcon(LuCircleArrowUp);
export const CopyIcon = makeChakraIcon(LuCopy);
export const CopyIconButton = makeIconButton(LuCopy);
export const CopyIconButton = makeIconButton(LuCopy);
export const ExclamationCircleIcon = makeChakraIcon(LuCircleAlert);
export const ExclamationTriangleIcon = makeChakraIcon(LuTriangleAlert);

View file

@ -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;
}

View file

@ -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];