diff --git a/src/client/components/ActivityDetail.tsx b/src/client/components/ActivityDetail.tsx new file mode 100644 index 00000000..3ae60853 --- /dev/null +++ b/src/client/components/ActivityDetail.tsx @@ -0,0 +1,47 @@ +import { ComponentProps } from "react" +import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Icon } from '@chakra-ui/react'; +import { ErrorLike, PlayActivity } from "../../core/Atomic"; +import { PlayData } from "./PlayData"; +import { ErrorAlert } from "./ErrorAlert"; +import { AiOutlineExclamationCircle } from "react-icons/ai"; + +export interface ActivityDetailProps { + activity: PlayActivity +} + +export const ActivityDetails = (props: ActivityDetailProps) => { + const { + activity, + activity: { + error + } + } = props; + return ( + + + + + Play Data + + + + + + + + + + + Timeline {error !== undefined ? ( + + ) : null} + + + + {error !== undefined ? : null} + + + + + ) +} \ No newline at end of file diff --git a/src/client/components/ChakraClipboard.tsx b/src/client/components/ChakraClipboard.tsx index 6f32d6cb..28738ddd 100644 --- a/src/client/components/ChakraClipboard.tsx +++ b/src/client/components/ChakraClipboard.tsx @@ -1,17 +1,21 @@ -import { useMemo } from 'react'; +import { ComponentProps, useMemo } from 'react'; import { Clipboard, IconButton } from "@chakra-ui/react" import {safeStringify} from '../../core/StringUtils'; -export const ChakraClip = (props: {value: any}) => { +export const ChakraClip = (props: Omit, 'children' | 'value'> & {value: any}) => { + const { + value, + ...rest + } = props; const clipVal = useMemo(() => { - if(typeof props.value === 'string') { - return props.value; + if(typeof value === 'string') { + return value; } - return safeStringify(props.value); - },[props.value]) + return safeStringify(value); + },[value]) return ( - + diff --git a/src/client/components/CodeBlock.tsx b/src/client/components/CodeBlock.tsx index 1b0cb110..9aa00acd 100644 --- a/src/client/components/CodeBlock.tsx +++ b/src/client/components/CodeBlock.tsx @@ -1,12 +1,13 @@ import type { HighlighterGeneric } from "shiki" import { createShikiAdapter, CodeBlock, IconButton, ClientOnly, ScrollArea } from "@chakra-ui/react" import { useColorMode } from "./Color-Mode"; +import { ComponentProps } from "react"; const shikiAdapter = createShikiAdapter>({ async load() { const { createHighlighter } = await import("shiki") return createHighlighter({ - langs: ["json"], + langs: ["json", "plaintext"], themes: ["github-dark", "github-light"], }) }, @@ -16,31 +17,62 @@ const shikiAdapter = createShikiAdapter>({ }, }); -export interface ChakraCodeBlockProps { +export type ChakraCodeBlockProps = Omit, 'children'> & { code: string language?: string title?: string maxHeight?: string + maxLines?: number + collapsedMaxHeight?: string } export const ChakraCodeBlock = (props: ChakraCodeBlockProps) => { + const { + maxHeight = '70vh', + maxLines, + collapsedMaxHeight = '320px', + language = 'json', + ...rest + } = props; + + const contentProps: ComponentProps = maxLines === undefined ? { maxHeight, overflowY: 'auto' } : { css: {"--code-block-max-height": collapsedMaxHeight}}; + return ( Loading...}> {() => ( - + {props.title ?? ' '} - - - - - + + + + + + + + + + + + - + + + + + + + )} diff --git a/src/client/components/ErrorAlert.tsx b/src/client/components/ErrorAlert.tsx new file mode 100644 index 00000000..7bc334c1 --- /dev/null +++ b/src/client/components/ErrorAlert.tsx @@ -0,0 +1,62 @@ +import { Alert, Accordion, Stack, Text, Box } from '@chakra-ui/react'; +import { Fragment } from 'react'; +import { ErrorLike } from '../../core/Atomic'; +import { ChakraCodeBlock } from './CodeBlock'; +import { ChakraClip } from './ChakraClipboard'; + +export interface ErrorAlertProps { + error: ErrorLike +} + +export const ErrorAlert = (props: ErrorAlertProps) => { + + let causes: ErrorData[] = []; + if(props.error.cause !== undefined && typeof props.error.cause === 'object') { + causes = walkError(props.error.cause as ErrorLike); + } + + return ( + + + + + {props.error.name ?? 'Error'} + + + {props.error.message} + {props.error.stack !== undefined ? : null} + {causes.map(x => ( + + Caused By: {x.name ?? ''}{x.code !== undefined ? ` (${x.code}) ` : ''}{x.message} + {x.stack !== undefined ? : null} + + ))} + + + + + + + ) +} + +interface ErrorData { + name?: string + code?: string + message?: string + stack?: string +} + +const walkError = (err: ErrorLike, errors: ErrorData[] = []): ErrorData[] => { + const thisErr: ErrorData = { + name: err.name, + code: 'code' in err ? err.code : undefined, + message: err.message, + stack: err.stack + }; + errors.push(thisErr); + if(err.cause !== undefined && typeof err.cause === 'object') { + return walkError(err, errors); + } + return errors; +} \ No newline at end of file diff --git a/src/client/components/List.tsx b/src/client/components/List.tsx index 864554cb..dfb126cb 100644 --- a/src/client/components/List.tsx +++ b/src/client/components/List.tsx @@ -1,15 +1,12 @@ import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container } from '@chakra-ui/react'; -import { JsonPlayObject } from '../../core/Atomic'; +import { JsonPlayObject, PlayActivity } from '../../core/Atomic'; import { ShortDateDisplay } from './DateDisplay'; import { TextMuted } from './TextMuted'; import { capitalize } from '../../core/StringUtils'; import { ComponentProps } from "react" import { VscDebugRestart } from "react-icons/vsc"; -import { PlayInfo, PlayInfoContainer } from './PlayInfo'; -export interface PlayActivity { - play: JsonPlayObject - status: string -} +import { PlayData, PlayInfoContainer } from './PlayData'; +import { ActivityDetails } from './ActivityDetail'; export interface ActivityLogProps { data: PlayActivity[] } @@ -58,32 +55,7 @@ export const CList = (props: ActivityLogProps) => { - - - - - - Play Info - - - - - - - - - - - Timeline - - - - test - - - - - + diff --git a/src/client/components/PlayInfo.tsx b/src/client/components/PlayData.tsx similarity index 94% rename from src/client/components/PlayInfo.tsx rename to src/client/components/PlayData.tsx index b269e9ff..c7aede08 100644 --- a/src/client/components/PlayInfo.tsx +++ b/src/client/components/PlayData.tsx @@ -1,14 +1,14 @@ import React, { Fragment, useMemo, useState } from 'react'; import { EmptyState, DataList, HStack, Tag, Wrap, Box, Flex, SegmentGroup, Stack, Text, Separator, IconButton, Container, SimpleGrid } from "@chakra-ui/react" import { LuCode, LuText } from "react-icons/lu" -import { JsonPlayObject } from '../../core/Atomic'; -import { shortTodayAwareFormat } from '../../core/TimeUtils'; +import { JsonPlayObject } from '../../core/Atomic.js'; +import { shortTodayAwareFormat } from '../../core/TimeUtils.js'; import dayjs from 'dayjs'; -import { ChakraCodeBlock } from './CodeBlock'; -import { safeStringify } from '../../core/StringUtils'; -import { TextMuted } from './TextMuted'; +import { ChakraCodeBlock } from './CodeBlock.js'; +import { safeStringify } from '../../core/StringUtils.js'; +import { TextMuted } from './TextMuted.js'; -const EmptyPlay = () => { +const EmptyPlayData = () => { return ( @@ -29,7 +29,7 @@ export interface PlayInfoProps { showDates?: false | 'all' | 'played' | 'seen' } -export const PlayInfo = (props?: PlayInfoProps) => { +export const PlayData = (props?: PlayInfoProps) => { const { play, final, @@ -39,7 +39,7 @@ export const PlayInfo = (props?: PlayInfoProps) => { } = props ?? {}; if (play === undefined) { - return + return } const [compareVal, setCompareVal] = useState('Original') @@ -180,5 +180,5 @@ export const PlayInfo = (props?: PlayInfoProps) => { } export const PlayInfoContainer = (props?: PlayInfoProps) => { - return + return } \ No newline at end of file diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 935c608c..f4081572 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -290,6 +290,8 @@ export interface PlayObjectLifecycleless { // lifecycle: PlayLifecycle // } +export type ErrorLike = Error | ErrorObject; + export interface PlayLifecycle { input?: object original: PlayObjectLifecycleless @@ -353,6 +355,11 @@ export interface PlayObject extends AmbPlayObject { // meta: PlayMetaLifecycled // } +export interface PlayActivity { + play: JsonPlayObject + status: string + error?: ErrorLike +} export interface JsonPlayObject extends AmbPlayObject { data: JsonPlayData } diff --git a/src/stories/ErrorAlert.stories.tsx b/src/stories/ErrorAlert.stories.tsx new file mode 100644 index 00000000..e4f4135a --- /dev/null +++ b/src/stories/ErrorAlert.stories.tsx @@ -0,0 +1,53 @@ +import preview from "../../.storybook/preview.js"; +import React from 'react'; +import { Container } from '@chakra-ui/react'; + +import { fn } from 'storybook/test'; +import { ErrorAlert } from "../client/components/ErrorAlert.js"; +import {Provider} from "../client/components/Provider"; +import { ErrorLike } from "../core/Atomic.js"; + +const stack = "Scrobble Submit Error: Failed to submit to Listenbrainz (listen_type single)\n at ListenbrainzApiClient.submitListen (/app/src/backend/common/vendor/ListenbrainzApiClient.ts:246:19)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async ListenbrainzScrobbler.doScrobble (/app/src/backend/scrobblers/ListenbrainzScrobbler.ts:87:28)\n at async ListenbrainzScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:679:28)\n at async ListenbrainzScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:920:39)\n at async ListenbrainzScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:894:43)\n at async PromisePoolExecutor.handler (/app/src/backend/tasks/heartbeatClients.ts:35:21)\n at async PromisePoolExecutor.waitForActiveTaskToFinish (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:375:9)\n at async PromisePoolExecutor.waitForProcessingSlot (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:368:13)\n at async PromisePoolExecutor.process (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:354:13)"; + +const errorExample: ErrorLike = { + showStopper: false, + name: "Scrobble Submit Error", + message: "Failed to submit to Listenbrainz (listen_type single)", + stack: `${stack}`, + cause: { + errno: -104, + code: "ECONNRESET", + syscall: "read", + name: "Error", + message: "read ECONNRESET", + stack: "Error: read ECONNRESET\n at TLSWrap.onStreamRead (node:internal/stream_base_commons:218:20)\n at TLSWrap.callbackTrampoline (node:internal/async_hooks:130:17)" + } +} + +type PropsAndCustomArgs = React.ComponentProps & { +}; +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta = preview.type<{args: PropsAndCustomArgs}>().meta({ + title: 'Examples/ErrorAlert', + component: ErrorAlert, + parameters: { + // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout + layout: 'padded', + }, + // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs + tags: ['autodocs'], +decorators: [ + (Story) => (), + ], +args: { + error: errorExample, + }, + // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args +}); + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const ErrorAlertStory = meta.story({ + render: function Render(args) { + return () + } +}); \ No newline at end of file diff --git a/src/stories/List.stories.tsx b/src/stories/List.stories.tsx index 47731765..29cfb7b7 100644 --- a/src/stories/List.stories.tsx +++ b/src/stories/List.stories.tsx @@ -2,16 +2,33 @@ import preview from "../../.storybook/preview.js"; import React from 'react'; import { fn } from 'storybook/test'; -import { CList, ListContainer } from "../client/components/List"; +import { Container } from '@chakra-ui/react'; +import { CList } from "../client/components/List"; import {Provider} from "../client/components/Provider"; import { generateJsonPlays } from "../backend/tests/utils/PlayTestUtils.js"; +import { ErrorLike } from "../core/Atomic.js"; +const stack = "Scrobble Submit Error: Failed to submit to Listenbrainz (listen_type single)\n at ListenbrainzApiClient.submitListen (/app/src/backend/common/vendor/ListenbrainzApiClient.ts:246:19)\n at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n at async ListenbrainzScrobbler.doScrobble (/app/src/backend/scrobblers/ListenbrainzScrobbler.ts:87:28)\n at async ListenbrainzScrobbler.scrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:679:28)\n at async ListenbrainzScrobbler.processDeadLetterScrobble (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:920:39)\n at async ListenbrainzScrobbler.processDeadLetterQueue (/app/src/backend/scrobblers/AbstractScrobbleClient.ts:894:43)\n at async PromisePoolExecutor.handler (/app/src/backend/tasks/heartbeatClients.ts:35:21)\n at async PromisePoolExecutor.waitForActiveTaskToFinish (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:375:9)\n at async PromisePoolExecutor.waitForProcessingSlot (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:368:13)\n at async PromisePoolExecutor.process (/app/node_modules/@supercharge/promise-pool/dist/promise-pool-executor.js:354:13)"; +const errorExample: ErrorLike = { + showStopper: false, + name: "Scrobble Submit Error", + message: "Failed to submit to Listenbrainz (listen_type single)", + stack: `${stack}`, + cause: { + errno: -104, + code: "ECONNRESET", + syscall: "read", + name: "Error", + message: "read ECONNRESET", + stack: "Error: read ECONNRESET\n at TLSWrap.onStreamRead (node:internal/stream_base_commons:218:20)\n at TLSWrap.callbackTrampoline (node:internal/async_hooks:130:17)" + } +} // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = preview.meta({ title: 'List', - component: ListContainer, + component: CList, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout layout: 'padded', @@ -20,10 +37,20 @@ const meta = preview.meta({ tags: ['autodocs'], // More on argTypes: https://storybook.js.org/docs/api/argtypes args: { - data: (generateJsonPlays(3, undefined, {source: 'Spotify'})).map((x, index) => ({play: x, status: index === 0 ? 'queued' : index === 1 ? 'scrobbled' : index === 2 ? 'error' : 'unknown'})), + data: (generateJsonPlays(3, undefined, {source: 'Spotify'})).map((x, index) => { + const mod = (index + 1) % 3; + switch(mod) { + case 1: + return {play: x, status: 'queued'} + case 2: + return {play: x, status: 'scrobbled'} + case 0: + return {play: x, status: 'error', error: errorExample}; + } + }), }, decorators: [ - (Story) => (), + (Story) => (), ] // Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#story-args }); diff --git a/src/stories/PlayInfo.stories.tsx b/src/stories/PlayInfo.stories.tsx index 7eb600b4..6ee1aed4 100644 --- a/src/stories/PlayInfo.stories.tsx +++ b/src/stories/PlayInfo.stories.tsx @@ -2,12 +2,12 @@ import preview from "../../.storybook/preview.js"; import React from 'react'; import { fn } from 'storybook/test'; -import { PlayInfo, PlayInfoContainer } from "../client/components/PlayInfo"; +import { PlayData, PlayInfoContainer } from "../client/components/PlayData.js"; import {Provider} from "../client/components/Provider"; import { generateArtists, generateJsonPlay, generatePlay } from "../backend/tests/utils/PlayTestUtils" import clone from "clone"; -type PropsAndCustomArgs = React.ComponentProps & { +type PropsAndCustomArgs = React.ComponentProps & { includeAlbumArtists?: boolean; defaultFinal?: boolean };