Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion web/src/elements/events/LogViewer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import "#components/ak-status-label";
import "#elements/EmptyState";
import "#elements/timestamp/ak-timestamp";

import { formatElapsedTime } from "#common/temporal";

Expand Down Expand Up @@ -101,7 +102,7 @@ export class LogViewer extends Table<LogEvent> {

row(item: LogEvent): SlottedTemplateResult[] {
return [
html`${formatElapsedTime(item.timestamp)}`,
html`<ak-timestamp .timestamp=${item.timestamp} refresh></ak-timestamp>`,
html`<ak-status-label
type=${this.statusForItem(item)}
bad-label=${item.logLevel}
Expand Down
4 changes: 2 additions & 2 deletions web/src/elements/tasks/TaskList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,9 +207,9 @@ export class TaskList extends Table<Task> {
renderExpanded(item: Task): TemplateResult {
return html`<div class="pf-c-content">
<p class="pf-c-title pf-u-mb-md">${msg("Current execution logs")}</p>
<ak-log-viewer .logs=${item?.logs}></ak-log-viewer>
<ak-log-viewer .logs=${item.logs}></ak-log-viewer>
<p class="pf-c-title pf-u-mt-xl pf-u-mb-md">${msg("Previous executions logs")}</p>
<ak-log-viewer .logs=${item?.previousLogs}></ak-log-viewer>
<ak-log-viewer .logs=${item.previousLogs}></ak-log-viewer>
</div>`;
}
}
Expand Down
109 changes: 97 additions & 12 deletions web/src/elements/timestamp/ak-timestamp.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand All @@ -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<this>): 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`<span role="time" aria-label="None">-</span>`;
Expand Down
Loading