Update Notifier (#464)

# Added
- Added: Ability to check for updates (stable-only) and be notified with a changelog. This is a first pass implementation. 
- Added: Ability to use SignalR within Kavita (websockets)
=====================================

* (some debug code present). Implemented the ability to check and log if the server is up to date or not.

* Fixed a bug for dark mode where anchor buttons wouldn't have the correct font color.

Suppress filter/sort button if there is no filters to show.

Debug: Active indicators for users currently on your server.

Refactored code to send update notification only to admins. Admins now get a popup where they can open the Github release (docker users can just close).

* Fixed an issue where getLibraryNames on first load would call for as many cards there was on the screen. Now we call it much earlier and the data is cached faster.

* Fixed a dark mode bug from previous commit

* Release notes is now rendered markdown

* Implemented the ability to check for an update ad-hoc. Response will come via websocket to all admins.

* Fixed a missing padding

* Cleanup, added some temp code to carousel

* Cleaned up old stat stuff from dev config and added debug only flow for checking for update

* Misc cleanup

* Added readonly to one variable

* Fixed In Progress not showing for all series due to pagination bug

* Fixed the In progress API returning back series that had another users progress on them. Added SplitQuery which speeds up query significantly.

* SplitQuery in GetRecentlyAdded for a speed increase on API.

Fixed the logic on VersionUpdaterService to properly send on non-dev systems.

Disable the check button once it's triggered once since the API does a task, so it can't return anything.

* Cleaned up the admin actions to be more friendly on mobile.

* Cleaned up the message as we wait for SingalR to notify the user

* more textual changes

* Code smells
This commit is contained in:
Joseph Milazzo 2021-08-09 08:52:24 -05:00 committed by GitHub
parent 867b8e5c8a
commit 2809233de0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 753 additions and 100 deletions

View file

@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { CanActivate } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { map, take } from 'rxjs/operators';
import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
@ -14,7 +14,7 @@ export class AdminGuard implements CanActivate {
canActivate(): Observable<boolean> {
// this automaticallys subs due to being router guard
return this.accountService.currentUser$.pipe(
return this.accountService.currentUser$.pipe(take(1),
map((user: User) => {
if (this.accountService.hasAdminRole(user)) {
return true;

View file

@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { CanActivate, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { map, take } from 'rxjs/operators';
import { User } from '../_models/user';
import { AccountService } from '../_services/account.service';
@ -14,7 +14,7 @@ export class AuthGuard implements CanActivate {
constructor(private accountService: AccountService, private router: Router, private toastr: ToastrService) {}
canActivate(): Observable<boolean> {
return this.accountService.currentUser$.pipe(
return this.accountService.currentUser$.pipe(take(1),
map((user: User) => {
if (user) {
return true;

View file

@ -0,0 +1,8 @@
export interface UpdateVersionEvent {
currentVersion: string;
updateVersion: string;
updateBody: string;
updateTitle: string;
updateUrl: string;
isDocker: boolean;
}

View file

@ -7,6 +7,8 @@ import { Preferences } from '../_models/preferences/preferences';
import { User } from '../_models/user';
import * as Sentry from "@sentry/angular";
import { Router } from '@angular/router';
import { MessageHubService } from './message-hub.service';
import { PresenceHubService } from './presence-hub.service';
@Injectable({
providedIn: 'root'
@ -23,7 +25,8 @@ export class AccountService implements OnDestroy {
private readonly onDestroy = new Subject<void>();
constructor(private httpClient: HttpClient, private router: Router) {}
constructor(private httpClient: HttpClient, private router: Router,
private messageHub: MessageHubService, private presenceHub: PresenceHubService) {}
ngOnDestroy(): void {
this.onDestroy.next();
@ -48,6 +51,8 @@ export class AccountService implements OnDestroy {
const user = response;
if (user) {
this.setCurrentUser(user);
this.messageHub.createHubConnection(user);
this.presenceHub.createHubConnection(user);
}
}),
takeUntil(this.onDestroy)
@ -79,6 +84,8 @@ export class AccountService implements OnDestroy {
this.currentUser = undefined;
// Upon logout, perform redirection
this.router.navigateByUrl('/login');
this.messageHub.stopHubConnection();
this.presenceHub.stopHubConnection();
}
register(model: {username: string, password: string, isAdmin?: boolean}) {

View file

@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { of } from 'rxjs';
import { map } from 'rxjs/operators';
import { map, take } from 'rxjs/operators';
import { environment } from 'src/environments/environment';
import { Library, LibraryType } from '../_models/library';
import { SearchResult } from '../_models/search-result';

View file

@ -0,0 +1,57 @@
import { Injectable } from '@angular/core';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
import { User } from '@sentry/angular';
import { environment } from 'src/environments/environment';
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
export enum EVENTS {
UpdateAvailable = 'UpdateAvailable'
}
@Injectable({
providedIn: 'root'
})
export class MessageHubService {
hubUrl = environment.hubUrl;
private hubConnection!: HubConnection;
private updateNotificationModalRef: NgbModalRef | null = null;
constructor(private modalService: NgbModal) { }
createHubConnection(user: User) {
this.hubConnection = new HubConnectionBuilder()
.withUrl(this.hubUrl + 'messages', {
accessTokenFactory: () => user.token
})
.withAutomaticReconnect()
.build();
this.hubConnection
.start()
.catch(err => console.error(err));
this.hubConnection.on('receiveMessage', body => {
console.log('[Hub] Body: ', body);
});
this.hubConnection.on(EVENTS.UpdateAvailable, resp => {
// Ensure only 1 instance of UpdateNotificationModal can be open at once
if (this.updateNotificationModalRef != null) { return; }
this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' });
this.updateNotificationModalRef.componentInstance.updateData = resp.body;
this.updateNotificationModalRef.closed.subscribe(() => {
this.updateNotificationModalRef = null;
});
});
}
stopHubConnection() {
this.hubConnection.stop().catch(err => console.error(err));
}
sendMessage(methodName: string, body?: any) {
return this.hubConnection.invoke(methodName, body);
}
}

View file

@ -0,0 +1,40 @@
import { Injectable } from '@angular/core';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { User } from '@sentry/angular';
import { ToastrService } from 'ngx-toastr';
import { BehaviorSubject } from 'rxjs';
import { environment } from 'src/environments/environment';
@Injectable({
providedIn: 'root'
})
export class PresenceHubService {
hubUrl = environment.hubUrl;
private hubConnection!: HubConnection;
private onlineUsersSource = new BehaviorSubject<string[]>([]);
onlineUsers$ = this.onlineUsersSource.asObservable();
constructor(private toatsr: ToastrService) { }
createHubConnection(user: User) {
this.hubConnection = new HubConnectionBuilder()
.withUrl(this.hubUrl + 'presence', {
accessTokenFactory: () => user.token
})
.withAutomaticReconnect()
.build();
this.hubConnection
.start()
.catch(err => console.error(err));
this.hubConnection.on('GetOnlineUsers', (usernames: string[]) => {
this.onlineUsersSource.next(usernames);
});
}
stopHubConnection() {
this.hubConnection.stop().catch(err => console.error(err));
}
}

View file

@ -27,4 +27,8 @@ export class ServerService {
backupDatabase() {
return this.httpClient.post(this.baseUrl + 'server/backup-db', {});
}
checkForUpdate() {
return this.httpClient.post(this.baseUrl + 'server/check-update', {});
}
}

View file

@ -2,7 +2,7 @@ import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminRoutingModule } from './admin-routing.module';
import { DashboardComponent } from './dashboard/dashboard.component';
import { NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbDropdownModule, NgbNavModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
import { ManageLibraryComponent } from './manage-library/manage-library.component';
import { ManageUsersComponent } from './manage-users/manage-users.component';
import { LibraryEditorModalComponent } from './_modals/library-editor-modal/library-editor-modal.component';
@ -40,6 +40,7 @@ import { ManageSystemComponent } from './manage-system/manage-system.component';
FormsModule,
NgbNavModule,
NgbTooltipModule,
NgbDropdownModule,
SharedModule,
],
providers: []

View file

@ -1,23 +1,29 @@
<div class="container-fluid">
<div class="float-right">
<button class="btn btn-secondary mr-2" (click)="clearCache()" [disabled]="clearCacheInProgress">
<ng-container *ngIf="clearCacheInProgress">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
</ng-container>
Clear Cache
</button>
<button class="btn btn-secondary mr-2" (click)="backupDB()" [disabled]="backupDBInProgress">
<ng-container *ngIf="backupDBInProgress">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
</ng-container>
Backup Database
</button>
<button class="btn btn-secondary" (click)="downloadService.downloadLogs()">
Download Logs
</button>
<div class="d-inline-block" ngbDropdown #myDrop="ngbDropdown">
<button class="btn btn-outline-primary mr-2" id="dropdownManual" ngbDropdownAnchor (focus)="myDrop.open()">
<ng-container *ngIf="backupDBInProgress || clearCacheInProgress">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
</ng-container>
Actions
</button>
<div ngbDropdownMenu aria-labelledby="dropdownManual">
<button ngbDropdownItem (click)="backupDB()" [disabled]="backupDBInProgress">
Backup Database
</button>
<button ngbDropdownItem (click)="clearCache()" [disabled]="clearCacheInProgress">
Clear Cache
</button>
<button ngbDropdownItem (click)="downloadService.downloadLogs()">
Download Logs
</button>
<button ngbDropdownItem (click)="checkForUpdates()" [disabled]="hasCheckedForUpdate">
Check for Updates
</button>
</div>
</div>
</div>
<h3>About System</h3>

View file

@ -1,6 +1,5 @@
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { Title } from '@angular/platform-browser';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs/operators';
import { DownloadService } from 'src/app/shared/_services/download.service';
@ -22,6 +21,7 @@ export class ManageSystemComponent implements OnInit {
clearCacheInProgress: boolean = false;
backupDBInProgress: boolean = false;
hasCheckedForUpdate: boolean = false;
constructor(private settingsService: SettingsService, private toastr: ToastrService,
private serverService: ServerService, public downloadService: DownloadService) { }
@ -80,4 +80,11 @@ export class ManageSystemComponent implements OnInit {
});
}
checkForUpdates() {
this.hasCheckedForUpdate = true;
this.serverService.checkForUpdate().subscribe(() => {
this.toastr.info('This might take a few minutes. If an update is available, the server will notify you.');
});
}
}

View file

@ -9,7 +9,7 @@
<li *ngFor="let member of members" class="list-group-item">
<div>
<h4>
{{member.username | titlecase}} <span *ngIf="member.isAdmin" class="badge badge-pill badge-secondary">Admin</span>
<i class="presence fa fa-circle" title="Active" aria-hidden="true" *ngIf="(presence.onlineUsers$ | async)?.includes(member.username)"></i>{{member.username | titlecase}} <span *ngIf="member.isAdmin" class="badge badge-pill badge-secondary">Admin</span>
<div class="float-right" *ngIf="canEditMember(member)">
<button class="btn btn-danger mr-2" (click)="deleteUser(member)" placement="top" ngbTooltip="Delete User" attr.aria-label="Delete User {{member.username | titlecase}}"><i class="fa fa-trash" aria-hidden="true"></i></button>
<button class="btn btn-secondary mr-2" (click)="updatePassword(member)" placement="top" ngbTooltip="Change Password" attr.aria-label="Change Password for {{member.username | titlecase}}"><i class="fa fa-key" aria-hidden="true"></i></button>

View file

@ -0,0 +1,3 @@
.presence {
font-size: 12px;
}

View file

@ -1,6 +1,6 @@
import { Component, OnInit } from '@angular/core';
import { Component, OnDestroy, OnInit } from '@angular/core';
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { take } from 'rxjs/operators';
import { take, takeUntil } from 'rxjs/operators';
import { MemberService } from 'src/app/_services/member.service';
import { Member } from 'src/app/_models/member';
import { User } from 'src/app/_models/user';
@ -10,13 +10,15 @@ import { ToastrService } from 'ngx-toastr';
import { ResetPasswordModalComponent } from '../_modals/reset-password-modal/reset-password-modal.component';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { EditRbsModalComponent } from '../_modals/edit-rbs-modal/edit-rbs-modal.component';
import { PresenceHubService } from 'src/app/_services/presence-hub.service';
import { Subject } from 'rxjs';
@Component({
selector: 'app-manage-users',
templateUrl: './manage-users.component.html',
styleUrls: ['./manage-users.component.scss']
})
export class ManageUsersComponent implements OnInit {
export class ManageUsersComponent implements OnInit, OnDestroy {
members: Member[] = [];
loggedInUsername = '';
@ -25,20 +27,29 @@ export class ManageUsersComponent implements OnInit {
createMemberToggle = false;
loadingMembers = false;
private onDestroy = new Subject<void>();
constructor(private memberService: MemberService,
private accountService: AccountService,
private modalService: NgbModal,
private toastr: ToastrService,
private confirmService: ConfirmService) {
private confirmService: ConfirmService,
public presence: PresenceHubService) {
this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => {
this.loggedInUsername = user.username;
});
}
ngOnInit(): void {
this.loadMembers();
}
ngOnDestroy() {
this.onDestroy.next();
this.onDestroy.complete();
}
loadMembers() {
this.loadingMembers = true;
this.memberService.getMembers().subscribe(members => {

View file

@ -69,7 +69,6 @@ export class AllCollectionsComponent implements OnInit {
}
loadPage() {
// TODO: See if we can move this pagination code into layout code
const page = this.route.snapshot.queryParamMap.get('page');
if (page != null) {
if (this.seriesPagination === undefined || this.seriesPagination === null) {

View file

@ -1,6 +1,10 @@
import { Component, OnInit } from '@angular/core';
import { take } from 'rxjs/operators';
import { AccountService } from './_services/account.service';
import { LibraryService } from './_services/library.service';
import { MessageHubService } from './_services/message-hub.service';
import { NavService } from './_services/nav.service';
import { PresenceHubService } from './_services/presence-hub.service';
import { StatsService } from './_services/stats.service';
@Component({
@ -10,7 +14,9 @@ import { StatsService } from './_services/stats.service';
})
export class AppComponent implements OnInit {
constructor(private accountService: AccountService, public navService: NavService, private statsService: StatsService) { }
constructor(private accountService: AccountService, public navService: NavService,
private statsService: StatsService, private messageHub: MessageHubService,
private presenceHub: PresenceHubService, private libraryService: LibraryService) { }
ngOnInit(): void {
this.setCurrentUser();
@ -28,6 +34,9 @@ export class AppComponent implements OnInit {
if (user) {
this.navService.setDarkMode(user.preferences.siteDarkMode);
this.messageHub.createHubConnection(user);
this.presenceHub.createHubConnection(user);
this.libraryService.getLibraryNames().pipe(take(1)).subscribe(() => {/* No Operation */});
} else {
this.navService.setDarkMode(true);
}

View file

@ -15,8 +15,11 @@ export class CarouselReelComponent implements OnInit {
swiper!: Swiper;
trackByIdentity: (index: number, item: any) => string;
constructor() { }
constructor() {
this.trackByIdentity = (index: number, item: any) => `${this.title}_${item.id}_${item?.name}_${item?.pagesRead}_${index}`;
}
ngOnInit(): void {}

View file

@ -49,7 +49,7 @@ export class LibraryComponent implements OnInit, OnDestroy {
this.accountService.currentUser$.pipe(take(1)).subscribe(user => {
this.user = user;
this.isAdmin = this.accountService.hasAdminRole(this.user);
this.libraryService.getLibrariesForMember().subscribe(libraries => {
this.libraryService.getLibrariesForMember().pipe(take(1)).subscribe(libraries => {
this.libraries = libraries;
this.isLoading = false;
});
@ -81,8 +81,9 @@ export class LibraryComponent implements OnInit, OnDestroy {
if (series === true || series === false) {
if (!series) {return;}
}
if ((series as Series).pagesRead !== (series as Series).pages && (series as Series).pagesRead !== 0) {
// If the update to Series doesn't affect the requirement to be in this stream, then ignore update request
const seriesObj = (series as Series);
if (seriesObj.pagesRead !== seriesObj.pages && seriesObj.pagesRead !== 0) {
return;
}

View file

@ -65,7 +65,7 @@ export class DomHelperService {
if (tagName === 'A' || tagName === 'AREA') {
return (el.attributes.getNamedItem('href') !== '');
}
return !el.attributes.hasOwnProperty('disabled'); // TODO: check for cases when: disabled="true" and disabled="false"
return !el.attributes.hasOwnProperty('disabled'); // check for cases when: disabled="true" and disabled="false"
}
return false;
}

View file

@ -10,7 +10,7 @@
</h2>
</div>
<button class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
<button *ngIf="filters !== undefined && filters.length > 0" class="btn btn-secondary btn-small" (click)="collapse.toggle()" [attr.aria-expanded]="!filteringCollapsed" placement="left" ngbTooltip="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting" attr.aria-label="{{filteringCollapsed ? 'Open' : 'Close'}} Filtering and Sorting">
<i class="fa fa-filter" aria-hidden="true"></i>
<span class="sr-only">Sort / Filter</span>
</button>

View file

@ -10,8 +10,6 @@ import { ActionItem } from 'src/app/_services/action-factory.service';
import { ImageService } from 'src/app/_services/image.service';
import { LibraryService } from 'src/app/_services/library.service';
import { UtilityService } from '../_services/utility.service';
// import 'lazysizes';
// import 'lazysizes/plugins/attrchange/ls.attrchange';
@Component({
selector: 'app-card-item',

View file

@ -17,6 +17,7 @@ import { CardDetailLayoutComponent } from './card-detail-layout/card-detail-layo
import { ShowIfScrollbarDirective } from './show-if-scrollbar.directive';
import { A11yClickDirective } from './a11y-click.directive';
import { SeriesFormatComponent } from './series-format/series-format.component';
import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.component';
@NgModule({
@ -33,7 +34,8 @@ import { SeriesFormatComponent } from './series-format/series-format.component';
CardDetailLayoutComponent,
ShowIfScrollbarDirective,
A11yClickDirective,
SeriesFormatComponent
SeriesFormatComponent,
UpdateNotificationModalComponent
],
imports: [
CommonModule,

View file

@ -0,0 +1,15 @@
<div class="modal-header">
<h4 class="modal-title">New Update Available!</h4>
<button type="button" class="close" aria-label="Close" (click)="close()">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<h5>{{updateData.updateTitle}}</h5>
<pre class="update-body" [innerHtml]="updateData.updateBody | safeHtml"></pre>
</div>
<div class="modal-footer">
<button type="button" class="btn {{updateData.isDocker ? 'btn-primary' : 'btn-secondary'}}" (click)="close()">Close</button>
<a *ngIf="!updateData.isDocker" href="{{updateData.updateUrl}}" class="btn btn-primary" target="_blank" (click)="close()">Download</a>
</div>

View file

@ -0,0 +1,5 @@
.update-body {
width: 100%;
word-wrap: break-word;
white-space: pre-wrap;
}

View file

@ -0,0 +1,26 @@
import { Component, Input, OnInit } from '@angular/core';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { UpdateVersionEvent } from 'src/app/_models/events/update-version-event';
@Component({
selector: 'app-update-notification-modal',
templateUrl: './update-notification-modal.component.html',
styleUrls: ['./update-notification-modal.component.scss']
})
export class UpdateNotificationModalComponent implements OnInit {
@Input() updateData!: UpdateVersionEvent;
constructor(public modal: NgbActiveModal) { }
ngOnInit(): void {
}
close() {
this.modal.close({success: false, series: undefined});
}
}

View file

@ -9,7 +9,7 @@
<form [formGroup]="loginForm" (ngSubmit)="login()">
<div class="form-group">
<label for="username">Username</label>
<input class="form-control" formControlName="username" id="username" type="text">
<input class="form-control" formControlName="username" id="username" type="text" autofocus>
</div>
<div class="form-group">

View file

@ -23,6 +23,11 @@ $dark-item-accent-bg: #292d32;
color: #4ac694;
}
a.btn {
color: white;
}
.accent {
background-color: $dark-form-background !important;
color: lightgray !important;
@ -37,7 +42,7 @@ $dark-item-accent-bg: #292d32;
}
}
.btn-information, .btn-outline-secondary {
.btn-information, .btn-outline-secondary, pre {
color: $dark-text-color;
}

View file

@ -1,4 +1,5 @@
export const environment = {
production: true,
apiUrl: '/api/'
apiUrl: '/api/',
hubUrl: '/hubs/'
};

View file

@ -4,7 +4,8 @@
export const environment = {
production: false,
apiUrl: 'http://localhost:5000/api/'
apiUrl: 'http://localhost:5000/api/',
hubUrl: 'http://localhost:5000/hubs/'
};
/*