From f6084acfc834d9567ae397abfed09ed89e45acb9 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Mon, 4 Mar 2024 09:26:25 -0800
Subject: [PATCH] Feature: system status (#5743)
---
Dockerfile | 3 +-
src-ui/angular.json | 4 +-
src-ui/messages.xlf | 307 +++++++++++++-----
src-ui/package-lock.json | 23 ++
src-ui/package.json | 1 +
src-ui/src/app/app.module.ts | 14 +
.../admin/settings/settings.component.html | 25 +-
.../admin/settings/settings.component.spec.ts | 64 ++++
.../admin/settings/settings.component.ts | 49 ++-
.../system-status-dialog.component.html | 154 +++++++++
.../system-status-dialog.component.scss | 0
.../system-status-dialog.component.spec.ts | 103 ++++++
.../system-status-dialog.component.ts | 39 +++
src-ui/src/app/data/system-status.ts | 41 +++
.../services/system-status.service.spec.ts | 35 ++
.../src/app/services/system-status.service.ts | 20 ++
src/documents/tests/test_api_status.py | 186 +++++++++++
src/documents/views.py | 138 ++++++++
src/paperless/urls.py | 6 +
19 files changed, 1129 insertions(+), 83 deletions(-)
create mode 100644 src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html
create mode 100644 src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.scss
create mode 100644 src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts
create mode 100644 src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts
create mode 100644 src-ui/src/app/data/system-status.ts
create mode 100644 src-ui/src/app/services/system-status.service.spec.ts
create mode 100644 src-ui/src/app/services/system-status.service.ts
create mode 100644 src/documents/tests/test_api_status.py
diff --git a/Dockerfile b/Dockerfile
index e113f975c..963aedbd0 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -59,7 +59,8 @@ ARG GS_VERSION=10.02.1
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
# Ignore warning from Whitenoise
- PYTHONWARNINGS="ignore:::django.http.response:517"
+ PYTHONWARNINGS="ignore:::django.http.response:517" \
+ PNGX_CONTAINERIZED=1
#
# Begin installation and configuration
diff --git a/src-ui/angular.json b/src-ui/angular.json
index 92f15d769..49e419879 100644
--- a/src-ui/angular.json
+++ b/src-ui/angular.json
@@ -77,7 +77,9 @@
"scripts": [],
"allowedCommonJsDependencies": [
"pdfjs-dist",
- "pdfjs-dist/web/pdf_viewer"
+ "pdfjs-dist/web/pdf_viewer",
+ "filesize",
+ "file-saver"
],
"vendorChunk": true,
"extractLicenses": false,
diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf
index ca23684f8..a111abc56 100644
--- a/src-ui/messages.xlf
+++ b/src-ui/messages.xlf
@@ -458,7 +458,7 @@
src/app/components/admin/settings/settings.component.html
- 354
+ 375
src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html
@@ -600,7 +600,7 @@
src/app/components/admin/settings/settings.component.html
- 342
+ 363
src/app/components/admin/tasks/tasks.component.html
@@ -622,6 +622,10 @@
src/app/components/common/permissions-dialog/permissions-dialog.component.html
23
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 10
+
src/app/components/dashboard/dashboard.component.html
15
@@ -667,7 +671,7 @@
src/app/components/admin/settings/settings.component.html
- 293
+ 314
src/app/components/app-frame/app-frame.component.html
@@ -693,238 +697,249 @@
Start tour
src/app/components/admin/settings/settings.component.html
- 7
+ 8
+
+
+
+ System Status
+
+ src/app/components/admin/settings/settings.component.html
+ 27
+
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 2
Open Django Admin
src/app/components/admin/settings/settings.component.html
- 9
+ 30
General
src/app/components/admin/settings/settings.component.html
- 18
+ 39
Appearance
src/app/components/admin/settings/settings.component.html
- 21
+ 42
Display language
src/app/components/admin/settings/settings.component.html
- 25
+ 46
You need to reload the page after applying a new language.
src/app/components/admin/settings/settings.component.html
- 38
+ 59
Date display
src/app/components/admin/settings/settings.component.html
- 46
+ 67
Date format
src/app/components/admin/settings/settings.component.html
- 63
+ 84
Short:
src/app/components/admin/settings/settings.component.html
- 69,70
+ 90,91
Medium:
src/app/components/admin/settings/settings.component.html
- 73,74
+ 94,95
Long:
src/app/components/admin/settings/settings.component.html
- 77,78
+ 98,99
Items per page
src/app/components/admin/settings/settings.component.html
- 85
+ 106
Document editor
src/app/components/admin/settings/settings.component.html
- 101
+ 122
Use PDF viewer provided by the browser
src/app/components/admin/settings/settings.component.html
- 105
+ 126
This is usually faster for displaying large PDF documents, but it might not work on some browsers.
src/app/components/admin/settings/settings.component.html
- 105
+ 126
Sidebar
src/app/components/admin/settings/settings.component.html
- 112
+ 133
Use 'slim' sidebar (icons only)
src/app/components/admin/settings/settings.component.html
- 116
+ 137
Dark mode
src/app/components/admin/settings/settings.component.html
- 123
+ 144
Use system settings
src/app/components/admin/settings/settings.component.html
- 126
+ 147
Enable dark mode
src/app/components/admin/settings/settings.component.html
- 127
+ 148
Invert thumbnails in dark mode
src/app/components/admin/settings/settings.component.html
- 128
+ 149
Theme Color
src/app/components/admin/settings/settings.component.html
- 134
+ 155
Reset
src/app/components/admin/settings/settings.component.html
- 141
+ 162
Update checking
src/app/components/admin/settings/settings.component.html
- 146
+ 167
Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually.
src/app/components/admin/settings/settings.component.html
- 150,153
+ 171,174
No tracking data is collected by the app in any way.
src/app/components/admin/settings/settings.component.html
- 155,157
+ 176,178
Enable update checking
src/app/components/admin/settings/settings.component.html
- 157
+ 178
Document editing
src/app/components/admin/settings/settings.component.html
- 161
+ 182
Automatically remove inbox tag(s) on save
src/app/components/admin/settings/settings.component.html
- 165
+ 186
Bulk editing
src/app/components/admin/settings/settings.component.html
- 169
+ 190
Show confirmation dialogs
src/app/components/admin/settings/settings.component.html
- 173
+ 194
Deleting documents will always ask for confirmation.
src/app/components/admin/settings/settings.component.html
- 173
+ 194
Apply on close
src/app/components/admin/settings/settings.component.html
- 174
+ 195
Notes
src/app/components/admin/settings/settings.component.html
- 178
+ 199
src/app/components/document-list/document-list.component.html
@@ -939,14 +954,14 @@
Enable notes
src/app/components/admin/settings/settings.component.html
- 182
+ 203
Permissions
src/app/components/admin/settings/settings.component.html
- 190
+ 211
src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html
@@ -1001,28 +1016,28 @@
Default Permissions
src/app/components/admin/settings/settings.component.html
- 193
+ 214
Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI
src/app/components/admin/settings/settings.component.html
- 197,199
+ 218,220
Default Owner
src/app/components/admin/settings/settings.component.html
- 204
+ 225
Objects without an owner can be viewed and edited by all users
src/app/components/admin/settings/settings.component.html
- 208
+ 229
src/app/components/common/input/permissions/permissions-form/permissions-form.component.html
@@ -1033,18 +1048,18 @@
Default View Permissions
src/app/components/admin/settings/settings.component.html
- 213
+ 234
Users:
src/app/components/admin/settings/settings.component.html
- 218
+ 239
src/app/components/admin/settings/settings.component.html
- 245
+ 266
src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
@@ -1067,11 +1082,11 @@
Groups:
src/app/components/admin/settings/settings.component.html
- 228
+ 249
src/app/components/admin/settings/settings.component.html
- 255
+ 276
src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
@@ -1094,14 +1109,14 @@
Default Edit Permissions
src/app/components/admin/settings/settings.component.html
- 240
+ 261
Edit permissions also grant viewing permissions
src/app/components/admin/settings/settings.component.html
- 264
+ 285
src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
@@ -1116,56 +1131,56 @@
Notifications
src/app/components/admin/settings/settings.component.html
- 272
+ 293
Document processing
src/app/components/admin/settings/settings.component.html
- 275
+ 296
Show notifications when new documents are detected
src/app/components/admin/settings/settings.component.html
- 279
+ 300
Show notifications when document processing completes successfully
src/app/components/admin/settings/settings.component.html
- 280
+ 301
Show notifications when document processing fails
src/app/components/admin/settings/settings.component.html
- 281
+ 302
Suppress notifications on dashboard
src/app/components/admin/settings/settings.component.html
- 282
+ 303
This will suppress all messages about document processing status on the dashboard.
src/app/components/admin/settings/settings.component.html
- 282
+ 303
Saved views
src/app/components/admin/settings/settings.component.html
- 290
+ 311
src/app/components/app-frame/app-frame.component.html
@@ -1176,14 +1191,14 @@
Show warning when closing saved views with unsaved changes
src/app/components/admin/settings/settings.component.html
- 296
+ 317
Views
src/app/components/admin/settings/settings.component.html
- 300
+ 321
src/app/components/document-list/document-list.component.html
@@ -1194,7 +1209,7 @@
Name
src/app/components/admin/settings/settings.component.html
- 306
+ 327
src/app/components/admin/tasks/tasks.component.html
@@ -1301,14 +1316,14 @@
Appears on
src/app/components/admin/settings/settings.component.html
- 310,311
+ 331,332
Show on dashboard
src/app/components/admin/settings/settings.component.html
- 313
+ 334
src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html
@@ -1319,7 +1334,7 @@
Show in sidebar
src/app/components/admin/settings/settings.component.html
- 317
+ 338
src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html
@@ -1330,7 +1345,7 @@
Actions
src/app/components/admin/settings/settings.component.html
- 321
+ 342
src/app/components/admin/tasks/tasks.component.html
@@ -1393,7 +1408,7 @@
Delete
src/app/components/admin/settings/settings.component.html
- 324
+ 345
src/app/components/admin/users-groups/users-groups.component.html
@@ -1504,28 +1519,28 @@
No saved views defined.
src/app/components/admin/settings/settings.component.html
- 336
+ 357
Use system language
src/app/components/admin/settings/settings.component.ts
- 51
+ 61
Use date format of display language
src/app/components/admin/settings/settings.component.ts
- 54
+ 64
Error retrieving users
src/app/components/admin/settings/settings.component.ts
- 159
+ 183
src/app/components/admin/users-groups/users-groups.component.ts
@@ -1536,7 +1551,7 @@
Error retrieving groups
src/app/components/admin/settings/settings.component.ts
- 178
+ 202
src/app/components/admin/users-groups/users-groups.component.ts
@@ -1547,35 +1562,35 @@
Saved view "" deleted.
src/app/components/admin/settings/settings.component.ts
- 380
+ 415
Settings were saved successfully.
src/app/components/admin/settings/settings.component.ts
- 506
+ 541
Settings were saved successfully. Reload is required to apply some changes.
src/app/components/admin/settings/settings.component.ts
- 510
+ 545
Reload now
src/app/components/admin/settings/settings.component.ts
- 511
+ 546
An error occurred while saving settings.
src/app/components/admin/settings/settings.component.ts
- 521
+ 556
src/app/components/app-frame/app-frame.component.ts
@@ -1586,7 +1601,7 @@
Error while storing settings on server.
src/app/components/admin/settings/settings.component.ts
- 555
+ 590
@@ -4075,6 +4090,10 @@
src/app/components/common/permissions-select/permissions-select.component.html
5
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 45
+
Change
@@ -4139,6 +4158,10 @@
src/app/components/common/share-links-dropdown/share-links-dropdown.component.html
29
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 152
+
Regenerate auth token
@@ -4370,8 +4393,68 @@
151
+
+ Environment
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 18
+
+
+
+ Paperless-ngx Version
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 22
+
+
+
+ Install Type
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 24
+
+
+
+ Server OS
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 26
+
+
+
+ Media Storage
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 28
+
+
+
+ available
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 31
+
+
+
+ total
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 31
+
+
+
+ Database
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 41
+
+
Status
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 47
+
src/app/components/common/toasts/toasts.component.html
26
@@ -4381,6 +4464,76 @@
19
+
+ Migration Status
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 56
+
+
+
+ Latest Migration
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 64
+
+
+
+ Pending Migrations
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 66
+
+
+
+ Tasks
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 83
+
+
+
+ Redis Status
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 87
+
+
+
+ Celery Status
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 96
+
+
+
+ Search Index
+
+ 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
+ 119
+
+
+
+ Classifier
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 121
+
+
+
+ Last Trained
+
+ src/app/components/common/system-status-dialog/system-status-dialog.component.html
+ 135
+
+
Copy Raw Error
diff --git a/src-ui/package-lock.json b/src-ui/package-lock.json
index 16fda59f0..0978ca81a 100644
--- a/src-ui/package-lock.json
+++ b/src-ui/package-lock.json
@@ -29,6 +29,7 @@
"ngx-color": "^9.0.0",
"ngx-cookie-service": "^17.1.0",
"ngx-file-drop": "^16.0.0",
+ "ngx-filesize": "^3.0.3",
"ngx-ui-tour-ng-bootstrap": "^14.0.2",
"pdfjs-dist": "^3.11.174",
"rxjs": "^7.8.1",
@@ -9844,6 +9845,15 @@
"node": ">=10"
}
},
+ "node_modules/filesize": {
+ "version": "9.0.11",
+ "resolved": "https://registry.npmjs.org/filesize/-/filesize-9.0.11.tgz",
+ "integrity": "sha512-gTAiTtI0STpKa5xesyTA9hA3LX4ga8sm2nWRcffEa1L/5vQwb4mj2MdzMkoHoGv4QzfDshQZuYscQSf8c4TKOA==",
+ "peer": true,
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@@ -14105,6 +14115,19 @@
"@angular/core": ">=14.0.0"
}
},
+ "node_modules/ngx-filesize": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/ngx-filesize/-/ngx-filesize-3.0.3.tgz",
+ "integrity": "sha512-qqP2p4WbbF7R+NXC9NqRQdAfWfMAYJ2Ijf4ezRCq7j3tPY6ybSP9AZ3FY1U7/95n1hmOJ2U5oY+oFb7LhHQRBw==",
+ "dependencies": {
+ "tslib": "^2.3.0"
+ },
+ "peerDependencies": {
+ "@angular/common": ">= 14.2.0 < 18.0.0",
+ "@angular/core": ">= 14.2.0 < 18.0.0",
+ "filesize": ">= 6.0.0 < 10.0.0"
+ }
+ },
"node_modules/ngx-ui-tour-core": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/ngx-ui-tour-core/-/ngx-ui-tour-core-12.0.1.tgz",
diff --git a/src-ui/package.json b/src-ui/package.json
index 1ee957e04..be12c3ad4 100644
--- a/src-ui/package.json
+++ b/src-ui/package.json
@@ -31,6 +31,7 @@
"ngx-color": "^9.0.0",
"ngx-cookie-service": "^17.1.0",
"ngx-file-drop": "^16.0.0",
+ "ngx-filesize": "^3.0.3",
"ngx-ui-tour-ng-bootstrap": "^14.0.2",
"pdfjs-dist": "^3.11.174",
"rxjs": "^7.8.1",
diff --git a/src-ui/src/app/app.module.ts b/src-ui/src/app/app.module.ts
index 69213846f..568b2bc0e 100644
--- a/src-ui/src/app/app.module.ts
+++ b/src-ui/src/app/app.module.ts
@@ -114,7 +114,10 @@ import { FileComponent } from './components/common/input/file/file.component'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component'
import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
+import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component'
+import { NgxFilesizeModule } from 'ngx-filesize'
import {
+ airplane,
archive,
arrowCounterclockwise,
arrowDown,
@@ -129,12 +132,14 @@ import {
boxes,
calendar,
calendarEvent,
+ cardChecklist,
caretDown,
caretUp,
chatLeftText,
check,
check2All,
checkAll,
+ checkCircleFill,
checkLg,
chevronDoubleLeft,
chevronDoubleRight,
@@ -148,7 +153,9 @@ import {
doorOpen,
download,
envelope,
+ exclamationCircleFill,
exclamationTriangle,
+ exclamationTriangleFill,
eye,
fileEarmark,
fileEarmarkCheck,
@@ -200,6 +207,7 @@ import {
} from 'ngx-bootstrap-icons'
const icons = {
+ airplane,
archive,
arrowCounterclockwise,
arrowDown,
@@ -214,12 +222,14 @@ const icons = {
boxes,
calendar,
calendarEvent,
+ cardChecklist,
caretDown,
caretUp,
chatLeftText,
check,
check2All,
checkAll,
+ checkCircleFill,
checkLg,
chevronDoubleLeft,
chevronDoubleRight,
@@ -233,7 +243,9 @@ const icons = {
doorOpen,
download,
envelope,
+ exclamationCircleFill,
exclamationTriangle,
+ exclamationTriangleFill,
eye,
fileEarmark,
fileEarmarkCheck,
@@ -445,6 +457,7 @@ function initializeApp(settings: SettingsService) {
FileComponent,
ConfirmButtonComponent,
MonetaryComponent,
+ SystemStatusDialogComponent,
],
imports: [
BrowserModule,
@@ -459,6 +472,7 @@ function initializeApp(settings: SettingsService) {
TourNgBootstrapModule,
DragDropModule,
NgxBootstrapIconsModule.pick(icons),
+ NgxFilesizeModule,
],
providers: [
{
diff --git a/src-ui/src/app/components/admin/settings/settings.component.html b/src-ui/src/app/components/admin/settings/settings.component.html
index 059b233e2..76625d886 100644
--- a/src-ui/src/app/components/admin/settings/settings.component.html
+++ b/src-ui/src/app/components/admin/settings/settings.component.html
@@ -4,10 +4,31 @@
info="Options to customize appearance, notifications, saved views and more. Settings apply to the current user only."
i18n-info
>
-
+
+
Open Django Admin
-
+
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 6256f646b..6110f7d1d 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
@@ -9,6 +9,8 @@ import {
NgbModule,
NgbAlertModule,
NgbNavLink,
+ NgbModal,
+ NgbModalModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { of, throwError } from 'rxjs'
@@ -39,6 +41,13 @@ import { SettingsComponent } from './settings.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
+import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
+import { SystemStatusService } from 'src/app/services/system-status.service'
+import {
+ SystemStatus,
+ InstallType,
+ SystemStatusItemStatus,
+} from 'src/app/data/system-status'
const savedViews = [
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
@@ -65,6 +74,8 @@ describe('SettingsComponent', () => {
let userService: UserService
let permissionsService: PermissionsService
let groupService: GroupService
+ let modalService: NgbModal
+ let systemStatusService: SystemStatusService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -96,6 +107,7 @@ describe('SettingsComponent', () => {
NgbAlertModule,
NgSelectModule,
NgxBootstrapIconsModule.pick(allIcons),
+ NgbModalModule,
],
}).compileComponents()
@@ -107,6 +119,8 @@ describe('SettingsComponent', () => {
settingsService.currentUser = users[0]
userService = TestBed.inject(UserService)
permissionsService = TestBed.inject(PermissionsService)
+ modalService = TestBed.inject(NgbModal)
+ systemStatusService = TestBed.inject(SystemStatusService)
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
@@ -372,4 +386,54 @@ describe('SettingsComponent', () => {
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
})
+
+ it('should load system status on initialize, show errors if needed', () => {
+ 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,
+ },
+ }
+ jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
+ completeSetup()
+ expect(component['systemStatus']).toEqual(status) // private
+ expect(component.systemStatusHasErrors).toBeTruthy()
+ // coverage
+ component['systemStatus'].database.status = SystemStatusItemStatus.OK
+ component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
+ component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
+ expect(component.systemStatusHasErrors).toBeFalsy()
+ })
+
+ it('should open system status dialog', () => {
+ const modalOpenSpy = jest.spyOn(modalService, 'open')
+ completeSetup()
+ component.showSystemStatus()
+ expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
+ size: 'xl',
+ })
+ })
})
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 a77a556bf..f04af2f9d 100644
--- a/src-ui/src/app/components/admin/settings/settings.component.ts
+++ b/src-ui/src/app/components/admin/settings/settings.component.ts
@@ -9,7 +9,11 @@ import {
} from '@angular/core'
import { FormGroup, FormControl } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
-import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'
+import {
+ NgbModal,
+ NgbModalRef,
+ NgbNavChangeEvent,
+} from '@ng-bootstrap/ng-bootstrap'
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import {
@@ -40,6 +44,12 @@ import {
} from 'src/app/services/settings.service'
import { ToastService, Toast } from 'src/app/services/toast.service'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
+import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
+import { SystemStatusService } from 'src/app/services/system-status.service'
+import {
+ SystemStatusItemStatus,
+ SystemStatus,
+} from 'src/app/data/system-status'
enum SettingsNavIDs {
General = 1,
@@ -111,6 +121,18 @@ export class SettingsComponent
users: User[]
groups: Group[]
+ private systemStatus: SystemStatus
+
+ get systemStatusHasErrors(): boolean {
+ return (
+ 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
+ )
+ }
+
get computedDateLocale(): string {
return (
this.settingsForm.value.dateLocale ||
@@ -131,7 +153,9 @@ export class SettingsComponent
private usersService: UserService,
private groupsService: GroupService,
private router: Router,
- public permissionsService: PermissionsService
+ public permissionsService: PermissionsService,
+ private modalService: NgbModal,
+ private systemStatusService: SystemStatusService
) {
super()
this.settings.settingsSaved.subscribe(() => {
@@ -360,6 +384,17 @@ export class SettingsComponent
// prevents loss of unsaved changes
this.settingsForm.patchValue(currentFormValue)
}
+
+ if (
+ this.permissionsService.currentUserCan(
+ PermissionAction.View,
+ PermissionType.Admin
+ )
+ ) {
+ this.systemStatusService.get().subscribe((status) => {
+ this.systemStatus = status
+ })
+ }
}
private emptyGroup(group: FormGroup) {
@@ -565,4 +600,14 @@ export class SettingsComponent
clearThemeColor() {
this.settingsForm.get('themeColor').patchValue('')
}
+
+ showSystemStatus() {
+ const modal: NgbModalRef = this.modalService.open(
+ SystemStatusDialogComponent,
+ {
+ size: 'xl',
+ }
+ )
+ modal.componentInstance.status = this.systemStatus
+ }
}
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
new file mode 100644
index 000000000..5b60abe34
--- /dev/null
+++ b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html
@@ -0,0 +1,154 @@
+
+
+ @if (!status) {
+
+ } @else {
+
+
+
+
+
+
+ - Paperless-ngx Version
+ - {{status.pngx_version}}
+ - Install Type
+ - {{status.install_type}}
+ - Server OS
+ - {{status.server_os}}
+ - Media Storage
+ -
+
+ {{status.storage.available | filesize}} available ({{status.storage.total | filesize}} total)
+
+
+
+
+
+
+
+
+
+
+
+ - Type
+ - {{status.database.type}}
+ - Status
+ -
+ {{status.database.status}}
+ @if (status.database.status === 'OK') {
+
+ } @else {
+
+ }
+
+ - Migration Status
+ -
+ @if (status.database.migration_status.unapplied_migrations.length === 0) {
+ Up to date
+ } @else {
+ {{status.database.migration_status.unapplied_migrations.length}} Pending
+ }
+
+
Latest Migration:
{{status.database.migration_status.latest_migration}}
+ @if (status.database.migration_status.unapplied_migrations.length > 0) {
+ Pending Migrations:
+
+ @for (migration of status.database.migration_status.unapplied_migrations; track migration) {
+ - {{migration}}
+ }
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+ - Redis Status
+ -
+ {{status.tasks.redis_status}}
+ @if (status.tasks.redis_status === 'OK') {
+
+ } @else {
+
+ }
+
+ - Celery Status
+ -
+ {{status.tasks.celery_status}}
+ @if (status.tasks.celery_status === 'OK') {
+
+ } @else {
+
+ }
+
+ - Search Index
+ -
+ {{status.tasks.index_status}}
+ @if (status.tasks.index_status === 'OK') {
+ @if (isStale(status.tasks.index_last_modified)) {
+
+ } @else {
+
+ }
+ } @else {
+
+ }
+
+
+ Last Updated:
{{status.tasks.index_last_modified | customDate:'medium'}}
+
+ - Classifier
+ -
+ {{status.tasks.classifier_status}}
+ @if (status.tasks.classifier_status === 'OK') {
+ @if (isStale(status.tasks.classifier_last_trained)) {
+
+ } @else {
+
+ }
+ } @else {
+
+ }
+
+
+ Last Trained:
{{status.tasks.classifier_last_trained | customDate:'medium'}}
+
+
+
+
+
+
+ }
+
+
diff --git a/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.scss b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.scss
new file mode 100644
index 000000000..e69de29bb
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
new file mode 100644
index 000000000..13baa363a
--- /dev/null
+++ b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts
@@ -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
+ 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()
+ })
+})
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
new file mode 100644
index 000000000..ae391c529
--- /dev/null
+++ b/src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts
@@ -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
+ }
+}
diff --git a/src-ui/src/app/data/system-status.ts b/src-ui/src/app/data/system-status.ts
new file mode 100644
index 000000000..247535602
--- /dev/null
+++ b/src-ui/src/app/data/system-status.ts
@@ -0,0 +1,41 @@
+export enum InstallType {
+ Containerized = 'containerized',
+ BareMetal = 'bare-metal',
+}
+
+export enum SystemStatusItemStatus {
+ OK = 'OK',
+ ERROR = 'ERROR',
+}
+
+export interface SystemStatus {
+ pngx_version: string
+ server_os: string
+ install_type: InstallType
+ storage: {
+ total: number
+ available: number
+ }
+ database: {
+ type: string
+ url: string
+ status: SystemStatusItemStatus
+ error?: string
+ migration_status: {
+ latest_migration: string
+ unapplied_migrations: string[]
+ }
+ }
+ tasks: {
+ redis_url: string
+ redis_status: SystemStatusItemStatus
+ redis_error: string
+ celery_status: SystemStatusItemStatus
+ index_status: SystemStatusItemStatus
+ index_last_modified: string // ISO date string
+ index_error: string
+ classifier_status: SystemStatusItemStatus
+ classifier_last_trained: string // ISO date string
+ 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
new file mode 100644
index 000000000..dd0eb3a88
--- /dev/null
+++ b/src-ui/src/app/services/system-status.service.spec.ts
@@ -0,0 +1,35 @@
+import { TestBed } from '@angular/core/testing'
+
+import { SystemStatusService } from './system-status.service'
+import {
+ HttpClientTestingModule,
+ HttpTestingController,
+} from '@angular/common/http/testing'
+import { environment } from 'src/environments/environment'
+
+describe('SystemStatusService', () => {
+ let httpTestingController: HttpTestingController
+ let service: SystemStatusService
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [SystemStatusService],
+ imports: [HttpClientTestingModule],
+ })
+
+ httpTestingController = TestBed.inject(HttpTestingController)
+ service = TestBed.inject(SystemStatusService)
+ })
+
+ afterEach(() => {
+ httpTestingController.verify()
+ })
+
+ it('calls get status endpoint', () => {
+ service.get().subscribe()
+ const req = httpTestingController.expectOne(
+ `${environment.apiBaseUrl}status/`
+ )
+ expect(req.request.method).toEqual('GET')
+ })
+})
diff --git a/src-ui/src/app/services/system-status.service.ts b/src-ui/src/app/services/system-status.service.ts
new file mode 100644
index 000000000..ae6c5a91c
--- /dev/null
+++ b/src-ui/src/app/services/system-status.service.ts
@@ -0,0 +1,20 @@
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { Observable } from 'rxjs'
+import { SystemStatus } from '../data/system-status'
+import { environment } from 'src/environments/environment'
+
+@Injectable({
+ providedIn: 'root',
+})
+export class SystemStatusService {
+ private endpoint = 'status'
+
+ constructor(private http: HttpClient) {}
+
+ 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
new file mode 100644
index 000000000..964995bdc
--- /dev/null
+++ b/src/documents/tests/test_api_status.py
@@ -0,0 +1,186 @@
+import os
+from pathlib import Path
+from unittest import mock
+
+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 documents.classifier import DocumentClassifier
+from documents.classifier import load_classifier
+from paperless import version
+
+
+class TestSystemStatus(APITestCase):
+ ENDPOINT = "/api/status/"
+
+ def setUp(self):
+ self.user = User.objects.create_superuser(
+ username="temp_admin",
+ )
+
+ def test_system_status(self):
+ """
+ GIVEN:
+ - A user is logged in
+ WHEN:
+ - The user requests the system status
+ THEN:
+ - The response contains relevant system status information
+ """
+ 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["pngx_version"], version.__full_version_str__)
+ self.assertIsNotNone(response.data["server_os"])
+ self.assertEqual(response.data["install_type"], "bare-metal")
+ self.assertIsNotNone(response.data["storage"]["total"])
+ self.assertIsNotNone(response.data["storage"]["available"])
+ self.assertEqual(response.data["database"]["type"], "sqlite")
+ self.assertIsNotNone(response.data["database"]["url"])
+ self.assertEqual(response.data["database"]["status"], "OK")
+ self.assertIsNone(response.data["database"]["error"])
+ self.assertIsNotNone(response.data["database"]["migration_status"])
+ self.assertEqual(response.data["tasks"]["redis_url"], "redis://localhost:6379")
+ self.assertEqual(response.data["tasks"]["redis_status"], "ERROR")
+ self.assertIsNotNone(response.data["tasks"]["redis_error"])
+
+ def test_system_status_insufficient_permissions(self):
+ """
+ GIVEN:
+ - A user is not logged in or does not have permissions
+ WHEN:
+ - The user requests the system status
+ THEN:
+ - The response contains a 401 status code or a 403 status code
+ """
+ response = self.client.get(self.ENDPOINT)
+ self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
+ normal_user = User.objects.create_user(username="normal_user")
+ self.client.force_login(normal_user)
+ response = self.client.get(self.ENDPOINT)
+ self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+
+ def test_system_status_container_detection(self):
+ """
+ GIVEN:
+ - The application is running in a containerized environment
+ WHEN:
+ - The user requests the system status
+ THEN:
+ - The response contains the correct install type
+ """
+ self.client.force_login(self.user)
+ os.environ["PNGX_CONTAINERIZED"] = "1"
+ response = self.client.get(self.ENDPOINT)
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data["install_type"], "docker")
+ os.environ["KUBERNETES_SERVICE_HOST"] = "http://localhost"
+ response = self.client.get(self.ENDPOINT)
+ self.assertEqual(response.data["install_type"], "kubernetes")
+
+ @mock.patch("redis.Redis.execute_command")
+ def test_system_status_redis_ping(self, mock_ping):
+ """
+ GIVEN:
+ - Redies ping returns True
+ WHEN:
+ - The user requests the system status
+ THEN:
+ - The response contains the correct redis status
+ """
+ mock_ping.return_value = True
+ 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"]["redis_status"], "OK")
+
+ @mock.patch("celery.app.control.Inspect.ping")
+ def test_system_status_celery_ping(self, mock_ping):
+ """
+ GIVEN:
+ - Celery ping returns pong
+ WHEN:
+ - The user requests the system status
+ THEN:
+ - The response contains the correct celery status
+ """
+ mock_ping.return_value = {"hostname": {"ok": "pong"}}
+ 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"]["celery_status"], "OK")
+
+ @override_settings(INDEX_DIR=Path("/tmp/index"))
+ @mock.patch("whoosh.index.FileIndex.last_modified")
+ def test_system_status_index_ok(self, mock_last_modified):
+ """
+ GIVEN:
+ - The index last modified time is set
+ WHEN:
+ - The user requests the system status
+ THEN:
+ - The response contains the correct index status
+ """
+ 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):
+ """
+ GIVEN:
+ - The index is not found
+ WHEN:
+ - The user requests the system status
+ THEN:
+ - The response contains the correct index status
+ """
+ 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(DATA_DIR="/tmp/does_not_exist/data/")
+ def test_system_status_classifier_ok(self):
+ """
+ GIVEN:
+ - The classifier is found
+ WHEN:
+ - The user requests the system status
+ THEN:
+ - The response contains the correct classifier status
+ """
+ load_classifier()
+ test_classifier = DocumentClassifier()
+ test_classifier.save()
+ 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"])
+
+ def test_system_status_classifier_error(self):
+ """
+ GIVEN:
+ - The classifier is not found
+ WHEN:
+ - The user requests the system status
+ THEN:
+ - The response contains an error classifier status
+ """
+ with override_settings(MODEL_FILE="does_not_exist"):
+ 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 5c84d5ea8..bd0b6fa0f 100644
--- a/src/documents/views.py
+++ b/src/documents/views.py
@@ -2,6 +2,7 @@ import itertools
import json
import logging
import os
+import platform
import re
import tempfile
import urllib
@@ -13,8 +14,12 @@ from unicodedata import normalize
from urllib.parse import quote
import pathvalidate
+from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
+from django.db import connections
+from django.db.migrations.loader import MigrationLoader
+from django.db.migrations.recorder import MigrationRecorder
from django.db.models import Case
from django.db.models import Count
from django.db.models import IntegerField
@@ -31,6 +36,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
@@ -40,6 +46,7 @@ from django.views.generic import TemplateView
from django_filters.rest_framework import DjangoFilterBackend
from langdetect import detect
from packaging import version as packaging_version
+from redis import Redis
from rest_framework import parsers
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
@@ -61,6 +68,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
@@ -138,6 +146,7 @@ from documents.serialisers import WorkflowTriggerSerializer
from documents.signals import document_updated
from documents.tasks import consume_file
from paperless import version
+from paperless.celery import app as celery_app
from paperless.config import GeneralConfig
from paperless.db import GnuPG
from paperless.views import StandardPagination
@@ -1539,3 +1548,132 @@ class CustomFieldViewSet(ModelViewSet):
model = CustomField
queryset = CustomField.objects.all().order_by("-created")
+
+
+class SystemStatusView(GenericAPIView, PassUserMixin):
+ permission_classes = (IsAuthenticated,)
+
+ def get(self, request, format=None):
+ if not request.user.has_perm("admin.view_logentry"):
+ return HttpResponseForbidden("Insufficient permissions")
+
+ current_version = version.__full_version_str__
+
+ install_type = "bare-metal"
+ if os.environ.get("KUBERNETES_SERVICE_HOST") is not None:
+ install_type = "kubernetes"
+ elif os.environ.get("PNGX_CONTAINERIZED") == "1":
+ install_type = "docker"
+
+ db_conn = connections["default"]
+ db_url = db_conn.settings_dict["NAME"]
+ db_error = None
+
+ try:
+ db_conn.ensure_connection()
+ db_status = "OK"
+ loader = MigrationLoader(connection=db_conn)
+ all_migrations = [f"{app}.{name}" for app, name in loader.graph.nodes]
+ applied_migrations = [
+ f"{m.app}.{m.name}"
+ for m in MigrationRecorder.Migration.objects.all().order_by("id")
+ ]
+ except Exception as e: # pragma: no cover
+ applied_migrations = []
+ db_status = "ERROR"
+ logger.exception(f"System status error connecting to database: {e}")
+ db_error = "Error connecting to database, check logs for more detail."
+
+ media_stats = os.statvfs(settings.MEDIA_ROOT)
+
+ redis_url = settings._CHANNELS_REDIS_URL
+ redis_error = None
+ with Redis.from_url(url=redis_url) as client:
+ try:
+ client.ping()
+ redis_status = "OK"
+ except Exception as e:
+ redis_status = "ERROR"
+ logger.exception(f"System status error connecting to redis: {e}")
+ redis_error = "Error connecting to redis, check logs for more detail."
+
+ try:
+ 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:
+ classifier = load_classifier()
+ if classifier is None:
+ raise Exception("Classifier not loaded")
+ classifier_status = "OK"
+ task_result_model = apps.get_model("django_celery_results", "taskresult")
+ result = (
+ task_result_model.objects.filter(
+ task_name="documents.tasks.train_classifier",
+ status="SUCCESS",
+ )
+ .order_by(
+ "-date_done",
+ )
+ .first()
+ )
+ classifier_last_trained = result.date_done if result else None
+ except Exception as e:
+ classifier_status = "ERROR"
+ classifier_last_trained = 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,
+ "server_os": platform.platform(),
+ "install_type": install_type,
+ "storage": {
+ "total": media_stats.f_frsize * media_stats.f_blocks,
+ "available": media_stats.f_frsize * media_stats.f_bavail,
+ },
+ "database": {
+ "type": db_conn.vendor,
+ "url": db_url,
+ "status": db_status,
+ "error": db_error,
+ "migration_status": {
+ "latest_migration": applied_migrations[-1],
+ "unapplied_migrations": [
+ m for m in all_migrations if m not in applied_migrations
+ ],
+ },
+ },
+ "tasks": {
+ "redis_url": redis_url,
+ "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_trained": classifier_last_trained,
+ "classifier_error": classifier_error,
+ },
+ },
+ )
diff --git a/src/paperless/urls.py b/src/paperless/urls.py
index 142f2792d..12b049918 100644
--- a/src/paperless/urls.py
+++ b/src/paperless/urls.py
@@ -32,6 +32,7 @@ from documents.views import SharedLinkView
from documents.views import ShareLinkViewSet
from documents.views import StatisticsView
from documents.views import StoragePathViewSet
+from documents.views import SystemStatusView
from documents.views import TagViewSet
from documents.views import TasksViewSet
from documents.views import UiSettingsView
@@ -147,6 +148,11 @@ urlpatterns = [
ProfileView.as_view(),
name="profile_view",
),
+ re_path(
+ "^status/",
+ SystemStatusView.as_view(),
+ name="system_status",
+ ),
*api_router.urls,
],
),