diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 338eeda43..a62059c21 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -1540,7 +1540,7 @@ Error retrieving users src/app/components/admin/settings/settings.component.ts - 182 + 185 src/app/components/admin/users-groups/users-groups.component.ts @@ -1551,7 +1551,7 @@ Error retrieving groups src/app/components/admin/settings/settings.component.ts - 201 + 204 src/app/components/admin/users-groups/users-groups.component.ts @@ -1562,35 +1562,35 @@ Saved view "" deleted. src/app/components/admin/settings/settings.component.ts - 414 + 417 Settings were saved successfully. src/app/components/admin/settings/settings.component.ts - 540 + 543 Settings were saved successfully. Reload is required to apply some changes. src/app/components/admin/settings/settings.component.ts - 544 + 547 Reload now src/app/components/admin/settings/settings.component.ts - 545 + 548 An error occurred while saving settings. src/app/components/admin/settings/settings.component.ts - 555 + 558 src/app/components/app-frame/app-frame.component.ts @@ -1601,7 +1601,7 @@ Error while storing settings on server. src/app/components/admin/settings/settings.component.ts - 589 + 592 @@ -4160,7 +4160,7 @@ src/app/components/common/system-status-dialog/system-status-dialog.component.html - 124 + 144 @@ -4449,18 +4449,11 @@ 41 - - URL - - src/app/components/common/system-status-dialog/system-status-dialog.component.html - 47 - - Status src/app/components/common/system-status-dialog/system-status-dialog.component.html - 49 + 47 src/app/components/common/toasts/toasts.component.html @@ -4475,49 +4468,67 @@ Migration Status src/app/components/common/system-status-dialog/system-status-dialog.component.html - 58 + 56 Latest Migration src/app/components/common/system-status-dialog/system-status-dialog.component.html - 66 + 64 Pending Migrations src/app/components/common/system-status-dialog/system-status-dialog.component.html - 68 + 66 Tasks src/app/components/common/system-status-dialog/system-status-dialog.component.html - 85 - - - - Redis URL - - src/app/components/common/system-status-dialog/system-status-dialog.component.html - 89 + 83 Redis Status src/app/components/common/system-status-dialog/system-status-dialog.component.html - 91 + 87 Celery Status src/app/components/common/system-status-dialog/system-status-dialog.component.html - 100 + 96 + + + + Index Status + + src/app/components/common/system-status-dialog/system-status-dialog.component.html + 105 + + + + Last Updated + + src/app/components/common/system-status-dialog/system-status-dialog.component.html + 115 + + + src/app/components/common/system-status-dialog/system-status-dialog.component.html + 127 + + + + Classifier Status + + src/app/components/common/system-status-dialog/system-status-dialog.component.html + 117 diff --git a/src-ui/src/app/components/admin/settings/settings.component.spec.ts b/src-ui/src/app/components/admin/settings/settings.component.spec.ts index 8fdaa626c..4c336a754 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.spec.ts @@ -44,9 +44,9 @@ import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-butt import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component' import { SystemStatusService } from 'src/app/services/system-status.service' import { - PaperlessSystemStatus, - PaperlessInstallType, - PaperlessConnectionStatus, + SystemStatus, + InstallType, + SystemStatusItemStatus, } from 'src/app/data/system-status' const savedViews = [ @@ -388,15 +388,15 @@ describe('SettingsComponent', () => { }) it('should load system status on initialize, show errors if needed', () => { - const status: PaperlessSystemStatus = { + const status: SystemStatus = { pngx_version: '2.4.3', server_os: 'macOS-14.1.1-arm64-arm-64bit', - install_type: PaperlessInstallType.BareMetal, + install_type: InstallType.BareMetal, storage: { total: 494384795648, available: 13573525504 }, database: { type: 'sqlite', url: '/paperless-ngx/data/db.sqlite3', - status: PaperlessConnectionStatus.ERROR, + status: SystemStatusItemStatus.ERROR, error: null, migration_status: { latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data', @@ -405,10 +405,16 @@ describe('SettingsComponent', () => { }, tasks: { redis_url: 'redis://localhost:6379', - redis_status: PaperlessConnectionStatus.ERROR, + redis_status: SystemStatusItemStatus.ERROR, redis_error: 'Error 61 connecting to localhost:6379. Connection refused.', - celery_status: PaperlessConnectionStatus.ERROR, + celery_status: SystemStatusItemStatus.ERROR, + index_status: SystemStatusItemStatus.OK, + index_last_modified: new Date(), + index_error: null, + classifier_status: SystemStatusItemStatus.OK, + classifier_last_modified: new Date(), + classifier_error: null, }, } jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status)) @@ -416,9 +422,9 @@ describe('SettingsComponent', () => { expect(component['systemStatus']).toEqual(status) // private expect(component.systemStatusHasErrors).toBeTruthy() // coverage - component['systemStatus'].database.status = PaperlessConnectionStatus.OK - component['systemStatus'].tasks.redis_status = PaperlessConnectionStatus.OK - component['systemStatus'].tasks.celery_status = PaperlessConnectionStatus.OK + component['systemStatus'].database.status = SystemStatusItemStatus.OK + component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK + component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK expect(component.systemStatusHasErrors).toBeFalsy() }) diff --git a/src-ui/src/app/components/admin/settings/settings.component.ts b/src-ui/src/app/components/admin/settings/settings.component.ts index 7fe5320e3..f04af2f9d 100644 --- a/src-ui/src/app/components/admin/settings/settings.component.ts +++ b/src-ui/src/app/components/admin/settings/settings.component.ts @@ -47,8 +47,8 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component' import { SystemStatusService } from 'src/app/services/system-status.service' import { - PaperlessConnectionStatus, - PaperlessSystemStatus, + SystemStatusItemStatus, + SystemStatus, } from 'src/app/data/system-status' enum SettingsNavIDs { @@ -121,14 +121,15 @@ export class SettingsComponent users: User[] groups: Group[] - private systemStatus: PaperlessSystemStatus + private systemStatus: SystemStatus get systemStatusHasErrors(): boolean { return ( - this.systemStatus.database.status === PaperlessConnectionStatus.ERROR || - this.systemStatus.tasks.redis_status === - PaperlessConnectionStatus.ERROR || - this.systemStatus.tasks.celery_status === PaperlessConnectionStatus.ERROR + this.systemStatus.database.status === SystemStatusItemStatus.ERROR || + this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR || + this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR || + this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR || + this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR ) } diff --git a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html index df32d9fdc..8edfbc9c1 100644 --- a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html +++ b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html @@ -44,15 +44,13 @@
Type
{{status.database.type}}
-
URL
-
{{status.database.url}}
Status
{{status.database.status}} @if (status.database.status === 'OK') { - + } @else { - + }
Migration Status
@@ -86,15 +84,13 @@
-
Redis URL
-
{{status.tasks.redis_url}}
Redis Status
{{status.tasks.redis_status}} @if (status.tasks.redis_status === 'OK') { - + } @else { - + }
Celery Status
@@ -106,6 +102,30 @@ } +
Index Status
+
+ {{status.tasks.index_status}} + @if (status.tasks.index_status === 'OK') { + + } @else { + + } +
+ +
Last Updated:
{{status.tasks.index_last_modified}} +
+
Classifier Status
+
+ {{status.tasks.classifier_status}} + @if (status.tasks.classifier_status === 'OK') { + + } @else { + + } +
+ +
Last Updated:
{{status.tasks.classifier_last_modified}} +
diff --git a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts index 413f34be1..72dc5e7e4 100644 --- a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts +++ b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts @@ -8,29 +8,28 @@ import { NgbActiveModal, NgbModalModule, NgbPopoverModule, + NgbProgressbarModule, } from '@ng-bootstrap/ng-bootstrap' import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard' -import { SystemStatusService } from 'src/app/services/system-status.service' import { SystemStatusDialogComponent } from './system-status-dialog.component' -import { of } from 'rxjs' import { - PaperlessConnectionStatus, - PaperlessInstallType, - PaperlessSystemStatus, + 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: PaperlessSystemStatus = { +const status: SystemStatus = { pngx_version: '2.4.3', server_os: 'macOS-14.1.1-arm64-arm-64bit', - install_type: PaperlessInstallType.BareMetal, + install_type: InstallType.BareMetal, storage: { total: 494384795648, available: 13573525504 }, database: { type: 'sqlite', url: '/paperless-ngx/data/db.sqlite3', - status: PaperlessConnectionStatus.ERROR, + status: SystemStatusItemStatus.ERROR, error: null, migration_status: { latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data', @@ -39,9 +38,15 @@ const status: PaperlessSystemStatus = { }, tasks: { redis_url: 'redis://localhost:6379', - redis_status: PaperlessConnectionStatus.ERROR, + redis_status: SystemStatusItemStatus.ERROR, redis_error: 'Error 61 connecting to localhost:6379. Connection refused.', - celery_status: PaperlessConnectionStatus.ERROR, + celery_status: SystemStatusItemStatus.ERROR, + index_status: SystemStatusItemStatus.OK, + index_last_modified: new Date(), + index_error: null, + classifier_status: SystemStatusItemStatus.OK, + classifier_last_modified: new Date(), + classifier_error: null, }, } @@ -61,6 +66,7 @@ describe('SystemStatusDialogComponent', () => { NgxBootstrapIconsModule.pick(allIcons), NgxFilesizeModule, NgbPopoverModule, + NgbProgressbarModule, ], }).compileComponents() diff --git a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts index 26948aa3f..f0d3372c6 100644 --- a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts +++ b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts @@ -1,6 +1,6 @@ import { Component, Input } from '@angular/core' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' -import { PaperlessSystemStatus } from 'src/app/data/system-status' +import { SystemStatus } from 'src/app/data/system-status' import { SystemStatusService } from 'src/app/services/system-status.service' import { Clipboard } from '@angular/cdk/clipboard' @@ -10,7 +10,7 @@ import { Clipboard } from '@angular/cdk/clipboard' styleUrl: './system-status-dialog.component.scss', }) export class SystemStatusDialogComponent { - public status: PaperlessSystemStatus + public status: SystemStatus public copied: boolean = false diff --git a/src-ui/src/app/data/system-status.ts b/src-ui/src/app/data/system-status.ts index f3953ed05..6b131ce1c 100644 --- a/src-ui/src/app/data/system-status.ts +++ b/src-ui/src/app/data/system-status.ts @@ -1,17 +1,17 @@ -export enum PaperlessInstallType { +export enum InstallType { Containerized = 'containerized', BareMetal = 'bare-metal', } -export enum PaperlessConnectionStatus { +export enum SystemStatusItemStatus { OK = 'OK', ERROR = 'ERROR', } -export interface PaperlessSystemStatus { +export interface SystemStatus { pngx_version: string server_os: string - install_type: PaperlessInstallType + install_type: InstallType storage: { total: number available: number @@ -19,7 +19,7 @@ export interface PaperlessSystemStatus { database: { type: string url: string - status: PaperlessConnectionStatus + status: SystemStatusItemStatus error?: string migration_status: { latest_migration: string @@ -28,8 +28,14 @@ export interface PaperlessSystemStatus { } tasks: { redis_url: string - redis_status: PaperlessConnectionStatus + redis_status: SystemStatusItemStatus redis_error: string - celery_status: PaperlessConnectionStatus + celery_status: SystemStatusItemStatus + index_status: SystemStatusItemStatus + index_last_modified: Date + index_error: string + classifier_status: SystemStatusItemStatus + classifier_last_modified: Date + classifier_error: string } } diff --git a/src-ui/src/app/services/system-status.service.spec.ts b/src-ui/src/app/services/system-status.service.spec.ts index 236d21a8e..dd0eb3a88 100644 --- a/src-ui/src/app/services/system-status.service.spec.ts +++ b/src-ui/src/app/services/system-status.service.spec.ts @@ -25,7 +25,7 @@ describe('SystemStatusService', () => { httpTestingController.verify() }) - it('calls get statys endpoint', () => { + it('calls get status endpoint', () => { service.get().subscribe() const req = httpTestingController.expectOne( `${environment.apiBaseUrl}status/` diff --git a/src-ui/src/app/services/system-status.service.ts b/src-ui/src/app/services/system-status.service.ts index 2a538658f..ae6c5a91c 100644 --- a/src-ui/src/app/services/system-status.service.ts +++ b/src-ui/src/app/services/system-status.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from '@angular/common/http' import { Injectable } from '@angular/core' import { Observable } from 'rxjs' -import { PaperlessSystemStatus } from '../data/system-status' +import { SystemStatus } from '../data/system-status' import { environment } from 'src/environments/environment' @Injectable({ @@ -12,8 +12,8 @@ export class SystemStatusService { constructor(private http: HttpClient) {} - get(): Observable { - return this.http.get( + get(): Observable { + return this.http.get( `${environment.apiBaseUrl}${this.endpoint}/` ) } diff --git a/src/documents/tests/test_api_status.py b/src/documents/tests/test_api_status.py index 9a1859585..a64c8c1b4 100644 --- a/src/documents/tests/test_api_status.py +++ b/src/documents/tests/test_api_status.py @@ -1,14 +1,16 @@ import os from unittest import mock +from django.conf import settings from django.contrib.auth.models import User +from django.test import override_settings from rest_framework import status from rest_framework.test import APITestCase from paperless import version -class TestSystemStatusView(APITestCase): +class TestSystemStatus(APITestCase): ENDPOINT = "/api/status/" def setUp(self): @@ -67,3 +69,46 @@ class TestSystemStatusView(APITestCase): response = self.client.get(self.ENDPOINT) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["tasks"]["celery_status"], "OK") + + @override_settings(INDEX_DIR="/tmp/index") + @mock.patch("whoosh.index.FileIndex.last_modified") + def test_system_status_index_ok(self, mock_last_modified): + mock_last_modified.return_value = 1707839087 + self.client.force_login(self.user) + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["tasks"]["index_status"], "OK") + self.assertIsNotNone(response.data["tasks"]["index_last_modified"]) + + @override_settings(INDEX_DIR="/tmp/index/") + @mock.patch("documents.index.open_index", autospec=True) + def test_system_status_index_error(self, mock_open_index): + mock_open_index.return_value = None + mock_open_index.side_effect = Exception("Index error") + self.client.force_login(self.user) + response = self.client.get(self.ENDPOINT) + mock_open_index.assert_called_once() + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["tasks"]["index_status"], "ERROR") + self.assertIsNotNone(response.data["tasks"]["index_error"]) + + @override_settings(MODEL_FILE="/tmp/does_not_exist") + def test_system_status_classifier_ok(self): + with open(settings.MODEL_FILE, "w") as f: + f.write("test") + f.close() + self.client.force_login(self.user) + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["tasks"]["classifier_status"], "OK") + self.assertIsNone(response.data["tasks"]["classifier_error"]) + + @override_settings(MODEL_FILE="/tmp/does_not_exist") + @mock.patch("documents.classifier.load_classifier") + def test_system_status_classifier_error(self, mock_load_classifier): + mock_load_classifier.side_effect = Exception("Classifier error") + self.client.force_login(self.user) + response = self.client.get(self.ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["tasks"]["classifier_status"], "ERROR") + self.assertIsNotNone(response.data["tasks"]["classifier_error"]) diff --git a/src/documents/views.py b/src/documents/views.py index 559d167ed..18d022b34 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -35,6 +35,7 @@ from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.decorators import method_decorator +from django.utils.timezone import make_aware from django.utils.translation import get_language from django.views import View from django.views.decorators.cache import cache_control @@ -66,6 +67,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet from rest_framework.viewsets import ViewSet from documents import bulk_edit +from documents import index from documents.bulk_download import ArchiveOnlyStrategy from documents.bulk_download import OriginalAndArchiveStrategy from documents.bulk_download import OriginalsOnlyStrategy @@ -1595,13 +1597,39 @@ class SystemStatusView(GenericAPIView, PassUserMixin): redis_error = "Error connecting to redis, check logs for more detail." try: - ping = celery_app.control.inspect().ping() - first_worker_ping = ping[next(iter(ping.keys()))] + celery_ping = celery_app.control.inspect().ping() + first_worker_ping = celery_ping[next(iter(celery_ping.keys()))] if first_worker_ping["ok"] == "pong": celery_active = "OK" except Exception: celery_active = "ERROR" + index_error = None + try: + ix = index.open_index() + index_status = "OK" + index_last_modified = make_aware( + datetime.fromtimestamp(ix.last_modified()), + ) + except Exception as e: + index_status = "ERROR" + index_error = "Error opening index, check logs for more detail." + logger.exception(f"System status error opening index: {e}") + index_last_modified = None + + classifier_error = None + try: + load_classifier() + classifier_status = "OK" + classifier_last_modified = make_aware( + datetime.fromtimestamp(os.path.getmtime(settings.MODEL_FILE)), + ) + except Exception as e: + classifier_status = "ERROR" + classifier_last_modified = None + classifier_error = "Error loading classifier, check logs for more detail." + logger.exception(f"System status error loading classifier: {e}") + return Response( { "pngx_version": current_version, @@ -1628,6 +1656,12 @@ class SystemStatusView(GenericAPIView, PassUserMixin): "redis_status": redis_status, "redis_error": redis_error, "celery_status": celery_active, + "index_status": index_status, + "index_last_modified": index_last_modified, + "index_error": index_error, + "classifier_status": classifier_status, + "classifier_last_modified": classifier_last_modified, + "classifier_error": classifier_error, }, }, )