diff --git a/web/src/elements/events/LogViewer.ts b/web/src/elements/events/LogViewer.ts index 02f1fa6271e7..ab0613314507 100644 --- a/web/src/elements/events/LogViewer.ts +++ b/web/src/elements/events/LogViewer.ts @@ -1,5 +1,6 @@ import "#components/ak-status-label"; import "#elements/EmptyState"; +import "#elements/timestamp/ak-timestamp"; import { formatElapsedTime } from "#common/temporal"; @@ -101,7 +102,7 @@ export class LogViewer extends Table { row(item: LogEvent): SlottedTemplateResult[] { return [ - html`${formatElapsedTime(item.timestamp)}`, + html``, html` { renderExpanded(item: Task): TemplateResult { return html`

${msg("Current execution logs")}

- +

${msg("Previous executions logs")}

- +
`; } } diff --git a/web/src/elements/timestamp/ak-timestamp.ts b/web/src/elements/timestamp/ak-timestamp.ts index c482ad018270..27410744d3f1 100644 --- a/web/src/elements/timestamp/ak-timestamp.ts +++ b/web/src/elements/timestamp/ak-timestamp.ts @@ -1,16 +1,41 @@ import { formatElapsedTime } from "#common/temporal"; import { AKElement } from "#elements/Base"; +import { intersectionObserver } from "#elements/decorators/intersection-observer"; import { ifPresent } from "#elements/utils/attributes"; -import { html, nothing } from "lit"; +import { html, nothing, PropertyValues } from "lit"; import { customElement, property } from "lit/decorators.js"; @customElement("ak-timestamp") export class AKTimestamp extends AKElement { + /** + * The interval at which the timestamp updates (in milliseconds). + */ + public static updateInterval = 1000 * 60; + + /** + * A lazy-loaded media query list for detecting reduced motion preferences. + * + * @remarks + * This is initialized only when needed to avoid: + * + * - Multiple media query list instances across all timestamp elements. + * - Initialization before the element is visible. + */ + protected static reducedMotionMediaQuery: MediaQueryList | null = null; + #timestamp: Date | null = null; - @property({ attribute: false }) + @property({ + attribute: false, + hasChanged(value, previousValue) { + if (value instanceof Date && previousValue instanceof Date) { + return value.getTime() !== previousValue.getTime(); + } + return value !== previousValue; + }, + }) public get timestamp(): Date | null { return this.#timestamp; } @@ -19,34 +44,94 @@ export class AKTimestamp extends AKElement { this.#timestamp = value ? (value instanceof Date ? value : new Date(value)) : null; } + @intersectionObserver() + public visible = false; + @property({ type: Boolean }) public elapsed: boolean = true; - @property({ type: Boolean }) + @property({ type: Boolean, useDefault: true }) public datetime: boolean = false; - @property({ type: Boolean }) + @property({ type: Boolean, useDefault: true }) public refresh: boolean = false; #interval = -1; + #animationFrameID = -1; public connectedCallback(): void { super.connectedCallback(); - - if (this.refresh) { - this.#interval = self.setInterval(() => { - this.requestUpdate(); - }, 1000 * 60); - } } public disconnectedCallback(): void { super.disconnectedCallback(); - if (this.#interval !== -1) { - self.clearInterval(this.#interval); + this.stopInterval(); + cancelAnimationFrame(this.#animationFrameID); + } + + public updated(changed: PropertyValues): void { + super.updated(changed); + + if (changed.has("visible") || changed.has("timestamp") || changed.has("refresh")) { + cancelAnimationFrame(this.#animationFrameID); + this.#animationFrameID = requestAnimationFrame(this.startInterval); } } + public stopInterval = () => { + clearInterval(this.#interval); + }; + + public startInterval = () => { + this.stopInterval(); + + if ( + !this.timestamp || + !this.refresh || + document.visibilityState !== "visible" || + !this.visible + ) { + return; + } + + if (!AKTimestamp.reducedMotionMediaQuery) { + AKTimestamp.reducedMotionMediaQuery = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ); + } + + const moment = this.timestamp.getTime(); + const start = Date.now(); + const { updateInterval, reducedMotionMediaQuery } = AKTimestamp; + + const startWithinInterval = + start >= moment - updateInterval && start <= moment + updateInterval; + + // Adjust interval based on how close we are to the minute mark, + // allowing the elapsed time to at first update every second for the first minute, + // then every minute afterwards. + + if (startWithinInterval && !reducedMotionMediaQuery.matches) { + this.#interval = self.setInterval(() => { + if (!this.visible || document.visibilityState !== "visible") return; + + this.requestUpdate(); + + const now = Date.now(); + + if (now < moment - updateInterval || now > moment + updateInterval) { + this.startInterval(); + } + }, 1000); + } else { + this.#interval = self.setInterval(() => { + if (!this.visible || document.visibilityState !== "visible") return; + + this.requestUpdate(); + }, updateInterval); + } + }; + public render() { if (!this.timestamp || this.timestamp.getTime() === 0) { return html`-`;