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).
+
+

+
+##### 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.
+
+
+
+##### 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.
+
+
+
+##### 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.
+
+
+
+##### 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.
+
+
+
+##### 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.
+
+
+
+##### 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;