From bf8f1175ce8c84bf3759735c9fd822f9328ca153 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 15 Jun 2026 14:43:13 +0000 Subject: [PATCH] feat(ui): Implement static component list in app entrypoint --- src/backend/common/AbstractComponent.ts | 17 ++++- .../scrobblers/AbstractScrobbleClient.ts | 22 +++++++ src/backend/server/api.ts | 65 ++++++++++++++++++- src/backend/server/middleware.ts | 40 +++++++++++- src/backend/sources/AbstractSource.ts | 29 ++++++++- src/backend/sources/MemorySource.ts | 11 +++- src/client/AppNext.tsx | 9 ++- .../msComponent/MSComponentList.tsx | 34 +++++++++- 8 files changed, 216 insertions(+), 11 deletions(-) diff --git a/src/backend/common/AbstractComponent.ts b/src/backend/common/AbstractComponent.ts index 16194ce3..3f93bcc7 100644 --- a/src/backend/common/AbstractComponent.ts +++ b/src/backend/common/AbstractComponent.ts @@ -30,7 +30,7 @@ import { objectsEqual } from "../utils/DataUtils.js"; import { RetentionOptions } from "./infrastructure/config/database.js"; import { getRetentionCompactAfterFromEnv, getRetentionDeleteAfterFromEnv, isCompactableProperty, parseRetentionOptions, parseRetentionOptionsDurations } from "./database/Database.js"; import { DbConcrete } from "./database/drizzle/drizzleUtils.js"; -import { ComponentSelect } from "./database/drizzle/drizzleTypes.js"; +import { ComponentMinimalSelect, ComponentSelect } from "./database/drizzle/drizzleTypes.js"; import { DrizzlePlayRepository } from "./database/drizzle/repositories/PlayRepository.js"; import { ClientType } from "./infrastructure/config/client/clients.js"; import { SourceType } from "./infrastructure/config/source/sources.js"; @@ -502,4 +502,19 @@ export default abstract class AbstractComponent extends AbstractInitializable { return [step, newTransformedPlay]; } + + public getApiData(): Omit { + return { + id: this.dbComponent.id, + uid: this.dbComponent.uid, + name: this.dbComponent.name, + mode: this.dbComponent.mode, + countLive: this.dbComponent.countLive, + countNonLive: this.dbComponent.countNonLive, + createdAt: this.dbComponent.createdAt, + lastReadyAt: this.dbComponent.lastReadyAt, + lastActiveAt: this.dbComponent.lastActiveAt, + ...this.additionalApiData() + } + } } diff --git a/src/backend/scrobblers/AbstractScrobbleClient.ts b/src/backend/scrobblers/AbstractScrobbleClient.ts index 9548476b..6e7bbfad 100644 --- a/src/backend/scrobblers/AbstractScrobbleClient.ts +++ b/src/backend/scrobblers/AbstractScrobbleClient.ts @@ -83,6 +83,7 @@ import { asPlay } from "../../core/PlayMarshalUtils.js"; import { DrizzleQueueRepository } from "../common/database/drizzle/repositories/QueueRepository.js"; import { GenericRepository } from "../common/database/drizzle/repositories/BaseRepository.js"; import assert from "node:assert"; +import { ComponentClientApi } from "../../core/Api.js"; type PlatformMappedPlays = Map; type NowPlayingQueue = Map; @@ -407,6 +408,27 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i return {name: this.getSafeExternalName(), type: this.type}; } + protected getComponentApiData() { + return { + hasAuth: this.requiresAuth, + hasAuthInteraction: this.requiresAuthInteraction, + authed: this.authed, + } + } + + public getApiData(): ComponentClientApi { + return { + ...super.getApiData(), + ...this.getComponentApiData(), + type: this.type, + state: 'idle', + status: 'idle', + queued: this.queuedLength, + deadLetterScrobbles: this.deadLetterQueued, + deadLetterScrobblesTotal: this.deadLetterLength, + } + } + public notify = async (payload: WebhookPayload) => { this.emitEvent('notify', payload); } diff --git a/src/backend/server/api.ts b/src/backend/server/api.ts index aba9c5b9..cdfd9f91 100644 --- a/src/backend/server/api.ts +++ b/src/backend/server/api.ts @@ -31,7 +31,7 @@ import { setupAuthRoutes } from "./auth.js"; import { setupDeezerRoutes } from "./deezerRoutes.js"; import {setupLZEndpointRoutes} from "./endpointListenbrainzRoutes.js"; import {setupLastfmEndpointRoutes} from "./endpointLastfmRoutes.js"; -import { makeClientCheckMiddle, makeSourceCheckMiddle } from "./middleware.js"; +import { makeClientCheckMiddle, makeComponentMiddle, makeSourceCheckMiddle } from "./middleware.js"; import { setupWebscrobblerRoutes } from "./webscrobblerRoutes.js"; import ScrobbleSources from "../sources/ScrobbleSources.js"; import ScrobbleClients from "../scrobblers/ScrobbleClients.js"; @@ -41,6 +41,7 @@ import { DrizzlePlayRepository, QueryPlaysOpts } from "../common/database/drizzl import { playSelectToDeadScrobble } from "../common/database/drizzle/entityUtils.js"; import AbstractHistoricalScrobbleClient from "../scrobblers/AbstractHistoricalScrobbleClient.js"; import { DrizzlePlayHistoricalRepository } from "../common/database/drizzle/repositories/PlayHistoricalRepository.js"; +import { ComponentClientApi, ComponentSourceApi, ComponentSourceApiJson } from "../../core/Api.js"; const maxBufferSize = 300; const output: Record> = {}; @@ -107,6 +108,8 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro const clientRequiredMiddle = clientMiddleFunc(true); const sourceRequiredMiddle = sourceMiddleFunc(true); + const componentMiddle = makeComponentMiddle(scrobbleSources, scrobbleClients); + const setLogWebSettings: ExpressHandler = async (req, res, next) => { // @ts-expect-error logLevel not part of session const sessionLevel: LogLevel | undefined = req.session.logLevel as LogLevel | undefined; @@ -172,6 +175,66 @@ export const setupApi = (app: Express, logger: Logger, appLoggerStream: PassThro res.sendStatus(200); }); + app.get('/api/components', async (req, res, next) => { + + const sourceData = scrobbleSources.sources.map((x) => { + const { + canPoll = false, + polling = false, + requiresAuth = false, + requiresAuthInteraction = false, + authed = false, + } = x; + const base: ComponentSourceApi = x.getApiData(); + if(!x.isReady()) { + if(x.buildOK === false) { + base.status = 'Initializing Data Failed'; + } else if(x.connectionOK === false) { + base.status = 'Communication Failed'; + } else if (requiresAuth && !authed) { + base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted' + } else { + base.status = 'Not Ready'; + } + } else { + if (canPoll) { + base.status = polling ? 'Polling' : 'Idle'; + } else { + base.status = !x.instantiatedAt.isSame(x.lastActivityAt) ? 'Received Data' : 'Awaiting Data'; + } + } + return base; + }); + + + const clientData = scrobbleClients.clients.map((x) => { + const { + requiresAuth = false, + requiresAuthInteraction = false, + authed = false, + scrobbling = false, + } = x; + const base: ComponentClientApi = x.getApiData(); + + if (!x.isReady()) { + if(x.buildOK === false) { + base.status = 'Initializing Data Failed'; + } else if(x.connectionOK === false) { + base.status = 'Communication Failed'; + } else if (requiresAuth && !authed) { + base.status = requiresAuthInteraction ? 'Auth Interaction Required' : 'Authentication Failed Or Not Attempted' + } else { + base.status = 'Not Ready'; + } + } else { + base.status = scrobbling ? 'Running' : 'Idle'; + } + return base; + }); + + return res.json([...sourceData, ...clientData]); + }); + app.get('/api/status', async (req, res, next) => { const sourceData = scrobbleSources.sources.map((x) => { diff --git a/src/backend/server/middleware.ts b/src/backend/server/middleware.ts index 1e1bab27..1de19a57 100644 --- a/src/backend/server/middleware.ts +++ b/src/backend/server/middleware.ts @@ -1,7 +1,12 @@ import { Logger } from "@foxxmd/logging"; import { ExpressHandler } from "../common/infrastructure/Atomic.js"; +import ScrobbleClients from "../scrobblers/ScrobbleClients.js"; +import ScrobbleSources from "../sources/ScrobbleSources.js"; +import { Request, Response, NextFunction } from "express"; +import AbstractSource from "../sources/AbstractSource.js"; +import AbstractScrobbleClient from "../scrobblers/AbstractScrobbleClient.js"; -export const makeSourceCheckMiddle = (sources: any) => (required: boolean ): ExpressHandler => (req: any, res: any, next: any) => { +export const makeSourceCheckMiddle = (sources: any) => (required: boolean): ExpressHandler => (req: any, res: any, next: any) => { const { query: { name, @@ -11,7 +16,7 @@ export const makeSourceCheckMiddle = (sources: any) => (required: boolean ): Exp if (required && name === undefined) { return res.status(404).send('Source name must be defined'); - } else if(name !== undefined) { + } else if (name !== undefined) { const source = sources.getByNameAndType(name, type); if (source === undefined) { @@ -65,3 +70,34 @@ export const nonEmptyBody = (logger: Logger, origin: string = 'Origin'): Express } next(); } + +export interface ComponentAwareRequest extends Request { + component: AbstractSource | AbstractScrobbleClient +} + +export const makeComponentMiddle = (sources: ScrobbleSources, clients: ScrobbleClients): ExpressHandler => async (req: Request, res: Response, next: NextFunction) => { + const { + params: { + componentVal + } + } = req; + + const componentId = Number.parseInt(componentVal as string); + if (isNaN(componentId)) { + return res.status(400).json({ error: 'Component id must be a number' }); + } + + let component: AbstractSource | AbstractScrobbleClient; + component = sources.sources.find(x => x.componentId === componentId); + if (component === undefined) { + component = clients.clients.find(x => x.componentId === componentId); + } + + if(component === undefined) { + return res.status(404).json({error: `No Component with the Id ${componentId} exists`}); + } + + (req as ComponentAwareRequest).component = component; + + next(); +} \ No newline at end of file diff --git a/src/backend/sources/AbstractSource.ts b/src/backend/sources/AbstractSource.ts index 288e5ee4..0ffbb82b 100644 --- a/src/backend/sources/AbstractSource.ts +++ b/src/backend/sources/AbstractSource.ts @@ -2,7 +2,7 @@ import { childLogger, LogDataPretty, LogLevel } from '@foxxmd/logging'; import dayjs, { Dayjs } from "dayjs"; import { EventEmitter } from "events"; import { FixedSizeList } from "fixed-size-list"; -import { JsonPlayObject, PlayMatchResult, PlayObject } from "../../core/Atomic.js"; +import { JsonPlayObject, PlayMatchResult, PlayObject, SOURCE_SOT } from "../../core/Atomic.js"; import { buildTrackString, capitalize, truncateStringToLength } from "../../core/StringUtils.js"; import AbstractComponent from "../common/AbstractComponent.js"; import { @@ -49,6 +49,8 @@ import { AbortedError, generateLoggableAbortReason } from '../common/errors/MSEr import { DrizzlePlayRepository, playToRepositoryCreatePlayOpts, queryArgsFromRequest, QueryPlaysOpts, RequestPlayQuery } from '../common/database/drizzle/repositories/PlayRepository.js'; import { asPlay } from '../../core/PlayMarshalUtils.js'; import { AsyncTask, SimpleIntervalJob, ToadScheduler } from 'toad-scheduler'; +import { ComponentMinimalSelect } from '../common/database/drizzle/drizzleTypes.js'; +import { ComponentSourceApi, ComponentSourceApiJson } from '../../core/Api.js'; export interface RecentlyPlayedOptions { limit?: number @@ -260,6 +262,31 @@ export default abstract class AbstractSource extends AbstractComponent implement return {name: this.getSafeExternalName(), type: this.type}; } + protected getComponentApiData() { + return { + hasAuth: this.requiresAuth, + hasAuthInteraction: this.requiresAuthInteraction, + authed: this.authed, + } + } + + public getApiData(): ComponentSourceApi { + return { + ...super.getApiData(), + ...this.getComponentApiData(), + type: this.type, + state: 'idle', + status: 'idle', + players: {}, + tracksDiscovered: this.tracksDiscovered, + sot: SOURCE_SOT.HISTORY, + supportsUpstreamRecentlyPlayed: this.supportsUpstreamRecentlyPlayed, + supportsManualListening: this.supportsManualListening, + manualListening: this.manualListening, + systemListeningBehavior: this.getSystemListeningBehavior(), + } + } + getSystemListeningBehavior = (): boolean | undefined => { if(this.supportsManualListening) { return this.config.options !== undefined && 'systemScrobble' in this.config.options ? this.config.options?.systemScrobble : undefined; diff --git a/src/backend/sources/MemorySource.ts b/src/backend/sources/MemorySource.ts index 3400d2a4..071c3fd5 100644 --- a/src/backend/sources/MemorySource.ts +++ b/src/backend/sources/MemorySource.ts @@ -2,7 +2,7 @@ import { Logger } from "@foxxmd/logging"; import dayjs, { Dayjs } from "dayjs"; import { EventEmitter } from "events"; import { AsyncTask, SimpleIntervalJob, Task, ToadScheduler } from "toad-scheduler"; -import { PlayObject, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerObj } from "../../core/Atomic.js"; +import { PlayObject, SOURCE_SOT, SOURCE_SOT_TYPES, SourcePlayerJson, SourcePlayerObj } from "../../core/Atomic.js"; import { buildTrackString } from "../../core/StringUtils.js"; import { asPlayerStateDataMaybePlay, @@ -29,6 +29,7 @@ import { AbstractPlayerState, createPlayerOptions, PlayerStateOptions } from "./ import { GenericPlayerState } from "./PlayerState/GenericPlayerState.js"; import { hashObject } from "../utils/StringUtils.js"; import { useDebugValue } from "react"; +import { ComponentSourceApi } from "../../core/Api.js"; const EXPECTED_NON_DISCOVERED_REASON = 'not added because an identical play with the same timestamp was already discovered.'; @@ -178,6 +179,14 @@ export default class MemorySource extends AbstractSource { return record; } + public getApiData(): ComponentSourceApi { + return { + ...super.getApiData(), + sot: this.playerSourceOfTruth, + players: this.playersToObject() as unknown as Record + } + } + getNewPlayer = (logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions): AbstractPlayerState => new GenericPlayerState(logger, id, opts) setNewPlayer = (idStr: string, logger: Logger, id: PlayPlatformId, opts: PlayerStateOptions = {}) => { diff --git a/src/client/AppNext.tsx b/src/client/AppNext.tsx index f64473e9..13b9881a 100644 --- a/src/client/AppNext.tsx +++ b/src/client/AppNext.tsx @@ -16,8 +16,9 @@ import DeadPage from "./deadLetter/DeadPage"; import {clientUpdate, sourceUpdate} from "./status/ducks"; import {useEventSource, useEventSourceListener} from "@react-nano/use-event-source"; import Version from "./Version"; -import { MSComponentList } from './components/msComponent/MSComponentList'; +import { MSComponentList, MSComponentListFetchable } from './components/msComponent/MSComponentList'; import { Provider } from './components/Provider'; +import { Container } from '@chakra-ui/react'; function NoMatch() { const location = useLocation(); @@ -58,7 +59,7 @@ function MissingDocs() { const routes: RouteObject[] = [ { path: "/next", - element: , + element: , }, { path: "/docs", @@ -112,7 +113,9 @@ const router = genRouter(); function App() { return ( - + + + ); } diff --git a/src/client/components/msComponent/MSComponentList.tsx b/src/client/components/msComponent/MSComponentList.tsx index 706a5ad7..ffb6f374 100644 --- a/src/client/components/msComponent/MSComponentList.tsx +++ b/src/client/components/msComponent/MSComponentList.tsx @@ -1,8 +1,11 @@ import React, { ComponentProps, useMemo, useState, forwardRef, Fragment } from "react" -import { CheckboxCard, Span, Stack, SegmentGroup, Text, Box, Center, Heading, Button, Separator, HStack, Flex, Badge } from '@chakra-ui/react'; +import { Stack, SegmentGroup, Text, Box, Center, Card, SkeletonText } from '@chakra-ui/react'; import { ComponentsApiJson } from "../../../core/Api"; -import { components } from "storybook/internal/components"; import { MSComponentSummary } from "./MSComponentSummary"; +import { QueryFunctionContext, queryOptions, useQuery } from '@tanstack/react-query'; +import ky from 'ky'; +import { baseUrl } from "../../utils"; +import { ErrorAlert } from "../ErrorAlert"; export interface ComponentListProps { components: ComponentsApiJson[] @@ -31,4 +34,31 @@ export const MSComponentList = (props: ComponentListProps) => { ) +} + +export const MSComponentListFetchable = () => { + const { isPending, isError, data, error } = useQuery({ + queryKey: ['components'], + queryFn: queryFn + }); + + if(isPending) { + return ( + + + + + ) + } + + if(isError) { + return + } + + return +} + +type ComponentListQueryKey = ['components']; +const queryFn = async (context: QueryFunctionContext) => { + return await ky.get(`components`, { baseUrl: baseUrl }).json() as ComponentsApiJson[]; } \ No newline at end of file -- 2.51.2