Compare commits

...

4 Commits

Author SHA1 Message Date
Harvey Tindall
788952d0a6 stats: add unfinished query builder
not gonna go any further. This is an unnecessary feature which could
just be a wiki page (and it will).
2025-11-23 16:41:10 +00:00
Harvey Tindall
faf782458f stats: start of some graph stuff
probably won't continue, this feature feels really unnecessary.
2025-11-22 13:40:29 +00:00
Harvey Tindall
863472657b Merge branch 'main' into stats 2025-11-22 12:53:22 +00:00
Harvey Tindall
d8cb4454b5 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?)
2025-05-28 12:48:07 +01:00
14 changed files with 621 additions and 67 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>
@@ -565,11 +566,11 @@
<div class="card ~neutral @low flex flex-col gap-2 flex-1">
<div class="flex flex-row gap-2">
<label class="w-1/2">
<input type="radio" name="duration" class="unfocused" id="radio-inv-duration" checked>
<input type="radio" name="radio-duration" class="unfocused" checked>
<span class="button ~neutral @high supra full-width center">{{ .strings.inviteDuration }}</span>
</label>
<label class="w-1/2">
<input type="radio" name="duration" class="unfocused" id="radio-user-expiry">
<input type="radio" name="radio-duration" class="unfocused">
<span class="button ~neutral @low supra full-width center">{{ .strings.userExpiry }}</span>
</label>
</div>
@@ -898,6 +899,55 @@
</div>
</div>
</div>
<div id="tab-statistics" class="flex flex-col gap-4 unfocused">
<div class="card @low dark:~d_neutral">
<div class="card @low dark:~d_neutral flex flex-col gap-2">
<div class="flex flex-row gap-2">
<label class="w-full">
<input type="radio" name="statistics-query-type" class="hidden" id="radio-statistics-accounts" checked>
<span class="button ~neutral w-full center @high">{{ .strings.accounts }}</span>
</label>
<label class="w-full">
<input type="radio" name="statistics-query-type" class="hidden" id="radio-statistics-activity">
<span class="button ~neutral w-full center @low">{{ .strings.activity }}</span>
</label>
</div>
<div id="statistics-query-tab-accounts">
<div class="flex flex-col align-middle gap-2">
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search mr-2" placeholder="{{ .strings.query }}">
<span class="button ~neutral @low center inside-input rounded-s-none statistics-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low statistics-query-execute" aria-label="{{ .strings.run }}"><i class="ri-refresh-line"></i></button>
</div>
<div class="flex flex-row gap-2 flex-wrap">
<div class="statistics-sort-by-field"></div>
<span class="flex flex-row gap-2 flex-wrap statistics-filter-area"></span>
</div>
<div class="card ~neutral @low statistics-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
</div>
<div id="statistics-query-tab-activity">
<div class="flex flex-col align-middle gap-2">
<div class="flex flex-row align-middle w-full gap-2">
<input type="search" class="field ~neutral @low input search mr-2" placeholder="{{ .strings.query }}">
<span class="button ~neutral @low center inside-input rounded-s-none statistics-search-clear" aria-label="{{ .strings.clearSearch }}" text="{{ .strings.clearSearch }}"><i class="ri-close-line"></i></span>
<button class="button ~info @low statistics-query-execute" aria-label="{{ .strings.run }}"><i class="ri-refresh-line"></i></button>
</div>
<div class="flex flex-row gap-2 flex-wrap">
<div class="statistics-sort-by-field"></div>
<span class="flex flex-row gap-2 flex-wrap statistics-filter-area"></span>
</div>
<div class="card ~neutral @low statistics-filter-list">
<p class="supra pb-2">{{ .strings.filters }}</p>
</div>
</div>
</div>
</div>
<div id="statistics-container"></div>
</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",
@@ -56,6 +57,8 @@
"unlink": "Unlink Account",
"deleted": "Deleted",
"disabled": "Disabled",
"query": "Query",
"run": "Run",
"sendPWR": "Send Password Reset",
"noResultsFound": "No Results Found",
"noResultsFoundLocally": "Only loaded records were searched. You can load more, or perform the search over all records on the server.",

