diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index e3371e56..84abd949 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -203,7 +203,7 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro polling = false, requiresAuth = false, requiresAuthInteraction = false, - authed = false, + authed = false } = x; const base: ComponentSourceApi = x.getApiData(); if(!x.isReady()) { diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 634c9e0c..40635bdd 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -293,6 +293,8 @@ export default abstract class AbstractSource extends AbstractComponent implement supportsManualListening: this.supportsManualListening, manualListening: this.manualListening, systemListeningBehavior: this.getSystemListeningBehavior(), + sleeping: this.getIsSleeping(), + wakeAt: this.wakeAt !== undefined ? this.wakeAt.toISOString() : undefined } } diff --git a/src/client/components/ToggleTip.tsx b/src/client/components/ToggleTip.tsx index df3bf56e..c18d9b84 100644 --- a/src/client/components/ToggleTip.tsx +++ b/src/client/components/ToggleTip.tsx @@ -3,6 +3,7 @@ import { IconButton, type IconButtonProps, Portal, + Tooltip as ChakraTooltip } from "@chakra-ui/react" import * as React from "react" import { HiOutlineInformationCircle } from "react-icons/hi" @@ -80,3 +81,49 @@ export const InfoTip = React.forwardRef( ) }, ) + + + +export interface TooltipProps extends ChakraTooltip.RootProps { + showArrow?: boolean + portalled?: boolean + portalRef?: React.RefObject + content: React.ReactNode + contentProps?: ChakraTooltip.ContentProps + disabled?: boolean +} + +export const Tooltip = React.forwardRef( + function Tooltip(props, ref) { + const { + showArrow, + children, + disabled, + portalled = true, + content, + contentProps, + portalRef, + ...rest + } = props + + if (disabled) return children + + return ( + + {children} + + + + {showArrow && ( + + + + )} + {content} + + + + + ) + }, +) diff --git a/src/client/components/icons/ChakraIcons.tsx b/src/client/components/icons/ChakraIcons.tsx index d0bafa65..7d9a60fa 100644 --- a/src/client/components/icons/ChakraIcons.tsx +++ b/src/client/components/icons/ChakraIcons.tsx @@ -1,4 +1,5 @@ import { LuChevronRight, LuChevronLeft, LuActivity, LuGithub, LuTerminal, LuAlignJustify, LuX, LuExternalLink, LuArrowUp, LuArrowDown } from "react-icons/lu" +import { RiZzzFill } from "react-icons/ri"; import { SiGoogledocs } from "react-icons/si"; import { IconButton } from "@chakra-ui/react" import { ComponentProps } from 'react'; @@ -62,4 +63,6 @@ export const ExternalLinkIcon = LuExternalLink; export const ExternalLinkButton = makeIconButton(ExternalLinkIcon); export const UpArrowIcon = LuArrowUp; -export const DownArrowIcon = LuArrowDown; \ No newline at end of file +export const DownArrowIcon = LuArrowDown; + +export const IdleIcon = RiZzzFill; \ No newline at end of file diff --git a/src/client/components/msComponent/MSComponentDetailed.tsx b/src/client/components/msComponent/MSComponentDetailed.tsx index a82c1ef7..36319f8b 100644 --- a/src/client/components/msComponent/MSComponentDetailed.tsx +++ b/src/client/components/msComponent/MSComponentDetailed.tsx @@ -1,13 +1,13 @@ import React, { ComponentProps, useMemo, forwardRef, Fragment, useEffect, useState, useCallback } from "react" -import { DataList, Badge, Grid, Spacer, ButtonGroup, Button, GridItem, Text, Box, Heading, Skeleton, Wrap, Stat, Separator, HStack, Stack, Flex, Collapsible, Card, LinkOverlay, LinkBox, SkeletonText } from '@chakra-ui/react'; +import { DataList, Badge, Grid, Spacer, Span, ButtonGroup, Button, GridItem, Text, Box, Heading, Skeleton, Wrap, Stat, Separator, HStack, Stack, Flex, Collapsible, Card, LinkOverlay, LinkBox, SkeletonText } from '@chakra-ui/react'; import { COMPONENT_STATE, ComponentClientApiJson, ComponentCommonApiJson, ComponentsApiJson, ComponentSourceApiJson, componentStateToFriendly, isComponentClientApiJson, isComponentSourceApiJson, MsSseEvent, MsSseEventPayload } from "../../../core/Api.js"; import { TextMuted } from "../TextMuted.js"; import { isClientType } from "../../../backend/common/infrastructure/Atomic.js"; import { capitalize } from "../../../core/StringUtils.js"; import { ShortDateDisplay } from "../DateDisplay.js"; -import { ChevronRightButton } from "../icons/ChakraIcons.js"; +import { ChevronRightButton, IdleIcon } from "../icons/ChakraIcons.js"; import { ChakraPlayer, ChakraPlayerFetchable, PlayersContainer } from "../chakraPlayer/Player.js"; -import { InfoTip } from "../ToggleTip.js"; +import { InfoTip, ToggleTip, Tooltip } from "../ToggleTip.js"; import { QueryFunctionContext, queryOptions, useQuery, useQueryClient } from '@tanstack/react-query'; import { ErrorAlert } from "../ErrorAlert"; import ky from 'ky'; @@ -20,10 +20,13 @@ import { } from "@flamefrontend/sse-runtime-react"; import { isComponentTypeSource, SourcePlayerJson } from "../../../core/Atomic.js"; import { CountLiveIndicator, DateIndicator, DeadLetterIndicator, QueuedIndicator } from "./Stats.js"; -import { ListContainerFetchable, ListContainerFilterable, PlayListSkeleton } from "../playActivity/ActivityList.js"; +import { ListContainerFetchable, ListContainerFilterable } from "../playActivity/ActivityList.js"; import { useParams } from "react-router-dom"; import { ComponentStateBadge } from "../Badges.js"; import { ActivitySummarySkeleton } from "../ActivityDetail.js"; +import dayjs from "dayjs"; +import { shortTodayAwareFormat } from "../../../core/TimeUtils.js"; +import { durationToHuman } from "../../../backend/utils.js"; export const MSComponentHeading = (props: { data?: Pick, fetchable?: boolean }) => { if (props.data === undefined) { @@ -74,13 +77,34 @@ const ComponentSettings = () => { } export const ComponentDetailedDesktop = (props: {data?: ComponentCommonApiJson, live?: boolean}) => { + let sleepingRender: React.JSX.Element = null; + const {data} = props; + if(isComponentSourceApiJson(data)) { + const { + sleeping, + wakeAt + } = data; + if(sleeping) { + if(sleeping && wakeAt !== undefined) { + const wakeDay = dayjs(wakeAt); + const now = dayjs(); + sleepingRender = ( + Will next poll Source for activity at {shortTodayAwareFormat(wakeDay)} (in {durationToHuman(dayjs.duration(wakeDay.diff(now, 'ms')))})}> + + + ) + } else { + sleepingRender = ; + } + } + } return ( - {props.data.status} + {props.data.status}{sleepingRender} diff --git a/src/client/components/msComponent/MSComponentSummary.tsx b/src/client/components/msComponent/MSComponentSummary.tsx index 071792ae..05a7ddb1 100644 --- a/src/client/components/msComponent/MSComponentSummary.tsx +++ b/src/client/components/msComponent/MSComponentSummary.tsx @@ -6,10 +6,10 @@ import { TextMuted } from "../TextMuted.js"; import { isClientType } from "../../../backend/common/infrastructure/Atomic.js"; import { capitalize } from "../../../core/StringUtils.js"; import { ShortDateDisplay } from "../DateDisplay.js"; -import { ChevronRightButton, UpArrowIcon } from "../icons/ChakraIcons.js"; +import { ChevronRightButton, IdleIcon, UpArrowIcon } from "../icons/ChakraIcons.js"; import { useTimeout } from 'react-use-timeout'; import { ChakraPlayer, ChakraPlayerFetchable, PlayersContainer } from "../chakraPlayer/Player.js"; -import { InfoTip } from "../ToggleTip.js"; +import { InfoTip, Tooltip } from "../ToggleTip.js"; import { QueryFunctionContext, queryOptions, useQuery, useQueryClient } from '@tanstack/react-query'; import { ErrorAlert } from "../ErrorAlert"; import ky from 'ky'; @@ -22,6 +22,9 @@ import { import { SourcePlayerJson } from "../../../core/Atomic.js"; import { CountLiveIndicator, DeadLetterIndicator, QueuedIndicator } from "./Stats.js"; import { ComponentStateBadge } from "../Badges.js"; +import { shortTodayAwareFormat } from "../../../core/TimeUtils.js"; +import dayjs from "dayjs"; +import { durationToHuman } from "../../../backend/utils.js"; export const MSComponentSummary = (props: { data: ComponentCommonApiJson, fetchable?: boolean }) => { const { @@ -29,13 +32,18 @@ export const MSComponentSummary = (props: { data: ComponentCommonApiJson, fetcha fetchable } = props; const isClient = isClientType(data.type); + let sleepingRender: React.JSX.Element = null; let body = ; let cardHeaderProps: Card.HeaderProps = {}; if(isComponentSourceApiJson(data)) { const { - players + players, + sleeping } = data; + if(sleeping) { + sleepingRender = ; + } if(Object.keys(players).length > 0) { cardHeaderProps.borderBottomWidth="1px"; cardHeaderProps.paddingBottom="2px"; @@ -52,6 +60,7 @@ export const MSComponentSummary = (props: { data: ComponentCommonApiJson, fetcha {data.name} + {sleepingRender} diff --git a/src/core/Api.ts b/src/core/Api.ts index 890e6335..e3b633a5 100644 --- a/src/core/Api.ts +++ b/src/core/Api.ts @@ -106,6 +106,8 @@ export type ComponentSourceApiBase = { systemListeningBehavior?: boolean tracksDiscovered: number; players: Record + wakeAt?: string + sleeping: boolean } export type ComponentSourceApi = ComponentCommonApi & ComponentSourceApiBase; diff --git a/src/core/tests/utils/apiFixtures.ts b/src/core/tests/utils/apiFixtures.ts index 5f27157a..8948bf36 100644 --- a/src/core/tests/utils/apiFixtures.ts +++ b/src/core/tests/utils/apiFixtures.ts @@ -161,7 +161,8 @@ export const generateSourceApiJson = (data: Partial = {}): C manualListening = faker.datatype.boolean({probability: 0.1}), systemListeningBehavior = true, tracksDiscovered = faker.number.int({min: 1, max: 2000}), - players = (data.players ?? {}) + players = (data.players ?? {}), + sleeping = false, } = data; return { ...common, @@ -171,7 +172,8 @@ export const generateSourceApiJson = (data: Partial = {}): C manualListening, systemListeningBehavior, tracksDiscovered, - players + players, + sleeping } } diff --git a/src/stories/ComponentDetailed.stories.tsx b/src/stories/ComponentDetailed.stories.tsx index 5039c134..2c102de2 100644 --- a/src/stories/ComponentDetailed.stories.tsx +++ b/src/stories/ComponentDetailed.stories.tsx @@ -11,6 +11,7 @@ import { SSEProvider } from "@flamefrontend/sse-runtime-react"; import { sseProviderOptions } from "../client/AppNext.js"; import { faker } from "@faker-js/faker"; import { PaginatedResponse } from "../backend/common/database/drizzle/repositories/BaseRepository.js"; +import dayjs from "dayjs"; let livePlayData: PlayApiCommonDetailed[] = []; @@ -114,4 +115,10 @@ export const SourceDetailed = meta.story({ args: { data: generateSourceApiJson({players: {test: generateSourcePlayerJson(undefined, {art: true}), foo: generateSourcePlayerJson(undefined, {art: true})}}) } +}); + +export const SourceDetailedSleeping = meta.story({ + args: { + data: generateSourceApiJson({sleeping: true, wakeAt: dayjs().add(45, 's').toISOString()}) + } }); \ No newline at end of file diff --git a/src/stories/ComponentSummary.stories.tsx b/src/stories/ComponentSummary.stories.tsx index 814f203f..0c21a551 100644 --- a/src/stories/ComponentSummary.stories.tsx +++ b/src/stories/ComponentSummary.stories.tsx @@ -10,6 +10,8 @@ import { MsSseEvent } from "../core/Api.js"; import { SSEProvider } from "@flamefrontend/sse-runtime-react"; import { sseProviderOptions } from "../client/AppNext.js"; import { faker } from "@faker-js/faker"; +import { withRouter, reactRouterParameters } from 'storybook-addon-remix-react-router'; +import dayjs from "dayjs"; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = preview.meta({ @@ -42,6 +44,7 @@ const meta = preview.meta({ }, decorators: [ (Story) => (), + withRouter ] // 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 }); @@ -77,6 +80,18 @@ export const SourceSummaryFetchable = meta.story({ } }); +export const SourceSleeping = meta.story({ + args: { + data: generateSourceApiJson({sleeping: true, wakeAt: dayjs().add(45, 's').toISOString()}) + } +}); + +export const SourceWithPlayerSummary = meta.story({ + args: { + data: generateSourceApiJson({players: {test: generateSourcePlayerJson(undefined, {art: true})}}) + } +}); + export const ClientSummary = meta.story({ args: { data: generateClientApiJson() @@ -115,10 +130,4 @@ export const ClientSummaryFetchable = meta.story({ ], }, } -}); - -export const SourceWithPlayerSummary = meta.story({ - args: { - data: generateSourceApiJson({players: {test: generateSourcePlayerJson(undefined, {art: true})}}) - } }); \ No newline at end of file