import { Accordion, Alert, Dialog, Checkbox, Box, Text, Group, Portal, Code, Collapsible, Flex, HStack, Separator, Menu, Skeleton, SkeletonText, Span, Stack, useAccordionItemContext, type BadgeProps, type MenuItemProps, type MenuSelectionDetails, useClipboard, CloseButton, Button } from '@chakra-ui/react'; import { useSSEContext, useSSEEvent } from "@flamefrontend/sse-runtime-react"; import { useMutation, useQuery, useQueryClient, type InfiniteData } from '@tanstack/react-query'; import React, { Fragment, useCallback, useEffect, useState, type ComponentProps } from "react"; import { LuChevronRight } from "react-icons/lu"; import type { MarkOptional } from "ts-essentials"; import type { ComponentsApiJson, MsSseEvent, PaginatedResponse, PlayApiCommonDetailed, QueryPlaysOptsJson, SortPlaysByProps } from "../../core/Api"; import { INGRESS_QUEUE, queueContextSchema, type ComponentType, type QueueContext, type Second } from "../../core/Atomic"; import { tanQueries, useQueryWatcher } from "../queries"; import { activityTimelineHasIssue } from "../utils/ComponentUtils"; import { ActivityTimeline } from "./ActivityTimeline"; import { EphemeralElement, PlayStateBadge } from "./Badges"; import { ShortDateDisplay } from "./DateDisplay"; import { ErrorAlert } from "./ErrorAlert"; import { ExpandCollapse } from "./ExpandCollapse"; import { DebugIcon, EllipsisButton, ExclamationCircleIcon, ExclamationTriangleIcon, FinishIconRaw, InsertedIcon, type PowerOffButton, RetryButton, RetryIcon, StopButton, StopIconRaw, TrashIconRaw, UpdatedIcon } from "./icons/ChakraIcons"; import { PlayData } from "./PlayData"; import { TextMuted } from "./TextMuted"; import { capitalize } from '../../core/StringUtils'; import ky from 'ky'; import type { IconType } from 'react-icons/lib'; import { toaster } from "./Toaster" import { useForm, formOptions} from '@tanstack/react-form' import { FormCheckbox } from './form/formComponents'; type UseActivityQueryOptions = { msQuery?: QueryPlaysOptsJson activity?: ActivitySummaryProps['activity'] refetchOnMount?: boolean | 'always' } export const useActivityQuery = (componentId: number, activityUid: string, options: UseActivityQueryOptions = {}) => { const { msQuery, activity: preloadedActivity, ...rest } = options; const queryClient = useQueryClient(); const { isPending, isError, data: activity, error } = useQuery({ ...tanQueries.activities.single(componentId, activityUid), ...rest, staleTime: Infinity, initialData: () => { if (msQuery === undefined && preloadedActivity === undefined) { return undefined; } if(preloadedActivity !== undefined) { return preloadedActivity; } const data = queryClient.getQueryData( tanQueries.activities.list(componentId, msQuery).queryKey ) as InfiniteData> | undefined; if (data !== undefined) { for (const p of data.pages) { const res = p.data.find(x => x.uid === activityUid); if (res !== undefined) { return res; } } return undefined; } }, // structuralSharing(oldData: PlayApiCommonDetailed, newData: PlayApiCommonDetailed) { // if(oldData !== undefined) { // console.debug(`Merging new data for Activity ${activityUid} in Component ${componentId}`); // return {...newData, isUpdated: true, updatedAt: dayjs().toISOString()}; // } // return newData; // }, }); const client = useSSEContext(); useSSEEvent(client, 'playUpdate', (payload) => { if (payload.componentId === componentId && payload.data.uid === activityUid) { console.debug(`Recieved playUpdate for Activity ${activityUid} in Component ${componentId}, invalidating single query`); queryClient.invalidateQueries({ queryKey: tanQueries.activities.single(componentId, activityUid).queryKey, refetchType: 'all' }); } }); return { activity, isPending, isError, error }; }; export interface ActivityDetailProps { activity: PlayApiCommonDetailed componentType: ComponentType componentName?: string } export interface ActivitySummaryProps extends SortPlaysByProps { activity: PlayApiCommonDetailed & {isNew?: boolean | Second} componentType: ComponentType } export const ActivitySummary = (props: ActivitySummaryProps) => { const { activity: { play, isNew, updatedAt } = {}, activity, sortBy } = props; const [updated, setUpdated] = useState<{lastUpdated: string, updated: boolean}>({lastUpdated: updatedAt, updated: false}); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect setUpdated((old) => { if(old.lastUpdated === activity.updatedAt) { return {lastUpdated: activity.updatedAt, updated: false}; } return {lastUpdated: activity.updatedAt, updated: true}; }); },[setUpdated, activity]); let ephemeralStatus: React.JSX.Element | undefined; if(isNew) { ephemeralStatus = ; } else if(updated.updated) { ephemeralStatus = ; } return ( {play.data.track}{ephemeralStatus} {/* */} {play.data.artists.map(x => x.name).join(' / ')} {play.meta?.source} {play.meta?.parsedFrom !== undefined && <>{capitalize(play.meta?.parsedFrom)}} ); } export const ActivitySummaryFetchable = (props: MarkOptional & { componentId: number, activityUid: string, query: QueryPlaysOptsJson}) => { const {isError, error, isPending, activity} = useActivityQuery(props.componentId, props.activityUid, {activity: props.activity}); if(isError) { return } if(activity === undefined && isPending) { return ; } return } export const ActivityErrorSummary = (props: {activity: ActivityDetailProps['activity']}) => { const { activity: { play: { lifecycle = [], scrobble, } = {}, error, } = {} } = props; if(error !== undefined && error !== null) { return ; } const lifecycleError = lifecycle.find(x => x.error !== undefined && x.error !== null && Object.keys(x.error).length > 0); if(lifecycleError !== undefined) { return ( Error occurred during Play Transform in Stage {lifecycleError.stageType}-{lifecycleError.stageName} in Hook {lifecycleError.hook} from {lifecycleError.source} {lifecycleError.error.message} Open the Timeline to find the error specifics. ); } if(scrobble?.error !== undefined) { return ( Error occurred during while trying to scrobble {scrobble.error.message} Open the Timeline to find the error specifics. ); } if(scrobble?.warnings !== undefined) { return ( There were warnings while scrobbling Open the Timeline to find warning specifics. ) } return null; } export const ActivityDetails = (props: ActivityDetailProps) => { const { activity, componentType, componentName, activity: { error, input: { play: original, } = {} } } = props; console.log(`Rendering ActivityDetails for ${activity.play.data.track}`); const ExpandCollapseContext = () => { const item = useAccordionItemContext(); return setCollapsibleOpen(val)} /> } const [collapsibleOpen, setCollapsibleOpen] = useState(undefined); let timelineStatusIcon: React.JSX.Element | undefined; const timelineIssue = activityTimelineHasIssue(activity); if(timelineIssue === 'error') { timelineStatusIcon = ; } else if(timelineIssue === 'warn') { timelineStatusIcon = ; } return ( {/* */} {error !== undefined && error !== null ? : null} Play Data Timeline {timelineStatusIcon} ) } export interface ActivityDetailFetchableProps { uid: string componentId: number componentType: ComponentType activity?: ActivitySummaryProps['activity'] } export const ActivityDetailFetchable = (props: ActivityDetailFetchableProps) => { const {isError, error, isPending, activity} = useActivityQuery(props.componentId, props.uid, {activity: props.activity, refetchOnMount: 'always'}); const { data } = useQueryWatcher(tanQueries.components.single(props.componentId).queryKey); if(isPending) { return } if(isError) { return } return } const opts = formOptions.strictSchema(queueContextSchema, { defaultValues: { transform: true, dupeCheck: true, useCache: true }, validators: [{ run: queueContextSchema, triggers: [], }], }); const RetryWithDialog = (props: { open: boolean, setOpen: (open: boolean) => void, onSubmit: (vals: QueueContext) => void }) => { const form = useForm({ ...opts, onSubmit: ({ schemaOutputs }) => { console.log(schemaOutputs[0]); props.onSubmit(schemaOutputs[0]); props.setOpen(false); } }); return ( props.setOpen(e.open)} > Retry With...
{ e.preventDefault() e.stopPropagation() form.handleSubmit() }}> Retry the Play using the options below: ()} /> ()} /> ()} />
) } const DeleteDialog = (props: { open: boolean, setOpen: (open: boolean) => void, onSubmit: (withChildren?: boolean) => void, source?: boolean }) => { const [children, setChildren] = useState(false); return ( props.setOpen(e.open)} > Confirm Deletion Are you sure you want to delete this Play? {props.source === true && setChildren(!!val.checked)}> and delete Play in Clients } ) } const playStateMenuItem = (Icon: IconType, value: string, name?: string) => (props: Omit = {}) => ( {name ?? capitalize(value)} ) const MenuItemRetry = playStateMenuItem(RetryIcon, 'retry'); const MenuItemRetryWith = playStateMenuItem(RetryIcon, 'retryWith', 'Retry With...'); const MenuItemCancel = playStateMenuItem(StopIconRaw, 'cancel'); const MenuItemTrash = playStateMenuItem(TrashIconRaw, 'delete'); const MenuItemDebug = playStateMenuItem(DebugIcon, 'debug'); const MenuItemFinish = playStateMenuItem(FinishIconRaw, 'finish', 'Mark Completed'); const primaryActionProps: ComponentProps = { margin: "1px", variant: "subtle", size: 'xs' } export const ActivityStateActions = (props: {activity: PlayApiCommonDetailed, componentType: ComponentType}) => { let suffix: React.JSX.Element | undefined; let primaryAction: React.JSX.Element | undefined; let menuElm: React.JSX.Element | undefined; let menuItems: React.JSX.Element[]; const badgeProps: BadgeProps = {}; const [retryOpen, setRetryOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const clipboard = useClipboard({value: JSON.stringify(props.activity)}); const {mutate, isPending, variables, isSuccess} = useMutation({ mutationKey: ['playAction', props.activity.uid], mutationFn: (data: {action: string, context?: QueueContext, state?: string}) => { if(data.action === 'queue') { return ky.post(`api/components/${props.activity.componentId}/plays/${props.activity.uid}/queue`,{ json: data.context ?? {} }); } if(data.action === 'delete') { return ky.delete(`api/components/${props.activity.componentId}/plays/${props.activity.uid}`); } if(data.action === 'cancel') { return ky.delete(`api/components/${props.activity.componentId}/plays/${props.activity.uid}/queue`); } if(data.action === 'finish') { return ky.post(`api/components/${props.activity.componentId}/plays/${props.activity.uid}/state`, { json: {state: data.state} }); } } }); const menuCb = useCallback((select: MenuSelectionDetails) => { switch(select.value) { case 'debug': clipboard.copy(); toaster.create({ title: 'Copied debug data to clipboard', type: 'success' }); break; case 'retry': mutate({action: 'queue'}); break; case 'retryWith': setRetryOpen(true); break; case 'cancel': mutate({action: 'cancel'}); break; case 'finish': mutate({action: 'finish', state: 'discarded'}); break; case 'delete': setDeleteOpen(true); break; } },[mutate,clipboard, setRetryOpen, setDeleteOpen]); const { activity: { queueStates = [] } = {} } = props; const hasDeadQueue = queueStates.some(x => x.queueName === INGRESS_QUEUE && x.retries > 0); switch(props.activity.state) { case 'queued': primaryAction = mutate({action: 'cancel'})}/>; menuItems = [,]; break; case 'failed': primaryAction = mutate({action: 'queue'})}/>; menuItems = [ , , , ]; if(hasDeadQueue) { menuItems.unshift() //primaryAction = mutate({action: 'cancel'})}/>; } break default: primaryAction = mutate({action: 'queue'})}/>; menuItems = [,,]; break } if(menuItems.length > 0) { menuElm = ( {primaryAction} {menuItems} ); suffix = menuElm; } else if(primaryAction !== undefined) { suffix = primaryAction; } if(suffix !== undefined || primaryAction !== undefined) { badgeProps.paddingRight = 0; } return ( mutate({action: 'queue', context: vals})}/> mutate({action: 'delete'})} source={props.componentType === 'source'}/> ) } export const ActivityStateActionsFetchable = (props: ActivityDetailFetchableProps) => { const {isError, error, isPending, activity} = useActivityQuery(props.componentId, props.uid, {activity: props.activity}); if(!isPending && !isError) { return ; } } export const ActivityCollapsible = (props: ActivitySummaryProps & { key?: string, live?: boolean, componentId: number, query: QueryPlaysOptsJson }) => { const { activity: { play } = {}, activity, sortBy, live = false } = props; return ( ) } export const ActivitySummarySkeleton = () => ( )