feat: Add hardened error handling and retries (#2451)
- Closes #1830 - Closes #2099
This commit is contained in:
committed by
dermotduffy
parent
4bc787e2b7
commit
47bcce93d3
@@ -4,7 +4,6 @@ import {
|
||||
AdvancedCameraCardMediaLoadedEventTarget,
|
||||
dispatchExistingMediaLoadedInfoAsEvent,
|
||||
} from '../../utils/media-info.js';
|
||||
import { AdvancedCameraCardMessageEventTarget } from '../message/dispatch.js';
|
||||
|
||||
interface LiveViewContext {
|
||||
// A cameraID override (used for dependencies/substreams to force a different
|
||||
@@ -23,9 +22,7 @@ interface LastMediaLoadedInfo {
|
||||
source: EventTarget;
|
||||
}
|
||||
|
||||
type LiveControllerHost = LitElement &
|
||||
AdvancedCameraCardMediaLoadedEventTarget &
|
||||
AdvancedCameraCardMessageEventTarget;
|
||||
type LiveControllerHost = LitElement & AdvancedCameraCardMediaLoadedEventTarget;
|
||||
|
||||
export class LiveController implements ReactiveController {
|
||||
private _host: LiveControllerHost;
|
||||
|
||||
+14
-12
@@ -33,8 +33,8 @@ export interface NotificationControlsContext {
|
||||
filterReviewed?: boolean;
|
||||
}
|
||||
|
||||
export class MediaDetailsController {
|
||||
private _details: NotificationDetail[] = [];
|
||||
export class MediaNotificationController {
|
||||
private _metadata: NotificationDetail[] = [];
|
||||
private _heading: NotificationDetail | null = null;
|
||||
private _item: ViewItem | null = null;
|
||||
|
||||
@@ -50,7 +50,7 @@ export class MediaDetailsController {
|
||||
: null;
|
||||
|
||||
this._calculateHeading(cameraMetadata, item);
|
||||
this._calculateDetails(cameraMetadata, item, seek);
|
||||
this._calculateMetadata(cameraMetadata, item, seek);
|
||||
}
|
||||
|
||||
private _calculateHeading(
|
||||
@@ -99,7 +99,7 @@ export class MediaDetailsController {
|
||||
this._heading = null;
|
||||
}
|
||||
|
||||
private _calculateDetails(
|
||||
private _calculateMetadata(
|
||||
cameraMetadata: CameraManagerCameraMetadata | null,
|
||||
item?: ViewItem,
|
||||
seek?: Date,
|
||||
@@ -111,7 +111,7 @@ export class MediaDetailsController {
|
||||
const duration = startTime && endTime ? getDurationString(startTime, endTime) : null;
|
||||
const inProgress = ViewItemClassifier.isMedia(item)
|
||||
? item.inProgress()
|
||||
? localize('thumbnail.in_progress')
|
||||
? localize('common.in_progress')
|
||||
: null
|
||||
: null;
|
||||
const where = ViewItemClassifier.isMedia(item)
|
||||
@@ -184,7 +184,7 @@ export class MediaDetailsController {
|
||||
const includeTitle =
|
||||
(!ViewItemClassifier.isEvent(item) && !ViewItemClassifier.isReview(item)) ||
|
||||
!startTime;
|
||||
this._details = [
|
||||
this._metadata = [
|
||||
...(includeTitle && itemTitle
|
||||
? [
|
||||
{
|
||||
@@ -204,18 +204,20 @@ export class MediaDetailsController {
|
||||
return this._heading;
|
||||
}
|
||||
|
||||
public getDetails(): NotificationDetail[] {
|
||||
return this._details;
|
||||
public getMetadata(): NotificationDetail[] {
|
||||
return this._metadata;
|
||||
}
|
||||
|
||||
public getNotification(context?: NotificationControlsContext): Notification {
|
||||
const description = ViewItemClassifier.isMedia(this._item)
|
||||
? this._item.getDescription()
|
||||
: null;
|
||||
|
||||
return {
|
||||
heading: this._heading ?? undefined,
|
||||
controls: context ? this._getControls(context) : undefined,
|
||||
details: this._details,
|
||||
text: ViewItemClassifier.isMedia(this._item)
|
||||
? this._item.getDescription() ?? undefined
|
||||
: undefined,
|
||||
metadata: this._metadata,
|
||||
body: description ? { text: description } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import yaml from 'js-yaml';
|
||||
import { Link } from '../../config/schema/common/link.js';
|
||||
import { TROUBLESHOOTING_URL } from '../../const';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import { Message } from '../../types';
|
||||
|
||||
export class MessageController {
|
||||
public getMessageString(message: Message): string {
|
||||
return (
|
||||
message.message +
|
||||
(message.context && typeof message.context === 'string'
|
||||
? ': ' + message.context
|
||||
: '')
|
||||
);
|
||||
}
|
||||
|
||||
public getIcon(message: Message): string {
|
||||
return message.icon
|
||||
? message.icon
|
||||
: message.type === 'error'
|
||||
? 'mdi:alert-circle'
|
||||
: 'mdi:information-outline';
|
||||
}
|
||||
|
||||
public getLink(message: Message): Link | null {
|
||||
return message.link
|
||||
? message.link
|
||||
: message.type === 'error'
|
||||
? { url: TROUBLESHOOTING_URL, title: localize('error.troubleshooting') }
|
||||
: null;
|
||||
}
|
||||
|
||||
public getContextStrings(message: Message): string[] {
|
||||
if (Array.isArray(message.context)) {
|
||||
return message.context.map((contextItem) => yaml.dump(contextItem));
|
||||
}
|
||||
if (typeof message.context === 'object') {
|
||||
return [yaml.dump(message.context)];
|
||||
}
|
||||
if (typeof message.context === 'string') {
|
||||
return [message.context];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { AdvancedCameraCardError, Message } from '../../types';
|
||||
import { fireAdvancedCameraCardEvent } from '../../utils/fire-advanced-camera-card-event';
|
||||
|
||||
// Facilitates correct typing of event handlers.
|
||||
export interface AdvancedCameraCardMessageEventTarget extends EventTarget {
|
||||
addEventListener(
|
||||
event: 'advanced-camera-card:message',
|
||||
listener: (
|
||||
this: AdvancedCameraCardMessageEventTarget,
|
||||
ev: CustomEvent<Message>,
|
||||
) => void,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
addEventListener(
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
options?: AddEventListenerOptions | boolean,
|
||||
): void;
|
||||
removeEventListener(
|
||||
event: 'advanced-camera-card:message',
|
||||
listener: (
|
||||
this: AdvancedCameraCardMessageEventTarget,
|
||||
ev: CustomEvent<Message>,
|
||||
) => void,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
removeEventListener(
|
||||
type: string,
|
||||
callback: EventListenerOrEventListenerObject,
|
||||
options?: boolean | EventListenerOptions,
|
||||
): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch an event with an error message to show to the user. Calling this
|
||||
* method will grind the card to a halt, so should only be used for "global" /
|
||||
* critical errors (i.e. not for individual errors with a given camera, since
|
||||
* there may be multiple correctly functioning cameras in a grid).
|
||||
* @param element The element to send the event.
|
||||
* @param message The message to show.
|
||||
*/
|
||||
export const dispatchAdvancedCameraCardErrorEvent = (
|
||||
element: EventTarget,
|
||||
error: unknown,
|
||||
): void => {
|
||||
if (error instanceof Error) {
|
||||
fireAdvancedCameraCardEvent<Message>(element, 'message', {
|
||||
message: error.message,
|
||||
type: 'error',
|
||||
...(error instanceof AdvancedCameraCardError && { context: error.context }),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { dispatchActionExecutionRequest } from '../../card-controller/actions/utils/execution-request.js';
|
||||
import { NotificationControl } from '../../config/schema/actions/types.js';
|
||||
import {
|
||||
getActionConfigGivenAction,
|
||||
stopEventFromActivatingCardWideActions,
|
||||
} from '../../utils/action.js';
|
||||
import { arrayify } from '../../utils/basic.js';
|
||||
|
||||
export function handleControlAction(
|
||||
ev: CustomEvent<{ action: string }>,
|
||||
control: NotificationControl,
|
||||
host: HTMLElement,
|
||||
onDismiss?: () => void,
|
||||
): void {
|
||||
stopEventFromActivatingCardWideActions(ev);
|
||||
const action = getActionConfigGivenAction(ev.detail.action, control.actions);
|
||||
if (action) {
|
||||
dispatchActionExecutionRequest(host, { actions: arrayify(action) });
|
||||
}
|
||||
if (onDismiss && control.dismiss !== false) {
|
||||
onDismiss();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
// Converts a structured data object into preformatted text strings for a
|
||||
// notification's context section. Arrays produce one string per item (strings
|
||||
// pass through; objects are YAML-dumped); all other objects produce a single
|
||||
// YAML-dumped string.
|
||||
export const dataToContext = (data: object): string[] => {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => (typeof item === 'string' ? item : yaml.dump(item)));
|
||||
}
|
||||
return [yaml.dump(data)];
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Notification, NotificationDetail } from '../../config/schema/actions/types.js';
|
||||
import { Link } from '../../config/schema/common/link.js';
|
||||
import { getContextFromError } from '../../utils/error-context.js';
|
||||
import { dataToContext } from './data-to-context.js';
|
||||
|
||||
const DEFAULT_ERROR_ICON = 'mdi:alert';
|
||||
|
||||
export interface NotificationOptions {
|
||||
heading?: NotificationDetail;
|
||||
icon?: string;
|
||||
link?: Link;
|
||||
metadata?: NotificationDetail[];
|
||||
context?: object;
|
||||
in_progress?: boolean;
|
||||
}
|
||||
|
||||
export const createNotificationFromText = (
|
||||
text: string,
|
||||
options?: NotificationOptions,
|
||||
): Notification => ({
|
||||
...(options?.heading && { heading: options.heading }),
|
||||
body: {
|
||||
text,
|
||||
...((options?.icon || !options?.heading) && {
|
||||
icon: options?.icon ?? DEFAULT_ERROR_ICON,
|
||||
}),
|
||||
},
|
||||
...(options?.metadata && { metadata: options.metadata }),
|
||||
...(options?.link && { link: options.link }),
|
||||
...(options?.context && {
|
||||
context: dataToContext(options.context),
|
||||
}),
|
||||
...(options?.in_progress !== undefined && { in_progress: options.in_progress }),
|
||||
});
|
||||
|
||||
export const createNotificationFromError = (
|
||||
error: NonNullable<unknown>,
|
||||
options?: NotificationOptions,
|
||||
): Notification => {
|
||||
const text =
|
||||
typeof error === 'object' && 'message' in error
|
||||
? String(error.message)
|
||||
: typeof error === 'object'
|
||||
? JSON.stringify(error)
|
||||
: String(error);
|
||||
|
||||
const context = options?.context ?? getContextFromError(error) ?? undefined;
|
||||
|
||||
return createNotificationFromText(text, {
|
||||
...options,
|
||||
context,
|
||||
heading: options?.heading
|
||||
? { icon: DEFAULT_ERROR_ICON, severity: 'high', ...options.heading }
|
||||
: undefined,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Notification } from '../../config/schema/actions/types.js';
|
||||
|
||||
export const summarizeNotification = (notification: Notification): string | null => {
|
||||
const text = notification.body?.text ?? notification.heading?.text ?? null;
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
const metadata = notification.metadata?.map((m) => m.text).join(', ');
|
||||
return metadata ? `${text} [${metadata}]` : text;
|
||||
};
|
||||
@@ -35,11 +35,25 @@ export class StatusBarController {
|
||||
const sufficientBefore = this._getSufficientValues(this._items);
|
||||
const sufficientAfter = this._getSufficientValues(newItems);
|
||||
|
||||
const hadPermanent = this._hasPermanentItems(this._items);
|
||||
this._items = newItems;
|
||||
|
||||
if (this._config?.style === 'popup' && !isEqual(sufficientBefore, sufficientAfter)) {
|
||||
this._show();
|
||||
this._popupTimer.start(this._config.popup_seconds, () => this._hide());
|
||||
if (this._config?.style === 'popup') {
|
||||
const hasPermanent = this._hasPermanentItems(newItems);
|
||||
const sufficientChanged = !isEqual(sufficientBefore, sufficientAfter);
|
||||
|
||||
if (hasPermanent) {
|
||||
// Permanent items present: show and cancel any running timer.
|
||||
this._show();
|
||||
this._popupTimer.stop();
|
||||
} else if (sufficientChanged) {
|
||||
// Sufficient items changed without permanent items: normal popup.
|
||||
this._show();
|
||||
this._popupTimer.start(this._config.popup_seconds, () => this._hide());
|
||||
} else if (hadPermanent && !hasPermanent) {
|
||||
// Permanent items just removed: start the popup timer to fade out.
|
||||
this._popupTimer.start(this._config.popup_seconds, () => this._hide());
|
||||
}
|
||||
}
|
||||
|
||||
this._host.requestUpdate();
|
||||
@@ -55,7 +69,7 @@ export class StatusBarController {
|
||||
this._host.setAttribute('data-style', config.style);
|
||||
this._host.setAttribute('data-position', config.position);
|
||||
|
||||
if (this._config?.style !== 'popup') {
|
||||
if (this._config?.style !== 'popup' || this._hasPermanentItems(this._items)) {
|
||||
this._show();
|
||||
}
|
||||
|
||||
@@ -67,7 +81,9 @@ export class StatusBarController {
|
||||
}
|
||||
|
||||
public shouldRender(): boolean {
|
||||
return this._items.some((item) => item.enabled !== false && item.sufficient);
|
||||
return this._items.some(
|
||||
(item) => item.enabled !== false && (item.sufficient || item.permanent),
|
||||
);
|
||||
}
|
||||
|
||||
public actionHandler(
|
||||
@@ -109,6 +125,10 @@ export class StatusBarController {
|
||||
.map((item) => this._getSufficientValue(item));
|
||||
}
|
||||
|
||||
private _hasPermanentItems(items: StatusBarItem[]): boolean {
|
||||
return items.some((item) => item.enabled !== false && item.permanent);
|
||||
}
|
||||
|
||||
private _show(): void {
|
||||
setOrRemoveAttribute(this._host, false, 'hide');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user