Feature/progress widget (#760)

* Implemented a new widget to show when operations are occuring in the backend (tasks + progress events). Fixed an oversight on progress reporting where I sent 100F instead of 1F.

* Hooked in more progress events for tasks on the backend. Cleaned up code and integrated some RBS into it. CSS needed.

* Show a colored icon when events are active

* Added some styling to the progress widget
This commit is contained in:
Joseph Milazzo 2021-11-15 16:50:14 -06:00 committed by GitHub
parent a94fdbc9cb
commit 281352001d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 255 additions and 16 deletions

View File

@ -280,13 +280,13 @@ namespace API.Services
"[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}",
chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name);
}
var progress = Math.Max(0F, Math.Min(100F, i * 1F / chunkInfo.TotalChunks));
var progress = Math.Max(0F, Math.Min(1F, i * 1F / chunkInfo.TotalChunks));
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
MessageFactory.RefreshMetadataProgressEvent(library.Id, progress));
}
await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadataProgress,
MessageFactory.RefreshMetadataProgressEvent(library.Id, 100F));
MessageFactory.RefreshMetadataProgressEvent(library.Id, 1F));
_logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime);
}

View File

@ -8,7 +8,9 @@ using API.Entities.Enums;
using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
using API.SignalR;
using Hangfire;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
@ -19,14 +21,17 @@ namespace API.Services.Tasks
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<BackupService> _logger;
private readonly IDirectoryService _directoryService;
private readonly IHubContext<MessageHub> _messageHub;
private readonly IList<string> _backupFiles;
public BackupService(IUnitOfWork unitOfWork, ILogger<BackupService> logger, IDirectoryService directoryService, IConfiguration config)
public BackupService(IUnitOfWork unitOfWork, ILogger<BackupService> logger,
IDirectoryService directoryService, IConfiguration config, IHubContext<MessageHub> messageHub)
{
_unitOfWork = unitOfWork;
_logger = logger;
_directoryService = directoryService;
_messageHub = messageHub;
var maxRollingFiles = config.GetMaxRollingFiles();
var loggingSection = config.GetLoggingFileName();
@ -76,6 +81,8 @@ namespace API.Services.Tasks
return;
}
await SendProgress(0F);
var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace("/", "_").Replace(":", "_");
var zipPath = Path.Join(backupDirectory, $"kavita_backup_{dateString}.zip");
@ -92,8 +99,12 @@ namespace API.Services.Tasks
_directoryService.CopyFilesToDirectory(
_backupFiles.Select(file => Path.Join(DirectoryService.ConfigDirectory, file)).ToList(), tempDirectory);
await SendProgress(0.25F);
await CopyCoverImagesToBackupDirectory(tempDirectory);
await SendProgress(0.75F);
try
{
ZipFile.CreateFromDirectory(tempDirectory, zipPath);
@ -105,6 +116,7 @@ namespace API.Services.Tasks
DirectoryService.ClearAndDeleteDirectory(tempDirectory);
_logger.LogInformation("Database backup completed");
await SendProgress(1F);
}
private async Task CopyCoverImagesToBackupDirectory(string tempDirectory)
@ -137,6 +149,12 @@ namespace API.Services.Tasks
}
}
private async Task SendProgress(float progress)
{
await _messageHub.Clients.All.SendAsync(SignalREvents.BackupDatabaseProgress,
MessageFactory.BackupDatabaseProgressEvent(progress));
}
/// <summary>
/// Removes Database backups older than 30 days. If all backups are older than 30 days, the latest is kept.
/// </summary>

View File

