diff --git a/docs/widgets.md b/docs/widgets.md index d5f7d92a..1bbd9e65 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -48,6 +48,12 @@ Dashy has support for displaying dynamic content in the form of widgets. There a - [AdGuard Home Filters](#adguard-home-filters) - [AdGuard Home DNS Info](#adguard-home-dns-info) - [AdGuard Home Top Domains](#adguard-home-top-domains) + - [Nextcloud User](#nextcloud-user) + - [Nextcloud User Statuses](#nextcloud-user-statuses) + - [Nextcloud Notifications](#nextcloud-notifications) + - [Nextcloud System](#nextcloud-system) + - [Nextcloud Stats](#nextcloud-stats) + - [Nextcloud PHP Opcache](#nextcloud-php-opcache-stats) - **[System Resource Monitoring](#system-resource-monitoring)** - [CPU Usage Current](#current-cpu-usage) - [CPU Usage Per Core](#cpu-usage-per-core) @@ -1564,6 +1570,224 @@ Fetches data from your [AdGuard Home](https://adguard.com/en/adguard-home/overvi --- +### Nextcloud User + +Nextcloud is a [self hosted](https://nextcloud.com/install/#instructions-server) productivity platform, it can also be used free of charge with [hundreds of existing hosting providers](https://nextcloud.com/sign-up/) that offer a free Nextcloud account. + +Displays branding information of a Nextcloud server (logo, url, slogan) and some user details (name, login name, last login, disk space or quota). Use with regular or admin user. + +Shows quota usage when quota is enabled for the user or disk usage when not enabled. + +Known issues: the User API incorrectly reports available disk space as total for admin users when quota is not enabled (which usually is the case for admins). + +

nextcloud-user

+ +##### Options + +**Field** | **Type** | **Required** | **Description** +--- | --- | --- | --- +**`hostname`** | `string` | Required | The URL of the Nextcloud server +**`username`** | `string` | Required | Nextcloud username +**`password`** | `string` | Required | Nextcloud app-password (create one in Settings -> Security) + + +##### Example + +```yaml +- type: nextcloud-user + useProxy: true + options: + hostname: https://nextcloud.example.com + username: alice + password: xxxxx-xxxxx-xxxxx-xxxxx +``` + +##### Info +- **CORS**: 🟠 Proxied +- **Auth**: 🟢 Required +- **Price**: 🟢 Free +- **Host**: Self-Hosted (see [Nextcloud](https://nextcloud.com)) +- **Privacy**: _See [Nextcloud Privacy Policy](https://nextcloud.com/privacy)_ + +--- + +### Nextcloud User Statuses + +Show user statuses for selected users. + +

nextcloud-userstatus

+ +##### Options + +**Field** | **Type** | **Required** | **Description** +--- | --- | --- | --- +**`hostname`** | `string` | Required | The URL of the Nextcloud server +**`username`** | `string` | Required | Nextcloud username +**`password`** | `string` | Required | Nextcloud app-password (create one in Settings -> Security) +**`users`** | `array` | Required | Nextcloud User IDs to show statuses for, list size between `1` and `100` +**`showEmpty`** | `boolean` | _Optional_ | Show statuses without a message, defaults to `true` + + +##### Example + +```yaml +- type: nextcloud-userstatus + useProxy: true + options: + hostname: https://nextcloud.example.com + username: alice + password: xxxxx-xxxxx-xxxxx-xxxxx + users: ['bob', 'alice'] +``` + +##### Info +- **CORS**: 🟠 Proxied +- **Auth**: 🟢 Required +- **Price**: 🟢 Free +- **Host**: Self-Hosted (see [Nextcloud](https://nextcloud.com)) +- **Privacy**: _See [Nextcloud Privacy Policy](https://nextcloud.com/privacy)_ + +--- + +### Nextcloud Notifications + +Displays your notifications and allows deleting them. + +

nextcloud-notifications

+ +##### Options + +**Field** | **Type** | **Required** | **Description** +--- | --- | --- | --- +**`hostname`** | `string` | Required | The URL of the Nextcloud server +**`username`** | `string` | Required | Nextcloud username +**`password`** | `string` | Required | Nextcloud app-password (create one in Settings -> Security) +**`limit`** | `number\|string` | _Optional_ | Limit displayed notifications either by count, e.g. `5` to show the 5 most recent, or by age, e.g. `1d` to only show notifications not older than a day. Accepted suffixes for age limit are `m`, `h` and `d`. + + +##### Example + +```yaml +- type: nextcloud-userstatus + useProxy: true + options: + hostname: https://nextcloud.example.com + username: alice + password: xxxxx-xxxxx-xxxxx-xxxxx + limit: 6h +``` + +##### Info +- **CORS**: 🟠 Proxied +- **Auth**: 🟢 Required +- **Price**: 🟢 Free +- **Host**: Self-Hosted (see [Nextcloud](https://nextcloud.com)) +- **Privacy**: _See [Nextcloud Privacy Policy](https://nextcloud.com/privacy)_ + +--- + +### Nextcloud System + +Visualises overall memory utilisation and CPU load averages, shows server versions. + +

nextcloud-system

+ +##### Options + +**Field** | **Type** | **Required** | **Description** +--- | --- | --- | --- +**`hostname`** | `string` | Required | The URL of the Nextcloud server +**`username`** | `string` | Required | Must be a Nextcloud admin user +**`password`** | `string` | Required | Nextcloud app-password (create one in Settings -> Security) + +##### Example + +```yaml +- type: nextcloud-system + useProxy: true + options: + hostname: https://nextcloud.example.com + username: alice + password: xxxxx-xxxxx-xxxxx-xxxxx +``` + +##### Info +- **CORS**: 🟠 Proxied +- **Auth**: 🟢 Required +- **Price**: 🟢 Free +- **Host**: Self-Hosted (see [Nextcloud](https://nextcloud.com)) +- **Privacy**: _See [Nextcloud Privacy Policy](https://nextcloud.com/privacy)_ + +--- + +### Nextcloud Stats + +Shows key usage statistics about your Nextcloud server. + +

nextcloud-stats

+ +##### Options + +**Field** | **Type** | **Required** | **Description** +--- | --- | --- | --- +**`hostname`** | `string` | Required | The URL of the Nextcloud server +**`username`** | `string` | Required | Must be a Nextcloud admin user +**`password`** | `string` | Required | Nextcloud app-password (create one in Settings -> Security) + +##### Example + +```yaml +- type: nextcloud-stats + useProxy: true + options: + hostname: https://nextcloud.example.com + username: alice + password: xxxxx-xxxxx-xxxxx-xxxxx +``` + +##### Info +- **CORS**: 🟠 Proxied +- **Auth**: 🟢 Required +- **Price**: 🟢 Free +- **Host**: Self-Hosted (see [Nextcloud](https://nextcloud.com)) +- **Privacy**: _See [Nextcloud Privacy Policy](https://nextcloud.com/privacy)_ + +--- + +### Nextcloud PHP Opcache Stats + +Shows statistics about PHP Opcache perforamnce on your Nextcloud server. + +

nextcloud-phpopcache

+ +##### Options + +**Field** | **Type** | **Required** | **Description** +--- | --- | --- | --- +**`hostname`** | `string` | Required | The URL of the Nextcloud server +**`username`** | `string` | Required | Must be a Nextcloud admin user +**`password`** | `string` | Required | Nextcloud app-password (create one in Settings -> Security) + +##### Example + +```yaml +- type: nextcloud-stats + useProxy: true + options: + hostname: https://nextcloud.example.com + username: alice + password: xxxxx-xxxxx-xxxxx-xxxxx +``` + +##### Info +- **CORS**: 🟠 Proxied +- **Auth**: 🟢 Required +- **Price**: 🟢 Free +- **Host**: Self-Hosted (see [Nextcloud](https://nextcloud.com)) +- **Privacy**: _See [Nextcloud Privacy Policy](https://nextcloud.com/privacy)_ + +--- + ## System Resource Monitoring The easiest method for displaying system info and resource usage in Dashy is with [Glances](https://nicolargo.github.io/glances/). diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index a064eb67..efdaf631 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -303,6 +303,77 @@ "remaining": "Remaining", "up": "Up", "down": "Down" + }, + "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/NextcloudNotifications.vue b/src/components/Widgets/NextcloudNotifications.vue new file mode 100644 index 00000000..dd1d4f34 --- /dev/null +++ b/src/components/Widgets/NextcloudNotifications.vue @@ -0,0 +1,208 @@ + + + + diff --git a/src/components/Widgets/NextcloudPhpOpcache.vue b/src/components/Widgets/NextcloudPhpOpcache.vue new file mode 100644 index 00000000..645cac07 --- /dev/null +++ b/src/components/Widgets/NextcloudPhpOpcache.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/src/components/Widgets/NextcloudStats.vue b/src/components/Widgets/NextcloudStats.vue new file mode 100644 index 00000000..5d43bc60 --- /dev/null +++ b/src/components/Widgets/NextcloudStats.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/src/components/Widgets/NextcloudSystem.vue b/src/components/Widgets/NextcloudSystem.vue new file mode 100644 index 00000000..c07ded6e --- /dev/null +++ b/src/components/Widgets/NextcloudSystem.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/src/components/Widgets/NextcloudUser.vue b/src/components/Widgets/NextcloudUser.vue new file mode 100644 index 00000000..1d84f825 --- /dev/null +++ b/src/components/Widgets/NextcloudUser.vue @@ -0,0 +1,206 @@ + + + + + diff --git a/src/components/Widgets/NextcloudUserStatus.vue b/src/components/Widgets/NextcloudUserStatus.vue new file mode 100644 index 00000000..37f8ae7c --- /dev/null +++ b/src/components/Widgets/NextcloudUserStatus.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/components/Widgets/WidgetBase.vue b/src/components/Widgets/WidgetBase.vue index d34d135d..7abb9790 100644 --- a/src/components/Widgets/WidgetBase.vue +++ b/src/components/Widgets/WidgetBase.vue @@ -321,6 +321,48 @@ @error="handleError" :ref="widgetRef" /> + + + + + + import('@/components/Widgets/NdLoadHistory.vue'), NdRamHistory: () => import('@/components/Widgets/NdRamHistory.vue'), NewsHeadlines: () => import('@/components/Widgets/NewsHeadlines.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 new file mode 100644 index 00000000..bdbe753e --- /dev/null +++ b/src/mixins/NextcloudMixin.js @@ -0,0 +1,208 @@ +import { serviceEndpoints } from '@/utils/defaults'; +import { + convertBytes, formatNumber, getTimeAgo, timestampToDateTime, +} from '@/utils/MiscHelpers'; + +/** + * 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: { + enabled: null, + features: [], + }, + userStatus: null, + }, + capabilitiesLastUpdated: 0, + 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 a Nextcloud app-password, not your login password.'); + return ''; + } + return this.options.password; + }, + /* HTTP headers for Nextcloud API requests */ + headers() { + const authBase = `${this.username}:${this.password}`; + return { + 'OCS-APIREQUEST': true, + Accept: 'application/json', + Authorization: `Basic ${window.btoa(authBase)}`, + }; + }, + /* TTL for data delivered by the capabilities endpoint, ms */ + capabilitiesTtl() { + return (parseInt(this.options.capabilitiesTtl, 10) || 3600) * 1000; + }, + proxyReqEndpoint() { + const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin; + return `${baseUrl}${serviceEndpoints.corsProxy}`; + }, + }, + methods: { + /* Nextcloud API endpoints */ + endpoint(id) { + switch (id) { + 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`; + case 'capabilities': + default: + return `${this.hostname}/ocs/v1.php/cloud/capabilities`; + } + }, + /* 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) + .then(this.processCapabilities); + } + return Promise.resolve(); + }, + /* 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.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(); + }, + /* 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(/(-?\d+)((\.\d+)?\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(/(\d+)((\.\d+)?([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..77360681 --- /dev/null +++ b/src/styles/widgets/nextcloud-shared.scss @@ -0,0 +1,64 @@ +.nextcloud-widget { + p { + color: var(--widget-text-color); + margin: .5em 0; + } + + a { + color: var(--widget-text-color); + } + + p i { + font-size: 1.1em; + min-width: 22px; + text-align: center; + } + + p em { + font-size: 1.1em; + margin: 0 .24em; + font-weight: 800; + } + + strong { + font-weight: 800; + font-size: 1.05em; + margin-left: .25em; + } + + small { + opacity: .66; + } + + hr { + color: var(--widget-text-color); + border: none; + border-top: 1px solid; + margin-top: .8em; + margin-bottom: .8em; + opacity: .25; + clear: both; + } + hr:last-child { + margin-bottom: 0; + } + + div.sep { + border-top: 1px dashed var(--widget-text-color); + width: 100%; + padding: .4em 0 0 0; + margin: .85em 0 0 0; + > div:not(:first-child) { + width: 100%; + position: relative; + } + } + + ::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 c3f59a8b..11e834bc 100644 --- a/src/utils/MiscHelpers.js +++ b/src/utils/MiscHelpers.js @@ -106,6 +106,18 @@ export const convertBytes = (bytes, decimals = 2) => { return `${parseFloat((bytes / (k ** i)).toFixed(decimals))} ${sizes[i]}`; }; +/* 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 units = ['', 'K', 'M', 'B', 'T']; + const k = 1000; + const i = Math.floor(Math.log(number) / Math.log(k)); + const f = parseFloat(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 */ export const roundPrice = (price) => { if (Number.isNaN(price)) return price;