36
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"any-date-parser": "^1.5.4",
"browserslist": "^4.21.7",
"cheerio": "^1.0.0-rc.12",
"echarts": "^6.0.0",
"fs-cheerio": "^3.0.0",
"inline-source": "^8.0.2",
"jsdom": "^22.1.0",
@@ -1125,6 +1126,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -1927,6 +1929,22 @@
"safer-buffer": "^2.1.0"
}
},
"node_modules/echarts": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz",
"integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.0.0"
}
},
"node_modules/echarts/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/editorconfig": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
@@ -5180,6 +5198,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -5536,6 +5555,7 @@
"integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==",
"deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"aws-sign2": "~0.7.0",
"aws4": "^1.8.0",
@@ -6526,6 +6546,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -8012,6 +8033,21 @@
"engines": {
"node": ">=8"
}
},
"node_modules/zrender": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
"integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
},
"node_modules/zrender/node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
}
}
}

View File

@@ -24,6 +24,7 @@
"any-date-parser": "^1.5.4",
"browserslist": "^4.21.7",
"cheerio": "^1.0.0-rc.12",
"echarts": "^6.0.0",
"fs-cheerio": "^3.0.0",
"inline-source": "^8.0.2",
"jsdom": "^22.1.0",

View File

@@ -145,6 +145,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

@@ -55,7 +55,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",
@@ -995,7 +995,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

