]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: system status (#5743)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 4 Mar 2024 17:26:25 +0000 (09:26 -0800)
committerGitHub <noreply@github.com>
Mon, 4 Mar 2024 17:26:25 +0000 (09:26 -0800)
19 files changed:
Dockerfile
src-ui/angular.json
src-ui/messages.xlf
src-ui/package-lock.json
src-ui/package.json
src-ui/src/app/app.module.ts
src-ui/src/app/components/admin/settings/settings.component.html
src-ui/src/app/components/admin/settings/settings.component.spec.ts
src-ui/src/app/components/admin/settings/settings.component.ts
src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.html [new file with mode: 0644]
src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.scss [new file with mode: 0644]
src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/common/system-status-dialog/system-status-dialog.component.ts [new file with mode: 0644]
src-ui/src/app/data/system-status.ts [new file with mode: 0644]
src-ui/src/app/services/system-status.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/system-status.service.ts [new file with mode: 0644]
src/documents/tests/test_api_status.py [new file with mode: 0644]
src/documents/views.py
src/paperless/urls.py

index e113f975c0e42c124083e15648423c6c4e70ae8d..963aedbd047135303eabab3186ed72d93265a54f 100644 (file)
@@ -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
index 92f15d769e4ca73423689bddca1463baa3be4c97..49e4198797c629dbb63515faabfa77ca8d484e6d 100644 (file)
@@ -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,
index ca23684f8b2855e3e07d2e8f7cb64a48cae327f5..a111abc567fc7679cf1e1839ad0add1b562aae79 100644 (file)
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">354</context>
+          <context context-type="linenumber">375</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">342</context>
+          <context context-type="linenumber">363</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
           <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
           <context context-type="linenumber">23</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">10</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context>
           <context context-type="linenumber">15</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">293</context>
+          <context context-type="linenumber">314</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
         <source>Start tour</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">7</context>
+          <context context-type="linenumber">8</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="3276228498925657259" datatype="html">
+        <source>System Status</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
+          <context context-type="linenumber">27</context>
+        </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">2</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4798013226763881638" datatype="html">
         <source>Open Django Admin</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">9</context>
+          <context context-type="linenumber">30</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6439365426343089851" datatype="html">
         <source>General</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">18</context>
+          <context context-type="linenumber">39</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8671234314555525900" datatype="html">
         <source>Appearance</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">21</context>
+          <context context-type="linenumber">42</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3777637051272512093" datatype="html">
         <source>Display language</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">25</context>
+          <context context-type="linenumber">46</context>
         </context-group>
       </trans-unit>
       <trans-unit id="53523152145406584" datatype="html">
         <source>You need to reload the page after applying a new language.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">38</context>
+          <context context-type="linenumber">59</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3766032098416558788" datatype="html">
         <source>Date display</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">46</context>
+          <context context-type="linenumber">67</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3733378544613473393" datatype="html">
         <source>Date format</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">63</context>
+          <context context-type="linenumber">84</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3407788781115661841" datatype="html">
         <source>Short: <x id="INTERPOLATION" equiv-text="{{today | customDate:&apos;shortDate&apos;:null:computedDateLocale}}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">69,70</context>
+          <context context-type="linenumber">90,91</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6290748171049664628" datatype="html">
         <source>Medium: <x id="INTERPOLATION" equiv-text="{{today | customDate:&apos;mediumDate&apos;:null:computedDateLocale}}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">73,74</context>
+          <context context-type="linenumber">94,95</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7189855711197998347" datatype="html">
         <source>Long: <x id="INTERPOLATION" equiv-text="{{today | customDate:&apos;longDate&apos;:null:computedDateLocale}}"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">77,78</context>
+          <context context-type="linenumber">98,99</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8939587804990976924" datatype="html">
         <source>Items per page</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">85</context>
+          <context context-type="linenumber">106</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8028535997917730106" datatype="html">
         <source>Document editor</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">101</context>
+          <context context-type="linenumber">122</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6708098108196142028" datatype="html">
         <source>Use PDF viewer provided by the browser</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">105</context>
+          <context context-type="linenumber">126</context>
         </context-group>
       </trans-unit>
       <trans-unit id="9003921625412907981" datatype="html">
         <source>This is usually faster for displaying large PDF documents, but it might not work on some browsers.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">105</context>
+          <context context-type="linenumber">126</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3982403428275430291" datatype="html">
         <source>Sidebar</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">112</context>
+          <context context-type="linenumber">133</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4608457133854405683" datatype="html">
         <source>Use &apos;slim&apos; sidebar (icons only)</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">116</context>
+          <context context-type="linenumber">137</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1356890996281769972" datatype="html">
         <source>Dark mode</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">123</context>
+          <context context-type="linenumber">144</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4913823100518391922" datatype="html">
         <source>Use system settings</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">126</context>
+          <context context-type="linenumber">147</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5782828784040423650" datatype="html">
         <source>Enable dark mode</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">127</context>
+          <context context-type="linenumber">148</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6336642923114460405" datatype="html">
         <source>Invert thumbnails in dark mode</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">128</context>
+          <context context-type="linenumber">149</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7983234071833154796" datatype="html">
         <source>Theme Color</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">134</context>
+          <context context-type="linenumber">155</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7808756054397155068" datatype="html">
         <source>Reset</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">141</context>
+          <context context-type="linenumber">162</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8901931207592071833" datatype="html">
         <source>Update checking</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">146</context>
+          <context context-type="linenumber">167</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7141691772243630313" datatype="html">
         <source> Update checking works by pinging the public <x id="START_LINK" ctype="x-a" equiv-text="&lt;a href=&quot;https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;"/>GitHub API<x id="CLOSE_LINK" ctype="x-a" equiv-text="&lt;/a&gt;"/> for the latest release to determine whether a new version is available.<x id="LINE_BREAK" ctype="lb" equiv-text="&lt;br/&gt;"/> Actual updating of the app must still be performed manually. </source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">150,153</context>
+          <context context-type="linenumber">171,174</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5489945693955857309" datatype="html">
         <source><x id="START_EMPHASISED_TEXT" ctype="x-em" equiv-text="&gt;"/>No tracking data is collected by the app in any way.<x id="CLOSE_EMPHASISED_TEXT" ctype="x-em" equiv-text="&lt;/em&gt;"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">155,157</context>
+          <context context-type="linenumber">176,178</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5070799004079086984" datatype="html">
         <source>Enable update checking</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">157</context>
+          <context context-type="linenumber">178</context>
         </context-group>
       </trans-unit>
       <trans-unit id="908152367861642592" datatype="html">
         <source>Document editing</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">161</context>
+          <context context-type="linenumber">182</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2959590948110714366" datatype="html">
         <source>Automatically remove inbox tag(s) on save</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">165</context>
+          <context context-type="linenumber">186</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8508424367627989968" datatype="html">
         <source>Bulk editing</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">169</context>
+          <context context-type="linenumber">190</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8158899674926420054" datatype="html">
         <source>Show confirmation dialogs</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">173</context>
+          <context context-type="linenumber">194</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6906812245033969309" datatype="html">
         <source>Deleting documents will always ask for confirmation.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">173</context>
+          <context context-type="linenumber">194</context>
         </context-group>
       </trans-unit>
       <trans-unit id="290238406234356122" datatype="html">
         <source>Apply on close</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">174</context>
+          <context context-type="linenumber">195</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8104421162933956065" datatype="html">
         <source>Notes</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">178</context>
+          <context context-type="linenumber">199</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
         <source>Enable notes</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">182</context>
+          <context context-type="linenumber">203</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7314814725704332646" datatype="html">
         <source>Permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">190</context>
+          <context context-type="linenumber">211</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/group-edit-dialog/group-edit-dialog.component.html</context>
         <source>Default Permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">193</context>
+          <context context-type="linenumber">214</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8222269449891326545" datatype="html">
         <source> Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI </source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">197,199</context>
+          <context context-type="linenumber">218,220</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4292903881380648974" datatype="html">
         <source>Default Owner</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">204</context>
+          <context context-type="linenumber">225</context>
         </context-group>
       </trans-unit>
       <trans-unit id="734147282056744882" datatype="html">
         <source>Objects without an owner can be viewed and edited by all users</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">208</context>
+          <context context-type="linenumber">229</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
         <source>Default View Permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">213</context>
+          <context context-type="linenumber">234</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2191775412581217688" datatype="html">
         <source>Users:</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">218</context>
+          <context context-type="linenumber">239</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">245</context>
+          <context context-type="linenumber">266</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
         <source>Groups:</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">228</context>
+          <context context-type="linenumber">249</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">255</context>
+          <context context-type="linenumber">276</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
         <source>Default Edit Permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">240</context>
+          <context context-type="linenumber">261</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3728984448750213892" datatype="html">
         <source>Edit permissions also grant viewing permissions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">264</context>
+          <context context-type="linenumber">285</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
         <source>Notifications</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">272</context>
+          <context context-type="linenumber">293</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8545554728558600606" datatype="html">
         <source>Document processing</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">275</context>
+          <context context-type="linenumber">296</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3656786776644872398" datatype="html">
         <source>Show notifications when new documents are detected</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">279</context>
+          <context context-type="linenumber">300</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6057053428592387613" datatype="html">
         <source>Show notifications when document processing completes successfully</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">280</context>
+          <context context-type="linenumber">301</context>
         </context-group>
       </trans-unit>
       <trans-unit id="370315664367425513" datatype="html">
         <source>Show notifications when document processing fails</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">281</context>
+          <context context-type="linenumber">302</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6838309441164918531" datatype="html">
         <source>Suppress notifications on dashboard</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">282</context>
+          <context context-type="linenumber">303</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2741919327232918179" datatype="html">
         <source>This will suppress all messages about document processing status on the dashboard.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">282</context>
+          <context context-type="linenumber">303</context>
         </context-group>
       </trans-unit>
       <trans-unit id="472206565520537964" datatype="html">
         <source>Saved views</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">290</context>
+          <context context-type="linenumber">311</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context>
         <source>Show warning when closing saved views with unsaved changes</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">296</context>
+          <context context-type="linenumber">317</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2123659921722214537" datatype="html">
         <source>Views</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">300</context>
+          <context context-type="linenumber">321</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.html</context>
         <source>Name</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">306</context>
+          <context context-type="linenumber">327</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
         <source> <x id="START_TAG_SPAN" ctype="x-span" equiv-text="&lt;span class=&quot;visually-hidden&quot;&gt;"/>Appears on<x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="&lt;/span&gt;"/></source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">310,311</context>
+          <context context-type="linenumber">331,332</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4104807402967139762" datatype="html">
         <source>Show on dashboard</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">313</context>
+          <context context-type="linenumber">334</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
         <source>Show in sidebar</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">317</context>
+          <context context-type="linenumber">338</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/save-view-config-dialog/save-view-config-dialog.component.html</context>
         <source>Actions</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">321</context>
+          <context context-type="linenumber">342</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/tasks/tasks.component.html</context>
         <source>Delete</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">324</context>
+          <context context-type="linenumber">345</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
         <source>No saved views defined.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.html</context>
-          <context context-type="linenumber">336</context>
+          <context context-type="linenumber">357</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6839066544204061364" datatype="html">
         <source>Use system language</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">51</context>
+          <context context-type="linenumber">61</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7729897675462249787" datatype="html">
         <source>Use date format of display language</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">54</context>
+          <context context-type="linenumber">64</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1235706724900303689" datatype="html">
         <source>Error retrieving users</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">159</context>
+          <context context-type="linenumber">183</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
         <source>Error retrieving groups</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">178</context>
+          <context context-type="linenumber">202</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
         <source>Saved view &quot;<x id="PH" equiv-text="savedView.name"/>&quot; deleted.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">380</context>
+          <context context-type="linenumber">415</context>
         </context-group>
       </trans-unit>
       <trans-unit id="7217000812750597833" datatype="html">
         <source>Settings were saved successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">506</context>
+          <context context-type="linenumber">541</context>
         </context-group>
       </trans-unit>
       <trans-unit id="525012668859298131" datatype="html">
         <source>Settings were saved successfully. Reload is required to apply some changes.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">510</context>
+          <context context-type="linenumber">545</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8491974984518503778" datatype="html">
         <source>Reload now</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">511</context>
+          <context context-type="linenumber">546</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3011185103048412841" datatype="html">
         <source>An error occurred while saving settings.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">521</context>
+          <context context-type="linenumber">556</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
         <source>Error while storing settings on server.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/admin/settings/settings.component.ts</context>
-          <context context-type="linenumber">555</context>
+          <context context-type="linenumber">590</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2991443309752293110" datatype="html">
           <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
           <context context-type="linenumber">5</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">45</context>
+        </context-group>
       </trans-unit>
       <trans-unit id="1230154438678955604" datatype="html">
         <source>Change</source>
           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
           <context context-type="linenumber">29</context>
         </context-group>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">152</context>
+        </context-group>
       </trans-unit>
       <trans-unit id="595732867213154214" datatype="html">
         <source>Regenerate auth token</source>
           <context context-type="linenumber">151</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="9180110319941008393" datatype="html">
+        <source>Environment</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">18</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5973078531069712831" datatype="html">
+        <source>Paperless-ngx Version</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">22</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6269705781013540301" datatype="html">
+        <source>Install Type</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">24</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7962174670320694437" datatype="html">
+        <source>Server OS</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">26</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2903495470702110128" datatype="html">
+        <source>Media Storage</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">28</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="2571831784751497241" datatype="html">
+        <source>available</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">31</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6489441800790477240" datatype="html">
+        <source>total</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">31</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4198035112366277884" datatype="html">
+        <source>Database</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">41</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="5611592591303869712" datatype="html">
         <source>Status</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">47</context>
+        </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/common/toasts/toasts.component.html</context>
           <context context-type="linenumber">26</context>
           <context context-type="linenumber">19</context>
         </context-group>
       </trans-unit>
+      <trans-unit id="2256165083739630668" datatype="html">
+        <source>Migration Status</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">56</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="7881311375431899727" datatype="html">
+        <source>Latest Migration</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">64</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4632965004151576238" datatype="html">
+        <source>Pending Migrations</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">66</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6904866445262015585" datatype="html">
+        <source>Tasks</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">83</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6911698235105017958" datatype="html">
+        <source>Redis Status</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">87</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="5349496739889768589" datatype="html">
+        <source>Celery Status</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">96</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="31377277941774469" datatype="html">
+        <source>Search Index</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">105</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="4089509911694721896" datatype="html">
+        <source>Last Updated</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">119</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="46628344485199198" datatype="html">
+        <source>Classifier</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">121</context>
+        </context-group>
+      </trans-unit>
+      <trans-unit id="6096684179126491743" datatype="html">
+        <source>Last Trained</source>
+        <context-group purpose="location">
+          <context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
+          <context context-type="linenumber">135</context>
+        </context-group>
+      </trans-unit>
       <trans-unit id="6732151329960766506" datatype="html">
         <source>Copy Raw Error</source>
         <context-group purpose="location">
index 16fda59f04717c4d377ade4add3cc484cb4fc55b..0978ca81a9122ccc07c813e6cae1ae8c9d6fbd50 100644 (file)
@@ -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",
         "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",
         "@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",
index 1ee957e04353ac08131d34a618c50f8a560c5b6e..be12c3ad4c8670358c38ace6f1fad450e18edd2a 100644 (file)
@@ -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",
index 69213846f2516374667e2b865efe92ff2e7e7e62..568b2bc0e0f403e3d81de94b2ccdd04fe7ea1337 100644 (file)
@@ -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: [
     {
index 059b233e2644f39f32e61636204c8f9fcecf90d6..76625d8863fe6e00ec449aabd2a2a6239010ca5f 100644 (file)
@@ -4,10 +4,31 @@
   info="Options to customize appearance, notifications, saved views and more. Settings apply to the <strong>current user only</strong>."
   i18n-info
   >
-  <button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button>
+  <button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
+    <i-bs class="me-1" name="airplane"></i-bs>&nbsp;<ng-container i18n>Start tour</ng-container>
+  </button>
+  <button class="btn btn-sm btn-outline-primary position-relative ms-5" (click)="showSystemStatus()"
+    [disabled]="!systemStatus"
+    *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
+    @if (!systemStatus) {
+      <div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
+    } @else {
+      <i-bs class="me-2" name="card-checklist"></i-bs>
+      @if (systemStatusHasErrors) {
+        <span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
+          <i-bs name="exclamation-circle-fill" class="text-danger" width="1.75em" height="1.75em"></i-bs>
+        </span>
+      } @else {
+        <span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
+          <i-bs name="check-circle-fill" class="text-primary" width="1.75em" height="1.75em"></i-bs>
+        </span>
+      }
+    }
+    <ng-container i18n>System Status</ng-container>
+  </button>
   <a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary ms-3" href="admin/" target="_blank">
     <ng-container i18n>Open Django Admin</ng-container>
-    <i-bs name="arrow-up-right"></i-bs>
+    &nbsp;<i-bs name="arrow-up-right"></i-bs>
   </a>
 </pngx-page-header>
 
index 6256f646bdc05b89d5b0e689a1ac4254fbadfca7..6110f7d1d8dde6f2c2e487f7770440f7f9caf427 100644 (file)
@@ -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',
+    })
+  })
 })
index a77a556bf5b706d3bc8f504e611de76637698e13..f04af2f9db307f90002b65450e621c1f25076eb5 100644 (file)
@@ -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 (file)
index 0000000..5b60abe
--- /dev/null
@@ -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>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..13baa36
--- /dev/null
@@ -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()
+  })
+})
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 (file)
index 0000000..ae391c5
--- /dev/null
@@ -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 (file)
index 0000000..2475356
--- /dev/null
@@ -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 (file)
index 0000000..dd0eb3a
--- /dev/null
@@ -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 (file)
index 0000000..ae6c5a9
--- /dev/null
@@ -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<SystemStatus> {
+    return this.http.get<SystemStatus>(
+      `${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 (file)
index 0000000..964995b
--- /dev/null
@@ -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"])
index 5c84d5ea847b0ef66a0ded63bb6704a537d5e570..bd0b6fa0f377908c9f59a4046e6f0f085c41e35a 100644 (file)
@@ -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,
+                },
+            },
+        )
index 142f2792ded6bd16436b31447357ec8de1061367..12b049918a4252cd7bbaa0719dd8be995e9cba6c 100644 (file)
@@ -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,
             ],
         ),