stats: beginnings

made a StatCard interface, a NumberCard abstract class, and
(Filtered)CountCard to extend it. Managed by StatsPanel, which has a
static method DefaultLayout(), returning a list of preconfigured default
cards (right now no. of users, invites and users created thru jfa-go). I
intend to allow the user to customize these, maybe. Currently uses hardcoded strings for
words, FIXME! Also ugly. The default layout currently shows in a new "Stats" tab.
Also FIXME: Display in a nice grid with the same stuff used in the
userpage, once we have cards of different sizes (graphs, maybe?)
This commit is contained in:
Harvey Tindall
2025-05-28 12:46:23 +01:00
parent 4f02c44e39
commit d8cb4454b5
11 changed files with 297 additions and 21 deletions

View File

@@ -551,6 +551,7 @@
<span id="button-tab-invites" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.invites }}</span>
<span id="button-tab-accounts" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.accounts }}</span>
<span id="button-tab-activity" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.activity }}</span>
<span id="button-tab-statistics" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.statistics }}</span>
<span id="button-tab-settings" class="text-3xl button portal ~neutral dark:~d_neutral @low px-5">{{ .strings.settings }}</span>
</div>
</header>
@@ -897,6 +898,10 @@
</div>
</div>
</div>
<div id="tab-statistics" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral mb-4" id="statistics-container">
</div>
</div>
<div id="tab-settings" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral settings overflow flex flex-col gap-2">
<div class="flex flex-col md:flex-row align-middle gap-2">

View File

@@ -8,6 +8,7 @@
"accounts": "Accounts",
"activity": "Activity",
"settings": "Settings",
"statistics": "Stats",
"inviteMonths": "Months",
"inviteDays": "Days",
"inviteHours": "Hours",

View File

@@ -144,6 +144,7 @@ func (app *appContext) loadRoutes(router *gin.Engine) {
}
router.GET(p+PAGES.Admin+"/settings", app.AdminPage)
router.GET(p+PAGES.Admin+"/activity", app.AdminPage)
router.GET(p+PAGES.Admin+"/statistics", app.AdminPage)
router.GET(p+PAGES.Admin+"/accounts/user/:userID", app.AdminPage)
router.GET(p+PAGES.Admin+"/invites/:code", app.AdminPage)
router.GET(p+"/lang/:page/:file", app.ServeLang)

View File

