Archive commit

This commit is contained in:
Erik McClure
2019-08-28 21:15:02 -07:00
commit 735845746e
1435 changed files with 140724 additions and 0 deletions
@@ -0,0 +1,447 @@
div(*ngIf="account")
.row
.col-md-12
h2
| {{account.name}}
small.text-muted.ml-1
em {{account.name | translit}}
.row
.col-md-4
.mb-2
account-info([account]="account" popoverPlacement="right")
.mb-2
code.text-muted {{account._id}}
.mb-2
div
span.text-muted created:
from-now.ml-1([time]="account.createdAt")
span.text-muted.ml-1 ({{account.createdAt | date}})
div
span.text-muted last visit:
from-now.ml-1([time]="account.lastVisit")
span.text-muted.ml-1 ({{account.lastVisit | date}})
div
span.text-muted last browser:
ua-info.ml-1([userAgent]="accountObject?.lastUserAgent")
div
span.text-muted age:
span([class.text-strike]="account.birthyear")
span.ml-1 {{age}}
span.text-muted.ml-1 ({{account.birthdate | date:'yyyy-MM-dd'}})
span(*ngIf="account.birthyear")
span.ml-1 {{forceAge}}
span.text-muted.ml-1 ({{account.birthyear}})
.d-inline-block.dropdown(dropdown)
a.icon-button.ml-1(dropdownToggle)
fa-icon([icon]="cogIcon")
.dropdown-menu(*dropdownMenu)
button.dropdown-item((click)="setAge(-1)")
| unset
button.dropdown-item(*ngFor="let age of ages" (click)="setAge(age)")
| set to #[b {{age}}]yo
.d-flex.justify-content-between.align-items-start
h5 Counters
div
button.btn.btn-xs.btn-default.ml-1((click)="refreshDetails()" title="Refresh account details")
fa-icon([icon]="syncIcon" [fixedWidth]="true")
.mb-2
div(*ngFor="let counter of counters")
span.text-muted {{counter.key}}:
span.ml-1 {{counter.value}}
.d-flex.justify-content-between.align-items-start
h5 Status
div
button.btn.btn-xs.btn-default.ml-1((click)="clearSessions()" title="Sign out user" [disabled]="clearingSessions")
fa-icon([icon]="clearingSessions ? spinnerIcon : signOutIcon" [fixedWidth]="true" [spin]="clearingSessions")
button.btn.btn-xs.btn-default.ml-1((click)="showAccountData()" title="Fetch account data (in console)")
fa-icon([icon]="consoleIcon" [fixedWidth]="true")
button.btn.btn-xs.btn-default.ml-1((click)="accountStatus.refresh()" title="Refresh status")
fa-icon([icon]="syncIcon" [fixedWidth]="true")
.mb-2
account-status(#accountStatus [account]="account" [verbose]="true")
h5 Roles
.mb-2
.btn-group.btn-group-sm
button.btn(*ngFor="let r of roles" [btnHighlight]="hasRole(r)" (click)="toggleRole(r)" [disabled]="!canToggleRole(r)")
| {{r}}
button.btn.btn-sm.btn-danger.ml-1((click)="remove()" [disabled]="!canRemove" title="Delete account")
fa-icon([icon]="trashIcon" [fixedWidth]="true")
.d-flex.justify-content-between.align-items-start
h5
| Notes
a.text-muted.ml-1([href]="translateUrl(account.note)" target="_blank" title="Translate note")
fa-icon([icon]="langIcon")
.text-muted
from-now([time]="account.noteUpdated")
.well.pre-line.text-truncate
| {{account.note}}
.d-flex.justify-content-between.align-items-start
h5 Merges #[span.text-muted ({{merges?.length || 0}})]
div
button.btn.btn-xs.btn-default.ml-1((click)="refreshDetails()" title="Refresh account details")
fa-icon([icon]="syncIcon" [fixedWidth]="true")
table.table.table-sm(*ngIf="merges?.length")
thead
tr
th id
th name
th date
tbody
tr(*ngFor="let m of merges" [class.text-slashed]="m.split")
td
ng-template(#mergeTooltip)
div {{getMergeTooltip(m)}}
div([tooltip]="mergeTooltip" placement="right" containerClass="tooltip-merge")
| {{m.id}}
td {{m.name}}
td
.d-flex
.flex-grow-1
time-field([time]="m.date")
.dropdown.float-right(dropdown)
button.btn.btn-xs.btn-default.dropdown-toggle(
dropdownToggle [tooltip]="(m.reason || '[null reason]') + (m.split ? ' (split)' : '')")
fa-icon([icon]="cogIcon" [fixedWidth]="true")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
button.dropdown-item((click)="printMerge(m)")
| Print merge info
button.dropdown-item((click)="printMerge2(m)")
| Print usable merge info
button.dropdown-item((click)="showMergeInNewTab(m)")
| Show merge info
button.dropdown-item((click)="showMergeInNewTab2(m)")
| Show usable merge info
.dropdown-divider
button.dropdown-item((click)="unmerge(m._id, m.data.merge, m.data.account)")
| Split off #[b merged]
button.dropdown-item((click)="unmerge(m._id, m.data.account, m.data.merge)")
| Split off #[b account]
h5 Support #[span.text-muted ({{support?.length || 0}})]
table.table.table-sm(*ngIf="support?.length")
thead
tr
th message
th date
tbody
tr(*ngFor="let l of support")
td
fa-icon.mr-1([icon]="l.icon" [ngClass]="l.class")
| {{l.message}}
td
time-field.mr-2([time]="l.date")
span.text-muted ({{l.date | date:'MMM d'}})
h5 Ban log #[span.text-muted ({{banLog?.length || 0}})]
table.table.table-sm(*ngIf="banLog?.length")
thead
tr
th message
th date
tbody
tr(*ngFor="let l of banLog")
td {{l.message}}
td: time-field([time]="l.date")
.col-md-5
//- auths
.d-flex.justify-content-between.align-items-start
h5 Auths #[span.text-muted ({{auths.length}})]
.dropdown(dropdown)
button.btn.btn-xs.btn-default.dropdown-toggle(dropdownToggle)
fa-icon([icon]="cogIcon" [fixedWidth]="true")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
a.dropdown-item((click)="fetchAuths()")
| Fetch auths data
a.dropdown-item((click)="printAuthList()")
| Print auths list
a.dropdown-item((click)="printAuthData()")
| Print auths data
.mb-2
auth-info-edit(*ngFor="let a of auths | slice:0:authLimit" [authId]="a" [duplicates]="duplicates")
a.text-muted((click)="authLimit = 99999" *ngIf="authLimit < auths.length")
| more ...
.well.mt-2.mb-1.py-2(*ngIf="authData" style="font-size: 12px;")
button.close((click)="clearAuthData()" style="color: #ddd; margin-right: -5px; margin-top: -5px;") &times;
button.close((click)="copyAuthData()" style="color: #ddd; margin-right: 6px; margin-top: -5px;")
fa-icon([icon]="copyIcon" size="xs")
.text-muted Auths for {{authDataAccount}}
.pre-line {{authData}}
//- origins
.d-flex.justify-content-between
h5 Origins #[span.text-muted ({{origins.length}})]
div
button.btn.btn-xs.btn-default((click)="clearOrigins(true, true)" title="Remove all origins older than 14 days used by only one account")
fa-icon.mr-1([icon]="trashIcon")
| old single
button.btn.btn-xs.btn-default.ml-1((click)="clearOrigins(true, false)" title="Remove all origins older than 14 days")
fa-icon.mr-1([icon]="trashIcon")
| old
button.btn.btn-xs.btn-default.ml-1((click)="clearOrigins(false, true)" title="Remove all origins used by only one account")
fa-icon.mr-1([icon]="trashIcon")
| single
button.btn.btn-xs.btn-default.ml-1((click)="clearOrigins(false, false)" title="Remove all origins")
fa-icon.mr-1([icon]="trashIcon")
| all
.dropdown.d-inline-block.ml-1(dropdown)
button.btn.btn-xs.btn-default.dropdown-toggle(dropdownToggle title="Remove all origins in region")
fa-icon.mr-1([icon]="trashIcon")
| region
.dropdown-menu(*dropdownMenu)
a.dropdown-item(*ngFor="let region of originRegions" (click)="clearOriginsInRegion(region)")
| {{region}}
.mb-2
.d-flex.align-items-center.mb-1(*ngFor="let o of origins")
origin-info-remote.flex-grow-1([originIP]="o.ip")
small.text-muted
from-now.mr-1([time]="o.last")
button.btn.btn-xs.btn-default((click)="removeOrigin(o.ip)" title="Remove origin")
fa-icon([icon]="trashIcon")
hr
//- duplicates
.d-flex.justify-content-between.align-items-start
h5
| Duplicates #[span.text-muted ({{duplicates?.length}})]
fa-icon.text-muted.ml-1(*ngIf="loadingDuplicates" [icon]="spinnerIcon" [fixedWidth]="true" [spin]="true")
div
button.btn.btn-xs.btn-default((click)="clearOriginsFromDuplicates()" title="Remove all origins older than 14 days from all duplicates")
fa-icon.mr-1([icon]="trashIcon")
| old
button.btn.btn-xs.btn-default.ml-1((click)="refresh()" title="Refresh duplicates")
fa-icon([icon]="syncIcon" [fixedWidth]="true")
.mb-2
.text-muted(*ngIf="!duplicates || !duplicates.length")
| - no duplicates -
.mb-1.d-flex.align-items-center(*ngFor="let d of duplicates | slice:0:duplicatesLimit")
ng-template(#duplicatePonies)
.text-left(*ngIf="d.ponies")
div(*ngFor="let p of d.ponies")
| {{p}}
span.badge.badge-none(title="Duplicate origins" [class.deleted]="d.origins === 0")
| {{d.origins}}
span.badge.badge-none.ml-1(
title="Duplicate ponies" [class.deleted]="!d.ponies || !d.ponies.length"
[tooltip]="duplicatePonies" [isDisabled]="!d.ponies || !d.ponies.length")
| {{d.ponies ? d.ponies.length : '-'}}
fa-icon.ml-1.text-alert(
*ngIf="d.note" [icon]="duplicateNoteIcon" title="Duplicate in note")
fa-icon.ml-1.text-muted(
*ngIf="d.browserId" [icon]="duplicateBrowserIdIcon" title="Duplicate browser ID")
fa-icon.ml-1.text-muted(
*ngIf="d.userAgent" [icon]="duplicateBrowserIcon" [tooltip]="d.userAgent" title="Duplicate browser")
fa-icon.ml-1(
*ngIf="d.emails" [icon]="duplicateEmailIcon" title="Duplicate emails: {{d.emails}}"
[ngClass]="d.indenticalEmail ? 'text-alert' : 'text-muted'")
fa-icon.ml-1.text-alert(*ngIf="d.birthdate" [icon]="duplicateBirthdateIcon" title="Duplicate birthdate")
fa-icon.ml-1.text-muted(*ngIf="d.name" [icon]="duplicateNameIcon" title="Duplicate name")
account-info-remote.ml-1(#info [accountId]="d.account" [extendedAuths]="true")
button.btn.btn-xs.btn-default.ml-1((click)="merge(d.account)" title="Merge account")
fa-icon([icon]="mergeIcon" size="lg")
button.btn.btn-xs.btn-default.ml-1((click)="chatLog.add(info.account)" title="Add to chat log")
fa-icon([icon]="commentIcon" [fixedWidth]="true")
a.text-muted(*ngIf="duplicates && duplicates.length > duplicatesLimit" (click)="duplicatesLimit = 9999999")
| more ...
a.text-muted(*ngIf="duplicates && duplicatesLimit === 9999999" (click)="duplicatesLimit = 10")
| less ...
hr
//- around
.d-flex.justify-content-between.align-items-start
h5 Around #[span.text-muted ({{around ? around.length : '-'}})]
button.btn.btn-xs.btn-default((click)="refreshAround()" title="Refresh around")
fa-icon([icon]="syncIcon" [fixedWidth]="true")
.mb-2
.mb-1.d-flex.align-items-center(*ngFor="let a of around")
span.badge.badge-none(title="Distance in tiles from user") {{a.distance.toFixed(1)}}
fa-icon.ml-1.text-alert(*ngIf="a.party" [icon]="partyIcon" title="In party with user")
account-info-remote.ml-1(#info [accountId]="a.account" [extendedAuths]="true" [showDuplicates]="true")
button.btn.btn-xs.btn-default.ml-1((click)="merge(a.account)" title="Merge account")
fa-icon([icon]="mergeIcon" size="lg")
button.btn.btn-xs.btn-default.ml-1((click)="chatLog.add(info.account)" title="Add to chat log")
fa-icon([icon]="commentIcon" [fixedWidth]="true")
hr
//- friends
.d-flex.justify-content-between.align-items-start
h5 Friends #[span.text-muted ({{friends ? friends.length : '?'}})]
button.btn.btn-xs.btn-default((click)="fetchFriends()" title="Fetch friends list")
fa-icon([icon]="syncIcon" [fixedWidth]="true")
.mb-2
.mb-1.d-flex.align-items-center(*ngFor="let a of friends | slice:0:friendsLimit")
account-info-remote([accountId]="a")
button.btn.btn-xs.btn-default.ml-1((click)="removeFriend(a)" title="Remove friend")
fa-icon([icon]="trashIcon")
a.text-muted(*ngIf="friends && friends.length > friendsLimit" (click)="friendsLimit = 9999999")
| more ...
//- supporter invites
//- h5 Supporter invites #[span.text-muted ({{invites?.length || 0}})]
//- div(*ngIf="invites?.length")
.mb-1.d-flex.align-items-center(*ngFor="let i of invites")
.text-muted.mr-2 {{i.type}}
fa-icon.mr-2([icon]="checkIcon" [ngClass]="i.active ? 'text-success' : 'text-danger'")
account-info-remote.ml-1([accountId]="i.target" [extendedAuths]="true")
small.text-muted.flex-grow-1.text-right
time-field([time]="i.createdAt")
hr
//- force hides
div(*ngIf="account.hides && account.hides.length")
.d-flex.justify-content-between.align-items-start
h5 Force hidden #[span.text-muted ({{account.hides.length}})]
.mb-2
.mb-1.d-flex.align-items-center(*ngFor="let a of account.hides")
account-info-remote([accountId]="a")
hr
//- hides
.d-flex.justify-content-between.align-items-start
h5 Hidden by #[span.text-muted ({{hiddenBy ? hiddenBy.length : '?'}})]
button.btn.btn-xs.btn-default((click)="fetchHidden()" title="Fetch hidden list")
fa-icon([icon]="syncIcon" [fixedWidth]="true")
.mb-2
.mb-1.d-flex.align-items-center(*ngFor="let a of hiddenBy | slice:0:hiddenByLimit")
account-info-remote([accountId]="a")
a.text-muted(*ngIf="hiddenBy && hiddenBy.length > hiddenByLimit" (click)="hiddenByLimit = 9999999")
| more ...
h5 Hides users #[span.text-muted ({{hidden ? hidden.length : '?'}})]
.mb-2
.mb-1.d-flex.align-items-center(*ngFor="let a of hidden | slice:0:hiddenLimit")
account-info-remote([accountId]="a")
a.text-muted(*ngIf="hidden && hidden.length > hiddenLimit" (click)="hiddenLimit = 9999999")
| more ...
//- hides (perma)
.d-flex.justify-content-between.align-items-start
h5 Perma hidden by #[span.text-muted ({{permaHiddenBy ? permaHiddenBy.length : '?'}})]
.mb-2
.mb-1.d-flex.align-items-center(*ngFor="let a of permaHiddenBy | slice:0:permaHiddenByLimit")
account-info-remote([accountId]="a")
a.text-muted(*ngIf="permaHiddenBy && permaHiddenBy.length > permaHiddenByLimit" (click)="permaHiddenByLimit = 9999999")
| more ...
h5 Perma hides users #[span.text-muted ({{permaHidden ? permaHidden.length : '?'}})]
.mb-2
.mb-1.d-flex.align-items-center(*ngFor="let a of permaHidden | slice:0:permaHiddenLimit")
account-info-remote([accountId]="a")
a.text-muted(*ngIf="permaHidden && permaHidden.length > permaHiddenLimit" (click)="permaHiddenLimit = 9999999")
| more ...
hr
//- ignores
.d-flex.justify-content-between.align-items-start
h5 Ignored by #[span.text-muted ({{ignoredBy ? ignoredBy.length : account.ignoresCount}})]
button.btn.btn-xs.btn-default((click)="fetchIgnores()" title="Fetch ignores list")
fa-icon([icon]="syncIcon" [fixedWidth]="true")
.mb-2
.mb-1.d-flex.align-items-center(*ngFor="let a of ignoredBy | slice:0:ignoredByLimit")
account-info-remote([accountId]="a")
button.btn.btn-xs.btn-default.ml-1((click)="removeIgnore(a._id, account._id)" title="Remove ignore")
fa-icon([icon]="trashIcon")
a.text-muted(*ngIf="ignoredBy && ignoredBy.length > ignoredByLimit" (click)="ignoredByLimit = 9999999")
| more ...
h5 Ignores users #[span.text-muted ({{ignores ? ignores.length : '?'}})]
.mb-2
.mb-1.d-flex.align-items-center(*ngFor="let a of ignores | slice:0:ignoresLimit")
account-info-remote([accountId]="a")
button.btn.btn-xs.btn-default.ml-1((click)="removeIgnore(account._id, a._id)" title="Remove ignore")
fa-icon([icon]="trashIcon")
a.text-muted(*ngIf="ignores && ignores.length > ignoresLimit" (click)="ignoresLimit = 9999999")
| more ...
.col-md-3
//- emails
h5.d-flex.justify-content-between
div Emails #[span.text-muted ({{account.emails?.length || 0}})]
.dropdown(dropdown)
button.btn.btn-xs.btn-default.dropdown-toggle(dropdownToggle)
fa-icon([icon]="cogIcon" [fixedWidth]="true")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
a.dropdown-item((click)="addEmail()")
| Add email
.mb-2
.text-muted(*ngIf="!account.emails?.length")
| - no emails -
.d-flex.justify-content-between.items-align-center.mb-1(*ngFor="let e of account.emails | orderBy | slice:0:emailLimit")
div {{e}}
button.btn.btn-xs.btn-default((click)="removeEmail(e)" title="Remove email")
fa-icon([icon]="trashIcon")
a.text-muted((click)="emailLimit = 99999" *ngIf="emailLimit < (account.emails?.length || 0)")
| more ...
//- ponies
h5.d-flex.justify-content-between
div Ponies #[span.text-muted ({{account.characterCount}})]
.dropdown(dropdown)
button.btn.btn-xs.btn-default.dropdown-toggle(dropdownToggle)
fa-icon([icon]="cogIcon" [fixedWidth]="true")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
a.dropdown-item((click)="getPoniesCreators()")
| Fetch creator info
a.dropdown-item((click)="restoringPonies = true")
| Restore ponies
a.dropdown-item((click)="removePoniesAboveLimit()")
| Remove ponies above limit
a.dropdown-item.text-danger((click)="removeAllPonies()")
| Remove all ponies
.mb-2(*ngIf="restoringPonies")
textarea.form-control.mb-1([(ngModel)]="poniesToRestore" placeholder="paste removed pony logs here")
textarea.form-control.mb-1([(ngModel)]="poniesToRestoreFilter" placeholder="paste pony ids to use here (optional)")
.text-right
button.btn.btn-xs.btn-default((click)="poniesToRestore = ''; restoringPonies = false") cancel
button.btn.btn-xs.btn-success.ml-1((click)="restorePonies()" [disabled]="!poniesToRestore") restore
hr
.mb-2
pony-list-remote(
[accountId]="id" [expanded]="true" [deletable]="true" [highlight]="highlighCharacter" [duplicates]="duplicates")
.row
.col-md-12
h5 Events
events-table([events]="events" (showChat)="chatLog.show(account)")
.row
.col-md-12
admin-chat-log(#chatLog [canClose]="false" [account]="account")
button.btn.btn-sm.btn-default((click)="printJSON()") raw data
.well.pre(*ngIf="rawData")
| {{rawData}}
@@ -0,0 +1,17 @@
@import 'partials/variables';
.spoiler-text {
background: $body-color;
border-radius: 3px;
cursor: default;
&:hover {
background: none;
}
}
.long-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@@ -0,0 +1,582 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { remove, uniq } from 'lodash';
import {
Account, Event, Auth, ROLES, Character, MergeInfo, MergeAccountData, SupporterInvite, accountFlags,
OriginInfoBase, DuplicateResult, AroundEntry, LogEntry
} from '../../../common/adminInterfaces';
import { compareByName, createSupporterChanges, SupporterChange, getTranslationUrl, getAge } from '../../../common/adminUtils';
import { hasRole } from '../../../common/accountUtils';
import { AdminModel } from '../../services/adminModel';
import {
faCog, faSync, faLanguage, faTrash, faGlobe, faTerminal, faSpinner,
faIdBadge, faEnvelope, faFont, faCompressArrowsAlt, faComment, faSignOutAlt, faUsers, faCheckCircle,
faDatabase, faCopy, faCalendar, faExclamationCircle,
} from '../../../client/icons';
import { flagsToString, includes, flatten, removeItem } from '../../../common/utils';
import { Subscription } from '../../../common/interfaces';
import { showTextInNewTab } from '../../../client/htmlUtils';
const defaultLimit = 15;
const defaultDuplicatesLimit = 10;
const year = (new Date()).getFullYear();
@Component({
selector: 'admin-account-details',
templateUrl: 'admin-account-details.pug',
styleUrls: ['admin-account-details.scss'],
})
export class AdminAccountDetails implements OnInit, OnDestroy {
readonly cogIcon = faCog;
readonly syncIcon = faSync;
readonly langIcon = faLanguage;
readonly trashIcon = faTrash;
readonly signOutIcon = faSignOutAlt;
readonly duplicateNoteIcon = faExclamationCircle;
readonly duplicateBrowserIcon = faGlobe;
readonly duplicateBrowserIdIcon = faIdBadge;
readonly duplicateEmailIcon = faEnvelope;
readonly duplicateNameIcon = faFont;
readonly duplicateBirthdateIcon = faCalendar;
readonly mergeIcon = faCompressArrowsAlt;
readonly commentIcon = faComment;
readonly partyIcon = faUsers;
readonly checkIcon = faCheckCircle;
readonly consoleIcon = faTerminal;
readonly spinnerIcon = faSpinner;
readonly dataIcon = faDatabase;
readonly copyIcon = faCopy;
readonly roles = ROLES;
ages = [5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18];
account?: Account;
duplicates?: DuplicateResult[];
ponyNames?: string[];
ignores?: string[];
ignoredBy?: string[];
hidden?: string[];
hiddenBy?: string[];
permaHidden?: string[];
permaHiddenBy?: string[];
friends?: string[];
events?: Event[];
merges?: MergeInfo[];
banLog?: LogEntry[];
support?: SupporterChange[];
rawData?: string;
invites?: SupporterInvite[];
auths: string[] = [];
origins: OriginInfoBase[] = [];
counters: { key: string; value: any; }[] = [];
authLimit = defaultLimit;
emailLimit = defaultLimit;
loadingDuplicates = false;
accountObject?: Account;
clearingSessions = false;
authData?: string;
authDataAccount?: string;
restoringPonies = false;
poniesToRestore = '';
poniesToRestoreFilter = '';
friendsLimit = defaultLimit;
hiddenByLimit = defaultLimit;
hiddenLimit = defaultLimit;
permaHiddenByLimit = defaultLimit;
permaHiddenLimit = defaultLimit;
private id?: string;
private aroundMap = new Map<string, AroundEntry[]>();
private duplicatesLimits = new Map<string, number>();
private ignoresLimits = new Map<string, number>();
private ignoredByLimits = new Map<string, number>();
private authsSubscription?: Subscription;
private accountSubscription?: Subscription;
private originsSubscription?: Subscription;
constructor(private route: ActivatedRoute, private model: AdminModel) {
}
get canRemove() {
return hasRole(this.model.account, 'superadmin');
}
get around() {
return this.aroundMap.get(this.id || '');
}
get duplicatesLimit() {
return (this.id && this.duplicatesLimits.has(this.id)) ? this.duplicatesLimits.get(this.id)! : defaultDuplicatesLimit;
}
set duplicatesLimit(value) {
if (this.id) {
this.duplicatesLimits.set(this.id, value);
}
}
get ignoresLimit() {
return (this.id && this.ignoresLimits.has(this.id)) ? this.ignoresLimits.get(this.id)! : defaultLimit;
}
set ignoresLimit(value) {
if (this.id) {
this.ignoresLimits.set(this.id, value);
}
}
get ignoredByLimit() {
return (this.id && this.ignoredByLimits.has(this.id)) ? this.ignoredByLimits.get(this.id)! : defaultLimit;
}
set ignoredByLimit(value) {
if (this.id) {
this.ignoredByLimits.set(this.id, value);
}
}
get originRegions() {
return uniq(this.origins.map(o => o.country));
}
get age() {
return (this.account && this.account.birthdate) ? getAge(this.account.birthdate) : '-';
}
get forceAge() {
return (this.account && this.account.birthyear) ? (year - this.account.birthyear) : '-';
}
ngOnInit() {
this.route.params.subscribe(({ id }) => {
this.id = id;
this.update();
});
this.model.updated = () => this.update();
this.update();
}
ngOnDestroy() {
this.model.updated = () => { };
this.authsSubscription && this.authsSubscription.unsubscribe();
this.accountSubscription && this.accountSubscription.unsubscribe();
this.originsSubscription && this.originsSubscription.unsubscribe();
}
refresh() {
const account = this.account;
this.ponyNames = [];
this.banLog = undefined;
this.merges = undefined;
this.support = undefined;
this.invites = undefined;
this.accountObject = undefined;
this.loadingDuplicates = false;
this.authsSubscription && this.authsSubscription.unsubscribe();
this.authsSubscription = undefined;
this.originsSubscription && this.originsSubscription.unsubscribe();
this.originsSubscription = undefined;
this.auths = [];
this.origins = [];
this.counters = [];
this.friends = undefined;
this.hidden = undefined;
this.hiddenBy = undefined;
this.permaHidden = undefined;
this.permaHiddenBy = undefined;
if (account) {
account.ignoredByLimit = account.ignoredByLimit || 10;
account.ignoresLimit = account.ignoresLimit || 10;
account.duplicatesLimit = account.duplicatesLimit || 10;
this.loadingDuplicates = true;
this.model.getAllDuplicates(account._id)
.then(duplicates => {
if (duplicates) {
this.ponyNames = uniq(flatten(duplicates.map(d => (d.ponies || []).map(x => x.toLowerCase()))));
this.duplicates = duplicates;
this.loadingDuplicates = false;
}
});
this.model.getAccount(account._id)
.then(account => this.accountObject = account);
this.refreshDetails();
this.authsSubscription = this.model.accountAuths
.subscribe(account._id, auths => this.auths = auths || []);
this.originsSubscription = this.model.accountOrigins
.subscribe(account._id, origins => this.origins = origins || []);
} else {
this.duplicates = [];
this.ignores = [];
this.ignoredBy = [];
}
}
refreshAround() {
const account = this.account;
if (account) {
this.model.getAccountAround(account._id)
.then(accounts => {
if (accounts) {
this.aroundMap.set(account._id, accounts);
}
});
}
}
refreshDetails() {
function createCounter(key: string, value: any) {
if (key === 'toys') {
value = (value >>> 0).toString(2).padStart(32, '0');
}
return { key, value };
}
if (this.account) {
this.model.getDetailsForAccount(this.account)
.then(details => {
if (details) {
this.banLog = details.banLog;
this.merges = details.merges;
this.support = createSupporterChanges(details.supporterLog);
this.invites = [
...details.invitesSent!.map(i => ({ ...i, type: 'sent' })),
...details.invitesReceived!.map(i => ({ ...i, type: 'recv' })),
];
const state = details.state as any;
this.counters = Object.keys(state).map(key => createCounter(key, state[key]));
}
});
}
}
fetchIgnores() {
if (this.id) {
this.model.getIgnoresAndIgnoredBy(this.id)
.then(result => {
if (result) {
this.ignores = result.ignores;
this.ignoredBy = result.ignoredBy;
}
});
}
}
fetchHidden() {
if (this.id) {
this.model.getAccountHidden(this.id)
.then(result => {
if (result) {
this.hidden = result.hidden;
this.hiddenBy = result.hiddenBy;
this.permaHidden = result.permaHidden;
this.permaHiddenBy = result.permaHiddenBy;
}
});
}
}
fetchFriends() {
if (this.id) {
this.model.getAccountFriends(this.id)
.then(result => this.friends = result);
}
}
removeFriend(friendId: string) {
if (this.id && confirm('Are you sure ?')) {
this.model.removeFriend(this.id, friendId)
.then(() => this.friends && removeItem(this.friends, friendId));
}
}
canToggleRole(role: string) {
return role !== 'superadmin' && hasRole(this.model.account, 'superadmin');
}
hasRole(role: string) {
return hasRole(this.account, role);
}
toggleRole(role: string) {
if (this.id) {
this.model.setRole(this.id, role, !this.hasRole(role));
}
}
showAccountData() {
if (this.id) {
this.model.getAccount(this.id)
.then(account => {
(window as any).$data = account;
console.log(account);
console.log('accessible in $data');
});
}
}
printAuthList() {
this.forAuths(auths => {
const authList = auths.map((a, i) => `${i + 1}. [${a.provider}] ${a.name || '<no name>'}`).join('\n');
this.authData = authList;
this.authDataAccount = this.account ? `${this.account.name} [${this.account._id}]` : '';
console.log(authList);
});
}
printAuthData() {
this.forAuths(auths => {
const authData = auths.map((a, i) => `${i + 1}. [${a._id}] [${a.provider}] ${a.name || '<no name>'} (${a.emails})`).join('\n');
this.authData = authData;
this.authDataAccount = this.account ? `${this.account.name} [${this.account._id}]` : '';
console.log(authData);
});
}
copyAuthData() {
if ('clipboard' in navigator) {
(navigator as any).clipboard.writeText(this.authData);
}
}
clearAuthData() {
this.authData = undefined;
this.authDataAccount = undefined;
}
fetchAuths() {
this.forAuths(auths => {
(window as any).$auths = auths;
console.log(auths);
});
}
private forAuths(callback: (auths: Auth[]) => void) {
if (this.account) {
this.model.getAuthsForAccount(this.account._id)
.then(auths => callback(auths || []));
}
}
printJSON() {
if (this.id) {
this.model.getAccount(this.id)
.then(account => this.rawData = JSON.stringify(account, undefined, 2));
}
}
clearOrigins(old: boolean, singles: boolean) {
if (this.id) {
this.model.clearOriginsForAccount(this.id, { old, singles })
.then(() => this.refresh());
}
}
clearOriginsInRegion(country: string) {
if (this.id) {
this.model.clearOriginsForAccount(this.id, { country })
.then(() => this.refresh());
}
}
clearOriginsFromDuplicates() {
if (this.account) {
const duplicates = (this.duplicates || []).slice(0, this.account.duplicatesLimit || 0);
const accounts = duplicates.map(d => d.account);
this.model.clearOriginsForAccounts(accounts, { old: true })
.then(() => this.refresh());
}
}
removeOrigin(ip: string) {
if (this.id) {
this.model.removeOriginsForAccount(this.id, [ip])
.then(() => this.refresh());
}
}
merge(accountId: string) {
if (this.account && confirm('Are you sure?')) {
this.model.mergeAccounts(this.account._id, accountId)
.then(() => remove(this.duplicates || [], d => d.account === accountId))
.then(() => this.refresh());
}
}
remove() {
if (this.account && confirm('Are you sure?')) {
this.model.removeAccount(this.account._id);
}
}
translateUrl(text: string) {
return getTranslationUrl(text);
}
getPoniesCreators() {
if (this.account) {
this.model.getPoniesCreators(this.account._id)
.then(items => {
if (items) {
items.sort(compareByName);
(window as any).$ponies = items;
console.log(items.map(i => `[${i._id}] "${i.name}" ${i.creator}`).join('\n'));
}
});
}
}
removePoniesAboveLimit() {
if (this.account && confirm('Are you sure?')) {
this.model.removePoniesAboveLimit(this.account._id);
}
}
removeAllPonies() {
if (this.account && confirm('Are you sure?')) {
this.model.removeAllPonies(this.account._id);
}
}
restorePonies() {
if (this.account && this.poniesToRestore) {
let ids: string[] | undefined = undefined;
if (this.poniesToRestoreFilter) {
const matches = Array.from(this.poniesToRestoreFilter.match(/\[[a-f0-9]{24}\]/g) || [])
.map(id => id.substr(1, 24));
if (matches.length) {
ids = matches;
}
}
this.model.restorePonies(this.account._id, this.poniesToRestore, ids);
this.poniesToRestore = '';
this.restoringPonies = false;
}
}
removeEmail(email: string) {
if (this.account && confirm('Are you sure?')) {
this.model.removeEmail(this.account._id, email);
}
}
removeIgnore(account: string, ignoredAccount: string) {
this.model.removeIgnore(account, ignoredAccount)
.then(() => this.fetchIgnores());
}
highlighCharacter = (char: Character | undefined): boolean => {
return !!char && includes(this.ponyNames, char.name.toLowerCase());
}
clearSessions() {
if (this.account) {
this.clearingSessions = true;
this.model.clearSessions(this.account._id)
.finally(() => this.clearingSessions = false);
}
}
getMergeTooltip(merge: MergeInfo) {
function mergeInfo(title: string, account: MergeAccountData) {
return [
title,
`\tname: ${account.name}`,
`\tnote: ${account.note || ''}`,
`\tflags: ${flagsToString(account.flags, accountFlags)}`,
`\tage: ${account.birthdate ? getAge(account.birthdate) : '-'}`,
`\tfriends: ${account.friends ? account.friends.length : 0}`,
`\tcounters:`,
...Object.keys(account.counters || {}).sort().map(key => `\t\t${key}: ${(account.counters as any)[key]}`),
`\tstate:`,
...(account.state ? JSON.stringify(account.state, null, 2).split(/\n/).map(x => `\t\t${x}`) : []),
`\temails:`,
...account.emails.map(e => `\t\t${e}`),
`\tauths:`,
...account.auths.map(a => `\t\t[${a.id}] ${a.name}`),
`\tcharacters: ${account.characters.length}`,
].join('\n');
}
if (merge.data) {
return `${mergeInfo('ACCOUNT', merge.data.account)}\n\n${mergeInfo('MERGED', merge.data.merge)}`;
} else {
return '<empty>';
}
}
printMerge(merge: MergeInfo) {
console.log(this.mergeInfo(merge));
}
printMerge2(merge: MergeInfo) {
console.log(this.mergeInfo2(merge));
}
mergeInfo(merge: MergeInfo) {
function mergeInfo(title: string, account: MergeAccountData) {
return [
title,
`\tname: ${account.name}`,
`\tnote: ${account.note || ''}`,
`\tflags: ${flagsToString(account.flags, accountFlags)}`,
`\tage: ${account.birthdate ? getAge(account.birthdate) : '-'}`,
`\tcounters:`,
...Object.keys(account.counters || {}).sort().map(key => `\t\t${key}: ${(account.counters as any)[key]}`),
`\tstate:`,
...(account.state ? JSON.stringify(account.state, null, 2).split(/\n/).map(x => `\t\t${x}`) : []),
`\temails:`,
...account.emails.map(e => `\t\t${e}`),
`\tauths:`,
...account.auths.map(a => `\t\t[${a.id}] ${a.name}`),
`\tcharacters:`,
...account.characters.map(a => `\t\t[${a.id}] ${a.name}`),
`\tfriends:`,
...(account.friends || []).map(i => `\t\t[${i}]`),
`\tignores:`,
...(account.ignores || []).map(i => `\t\t[${i}]`),
].join('\n');
}
return merge.data && `${mergeInfo('ACCOUNT', merge.data.account)}\n\n${mergeInfo('MERGED', merge.data.merge)}`;
}
private mergeInfo2(merge: MergeInfo) {
function mergeInfo(title: string, account: MergeAccountData) {
return [
title,
`\tname: ${JSON.stringify(account.name)}`,
`\tnote: ${JSON.stringify(account.note)}`,
`\tflags: ${JSON.stringify(account.flags)}`,
`\tcounters: ${JSON.stringify(account.counters)}`,
`\tstate: ${JSON.stringify(account.state)}`,
`\temails: ${JSON.stringify(account.emails)}`,
`\tauths: ${JSON.stringify(account.auths)}`,
`\tcharacters: ${JSON.stringify(account.characters)}`,
`\tfriends: ${JSON.stringify(account.friends)}`,
`\tignores: ${JSON.stringify(account.ignores)}`,
].join('\n');
}
return merge.data && `${mergeInfo('ACCOUNT', merge.data.account)}\n\n${mergeInfo('MERGED', merge.data.merge)}`;
}
showMergeInNewTab(merge: MergeInfo) {
showTextInNewTab(this.mergeInfo(merge) || '');
}
showMergeInNewTab2(merge: MergeInfo) {
showTextInNewTab(this.mergeInfo2(merge) || '');
}
unmerge(mergeId: string | undefined, split: MergeAccountData, keep: MergeAccountData) {
if (this.account) {
this.model.unmergeAccounts(this.account._id, mergeId, split, keep)
.then(() => this.refresh());
}
}
setAge(age: number) {
if (this.account) {
this.model.setAge(this.account._id, age);
}
}
addEmail() {
if (this.account) {
const email = prompt('enter email');
if (email && /@/.test(email)) {
this.model.addEmail(this.account._id, email);
}
}
}
private update() {
if (this.id) {
this.accountSubscription && this.accountSubscription.unsubscribe();
this.accountSubscription = this.id ? this.model.accounts.subscribe(this.id, a => this.setAccount(a)) : undefined;
this.events = this.model.events
.filter(e => e.account === this.id)
.slice(0, 10);
}
}
private setAccount(account: Account | undefined) {
const theSame = this.account === account || (this.account && account && this.account._id === account._id);
this.account = account;
if (!theSame) {
this.duplicates = [];
this.ponyNames = [];
this.ignores = [];
this.ignoredBy = [];
this.rawData = undefined;
this.authLimit = defaultLimit;
this.emailLimit = defaultLimit;
this.duplicatesLimit = defaultDuplicatesLimit;
this.refresh();
this.friendsLimit = defaultLimit;
this.hiddenByLimit = defaultLimit;
this.hiddenLimit = defaultLimit;
this.permaHiddenByLimit = defaultLimit;
this.permaHiddenLimit = defaultLimit;
(window as any).$account = account;
}
}
}
@@ -0,0 +1,88 @@
ng-template(#searchTooltip)
.text-left.help-tooltip
div: em search by:
div name:&lt;account_name&gt; #[em - account name]
div note:&lt;note_text&gt; #[em - note contents]
div email:&lt;email_fragment&gt; #[em - email fragment]
div role:&lt;role_name&gt; #[em - role]
div exact:&lt;name&gt; #[em - exact account name]
div ignores:&lt;count&gt; #[em - amount of ignores (at least)]
div ponies:&lt;count&gt; #[em - amount of ponies (at least)]
div auths:&lt;count&gt; #[em - amount of auths (at least)]
div old:&lt;number_of_days&gt; #[em - days since last visit]
div disabled! #[em - list accounts with disabled auths]
div locked! #[em - list accounts with locked auths]
div spam/swering/timeouts/limits:&lt;number&gt;
ng-template(#duplicatesPopover)
a.text-secondary.d-block(*ngFor="let e of duplicateEntries" (click)="search = e")
| {{e}}
.d-block.d-sm-flex
input.form-control.form-control-sm.admin-search(type="search" placeholder="search" [(ngModel)]="search")
.btn-group.btn-group-sm.ml-1
button.btn.btn-default((click)="refresh(true)")
fa-icon([icon]="syncIcon" size="lg")
button.btn(btnCheckbox [(ngModel)]="autoRefresh" [btnHighlight]="autoRefresh")
| auto
.btn-group.btn-group-sm.ml-1
button.btn.btn-sm.btn-danger(
*ngIf="duplicateEntries?.length" [popover]="duplicatesPopover" [outsideClick]="true"
placement="bottom" containerClass="popover-wide")
| Duplicates ({{duplicateEntries.length}})
button.btn.btn-sm.btn-danger((click)="refreshDuplicates()" title="Refresh duplicates")
fa-icon([icon]="syncIcon" size="lg")
.btn-group.btn-group-sm.ml-1(btnRadioGroup [(ngModel)]="showOnly")
button.btn(*ngFor="let f of filters" [btnRadio]="f" [btnHighlight]="showOnly === f") {{f}}
button.btn.btn-sm.ml-1((click)="not = !not" [btnHighlight]="not") not
button.btn.btn-sm.btn-default.ml-2((click)="createAccount()") create account
.d-block.d-sm-flex.justify-content-between.align-items-start
.pl-1([tooltip]="searchTooltip" placement="bottom" containerClass="tooltip-pre" style="width: 280px;")
small.text-muted
fa-icon.mr-1([icon]="filterIcon")
| filters
.d-block.d-sm-flex.mt-2
fa-icon.text-muted.mr-3(*ngIf="loading" [icon]="spinnerIcon" [spin]="true" size="2x" style="margin-top: 3px")
strong.text-muted.mr-3(style="margin-top: 6px")
| {{totalItems === 99999999 ? '' : totalItems}}
pagination(
[totalItems]="totalItems" [itemsPerPage]="itemsPerPage" [(ngModel)]="currentPage"
[maxSize]="15" [directionLinks]="false" [boundaryLinks]="true")
table.table.table-fixed.table-striped.table-hover.table-sm
thead
tr
th.col-time created
th.col-time last visit
th.col-account name
th.col-email emails
th.col-auth auths
th.col-origin origins
th ponies
th(style="width: 40px;")
tbody
tr(*ngFor="let a of itemsOnPageIds")
td.col-time
time-field([time]="info.account?.createdAt")
td.col-time
time-field([time]="info.account?.lastVisit")
td.col-account
account-info-remote(#info [accountId]="a")
td.col-email
email-list([emails]="info.account?.emails")
td.col-auth
auth-list-remote([accountId]="a")
td.col-origin
origin-list-remote([accountId]="a")
td
pony-list-remote([accountId]="a")
td(style="width: 40px;")
.btn-group.btn-group-xs
button.btn.btn-xs.btn-default(
(click)="$event.shiftKey ? chatLog.add(info.account) : chatLog.show(info.account)" title="Show chat")
fa-icon([icon]="commentIcon" [fixedWidth]="true")
admin-chat-log(#chatLog)
.admin-bottom-padding
@@ -0,0 +1,123 @@
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { debounce } from 'lodash';
import { Account } from '../../../common/adminInterfaces';
import { AdminModel } from '../../services/adminModel';
import { faSync, faComment, faFilter, faEraser, faSpinner } from '../../../client/icons';
let autoRefresh = false;
let showOnly = 'all';
let search = '';
let currentPage = 0;
let not = false;
@Component({
selector: 'admin-accounts',
templateUrl: 'admin-accounts.pug',
})
export class AdminAccounts implements OnInit {
readonly syncIcon = faSync;
readonly filterIcon = faFilter;
readonly commentIcon = faComment;
readonly eraserIcon = faEraser;
readonly spinnerIcon = faSpinner;
readonly filters = [
'all',
'banned',
'timed out',
'with flags',
'notes',
'supporters',
];
totalItems = 99999999;
itemsOnPageIds: string[] = [];
itemsPerPage = 20;
loading = false;
private expanded = new Set<string>();
constructor(public model: AdminModel, private router: Router) {
}
get showOnly() {
return showOnly;
}
set showOnly(value) {
if (showOnly !== value) {
showOnly = value;
this.refresh();
}
}
get not() {
return not;
}
set not(value) {
if (not !== value) {
not = value;
this.refresh();
}
}
get autoRefresh() {
return autoRefresh;
}
set autoRefresh(value) {
autoRefresh = value;
}
get currentPage() {
return currentPage;
}
set currentPage(value) {
if (currentPage !== value) {
currentPage = value;
this.refresh();
}
}
get search() {
return search;
}
set search(value) {
if (search !== value) {
search = value;
this.execSearch();
}
}
private execSearch = debounce(() => this.refresh(), 500);
get duplicateEntries() {
return this.model.duplicateEntries;
}
refreshDuplicates() {
this.model.checkDuplicateEntries(true);
}
limit(account: Account) {
return this.expanded.has(account._id) ? 99999 : 2;
}
expand(account: Account) {
this.expanded.add(account._id);
}
ngOnInit() {
this.model.accountPromise
.then(() => this.refresh());
}
refresh(force = false) {
this.loading = true;
this.model.findAccounts({
search: this.search.trim(),
not: this.not,
showOnly: this.showOnly,
page: this.currentPage - 1,
itemsPerPage: this.itemsPerPage,
force,
}).then(result => {
if (result && result.page === (this.currentPage - 1)) {
this.totalItems = result.totalItems;
this.itemsOnPageIds = result.accounts;
this.loading = false;
}
});
}
createAccount() {
const name = prompt('enter new account name');
if (name) {
this.model.createAccount(name)
.then(id => this.router.navigate(['accounts', id]));
}
}
}
@@ -0,0 +1,55 @@
ng-template(#duplicatesPopover)
div(*ngFor="let e of duplicateEntries")
| {{e}}
.d-block.d-sm-flex.justify-content-between.align-items-center
.form-inline
input.form-control.form-control-sm.admin-search-inline.d-none.d-sm-inline-block(
type="search" placeholder="search" [(ngModel)]="search" disabled)
button.btn.btn-sm.ml-1((click)="toggleNotifications()" [btnHighlight]="notifications" title="Toggle notifications")
fa-icon([icon]="bellIcon" size="lg")
button.btn.btn-sm.btn-default.ml-1((click)="cleanupDeleted()" title="Cleanup deleted events")
fa-icon.mr-1([icon]="syncIcon" size="lg")
| clear
button.btn.btn-sm.btn-default.ml-1((click)="removeEvents(1000 * 60 * 10)" title="Delete all older than 10 minutes")
fa-icon.mr-1([icon]="clockIcon" size="lg")
| 10min
button.btn.btn-sm.btn-default.ml-1((click)="removeEvents(1000 * 3)" title="Delete all events")
fa-icon.mr-1([icon]="trashIcon" size="lg")
| all
button.btn.btn-sm.btn-default.ml-1((click)="showChat()" title="Show chatlog")
fa-icon.mr-1([icon]="commentsIcon" size="lg")
.btn-group.ml-1
button.btn.btn-sm.btn-danger(
*ngIf="duplicateEntries?.length" [popover]="duplicatesPopover" placement="bottom" containerClass="popover-wide")
| duplicates ({{duplicateEntries.length}})
button.btn.btn-sm.btn-danger((click)="refreshDuplicates()" title="Refresh duplicates")
fa-icon([icon]="syncIcon" size="lg")
span.text-muted.d-none.d-sm-inline
span.ml-3(title="Used disk space" [class.text-danger]="isLowDiskSpace")
fa-icon.mr-2([icon]="hddIcon")
| {{status.diskSpace}}
span.ml-3(title="Memory usage" [class.text-danger]="isLowMemory")
fa-icon.mr-2([icon]="ramIcon")
| {{status.memoryUsage}}
span.ml-3(title="Certificate expires in" [class.text-danger]="isOldCertificate")
fa-icon.mr-2([icon]="certificateIcon")
from-now([time]="status.certificateExpiration")
span.ml-3(title="Last Patreon update" [class.text-danger]="isOldPatreon")
fa-icon.mr-2([icon]="patreonIcon")
from-now([time]="status.lastPatreonUpdate")
pagination(
[totalItems]="filtered.length" [itemsPerPage]="itemsPerPage" [maxSize]="15" [(ngModel)]="currentPage"
[directionLinks]="false")
events-table(
[events]="filteredOnPage" (showChat)="showChat($event)" (addChat)="addChat($event)"
(removedEvent)="removedEvent($event)")
admin-chat-log(#chatLog)
.admin-bottom-padding
@@ -0,0 +1,99 @@
import { Component, OnInit, ViewChild, OnDestroy } from '@angular/core';
import { fromNow } from '../../../common/utils';
import { Event, ChatEvent } from '../../../common/adminInterfaces';
import { AdminModel } from '../../services/adminModel';
import { BaseTable, BaseTableState } from '../base-table';
import { AdminChatLog } from '../shared/admin-chat-log/admin-chat-log';
import {
faBell, faSync, faClock, faTrash, faComments, faHdd, faMicrochip, faCertificate, faClone, faPatreon
} from '../../../client/icons';
let state: BaseTableState;
@Component({
selector: 'admin-events',
templateUrl: 'admin-events.pug',
})
export class AdminEvents extends BaseTable<Event> implements OnInit, OnDestroy {
readonly bellIcon = faBell;
readonly syncIcon = faSync;
readonly clockIcon = faClock;
readonly trashIcon = faTrash;
readonly commentsIcon = faComments;
readonly hddIcon = faHdd;
readonly ramIcon = faMicrochip;
readonly certificateIcon = faCertificate;
readonly duplicateIcon = faClone;
readonly patreonIcon = faPatreon;
@ViewChild('chatLog', { static: true }) chatLog!: AdminChatLog;
private chatEvent?: Event;
constructor(private model: AdminModel) {
super();
}
get status() {
return this.model.state.status;
}
get isLowDiskSpace() {
return this.model.isLowDiskSpace;
}
get isLowMemory() {
return this.model.isLowMemory;
}
get isOldCertificate() {
return this.model.isOldCertificate;
}
get isOldPatreon() {
return this.model.isOldPatreon;
}
get items() {
return this.model.events;
}
get duplicateEntries() {
return this.model.duplicateEntries;
}
get notifications() {
return this.model.notifications;
}
ngOnInit() {
this.model.updated = () => this.updateItems();
this.setState(state);
this.updateItems();
}
ngOnDestroy() {
this.model.updated = () => { };
}
cleanupDeleted() {
this.model.cleanupDeletedEvents();
}
refreshDuplicates() {
this.model.checkDuplicateEntries(true);
}
removeEvents(olderThan: number) {
const date = fromNow(-olderThan);
const oldEvents = this.items.filter(e => e.updatedAt.getTime() < date.getTime());
return Promise.all(oldEvents.map(e => this.model.removeEvent(e._id).then(() => this.removedEvent(e))));
}
showChat(e?: ChatEvent) {
this.chatEvent = e && e.event;
this.chatLog.show(e && e.account);
}
addChat(e: ChatEvent) {
if (e.account) {
this.chatLog.add(e.account);
}
}
removedEvent(e: Event) {
if (this.chatEvent === e) {
this.chatLog.close();
}
}
toggleNotifications() {
this.model.toggleNotifications();
}
protected onChange() {
state = this.getState();
}
protected updatePage() {
super.updatePage();
}
}
@@ -0,0 +1,33 @@
.row(style="margin-top: 100px;")
.col-md-6
h2 {{ip}} [{{info.origin?.country}}]
div(style="margin-bottom: 10px;")
origin-info-remote(#info [originIP]="ip")
div
a([href]="whoisHref" target="_blank" rel="noopener noreferrer")
| show whois info
div
span.text-muted created:
from-now.ml-1([time]="info.origin?.createdAt")
div
span.text-muted updated:
from-now.ml-1([time]="info.origin?.updatedAt")
.col-md-6
h3 Accounts #[span.text-muted.ml-1 ({{accounts.length}})]
div(*ngFor="let a of accounts | slice:0:50")
account-info-remote([accountId]="a")
.row
.col-md-12
h3 Events
events-table([events]="events" (showChat)="chatLog.show($event.account)")
.row
.col-md-12
admin-chat-log(#chatLog)
@@ -0,0 +1,36 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { Event } from '../../../common/adminInterfaces';
import { AdminModel } from '../../services/adminModel';
@Component({
selector: 'admin-origin-details',
templateUrl: 'admin-origin-details.pug',
})
export class AdminOriginDetails implements OnInit {
events?: Event[];
accounts: string[] = [];
ip?: string;
constructor(private route: ActivatedRoute, private model: AdminModel) {
}
get whoisHref() {
return `http://whois.urih.com/record/${this.ip}/`;
}
ngOnInit() {
this.route.params.forEach(p => {
this.ip = p['ip'];
this.update();
});
}
private update() {
this.accounts = [];
if (this.model.connected && this.ip) {
this.model.getAccountsByOrigin(this.ip)
.then(accounts => this.accounts = accounts || []);
this.events = this.model.events
.filter(e => e.origin && e.origin.ip === this.ip)
.slice(0, 20);
}
}
}
@@ -0,0 +1,59 @@
.text-center.pt-5(*ngIf="!stats")
fa-icon.text-muted([icon]="spinnerIcon" [fixedWidth]="true" [spin]="true" size="3x")
.row(*ngIf="stats")
.col-md-6
h3
| Origins statistics
button.btn.btn-xs.btn-default.ml-1((click)="update()")
fa-icon([icon]="syncIcon" [fixedWidth]="true")
.stat-field Total origin count: #[b {{stats.totalOrigins}}]
.stat-field Total origin count (IP4 / IP6): #[b {{stats.totalOriginsIP4}} / {{stats.totalOriginsIP6}}]
.stat-field.mt-3 Unique origins: #[b {{stats.uniqueOrigins}}]
.stat-field Multiple account origins: #[b {{stats.duplicateOrigins}}]
.stat-field Single account origins: #[b {{stats.singleOrigins}}]
h3.mt-3
| Ignores statistics
button.btn.btn-xs.btn-default.ml-1((click)="update()")
fa-icon([icon]="syncIcon" [fixedWidth]="true")
.stat-field Total ignore count: #[b {{other?.totalIgnores}}]
h3.mt-3
| Other statistics
button.btn.btn-xs.btn-default.ml-1((click)="update()")
fa-icon([icon]="syncIcon" [fixedWidth]="true")
.stat-field Auths with empty account: #[b {{other?.authsWithEmptyAccount}}]
.stat-field Auths with missing account: #[b {{other?.authsWithMissingAccount}}]
.col-md-6
h3 Origins distribution
table.table.table-sm(style="width: auto;")
thead
tr
th origins
th accounts
th
tbody
tr(*ngFor="let d of stats.distribution; let i = index")
td {{i}}
td {{d}}
td
button.btn.btn-xs.btn-default((click)="clear(i)" [disabled]="pending" title="Clear old singles in this row")
fa-icon([icon]="eraserIcon" [fixedWidth]="true")
button.btn.btn-xs.btn-default.ml-1((click)="clear(i, true)" [disabled]="pending" title="Clear old singles in or below this row")
fa-icon([icon]="eraserIcon" [fixedWidth]="true")
fa-icon([icon]="chevronDownIcon" [fixedWidth]="true")
button.btn.btn-xs.btn-default.ml-1((click)="clearSingles(i)" [disabled]="pending" title="Clear singles in or below this row")
fa-icon([icon]="userIcon" [fixedWidth]="true")
fa-icon([icon]="chevronDownIcon" [fixedWidth]="true")
button.btn.btn-xs.btn-default.ml-1((click)="clearOld(i)" [disabled]="pending" title="Clear very old (3 months) in or below this row")
fa-icon([icon]="clockIcon" [fixedWidth]="true")
fa-icon([icon]="chevronDownIcon" [fixedWidth]="true")
button.btn.btn-xs.btn-default.ml-1((click)="clearTo10(i)" [disabled]="pending" title="Trim origins to 10 most recent")
b 10
fa-icon([icon]="chevronDownIcon" [fixedWidth]="true")
@@ -0,0 +1,53 @@
import { Component, OnInit } from '@angular/core';
import { RequestStats, OriginStats, OtherStats } from '../../../common/adminInterfaces';
import { faSync, faEraser, faClock, faUser, faChevronDown, faSpinner } from '../../../client/icons';
import { AdminModel } from '../../services/adminModel';
@Component({
selector: 'admin-origins',
templateUrl: 'admin-origins.pug',
})
export class AdminOrigins implements OnInit {
readonly syncIcon = faSync;
readonly eraserIcon = faEraser;
readonly clockIcon = faClock;
readonly userIcon = faUser;
readonly chevronDownIcon = faChevronDown;
readonly spinnerIcon = faSpinner;
stats?: OriginStats;
other?: OtherStats;
requestStats: RequestStats[] = [];
pending = false;
constructor(public model: AdminModel) {
}
ngOnInit() {
if (this.model.connected) {
this.update();
}
}
update() {
this.model.getOriginStats().then(stats => this.stats = stats);
this.model.getOtherStats().then(stats => this.other = stats);
}
clear(count: number, andHigher = false) {
this.clearAll(count, andHigher, true, true, false);
}
clearOld(count: number) {
this.clearAll(count, true, true, false, false);
}
clearSingles(count: number) {
this.clearAll(count, true, false, true, false);
}
clearTo10(count: number) {
this.clearAll(count, true, false, true, true);
}
clearAll(count: number, andHigher: boolean, old: boolean, singles: boolean, trim: boolean) {
this.pending = true;
return this.model.clearOrigins(count, andHigher, { old, singles, trim })
.finally(() => {
this.pending = false;
this.update();
});
}
}
@@ -0,0 +1,52 @@
.row
.col-6
.form
.form-group
h3 Notifications
.form
.form-group(*ngFor="let field of fields")
label {{field.title}}
textarea.form-control([(ngModel)]="field.value" rows="4" style="width: 400px;")
.form-group
button.btn.btn-primary(type="button" (click)="saveFields()") Save
button.btn.btn-default.ml-1(type="button" (click)="resetFields()") Reset
.col-6
.form
.form-group
h3 Patreon
.form-group.d-flex
button.btn.btn-default((click)="updatePatreon()")
| Update Data
.input-group.ml-1(style="width: 400px")
input.form-control(#patreonToken placeholder="patreon token")
.input-group-append
button.btn.btn-warning((click)="updatePatreonToken(patreonToken.value); patreonToken.value = ''")
| Update Token
.form-group
| To get new token, got to #[a(href="https://www.patreon.com/portal/registration/register-clients") My Clients],
| refresh token and update patreon token field with "Creator's Access Token" from the site.
.form-group
button.btn.btn-default((click)="getLastPatreonData()")
| Get last patreon data
.form
.form-group
h3 Other
.form-group
button.btn.btn-default((click)="updatePastSupporters()")
| Update past supporters
.row
.col-12
.form
.form-group
label Suspicious pony info to report
textarea.form-control([(ngModel)]="suspiciousPonies" rows="15")
.form-group(*ngIf="suspiciousPoniesError")
.alert.alert-danger
| {{suspiciousPoniesError}}
.form-group
button.btn.btn-primary((click)="saveSuspiciousPonies()") Save
button.btn.btn-default.ml-1((click)="resetSuspiciousPonies()") Reset
@@ -0,0 +1,90 @@
import { Component, OnInit, OnDestroy } from '@angular/core';
import { compact } from 'lodash';
import { AdminModel } from '../../services/adminModel';
import { Account, GeneralSettings } from '../../../common/adminInterfaces';
import { Subscription } from '../../../common/interfaces';
import { showTextInNewTab } from '../../../client/htmlUtils';
interface Field {
key: keyof GeneralSettings;
title: string;
value: string | undefined;
}
@Component({
selector: 'admin-other',
templateUrl: 'admin-other.pug',
})
export class AdminOther implements OnInit, OnDestroy {
fields: Field[] = [
{ key: 'suspiciousNames', title: 'Suspicious pony names & emails to report', value: undefined },
{ key: 'suspiciousAuths', title: 'Suspicious auths to report', value: undefined },
{ key: 'suspiciousMessages', title: 'Suspicious messages to report (instant)', value: undefined },
{ key: 'suspiciousSafeMessages', title: 'Suspicious messages to report (safe only) (5+)', value: undefined },
{ key: 'suspiciousSafeWholeMessages', title: 'Suspicious messages to report (safe only) (5+) (whole words)', value: undefined },
{ key: 'suspiciousSafeInstantMessages', title: 'Suspicious messages to report (safe only) (instant)', value: undefined },
{
key: 'suspiciousSafeInstantWholeMessages',
title: 'Suspicious messages to report (safe only) (whole words) (instant)',
value: undefined
},
];
max = 100;
value = 0;
error?: string;
succeeded = false;
suspiciousPonies?: string;
suspiciousPoniesError?: string;
ignoreErrors?: string;
account: Account | undefined;
private subscription?: Subscription;
constructor(public model: AdminModel) {
}
ngOnInit() {
this.model.accountPromise.then(() => {
this.resetFields();
this.resetSuspiciousPonies();
});
}
ngOnDestroy() {
this.subscription && this.subscription.unsubscribe();
}
saveFields() {
let settings: any = {};
this.fields.forEach(field => settings[field.key] = field.value);
this.model.updateSettings(settings);
}
resetFields() {
this.fields.map(field => {
field.value = this.model.state.loginServers[0][field.key] as string | undefined;
});
}
saveSuspiciousPonies() {
this.suspiciousPoniesError = undefined;
try {
compact(this.suspiciousPonies!.split(/\n/g).map(x => x.trim())).map(x => JSON.parse(x));
this.model.updateSettings({ suspiciousPonies: this.suspiciousPonies });
} catch (e) {
this.suspiciousPoniesError = e.message;
}
}
resetSuspiciousPonies() {
this.suspiciousPonies = this.model.state.loginServers[0].suspiciousPonies;
}
updatePatreon() {
this.model.server.updatePatreon();
}
updatePatreonToken(patreonToken: string) {
this.model.updateSettings({ patreonToken });
}
getLastPatreonData() {
this.model.getLastPatreonData()
.then(data => {
showTextInNewTab(JSON.stringify(data, null, 2));
});
}
updatePastSupporters() {
this.model.updatePastSupporters();
}
}
@@ -0,0 +1,65 @@
ng-template(#searchTooltip)
.text-left.help-tooltip
div: em search by:
div exact:&lt;name&gt; #[em - exact pony name]
div orphan #[em - list orphaned ponies]
.form-horizontal
input.form-control.form-control-sm.admin-search(type="search" placeholder="search" [(ngModel)]="search")
.btn-group.btn-group-sm.ml-1
button.btn.btn-default((click)="refresh()")
fa-icon([icon]="syncIcon" size="lg")
label.mx-2
| order by:
.btn-group.btn-group-sm(btnRadioGroup [(ngModel)]="orderBy")
button.btn(btnRadio="" [btnHighlight]="!orderBy")
| created
button.btn(btnRadio="-lastUsed" [btnHighlight]="orderBy === '-lastUsed'")
| last used
.pl-1([tooltip]="searchTooltip" placement="bottom" containerClass="tooltip-pre" style="width: 280px;")
small.text-muted
fa-icon.mr-1([icon]="filterIcon")
| filters
.d-block.d-sm-flex.justify-content-end
.table-loader.text-muted.flex-grow-1.text-left(*ngIf="loading")
fa-icon.mr-1([icon]="spinnerIcon" [spin]="true")
| Loading...
strong.text-muted.mr-3(style="margin-top: 6px")
| {{totalItems > 900000000 ? '---' : totalItems}}
pagination(
[totalItems]="totalItems" [itemsPerPage]="itemsPerPage" [(ngModel)]="currentPage" [maxSize]="15"
[directionLinks]="false" [boundaryLinks]="true")
table.table.table-fixed.table-striped.table-hover.table-sm
thead
tr
th.col-time created
th.col-time last used
th.col-pony pony
th.col-account account
th
th(style="width: 80px;")
tbody
tr(*ngFor="let i of items")
td.col-time
time-field([time]="ponyInfo.pony?.createdAt")
td.col-time
time-field([time]="ponyInfo.pony?.lastUsed")
td.col-pony
pony-info-remote(#ponyInfo [ponyId]="i")
td.col-account
account-info-remote(#accountInfo [accountId]="ponyInfo.pony?.account" [showDuplicates]="true")
td
td(style="width: 80px;")
button.btn.btn-xs.btn-danger((click)="remove(i)" title="Remove pony")
fa-icon([icon]="trashIcon" [fixedWidth]="true")
button.btn.btn-xs.btn-default.ml-1(
(click)="$event.shiftKey ? chatLog.add(accountInfo.account) : chatLog.show(accountInfo.account)" title="Show chat")
fa-icon([icon]="commentIcon" [fixedWidth]="true")
admin-chat-log(#chatLog)
.admin-bottom-padding
@@ -0,0 +1,4 @@
.table-loader {
line-height: 33px;
font-size: larger;
}
@@ -0,0 +1,97 @@
import { Component, OnInit } from '@angular/core';
import { isEqual, debounce } from 'lodash';
import { Character, FindPonyQuery } from '../../../common/adminInterfaces';
import { AdminModel } from '../../services/adminModel';
import { faSync, faFilter, faTrash, faComment, faSpinner } from '../../../client/icons';
import { cloneDeep, delay } from '../../../common/utils';
let currentPage = 1;
let query: FindPonyQuery = {};
@Component({
selector: 'admin-ponies',
templateUrl: 'admin-ponies.pug',
styleUrls: ['admin-ponies.scss'],
})
export class AdminPonies implements OnInit {
readonly syncIcon = faSync;
readonly filterIcon = faFilter;
readonly spinnerIcon = faSpinner;
readonly trashIcon = faTrash;
readonly commentIcon = faComment;
items?: string[];
itemsPerPage = 20;
query: FindPonyQuery = {};
loading = false;
private lastQuery: FindPonyQuery = {};
private totalCount?: number;
private execSearch = debounce(() => this.fetchPonies(false), 1000);
constructor(private model: AdminModel) {
}
get totalItems() {
return this.totalCount === undefined ? this.model.counts.characters : this.totalCount;
}
get search() {
return query.search;
}
set search(value: string | undefined) {
if (query.search !== value) {
query.search = value;
this.execSearch();
}
}
get orderBy() {
return query.orderBy;
}
set orderBy(value: string | undefined) {
if (query.orderBy !== value) {
query.orderBy = value;
this.execSearch();
}
}
get currentPage() {
return currentPage;
}
set currentPage(value) {
if (currentPage !== value) {
currentPage = value;
this.fetchPonies(isEqual(this.lastQuery, query));
}
}
ngOnInit() {
if (this.model.connected) {
this.fetchPonies(false);
}
}
refresh() {
this.fetchPonies(false);
}
remove(pony: Character) {
if (confirm('Are you sure?')) {
this.model.removePony(pony._id)
.then(() => delay(500))
.then(() => this.refresh());
}
}
private fetchPonies(skipTotalCount: boolean) {
const thisQuery = cloneDeep(query);
this.lastQuery = cloneDeep(query);
this.loading = true;
thisQuery.search = thisQuery.search && thisQuery.search.trim();
this.model.findPonies(thisQuery, this.currentPage - 1, skipTotalCount)
.then(result => {
if (result) {
if (!skipTotalCount) {
this.totalCount = result.totalCount;
}
this.items = result.items;
} else {
this.items = [];
}
})
.finally(() => this.loading = false);
}
}
@@ -0,0 +1,41 @@
div(style="position: relative;")
div(style="position: absolute; top: -50px; right: 0;")
b.p-2 {{server}}
.btn-group(*ngIf="server")
button.btn.btn-default((click)="resetZoom()")
| Reset zoom
button.btn.btn-default((click)="fitZoom()")
| Fit zoom
button.btn.btn-default((click)="fullFrameZoom()")
| Full frame
button.btn.btn-default.ml-1(*ngIf="server" (click)="load(server)")
| Refresh
.btn-group.dropdown.ml-1(dropdown)
button.btn.btn-default.dropdown-toggle(dropdownToggle)
| Load
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
button.dropdown-item(*ngFor="let s of servers" (click)="load(s)")
| {{s}}
div(#container style="position: relative; overflow: hidden;")
canvas(#canvas (mousemove)="mouseMove($event)" (wheel)="wheel($event)" (agDrag)="drag($event)")
div(#tooltip style="display: none; position: absolute; left: 0; top: 0; padding: 2px 6px; background: #111; border-radius: 2px; font-size: 12px; white-space: pre; pointer-events: none;")
.float-right.text-muted
div total samples: {{timings.length}}
table.table.table-sm(style="font-size: small; width: 1000px;")
thead
tr
th.text-right Self Time
th.text-right Self %
th.text-right Total %
th.text-right Count
th Name
tbody.font-monospace
tr(*ngFor="let l of listing")
td.text-right {{l.selfTime.toFixed(2)}} ms
td.text-right.text-muted {{l.selfPercent.toFixed(2)}} %
td.text-right.text-muted {{l.totalPercent.toFixed(2)}} %
td.text-right.text-muted {{l.count}}
td {{l.name}}
@@ -0,0 +1,291 @@
import { Component, ViewChild, ElementRef } from '@angular/core';
import { AdminModel } from '../../../services/adminModel';
import { TimingEntry, TimingEntryType } from '../../../../common/adminInterfaces';
import { findById, pointInRect, clamp } from '../../../../common/utils';
import { SERVER_FPS } from '../../../../common/constants';
import { AgDragEvent } from '../../../shared/directives/agDrag';
interface Tooltip {
x: number;
y: number;
w: number;
h: number;
text: string;
}
interface ListingEntry {
name: string;
count: number;
selfTime: number;
totalTime: number;
selfPercent: number;
totalPercent: number;
}
const frameTime = 1000 / SERVER_FPS;
const timePadding = 10; // ms
@Component({
selector: 'admin-reports-perf',
templateUrl: 'admin-reports-perf.pug',
})
export class AdminReportsPerf {
@ViewChild('container', { static: true }) container!: ElementRef;
@ViewChild('canvas', { static: true }) canvas!: ElementRef;
@ViewChild('tooltip', { static: true }) tooltip!: ElementRef;
loaded = false;
server = '';
startTime = 0;
endTime = 0;
listing: ListingEntry[] = [];
timings: TimingEntry[] = [];
private tooltips: Tooltip[] = [];
private startTimeFrom = 0;
private endTimeFrom = 0;
private frame: any = 0;
private lastZoom = 0;
constructor(private model: AdminModel) {
if (DEVELOPMENT) {
const interval = setInterval(() => {
if (findById(this.model.state.gameServers, 'dev')) {
clearInterval(interval);
this.load('dev');
}
}, 100);
}
}
get servers() {
return this.model.state.gameServers.map(s => s.id);
}
async load(server: string) {
this.server = server;
this.timings = [];
this.loaded = false;
const result = await this.model.getTimings(server);
if (result) {
this.loaded = true;
this.timings = result as TimingEntry[];
this.setupZoom();
this.recalcListing();
}
this.redraw();
}
mouseMove(e: MouseEvent) {
const rect = this.container.nativeElement.getBoundingClientRect() as DOMRect;
const x = e.pageX - rect.left;
const y = e.pageY - rect.top;
const tooltip = this.tooltips.find(t => pointInRect(x, y, t));
const element = this.tooltip.nativeElement as HTMLElement;
if (tooltip) {
element.style.display = 'block';
element.style.left = `${x + 10}px`;
element.style.top = `${y + 10}px`;
element.innerText = tooltip.text;
} else {
element.style.display = 'none';
}
}
wheel(e: WheelEvent) {
e.preventDefault();
const deltaY = clamp(e.deltaY, -1, 1);
const change = ((this.endTime - this.startTime) * 0.2 * deltaY);
const rect = this.container.nativeElement.getBoundingClientRect() as DOMRect;
const ratioFromLeft = (e.pageX - rect.left) / rect.width;
this.startTime = this.startTime - change * ratioFromLeft;
this.endTime = this.endTime + change * (1 - ratioFromLeft);
this.redraw();
}
setupZoom() {
switch (this.lastZoom) {
case 0:
default:
this.resetZoom();
break;
case 1:
this.fitZoom();
break;
case 2:
this.fullFrameZoom();
break;
}
}
resetZoom() {
const firstTime = this.timings[0].time;
const lastTime = this.timings[this.timings.length - 1].time;
this.startTime = firstTime - timePadding;
this.endTime = lastTime + timePadding;
this.redraw();
this.lastZoom = 0;
}
fitZoom() {
const firstTime = this.timings[0].time;
const lastTime = this.timings[this.timings.length - 1].time;
const totalTime = lastTime - firstTime;
this.startTime = firstTime - totalTime * 0.05;
this.endTime = lastTime + totalTime * 0.05;
this.redraw();
this.lastZoom = 1;
}
fullFrameZoom() {
const firstTime = this.timings[0].time;
const lastTime = firstTime + frameTime;
this.startTime = firstTime - 2;
this.endTime = lastTime + 2;
this.redraw();
this.lastZoom = 2;
}
drag(e: AgDragEvent) {
if (e.type === 'start') {
this.startTimeFrom = this.startTime;
this.endTimeFrom = this.endTime;
}
const scale = (this.endTime - this.startTime) / this.canvas.nativeElement.width;
this.startTime = this.startTimeFrom - e.dx * scale;
this.endTime = this.endTimeFrom - e.dx * scale;
this.redraw();
}
redraw() {
this.frame = this.frame || requestAnimationFrame(() => this.draw());
}
draw() {
this.frame = 0;
const rect = this.container.nativeElement.getBoundingClientRect() as DOMRect;
const canvas = this.canvas.nativeElement as HTMLCanvasElement;
canvas.width = rect.width;
canvas.height = 400;
const context = canvas.getContext('2d')!;
context.fillStyle = '#222';
context.fillRect(0, 0, canvas.width, canvas.height);
this.tooltips.length = 0;
if (!this.timings.length)
return;
const firstTime = this.timings[0].time;
const startTime = this.startTime;
const endTime = this.endTime;
const totalTime = endTime - startTime;
const timeScale = canvas.width / totalTime;
function timeToX(time: number) {
return (time - startTime) * timeScale;
}
function timeToXAligned(time: number) {
return Math.floor(timeToX(time)) + 0.5;
}
context.textBaseline = 'middle';
context.font = 'Arial 14px normal';
// scale
const scaleHeight = 20;
context.strokeStyle = '#666';
context.beginPath();
for (let time = firstTime; time < endTime; time += frameTime) {
context.moveTo(timeToXAligned(time), 0);
context.lineTo(timeToXAligned(time), canvas.height);
}
context.stroke();
context.strokeStyle = '#ddd';
context.beginPath();
context.moveTo(0, scaleHeight + 0.5);
context.lineTo(canvas.width, scaleHeight + 0.5);
context.stroke();
// entries
const rowHeight = 20;
const startStack: TimingEntry[] = [];
for (const entry of this.timings) {
if (entry.type === TimingEntryType.Start) {
startStack.push(entry);
} else {
const start = startStack.pop()!;
const name = start.name!;
const startX = timeToX(start.time);
const endX = timeToX(entry.time);
const y = scaleHeight + 2 + startStack.length * rowHeight;
const w = endX - startX;
let text = name;
const time = entry.time - start.time;
if (startStack.length === 0) {
context.fillStyle = '#efc457';
text = `${text} (${time.toFixed(2)} ms) ${(100 * time / frameTime).toFixed(0)}%`;
} else if (/\(\)$/.test(name)) {
context.fillStyle = '#d4ecc6';
} else {
context.fillStyle = '#c6dcec';
}
context.fillRect(startX, y, w, rowHeight - 1);
this.tooltips.push({ x: startX, y, w, h: rowHeight, text: `${name}\n${time.toFixed(2)} ms` });
if (w > 4) {
context.fillStyle = '#222';
context.fillText(text, startX + 4, y + rowHeight / 2);
}
}
}
}
recalcListing() {
interface Entry extends TimingEntry {
excludedTime: number;
}
this.listing = [];
const startStack: Entry[] = [];
for (const entry of this.timings) {
if (entry.type === TimingEntryType.Start) {
startStack.push({ ...entry, excludedTime: 0 });
} else {
const start = startStack.pop()!;
const name = start.name!;
const time = entry.time - start.time;
let listing = this.listing.find(l => l.name === name);
if (!listing) {
listing = { name, selfTime: 0, totalTime: 0, selfPercent: 0, totalPercent: 0, count: 0 };
this.listing.push(listing);
}
listing.count++;
listing.selfTime += (time - start.excludedTime);
listing.totalTime += time;
if (startStack.length) {
startStack[startStack.length - 1].excludedTime += time;
}
}
}
const firstTime = this.timings[0].time;
const lastTime = this.timings[this.timings.length - 1].time;
const totalTime = lastTime - firstTime;
for (const listing of this.listing) {
listing.selfPercent = 100 * listing.selfTime / totalTime;
listing.totalPercent = 100 * listing.totalTime / totalTime;
}
this.listing.sort((a, b) => b.selfTime - a.selfTime);
}
}
@@ -0,0 +1,4 @@
nav.nav.nav-pills.mb-3
a.nav-item.nav-link(routerLink="/reports/perf" routerLinkActive="active") Perf
router-outlet
@@ -0,0 +1,8 @@
import { Component } from '@angular/core';
@Component({
selector: 'admin-reports',
templateUrl: 'admin-reports.pug',
})
export class AdminReports {
}
@@ -0,0 +1,2 @@
.mx-auto.mt-4(style="max-width: 400px")
sign-in-box
@@ -0,0 +1,8 @@
import { Component } from '@angular/core';
@Component({
selector: 'admin-sign-in',
templateUrl: 'admin-sign-in.pug',
})
export class AdminSignIn {
}
@@ -0,0 +1,161 @@
div(*ngIf="isSuperadmin")
button.btn.btn-sm.btn-default((click)="notifyOfUpdate()")
| Notify of update
button.btn.btn-sm.btn-default.ml-1((click)="shutdownServers()")
| Shutdown servers
button.btn.btn-sm.btn-default.ml-1((click)="resetUpdating()")
| Reset updating
h3.mt-4 Servers
.server-box(*ngFor="let s of loginServers")
.row.form-group
.col-md-4
strong Login server
.col-md-2.pb-1
.badge.badge-none(*ngIf="s.dead") dead
.badge.badge-success(*ngIf="!s.dead && !s.isPageOffline") online
.badge.badge-danger(*ngIf="!s.dead && s.isPageOffline") offline
.badge.badge-warning.ml-1(*ngIf="s.updating") updating
.col-md-6
button.btn.btn-sm.btn-success((click)="updateLoginSettings({ isPageOffline: false })")
| Switch online
button.btn.btn-sm.btn-danger.ml-1((click)="updateLoginSettings({ isPageOffline: true })")
| Switch offline
button.btn.btn-sm.btn-default.ml-1((click)="fetchRequestStats()" title="Show request stats from last hour")
| Stats
.row.form-group
.col-md-3
.col-md-9
.text-left(style="column-width: 280px;")
div(*ngFor="let o of loginOptions")
on-off-switch([on]="s[o.id]" (toggle)="updateLoginSetting(o.id, $event)" [label]="o.label")
.row.form-group(*ngIf="requestStats")
.col-md-12
table.table.table-striped.table-hover.table-sm
thead
tr
th path
th all
th count
th avg
th
button.float-right.btn.btn-default.btn-xs((click)="resetRequestStats()") close
| total
tbody
tr(*ngFor="let s of requestStats")
td.limit-length {{s.path}}
td {{s.totalCount}}
td {{s.count}}
td {{s.average}}
td {{s.total}}
.row.form-group(*ngIf="userCounts")
.col-md-12
table.table.table-striped.table-hover.table-sm
thead
tr
th date
th unique users
tbody
tr(*ngFor="let u of userCounts")
td {{u.date | date:'MMM d'}}
td {{u.count}}
.server-box(*ngFor="let s of servers")
.row.form-group
.col-md-2
strong {{s.name}}
.col-md-2
div
strong.mr-1 {{s.online}}
span.text-muted users
span.text-muted.ml-2(title="Users in queue | on main map") ({{s.queued}} | {{s.onMain}})
strong.mr-1.ml-2 {{s.maps}}
span.text-muted maps
.col-md-2.pb-1
.badge.badge-none(*ngIf="s.dead") dead
.badge.badge-danger(*ngIf="!s.dead && s.settings.isServerOffline") offline
.badge.badge-success(*ngIf="!s.dead && !s.settings.isServerOffline") online
.badge.badge-forbidden.ml-1(*ngIf="s.require") {{s.require}}
.badge.badge-warning.ml-1(*ngIf="s.shutdown") shutdown
.col-md-6
.d-flex
button.btn.btn-sm.btn-success((click)="updateServerSettings(s, { isServerOffline: false })" [disabled]="s.dead")
| Switch online
button.btn.btn-sm.btn-danger.ml-1((click)="updateServerSettings(s, { isServerOffline: true })" [disabled]="s.dead")
| Switch offline
button.btn.btn-sm.btn-danger.ml-1((click)="kickAll(s)" [disabled]="s.dead")
| Kick all
.dropdown.ml-1.d-flex(dropdown)
button.dropdown-toggle.btn.btn-sm.btn-default(dropdownToggle [disabled]="s.dead")
fa-icon([icon]="cogIcon" [fixedWidth]="true")
.dropdown-menu(*dropdownMenu)
button.dropdown-item.btn.btn-sm.btn-default((click)="fetchStats(s)")
| Stats: Socket transfer rates from last hour
button.dropdown-item.btn.btn-sm.btn-default((click)="fetchCountryStats(s)")
| Stats: Online users per country
button.dropdown-item.btn.btn-sm.btn-default((click)="fetchSupportStats(s)")
| Stats: Support
button.dropdown-item.btn.btn-sm.btn-default((click)="fetchMapStats(s)")
| Stats: Maps
button.dropdown-item.btn.btn-sm.btn-default((click)="notifyOfUpdate(s.id)")
| Notify of update
button.dropdown-item.btn.btn-sm.btn-default((click)="shutdownServers(s.id)")
| Shutdown server
button.dropdown-item.btn.btn-sm.btn-default((click)="resetUpdating(s.id)")
| Reset updating
button.btn.btn-sm.btn-default.ml-1(
(click)="showSettings[s.id] = !showSettings[s.id]" [class.active]="showSettings[s.id]"
[disabled]="s.dead" title="Settings")
fa-icon([icon]="optionsIcon")
.row.form-group(*ngIf="s.settings && !s.dead")
.col-md-3.pb-1
.badge.badge-none.mr-1(*ngFor="let f of s.flags | keys")
| {{f}}
.col-md-9(*ngIf="showSettings[s.id]")
.text-left(style="column-width: 280px;")
div(*ngFor="let o of options")
on-off-switch(
[on]="s.settings[o.id]" (toggle)="updateServerSetting(s, o.id, $event)" [disabled]="s.dead" [label]="o.label")
.row.form-group(*ngIf="getStats(s); let stats")
.col-12
table.table.table-striped.table-hover.table-sm
thead
tr
th id
th name
th type
th count (bin)
th count (str)
th average
th
button.float-right.btn.btn-default.btn-xs((click)="resetStats(s)") close
| total
tbody
tr(*ngFor="let a of stats.actions")
th {{a.id}}
th {{a.name}}
td([ngClass]="a.type === 'recv' ? 'text-danger' : 'text-success'") {{a.type}}
td {{a.countBin | number}}
td {{a.countStr | number}}
td {{a.average}}
td {{a.total}}
.row.form-group(*ngIf="getStatsTable(s); let stats")
.col-12
table.table.table-striped.table-hover.table-sm
thead
tr
th(*ngFor="let cell of stats[0]") {{cell}}
th
button.float-right.btn.btn-default.btn-xs((click)="resetStatsTable(s)") close
|
tbody
tr(*ngFor="let row of stats | slice:1")
th {{row[0]}}
td(*ngFor="let cell of row | slice:1") {{cell}}
td
@@ -0,0 +1,14 @@
@import 'partials/variables';
.limit-length {
max-width: 350px;
overflow: hidden;
text-overflow: ellipsis;
}
// .server-box {
// background: #272727;
// border-radius: $border-radius-lg;
// padding: $spacer / 2 $spacer;
// margin-bottom: $spacer / 2;
// }
@@ -0,0 +1,111 @@
import { Component } from '@angular/core';
import {
GameServerState, SERVER_SETTINGS, LOGIN_SERVER_SETTINGS, ServerStats, RequestStats,
UserCountStats, Stats, StatsTable
} from '../../../common/adminInterfaces';
import { hasRole } from '../../../common/accountUtils';
import { AdminModel } from '../../services/adminModel';
import { faCog, faSlidersH } from '../../../client/icons';
@Component({
selector: 'admin-state',
templateUrl: 'admin-state.pug',
styleUrls: ['admin-state.scss'],
})
export class AdminState {
readonly cogIcon = faCog;
readonly optionsIcon = faSlidersH;
readonly loginOptions = LOGIN_SERVER_SETTINGS;
readonly options = SERVER_SETTINGS;
private stats = new Map<string, ServerStats>();
private statsTables = new Map<string, StatsTable>();
requestStats?: RequestStats[];
userCounts?: UserCountStats[];
showSettings: any = {};
constructor(private model: AdminModel) {
}
get state() {
return this.model.state;
}
get loginServers() {
return this.model.state.loginServers;
}
get servers() {
return this.model.state.gameServers;
}
get isSuperadmin() {
return hasRole(this.model.account, 'superadmin');
}
kickAll(server: GameServerState) {
return this.model.kickAll(server.id);
}
updateLoginSetting(key: string, value: boolean) {
return this.model.updateSettings({ [key]: value });
}
updateLoginSettings(state: any) {
return this.model.updateSettings(state);
}
updateServerSetting(server: GameServerState, key: string, value: boolean) {
return this.model.updateGameServerSettings(server.id, { [key]: value });
}
updateServerSettings(server: GameServerState, state: any) {
return this.model.updateGameServerSettings(server.id, state);
}
// request stats
fetchRequestStats() {
this.model.getRequestStats()
.then(stats => {
this.requestStats = stats && stats.requests;
this.userCounts = stats && stats.userCounts;
});
}
resetRequestStats() {
this.requestStats = undefined;
this.userCounts = undefined;
}
// socket stats
fetchStats(server: GameServerState) {
return this.model.fetchServerStats(server.id)
.then(stats => stats && this.stats.set(server.id, stats));
}
resetStats(server: GameServerState) {
this.stats.delete(server.id);
}
getStats(server: GameServerState) {
return this.stats.get(server.id);
}
// stats tables
fetchCountryStats(server: GameServerState) {
return this.fetchStatsTable(server, Stats.Country);
}
fetchSupportStats(server: GameServerState) {
return this.fetchStatsTable(server, Stats.Support);
}
fetchMapStats(server: GameServerState) {
return this.fetchStatsTable(server, Stats.Maps);
}
resetStatsTable(server: GameServerState) {
this.statsTables.delete(server.id);
}
getStatsTable(server: GameServerState) {
return this.statsTables.get(server.id);
}
private fetchStatsTable(server: GameServerState, stats: Stats) {
return this.model.fetchServerStatsTable(server.id, stats)
.then(stats => stats && this.statsTables.set(server.id, stats));
}
// updates
notifyOfUpdate(server = '*') {
if (confirm('Are you sure?')) {
this.model.notifyUpdate(server);
}
}
shutdownServers(server = '*') {
if (confirm('Are you sure?')) {
this.model.shutdownServers(server);
}
}
resetUpdating(server = '*') {
this.model.resetUpdating(server);
}
}
+139
View File
@@ -0,0 +1,139 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { HttpClientModule } from '@angular/common/http';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { TooltipModule } from 'ngx-bootstrap/tooltip';
import { PopoverModule } from 'ngx-bootstrap/popover';
import { PaginationModule } from 'ngx-bootstrap/pagination';
import { ButtonsModule } from 'ngx-bootstrap/buttons';
import { ModalModule } from 'ngx-bootstrap/modal';
import { SharedModule } from '../shared/shared.module';
import { KeysPipe } from './pipes/keysPipe';
import { OrderByPipe } from './pipes/orderByPipe';
import { TranslitPipe } from './pipes/translitPipe';
import { EventsTable } from './shared/events-table/events-table';
import { AccountInfo } from './shared/account-info/account-info';
import { AccountInfoRemote } from './shared/account-info-remote/account-info-remote';
import { AccountStatus } from './shared/account-status/account-status';
import { AccountTooltip } from './shared/account-tooltip/account-tooltip';
import { OriginInfo } from './shared/origin-info/origin-info';
import { OriginInfoRemote } from './shared/origin-info-remote/origin-info-remote';
import { OriginListRemote } from './shared/origin-list-remote/origin-list-remote';
import { PonyInfo } from './shared/pony-info/pony-info';
import { PonyInfoRemote } from './shared/pony-info-remote/pony-info-remote';
import { PonyListRemote } from './shared/pony-list-remote/pony-list-remote';
import { AuthInfo } from './shared/auth-info/auth-info';
import { AuthInfoRemote } from './shared/auth-info-remote/auth-info-remote';
import { AuthInfoEdit } from './shared/auth-info-edit/auth-info-edit';
import { AuthList } from './shared/auth-list/auth-list';
import { AuthListRemote } from './shared/auth-list-remote/auth-list-remote';
import { OnOffSwitch } from './shared/on-off-switch/on-off-switch';
import { AdminChatLog } from './shared/admin-chat-log/admin-chat-log';
import { BanIcon } from './shared/ban-icon/ban-icon';
import { EmailList } from './shared/email-list/email-list';
import { FromNow } from './shared/from-now';
import { TimeField } from './shared/time-field/time-field';
import { UAInfo } from './shared/ua-info/ua-info';
import { AdminState } from './admin-state/admin-state';
import { AdminEvents } from './admin-events/admin-events';
import { AdminOther } from './admin-other/admin-other';
import { AdminAccounts } from './admin-accounts/admin-accounts';
import { AdminAccountDetails } from './admin-account-details/admin-account-details';
import { AdminPonies } from './admin-ponies/admin-ponies';
import { AdminReports } from './admin-reports/admin-reports';
import { AdminReportsPerf } from './admin-reports/admin-reports-perf/admin-reports-perf';
import { AdminOrigins } from './admin-origins/admin-origins';
import { AdminOriginDetails } from './admin-origin-details/admin-origin-details';
import { AdminSignIn } from './admin-sign-in/admin-sign-in';
import { AdminApp } from './admin';
import { ErrorReporter } from '../services/errorReporter';
export const routes: Routes = [
{ path: '', component: AdminState },
{ path: 'sign-in', component: AdminSignIn },
{ path: 'events', component: AdminEvents },
{ path: 'accounts', component: AdminAccounts },
{ path: 'accounts/:id', component: AdminAccountDetails },
{ path: 'ponies', component: AdminPonies },
{ path: 'origins', component: AdminOrigins },
{ path: 'origins/:ip/:country', component: AdminOriginDetails },
{
path: 'reports',
component: AdminReports,
children: [
{ path: '', redirectTo: 'perf', pathMatch: 'full' },
{ path: 'perf', component: AdminReportsPerf },
],
},
{ path: 'other', component: AdminOther },
];
@NgModule({
imports: [
BrowserModule,
RouterModule,
FormsModule,
HttpClientModule,
PopoverModule.forRoot(),
PaginationModule.forRoot(),
ButtonsModule.forRoot(),
ModalModule,
TooltipModule,
SharedModule,
RouterModule.forRoot(routes),
FontAwesomeModule,
],
declarations: [
KeysPipe,
OrderByPipe,
TranslitPipe,
EventsTable,
AccountInfo,
AccountInfoRemote,
AccountStatus,
AccountTooltip,
OriginInfo,
OriginInfoRemote,
OriginListRemote,
PonyInfo,
PonyInfoRemote,
PonyListRemote,
AuthInfo,
AuthInfoRemote,
AuthInfoEdit,
AuthList,
AuthListRemote,
OnOffSwitch,
AdminChatLog,
BanIcon,
EmailList,
FromNow,
TimeField,
UAInfo,
AdminState,
AdminEvents,
AdminOther,
AdminAccounts,
AdminAccountDetails,
AdminPonies,
AdminReports,
AdminReportsPerf,
AdminOrigins,
AdminOriginDetails,
AdminSignIn,
AdminApp,
],
providers: [
ErrorReporter,
],
bootstrap: [AdminApp],
})
export class AdminAppModule {
}
+24
View File
@@ -0,0 +1,24 @@
.container-fluid
.d-flex.justify-content-between
.admin-initializing
div(*ngIf="loading")
fa-icon.mr-1([icon]="spinnerIcon" [spin]="true")
| {{loading}}...
menu-bar([loading]="model.loading" [account]="model.account" (signOut)="signOut()")
menu-item(route="/" name="State ({{clients}})" [icon]="stateIcon")
menu-item(route="/events" name="Events ({{events}})" [icon]="eventsIcon")
menu-item(route="/accounts" name="Accounts ({{accounts}})" [icon]="accountsIcon")
menu-item(route="/ponies" name="Ponies ({{ponies}})" [icon]="poniesIcon")
menu-item(route="/origins" name="Origins ({{origins}})" [icon]="originsIcon")
menu-item(route="/reports" name="Reports" *ngIf="isSuperadmin" [icon]="reportsIcon")
menu-item(route="/other" name="Other" *ngIf="isSuperadmin" [icon]="otherIcon")
.admin-view.mt-5
router-outlet
.admin-error(*ngIf="model.error")
.alert.alert-danger
button.close.ml-2((click)="model.error = null") &times;
| {{model.error}}
+14
View File
@@ -0,0 +1,14 @@
@import 'partials/variables';
.admin-initializing {
margin: 15px;
font-size: larger;
color: $text-muted;
}
.admin-error {
position: fixed;
left: 15px;
top: 10px;
width: 400px;
}
+75
View File
@@ -0,0 +1,75 @@
import { Component, HostListener } from '@angular/core';
import { Router } from '@angular/router';
import { TooltipConfig } from 'ngx-bootstrap/tooltip';
import { PopoverConfig } from 'ngx-bootstrap/popover';
import { hasRole } from '../../common/accountUtils';
import { AdminModel } from '../services/adminModel';
import {
faSpinner, faSlidersH, faExclamationCircle, faUsers, faHorseHead, faMapMarkerAlt, faChartPie, faCog
} from '../../client/icons';
export function tooltipConfig() {
return Object.assign(new TooltipConfig(), { container: 'body' });
}
export function popoverConfig() {
return Object.assign(new PopoverConfig(), { container: 'body' });
}
@Component({
selector: 'pony-town-app',
templateUrl: 'admin.pug',
styleUrls: ['admin.scss'],
providers: [
{ provide: TooltipConfig, useFactory: tooltipConfig },
{ provide: PopoverConfig, useFactory: popoverConfig },
]
})
export class AdminApp {
readonly spinnerIcon = faSpinner;
readonly stateIcon = faSlidersH;
readonly eventsIcon = faExclamationCircle;
readonly accountsIcon = faUsers;
readonly poniesIcon = faHorseHead;
readonly originsIcon = faMapMarkerAlt;
readonly reportsIcon = faChartPie;
readonly otherIcon = faCog;
constructor(public model: AdminModel, private router: Router) {
}
get loading() {
if (!this.model.initialized) {
return 'Initializing';
} else if (!this.model.connected) {
return 'Connecting';
} else if (!this.model.loaded) {
return 'Loading';
} else {
return '';
}
}
get clients() {
return this.model.state.gameServers.reduce((sum, s) => sum + s.online, 0);
}
get events() {
return this.model.events.length;
}
get accounts() {
return this.model.counts.accounts;
}
get ponies() {
return this.model.counts.characters;
}
get origins() {
return this.model.counts.origins;
}
get isSuperadmin() {
return hasRole(this.model.account, 'superadmin');
}
@HostListener('window:go-to-account', ['$event'])
goToAccount({ detail }: CustomEvent) {
this.router.navigate(['/accounts', detail]);
}
signOut() {
window.location.href = '/';
}
}
+100
View File
@@ -0,0 +1,100 @@
import { debounce } from 'lodash';
export interface BaseTableState {
search: string;
currentPage: number;
sortedBy: string;
sortedAsc: boolean;
}
export abstract class BaseTable<T> {
itemsPerPage = 20;
sorted: T[] = [];
filtered: T[] = [];
filteredOnPage: T[] = [];
sortedBy = 'createdAt';
sortedAsc = true;
private _search = '';
private _currentPage = 1;
private execSearch = debounce(() => {
this.updateFiltered();
this.onChange();
}, 500);
get itemsFrom() {
return (this.currentPage - 1) * this.itemsPerPage;
}
get items(): T[] {
return [];
}
get search() {
return this._search;
}
set search(value: string) {
if (this._search !== value) {
this._search = value;
this.execSearch();
}
}
get currentPage() {
return this._currentPage;
}
set currentPage(value: number) {
if (this._currentPage !== value) {
this._currentPage = value;
this.updatePage();
this.onChange();
}
}
sortBy(field: string) {
if (this.sortedBy === field) {
this.sortedAsc = !this.sortedAsc;
} else {
this.sortedBy = field;
this.sortedAsc = true;
}
this.updateSorted();
this.onChange();
}
sortedClass(field: string) {
return this.sortedBy === field ? (this.sortedAsc ? 'sorted-asc' : 'sorted-desc') : undefined;
}
protected updateItems() {
this.updateSorted();
}
protected updateSorted() {
this.sorted = this.sortItems(this.items, this.sortedBy, this.sortedAsc);
this.updateFiltered();
}
protected updateFiltered() {
this.filtered = this.filterItems(this.sorted, this.search);
this.updatePage();
}
protected updatePage() {
this.filteredOnPage = this.filtered.slice(this.itemsFrom, this.itemsFrom + this.itemsPerPage);
}
protected sortItems(items: T[], _by: string, _asc: boolean) {
return items;
}
protected filterItems(items: T[], _search: string) {
return items;
}
protected onChange() {
}
protected getState(): BaseTableState {
return {
search: this.search,
currentPage: this.currentPage,
sortedBy: this.sortedBy,
sortedAsc: this.sortedAsc,
};
}
protected setState(state: BaseTableState | undefined) {
if (state) {
this._search = state.search;
this._currentPage = state.currentPage || 1;
this.sortedBy = state.sortedBy;
this.sortedAsc = state.sortedAsc;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'keys',
})
export class KeysPipe implements PipeTransform {
transform(value: any) {
return value ? Object.keys(value) : value;
}
}
@@ -0,0 +1,10 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'orderBy',
})
export class OrderByPipe implements PipeTransform {
transform(value: any[] | undefined, compare?: any) {
return value && value.slice().sort(compare);
}
}
@@ -0,0 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core';
import { transliterate } from 'transliteration';
@Pipe({
name: 'translit',
})
export class TranslitPipe implements PipeTransform {
transform(value: string) {
if (!value || /^[a-z0-9-_.,\[\]!@#$%^&*{}|\/\\ ]+$/i.test(value))
return undefined;
const translit = transliterate(value);
return translit !== value ? translit : undefined;
}
}
@@ -0,0 +1,5 @@
account-info(
*ngIf="!basic" [account]="account" [extendedAuths]="extendedAuths" [popoverPlacement]="popoverPlacement"
[showDuplicates]="showDuplicates")
span(*ngIf="basic")
| {{account?.name || '[none]'}} [{{account?._id | slice:-3}}]
@@ -0,0 +1,44 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { Account } from '../../../../common/adminInterfaces';
import { Subscription } from '../../../../common/interfaces';
import { AdminModel } from '../../../services/adminModel';
@Component({
selector: 'account-info-remote',
templateUrl: 'account-info-remote.pug',
})
export class AccountInfoRemote implements OnDestroy {
@Input() extendedAuths = false;
@Input() popoverPlacement?: string;
@Input() showDuplicates = false;
@Input() basic = false;
account?: Account;
private _accountId?: string;
private subscription?: Subscription;
constructor(private model: AdminModel) {
}
ngOnDestroy() {
this.subscription && this.subscription.unsubscribe();
}
get accountId() {
return this._accountId;
}
@Input() set accountId(value) {
if (this._accountId !== value) {
this._accountId = value;
this.account = undefined;
this.updateSubscription();
}
}
private updateSubscription() {
if (this.subscription) {
this.subscription.unsubscribe();
this.subscription = undefined;
}
if (this.accountId) {
this.subscription = this.model.accounts
.subscribe(this.accountId, account => this.account = account);
}
}
}
@@ -0,0 +1,179 @@
ng-template(#notePopover)
textarea.form-control.note-editor(
cols="20" rows="15" agAutoFocus [(ngModel)]="note" (keydown.escape)="blur()" (blur)="blur()")
ng-template(#accountTooltip)
account-tooltip([account]="account" [extendedAuths]="extendedAuths")
ng-template(#noteTooltip)
small.text-muted
from-now([time]="account.noteUpdated")
div {{account.note}}
ng-template(#alertTooltip)
.text-left
small.text-muted
| expires in
from-now.ml-1([time]="alert.expires")
div {{alert.message}}
ng-template(#supporterTooltip)
div {{supporterTitle}}
.text-muted(*ngIf="account.supporterDeclinedSince as declined")
| declined: #[from-now([time]="declined")] ago | {{declined | date:'MMM d'}}
ng-template(#flagsTooltip)
.text-left
div(*ngFor="let c of counters")
span.text-muted.mr-1 {{c.label}}:
span {{getCounter(c.name)}}
br(*ngIf="account.flags || account.supporter")
div(*ngFor="let f of flags")
span(*ngIf="hasFlag(f.value)")
fa-icon.mr-1([icon]="checkIcon")
| {{f.label}}
div(*ngFor="let f of supporterFlags")
span(*ngIf="hasSupporterFlag(f.value)")
fa-icon.mr-1([icon]="checkIcon")
| {{f.label}}
ng-template(#alertModal)
.modal-header.d-none.d-sm-block
h4.modal-title(labelledBy=".modal")
| Account Alert
.modal-body.modal-checkboxes
.form-group
label Predefined:
.btn-group.ml-2
button.btn.btn-sm.btn-default(*ngFor="let a of predefinedAlerts" (click)="alertMessage = a.message")
| {{a.name}}
.form-group
textarea.form-control([(ngModel)]="alertMessage" autofocus rows="4")
.form-group
label Expire in
.btn-group.ml-2(btnRadioGroup [(ngModel)]="alertExpire")
button.btn.btn-sm.btn-default(*ngFor="let e of alertExpires" [btnRadio]="e" [btnHighlight]="alertExpire === e")
| {{e.name}}
.modal-footer.d-flex
.flex-grow-1
button.btn.btn-outline-secondary((click)="cancelAlert()")
| Cancel
button.btn.btn-outline-secondary.ml-2((click)="confirmAlert()")
| Set
//- component
.account-info(*ngIf="account" [class.outdated]="isInactive")
span.badge.badge-admin.badge-duplicates.mr-1(
*ngIf="showDuplicates && duplicates" title="Duplicates" [class.none]="duplicates.count === 0")
| {{duplicates.count}}
fa-icon.ml-1(*ngIf="duplicates.browserId" [icon]="duplicateBrowserIdIcon")
fa-icon.ml-1(*ngIf="duplicates.emails" [icon]="duplicateEmailIcon")
fa-icon.ml-1(*ngIf="duplicates.name" [icon]="duplicateNameIcon")
fa-icon.ml-1(*ngIf="duplicates.perma" [icon]="duplicatePermaIcon")
a.account-info-name.mr-1(
[routerLink]="['/accounts', account._id]"
[tooltip]="accountTooltip"
[placement]="popoverPlacement || 'top'"
containerClass="tooltip-pre tooltip-account")
| {{account.name || '[none]'}} [{{account._id | slice:-3}}]
span.badge.badge-admin.badge-none(*ngFor="let r of roles" title="role")
| {{r}}
//-span.badge.badge-admin.badge-primary([title]="account.birthdate | date:'yyyy-MM-dd'")
| {{age}}
span.badge.badge-admin.badge-supporter(
*ngIf="isPatreonOrSupporter"
[ngClass]="supporterClass"
[class.declined]="account.supporterDeclinedSince && supporterLevel"
[tooltip]="supporterTooltip")
fa-icon([icon]="supporterIcon" [fixedWidth]="true")
| {{supporterLevelString}}
span.badge.badge-admin.badge-supporter.badge-success(*ngIf="account.totalPledged" title="lifetime support")
| ${{account.totalPledged}}
span.badge.badge-admin.badge-danger(*ngIf="account.ignoresCount" title="ignored by")
| {{account.ignoresCount}}
fa-icon.text-warning(*ngIf="isNew" [icon]="newIcon" [fixedWidth]="true" title="new account")
a.icon-button.cursor-pointer(
[class.pointer-none]="isNoteOpen"
[popover]="notePopover"
[isOpen]="isNoteOpen"
[placement]="popoverPlacement || 'right'"
(onShown)="isNoteOpen = true"
(onHidden)="isNoteOpen = false"
containerClass="popover-notes")
fa-icon.text-muted(
[icon]="noteIcon"
[fixedWidth]="true"
[class.text-present]="account.note"
[tooltip]="isNoteOpen ? null : noteTooltip"
[isDisabled]="!account.note || isNoteOpen"
[placement]="popoverPlacement || 'top'"
containerClass="tooltip-notes")
span.account-info-note-indicator.pointer-none(*ngIf="hasDuplicateNote") d
span.dropdown(dropdown autoClose="outsideClick")
a.icon-button.cursor-pointer.dropdown-toggle.no-arrow(dropdownToggle [tooltip]="flagsTooltip")
fa-icon([icon]="flagIcon" [fixedWidth]="true" [ngClass]="flagClass")
.dropdown-menu(*dropdownMenu [class.dropdown-menu-right]="!popoverPlacement")
a.dropdown-item(*ngFor="let f of flags" (click)="toggleFlag(f.value)")
fa-icon.mr-1([icon]="checkIcon" [fixedWidth]="true" [class.invisible]="!hasFlag(f.value)")
| {{f.label}}
.dropdown-divider
a.dropdown-item(*ngFor="let f of supporterFlags" (click)="toggleSupporterFlag(f.value)")
fa-icon.mr-1([icon]="checkIcon" [fixedWidth]="true" [class.invisible]="!hasSupporterFlag(f.value)")
| {{f.label}}
.dropdown-item
.d-flex.justify-content-between
div
fa-icon.mr-1([icon]="checkIcon" [fixedWidth]="true" [class.invisible]="!hasAnySupporter")
| supporter
.btn-group.supporter-buttons
button.btn.btn-xs.btn-info([class.active]="isSupporter(0)" (click)="setSupporter(0)") none
button.btn.btn-xs.btn-info([class.active]="isSupporter(1)" (click)="setSupporter(1)") 1
button.btn.btn-xs.btn-info([class.active]="isSupporter(2)" (click)="setSupporter(2)") 2
button.btn.btn-xs.btn-info([class.active]="isSupporter(3)" (click)="setSupporter(3)") 3
.dropdown-item
.d-flex.justify-content-between
div
fa-icon.mr-1([icon]="checkIcon" [fixedWidth]="true" [class.invisible]="!hasPastSupporter")
| past supporter
.btn-group.supporter-buttons
button.btn.btn-xs.btn-info([class.active]="isForcePastSupporter" (click)="toggleForcePastSupporter()") force
button.btn.btn-xs.btn-info([class.active]="isIgnorePastSupporter" (click)="toggleIgnorePastSupporter()") ignore
.dropdown-divider
.dropdown-item.counter-item(*ngFor="let c of counters")
span.text-muted {{c.label}}:
span.counter-value {{getCounter(c.name)}}
button.btn.btn-xs.btn-info((click)="decrementCounter(c.name)")
fa-icon([icon]="minusIcon" [fixedWidth]="true")
button.btn.btn-xs.btn-info((click)="incrementCounter(c.name)")
fa-icon([icon]="plusIcon" [fixedWidth]="true")
span.dropdown(dropdown)
a.icon-button.cursor-pointer.dropdown-toggle.no-arrow(dropdownToggle [tooltip]="alert ? alertTooltip : undefined")
fa-icon.text-muted([icon]="cogIcon" [fixedWidth]="true" [class.text-present]="alert")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
a.dropdown-item((click)="kick()")
| kick
a.dropdown-item((click)="report()")
| report
.dropdown-divider
a.dropdown-item((click)="removeAlert()")
| remove alert
a.dropdown-item((click)="setAlert()")
| set alert
a.icon-button.cursor-pointer(title="Teleport to" (click)="teleportTo()")
fa-icon.text-muted([icon]="teleportIcon" [fixedWidth]="true")
ban-icon((toggle)="toggleBan('mute', $event)" [value]="account.mute" type="mute")
ban-icon.ml-ban((toggle)="toggleBan('shadow', $event)" [value]="account.shadow" type="shadow")
ban-icon.ml-ban((toggle)="toggleBan('ban', $event)" [value]="account.ban" type="ban")
@@ -0,0 +1,98 @@
@import 'partials/variables';
.account-info {
display: flex;
align-items: center;
white-space: nowrap;
flex-wrap: wrap;
}
.account-info-name {
.outdated & {
opacity: 0.6;
}
.table & {
display: inline-block;
max-width: 130px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
}
.account-info-note-indicator {
position: absolute;
left: 0;
top: 2px;
color: $body-bg;
font-weight: bold;
text-align: center;
display: inline-block;
width: 100%;
font-size: 10px;
text-shadow: none;
}
.badge-supporter {
padding-left: 3px;
padding-right: 3px;
&.declined {
border-right: solid 5px $warning;
}
> i {
font-size: 10px;
}
}
.badge-duplicates {
@include badge-variant(#bf6060);
&.none {
@include badge-variant(#63a367);
}
}
.icon-button {
position: relative;
&:hover {
filter: drop-shadow(0 0 2px #000);
}
}
.supporter-buttons .btn {
min-width: 25px;
}
.counter-item {
margin-bottom: 5px;
> .text-muted {
display: inline-block;
width: 80px;
}
> .counter-value {
display: inline-block;
font-weight: bold;
width: 60px;
padding-left: 5px;
}
> button {
margin-right: 5px;
width: 35px;
}
}
.text-present, .text-muted.text-present {
color: #fff !important;
}
.ml-ban {
margin-left: 2px;
}
@@ -0,0 +1,292 @@
import { Component, Input, OnChanges, SimpleChanges, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { BsModalService, BsModalRef } from 'ngx-bootstrap/modal';
import { compact } from 'lodash';
import { DAY, MINUTE, HOUR } from '../../../../common/constants';
import { hasFlag, fromNow, toInt, setFlag } from '../../../../common/utils';
import {
Account, AccountFlags, accountCounters, accountFlags, SupporterFlags, supporterFlags, BannedMuted, DuplicatesInfo
} from '../../../../common/adminInterfaces';
import { supporterLevel, patreonSupporterLevel, getAge, isPastSupporter } from '../../../../common/adminUtils';
import { AdminModel } from '../../../services/adminModel';
import { AccountCounters } from '../../../../common/interfaces';
import {
faPatreon, faCog, faMinus, faPlus, faCheck, faFlag, faStickyNote, faCertificate, faIdBadge, faEnvelope, faFont,
faClock, faBan, faMapMarkerAlt
} from '../../../../client/icons';
const EMPTY_ROLES: string[] = [];
const oldTime = fromNow(-14 * DAY).getTime();
const newTime = fromNow(-DAY).getTime();
const accountDuplicatesIntervalTime = 10 * MINUTE;
const accountDuplicates = new Map<string, DuplicatesInfo>();
const predefinedAlerts = [
{
name: 'erp:flagged',
message: `Your account has been flagged for inappropriate bahavior on PG rated server. `
+ `Continuing that behavior may result in permanent ban.`
},
{
name: 'erp:timeout',
message: `Your account has been timed out for inappropriate language and bahavior on PG rated server. `
+ `Continuing that behavior may result in permanent ban.`
},
{
name: 'dups',
message: `Your account has been flagged for making multiple accounts. `
+ `Continuing that behavior may result in permanent ban.`
},
{
name: 'under',
message: `Your account has been reported for being underage, please do NOT play on 18+ server. `
+ `Continuing that may result in permanent ban.`
},
];
const alertExpires = [
{ name: '1h', length: HOUR },
{ name: '5h', length: 5 * HOUR },
{ name: '12h', length: 12 * HOUR },
{ name: '1d', length: DAY },
{ name: '2d', length: 2 * DAY },
{ name: '5d', length: 5 * DAY },
{ name: '7d', length: 7 * DAY },
{ name: '2w', length: 14 * DAY },
];
@Component({
selector: 'account-info',
templateUrl: 'account-info.pug',
styleUrls: ['account-info.scss'],
})
export class AccountInfo implements OnInit, OnChanges {
readonly counters = accountCounters;
readonly flags = accountFlags;
readonly supporterFlags = supporterFlags;
readonly cogIcon = faCog;
readonly minusIcon = faMinus;
readonly plusIcon = faPlus;
readonly checkIcon = faCheck;
readonly flagIcon = faFlag;
readonly noteIcon = faStickyNote;
readonly newIcon = faCertificate;
readonly duplicateBrowserIdIcon = faIdBadge;
readonly duplicateEmailIcon = faEnvelope;
readonly duplicateNameIcon = faFont;
readonly duplicatePermaIcon = faBan;
readonly teleportIcon = faMapMarkerAlt;
predefinedAlerts = predefinedAlerts;
alertExpires = alertExpires;
alertExpire = alertExpires[3];
alertMessage = '';
@Input() account!: Account;
@Input() extendedAuths = false;
@Input() popoverPlacement?: string;
@Input() showDuplicates = false;
@ViewChild('alertModal', { static: true }) alertModal!: TemplateRef<any>;
note?: string;
duplicates?: DuplicatesInfo;
private alertModalRef?: BsModalRef;
private _isNoteOpen = false;
constructor(private model: AdminModel, private modalService: BsModalService) {
}
ngOnInit() {
this.updateDuplicates();
}
ngOnChanges(changes: SimpleChanges) {
if (changes.account) {
this.updateDuplicates();
}
}
get age() {
return this.account.birthdate ? getAge(this.account.birthdate) : '-';
}
get alert() {
const alert = this.account.alert;
return (alert && alert.expires.getTime() > Date.now()) ? alert : undefined;
}
get isNew() {
return this.account.createdAt && this.account.createdAt.getTime() > newTime;
}
get isNoteOpen() {
return this._isNoteOpen;
}
set isNoteOpen(value: boolean) {
if (this._isNoteOpen !== value) {
this._isNoteOpen = value;
if (value) {
this.note = this.account.note;
}
}
}
get isInactive() {
return this.account && this.account.lastVisit && this.account.lastVisit.getTime() < oldTime;
}
get roles() {
return this.account.roles ? this.account.roles.filter(r => r !== 'superadmin') : EMPTY_ROLES;
}
get flagClass() {
const counters = this.account.counters;
if (this.account.flags) {
return 'text-banned';
} else if (!counters && !this.account.supporter) {
return 'text-muted';
} else if (counters && ((counters.spam || 0) > 100 || (counters.swears || 0) > 10)) {
return 'text-alert';
} else {
return 'text-present';
}
}
get hasDuplicateNote() {
return this.account.note && /duplicate/i.test(this.account.note);
}
hasFlag(value: AccountFlags) {
return hasFlag(this.account.flags, value);
}
toggleFlag(value: AccountFlags) {
this.model.setAccountFlags(this.account._id, this.account.flags ^ value);
}
toggleBan(field: keyof BannedMuted, value: number) {
this.model.setAccountBanField(this.account._id, field, value);
}
kick() {
this.model.kick(this.account._id);
}
report() {
this.model.report(this.account._id);
}
blur() {
this.model.setNote(this.account._id, this.note || '');
this.isNoteOpen = false;
}
decrementCounter(name: keyof AccountCounters) {
if (this.getCounter(name) > 0) {
this.setCounter(name, this.getCounter(name) - 1);
}
}
incrementCounter(name: keyof AccountCounters) {
this.setCounter(name, this.getCounter(name) + 1);
}
getCounter(name: keyof AccountCounters) {
const counters = this.account.counters;
return toInt(counters && counters[name]);
}
setCounter(name: keyof AccountCounters, value: number) {
const counters = this.account.counters || (this.account.counters = {});
counters[name] = value;
this.model.setAccountCounter(this.account._id, name, value);
}
removeAlert() {
this.model.setAlert(this.account._id, '', 0);
}
setAlert() {
this.alertMessage = this.alert ? this.alert.message : '';
this.alertExpire = this.alertExpires[2];
this.alertModalRef = this.modalService.show(this.alertModal, { ignoreBackdropClick: true });
}
cancelAlert() {
this.alertModalRef && this.alertModalRef.hide();
this.alertModalRef = undefined;
}
confirmAlert() {
this.model.setAlert(this.account._id, this.alertMessage, this.alertExpire.length);
this.cancelAlert();
}
private updateDuplicates() {
const account = this.account;
if (this.showDuplicates && account) {
const cached = accountDuplicates.get(account._id);
const threshold = fromNow(-accountDuplicatesIntervalTime);
if (cached && cached.generatedAt > threshold.getTime()) {
this.duplicates = cached;
} else {
this.model.getAllDuplicatesQuickInfo(account._id)
.then(duplicates => {
if (duplicates) {
accountDuplicates.set(account._id, duplicates);
this.duplicates = duplicates;
}
});
}
}
}
teleportTo() {
this.model.teleportTo(this.account._id);
}
// supporters
get isPatreonOrSupporter() {
return !!(this.account.patreon || this.account.supporter || this.account.supporterDeclinedSince);
}
get supporterClass() {
return supporterLevel(this.account) ? 'badge-success' : 'badge-warning';
}
get supporterTitle() {
const supporter = this.account.supporter!;
const flagSupporter = (supporter & SupporterFlags.SupporterMask) !== 0;
const patreonSupporter = patreonSupporterLevel(this.account);
const ignorePatreon = hasFlag(supporter, SupporterFlags.IgnorePatreon);
const pastSupporter = hasFlag(supporter, SupporterFlags.PastSupporter);
return compact([
flagSupporter && 'flags',
patreonSupporter && `patreon`,
ignorePatreon && 'ignore',
!patreonSupporter && this.account.supporterDeclinedSince && 'declined',
pastSupporter && 'past',
]).join(', ');
}
get supporterIcon() {
const hasPatreon = patreonSupporterLevel(this.account);
const hasIgnoreFlag = hasFlag(this.account.supporter, SupporterFlags.IgnorePatreon);
const hasDeclined = !!this.account.supporterDeclinedSince;
return hasPatreon ? faPatreon : ((hasIgnoreFlag || !hasDeclined) ? faFlag : faClock);
}
get hasAnySupporter() {
return (this.account.supporter! & SupporterFlags.SupporterMask) !== 0;
}
get hasPastSupporter() {
return hasFlag(this.account.supporter, SupporterFlags.PastSupporter);
}
get supporterLevel() {
return supporterLevel(this.account);
}
get supporterLevelString() {
const level = supporterLevel(this.account);
return level ? level : (isPastSupporter(this.account) ? 'P' : '');
}
isSupporter(level: number) {
return (this.account.supporter! & SupporterFlags.SupporterMask) === level;
}
setSupporter(level: number) {
const supporter = (this.account.supporter! & ~SupporterFlags.SupporterMask) | level;
this.model.setSupporterFlags(this.account._id, supporter);
}
hasSupporterFlag(value: SupporterFlags) {
return hasFlag(this.account.supporter, value);
}
toggleSupporterFlag(value: SupporterFlags) {
this.model.setSupporterFlags(this.account._id, this.account.supporter! ^ value);
}
// past supporter
get isForcePastSupporter() {
return hasFlag(this.account.supporter, SupporterFlags.ForcePastSupporter);
}
get isIgnorePastSupporter() {
return hasFlag(this.account.supporter, SupporterFlags.IgnorePastSupporter);
}
toggleForcePastSupporter() {
const has = hasFlag(this.account.supporter, SupporterFlags.ForcePastSupporter);
const supporter = setFlag(this.account.supporter, SupporterFlags.ForcePastSupporter, !has);
this.model.setSupporterFlags(this.account._id, supporter);
}
toggleIgnorePastSupporter() {
const has = hasFlag(this.account.supporter, SupporterFlags.IgnorePastSupporter);
const supporter = setFlag(this.account.supporter, SupporterFlags.IgnorePastSupporter, !has);
this.model.setSupporterFlags(this.account._id, supporter);
}
}
@@ -0,0 +1,10 @@
.text-muted(*ngIf="!status")
| checking...
.text-unsafe(*ngIf="status && !status.length")
| offline
.text-safe(*ngFor="let s of status")
| [{{s.server}}] [{{s.map}}] {{s.character}} ({{s.x}}, {{s.y}}) ({{s.duration}})
ua-info.ml-1([userAgent]="s.userAgent")
fa-icon.ml-1(*ngIf="s.incognito" [icon]="incognitoIcon")
.text-muted(*ngIf="verbose")
small {{s.userAgent}}
@@ -0,0 +1,28 @@
import { Component, OnInit, Input, OnChanges } from '@angular/core';
import { AdminModel } from '../../../services/adminModel';
import { Account, AccountStatus as IAccountStatus } from '../../../../common/adminInterfaces';
import { faUserSecret } from '../../../../client/icons';
@Component({
selector: 'account-status',
templateUrl: 'account-status.pug',
})
export class AccountStatus implements OnInit, OnChanges {
readonly incognitoIcon = faUserSecret;
@Input() account!: Account;
@Input() verbose = false;
status: IAccountStatus[] | undefined = undefined;
constructor(private model: AdminModel) {
}
ngOnInit() {
this.refresh();
}
ngOnChanges() {
this.status = undefined;
this.refresh();
}
refresh() {
this.model.getAccountStatus(this.account._id)
.then(status => this.status = status);
}
}
@@ -0,0 +1,27 @@
.text-left
div
| {{account.name || '[none]'}} [{{account._id | slice:-3}}]
auth-list-remote.ml-1(*ngIf="!extendedAuths" [accountId]="account._id")
.text-muted
em {{account.name | translit}}
div(*ngIf="extendedAuths")
auth-list-remote([accountId]="account._id" [extended]="true")
.text-muted(*ngFor="let e of account.emails | slice:0:3")
| {{e}}
.text-muted(*ngIf="account.emails && account.emails.length > 3")
| ({{account.emails.length - 3}} more)
div
span.text-muted created:
from-now.ml-1([time]="account.createdAt")
div
span.text-muted last visit:
from-now.ml-1([time]="account.lastVisit")
div
span.text-muted ponies:
span.ml-1 {{account.characterCount}}
div
span.text-muted age:
span.ml-1([class.text-strike]="account.birthyear") {{age}}
span.ml-1 {{forceAge}}
div
account-status([account]="account")
@@ -0,0 +1,20 @@
import { Component, Input } from '@angular/core';
import { Account } from '../../../../common/adminInterfaces';
import { getAge } from '../../../../common/adminUtils';
const year = (new Date()).getFullYear();
@Component({
selector: 'account-tooltip',
templateUrl: 'account-tooltip.pug',
})
export class AccountTooltip {
@Input() account!: Account;
@Input() extendedAuths = false;
get age() {
return this.account.birthdate ? getAge(this.account.birthdate) : '-';
}
get forceAge() {
return this.account.birthyear ? (year - this.account.birthyear) : '';
}
}
@@ -0,0 +1,38 @@
div(*ngIf="open || !canClose")
.d-flex.justify-content-between.align-items-center
h3
| {{title}}
span.text-muted.ml-1
| ({{date ? date.label : 'none'}})
span.text-muted.ml-1(*ngIf="loading")
fa-icon([icon]="spinnerIcon" [spin]="true")
.form-inline
ng-content
form.d-inline-block((submit)="searchChat(searchInput.value)")
input.form-control.form-control-sm.ml-1(#searchInput type="search" placeholder="search")
button.btn.btn-sm.btn-default.ml-1(type="submit" title="Search whole chat for phrase")
fa-icon([icon]="searchIcon" [fixedWidth]="true" size="lg")
button.btn.btn-sm.btn-default.ml-1((click)="openLog()" title="Open log in plain text format")
fa-icon([icon]="fileIcon" [fixedWidth]="true" size="lg")
button.btn.btn-sm.ml-1(
[(ngModel)]="autoRefresh" btnCheckbox [btnHighlight]="autoRefresh" title="Auto-refresh every 10 seconds")
fa-icon.mr-1([icon]="syncIcon" [fixedWidth]="true" size="lg")
| auto
.btn-group.btn-group-sm.ml-1
button.btn.btn-sm.btn-default((click)="prev()" title="Load previous day")
fa-icon([icon]="chevronLeftIcon" [fixedWidth]="true" size="lg")
button.btn.btn-sm.btn-default((click)="next()" title="Load next day")
fa-icon([icon]="chevronRightIcon" [fixedWidth]="true" size="lg")
.btn-group.btn-group-sm.dropup.ml-1(dropdown)
button.btn.btn-sm.btn-default((click)="showDate(today)")
| Load todays chat
button.btn.btn-default.dropdown-toggle(dropdownToggle)
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
a.dropdown-item(*ngFor="let d of dates" (click)="showDate(d)")
| {{d.label}}
button.btn.btn-sm.btn-default.ml-1(*ngIf="canClose" (click)="close()")
fa-icon([icon]="closeIcon" [fixedWidth]="true")
.mb-1(*ngIf="accounts.length")
a.badge.badge-none.mr-1(*ngFor="let a of accounts; let i = index" (click)="removeAccount(i)" title="Remove")
| [{{i + 1}}] {{a.name}}
.well.pre.chatlog
@@ -0,0 +1 @@
// @import 'partials/variables';
@@ -0,0 +1,182 @@
import { Component, Input, OnDestroy, ElementRef } from '@angular/core';
import * as moment from 'moment';
import { AdminModel } from '../../../services/adminModel';
import { Account } from '../../../../common/adminInterfaces';
import { ChatDate, createChatDate, createDateRange, replaceSwears } from '../../../../common/adminUtils';
import { faSearch, faSpinner, faSync, faFileAlt, faTimes, faChevronLeft, faChevronRight } from '../../../../client/icons';
import { removeAllNodes, appendAllNodes, showTextInNewTab } from '../../../../client/htmlUtils';
import { includes } from '../../../../common/utils';
@Component({
selector: 'admin-chat-log',
templateUrl: 'admin-chat-log.pug',
styleUrls: ['admin-chat-log.scss'],
})
export class AdminChatLog implements OnDestroy {
readonly searchIcon = faSearch;
readonly spinnerIcon = faSpinner;
readonly syncIcon = faSync;
readonly fileIcon = faFileAlt;
readonly closeIcon = faTimes;
readonly chevronLeftIcon = faChevronLeft;
readonly chevronRightIcon = faChevronRight;
@Input() canClose = true;
accounts: Account[] = [];
search?: string;
open = false;
today: ChatDate = createChatDate(moment());
dates: ChatDate[] = [/*{ value: 'all', label: 'All' },*/ ...createDateRange(new Date(), 14)];
date?: ChatDate;
chatRaw?: string;
loading = false;
private _account?: Account;
private refreshInterval?: any;
constructor(private model: AdminModel, private element: ElementRef) {
}
get autoRefresh() {
return !!this.refreshInterval;
}
set autoRefresh(value: boolean) {
if (value) {
this.refreshInterval = this.refreshInterval || setInterval(() => this.refresh(), 10 * 1000);
} else {
this.stopInterval();
}
}
get title() {
return this.search || (this.account && this.account.name) || 'Chat';
}
get account() {
return this._account;
}
@Input() set account(value) {
const theSame = this._account === value || (value && this._account && value._id === this._account._id);
this._account = value;
if (!theSame) {
this.date = undefined;
this.setChatlogElements([]);
}
}
ngOnDestroy() {
this.close();
}
show(account?: Account, date?: ChatDate) {
this.search = undefined;
this.account = account;
this.date = date || this.today;
this.open = true;
this.accounts = [];
this.refresh();
}
add(account: Account) {
if (!this.account) {
this.show(account);
} else if (account !== this.account && !includes(this.accounts, account)) {
this.date = this.date || this.today;
this.accounts.push(account);
this.refresh();
}
}
removeAccount(index: number) {
this.accounts.splice(index, 1);
this.refresh();
}
showDate(date: ChatDate) {
this.date = date;
this.refresh();
}
prev() {
this.switchDate(-1);
}
next() {
this.switchDate(1);
}
all() {
this.date = this.dates[0];
this.refresh();
}
close() {
this.account = undefined;
this.date = undefined;
this.open = false;
this.setChatlogElements([]);
this.stopInterval();
}
searchChat(search: string | undefined) {
this.search = search;
this.refresh();
}
refresh() {
const date = this.date && this.date.value;
if (this.account) {
const accounts = [this.account._id, ...this.accounts.map(a => a._id)];
this.handleChat(this.model.accountsFormattedChat(accounts, date));
} else if (this.search) {
this.handleChat(this.model.searchFormattedChat(this.search, date));
}
}
openLog() {
showTextInNewTab(`${this.date ? this.date.label : 'none'}\n\n${(this.chatRaw || '').replace(/\t/g, ' ')}`);
}
private handleChat(promise: Promise<{ raw: string; html: HTMLElement[]; }>) {
this.loading = true;
promise
.then(({ raw, html }) => {
this.chatRaw = raw;
this.setChatlogElements(html);
})
.finally(() => this.loading = false);
}
private switchDate(days: number) {
const validDate = this.date && this.date.value !== 'all';
this.date = validDate ? createChatDate(moment(this.date!.value).add(days, 'days')) : this.today;
this.refresh();
}
private stopInterval() {
clearInterval(this.refreshInterval);
this.refreshInterval = undefined;
}
private getChatlogElement() {
return (this.element.nativeElement as HTMLElement).querySelector('.chatlog');
}
private setChatlogElements(elements: HTMLElement[]) {
const element = this.getChatlogElement();
if (element) {
removeAllNodes(element);
appendAllNodes(element, elements);
this.nodesToProcess = [
...Array.from(element.getElementsByClassName('name')),
...Array.from(element.getElementsByClassName('message')),
] as HTMLElement[];
this.atNode = 0;
this.processNodes();
}
}
private processNodes() {
const processStep = 50;
const nodes = this.nodesToProcess;
cancelIdleCallback(this.processIdle);
if (nodes && this.atNode < nodes.length) {
let i = 0;
while (i < processStep && (i + this.atNode) < nodes.length) {
replaceSwears(nodes[i + this.atNode]);
i++;
}
this.atNode += i;
this.processIdle = requestIdleCallback(() => this.processNodes());
} else {
this.nodesToProcess = undefined;
}
}
private nodesToProcess?: HTMLElement[];
private atNode = 0;
private processIdle = 0;
}
@@ -0,0 +1,22 @@
.d-flex.align-items-center.mb-1
auth-info.flex-grow-1([auth]="auth" [showName]="true")
small.text-muted
from-now.mr-1([time]="auth?.lastUsed")
.btn-group.dropdown(dropdown)
button.btn.btn-xs.btn-default(dropdownToggle title="Assign to another account")
fa-icon([icon]="assignIcon" [fixedWidth]="true")
.dropdown-menu(*dropdownMenu)
button.dropdown-item(*ngFor="let d of duplicates" (click)="assignTo(d.account)")
span.text-muted assign to
account-info-remote.ml-1([accountId]="d.account" [basic]="true")
.btn-group.ml-1
button.btn.btn-xs.btn-default((click)="showAuthData(auth)" title="Show more data")
fa-icon([icon]="infoIcon" [fixedWidth]="true")
button.btn.btn-xs((click)="toggleAuthBanned(auth)" [btnHighlight]="auth?.banned || false"
title="Toggle auth locked (can't be restored by user)")
fa-icon([icon]="lockIcon" [fixedWidth]="true")
button.btn.btn-xs((click)="toggleAuthDisabled(auth)" [btnHighlight]="auth?.disabled || false"
title="Toggle auth disabled (can be restored by user)")
fa-icon([icon]="eyeSlashIcon" [fixedWidth]="true")
button.btn.btn-xs.btn-default((click)="removeAuth(auth)" title="Remove auth")
fa-icon([icon]="trashIcon" [fixedWidth]="true")
@@ -0,0 +1,64 @@
import { Component, OnDestroy, Input } from '@angular/core';
import { Auth, DuplicateResult } from '../../../../common/adminInterfaces';
import { Subscription } from '../../../../common/interfaces';
import { AdminModel } from '../../../services/adminModel';
import { faInfo, faLock, faTrash, faEyeSlash, faArrowRight } from '../../../../client/icons';
@Component({
selector: 'auth-info-edit',
templateUrl: 'auth-info-edit.pug',
})
export class AuthInfoEdit implements OnDestroy {
readonly assignIcon = faArrowRight;
readonly infoIcon = faInfo;
readonly lockIcon = faLock;
readonly trashIcon = faTrash;
readonly eyeSlashIcon = faEyeSlash;
@Input() duplicates?: DuplicateResult[];
@Input() showName = false;
auth?: Auth;
private _authId?: string;
private subscription?: Subscription;
constructor(private model: AdminModel) {
}
get authId() {
return this._authId;
}
@Input() set authId(value: string | undefined) {
if (this.authId !== value) {
this._authId = value;
this.auth = undefined;
this.subscription && this.subscription.unsubscribe();
this.subscription = value ? this.model.auths.subscribe(value, auth => this.auth = auth) : undefined;
}
}
ngOnDestroy() {
this.subscription && this.subscription.unsubscribe();
}
removeAuth(auth: Auth | undefined) {
if (auth && confirm('Are you sure?')) {
this.model.removeAuth(auth._id);
}
}
showAuthData(auth: Auth | undefined) {
if (auth) {
this.model.getAuth(auth._id)
.then(x => console.log(x));
}
}
toggleAuthDisabled(auth: Auth | undefined) {
if (auth) {
this.model.updateAuth(auth._id, { disabled: !auth.disabled });
}
}
toggleAuthBanned(auth: Auth | undefined) {
if (auth) {
this.model.updateAuth(auth._id, { banned: !auth.banned });
}
}
assignTo(accountId: string) {
if (this.authId) {
this.model.assignAuth(this.authId, accountId);
}
}
}
@@ -0,0 +1 @@
auth-info([auth]="auth" [showName]="showName")
@@ -0,0 +1,31 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { Subscription } from '../../../../common/interfaces';
import { AdminModel } from '../../../services/adminModel';
import { Auth } from '../../../../common/adminInterfaces';
@Component({
selector: 'auth-info-remote',
templateUrl: 'auth-info-remote.pug',
})
export class AuthInfoRemote implements OnDestroy {
@Input() showName = false;
auth?: Auth;
private _authId?: string;
private subscription?: Subscription;
constructor(private model: AdminModel) {
}
get authId() {
return this._authId;
}
@Input() set authId(value: string | undefined) {
if (this.authId !== value) {
this._authId = value;
this.auth = undefined;
this.subscription && this.subscription.unsubscribe();
this.subscription = value ? this.model.auths.subscribe(value, auth => this.auth = auth) : undefined;
}
}
ngOnDestroy() {
this.subscription && this.subscription.unsubscribe();
}
}
@@ -0,0 +1,13 @@
a(*ngIf="auth?.url" [href]="auth?.url" target="_blank" rel="noopener noreferrer" [title]="name")
fa-icon([icon]="icon" [fixedWidth]="true")
span(*ngIf="!auth?.url" [title]="name")
fa-icon([icon]="icon" [fixedWidth]="true")
span(*ngIf="showName")
span.ml-1(*ngIf="name")
| {{name}}
em.text-muted.ml-1
| {{name | translit}}
span.text-muted.ml-1(*ngIf="!name")
| &lt;no name&gt;
span.badge.badge-success.ml-1(*ngIf="pledged")
| ${{pledged}}
@@ -0,0 +1,4 @@
fa-icon {
font-size: 13px;
margin: 0 1px;
}
@@ -0,0 +1,28 @@
import { Component, Input } from '@angular/core';
import { Auth } from '../../../../common/adminInterfaces';
import { oauthIcons, faGlobe } from '../../../../client/icons';
@Component({
selector: 'auth-info',
templateUrl: 'auth-info.pug',
styleUrls: ['auth-info.scss'],
host: {
'[class.deleted]': 'deleted',
},
})
export class AuthInfo {
@Input() auth?: Auth;
@Input() showName = false;
get deleted(): boolean {
return !!(this.auth && (this.auth.disabled || this.auth.banned));
}
get name(): string {
return this.auth && this.auth.name || '';
}
get icon() {
return oauthIcons[this.auth && this.auth.provider || ''] || faGlobe;
}
get pledged() {
return (this.auth && this.auth.pledged || 0) / 100;
}
}
@@ -0,0 +1,5 @@
span([class.extended]="extended")
span.text-muted(*ngIf="loading") ...
auth-info-remote(*ngFor="let a of auths | slice:0:limit" [authId]="a" [showName]="extended")
a.text-muted.ml-1.text-nowrap(*ngIf="auths.length > limit" (click)="limit = 9999")
small ({{auths.length - limit}} more)
@@ -0,0 +1,3 @@
.extended > auth-info-remote {
display: block;
}
@@ -0,0 +1,37 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { Subscription } from '../../../../common/interfaces';
import { AdminModel } from '../../../services/adminModel';
@Component({
selector: 'auth-list-remote',
templateUrl: 'auth-list-remote.pug',
styleUrls: ['auth-list-remote.scss'],
})
export class AuthListRemote implements OnDestroy {
@Input() limit = 6;
@Input() extended = false;
auths: string[] = [];
loading = false;
private _accountId?: string;
private subscription?: Subscription;
constructor(private model: AdminModel) {
}
get accountId() {
return this._accountId;
}
@Input() set accountId(value: string | undefined) {
if (this.accountId !== value) {
this._accountId = value;
this.auths = [];
this.loading = true;
this.subscription && this.subscription.unsubscribe();
this.subscription = value ? this.model.accountAuths.subscribe(value, auths => {
this.auths = auths || [];
this.loading = false;
}) : undefined;
}
}
ngOnDestroy() {
this.subscription && this.subscription.unsubscribe();
}
}
@@ -0,0 +1,4 @@
span([class.extended]="extended")
auth-info(*ngFor="let a of fixedAuths | slice:0:limit" [auth]="a" [showName]="extended")
a.text-muted.ml-1.text-nowrap(*ngIf="fixedAuths.length > limit" (click)="limit = 9999")
small ({{fixedAuths.length - limit}} more)
@@ -0,0 +1,7 @@
.extended {
display: block;
auth-info, span {
display: block;
}
}
@@ -0,0 +1,19 @@
import { Component, Input } from '@angular/core';
import { Auth } from '../../../../common/adminInterfaces';
@Component({
selector: 'auth-list',
templateUrl: 'auth-list.pug',
styleUrls: ['auth-list.scss'],
host: {
'[class.extended]': 'extended',
},
})
export class AuthList {
@Input() limit = 6;
@Input() extended = false;
@Input() auths?: Auth[];
get fixedAuths() {
return this.auths || [];
}
}
@@ -0,0 +1,16 @@
ng-template(#timeoutTooltip)
from-now([time]="value")
.dropdown(dropdown)
a.icon-button.cursor-pointer(dropdownToggle [title]="type" [tooltip]="isTimedOut ? timeoutTooltip : null")
fa-icon([icon]="icon" [fixedWidth]="true" [ngClass]="className")
fa-icon.text-alert.ban-icon-timer(*ngIf="isTimedOut" [icon]="clockIcon" [fixedWidth]="true")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
a.dropdown-item((click)="clear()")
| clear #[b {{type}}]
a.dropdown-item((click)="perma()")
| perma #[b {{type}}]
.dropdown-divider
a.dropdown-item(*ngFor="let t of timeouts" (click)="timeout(t.value)")
| {{t.label}}
@@ -0,0 +1,26 @@
.icon-button {
position: relative;
&:hover {
filter: drop-shadow(0 0 2px #000);
}
}
.ban-icon-timer {
position: absolute;
right: -2px;
bottom: -2px;
font-size: 10px;
}
i.ban-icon-timer {
text-shadow: 0 0 1px #000;
}
fa-icon.ban-icon-timer {
filter: drop-shadow(0 0 1px #000);
}
i, fa-icon {
font-size: 13px;
}
@@ -0,0 +1,77 @@
import {
Component, Input, EventEmitter, Output, ChangeDetectionStrategy, OnInit, OnDestroy, OnChanges, NgZone
} from '@angular/core';
import { TIMEOUTS } from '../../../../common/constants';
import { BannedMuted } from '../../../../common/adminInterfaces';
import { IntervalUpdateService } from '../../../services/intervalUpdateService';
import { faClock, faMicrophoneSlash, faEyeSlash, faBan } from '../../../../client/icons';
const ICONS = {
mute: faMicrophoneSlash,
shadow: faEyeSlash,
ban: faBan,
};
@Component({
selector: 'ban-icon',
templateUrl: 'ban-icon.pug',
styleUrls: ['ban-icon.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BanIcon implements OnInit, OnDestroy, OnChanges {
readonly timeouts = TIMEOUTS;
readonly clockIcon = faClock;
@Input() type: keyof BannedMuted = 'ban';
@Input() value = 0;
@Output() toggle = new EventEmitter<number>();
get icon() {
return ICONS[this.type] || ICONS.ban;
}
get isPerma() {
return this.value === -1;
}
get isTimedOut() {
return this.value > Date.now();
}
get className() {
if (this.isPerma) {
return 'text-banned';
} else if (this.isTimedOut) {
return 'text-alert';
} else {
return 'text-muted';
}
}
private timedOut = false;
private toggleUpdate: (on: boolean) => void;
constructor(zone: NgZone, updateService: IntervalUpdateService) {
this.toggleUpdate = updateService.toggle(() => {
if (this.timedOut !== this.isTimedOut) {
zone.run(() => this.timedOut = this.isTimedOut);
this.toggleUpdate(this.isTimedOut);
}
});
}
ngOnInit() {
this.toggleUpdate(this.isTimedOut);
}
ngOnChanges() {
this.toggleUpdate(this.isTimedOut);
}
ngOnDestroy() {
this.toggleUpdate(false);
}
clear() {
this.setValue(0);
}
perma() {
this.setValue(-1);
}
timeout(value: number) {
this.setValue(Date.now() + value);
}
private setValue(value: number) {
this.value = value;
this.toggle.emit(value);
}
}
@@ -0,0 +1,6 @@
div(*ngIf="emails && emails.length")
.account-email.text-muted(*ngFor="let e of emails | slice:0:limit")
| {{e}}
div(*ngIf="hasMore")
a.text-muted.ml-1.text-nowrap((click)="showMore()")
small ({{emails.length - limit}} more)
@@ -0,0 +1,16 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'email-list',
templateUrl: 'email-list.pug',
})
export class EmailList {
@Input() emails?: string[];
limit = 3;
get hasMore() {
return this.emails && this.emails.length > this.limit;
}
showMore() {
this.limit = 9999;
}
}
@@ -0,0 +1,51 @@
table.table.table-fixed.table-striped.table-hover.table-sm.table-ellipsis
thead
tr
th.col-time created
th.col-time updated
th message
th.col-account account
th.col-auth auths
th.col-pony pony
th.col-origin origin
th.col-actions actions
tbody
tr(*ngFor="let e of events" [class.deleted]="e.deleted")
td.col-time
time-field([time]="e.createdAt")
td.col-time
time-field(*ngIf="e.createdAt !== e.updatedAt" [time]="e.updatedAt")
td
span.badge.badge-admin([ngClass]="serverLabel(e)") {{e.server}}
span.badge.badge-admin([ngClass]="'badge-' + e.type") {{e.type}}
span.badge.badge-admin(*ngIf="e.count > 1" [ngClass]="e.count > 9 ? 'badge-danger' : 'badge-none'") {{e.count}}
b {{e.message}}
a.text-muted(*ngIf="e.account" (click)="copyToNotes(e, accountInfo.account)" title="copy to account notes")
fa-icon([icon]="clipboardIcon")
.text-muted.event-desc.pre-line(*ngIf="e.desc")
span([innerHTML]="e.descHTML")
a.text-muted.ml-1([href]="translateUrl(e)" target="_blank" title="translate")
fa-icon([icon]="langIcon")
td.col-account
account-info-remote(#accountInfo [accountId]="e.account" [showDuplicates]="true")
td.col-auth
auth-list-remote([accountId]="e.account")
td.col-pony
pony-info-remote(*ngIf="e.pony" [ponyId]="e.pony")
td.col-origin
origin-info-remote([originIP]="e.origin?.ip")
td.col-actions.text-nowrap
button.btn.btn-xs.btn-danger.mr-1((click)="remove(e)" title="Remove event")
fa-icon([icon]="trashIcon")
.btn-group.dropdown(dropdown)
button.btn.btn-xs.btn-default(
(click)="onShowChat($event, e, accountInfo.account)" [disabled]="!e.account" title="Show chat log")
fa-icon([icon]="commentIcon")
button.btn.btn-xs.btn-default.dropdown-toggle(dropdownToggle)
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
a.dropdown-item((click)="removeAll(e)")
fa-icon.mr-1([icon]="trashIcon" [fixedWidth]="true")
| Remove all #[strong {{e.message}}]
a.dropdown-item((click)="onAddChat(e, accountInfo.account)")
fa-icon.mr-1([icon]="commentIcon" [fixedWidth]="true")
| Add to chat log
@@ -0,0 +1,8 @@
.event-desc {
word-wrap: break-word;
max-width: 500px;
}
.col-actions {
width: 90px;
}
@@ -0,0 +1,56 @@
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Account, Event, SERVER_LABELS, ChatEvent } from '../../../../common/adminInterfaces';
import { AdminModel } from '../../../services/adminModel';
import { faLanguage, faTrash, faComment, faClipboard } from '../../../../client/icons';
import { getTranslationUrl } from '../../../../common/adminUtils';
@Component({
selector: 'events-table',
templateUrl: 'events-table.pug',
styleUrls: ['events-table.scss'],
})
export class EventsTable {
readonly clipboardIcon = faClipboard;
readonly langIcon = faLanguage;
readonly trashIcon = faTrash;
readonly commentIcon = faComment;
@Input() events!: Event[];
@Output() showChat = new EventEmitter<ChatEvent>();
@Output() addChat = new EventEmitter<ChatEvent>();
@Output() removedEvent = new EventEmitter<Event>();
constructor(private model: AdminModel) {
}
serverLabel(e: Event) {
return SERVER_LABELS[e.server] || 'badge-none';
}
remove(e: Event) {
this.model.removeEvent(e._id);
this.removedEvent.emit(e);
}
removeAll(e: Event) {
this.model.events
.filter(x => x.message === e.message)
.forEach(x => this.model.removeEvent(x._id));
}
copyToNotes(e: Event, account: Account | undefined) {
if (account) {
const desc = e.desc ? `: ${e.desc}` : '';
const count = e.count > 1 ? `[${e.count}] ` : '';
const note = `${(account.note || '')}\r\n[${e.server}]${count}${e.message}${desc}`;
this.model.setNote(account._id, note.trim());
}
}
translateUrl(e: Event) {
return getTranslationUrl(e.desc);
}
onShowChat(e: MouseEvent, event: Event, account: Account | undefined) {
if (e.shiftKey) {
this.addChat.emit({ event, account });
} else {
this.showChat.emit({ event, account });
}
}
onAddChat(event: Event, account: Account | undefined) {
this.addChat.emit({ event, account });
}
}
@@ -0,0 +1,36 @@
import { Component, Input, OnInit, OnDestroy, OnChanges, ElementRef, ChangeDetectionStrategy } from '@angular/core';
import * as moment from 'moment';
import { IntervalUpdateService } from '../../services/intervalUpdateService';
@Component({
selector: 'from-now',
template: '<span></span>',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FromNow implements OnInit, OnDestroy, OnChanges {
@Input() time?: any;
private moment?: moment.Moment;
private text?: string;
private unsubscribe?: () => void;
constructor(private element: ElementRef, private updateService: IntervalUpdateService) {
}
ngOnChanges() {
this.moment = this.time ? moment(this.time) : undefined;
this.update();
}
ngOnInit() {
this.unsubscribe = this.updateService.subscribe(() => this.update());
this.update();
}
ngOnDestroy() {
this.unsubscribe && this.unsubscribe();
}
private update() {
const text = this.moment ? this.moment.fromNow(true).replace('seconds', 'secs') : '';
if (this.text !== text) {
this.text = text;
(this.element.nativeElement as HTMLElement).children[0].textContent = text;
}
}
}
@@ -0,0 +1,8 @@
.btn-group.mb-1
button.btn.btn-xs([ngClass]="on ? 'btn-default' : 'btn-danger active'" (click)="onToggle(false)" [disabled]="disabled")
b {{offText}}
button.btn.btn-xs([ngClass]="on ? 'btn-success active' : 'btn-default'" (click)="onToggle(true)" [disabled]="disabled")
b {{onText}}
b.ml-2.mb-1.text-muted
| {{label}}
@@ -0,0 +1,20 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'on-off-switch',
templateUrl: 'on-off-switch.pug',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OnOffSwitch {
@Input() on = false;
@Input() disabled = false;
@Input() onText = 'ON';
@Input() offText = 'OFF';
@Input() label = '';
@Output() toggle = new EventEmitter<boolean>();
onToggle(value: boolean) {
if (value !== this.on) {
this.toggle.emit(value);
}
}
}
@@ -0,0 +1 @@
origin-info([origin]="origin")
@@ -0,0 +1,31 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { Subscription } from '../../../../common/interfaces';
import { AdminModel } from '../../../services/adminModel';
import { Origin } from '../../../../common/adminInterfaces';
@Component({
selector: 'origin-info-remote',
templateUrl: 'origin-info-remote.pug',
})
export class OriginInfoRemote implements OnDestroy {
@Input() showName = false;
origin?: Origin;
private _originIP?: string;
private subscription?: Subscription;
constructor(private model: AdminModel) {
}
get originIP() {
return this._originIP;
}
@Input() set originIP(value: string | undefined) {
if (this.originIP !== value) {
this._originIP = value;
this.origin = undefined;
this.subscription && this.subscription.unsubscribe();
this.subscription = value ? this.model.origins.subscribe(value, origin => this.origin = origin) : undefined;
}
}
ngOnDestroy() {
this.subscription && this.subscription.unsubscribe();
}
}
@@ -0,0 +1,9 @@
.d-flex.align-items-center(*ngIf="origin")
a.origin-info-ip.mr-1([routerLink]="['/origins', origin.ip, origin.country]" [title]="origin.ip")
| {{origin.ip}}
span([title]="countryName") [{{origin.country}}]
span.badge.badge-none.ml-1(*ngIf="origin.accountsCount > 1" title="accounts using this IP")
| {{origin.accountsCount}}
ban-icon((toggle)="toggleBan('mute', $event)" [value]="origin.mute" type="mute")
ban-icon((toggle)="toggleBan('shadow', $event)" [value]="origin.shadow" type="shadow")
ban-icon((toggle)="toggleBan('ban', $event)" [value]="origin.ban" type="ban")
@@ -0,0 +1,17 @@
.origin-info-ip {
display: inline-block;
max-width: 110px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: bottom;
white-space: nowrap;
}
i {
font-size: 13px;
margin: 0 1px;
}
ban-icon {
margin-left: 3px;
}
@@ -0,0 +1,23 @@
import { Component, Input } from '@angular/core';
import { Origin, BannedMuted } from '../../../../common/adminInterfaces';
import { AdminModel } from '../../../services/adminModel';
import { countryCodeToName } from '../../../../common/countries';
@Component({
selector: 'origin-info',
templateUrl: 'origin-info.pug',
styleUrls: ['origin-info.scss'],
})
export class OriginInfo {
@Input() origin?: Origin;
constructor(private model: AdminModel) {
}
get countryName() {
return countryCodeToName[this.origin && this.origin.country || '??'] || 'Unknown';
}
toggleBan(field: keyof BannedMuted, value: number) {
if (this.origin) {
this.model.updateOrigin({ ip: this.origin.ip, country: this.origin.country, [field]: value });
}
}
}
@@ -0,0 +1,4 @@
div(*ngFor="let o of origins | slice:0:limit")
origin-info-remote([originIP]="o.ip")
a.text-muted(*ngIf="origins.length > limit" (click)="limit = 999999")
small ({{origins.length - limit}} more)
@@ -0,0 +1,32 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { Subscription } from '../../../../common/interfaces';
import { AdminModel } from '../../../services/adminModel';
import { OriginInfoBase } from '../../../../common/adminInterfaces';
@Component({
selector: 'origin-list-remote',
templateUrl: 'origin-list-remote.pug',
})
export class OriginListRemote implements OnDestroy {
@Input() limit = 2;
@Input() extended = false;
origins: OriginInfoBase[] = [];
private _accountId?: string;
private subscription?: Subscription;
constructor(private model: AdminModel) {
}
get accountId() {
return this._accountId;
}
@Input() set accountId(value: string | undefined) {
if (this.accountId !== value) {
this._accountId = value;
this.origins = [];
this.subscription && this.subscription.unsubscribe();
this.subscription = value ? this.model.accountOrigins.subscribe(value, x => this.origins = x || []) : undefined;
}
}
ngOnDestroy() {
this.subscription && this.subscription.unsubscribe();
}
}
@@ -0,0 +1 @@
pony-info([pony]="pony" [highlight]="highlight")
@@ -0,0 +1,32 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { Subscription } from '../../../../common/interfaces';
import { AdminModel } from '../../../services/adminModel';
import { Character } from '../../../../common/adminInterfaces';
@Component({
selector: 'pony-info-remote',
templateUrl: 'pony-info-remote.pug',
})
export class PonyInfoRemote implements OnDestroy {
@Input() highlight = false;
@Input() showName = false;
pony?: Character;
private _ponyId?: string;
private subscription?: Subscription;
constructor(private model: AdminModel) {
}
get ponyId() {
return this._ponyId;
}
@Input() set ponyId(value: string | undefined) {
if (this.ponyId !== value) {
this._ponyId = value;
this.pony = undefined;
this.subscription && this.subscription.unsubscribe();
this.subscription = value ? this.model.ponies.subscribe(value, pony => this.pony = pony) : undefined;
}
}
ngOnDestroy() {
this.subscription && this.subscription.unsubscribe();
}
}
@@ -0,0 +1,12 @@
ng-template(#tooltip)
.character-tooltip(style="position: relative")
character-preview([pony]="pony?.ponyInfo" [passive]="true")
.character-tooltip-indicator(*ngIf="isBadCM") &bull;
.character-tooltip-date
from-now([time]="pony?.lastUsed")
span.ml-1(*ngIf="pony?.deleted") [deleted]
span.cursor-pointer.badge(
[ngClass]="labelClass" [class.deleted]="pony?.deleted" [class.badge-highlight]="highlight" (click)="click()"
[tooltip]="tooltip" (onShown)="onShown()" containerClass="tooltip-pony")
| {{pony?.name || '...'}}
@@ -0,0 +1,3 @@
.badge-highlight {
box-shadow: -3px 0 0 white;
}
@@ -0,0 +1,42 @@
import { Component, Input, OnChanges } from '@angular/core';
import { hasFlag } from '../../../../common/utils';
import { Character, CharacterFlags } from '../../../../common/adminInterfaces';
import { isForbiddenName } from '../../../../common/security';
import { AdminModel } from '../../../services/adminModel';
@Component({
selector: 'pony-info',
templateUrl: 'pony-info.pug',
styleUrls: ['pony-info.scss'],
})
export class PonyInfo implements OnChanges {
@Input() pony?: Character;
@Input() highlight = false;
labelClass = 'badge-none';
private promise?: Promise<void>;
constructor(private model: AdminModel) {
}
get isBadCM() {
return !!this.pony && hasFlag(this.pony.flags, CharacterFlags.BadCM);
}
ngOnChanges() {
if (this.pony) {
if (isForbiddenName(this.pony.name)) {
this.labelClass = 'badge-forbidden';
} else if (hasFlag(this.pony.flags, CharacterFlags.BadCM)) {
this.labelClass = 'badge-danger';
} else {
this.labelClass = 'badge-none';
}
}
}
onShown() {
if (this.pony && !this.pony.ponyInfo && !this.promise) {
this.promise = this.model.getPonyInfo(this.pony)
.finally(() => this.promise = undefined);
}
}
click() {
console.log(this.pony);
}
}
@@ -0,0 +1,17 @@
div([class.pony-list-expanded]="expanded" [class.pony-list-deletable]="deletable")
span.text-muted(*ngIf="loading") ...
span.pony-list-item.mr-1(*ngFor="let p of ponies | slice:0:limitTo")
pony-info-remote(#info [ponyId]="p" [highlight]="highlight(info.pony)")
a.ml-1.icon-button.cursor-pointer((click)="remove(p)" title="delete character")
fa-icon.text-muted([icon]="trashIcon" [fixedWidth]="true")
.btn-group.dropdown(dropdown *ngIf="duplicates")
a.ml-1.icon-button.cursor-pointer(dropdownToggle title="Assign to another account")
fa-icon.text-muted([icon]="assignIcon" [fixedWidth]="true")
.dropdown-menu.dropdown-menu-right(*dropdownMenu)
button.dropdown-item(*ngFor="let d of duplicates" (click)="assignTo(p, d.account)")
span.text-muted assign to
account-info-remote.ml-1([accountId]="d.account" [basic]="true")
a.text-muted.mr-1(*ngIf="!full && ponies.length > limit" (click)="toggleFull()")
small ({{ponies.length - limit}} more)
a.text-muted(*ngIf="full && ponies.length > limit" (click)="toggleFull()")
small (less)
@@ -0,0 +1,14 @@
.pony-list-expanded {
.pony-list-item {
display: block;
}
}
.icon-button {
display: none;
font-size: 12px;
.pony-list-deletable & {
display: inline;
}
}
@@ -0,0 +1,75 @@
import { Component, Input, OnDestroy } from '@angular/core';
import { Subscription } from '../../../../common/interfaces';
import { AdminModel } from '../../../services/adminModel';
import { faTrash, faArrowRight } from '../../../../client/icons';
import { Character, PonyIdDateName, DuplicateResult } from '../../../../common/adminInterfaces';
@Component({
selector: 'pony-list-remote',
templateUrl: 'pony-list-remote.pug',
styleUrls: ['pony-list-remote.scss'],
})
export class PonyListRemote implements OnDestroy {
readonly trashIcon = faTrash;
readonly assignIcon = faArrowRight;
@Input() limit = 10;
@Input() expanded = false;
@Input() deletable = false;
@Input() highlight: (pony: Character) => boolean = () => false;
@Input() duplicates?: DuplicateResult[];
full = false;
ponies: string[] = [];
loading = false;
private ponyInfos: PonyIdDateName[] = [];
private _accountId?: string;
private subscription?: Subscription;
constructor(private model: AdminModel) {
}
get limitTo() {
return this.full ? 999999 : this.limit;
}
get accountId() {
return this._accountId;
}
@Input() set accountId(value: string | undefined) {
if (this.accountId !== value) {
this._accountId = value;
this.ponies = [];
this.ponyInfos = [];
this.loading = true;
this.subscription && this.subscription.unsubscribe();
this.subscription = value ? this.model.accountPonies.subscribe(value, (x = []) => {
this.ponyInfos = x;
this.updatePonies();
this.loading = false;
}) : undefined;
}
}
ngOnDestroy() {
this.subscription && this.subscription.unsubscribe();
}
remove(characterId: string) {
if (confirm('Are you sure?')) {
this.model.removePony(characterId);
}
}
toggleFull() {
this.full = !this.full;
this.updatePonies();
}
assignTo(pony: string, account: string) {
this.model.assignPony(pony, account);
}
private updatePonies() {
const compare = this.full ? compareNames : compareDates;
this.ponies = this.ponyInfos.sort(compare).map(p => p.id);
}
}
export function compareNames(a: { name: string }, b: { name: string }) {
return a.name.localeCompare(b.name);
}
export function compareDates(a: { date: number }, b: { date: number }) {
return b.date - a.date;
}
@@ -0,0 +1,5 @@
ng-template(#tooltip)
div {{time | date:'mediumDate'}}
.text-muted {{time | date:'mediumTime'}}
from-now([time]="time" [tooltip]="tooltip" [isDisabled]="!time")
@@ -0,0 +1,10 @@
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'time-field',
templateUrl: 'time-field.pug',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TimeField {
@Input() time: any;
}
@@ -0,0 +1,6 @@
div(*ngIf="userAgent" [tooltip]="userAgent")
fa-icon([icon]="browserClass" [fixedWidth]="true")
b.ml-1 {{browserVersion}}
fa-icon.ml-1([icon]="osClass" [fixedWidth]="true")
b.ml-1 {{osVersion}}
fa-icon.ml-1([icon]="deviceClass" [fixedWidth]="true")
@@ -0,0 +1,45 @@
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { UAParser } from 'ua-parser-js';
import { uaIcons, faQuestionCircle, faGlobe, faDesktop } from '../../../../client/icons';
function icon(value: string | undefined, defaultValue: any): any {
return value && uaIcons[value] || defaultValue;
}
const extensions = {
browser: [
[/(Amigo|YaBrowser)\/([\w\.]+)/i], [UAParser.BROWSER.NAME, UAParser.BROWSER.VERSION]
],
};
@Component({
selector: 'ua-info',
templateUrl: 'ua-info.pug',
styles: [`:host { display: inline-block; }`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UAInfo {
osClass?: string;
osVersion?: string;
browserClass?: string;
browserVersion?: string;
deviceClass?: string;
private _userAgent?: string;
@Input() set userAgent(value: string | undefined) {
if (this._userAgent !== value) {
this._userAgent = value;
const parser = new UAParser(value, extensions);
const { os, browser, device } = parser.getResult();
this.osVersion = os.version;
this.browserVersion = (browser.version || '').replace(/\..*$/, '');
this.osClass = icon(os.name, faQuestionCircle);
this.browserClass = icon(browser.name, faGlobe);
this.deviceClass = icon(device.type, faDesktop);
}
}
get userAgent() {
return this._userAgent;
}
}