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
"scripts": [],
"allowedCommonJsDependencies": [
"pdfjs-dist",
- "pdfjs-dist/web/pdf_viewer"
+ "pdfjs-dist/web/pdf_viewer",
+ "filesize",
+ "file-saver"
],
"vendorChunk": true,
"extractLicenses": false,
</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:'shortDate':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:'mediumDate':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:'longDate':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 'slim' 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="<a href="https://api.github.com/repos/paperless-ngx/paperless-ngx/releases/latest" target="_blank" rel="noopener noreferrer">"/>GitHub API<x id="CLOSE_LINK" ctype="x-a" equiv-text="</a>"/> for the latest release to determine whether a new version is available.<x id="LINE_BREAK" ctype="lb" equiv-text="<br/>"/> 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=">"/>No tracking data is collected by the app in any way.<x id="CLOSE_EMPHASISED_TEXT" ctype="x-em" equiv-text="</em>"/></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="<span class="visually-hidden">"/>Appears on<x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></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 "<x id="PH" equiv-text="savedView.name"/>" 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">
"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",
"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",
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,
boxes,
calendar,
calendarEvent,
+ cardChecklist,
caretDown,
caretUp,
chatLeftText,
check,
check2All,
checkAll,
+ checkCircleFill,
checkLg,
chevronDoubleLeft,
chevronDoubleRight,
doorOpen,
download,
envelope,
+ exclamationCircleFill,
exclamationTriangle,
+ exclamationTriangleFill,
eye,
fileEarmark,
fileEarmarkCheck,
} from 'ngx-bootstrap-icons'
const icons = {
+ airplane,
archive,
arrowCounterclockwise,
arrowDown,
boxes,
calendar,
calendarEvent,
+ cardChecklist,
caretDown,
caretUp,
chatLeftText,
check,
check2All,
checkAll,
+ checkCircleFill,
checkLg,
chevronDoubleLeft,
chevronDoubleRight,
doorOpen,
download,
envelope,
+ exclamationCircleFill,
exclamationTriangle,
+ exclamationTriangleFill,
eye,
fileEarmark,
fileEarmarkCheck,
FileComponent,
ConfirmButtonComponent,
MonetaryComponent,
+ SystemStatusDialogComponent,
],
imports: [
BrowserModule,
TourNgBootstrapModule,
DragDropModule,
NgxBootstrapIconsModule.pick(icons),
+ NgxFilesizeModule,
],
providers: [
{
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> <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>
+ <i-bs name="arrow-up-right"></i-bs>
</a>
</pngx-page-header>
NgbModule,
NgbAlertModule,
NgbNavLink,
+ NgbModal,
+ NgbModalModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { of, throwError } from 'rxjs'
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 },
let userService: UserService
let permissionsService: PermissionsService
let groupService: GroupService
+ let modalService: NgbModal
+ let systemStatusService: SystemStatusService
beforeEach(async () => {
TestBed.configureTestingModule({
NgbAlertModule,
NgSelectModule,
NgxBootstrapIconsModule.pick(allIcons),
+ NgbModalModule,
],
}).compileComponents()
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')
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',
+ })
+ })
})
} 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 {
} 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,
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 ||
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(() => {
// 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) {
clearThemeColor() {
this.settingsForm.get('themeColor').patchValue('')
}
+
+ showSystemStatus() {
+ const modal: NgbModalRef = this.modalService.open(
+ SystemStatusDialogComponent,
+ {
+ size: 'xl',
+ }
+ )
+ modal.componentInstance.status = this.systemStatus
+ }
}
--- /dev/null
+<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>
+ }
+ @if (copied) {
+ <i-bs name="clipboard-check-fill"></i-bs>
+ }
+ <ng-container i18n>Copy</ng-container>
+ </button>
+</div>
--- /dev/null
+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()
+ })
+})
--- /dev/null
+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
+ }
+}
--- /dev/null
+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
+ }
+}
--- /dev/null
+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')
+ })
+})
--- /dev/null
+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}/`
+ )
+ }
+}
--- /dev/null
+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"])
import json
import logging
import os
+import platform
import re
import tempfile
import urllib
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
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
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
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
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
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,
+ },
+ },
+ )
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
ProfileView.as_view(),
name="profile_view",
),
+ re_path(
+ "^status/",
+ SystemStatusView.as_view(),
+ name="system_status",
+ ),
*api_router.urls,
],
),