Manage dialog polling

This commit is contained in:
shamoon 2025-11-04 21:25:15 -08:00
parent 01e9988bd2
commit 92be4e37ab
No known key found for this signature in database
3 changed files with 172 additions and 32 deletions

View File

@ -16,6 +16,11 @@
</div> </div>
} }
@if (!loading && !error) { @if (!loading && !error) {
<div class="d-flex justify-content-between align-items-center mb-2">
<p class="mb-0 text-muted small">
<ng-container i18n>Status updates every few seconds while bundles are being prepared.</ng-container>
</p>
</div>
@if (bundles.length === 0) { @if (bundles.length === 0) {
<p class="mb-0 text-muted fst-italic" i18n>No bulk share links currently exist.</p> <p class="mb-0 text-muted fst-italic" i18n>No bulk share links currently exist.</p>
} }
@ -26,17 +31,42 @@
<tr> <tr>
<th scope="col" i18n>Created</th> <th scope="col" i18n>Created</th>
<th scope="col" i18n>Status</th> <th scope="col" i18n>Status</th>
<th scope="col" i18n>Size</th>
<th scope="col" i18n>Expires</th> <th scope="col" i18n>Expires</th>
<th scope="col" i18n>Documents</th> <th scope="col" i18n>Documents</th>
<th scope="col" i18n>Actions</th> <th scope="col" i18n>File version</th>
<th scope="col" class="text-end" i18n>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (bundle of bundles; track bundle.id) { @for (bundle of bundles; track bundle.id) {
<tr> <tr>
<td>{{ bundle.created | date: 'short' }}</td>
<td> <td>
<span class="badge text-bg-secondary text-uppercase">{{ bundle.status }}</span> <div>{{ bundle.created | date: 'short' }}</div>
@if (bundle.built_at) {
<div class="small text-muted">
<ng-container i18n>Built:</ng-container> {{ bundle.built_at | date: 'short' }}
</div>
}
</td>
<td>
<div class="d-flex align-items-center gap-2">
@if (bundle.status === statuses.Processing || bundle.status === statuses.Pending) {
<span class="spinner-border spinner-border-sm" role="status"></span>
}
<span>{{ statusLabel(bundle.status) }}</span>
</div>
@if (bundle.last_error && bundle.status === statuses.Failed) {
<div class="small text-danger mt-1">{{ bundle.last_error }}</div>
}
</td>
<td>
@if (bundle.size_bytes !== undefined && bundle.size_bytes !== null) {
{{ bundle.size_bytes | fileSize }}
}
@if (bundle.size_bytes === undefined || bundle.size_bytes === null) {
<span class="text-muted">&mdash;</span>
}
</td> </td>
<td> <td>
@if (bundle.expiration) { @if (bundle.expiration) {
@ -47,11 +77,13 @@
} }
</td> </td>
<td>{{ bundle.document_count }}</td> <td>{{ bundle.document_count }}</td>
<td> <td>{{ fileVersionLabel(bundle.file_version) }}</td>
<td class="text-end">
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button <button
type="button" type="button"
class="btn btn-outline-primary" class="btn btn-outline-primary"
[disabled]="bundle.status !== statuses.Ready"
(click)="copy(bundle)" (click)="copy(bundle)"
> >
@if (copiedSlug === bundle.slug) { @if (copiedSlug === bundle.slug) {
@ -62,9 +94,21 @@
} }
<span class="visually-hidden" i18n>Copy link</span> <span class="visually-hidden" i18n>Copy link</span>
</button> </button>
@if (bundle.status === statuses.Failed) {
<button
type="button"
class="btn btn-outline-warning"
[disabled]="loading"
(click)="retry(bundle)"
>
<i-bs name="arrow-clockwise"></i-bs>
<span class="visually-hidden" i18n>Retry</span>
</button>
}
<button <button
type="button" type="button"
class="btn btn-outline-danger" class="btn btn-outline-danger"
[disabled]="loading"
(click)="delete(bundle)" (click)="delete(bundle)"
> >
<i-bs name="trash"></i-bs> <i-bs name="trash"></i-bs>

View File

@ -1,10 +1,15 @@
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core' import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first } from 'rxjs' import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
import { ShareBundleSummary } from 'src/app/data/share-bundle' import {
ShareBundleStatus,
ShareBundleSummary,
} from 'src/app/data/share-bundle'
import { FileVersion } from 'src/app/data/share-link'
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
import { ShareBundleService } from 'src/app/services/rest/share-bundle.service' import { ShareBundleService } from 'src/app/services/rest/share-bundle.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
@ -14,11 +19,11 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
selector: 'pngx-share-bundle-manage-dialog', selector: 'pngx-share-bundle-manage-dialog',
templateUrl: './share-bundle-manage-dialog.component.html', templateUrl: './share-bundle-manage-dialog.component.html',
standalone: true, standalone: true,
imports: [CommonModule, NgxBootstrapIconsModule], imports: [CommonModule, NgxBootstrapIconsModule, FileSizePipe],
}) })
export class ShareBundleManageDialogComponent export class ShareBundleManageDialogComponent
extends LoadingComponentWithPermissions extends LoadingComponentWithPermissions
implements OnInit implements OnInit, OnDestroy
{ {
private activeModal = inject(NgbActiveModal) private activeModal = inject(NgbActiveModal)
private shareBundleService = inject(ShareBundleService) private shareBundleService = inject(ShareBundleService)
@ -28,33 +33,70 @@ export class ShareBundleManageDialogComponent
title = $localize`Bulk Share Links` title = $localize`Bulk Share Links`
bundles: ShareBundleSummary[] = [] bundles: ShareBundleSummary[] = []
error: string error: string | null = null
copiedSlug: string copiedSlug: string | null = null
readonly statuses = ShareBundleStatus
readonly fileVersions = FileVersion
private readonly refresh$ = new Subject<boolean>()
private readonly statusLabels: Record<ShareBundleStatus, string> = {
[ShareBundleStatus.Pending]: $localize`Pending`,
[ShareBundleStatus.Processing]: $localize`Processing`,
[ShareBundleStatus.Ready]: $localize`Ready`,
[ShareBundleStatus.Failed]: $localize`Failed`,
}
private readonly fileVersionLabels: Record<FileVersion, string> = {
[FileVersion.Archive]: $localize`Archive`,
[FileVersion.Original]: $localize`Original`,
}
ngOnInit(): void { ngOnInit(): void {
this.fetchBundles() this.refresh$
.pipe(
switchMap((silent) => {
if (!silent) {
this.loading = true
}
this.error = null
return this.shareBundleService.listAllBundles().pipe(
catchError((error) => {
if (!silent) {
this.loading = false
}
this.error = $localize`Failed to load bulk share links.`
this.toastService.showError(
$localize`Error retrieving bulk share links.`,
error
)
return of(null)
})
)
}),
takeUntil(this.unsubscribeNotifier)
)
.subscribe((results) => {
if (results) {
this.bundles = results
this.copiedSlug = null
}
this.loading = false
})
this.triggerRefresh(false)
timer(5000, 5000)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => this.triggerRefresh(true))
}
ngOnDestroy(): void {
super.ngOnDestroy()
} }
fetchBundles(): void { fetchBundles(): void {
this.loading = true this.triggerRefresh(false)
this.error = null
this.shareBundleService
.listAllBundles()
.pipe(first())
.subscribe({
next: (results) => {
this.bundles = results
this.loading = false
},
error: (e) => {
this.loading = false
this.error = $localize`Failed to load bulk share links.`
this.toastService.showError(
$localize`Error retrieving bulk share links.`,
e
)
},
})
} }
getShareUrl(bundle: ShareBundleSummary): string { getShareUrl(bundle: ShareBundleSummary): string {
@ -65,22 +107,29 @@ export class ShareBundleManageDialogComponent
} }
copy(bundle: ShareBundleSummary): void { copy(bundle: ShareBundleSummary): void {
if (bundle.status !== ShareBundleStatus.Ready) {
return
}
const success = this.clipboard.copy(this.getShareUrl(bundle)) const success = this.clipboard.copy(this.getShareUrl(bundle))
if (success) { if (success) {
this.copiedSlug = bundle.slug this.copiedSlug = bundle.slug
setTimeout(() => { setTimeout(() => {
this.copiedSlug = null this.copiedSlug = null
}, 3000) }, 3000)
this.toastService.showInfo($localize`Share link copied to clipboard.`)
} }
} }
delete(bundle: ShareBundleSummary): void { delete(bundle: ShareBundleSummary): void {
this.error = null
this.loading = true
this.shareBundleService.delete(bundle).subscribe({ this.shareBundleService.delete(bundle).subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Bulk share link deleted.`) this.toastService.showInfo($localize`Bulk share link deleted.`)
this.fetchBundles() this.triggerRefresh(false)
}, },
error: (e) => { error: (e) => {
this.loading = false
this.toastService.showError( this.toastService.showError(
$localize`Error deleting bulk share link.`, $localize`Error deleting bulk share link.`,
e e
@ -89,7 +138,47 @@ export class ShareBundleManageDialogComponent
}) })
} }
retry(bundle: ShareBundleSummary): void {
this.error = null
this.shareBundleService.rebuildBundle(bundle.id).subscribe({
next: (updated) => {
this.toastService.showInfo(
$localize`Bulk share link rebuild requested.`
)
this.replaceBundle(updated)
},
error: (e) => {
this.toastService.showError($localize`Error requesting rebuild.`, e)
},
})
}
statusLabel(status: ShareBundleStatus): string {
return this.statusLabels[status] ?? status
}
fileVersionLabel(version: FileVersion): string {
return this.fileVersionLabels[version] ?? version
}
close(): void { close(): void {
this.activeModal.close() this.activeModal.close()
} }
private replaceBundle(updated: ShareBundleSummary): void {
const index = this.bundles.findIndex((bundle) => bundle.id === updated.id)
if (index >= 0) {
this.bundles = [
...this.bundles.slice(0, index),
updated,
...this.bundles.slice(index + 1),
]
} else {
this.bundles = [updated, ...this.bundles]
}
}
private triggerRefresh(silent: boolean): void {
this.refresh$.next(silent)
}
} }

View File

@ -22,6 +22,13 @@ export class ShareBundleService extends AbstractNameFilterService<ShareBundleSum
this.clearCache() this.clearCache()
return this.http.post<ShareBundleSummary>(this.getResourceUrl(), payload) return this.http.post<ShareBundleSummary>(this.getResourceUrl(), payload)
} }
rebuildBundle(bundleId: number): Observable<ShareBundleSummary> {
this.clearCache()
return this.http.post<ShareBundleSummary>(
this.getResourceUrl(bundleId, 'rebuild'),
{}
)
}
listAllBundles(): Observable<ShareBundleSummary[]> { listAllBundles(): Observable<ShareBundleSummary[]> {
return this.list(1, 1000, 'created', true).pipe( return this.list(1, 1000, 'created', true).pipe(