Feature: system status (#5743)

This commit is contained in:
shamoon
2024-03-04 09:26:25 -08:00
committed by GitHub
parent 23ceb2a5ec
commit f6084acfc8
19 changed files with 1129 additions and 83 deletions

View File

@@ -0,0 +1,154 @@
<div class="modal-header">
<h5 class="modal-title" id="modal-basic-title" i18n>System Status</h5>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
@if (!status) {
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
<div>
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</div>
</div>
} @else {
<div class="row row-cols-1 row-cols-md-3 g-3">
<div class="col">
<div class="card bg-light h-100">
<div class="card-header">
<h5 class="card-title mb-0" i18n>Environment</h5>
</div>
<div class="card-body">
<dl class="card-text">
<dt i18n>Paperless-ngx Version</dt>
<dd>{{status.pngx_version}}</dd>
<dt i18n>Install Type</dt>
<dd>{{status.install_type}}</dd>
<dt i18n>Server OS</dt>
<dd>{{status.server_os}}</dd>
<dt i18n>Media Storage</dt>
<dd>
<ngb-progressbar style="height: 4px;" class="mt-2 mb-1" type="primary" [max]="status.storage.total" [value]="status.storage.total - status.storage.available"></ngb-progressbar>
<span class="small">{{status.storage.available | filesize}} <ng-container i18n>available</ng-container> ({{status.storage.total | filesize}} <ng-container i18n>total</ng-container>)</span>
</dd>
</dl>
</div>
</div>
</div>
<div class="col">
<div class="card bg-light h-100">
<div class="card-header">
<h5 class="card-title mb-0" i18n>Database</h5>
</div>
<div class="card-body">
<dl class="card-text">
<dt i18n>Type</dt>
<dd>{{status.database.type}}</dd>
<dt i18n>Status</dt>
<dd class="d-flex align-items-center">
{{status.database.status}}
@if (status.database.status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.database.url}}" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.database.url}}: {{status.database.error}}" triggers="mouseenter:mouseleave"></i-bs>
}
</dd>
<dt i18n>Migration Status</dt>
<dd class="d-flex align-items-center">
@if (status.database.migration_status.unapplied_migrations.length === 0) {
<ng-container>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<ng-container>{{status.database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
}
<ng-template #migrationStatus>
<h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
@if (status.database.migration_status.unapplied_migrations.length > 0) {
<h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6>
<ul>
@for (migration of status.database.migration_status.unapplied_migrations; track migration) {
<li class="font-monospace small">{{migration}}</li>
}
</ul>
}
</ng-template>
</dd>
</dl>
</div>
</div>
</div>
<div class="col">
<div class="card bg-light h-100">
<div class="card-header">
<h5 class="card-title mb-0" i18n>Tasks</h5>
</div>
<div class="card-body">
<dl class="card-text">
<dt i18n>Redis Status</dt>
<dd class="d-flex align-items-center">
{{status.tasks.redis_status}}
@if (status.tasks.redis_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}: {{status.tasks.redis_error}}" triggers="mouseenter:mouseleave"></i-bs>
}
</dd>
<dt i18n>Celery Status</dt>
<dd class="d-flex align-items-center">
{{status.tasks.celery_status}}
@if (status.tasks.celery_status === 'OK') {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
}
</dd>
<dt i18n>Search Index</dt>
<dd class="d-flex align-items-center">
{{status.tasks.index_status}}
@if (status.tasks.index_status === 'OK') {
@if (isStale(status.tasks.index_last_modified)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.index_error}}" triggers="mouseenter:mouseleave"></i-bs>
}
</dd>
<ng-template #indexStatus>
<h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span>
</ng-template>
<dt i18n>Classifier</dt>
<dd class="d-flex align-items-center">
{{status.tasks.classifier_status}}
@if (status.tasks.classifier_status === 'OK') {
@if (isStale(status.tasks.classifier_last_trained)) {
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
} @else {
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
}
} @else {
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.classifier_error}}" triggers="mouseenter:mouseleave"></i-bs>
}
</dd>
<ng-template #classifierStatus>
<h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span>
</ng-template>
</dl>
</div>
</div>
</div>
</div>
}
</div>
<div class="modal-footer">
<button class="btn btn-sm btn-outline-secondary" (click)="copy()">
@if (!copied) {
<i-bs name="clipboard-fill"></i-bs>&nbsp;
}
@if (copied) {
<i-bs name="clipboard-check-fill"></i-bs>&nbsp;
}
<ng-container i18n>Copy</ng-container>
</button>
</div>

