From 821af624260cf7833f156e28f8aeb63d5b253dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=BCl=C3=B6p?= Date: Sun, 19 Jun 2022 12:06:43 +0000 Subject: [PATCH] :construction: Refactor + more widgets * :recycle: segment into smaller widgets, improve mixin * :recycle: change NextcloudInfo to NextcloudUser * a small widget showing branding and uesr info, including quota * :sparkles: add NextcloudNotifications widget * show and delete Nextcloud notifications * :sparkles: add NextcloudUserStatus widget * display user statuses of selected users * :sparkles: add NextcloudStats widget (admin only) * display Nextcloud usage statistics (users, files, shares) * :sparkles: add NextcloudSystem widget (admin only) * visualise cpu load and memory utilisation, show server versions * :sparkles: add NextcloudPhpOpcache widget (admin only) * show statistics about php opcache performance * :sparkles: add a misc helper for formatting nunbers * :globe_with_meridians: add translations to widget templates * :globe_with_meridians: add translation entries for en * :bento: add scss styles file, shared by all widgets --- src/assets/locales/en.json | 74 +++- src/components/Widgets/NextcloudInfo.vue | 381 ------------------ .../Widgets/NextcloudNotifications.vue | 192 +++++++++ .../Widgets/NextcloudPhpOpcache.vue | 205 ++++++++++ src/components/Widgets/NextcloudStats.vue | 203 ++++++++++ src/components/Widgets/NextcloudSystem.vue | 234 +++++++++++ src/components/Widgets/NextcloudUser.vue | 193 +++++++++ .../Widgets/NextcloudUserStatus.vue | 195 +++++++++ src/components/Widgets/WidgetBase.vue | 46 ++- src/mixins/NextcloudMixin.js | 187 ++++++--- src/styles/widgets/nextcloud-shared.scss | 77 ++++ src/utils/MiscHelpers.js | 13 +- 12 files changed, 1558 insertions(+), 442 deletions(-) delete mode 100644 src/components/Widgets/NextcloudInfo.vue create mode 100644 src/components/Widgets/NextcloudNotifications.vue create mode 100644 src/components/Widgets/NextcloudPhpOpcache.vue create mode 100644 src/components/Widgets/NextcloudStats.vue create mode 100644 src/components/Widgets/NextcloudSystem.vue create mode 100644 src/components/Widgets/NextcloudUser.vue create mode 100644 src/components/Widgets/NextcloudUserStatus.vue create mode 100644 src/styles/widgets/nextcloud-shared.scss diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index b22974e4..f368f61e 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -304,10 +304,76 @@ "up": "Up", "down": "Down" }, - "nextcloud-info": { - "label-version": "Nextcloud version", - "label-last-login": "Last login", - "updates-available": "update{plural} available" + "nextcloud": { + "active": "active", + "and": "and", + "applications": "applications", + "available": "available", + "away": "Away", + "cache-full": "CACHE FULL", + "chat-room": "chat room", + "delete-all": "Deleta all", + "delete-notification": "Delete notification", + "disabled": "disabled", + "disk-quota": "Disk Quota", + "disk-space": "Disk Space", + "dnd": "Do Not Distrub", + "email": "email", + "enabled": "enabled", + "federated-shares-ucfirst": "Federated shares", + "federated-shares": "federated shares", + "files": "file{plural}", + "free": "free", + "groups": "groups", + "hit-rate": "hit rate", + "hits": "hits", + "home": "home", + "in": "in", + "keys": "keys", + "last-24-hours": "last 24 hours", + "last-5-minutes": "in the last 5 minutes", + "last-hour": "in the last hour", + "last-login": "Last login", + "last-restart": "Last restart", + "load-averages": "Load Averages over all CPU cores", + "local-shares": "Local shares", + "local": "local", + "max-keys": "max keys", + "memory-used": "memory-used", + "memory-utilisation": "memory utilisation", + "memory": "memory", + "misses": "misses", + "no-notifications": "No notifications", + "no-pending-updates": "no pending updates", + "nothing-to-show": "Nothing to show here at this time", + "of-which": "of which", + "of": "of", + "offline": "Offline", + "online": "Online", + "other": "other", + "overall": "Ovarall", + "private-link": "private link", + "public-link": "public link", + "quota-enabled": "Disk Quota is {not}enabled for this user", + "received": "received", + "scripts": "scripts", + "sent": "sent", + "started": "Started", + "storages-by-type": "Storages by type", + "storages": "storage{plural}", + "strings-use": "strings use", + "tasks": "Tasks", + "total-files": "total files", + "total-users": "total users", + "total": "total", + "until": "Until", + "updates-available-for": "Updates are available for", + "updates-available": "update{plural} available", + "used": "used", + "user": "user", + "using": "using", + "version": "version", + "wasted": "wasted" } } } diff --git a/src/components/Widgets/NextcloudInfo.vue b/src/components/Widgets/NextcloudInfo.vue deleted file mode 100644 index 6b9c953a..00000000 --- a/src/components/Widgets/NextcloudInfo.vue +++ /dev/null @@ -1,381 +0,0 @@ - - - - - diff --git a/src/components/Widgets/NextcloudNotifications.vue b/src/components/Widgets/NextcloudNotifications.vue new file mode 100644 index 00000000..d68ad1b8 --- /dev/null +++ b/src/components/Widgets/NextcloudNotifications.vue @@ -0,0 +1,192 @@ + + + + diff --git a/src/components/Widgets/NextcloudPhpOpcache.vue b/src/components/Widgets/NextcloudPhpOpcache.vue new file mode 100644 index 00000000..b64bdaf1 --- /dev/null +++ b/src/components/Widgets/NextcloudPhpOpcache.vue @@ -0,0 +1,205 @@ + + + + + diff --git a/src/components/Widgets/NextcloudStats.vue b/src/components/Widgets/NextcloudStats.vue new file mode 100644 index 00000000..a03f247f --- /dev/null +++ b/src/components/Widgets/NextcloudStats.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/src/components/Widgets/NextcloudSystem.vue b/src/components/Widgets/NextcloudSystem.vue new file mode 100644 index 00000000..1e4e5bde --- /dev/null +++ b/src/components/Widgets/NextcloudSystem.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/src/components/Widgets/NextcloudUser.vue b/src/components/Widgets/NextcloudUser.vue new file mode 100644 index 00000000..832fa3ba --- /dev/null +++ b/src/components/Widgets/NextcloudUser.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/src/components/Widgets/NextcloudUserStatus.vue b/src/components/Widgets/NextcloudUserStatus.vue new file mode 100644 index 00000000..738997f5 --- /dev/null +++ b/src/components/Widgets/NextcloudUserStatus.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/src/components/Widgets/WidgetBase.vue b/src/components/Widgets/WidgetBase.vue index 6551f265..7abb9790 100644 --- a/src/components/Widgets/WidgetBase.vue +++ b/src/components/Widgets/WidgetBase.vue @@ -321,8 +321,43 @@ @error="handleError" :ref="widgetRef" /> - + + + + + import('@/components/Widgets/NdLoadHistory.vue'), NdRamHistory: () => import('@/components/Widgets/NdRamHistory.vue'), NewsHeadlines: () => import('@/components/Widgets/NewsHeadlines.vue'), - NextcloudInfo: () => import('@/components/Widgets/NextcloudInfo.vue'), + NextcloudNotifications: () => import('@/components/Widgets/NextcloudNotifications.vue'), + NextcloudPhpOpcache: () => import('@/components/Widgets/NextcloudPhpOpcache.vue'), + NextcloudStats: () => import('@/components/Widgets/NextcloudStats.vue'), + NextcloudSystem: () => import('@/components/Widgets/NextcloudSystem.vue'), + NextcloudUser: () => import('@/components/Widgets/NextcloudUser.vue'), + NextcloudUserStatus: () => import('@/components/Widgets/NextcloudUserStatus.vue'), PiHoleStats: () => import('@/components/Widgets/PiHoleStats.vue'), PiHoleTopQueries: () => import('@/components/Widgets/PiHoleTopQueries.vue'), PiHoleTraffic: () => import('@/components/Widgets/PiHoleTraffic.vue'), diff --git a/src/mixins/NextcloudMixin.js b/src/mixins/NextcloudMixin.js index a64f1387..0d933f85 100644 --- a/src/mixins/NextcloudMixin.js +++ b/src/mixins/NextcloudMixin.js @@ -1,47 +1,64 @@ import { serviceEndpoints } from '@/utils/defaults'; -import { convertBytes, formatNumber, getTimeAgo } from '@/utils/MiscHelpers'; -// //import { NcdCap, NcdUsr } from '@/utils/ncd'; +import { + convertBytes, formatNumber, getTimeAgo, timestampToDateTime, +} from '@/utils/MiscHelpers'; +// //import { NcdCap } from '@/utils/ncd'; -/** Reusable mixin for Nextcloud widgets */ +/** + * Reusable mixin for Nextcloud widgets + * Nextcloud APIs + * - capabilities: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html#capabilities-api + * - userstatus: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-status-api.html#user-status-retrieve-statuses + * - user: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html#user-metadata + * - notifications: https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md + * - serverinfo: https://github.com/nextcloud/serverinfo + */ export default { data() { return { + validCredentials: null, capabilities: { - notifications: null, - activity: null, + notifications: { + enabled: null, + features: [], + }, + userStatus: null, }, capabilitiesLastUpdated: 0, - user: { - id: null, - isAdmin: false, - displayName: null, - email: null, - quota: { - relative: null, - total: null, - used: null, - free: null, - quota: null, - }, + branding: { + name: null, + logo: null, + url: null, + slogan: null, + }, + version: { + string: null, + edition: null, }, }; }, computed: { + /* The user provided Nextcloud hostname */ hostname() { if (!this.options.hostname) this.error('A hostname is required'); return this.options.hostname; }, + /* The user provided Nextcloud username */ username() { if (!this.options.username) this.error('A username is required'); return this.options.username; }, + /* The user provided Nextcloud password */ password() { if (!this.options.password) this.error('An app-password is required'); + // reject Nextcloud user passord (enforce 'app-password') if (!/^([a-z0-9]{5}-){4}[a-z0-9]{5}$/i.test(this.options.password)) { - this.error('Please use an app-password for this widget, not your login password.'); + this.error('Please use a Nextcloud app-password, not your login password.'); + return ''; } return this.options.password; }, + /* HTTP headers for Nextcloud API requests */ headers() { return { 'OCS-APIREQUEST': true, @@ -49,6 +66,7 @@ export default { Authorization: `Basic ${window.btoa(`${this.username}:${this.password}`)}`, }; }, + /* TTL for data delivered by the capabilities endpoint, ms */ capabilitiesTtl() { return (parseInt(this.options.capabilitiesTtl, 10) || 3600) * 1000; }, @@ -58,6 +76,7 @@ export default { }, }, methods: { + /* Nextcloud API endpoints */ endpoint(id) { switch (id) { case 'capabilities': @@ -65,10 +84,65 @@ export default { return `${this.hostname}/ocs/v1.php/cloud/capabilities`; case 'user': return `${this.hostname}/ocs/v1.php/cloud/users/${this.username}`; + case 'userstatus': + return `${this.hostname}/ocs/v2.php/apps/user_status/api/v1/statuses`; case 'serverinfo': return `${this.hostname}/ocs/v2.php/apps/serverinfo/api/v1/info`; + case 'notifications': + return `${this.hostname}/ocs/v2.php/apps/notifications/api/v2/notifications`; } }, + /* Helper for widgets to terminate {fetchData} early */ + hasValidCredentials() { + return this.validCredentials !== false + && this.username.length > 0 + && this.password.length > 0; + }, + /* Primary handler for every Nextcloud API response */ + validateResponse(response) { + const data = response?.ocs?.data; + let meta = response?.ocs?.meta; + const error = response?.error; // Dashy error when cors-proxied + if (error && error.status) { + meta = { statuscode: error.status }; + } + if (!meta || !meta.statuscode || !data) { + this.error('Invalid response'); + } + switch (meta.statuscode) { + case 401: + this.validCredentials = false; + this.error( + `Access denied for user ${this.username}.` + + ' Note that some Nextcloud widgets only work with an admin user.', + ); + break; + case 429: + this.validCredentials = false; + this.error( + 'The server indicated \'rate-limit reached\' error (HTTP 429).' + + ' The server-info API may return this error for incorrect user/password.', + ); + break; + case 993: + case 997: + case 998: + this.validCredentials = false; + this.error( + 'The provided app-password is not permitted to access the requested resource or it has' + + ' been revoked, or the username/password combination is incorrect', + ); + break; + default: + this.validCredentials = true; + if (!this.allowedStatuscodes().includes(meta.statuscode)) { + this.error('Unexpected response'); + } + break; + } + return data; + }, + /* Process the capabilities endpoint if {capabilitiesTtl} has expired */ loadCapabilities() { if ((new Date().getTime()) - this.capabilitiesLastUpdated > this.capabilitiesTtl) { return this.makeRequest(this.endpoint('capabilities'), this.headers) @@ -77,44 +151,59 @@ export default { } return Promise.resolve(); }, - processCapabilities(data) { - const ocdata = data?.ocs?.data; - if (!ocdata) { - this.error('Invalid response'); - return; - } + /* Update the sate based on the capabilites response */ + processCapabilities(capResponse) { + const ocdata = this.validateResponse(capResponse); + const capNotif = ocdata?.capabilities?.notifications?.['ocs-endpoints']; this.branding = ocdata?.capabilities?.theming; - this.capabilities.notifications = ocdata?.capabilities?.notifications?.['ocs-endpoints']; - this.capabilities.activity = ocdata?.capabilities?.activity?.apiv2; + this.capabilities.notifications.enabled = !!(capNotif?.length); + this.capabilities.notifications.features = capNotif || []; + this.capabilities.userStatus = !!(ocdata?.capabilities?.user_status?.enabled); this.version.string = ocdata?.version?.string; this.version.edition = ocdata?.version?.edition; this.capabilitiesLastUpdated = new Date().getTime(); }, - loadUser() { - return this.makeRequest(this.endpoint('user'), this.headers).then(this.processUser); - // //return Promise.resolve(NcdUsr).then(this.processUser); - }, - processUser(userData) { - const user = userData?.ocs?.data; - if (!user) { - this.error('Invalid response'); - return; - } - this.user.id = user.id; - this.user.email = user.email; - this.user.quota = user.quota; - this.user.displayName = user.displayname; - this.user.lastLogin = user.lastLogin; - this.user.isAdmin = user.groups && user.groups.includes('admin'); - }, - formatNumber(number) { - return formatNumber(number); - }, - convertBytes(bytes) { - return convertBytes(bytes); - }, + /* Shared template helpers */ getTimeAgo(time) { return getTimeAgo(time); }, + formatDateTime(time) { + return timestampToDateTime(time); + }, + /* Add additional formatting to {MiscHelpers.convertBytes()} */ + convertBytes(bytes, decimals = 2, formatHtml = true) { + const formatted = convertBytes(bytes, decimals).toString(); + if (!formatHtml) return formatted; + const m = formatted.match(/(-?[0-9]+)((\.[0-9]+)?\s(([KMGTPEZY]B|Bytes)))/); + return `${m[1]}${m[2]}`; + }, + /* Add additional formatting to {MiscHelpers.formatNumber()} */ + formatNumber(number, decimals = 1, formatHtml = true) { + const formatted = formatNumber(number, decimals).toString(); + if (!formatHtml) return formatted; + const m = formatted.match(/([0-9]+)((\.[0-9]+)?([KMBT]?))/); + return `${m[1]}${m[2]}`; + }, + /* Format a number as percentage value */ + formatPercent(number, decimals = 2) { + const n = parseFloat(number).toFixed(decimals).split('.'); + const d = n.length > 1 ? `.${n[1]}` : ''; + return `${n[0]}${d}%`; + }, + /* Similar to {MiscHelpers.getValueFromCss()} but uses the widget root node to get + * the computed style so widget color is respected in variable widget color themes. */ + getValueFromCss(colorVar) { + const cssProps = getComputedStyle(this.$el || document.documentElement); + return cssProps.getPropertyValue(`--${colorVar}`).trim(); + }, + /* Get {colorVar} CSS property value and return as rgba() */ + getColorRgba(colorVar, alpha = 1) { + const [r, g, b] = this.getValueFromCss(colorVar).match(/\w\w/g).map(x => parseInt(x, 16)); + return `rgba(${r},${g},${b},${alpha})`; + }, + /* Translation shorthand with key prefix */ + tt(key, options = null) { + return this.$t(`widgets.nextcloud.${key}`, options); + }, }, }; diff --git a/src/styles/widgets/nextcloud-shared.scss b/src/styles/widgets/nextcloud-shared.scss new file mode 100644 index 00000000..204479a5 --- /dev/null +++ b/src/styles/widgets/nextcloud-shared.scss @@ -0,0 +1,77 @@ +.nextcloud-widget { + p { + color: var(--widget-text-color); + margin: 0.5rem 0; + } + + a { + color: var(--widget-text-color); + } + + p i { + font-size: 110%; + min-width: 22px; + text-align: center; + } + + p em { + font-size: 110%; + margin: 0 4px; + font-weight: 800; + } + + strong { + font-weight: 800; + font-size: 105%; + margin-left: .25rem; + } + + small { + opacity: .66; + } + + hr { + color: var(--widget-text-color); + border: none; + border-top: 1px solid; + margin-top: 0.8rem; + margin-bottom: 0.8rem; + opacity: .25; + clear: both; + } + + div.sep { + border-top: 1px dashed var(--widget-text-color); + width: 100%; + padding: .4rem 0; + margin: .85em 0 0 0; + > div:not(:first-child) { + width: 100%; + position: relative; + } + } + + .success { + color: var(--success); + } + + .warning { + color: #ff9000; + } + + .danger { + color: var(--danger); + } + + .disabled { + color: #818181; + } + + ::v-deep span.decimals { + font-size: 85%; + } + + ::v-deep div.percentage-chart { + margin: 0; + } +} diff --git a/src/utils/MiscHelpers.js b/src/utils/MiscHelpers.js index 6a65222f..14e6c808 100644 --- a/src/utils/MiscHelpers.js +++ b/src/utils/MiscHelpers.js @@ -105,14 +105,17 @@ export const convertBytes = (bytes, decimals = 2) => { const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / (k ** i)).toFixed(decimals))} ${sizes[i]}`; }; -/* Returns a numbers shortened version with suffixes for thousand, million, billion - and trillion, e.g. 105_411 => 105.4K, 4_294_967_295 => 4.3B */ -export const formatNumber = (number) => { + +/* Round a number to thousands, millions, billions or trillions and suffix + * with K, M, B or T respectively, e.g. 4_294_967_295 => 4.3B */ +export const formatNumber = (number, decimals = 1) => { if (number > -1000 && number < 1000) return number; - const k = 1000; const units = ['', 'K', 'M', 'B', 'T']; + const k = 1000; const i = Math.floor(Math.log(number) / Math.log(k)); - return `${(number / (k ** i)).toFixed(1)}${units[i]}`; + const f = number / (k ** i); + const d = f.toFixed(decimals) % 1.0 === 0 ? 0 : decimals; // number of decimals, omit .0 + return `${f.toFixed(d)}${units[i]}`; }; /* Round price to appropriate number of decimals */