Reimplement frontend components with EventStore that manages event lifespan

This commit is contained in:
Mikko Ahlroth 2020-04-04 18:10:21 +03:00
parent 99a69380db
commit 023c536ca1
9 changed files with 159 additions and 72 deletions

View file

@ -1,14 +1,20 @@
import { EventStore, IEventListener } from "./event-store.js";
import { IPageView } from "./page-view.js";
/** Component that controls an element, rendering output to it. */
export abstract class Component {
export abstract class Component implements IEventListener {
protected el: HTMLElement;
protected eventStore: EventStore;
constructor(el: HTMLElement) {
constructor(el: HTMLElement, eventStore: EventStore) {
this.el = el;
this.eventStore = eventStore;
this.eventStore.listen(this);
}
public abstract handleView(view: IPageView): void;
public abstract add(view: IPageView): void;
public abstract expire(view: IPageView): void;
public abstract render(): void;
}

View file

@ -3,3 +3,21 @@ export const CURRENT_THRESHOLD = 300 as const;
/** Maximum reconnect delay in seconds. */
export const MAX_RECONNECT_DELAY = 30 as const;
/** How often to check old events and clear non-current. */
export const OLD_CLEAR_INTERVAL = 1 as const;
/** Max amount of events to show in event list. */
export const EVENT_LIST_MAX_VIEWS = 20 as const;
/** Max amount of paths shown in top paths. */
export const PATH_LIST_MAX_LEN = 6 as const;
/** Locale to use for string/date/time formatting. */
export const LOCALE = "fi-FI" as const;
/** Timezone to use displaying datetimes. */
export const TIMEZONE = "Europe/Helsinki" as const;
/** Use 24 hour clock when displaying datetimes? */
export const CLOCK_24H = true as const;

View file

@ -1,27 +1,40 @@
import { CLOCK_24H, EVENT_LIST_MAX_VIEWS, LOCALE, TIMEZONE } from "./config.js";
import { EventStore } from "./event-store.js";
import { IPageView } from "./page-view.js";
import { Table } from "./table.js";
const MAX_VIEWS = 20;
export class CurrentSessions extends Table {
private viewData: IPageView[];
private dateFormatter: Intl.DateTimeFormat;
constructor(el: HTMLElement) {
super(el, ["Time", "Session", "Path", "Referrer"]);
constructor(el: HTMLElement, eventStore: EventStore) {
super(el, eventStore, ["Time", "Session", "Path", "Referrer"]);
this.viewData = [];
this.dateFormatter = new Intl.DateTimeFormat(LOCALE, {
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZone: TIMEZONE,
hour12: !CLOCK_24H,
});
}
public handleView(view: IPageView) {
public add(view: IPageView) {
this.viewData.push(view);
this.render();
}
public expire(view: IPageView) {
this.viewData = this.viewData.filter((v) => v !== view);
this.render();
}
public render() {
this.viewData.sort((a, b) => {
return (b.at.getTime() - a.at.getTime());
});
this.viewData = this.viewData.slice(0, MAX_VIEWS - 1);
this.viewData = this.viewData.slice(0, EVENT_LIST_MAX_VIEWS - 1);
super.setData(this.viewData.map((v) => {
let ref: string | HTMLAnchorElement = v.referrer;
@ -33,7 +46,7 @@ export class CurrentSessions extends Table {
ref.target = "_blank";
}
return [v.at.toISOString(), v.session, v.path_noq, ref];
return [this.dateFormatter.format(v.at), v.session, v.path_noq, ref];
}));
super.render();

View file

@ -0,0 +1,48 @@
import { CURRENT_THRESHOLD, OLD_CLEAR_INTERVAL } from "./config.js";
import { IPageView } from "./page-view.js";
export interface IEventListener {
add(view: IPageView): void;
expire(view: IPageView): void;
}
/**
* The event store keeps track of events that can be displayed as "current" and clears old ones.
*
* Components can subscribe to events to get informed of event changes.
*/
export class EventStore {
private events: IPageView[];
private listeners: IEventListener[];
constructor() {
this.events = [];
this.listeners = [];
setInterval(() => this.clearOld(), OLD_CLEAR_INTERVAL * 1000);
}
public add(view: IPageView) {
this.events.push(view);
for (const listener of this.listeners) {
listener.add(view);
}
}
public listen(listener: IEventListener) {
this.listeners.push(listener);
}
private clearOld() {
const now = Date.now();
const firstCurrent = this.events.findIndex((v) => (now - v.at.getTime()) <= CURRENT_THRESHOLD * 1000);
const deleted = this.events.splice(0, firstCurrent !== -1 ? firstCurrent : undefined);
for (const deletedEvent of deleted) {
for (const listener of this.listeners) {
listener.expire(deletedEvent);
}
}
}
}

View file

@ -1,11 +1,13 @@
import { connectListener } from "./archive-client.js";
import { MAX_RECONNECT_DELAY } from "./config.js";
import { CurrentSessions } from "./current-sessions.js";
import { EventStore } from "./event-store.js";
import { LiveCounter } from "./live-counter.js";
import { IRemotePageView, parseView } from "./page-view.js";
import { TopPaths } from "./top-paths.js";
import { WorldMap } from "./world-map.js";
let eventStore: EventStore;
let worldMap: WorldMap;
let currentyLiveCounter: LiveCounter;
let currentSessions: CurrentSessions;
@ -22,8 +24,8 @@ function errorCallback(stream: EventSource, e: ErrorEvent) {
stream.close();
console.log("Reconnecting...");
const delaySecs = Math.random() * MAX_RECONNECT_DELAY;
const delaySecs = Math.round(Math.random() * MAX_RECONNECT_DELAY);
console.log(`Reconnecting in ${delaySecs} seconds…`);
setTimeout(initConnection, delaySecs * 1000);
}
@ -32,11 +34,7 @@ function msgCallback(stream: EventSource, e: MessageEvent) {
console.log("Received data", data);
const parsedData = parseView(data);
currentyLiveCounter.handleView(parsedData);
worldMap.handleView(parsedData);
currentSessions.handleView(parsedData);
topPaths.handleView(parsedData);
eventStore.add(parsedData);
}
function initConnection() {
@ -44,10 +42,11 @@ function initConnection() {
}
function main() {
currentyLiveCounter = new LiveCounter(document.getElementById("currently-live-counter")!);
worldMap = new WorldMap(document.getElementById("world-map")!);
currentSessions = new CurrentSessions(document.getElementById("current-sessions-data")!);
topPaths = new TopPaths(document.getElementById("top-paths-data")!);
eventStore = new EventStore();
currentyLiveCounter = new LiveCounter(document.getElementById("currently-live-counter")!, eventStore);
worldMap = new WorldMap(document.getElementById("world-map")!, eventStore);
currentSessions = new CurrentSessions(document.getElementById("current-sessions-data")!, eventStore);
topPaths = new TopPaths(document.getElementById("top-paths-data")!, eventStore);
const hostParam = (new URL(window.location.href)).searchParams.get("host");
@ -58,6 +57,9 @@ function main() {
host = hostParam;
initConnection();
// We need to close the event source to prevent an error being logged when refreshing the page
window.addEventListener("beforeunload", () => eventSource.close());
}
if (document.readyState === "interactive") {

View file

@ -1,47 +1,45 @@
import { Component } from "./component.js";
import { CURRENT_THRESHOLD } from "./config.js";
import { CURRENT_THRESHOLD, LOCALE } from "./config.js";
import { EventStore } from "./event-store.js";
import { IPageView } from "./page-view.js";
/**
* LiveCounter shows the amount of users on the site in the last 5 minutes.
*/
export class LiveCounter extends Component {
private sessions: Map<string, Date>;
private sessions: Map<string, number>;
private numberFormatter: Intl.NumberFormat;
constructor(el: HTMLElement) {
super(el);
constructor(el: HTMLElement, eventStore: EventStore) {
super(el, eventStore);
this.sessions = new Map();
setInterval(() => this.removeOld());
this.numberFormatter = new Intl.NumberFormat(LOCALE);
}
public handleView(view: IPageView) {
let isLatest = true;
public add(view: IPageView) {
let amount = 0;
if (this.sessions.has(view.session) && this.sessions.get(view.session)! >= view.at) {
isLatest = false;
}
if (isLatest) {
this.sessions.set(view.session, view.at);
if (this.sessions.has(view.session)) {
amount = this.sessions.get(view.session)!;
}
this.sessions.set(view.session, amount + 1);
this.render();
}
public expire(view: IPageView) {
if (this.sessions.has(view.session)) {
const amount = this.sessions.get(view.session)!;
if (amount <= 1) {
this.sessions.delete(view.session);
} else {
this.sessions.set(view.session, amount - 1);
}
}
}
public render() {
this.el.innerText = this.sessions.size.toString();
}
private removeOld() {
const now = Date.now();
for (const [session, at] of this.sessions.entries()) {
if (now - at.getTime() > CURRENT_THRESHOLD * 1000) {
this.sessions.delete(session);
}
}
this.render();
this.el.innerText = this.numberFormatter.format(this.sessions.size);
}
}

View file

@ -1,4 +1,5 @@
import { Component } from "./component.js";
import { EventStore } from "./event-store.js";
type CellData = string | number | HTMLElement;
type RowData = CellData[];
@ -8,8 +9,8 @@ export abstract class Table extends Component {
private table: HTMLTableElement;
private tbody: HTMLTableSectionElement;
constructor(el: HTMLElement, headings: string[], showFooter: boolean = true) {
super(el);
constructor(el: HTMLElement, eventStore: EventStore, headings: string[], showFooter: boolean = true) {
super(el, eventStore);
this.data = [];
this.table = document.createElement("table");

View file

@ -1,28 +1,32 @@
import { CURRENT_THRESHOLD } from "./config.js";
import { CURRENT_THRESHOLD, PATH_LIST_MAX_LEN } from "./config.js";
import { EventStore } from "./event-store.js";
import { IPageView } from "./page-view.js";
import { Table } from "./table.js";
const OLD_CLEAR_INTERVAL = 1;
const MAX_LEN = 8;
export class TopPaths extends Table {
private views: IPageView[];
private sessions: Map<string, IPageView>;
constructor(el: HTMLElement) {
super(el, ["#", "Path"], false);
constructor(el: HTMLElement, eventStore: EventStore) {
super(el, eventStore, ["#", "Path"], false);
this.views = [];
setInterval(() => this.clearOld(), OLD_CLEAR_INTERVAL * 1000);
this.sessions = new Map();
}
public handleView(view: IPageView) {
this.views.push(view);
public add(view: IPageView) {
this.sessions.set(view.session, view);
this.render();
}
public expire(view: IPageView) {
if (this.sessions.get(view.session) === view) {
this.sessions.delete(view.session);
}
this.render();
}
public render() {
const hitMap = this.views.reduce((acc, view) => {
const hitMap = [...this.sessions.values()].reduce((acc, view) => {
let hits = 1;
if (acc.has(view.path_noq)) {
hits += acc.get(view.path_noq)!;
@ -38,14 +42,8 @@ export class TopPaths extends Table {
});
let transposedList = hitList.map(([path, hits]) => ([hits, path]));
transposedList = transposedList.slice(0, MAX_LEN - 1);
transposedList = transposedList.slice(0, PATH_LIST_MAX_LEN - 1);
super.setData(transposedList);
super.render();
}
private clearOld() {
const now = Date.now();
this.views = this.views.filter((v) => (now - v.at.getTime()) <= CURRENT_THRESHOLD * 1000);
this.render();
}
}

View file

@ -1,5 +1,6 @@
import { Component } from "./component.js";
import { CURRENT_THRESHOLD } from "./config.js";
import { EventStore } from "./event-store.js";
import { IPageView } from "./page-view.js";
/** How many seconds a spot lives on the map. */
@ -11,8 +12,8 @@ const SPOT_UPDATE_INTERVAL = 1;
export class WorldMap extends Component {
private spots: Map<string, [HTMLDivElement, Date]>;
constructor(el: HTMLElement) {
super(el);
constructor(el: HTMLElement, eventStore: EventStore) {
super(el, eventStore);
this.spots = new Map();
@ -26,7 +27,7 @@ export class WorldMap extends Component {
setInterval(() => this.render(), SPOT_UPDATE_INTERVAL * 1000);
}
public handleView(view: IPageView) {
public add(view: IPageView) {
if (view.loc_lat === 0.0 || view.loc_lon === 0.0) {
return;
} else {
@ -50,6 +51,8 @@ export class WorldMap extends Component {
}
}
public expire() { /* Noop */ }
public render() {
const now = Date.now();
for (const [session, [el, at]] of this.spots.entries()) {