View File

@@ -0,0 +1,103 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import {
NgbActiveModal,
NgbModalModule,
NgbPopoverModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'
import { SystemStatusDialogComponent } from './system-status-dialog.component'
import {
SystemStatusItemStatus,
InstallType,
SystemStatus,
} from 'src/app/data/system-status'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NgxFilesizeModule } from 'ngx-filesize'
const status: SystemStatus = {
pngx_version: '2.4.3',
server_os: 'macOS-14.1.1-arm64-arm-64bit',
install_type: InstallType.BareMetal,
storage: { total: 494384795648, available: 13573525504 },
database: {
type: 'sqlite',
url: '/paperless-ngx/data/db.sqlite3',
status: SystemStatusItemStatus.ERROR,
error: null,
migration_status: {
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
unapplied_migrations: [],
},
},
tasks: {
redis_url: 'redis://localhost:6379',
redis_status: SystemStatusItemStatus.ERROR,
redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
celery_status: SystemStatusItemStatus.ERROR,
index_status: SystemStatusItemStatus.OK,
index_last_modified: new Date().toISOString(),
index_error: null,
classifier_status: SystemStatusItemStatus.OK,
classifier_last_trained: new Date().toISOString(),
classifier_error: null,
},
}
describe('SystemStatusDialogComponent', () => {
let component: SystemStatusDialogComponent
let fixture: ComponentFixture<SystemStatusDialogComponent>
let clipboard: Clipboard
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [SystemStatusDialogComponent],
providers: [NgbActiveModal],
imports: [
NgbModalModule,
ClipboardModule,
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
NgxFilesizeModule,
NgbPopoverModule,
NgbProgressbarModule,
],
}).compileComponents()
fixture = TestBed.createComponent(SystemStatusDialogComponent)
component = fixture.componentInstance
component.status = status
clipboard = TestBed.inject(Clipboard)
fixture.detectChanges()
})
it('should close the active modal', () => {
const closeSpy = jest.spyOn(component.activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
})
it('should copy the system status to clipboard', fakeAsync(() => {
jest.spyOn(clipboard, 'copy')
component.copy()
expect(clipboard.copy).toHaveBeenCalledWith(
JSON.stringify(component.status)
)
expect(component.copied).toBeTruthy()
tick(3000)
expect(component.copied).toBeFalsy()
}))
it('should calculate if date is stale', () => {
const date = new Date()
date.setHours(date.getHours() - 25)
expect(component.isStale(date.toISOString())).toBeTruthy()
expect(component.isStale(date.toISOString(), 26)).toBeFalsy()
})
})

View File

@@ -0,0 +1,39 @@
import { Component, Input } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { SystemStatus } from 'src/app/data/system-status'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { Clipboard } from '@angular/cdk/clipboard'
@Component({
selector: 'pngx-system-status-dialog',
templateUrl: './system-status-dialog.component.html',
styleUrl: './system-status-dialog.component.scss',
})
export class SystemStatusDialogComponent {
public status: SystemStatus
public copied: boolean = false
constructor(
public activeModal: NgbActiveModal,
private clipboard: Clipboard
) {}
public close() {
this.activeModal.close()
}
public copy() {
this.clipboard.copy(JSON.stringify(this.status))
this.copied = true
setTimeout(() => {
this.copied = false
}, 3000)
}
public isStale(dateStr: string, hours: number = 24): boolean {
const date = new Date(dateStr)
const now = new Date()
return now.getTime() - date.getTime() > hours * 60 * 60 * 1000
}
}