mirror of
https://github.com/hrfee/jfa-go.git
synced 2026-01-19 17:17:39 +01:00
Compare commits
4 Commits
ci-streaml
...
stats
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
788952d0a6 | ||
|
|
faf782458f | ||
|
|
863472657b | ||
|
|
d8cb4454b5 |
@@ -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">
|
||||
|
||||
@@ -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
36
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
361
ts/modules/stats.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user