feat: Implement multiple stateful errors/warnings on component

This commit is contained in:
FoxxMD 2026-08-12 23:28:08 +00:00
parent 08a4686885
commit eb43fa04fd
10 changed files with 56 additions and 41 deletions

View file

@ -245,7 +245,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
this.setStatus('Retention cleanup finished');
} catch (e) {
const retentionErr = new Error('Failed to do retention cleanup', {cause: e});
this.warning = retentionErr;
this.warnings.push(retentionErr);
this.logger.warn(retentionErr);
this.setStatus('Retention cleanup failed');
}
@ -545,7 +545,7 @@ export default abstract class AbstractComponent extends AbstractInitializable {
public abstract getRunningState(): ComponentState
public getApiData(): Omit<ComponentCommonApiJson, 'type' | 'countLive' | 'players'> & Pick<ComponentCommonApi, 'state' | 'error' | 'warning'> {
public getApiData(): Omit<ComponentCommonApiJson, 'type' | 'countLive' | 'players'> & Pick<ComponentCommonApi, 'state' | 'errors' | 'warnings'> {
let state: ComponentState;
if(!this.initializedOnce || this.initializing) {
state = COMPONENT_STATE.INITIALIZING;
@ -565,8 +565,8 @@ export default abstract class AbstractComponent extends AbstractInitializable {
createdAt: this.dbComponent.createdAt?.toISOString(),
lastReadyAt: this.lastReadyAt?.toISOString(),
lastActiveAt: this.lastActiveAt?.toISOString(),
error: this.error !== undefined && this.error instanceof Error ? serializeError(this.error) : this.error,
warning: this.warning !== undefined && this.warning instanceof Error ? serializeError(this.warning) : this.warning,
errors: this.errors.map(x => x instanceof Error ? serializeError(x) : x),
warnings: this.warnings.map(x => x instanceof Error ? serializeError(x) : x),
...this.additionalApiData()
}
}
@ -582,11 +582,19 @@ export default abstract class AbstractComponent extends AbstractInitializable {
}
protected emitComponentUpdate = <T extends Partial<ReturnType<typeof this.getApiData>>>(payload: T) => {
if('error' in payload && payload.error instanceof Error) {
payload.error = serializeError(payload.error);
if('errors' in payload) {
if(payload.errors.length > 0) {
payload.errors = payload.errors.map(x => x instanceof Error ? serializeError(x) : x);
} else {
payload.errors = [];
}
}
if('warning' in payload && payload.warning instanceof Error) {
payload.warning = serializeError(payload.warning);
if('warnings' in payload) {
if(payload.warnings.length > 0) {
payload.warnings = payload.warnings.map(x => x instanceof Error ? serializeError(x) : x);
} else {
payload.warnings = [];
}
}
this.emitEvent('componentUpdate', payload);
}

View file

@ -19,8 +19,8 @@ export default abstract class AbstractInitializable {
databaseOK?: boolean | null;
connectionOK?: boolean | null;
cacheOK?: boolean | null;
error?: Error;
warning?: Error;
errors?: Error[] = [];
warnings?: Error[] = [];
protected initializedOnce: boolean = false;
initializing: boolean = false;
@ -88,15 +88,15 @@ export default abstract class AbstractInitializable {
} catch (e) {
throw new PostInitError('Error occurred during post-initialization hook', {cause: e});
}
this.error = undefined;
this.warning = undefined;
this.errors = [];
this.warnings = [];
return true;
} catch(e) {
if(notify) {
await this.notify({identifier: this.getIdentifier(), title: notifyTitle, message: truncateStringToLength(500)(messageWithCausesTruncatedDefault(e)), priority: 'error'});
}
const initError = new Error('Initialization failed', {cause: e});
this.error = initError;
this.errors = [initError];
throw initError;
} finally {
this.initializing = false;

View file

@ -249,12 +249,12 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
'Heartbeat',
(): Promise<any> => {
return this.heartbeatTask().then(() => null).catch((err) => {
this.error = err;
this.errors.push(err);
this.logger.error(err);
});
},
(err: Error) => {
this.error = err;
this.errors.push(err);
this.logger.error(err);
}
), {id: 'heartbeat'}));
@ -278,14 +278,14 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
(): Promise<any> => {
if(this.isReady()) {
return this.processDeadLetterQueue().then(() => null).catch((e) => {
this.warning = e;
this.warnings = e;
this.logger.error(e);
})
}
return new Promise((resolve, reject) => resolve);
},
(err: Error) => {
this.warning = err;
this.warnings.push(err);
this.logger.error(err);
}
), {id: 'dead'}));
@ -547,8 +547,8 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
}, (err: Error) => {
const npErr = new Error('Unexpected error while processing Now Playing queue', {cause: err});
this.npLogger.error(npErr);
this.warning = npErr;
this.emitComponentUpdate<Partial<ComponentClientApiJson>>({warning: npErr});
this.warnings.push(npErr);
this.emitComponentUpdate<Partial<ComponentClientApiJson>>({warnings: this.warnings});
});
// even though we are processing every 5 seconds the interval that Now Playing is updated at, and that the queue is cleared on,
@ -856,8 +856,8 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
}
} catch (e) {
const preloadErr = new SimpleError('Could not preload scrobbles', {cause: e, shortStack: true});
this.warning = preloadErr;
this.emitComponentUpdate<Partial<ComponentClientApiJson>>({warning: preloadErr});
this.warnings.push(preloadErr);
this.emitComponentUpdate<Partial<ComponentClientApiJson>>({warnings: this.warnings});
this.logger.warn(preloadErr);
}
}
@ -1044,7 +1044,8 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
const err = new Error('Scrobble processing stopped with error', { cause: e });
this.logger.warn(err);
componentUpdate.status = 'Processing stopped with error';
componentUpdate.warning = err;
this.warnings.push(err);
componentUpdate.warnings = this.warnings;
}
this.emitComponentUpdate<Partial<ComponentClientApiJson>>(componentUpdate);
}).finally(() => {
@ -1169,10 +1170,10 @@ export default abstract class AbstractScrobbleClient extends AbstractComponent i
if(nextQueued !== undefined) {
while (nextQueued !== undefined) {
await this.processQueueCurrentScrobble(nextQueued, signal);
if(this.error !== undefined) {
if(this.errors.length > 0) {
// we made it through a scrobble without any issues so clear any issue we may have previously had
this.error = undefined;
this.emitComponentUpdate<Partial<ComponentClientApiJson>>({error: null});
this.errors = [];
this.emitComponentUpdate<Partial<ComponentClientApiJson>>({errors: []});
}
nextQueued = await this.playRepo.getQueueNext(CLIENT_INGRESS_QUEUE)
}

View file

@ -27,9 +27,7 @@ export const setupDeezerRoutes = (app: Express, logger: Logger, scrobbleSources:
// @ts-expect-error TS(2339): Property 'deezerSource' does not exist on type 'Se... Remove this comment to see the full error message
const entity = scrobbleSources.getByName(req.session.deezerSource as string) as DeezerSource;
for(let i = 0; i < 3; i++) {
if(entity.error !== undefined) {
return res.send('Error with deezer credentials storage');
} else if(entity.config.data.accessToken !== undefined) {
if(entity.config.data.accessToken !== undefined) {
// start polling
await entity.doAuthentication();
entity.poll()

View file

@ -155,13 +155,13 @@ export default abstract class AbstractSource extends AbstractComponent implement
'Heartbeat',
(): Promise<any> => {
return this.heartbeatTask().then(() => null).catch((err) => {
this.error = err;
this.errors.push(err);
this.logger.error(err);
});
},
(err: Error) => {
this.logger.error(err);
this.error = err;
this.errors.push(err);
}
), {id: 'heartbeat'}));
} else {
@ -556,8 +556,8 @@ export default abstract class AbstractSource extends AbstractComponent implement
const err = new Error('Cannot start polling because Source is not ready', {cause: e});
this.logger.error(err);
this.setStatus('Polling Error');
this.emitComponentUpdate<Partial<ComponentSourceApiJson>>({error: err});
this.error = err;
this.errors.push(err);
this.emitComponentUpdate<Partial<ComponentSourceApiJson>>({errors: this.errors});
if(notify) {
await this.notify( {title: `Polling Error`, message: `Cannot start polling because Source is not ready: ${truncateStringToLength(500)(messageWithCausesTruncatedDefault(e))}`, priority: 'error'});
}
@ -609,8 +609,8 @@ export default abstract class AbstractSource extends AbstractComponent implement
const err = new Error('Polling stopped with error', { cause: e });
this.logger.warn(err);
componentUpdate.status = 'Polling stopped with error';
componentUpdate.warning = err;
this.warning = err;
this.warnings.push(err);
componentUpdate.warnings = this.warnings;
}
this.emitComponentUpdate<Partial<ComponentSourceApiJson>>(componentUpdate);
}).finally(() => {

View file

@ -242,7 +242,7 @@ export default class DeezerSource extends AbstractSource {
return true;
} else {
this.logger.warn('Callback contained an error! User may have denied access?')
this.error = error;
this.errors = error;
this.logger.error(error);
return error;
}

View file

@ -34,6 +34,12 @@ class TestComponent extends AbstractComponent {
protected getIdentifier(): string {
return 'test';
}
public async start(opts?: { forceInit?: boolean; }): Promise<boolean> {
return true;
}
public async stop(opts?: { reason?: string | Error; }): Promise<void> {
return;
}
constructor(config?: Omit<AbstractComponentConfig, 'id'>) {
super({id: `test-${Date.now()}`, ...(config ?? {})});
}

View file

@ -165,8 +165,8 @@ export const ComponentDetailedDesktop = (props: {data?: ComponentCommonApiJson,
const {
data,
data: {
warning,
error
warnings = [],
errors = []
} = {}
} = props;
const isSource = isComponentSourceApiJson(data)
@ -207,8 +207,8 @@ export const ComponentDetailedDesktop = (props: {data?: ComponentCommonApiJson,
<Flex justifyContent="flex-end" rowGap="6" flexDirection="row-reverse" wrap="wrap">
<Box marginEnd="auto"><MSComponentStats {...props}/></Box>
</Flex>
{error !== undefined && error !== null ? <ErrorAlert error={error}/> : undefined}
{warning !== undefined && warning !== null ? <ErrorAlert error={warning} status="warning"/> : undefined}
{errors.length > 0 ? <>{errors.map(x => <ErrorAlert error={x}/>)}</> : undefined }
{warnings.length > 0 ? <>{warnings.map(x => <ErrorAlert error={x} status="warning"/>)}</> : undefined }
<MSErrorBoundary>{props.live ? <PlayersContainerFetchable nowPlaying={isSource ? undefined : true} data={data}/> : <PlayersContainer nowPlaying={isSource ? undefined : true} data={data} live={props.live}/>}</MSErrorBoundary>
<MSErrorBoundary><ListContainerFilterable render="virtDynamic" componentType={data.mode} componentId={data.id}/></MSErrorBoundary>
</Flex>

View file

@ -80,8 +80,8 @@ export type ComponentCommonApi = {
/** More specific, live activity state like "sleeping", "hydrating historical scrobbles", "processing dead scrobbles", etc... */
status?: string
players: Record<string, SourcePlayerJson>
error?: ErrorIsh
warning?: ErrorIsh
errors?: ErrorIsh[]
warnings?: ErrorIsh[]
monitoringStatus?: MonitoringStatus
} & Omit<ComponentMinimalSelect, 'type'>

View file

@ -808,6 +808,8 @@ export const NO_DEVICE = 'NoDevice';export const NO_USER = 'SingleUser';
export const SINGLE_USER_PLATFORM_ID: PlayPlatformId = [NO_DEVICE, NO_USER];
export const SINGLE_USER_PLATFORM_ID_STR = `${NO_DEVICE}-${NO_USER}`;
export type ComponentAuthType = 'none' | 'interactive' | 'unattended';
export type EmittedMSEvent<T = Record<string, any>, K = Record<string, any>,Y = ClientType | SourceType> = {
type: Y
name: string