@@ -1,6 +1,7 @@
import { _get, _post, _delete, toClipboard, toggleLoader, toDateString } from "../modules/common.js";
import { DiscordUser, newDiscordSearch } from "../modules/discord.js";
import { reloadProfileNames } from "../modules/profiles.js";
import { SimpleRadioTabs } from "./ui.js";
declare var window: GlobalWindow;
@@ -604,10 +605,10 @@ export class createInvite {
private _userHours = document.getElementById("user-hours") as HTMLSelectElement;
private _userMinutes = document.getElementById("user-minutes") as HTMLSelectElement;
private _invDurationButton = document.getElementById('radio-inv-duration') as HTMLInputElement;
private _userExpiryButton = document.getElementById('radio-user-expiry') as HTMLInputElement;
private _invDuration = document.getElementById('inv-duration');
private _userExpiry = document.getElementById('user-expiry');
private _durationTabs = new SimpleRadioTabs(
[document.getElementById("inv-duration"), document.getElementById("user-expiry")],
"radio-duration"
);
private _sendToDiscord: (passData: string) => void;
@@ -849,30 +850,8 @@ export class createInvite {
this.uses = 1;
this.label = "";
const checkDuration = () => {
const invSpan = this._invDurationButton.nextElementSibling as HTMLSpanElement;
const userSpan = this._userExpiryButton.nextElementSibling as HTMLSpanElement;
if (this._invDurationButton.checked) {
this._invDuration.classList.remove("unfocused");
this._userExpiry.classList.add("unfocused");
invSpan.classList.add("@high");
invSpan.classList.remove("@low");
userSpan.classList.add("@low");
userSpan.classList.remove("@high");
} else if (this._userExpiryButton.checked) {
this._userExpiry.classList.remove("unfocused");
this._invDuration.classList.add("unfocused");
invSpan.classList.add("@low");
invSpan.classList.remove("@high");
userSpan.classList.add("@high");
userSpan.classList.remove("@low");
}
};
this._userExpiryButton.checked = false;
this._invDurationButton.checked = true;
this._userExpiryButton.onchange = checkDuration;
this._invDurationButton.onchange = checkDuration;
// Select the first tab by default (inv duration)
this._durationTabs.select(0);
this._days.onchange = this._checkDurationValidity;
this._months.onchange = this._checkDurationValidity;

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

@@ -33,17 +33,21 @@ export interface QueryType {
}
export interface SearchConfiguration {
filterArea: HTMLElement;
sortingByButton?: HTMLButtonElement;
searchOptionsHeader: HTMLElement;
notFoundPanel: HTMLElement;
notFoundLocallyText: HTMLElement;
notFoundCallback?: (notFound: boolean) => void;
filterList: HTMLElement;
clearSearchButtonSelector: string;
serverSearchButtonSelector: string;
search: HTMLInputElement;
queries: { [field: string]: QueryType };
filterArea: HTMLElement;
filterList: HTMLElement;
search: HTMLInputElement;
clearSearchButtonSelector?: string;
serverSearchButtonSelector?: string;
// Optionally supply a button for a single type of sorting.
sortingByButton?: HTMLButtonElement;
searchOptionsHeader?: HTMLElement;
notFoundPanel?: HTMLElement;
notFoundLocallyText?: HTMLElement;
notFoundCallback?: (notFound: boolean) => void;
// Function for showing/hiding search items.
setVisibility: (items: string[], visible: boolean, appendedItems: boolean) => void;
onSearchCallback: (newItems: boolean, loadAll: boolean, callback?: (resp: paginatedDTO) => void) => void;
searchServer: (params: PaginatedReqDTO, newSearch: boolean) => void;
@@ -51,11 +55,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 +336,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 +349,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 +359,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 +372,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 +387,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 +402,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 +497,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);
@@ -498,9 +525,9 @@ export class Search {
if (this._c.sortingByButton) sortingBy = !(this._c.sortingByButton.classList.contains("hidden"));
const hasFilters = this._c.filterArea.textContent != "";
if (sortingBy || hasFilters) {
this._c.searchOptionsHeader.classList.remove("hidden");
this._c.searchOptionsHeader?.classList.remove("hidden");
} else {
this._c.searchOptionsHeader.classList.add("hidden");
this._c.searchOptionsHeader?.classList.add("hidden");
}
}
@@ -553,14 +580,14 @@ export class Search {
setNotFoundPanelVisibility = (visible: boolean) => {
if (this._inServerSearch || !this.inSearch) {
this._c.notFoundLocallyText.classList.add("unfocused");
this._c.notFoundLocallyText?.classList.add("unfocused");
} else if (this.inSearch) {
this._c.notFoundLocallyText.classList.remove("unfocused");
this._c.notFoundLocallyText?.classList.remove("unfocused");
}
if (visible) {
this._c.notFoundPanel.classList.remove("unfocused");
this._c.notFoundPanel?.classList.remove("unfocused");
} else {
this._c.notFoundPanel.classList.add("unfocused");
this._c.notFoundPanel?.classList.add("unfocused");
}
}

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

@@ -0,0 +1,361 @@
import { ActivityQueries } from "./activity";
import { HTTPMethod, _http } from "./common";
import { PageCountDTO } from "./list";
import { Search, ServerFilterReqDTO } from "./search";
import * as echarts from "echarts/core";
import { BarChart } from "echarts/charts";
import {
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent
} from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
import { AccountsQueries } from "./accounts";
import { SimpleRadioTabs } from "./ui";
echarts.use([
BarChart,
TitleComponent,
TooltipComponent,
GridComponent,
DatasetComponent,
TransformComponent,
CanvasRenderer
]);
// 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;
}
}
export class GraphCard 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;
this._chartOpts.title = { text: v };
}
protected _unit: Unit | null = null;
get unit(): Unit { return this._unit; }
set unit(v: Unit) {
this._unit = v;
this._chartOpts.series[0].name = v;
}
protected _range: string[];
get range(): string[] { return this._range; }
set range(v: string[]) {
this._range = v;
this._chartOpts.xAxis = { data: v };
}
protected _value: number[];
protected _graphEl: HTMLElement;
protected _chart: echarts.ECharts;
protected _chartOpts: echarts.EChartsCoreOption;
get value(): number[] { return this._value; }
set value(v: number[]) {
this._value = v;
this._chartOpts.series[0].data = v;
}
protected redraw() {
this._chart.setOption(this._chartOpts);
}
protected _url: string;
protected _method: HTTPMethod;
protected _container: HTMLElement;
// generates params for each req.
params: () => ServerFilterReqDTO[];
// returns value from HTTP response.
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 = [];
} 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 graph-card-name"></p>
<div class="text-2xl font-bold graph-card-graph"></div>
`;
this._nameEl = this._container.querySelector(".graph-card-name");
this._graphEl = this._container.querySelector(".graph-card-graph");
this._chart = echarts.init(this._graphEl);
this._chartOpts = {
title: {
text: "?"
},
tooltip: {},
xAxis: {
data: []
},
yAxis: {},
series: [
{
name: "",
type: "bar",
data: []
}
]
};
this.name = name;
if (unit) this.unit = unit;
this.range = [];
this.value = [];
this.eventName = this.name;
document.addEventListener(EventNamespace+this.eventName, this.onReloadRequest);
document.addEventListener(GlobalReload, this.onReloadRequest);
}
}
// 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 = [];
const queryTypeTabs = new SimpleRadioTabs(
[document.getElementById("statistics-query-tab-accounts"), document.getElementById("statistics-query-tab-activity")],
"statistics-query-type"
);
queryTypeTabs.select(0);
const areas = [document.getElementById("statistics-query-tab-accounts"), document.getElementById("statistics-query-tab-activity")];
const querySets = [AccountsQueries(), ActivityQueries()];
for (let i = 0; i < 2; i++) {
let search = new Search({
queries: querySets[i],
filterArea: areas[i].getElementsByClassName("statistics-filter-area")[0] as HTMLElement,
filterList: areas[i].getElementsByClassName("statistics-filter-list")[0] as HTMLElement,
search: areas[i].getElementsByClassName("search")[0] as HTMLInputElement,
setVisibility: () => {},
onSearchCallback: () => {},
searchServer: () => {},
clearServerSearch: () => {},
});
search.generateFilterList();
}
}
}

View File

@@ -89,3 +89,63 @@ 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;
};*/
// Simple radio tabs, when each "button" is a label containing an input[type="radio"] and some element[class="button"].
export class SimpleRadioTabs {
tabs: Array<HTMLElement>;
radios: Array<HTMLInputElement>;
// Pass nothing, or a list of the tab container elements and either the "name" field used on the radios, of a list of them.
constructor(tabs?: Array<HTMLElement>, radios?: Array<HTMLInputElement>|string) {
this.tabs = tabs || new Array<HTMLElement>();
if (radios) {
if (typeof radios === "string") {
this.radios = Array.from(document.querySelectorAll(`input[name=${radios}]`)) as Array<HTMLInputElement>;
} else {
this.radios = radios as Array<HTMLInputElement>;
}
this.radios.forEach((radio) => { radio.onchange = this.onChange });
}
}
onChange = () => {
for (let i = 0; i < this.radios.length; i++) {
const buttonEl = this.radios[i].nextElementSibling;
if (this.radios[i].checked) {
buttonEl.classList.add("@high");
buttonEl.classList.remove("@low");
this.tabs[i].classList.remove("unfocused");
} else {
buttonEl.classList.add("@low");
buttonEl.classList.remove("@high");
this.tabs[i].classList.add("unfocused");
}
}
};
select(i: number) {
for (let j = 0; j < this.radios.length; j++) {
this.radios[j].checked = i == j;
}
this.onChange();
}
push(tab: HTMLElement, radio: HTMLInputElement) {
this.tabs.push(tab);
radio.onchange = this.onChange;
this.radios.push(radio);
}
}