From 3ce94f443aa37bb51a1415d997807a65599728dc Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 11 Jun 2026 18:55:11 +0000 Subject: [PATCH] feat(ui): Implement component overview and api fixtures --- .../common/database/drizzle/drizzleTypes.ts | 1 + src/client/components/DateDisplay.tsx | 16 ++- src/client/components/icons/ChakraIcons.tsx | 10 ++ .../msComponent/MSComponentQuickDisplay.tsx | 99 +++++++++++++ src/core/Api.ts | 55 +++++++- src/core/Atomic.ts | 7 +- src/core/tests/utils/apiFixtures.ts | 130 +++++++++++++++++- src/stories/ComponentSummary.stories.tsx | 49 +++++++ 8 files changed, 357 insertions(+), 10 deletions(-) create mode 100644 src/client/components/icons/ChakraIcons.tsx create mode 100644 src/client/components/msComponent/MSComponentQuickDisplay.tsx create mode 100644 src/stories/ComponentSummary.stories.tsx diff --git a/src/backend/common/database/drizzle/drizzleTypes.ts b/src/backend/common/database/drizzle/drizzleTypes.ts index 8d7f7abe..a63972e7 100644 --- a/src/backend/common/database/drizzle/drizzleTypes.ts +++ b/src/backend/common/database/drizzle/drizzleTypes.ts @@ -6,6 +6,7 @@ import { MarkOptional, MarkRequired } from "ts-essentials"; export type ComponentNew = typeof components.$inferInsert; export type ComponentSelect = GenericRelationResult<'components', 'migrations'>; +export type ComponentMinimalSelect = typeof components.$inferSelect; export type ComponentMigrationNew = typeof componentMigrations.$inferInsert; export type ComponentMigrationSelect = typeof componentMigrations.$inferSelect; diff --git a/src/client/components/DateDisplay.tsx b/src/client/components/DateDisplay.tsx index 51072b78..f85cbfc6 100644 --- a/src/client/components/DateDisplay.tsx +++ b/src/client/components/DateDisplay.tsx @@ -1,14 +1,20 @@ import { Text } from "@chakra-ui/react" import dayjs, {Dayjs} from 'dayjs'; import { shortTodayAwareFormat } from "../../core/TimeUtils"; +import { ComponentProps } from "react" -export interface DateDisplayProps { +export type DateDisplayProps = { date?: string | Dayjs prefix?: string -} +} & ComponentProps export const ShortDateDisplay = (props: DateDisplayProps) => { - if(props.date === undefined) { - return (No Date) + const { + date, + prefix, + ...rest + } = props; + if(date === undefined) { + return (No Date) } - return {`${props.prefix !== undefined ? `${props.prefix} ` : ''}${shortTodayAwareFormat(typeof props.date === 'string' ? dayjs(props.date) : props.date)}`} + return {`${prefix !== undefined ? `${prefix} ` : ''}${shortTodayAwareFormat(typeof date === 'string' ? dayjs(date) : date)}`} } \ No newline at end of file diff --git a/src/client/components/icons/ChakraIcons.tsx b/src/client/components/icons/ChakraIcons.tsx new file mode 100644 index 00000000..9f0cfdf2 --- /dev/null +++ b/src/client/components/icons/ChakraIcons.tsx @@ -0,0 +1,10 @@ +import { LuChevronRight } from "react-icons/lu" +import { IconButton } from "@chakra-ui/react" +import { ComponentProps } from 'react'; + +export const ChevronRight = LuChevronRight; +export const ChevronRightButton = (props: ComponentProps) => ( + + + +); \ No newline at end of file diff --git a/src/client/components/msComponent/MSComponentQuickDisplay.tsx b/src/client/components/msComponent/MSComponentQuickDisplay.tsx new file mode 100644 index 00000000..59f2084b --- /dev/null +++ b/src/client/components/msComponent/MSComponentQuickDisplay.tsx @@ -0,0 +1,99 @@ +import React, { ComponentProps, useMemo, forwardRef, Fragment } from "react" +import { Accordion, For, Span, Stack, Text, Box, Heading, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Collapsible, Card, LinkOverlay, LinkBox } from '@chakra-ui/react'; +import { ComponentCommonApi, ComponentCommonApiJson, isComponentClientApiJson, isComponentSourceApiJson } from "../../../core/Api"; +import { TextMuted } from "../TextMuted"; +import { isClientType } from "../../../backend/common/infrastructure/Atomic"; +import { capitalize } from "../../../core/StringUtils"; +import { ShortDateDisplay } from "../DateDisplay"; +import { ChevronRightButton } from "../icons/ChakraIcons"; + +export const MSComponentSummary = (props: { data: ComponentCommonApiJson }) => { + const { + data + } = props; + const isClient = isClientType(data.type); + return ( + + + + {data.name} + + + + + + + + + {/* {props.data.status} */} + + + + {isClient ? `(${data.mode}) ` : ''}{capitalize(data.type)} + + + + ) +} + +const QuickStatsSource = (props: { data: ComponentCommonApiJson }) => { + if (isComponentSourceApiJson(props.data)) { + const { + tracksDiscovered, + countLive + } = props.data; + return ( + + + {tracksDiscovered} Discovered + + + ) + } else if (isComponentClientApiJson(props.data)) { + const { + queued, + deadLetterScrobbles, + deadLetterScrobblesTotal, + countLive, + } = props.data; + + return ( + + + {queued} Queued + + {deadLetterScrobbles} ({deadLetterScrobblesTotal}) Dead (Total) + + {countLive} Scrobbled + + + ) + } +} + +const StateBadge = (props: ComponentProps & { data: ComponentCommonApiJson }) => { + + const { data, ...rest } = props; + + let badgeColor = undefined, + badgeText = capitalize(data.state); + + switch (data.state) { + case 'stopped': + badgeColor = 'gray'; + break; + case 'running': + case 'polling': + case 'awaiting data': + badgeColor = 'green'; + break; + case 'error': + badgeColor = 'red'; + break; + case 'idle': + badgeColor = 'orange'; + break; + } + + return {badgeText} +} \ No newline at end of file diff --git a/src/core/Api.ts b/src/core/Api.ts index 6f395d45..fab5bd59 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -1,4 +1,9 @@ -import { ErrorLike, JsonPlayObject, PlayState } from "./Atomic.js" +import { PickKeys, StrictOmit } from "ts-essentials" +import { ComponentMinimalSelect } from "../backend/common/database/drizzle/drizzleTypes.js" +import { ClientType } from "../backend/common/infrastructure/config/client/clients.js" +import { SourceType } from "../backend/common/infrastructure/config/source/sources.js" +import { ErrorLike, JsonPlayObject, PlayState, Replace, SOURCE_SOT_TYPES, SourcePlayerJson } from "./Atomic.js" +import { Dayjs } from "dayjs" export interface PlayApiCommon { uid: string @@ -34,4 +39,52 @@ export interface PlayApiCommonDetailed extends PlayApiCommon { error?: ErrorLike input?: PlayInputApi queueStates: QueueStateApi[] +} + +export type ComponentCommonApi = { + type: SourceType | ClientType + name: string + /** General state of the component like Idle, Stopped, Running, Error */ + state: string + /** More specific, live activity state like "sleeping", "hydrating historical scrobbles", "processing dead scrobbles", etc... */ + status?: string +} & Omit + +export type ComponentCommonApiJson = Replace, string>; + +export type ComponentDetailedApi = ComponentCommonApi & { + hasAuth: boolean; + hasAuthInteraction: boolean; + authed: boolean + initialized: boolean +} + +export type ComponentCientApiBase = { + queued: number + deadLetterScrobbles: number + deadLetterScrobblesTotal: number +} + +export type ComponentClientApi = ComponentCommonApi & ComponentCientApiBase; +export type ComponentClientApiJson = Replace, string>; + +export type ComponentSourceApiBase = { + sot: SOURCE_SOT_TYPES + supportsUpstreamRecentlyPlayed: boolean; + supportsManualListening: boolean; + manualListening?: boolean + systemListeningBehavior?: boolean + tracksDiscovered: number; + players: Record +} + +export type ComponentSourceApi = ComponentCommonApi & ComponentSourceApiBase; +export type ComponentSourceApiJson = Replace, string>; + +export const isComponentSourceApiJson = (data: ComponentCommonApiJson): data is ComponentSourceApiJson => { + return data.mode === 'source'; +} + +export const isComponentClientApiJson = (data: ComponentCommonApiJson): data is ComponentClientApiJson => { + return data.mode === 'client'; } \ No newline at end of file diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 923d7dcb..a31055a0 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -1,7 +1,7 @@ import { LogDataPretty, LogLevel } from "@foxxmd/logging"; import { Dayjs } from "dayjs"; import { AdditionalTrackInfoResponse } from "../backend/common/vendor/listenbrainz/interfaces.js"; -import { Merge, RequiredKeys } from "ts-essentials"; +import { Merge, RequiredKeys, StrictOmit } from "ts-essentials"; import { ErrorObject } from "serialize-error"; import { PlayPlatformIdStr } from "../backend/common/infrastructure/Atomic.js"; import { FlowControlTerm, TransformHook } from "../backend/common/infrastructure/Transform.js"; @@ -485,6 +485,7 @@ export const SOURCE_SOT = { PLAYER : 'player' as SOURCE_SOT_TYPES, HISTORY: 'history' as SOURCE_SOT_TYPES } +export const sourceSotTypes: SOURCE_SOT_TYPES[] = ['player','history']; export interface LeveledLogData extends LogDataPretty { levelLabel: string @@ -652,4 +653,6 @@ export type QueueStatus = 'queued' | 'completed' | 'failed'; export const QUEUE_STATUS_QUEUED: QueueStatus = 'queued'; export const QUEUE_STATUS_COMPLETED: QueueStatus = 'completed'; export const QUEUE_STATUS_FAILED: QueueStatus = 'failed'; -export const QUEUE_STATUSES: QueueStatus[] = [QUEUE_STATUS_COMPLETED, QUEUE_STATUS_FAILED, QUEUE_STATUS_QUEUED]; \ No newline at end of file +export const QUEUE_STATUSES: QueueStatus[] = [QUEUE_STATUS_COMPLETED, QUEUE_STATUS_FAILED, QUEUE_STATUS_QUEUED]; + +export type Replace = StrictOmit & Record \ No newline at end of file diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts index 57cc12ea..3a349c1b 100644 --- a/src/core/tests/utils/apiFixtures.ts +++ b/src/core/tests/utils/apiFixtures.ts @@ -1,12 +1,17 @@ import { faker } from "@faker-js/faker"; -import { PlayApiCommon, PlayApiCommonDetailed, PlayInputApi, QueueStateApi } from "../../Api.js"; -import { CLIENT_INGRESS_QUEUE, JsonPlayObject, PlayObject, PlayState, QUEUE_STATUSES } from "../../Atomic.js"; +import { ComponentClientApi, ComponentClientApiJson, ComponentCommonApi, ComponentCommonApiJson, ComponentSourceApi, ComponentSourceApiJson, PlayApiCommon, PlayApiCommonDetailed, PlayInputApi, QueueStateApi } from "../../Api.js"; +import { CLIENT_INGRESS_QUEUE, JsonPlayObject, PlayObject, PlayState, QUEUE_STATUSES, SOURCE_SOT, sourceSotTypes } from "../../Atomic.js"; import { generatePlay } from "../../PlayTestUtils.js"; import { generatePlayInput, randomPlayState } from "./fixtures.js"; import { asJsonPlayObject } from "../../PlayMarshalUtils.js"; import { generatePlayUid } from "../../StringUtils.js"; import dayjs from "dayjs"; import { ErrorLike } from "serialize-error"; +import { nanoid } from "nanoid"; +import { isSourceType, SourceType, sourceTypes } from "../../../backend/common/infrastructure/config/source/sources.js"; +import { ClientType, clientTypes } from "../../../backend/common/infrastructure/config/client/clients.js"; +import { ComponentSelect } from "../../../backend/common/database/drizzle/drizzleTypes.js"; +import { isClientType } from "../../../backend/common/infrastructure/Atomic.js"; export const generatePlayApiCommon = (commonData: Partial & {play?: JsonPlayObject | PlayObject } = {}, ...playOpts: Parameters): PlayApiCommon => { let play: JsonPlayObject | PlayObject; @@ -96,4 +101,125 @@ export const generatePlayApiCommonDetailed = (opts: { queueStates: [queueRes], error } +} + +const statusSamples = ['Sleeping 💤', 'Processing Queue', '⚠️ Authentication Failed', 'Updating Now Playing', 'Monitoring Players', '⚠️ Upstream error']; + +export const generateComponentCommonApiJson = (data: Partial = {}): ComponentCommonApiJson => { + const { + type = faker.helpers.arrayElement([...sourceTypes, ...clientTypes]), + createdAt = dayjs(), + lastActiveAt = dayjs(), + lastReadyAt = dayjs(), + state = faker.helpers.arrayElement(['Idle','polling','running','error','awaiting data','stopped']), + ...rest + } = data; + + + let mode: ComponentSelect['mode'] = data.mode; + if(mode === undefined) { + if(isSourceType(type)) { + mode = 'source'; + } else { + mode = faker.helpers.arrayElement(['source', 'client']) + } + } + + return { + id: faker.number.int({min: 1, max: 100}), + uid: generatePlayUid(), + name: `${faker.word.adjective()} ${faker.word.noun()}`, + createdAt: createdAt.toISOString(), + lastActiveAt: lastActiveAt.toISOString(), + lastReadyAt: lastReadyAt.toISOString(), + type, + mode, + countLive: faker.number.int({min: 0, max: 2000}), + countNonLive: 0, + state, + status: faker.helpers.arrayElement(statusSamples), + ...rest + } +} + +export const generateSourceApiJson = (data: Partial = {}): ComponentSourceApiJson => { + const { + mode, + type = faker.helpers.arrayElement(sourceTypes), + ...rest + } = data; + const common = generateComponentCommonApiJson({ + mode: 'source', + type, + ...rest + }); + const { + sot = faker.helpers.arrayElement(sourceSotTypes), + supportsUpstreamRecentlyPlayed = faker.datatype.boolean(), + supportsManualListening = faker.datatype.boolean({probability: 0.1}), + manualListening = faker.datatype.boolean({probability: 0.1}), + systemListeningBehavior = true, + tracksDiscovered = faker.number.int({min: 1, max: 2000}), + players = {} + } = data; + return { + ...common, + sot, + supportsManualListening, + supportsUpstreamRecentlyPlayed, + manualListening, + systemListeningBehavior, + tracksDiscovered, + players + } +} + +export const generateClientApiJson = (data: Partial = {}): ComponentClientApiJson => { + const { + mode, + type = faker.helpers.arrayElement(clientTypes), + ...rest + } = data; + const common = generateComponentCommonApiJson({ + mode: 'client', + type, + ...rest + }); + const { + queued = faker.number.int({min: 1, max: 2000}), + deadLetterScrobbles = faker.number.int({min: 1, max: 2000}), + deadLetterScrobblesTotal = faker.number.int({min: deadLetterScrobbles, max: 2000}) + } = data; + return { + ...common, + queued, + deadLetterScrobbles, + deadLetterScrobblesTotal, + } +} + +export const generateComponentApiJson = (data: Partial = {}): ComponentClientApiJson | ComponentSourceApiJson => { + const { + mode: modeData, + type: typeData + } = data; + + let mode: ComponentCommonApi['mode'], + type: ComponentCommonApi['type']; + + if(modeData === undefined && typeData === undefined) { + mode = faker.helpers.arrayElement(['source', 'client']); + type = faker.helpers.arrayElement(mode === 'source' ? sourceTypes : clientTypes) + } else if(modeData !== undefined && typeData === undefined) { + mode = modeData; + type = faker.helpers.arrayElement(mode === 'source' ? sourceTypes : clientTypes) + } else if(typeData !== undefined) { + type = typeData; + mode = isClientType(type) ? 'client' : 'source'; + } + + if(mode === 'source') { + return generateSourceApiJson({mode, type, ...data}); + } + return generateClientApiJson({mode, type, ...data}); } \ No newline at end of file diff --git a/src/stories/ComponentSummary.stories.tsx b/src/stories/ComponentSummary.stories.tsx new file mode 100644 index 00000000..1d3983ec --- /dev/null +++ b/src/stories/ComponentSummary.stories.tsx @@ -0,0 +1,49 @@ +import preview from "../../.storybook/preview.js"; +import React from 'react'; + +import { Container } from '@chakra-ui/react'; +import { MSComponentSummary } from "../client/components/msComponent/MSComponentQuickDisplay"; +import {Provider} from "../client/components/Provider"; +import { generateClientApiJson, generateSourceApiJson } from "../core/tests/utils/apiFixtures.js"; + +// More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export +const meta = preview.meta({ + title: 'Examples/ComponentSummary', + component: MSComponentSummary, + 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'], + // More on argTypes: https://storybook.js.org/docs/api/argtypes + // args: { + // data: generatePlayApiCommonDetailed(), + // }, + // argTypes: { + // componentType: { + // control: { type: 'select' }, + // options: ['source', 'client'], + // } + // }, + render: function Render(args) { + return () + }, +decorators: [ + (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 +}); + +// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args +export const SourceSummary = meta.story({ + args: { + data: generateSourceApiJson() + } +}); + +export const ClientSummary = meta.story({ + args: { + data: generateClientApiJson() + } +}); \ No newline at end of file -- 2.51.2