@ -2,7 +2,9 @@
using System.Threading.Tasks;
using API.Interfaces;
using API.Interfaces.Services;
using API.SignalR;
using Hangfire;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace API.Services.Tasks
@ -16,14 +18,16 @@ namespace API.Services.Tasks
private readonly ILogger<CleanupService> _logger;
private readonly IBackupService _backupService;
private readonly IUnitOfWork _unitOfWork;
private readonly IHubContext<MessageHub> _messageHub;
public CleanupService(ICacheService cacheService, ILogger<CleanupService> logger,
IBackupService backupService, IUnitOfWork unitOfWork)
IBackupService backupService, IUnitOfWork unitOfWork, IHubContext<MessageHub> messageHub)
{
_cacheService = cacheService;
_logger = logger;
_backupService = backupService;
_unitOfWork = unitOfWork;
_messageHub = messageHub;
}
public void CleanupCacheDirectory()
@ -39,19 +43,31 @@ namespace API.Services.Tasks
public async Task Cleanup()
{
_logger.LogInformation("Starting Cleanup");
await SendProgress(0F);
_logger.LogInformation("Cleaning temp directory");
var tempDirectory = DirectoryService.TempDirectory;
DirectoryService.ClearDirectory(tempDirectory);
DirectoryService.ClearDirectory(DirectoryService.TempDirectory);
await SendProgress(0.1F);
CleanupCacheDirectory();
await SendProgress(0.25F);
_logger.LogInformation("Cleaning old database backups");
_backupService.CleanupBackups();
await SendProgress(0.50F);
_logger.LogInformation("Cleaning deleted cover images");
await DeleteSeriesCoverImages();
await SendProgress(0.6F);
await DeleteChapterCoverImages();
await SendProgress(0.7F);
await DeleteTagCoverImages();
await SendProgress(1F);
_logger.LogInformation("Cleanup finished");
}
private async Task SendProgress(float progress)
{
await _messageHub.Clients.All.SendAsync(SignalREvents.CleanupProgress,
MessageFactory.CleanupProgressEvent(progress));
}
private async Task DeleteSeriesCoverImages()
{
var images = await _unitOfWork.SeriesRepository.GetAllCoverImagesAsync();

View File

@ -244,7 +244,7 @@ namespace API.Services.Tasks
BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false));
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
MessageFactory.ScanLibraryProgressEvent(libraryId, 100));
MessageFactory.ScanLibraryProgressEvent(libraryId, 1F));
}
/// <summary>
@ -342,7 +342,7 @@ namespace API.Services.Tasks
await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id));
}
var progress = Math.Max(0, Math.Min(100, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize));
var progress = Math.Max(0, Math.Min(1, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize));
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
MessageFactory.ScanLibraryProgressEvent(library.Id, progress));
}
@ -405,7 +405,7 @@ namespace API.Services.Tasks
series.Name, $"{series.Name}_{series.NormalizedName}_{series.LocalizedName}_{series.LibraryId}_{series.Format}");
}
var progress = Math.Max(0F, Math.Min(100F, i * 1F / newSeries.Count));
var progress = Math.Max(0F, Math.Min(1F, i * 1F / newSeries.Count));
await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress,
MessageFactory.ScanLibraryProgressEvent(library.Id, progress));
i++;

View File

@ -89,6 +89,31 @@ namespace API.SignalR
};
}
public static SignalRMessage BackupDatabaseProgressEvent(float progress)
{
return new SignalRMessage()
{
Name = SignalREvents.BackupDatabaseProgress,
Body = new
{
Progress = progress
}
};
}
public static SignalRMessage CleanupProgressEvent(float progress)
{
return new SignalRMessage()
{
Name = SignalREvents.CleanupProgress,
Body = new
{
Progress = progress
}
};
}
public static SignalRMessage UpdateVersionEvent(UpdateNotificationDto update)
{
return new SignalRMessage

View File

@ -19,5 +19,13 @@
public const string OnlineUsers = "OnlineUsers";
public const string SeriesAddedToCollection = "SeriesAddedToCollection";
public const string ScanLibraryError = "ScanLibraryError";
/// <summary>
/// Event sent out during backing up the database
/// </summary>
public const string BackupDatabaseProgress = "BackupDatabaseProgress";
/// <summary>
/// Event sent out during cleaning up temp and cache folders
/// </summary>
public const string CleanupProgress = "CleanupProgress";
}
}

View File