@@ -10,6 +10,7 @@ import { ProfileEditor, reloadProfileNames } from "./modules/profiles.js";
import { _get, _post, notificationBox, whichAnimationEvent, bindManualDropdowns } from "./modules/common.js";
import { Updater } from "./modules/update.js";
import { Login } from "./modules/login.js";
import { StatsPanel } from "./modules/stats.js";
declare var window: GlobalWindow;
@@ -107,6 +108,11 @@ var settings = new settingsList();
var profiles = new ProfileEditor();
var stats = new StatsPanel(document.getElementById("statistics-container"));
stats.addCards(...StatsPanel.DefaultLayout());
// FIXME: Remove!
(window as any).stats = stats;
window.notifications = new notificationBox(document.getElementById('notification-box') as HTMLDivElement, 5);
/*const modifySettingsSource = function () {
@@ -162,6 +168,15 @@ const tabs: { id: string, url: string, reloader: () => void, unloader?: () => vo
},
unloader: activity.unbindPageEvents
},
{
id: "statistics",
url: "statistics",
reloader: () => {
stats.reload()
stats.bindPageEvents();
},
unloader: stats.unbindPageEvents
},
{
id: "settings",
url: "settings",
@@ -195,7 +210,11 @@ login.onLogin = () => {
window.updater = new Updater();
// FIXME: Decide whether to autoload activity or not
reloadProfileNames();
setInterval(() => { window.invites.reload(); accounts.reloadIfNotInScroll(); }, 30*1000);
setInterval(() => {
window.invites.reload();
accounts.reloadIfNotInScroll();
stats.reload();
}, 30*1000);
// Triggers pre and post funcs, even though we're already on that page
window.tabs.switch(window.tabs.current);
}

View File

@@ -48,7 +48,7 @@ interface announcementTemplate {
var addDiscord: (passData: string) => void;
const queries = (): { [field: string]: QueryType } => { return {
export const AccountsQueries = (): { [field: string]: QueryType } => { return {
"id": {
// We don't use a translation here to circumvent the name substitution feature.
name: "Jellyfin/Emby ID",
@@ -982,7 +982,7 @@ export class accountsList extends PaginatedList {
notFoundLocallyText: document.getElementById("accounts-no-local-results"),
filterList: document.getElementById("accounts-filter-list"),
search: this._c.searchBox,
queries: queries(),
queries: AccountsQueries(),
setVisibility: null,
clearSearchButtonSelector: ".accounts-search-clear",
serverSearchButtonSelector: ".accounts-search-server",

View File

@@ -37,7 +37,7 @@ var activityTypeMoods = {
};
// window.lang doesn't exist at page load, so I made this a function that's invoked by activityList.
const queries = (): { [field: string]: QueryType } => { return {
export const ActivityQueries = (): { [field: string]: QueryType } => { return {
"id": {
name: window.lang.strings("activityID"),
getter: "id",
@@ -546,7 +546,7 @@ export class activityList extends PaginatedList {
search: this._c.searchBox,
clearSearchButtonSelector: ".activity-search-clear",
serverSearchButtonSelector: ".activity-search-server",
queries: queries(),
queries: ActivityQueries(),
setVisibility: null,
filterList: document.getElementById("activity-filter-list"),
// notFoundCallback: this._notFoundCallback,

View File

@@ -125,6 +125,19 @@ export function _delete(url: string, data: Object, onreadystatechange: (req: XML
req.send(JSON.stringify(data));
}
export type HTTPMethod = "GET" | "POST" | "DELETE";
export const _http = (method: HTTPMethod, url: string, data: Object, onreadystatechange: (req: XMLHttpRequest) => void) => {
switch (method) {
case "GET":
return _get(url, data, onreadystatechange);
case "POST":
return _post(url, data, onreadystatechange, true);
case "DELETE":
return _delete(url, data, onreadystatechange);
}
}
export function toClipboard (str: string) {
const el = document.createElement('textarea') as HTMLTextAreaElement;
el.value = str;

View File

@@ -8,6 +8,10 @@ export interface ListItem {
asElement: () => HTMLElement;
};
export interface PageCountDTO {
count: number;
}
export class RecordCounter {
private _container: HTMLElement;
private _totalRecords: HTMLElement;
@@ -46,7 +50,7 @@ export class RecordCounter {
getTotal(endpoint: string) {
_get(endpoint, null, (req: XMLHttpRequest) => {
if (req.readyState != 4 || req.status != 200) return;
this.total = req.response["count"] as number;
this.total = (req.response as PageCountDTO).count;
});
}

View File

@@ -51,11 +51,13 @@ export interface SearchConfiguration {
loadMore?: () => void;
}
export interface ServerSearchReqDTO extends PaginatedReqDTO {
export interface ServerFilterReqDTO {
searchTerms: string[];
queries: QueryDTO[];
}
export interface ServerSearchReqDTO extends PaginatedReqDTO, ServerFilterReqDTO {};
export interface QueryDTO {
class: "bool" | "string" | "date";
// QueryType.getter
@@ -330,7 +332,7 @@ export class Search {
return words;
}
parseTokens = (tokens: string[]): [string[], Query[]] => {
static parseTokens = (tokens: string[], queryTypes: { [field: string]: QueryType }, searchBox?: HTMLInputElement): [string[], Query[]] => {
let queries: Query[] = [];
let searchTerms: string[] = [];
@@ -343,9 +345,9 @@ export class Search {
// 2. A filter query of some sort.
const split = [word.substring(0, word.indexOf(":")), word.substring(word.indexOf(":")+1)];
if (!(split[0] in this._c.queries)) continue;
if (!(split[0] in queryTypes)) continue;
const queryFormat = this._c.queries[split[0]];
const queryFormat = queryTypes[split[0]];
let q: Query | null = null;
@@ -353,11 +355,11 @@ export class Search {
let [boolState, isBool] = BoolQuery.paramsFromString(split[1]);
if (isBool) {
q = new BoolQuery(queryFormat, boolState);
q.onclick = () => {
if (searchBox) q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
this._c.search.value = this._c.search.value.replace(split[0] + ":" + quote + split[1] + quote, "");
searchBox.value = searchBox.value.replace(split[0] + ":" + quote + split[1] + quote, "");
}
this._c.search.oninput((null as Event));
searchBox.oninput((null as Event));
};
queries.push(q);
continue;
@@ -366,12 +368,12 @@ export class Search {
if (queryFormat.string) {
q = new StringQuery(queryFormat, split[1]);
q.onclick = () => {
if (searchBox) q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
this._c.search.value = this._c.search.value.replace(regex, "");
searchBox.value = searchBox.value.replace(regex, "");
}
this._c.search.oninput((null as Event));
searchBox.oninput((null as Event));
}
queries.push(q);
continue;
@@ -381,13 +383,13 @@ export class Search {
if (!isDate) continue;
q = new DateQuery(queryFormat, op, parsedDate);
q.onclick = () => {
if (searchBox) q.onclick = () => {
for (let quote of [`"`, `'`, ``]) {
let regex = new RegExp(split[0] + ":" + quote + split[1] + quote, "ig");
this._c.search.value = this._c.search.value.replace(regex, "");
searchBox.value = searchBox.value.replace(regex, "");
}
this._c.search.oninput((null as Event));
searchBox.oninput((null as Event));
}
queries.push(q);
continue;
@@ -396,7 +398,24 @@ export class Search {
}
return [searchTerms, queries];
}
// Convenience method for others to use
static DTOFromString = (query: string, queryTypes: { [field: string]: QueryType }): ServerFilterReqDTO => {
const [searchTerms, queries] = Search.parseTokens(
Search.tokenizeSearch(query),
queryTypes
);
let req = {
searchTerms: searchTerms,
queries: [],
}
for (const q of queries) {
const dto = q.asDTO();
if (dto !== null) req.queries.push(dto);
}
return req;
}
// Returns a list of identifiers (used as keys in items, values in ordering).
searchParsed = (searchTerms: string[], queries: Query[]): string[] => {
let result: string[] = [...this._ordering];
@@ -474,7 +493,11 @@ export class Search {
let timer = this.timeSearches ? performance.now() : null;
this._c.filterArea.textContent = "";
const [searchTerms, queries] = this.parseTokens(Search.tokenizeSearch(query));
const [searchTerms, queries] = Search.parseTokens(
Search.tokenizeSearch(query),
this._c.queries,
this._c.search
);
let result = this.searchParsed(searchTerms, queries);

199
ts/modules/stats.ts Normal file
View File

@@ -0,0 +1,199 @@
import { ActivityQueries } from "./activity";
import { HTTPMethod, _http } from "./common";
import { PageCountDTO } from "./list";
import { Search, ServerFilterReqDTO } from "./search";
// FIXME: These should be language keys, not literals!
export type Unit = string | [string, string]; // string or [singular, plural]
export const UnitUsers: Unit = ["User", "Users"];
export const UnitRecords: Unit = ["Record", "Records"];
export const UnitOccurences: Unit = ["Occurrence", "Occurrences"];
export const UnitInvites: Unit = ["Invite", "Invites"];
export const EventNamespace = "stats-";
export const GlobalReload = EventNamespace + "reload-all";
export interface StatCard {
name: string;
value: any;
reload: () => void;
asElement: () => HTMLElement;
// Event the card will listen to for reloads
eventName: string;
};
export abstract class NumberCard implements StatCard {
protected _name: string;
protected _nameEl: HTMLElement;
get name(): string { return this._name; }
set name(v: string) {
this._name = v;
this._nameEl.textContent = v;
}
protected _unit: Unit | null = null;
protected _unitEl: HTMLElement;
get unit(): Unit { return this._unit; }
set unit(v: Unit) {
this._unit = v;
// re-load value to set units correctly
this.value = this.value;
}
protected _value: number;
protected _valueEl: HTMLElement;
get value(): number { return this._value; }
set value(v: number) {
this._value = v;
if (v == -1) {
this._valueEl.textContent = "";
} else {
this._valueEl.textContent = ""+this._value;
}
if (this._unit === null) return;
if (typeof this._unit === "string") this._unitEl.textContent = this._unit;
else this._unitEl.textContent = (v == 1) ? this._unit[0] : this._unit[1];
}
protected _url: string;
protected _method: HTTPMethod;
protected _container: HTMLElement;
// generates data to be passed in the HTTP request.
abstract params: () => any;
// returns value from HTTP response.
abstract handler: (req: XMLHttpRequest) => number;
// Name of a custom event that will trigger this specific card to reload.
readonly eventName: string;
public reload = () => {
let params = this.params();
_http(this._method, this._url, params, (req: XMLHttpRequest) => {
if (req.readyState != 4) return;
if (req.status != 200) {
this.value = -1;
} else {
this.value = this.handler(req);
}
});
}
protected onReloadRequest = () => this.reload();
asElement = () => { return this._container; }
constructor(name: string, url: string, method: HTTPMethod, unit?: Unit) {
this._url = url;
this._method = method;
this._container = document.createElement("div");
this._container.classList.add("card", "@low", "dark:~d_neutral", "flex", "flex-col", "gap-4");
this._container.innerHTML = `
<p class="text-xl italic number-card-name"></p>
<p class="text-2xl font-bold"><span class="number-card-value"></span> <span class="number-card-unit"></span></p>
`;
this._nameEl = this._container.querySelector(".number-card-name");
this._valueEl = this._container.querySelector(".number-card-value");
this._unitEl = this._container.querySelector(".number-card-unit");
this.name = name;
if (unit) this.unit = unit;
this.value = -1;
this.eventName = this.name;
document.addEventListener(EventNamespace+this.eventName, this.onReloadRequest);
document.addEventListener(GlobalReload, this.onReloadRequest);
}
}
export class CountCard extends NumberCard {
params = () => {};
handler = (req: XMLHttpRequest): number => {
return (req.response as PageCountDTO).count;
};
constructor(name: string, url: string, unit?: Unit) {
super(name, url, "GET", unit);
}
}
export class FilteredCountCard extends CountCard {
private _params: ServerFilterReqDTO;
params = (): ServerFilterReqDTO => { return this._params };
constructor(name: string, url: string, params: ServerFilterReqDTO, unit?: Unit) {
super(name, url, unit);
this._method = "POST";
this._params = params;
}
}
// FIXME: Make a page and load some of these!
export class StatsPanel {
private _container: HTMLElement;
private _cards: Map<string, StatCard>;
private _order: string[];
private _loaded = false;
public static DefaultLayout(): StatCard[] {
return [
new CountCard("Number of users", "/users/count", UnitUsers),
new CountCard("Number of invites", "/invites/count", UnitInvites),
new FilteredCountCard(
"Users created through jfa-go",
"/activity/count",
Search.DTOFromString("account-creation:true", ActivityQueries()),
UnitUsers
)
];
}
addCards = (...cards: StatCard[]) => {
for (const card of cards) {
this._cards.set(card.name, card);
this._order.push(card.name);
}
}
deleteCards = (...cards: StatCard[]) => {
for (const card of cards) {
this._cards.delete(card.name);
let idx = this._order.indexOf(card.name);
if (idx != -1) this._order.splice(idx, 1);
}
};
reflow = () => {
const temp = document.createDocumentFragment();
for (const name of this._order) {
temp.appendChild(this._cards.get(name).asElement());
}
this._container.replaceChildren(temp);
}
reload = () => {
const hasLoaded = this._loaded;
this._loaded = true;
document.dispatchEvent(new CustomEvent(GlobalReload));
if (!hasLoaded) {
this.reflow();
}
}
bindPageEvents = () => {};
unbindPageEvents = () => {};
constructor(container: HTMLElement) {
this._container = container;
this._container.classList.add("flex", "flex-row", "gap-2", "flex-wrap");
this._cards = new Map<string, StatCard>();
this._order = [];
}
}

View File

@@ -89,3 +89,14 @@ export class HiddenInputField {
toggle(noSave: boolean = false) { this.setEditing(!this.editing, false, noSave); }
}
/*
* class GenericNumber<NumType> {
zeroValue: NumType;
add: (x: NumType, y: NumType) => NumType;
}
let myGenericNumber = new GenericNumber<number>();
myGenericNumber.zeroValue = 0;
myGenericNumber.add = function (x, y) {
return x + y;
};*/