Reimplement frontend components with EventStore that manages event lifespan
This commit is contained in:
parent
99a69380db
commit
023c536ca1
9 changed files with 159 additions and 72 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
|
48
frontend/src/event-store.ts
Normal file
48
frontend/src/event-store.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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") {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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");
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()) {
|
||||
|
|
Loading…
Reference in a new issue