@ -1,4 +1,4 @@
export interface ScanLibraryProgressEvent {
export interface ProgressEvent {
libraryId: number;
progress: number;
eventTime: string;

View File

@ -7,7 +7,7 @@ import { BehaviorSubject, ReplaySubject } from 'rxjs';
import { environment } from 'src/environments/environment';
import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component';
import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event';
import { ScanLibraryProgressEvent } from '../_models/events/scan-library-progress-event';
import { ProgressEvent } from '../_models/events/scan-library-progress-event';
import { ScanSeriesEvent } from '../_models/events/scan-series-event';
import { SeriesAddedEvent } from '../_models/events/series-added-event';
import { User } from '../_models/user';
@ -22,7 +22,9 @@ export enum EVENTS {
ScanLibraryProgress = 'ScanLibraryProgress',
OnlineUsers = 'OnlineUsers',
SeriesAddedToCollection = 'SeriesAddedToCollection',
ScanLibraryError = 'ScanLibraryError'
ScanLibraryError = 'ScanLibraryError',
BackupDatabaseProgress = 'BackupDatabaseProgress',
CleanupProgress = 'CleanupProgress'
}
export interface Message<T> {
@ -45,7 +47,7 @@ export class MessageHubService {
onlineUsers$ = this.onlineUsersSource.asObservable();
public scanSeries: EventEmitter<ScanSeriesEvent> = new EventEmitter<ScanSeriesEvent>();
public scanLibrary: EventEmitter<ScanLibraryProgressEvent> = new EventEmitter<ScanLibraryProgressEvent>();
public scanLibrary: EventEmitter<ProgressEvent> = new EventEmitter<ProgressEvent>(); // TODO: Refactor this name to be generic
public seriesAdded: EventEmitter<SeriesAddedEvent> = new EventEmitter<SeriesAddedEvent>();
public refreshMetadata: EventEmitter<RefreshMetadataEvent> = new EventEmitter<RefreshMetadataEvent>();
@ -90,6 +92,20 @@ export class MessageHubService {
this.scanLibrary.emit(resp.body);
});
this.hubConnection.on(EVENTS.BackupDatabaseProgress, resp => {
this.messagesSource.next({
event: EVENTS.BackupDatabaseProgress,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.CleanupProgress, resp => {
this.messagesSource.next({
event: EVENTS.CleanupProgress,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.RefreshMetadataProgress, resp => {
this.messagesSource.next({
event: EVENTS.RefreshMetadataProgress,

View File

@ -4,7 +4,7 @@ import { ToastrService } from 'ngx-toastr';
import { Subject } from 'rxjs';
import { take, takeUntil } from 'rxjs/operators';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { ScanLibraryProgressEvent } from 'src/app/_models/events/scan-library-progress-event';
import { ProgressEvent } from 'src/app/_models/events/scan-library-progress-event';
import { Library, LibraryType } from 'src/app/_models/library';
import { LibraryService } from 'src/app/_services/library.service';
import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service';
@ -40,7 +40,7 @@ export class ManageLibraryComponent implements OnInit, OnDestroy {
this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => {
if (event.event !== EVENTS.ScanLibraryProgress) return;
const scanEvent = event.payload as ScanLibraryProgressEvent;
const scanEvent = event.payload as ProgressEvent;
this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100};
if (scanEvent.progress === 0) {
this.scanInProgress[scanEvent.libraryId].timestamp = scanEvent.eventTime;

View File

@ -7,7 +7,7 @@ import { AppComponent } from './app.component';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbPopoverModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap';
import { NavHeaderComponent } from './nav-header/nav-header.component';
import { JwtInterceptor } from './_interceptors/jwt.interceptor';
import { UserLoginComponent } from './user-login/user-login.component';
@ -32,6 +32,7 @@ import { CollectionsModule } from './collections/collections.module';
import { ReadingListModule } from './reading-list/reading-list.module';
import { SAVER, getSaver } from './shared/_providers/saver.provider';
import { ConfigData } from './_models/config-data';
import { NavEventsToggleComponent } from './nav-events-toggle/nav-events-toggle.component';
@NgModule({
@ -48,6 +49,7 @@ import { ConfigData } from './_models/config-data';
RecentlyAddedComponent,
OnDeckComponent,
DashboardComponent,
NavEventsToggleComponent,
],
imports: [
HttpClientModule,
@ -59,6 +61,7 @@ import { ConfigData } from './_models/config-data';
NgbDropdownModule, // Nav
AutocompleteLibModule, // Nav
NgbPopoverModule, // Nav Events toggle
NgbRatingModule, // Series Detail
NgbNavModule,
NgbPaginationModule,

View File

@ -0,0 +1,22 @@
<ng-container>
<button type="button" class="btn btn-icon {{progressEventsSource.getValue().length > 0 ? 'colored' : ''}}"
[ngbPopover]="popContent" title="Activity" placement="bottom" [popoverClass]="'nav-events'">
<i aria-hidden="true" class="fa fa-wave-square"></i>
</button>
<ng-template #popContent>
<ul class="list-group list-group-flush dark-menu">
<li class="list-group-item dark-menu-item" *ngFor="let event of progressEvents$ | async">
<div class="spinner-border text-primary small-spinner"
role="status" title="Started at {{event.timestamp | date: 'short'}}"
attr.aria-valuetext="{{prettyPrintProgress(event.progress)}}%" [attr.aria-valuenow]="prettyPrintProgress(event.progress)">
<span class="sr-only">Scan for {{event.libraryName}} in progress</span>
</div>
{{prettyPrintProgress(event.progress)}}%
{{prettyPrintEvent(event.eventType)}} {{event.libraryName}}
</li>
<li class="list-group-item dark-menu-item" *ngIf="progressEventsSource.getValue().length === 0">Not much going on here</li>
</ul>
</ng-template>
</ng-container>

View File

@ -0,0 +1,23 @@
@import "../../theme/colors";
.small-spinner {
width: 1rem;
height: 1rem;
}
.nav-events {
background-color: white;
}
.nav-events .popover-body {
padding: 0px;
}
.btn-icon {
color: white;
}
.colored {
background-color: $primary-color;
border-radius: 60px;
}

View File

@ -0,0 +1,89 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core';
import { BehaviorSubject, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ProgressEvent } from '../_models/events/scan-library-progress-event';
import { User } from '../_models/user';
import { LibraryService } from '../_services/library.service';
import { EVENTS, Message, MessageHubService } from '../_services/message-hub.service';
interface ProcessedEvent {
eventType: string;
timestamp?: string;
progress: number;
libraryId: number;
libraryName: string;
}
type ProgressType = EVENTS.ScanLibraryProgress | EVENTS.RefreshMetadataProgress | EVENTS.BackupDatabaseProgress | EVENTS.CleanupProgress;
@Component({
selector: 'app-nav-events-toggle',
templateUrl: './nav-events-toggle.component.html',
styleUrls: ['./nav-events-toggle.component.scss']
})
export class NavEventsToggleComponent implements OnInit, OnDestroy {
@Input() user!: User;
private readonly onDestroy = new Subject<void>();
/**
* Events that come through and are merged (ie progress event gets merged into a progress event)
*/
progressEventsSource = new BehaviorSubject<ProcessedEvent[]>([]);
progressEvents$ = this.progressEventsSource.asObservable();
constructor(private messageHub: MessageHubService, private libraryService: LibraryService) { }
ngOnDestroy(): void {
this.onDestroy.next();
this.onDestroy.complete();
}
ngOnInit(): void {
this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => {
if (event.event === EVENTS.ScanLibraryProgress || event.event === EVENTS.RefreshMetadataProgress || event.event === EVENTS.BackupDatabaseProgress || event.event === EVENTS.CleanupProgress) {
this.processProgressEvent(event, event.event);
}
});
}
processProgressEvent(event: Message<ProgressEvent>, eventType: string) {
const scanEvent = event.payload as ProgressEvent;
console.log(event.event, event.payload);
this.libraryService.getLibraryNames().subscribe(names => {
const data = this.progressEventsSource.getValue();
const index = data.findIndex(item => item.eventType === eventType && item.libraryId === event.payload.libraryId);
if (index >= 0) {
data.splice(index, 1);
}
if (scanEvent.progress !== 1) {
const libraryName = names[scanEvent.libraryId] || '';
const newEvent = {eventType: eventType, timestamp: scanEvent.eventTime, progress: scanEvent.progress, libraryId: scanEvent.libraryId, libraryName};
data.push(newEvent);
}
this.progressEventsSource.next(data);
});
}
prettyPrintProgress(progress: number) {
return Math.trunc(progress * 100);
}
prettyPrintEvent(eventType: string) {
switch(eventType) {
case (EVENTS.ScanLibraryProgress): return 'Scanning ';
case (EVENTS.RefreshMetadataProgress): return 'Refreshing ';
case (EVENTS.CleanupProgress): return 'Clearing Cache';
case (EVENTS.BackupDatabaseProgress): return 'Backing up Database';
default: return eventType;
}
}
}

View File

@ -62,6 +62,10 @@
</button>
</div>
<div class="nav-item" *ngIf="(accountService.currentUser$ | async) as user">
<app-nav-events-toggle [user]="user"></app-nav-events-toggle>
</div>
<div ngbDropdown class="nav-item dropdown" display="dynamic" placement="bottom-right" *ngIf="(accountService.currentUser$ | async) as user" dropdown>
<button class="btn btn-outline-secondary primary-text" ngbDropdownToggle>
{{user.username | sentenceCase}}

View File

@ -94,6 +94,17 @@ $dark-item-accent-bg: #292d32;
border-color: $dark-form-border;
}
.dark-menu {
background-color: $dark-form-background-no-opacity;
border-color: $dark-form-background;
}
.dark-menu-item {
color: $dark-text-color;
background-color: $dark-form-background-no-opacity;
border-color: $dark-form-background;
}
.dropdown .list-group-item:hover {
background-color: $dark-hover-color;
}
@ -177,6 +188,10 @@ $dark-item-accent-bg: #292d32;
color: #efefef;
}
.nav-events, .nav-events .popover-body {
background-color: $dark-form-background-no-opacity;
}
}