mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-18 16:47:42 +01:00
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:
@@ -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">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"accounts": "Accounts",
|
||||
"activity": "Activity",
|
||||
"settings": "Settings",
|
||||
"statistics": "Stats",
|
||||
"inviteMonths": "Months",
|
||||
"inviteDays": "Days",
|
||||
"inviteHours": "Hours",
|
||||
|
||||
@@ -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)
|
||||
|
||||
21
ts/admin.ts
21
ts/admin.ts
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
199
ts/modules/stats.ts
Normal 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 = [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};*/
|
||||
|
||||
Reference in New Issue
Block a user