🔀 Merge pull request #740 from marekful/FEATURE/nextcloud-widgets

Feature/nextcloud widgets
Credit to @marekful
This commit is contained in:
Alicia Sykes 2022-06-20 21:34:32 +01:00 committed by GitHub
commit dbb261c859
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 1874 additions and 0 deletions

View File

@ -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).
<p align="center"><img width="450" src="https://i.ibb.co/F8Fdm3t/nextcloud-user.png" alt="nextcloud-user" /></p>
##### 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.
<p align="center"><img width="450" src="https://i.ibb.co/Lk4DFT5/nextcloud-userstatus.png" alt="nextcloud-userstatus" /></p>
##### 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.
<p align="center"><img width="450" src="https://i.ibb.co/yQCS51k/nextcloud-notifications.png" alt="nextcloud-notifications" /></p>
##### 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.
<p align="center"><img width="450" src="https://i.ibb.co/KW4t6nG/nextcloud-system.png" alt="nextcloud-system" /></p>
##### 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.
<p align="center"><img width="450" src="https://i.ibb.co/pPXPQFB/nextcloud-stats.png" alt="nextcloud-stats" /></p>
##### 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.
<p align="center"><img width="450" src="https://i.ibb.co/xf6M4J2/nextcloud-phpopcache.png" alt="nextcloud-phpopcache" /></p>
##### 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/).

View File

@ -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"
}
}
}

View File

@ -0,0 +1,208 @@
<template>
<div class="nextcloud-widget nextcloud-status-wrapper">
<div v-if="notifications.length">
<!-- group actions: delete all -->
<p v-if="canDeleteNotification('delete-all')" class="group-action">
<span class="action secondary" @click="deleteNotifications">{{ tt('delete-all') }}</span>
</p>
<hr/>
<!-- notifications list -->
<div v-for="(notification, idx) in notifications" :key="idx" class="notification">
<div><img :src="notificationIcon(notification.icon)" /></div>
<div>
<p>
<small class="date" v-tooltip="dateTooltip(notification)">
{{ getTimeAgo(Date.parse(notification.datetime)) }}
</small> <span v-tooltip="subjectTooltip(notification)">{{ notification.subject }} </span>
<!-- notifications item: action links -->
<span v-if="notification.actions.length">
<span v-for="(action, idx) in notification.actions" :key="idx">
<a :href="action.link" class="action" target="_blank">{{ action.label }}</a>
</span>
</span>
<span v-if="canDeleteNotification('delete')">
<a @click="deleteNotification(notification.notification_id)"
class="action secondary">{{ tt('delete-notification') }}</a>
</span>
</p>
</div>
<hr/>
</div>
</div>
<!-- empty notifications list -->
<div v-else class="sep">
<p>{{ tt('no-notifications') }}</p>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import NextcloudMixin from '@/mixins/NextcloudMixin';
/**
* NextcloudNotifications widget - Displays the user's notifications
* Used endpoints
* - capabilities: to determine if the User Notification API is enabled
* - notifications: to fetch list of notifications, delete all or a single notification
*/
export default {
mixins: [WidgetMixin, NextcloudMixin],
components: {},
data() {
return {
notifications: [],
};
},
computed: {
/* Parse the limit user option to either an integer or to an integer + 'm', 'h' or 'd' */
limit() {
const lim = this.options.limit;
const defaultLimit = [0, false];
if (typeof lim === 'string') {
const k = { m: 60, h: 60 * 60, d: 60 * 60 * 24 };
const m = lim.match(/(\d+)([hmd])/);
if (m.length !== 3) return defaultLimit;
return [false, m[1] * k[m[2]] * 1000];
}
if (typeof lim === 'number') {
return [parseInt(this.options.limit, 10) || 0, false];
}
return defaultLimit;
},
},
methods: {
allowedStatuscodes() {
return [100, 200];
},
async fetchData() {
if (!this.hasValidCredentials()) return;
await this.loadCapabilities();
if (!this.capabilities?.notifications?.enabled) {
this.error('This Nextcloud server doesn\'t support the Notifications API');
return;
}
this.makeRequest(this.endpoint('notifications'), this.headers)
.then(this.processNotifications)
.finally(this.finishLoading);
},
processNotifications(response) {
const notifications = this.validateResponse(response);
const [limitCount, limitTime] = this.limit;
this.notifications = [];
notifications.forEach((notification) => {
if (limitCount && this.notifications.length === limitCount) return; // count limit
const notiTime = Date.parse(notification.datetime);
const nowTime = new Date().getTime();
if (limitTime && notiTime && nowTime - notiTime > limitTime) return; // time limit
this.notifications.push(notification);
});
},
/* Transform icon URL to SVG Color API request URL
* @see https://docs.nextcloud.com/server/latest/developer_manual/html_css_design/icons.html */
notificationIcon(url) {
const color = this.getValueFromCss('widget-text-color').replace('#', '');
return url.replace('core/img', 'svg/core')
.replace(/extra-apps\/([^/]+)\/img/, 'svg/$1')
.replace(/apps\/([^/]+)\/img/, 'svg/$1')
.replace('.svg', `?color=${color}`);
},
/* Notification actions */
canDeleteNotification(deleteTarget) {
const capNotif = this.capabilities?.notifications?.features;
return Array.isArray(capNotif) && capNotif.includes(deleteTarget);
},
deleteNotifications() {
this.makeRequest(this.endpoint('notifications'), this.headers, 'DELETE')
.then(() => {
this.notifications = [];
});
},
deleteNotification(id) {
this.makeRequest(`${this.endpoint('notifications')}/${id}`, this.headers, 'DELETE')
.then(this.fetchData);
},
/* Tooltip generators */
subjectTooltip(notification) {
const content = notification.message;
return {
content, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
dateTooltip(notification) {
const content = new Date(Date.parse(notification.datetime)).toLocaleString();
return {
content, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
},
created() {
this.overrideUpdateInterval = 60;
},
};
</script>
<style scoped lang="scss">
@import '@/styles/widgets/nextcloud-shared.scss';
.nextcloud-status-wrapper {
div p small i {
position: relative;
top: .25em;
}
small.date {
background: var(--widget-text-color);
color: var(--widget-accent-color);
border-radius: .25em;
padding: .15em .3em;
margin: .25em .25em .25em 0;
display: inline-block;
font-weight: bold;
}
p.group-action {
margin-top: 0;
}
span.action, span a.action {
cursor: pointer;
margin: .1em .5em .1em 0;
padding: .15em;
border-radius: .25em;
white-space: nowrap;
}
span.action:hover, span a.action:hover {
background: var(--widget-text-color);
color: var(--widget-accent-color);
text-decoration: underline;
}
.secondary {
opacity: .5;
font-size: 75%;
margin-left: .2rem;
}
div.notification {
display: table;
width: 100%;
> div:first-child {
float: right;
}
> div:nth-child(2) {
float: left;
width: 93%;
}
> div {
display: table-cell;
text-align: left;
> img {
float: right;
width: 1em;
position: relative;
top: 1em;
opacity: .75;
}
}
}
div hr {
margin-top: .3em;
margin-bottom: 0;
}
}
</style>

View File

@ -0,0 +1,203 @@
<template>
<div v-if="didLoadData" class="nextcloud-widget nextcloud-phpopcache-wrapper">
<div class="sep">
<!-- PHP opcache enabled and cache full -->
<p v-tooltip="opcacheStartTimeTooltip()">
<i class="fal fa-microchip"></i>
<strong>PHP opcache</strong>&nbsp;
<em v-if="opcache.opcache_enabled" class="success">
{{ tt('enabled') }}
</em>
<em v-else class="disabled">{{ tt('disabled') }}</em>&nbsp;
<strong v-if="opcache.cache_full" class="danger">
<i class="far fa-siren-on"></i>{{ tt('cache-full') }}
</strong>
</p>
<hr/>
<!-- PHP opcache stats -->
<div v-if="opcache.opcache_enabled">
<!-- PHP opcache stats: hit/miss -->
<p v-tooltip="opcacheStatsTooltip()">
<i class="fal fa-bullseye-arrow"></i>
<em v-html="formatNumber(opcache_stats.hits)"></em>&nbsp;
<small>{{ tt('hits') }}</small>&nbsp;
<em v-html="formatNumber(opcache_stats.misses)"></em>&nbsp;
<small>{{ tt('misses') }}</small>&nbsp;
<em v-html="formatPercent(opcache_stats.opcache_hit_rate, 3)"></em>&nbsp;
<small>{{ tt('hit-rate') }}</small>
</p>
<hr/>
<!-- PHP opcache stats: memory -->
<p v-tooltip="opcacheMemoryUsageTooltip()">
<i class="fal fa-memory"></i>
<em v-html="formatPercent(opcache.memory_usage.used_memory_percentage, 1)"></em>&nbsp;
<small>of</small>
<em v-html="convertBytes(opcache.memory_usage.total_memory)"></em>&nbsp;
<small>{{ tt('memory-used') }}</small>
</p>
<hr/>
<!-- PHP opcache stats: interned strings -->
<p v-tooltip="opcacheInternedStringsTooltip()">
<i class="fal fa-puzzle-piece"></i>
<em v-html="formatNumber(opcache.interned_strings_usage.number_of_strings, 1, true)"></em>
&nbsp;<small>{{ tt('strings-use') }}</small>
<em v-html="formatPercent(opcache.interned_strings_usage.used_memory_percentage)"></em>
&nbsp;<small>{{ tt('of') }}</small>
<em v-html="convertBytes(opcache.interned_strings_usage.total_memory)"></em>
</p>
<hr/>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import NextcloudMixin from '@/mixins/NextcloudMixin';
/**
* NextcloudPhpOpcache widget - Shows statistics about PHP opcache performance
* Used endpoints
* - serverinfo: requires Nextcloud admin user
*/
export default {
mixins: [WidgetMixin, NextcloudMixin],
components: {},
data() {
return {
opcache: {
opcache_enabled: null,
full: null,
opcache_statistics: {
num_cached_scripts: null,
num_cached_keys: null,
max_cached_keys: null,
hits: null,
start_time: null,
last_restart_time: null,
misses: null,
opcache_hit_rate: null,
},
memory_usage: {
used_memory: null,
free_memory: null,
total_memory: null,
wasted_memory: null,
used_memory_percentage: null,
current_wasted_percentage: null,
},
interned_strings_usage: {
buffer_size: null,
used_memory: null,
total_memory: null,
free_memory: null,
number_of_strings: null,
used_memory_percentage: null,
},
},
};
},
computed: {
didLoadData() {
return typeof (this?.opcache?.opcache_enabled) === 'boolean';
},
// shortcuts to data members
opcache_stats() {
return this.opcache.opcache_statistics;
},
opcache_interned() {
return this.opcache.interned_strings_usage;
},
},
methods: {
allowedStatuscodes() {
return [200];
},
fetchData() {
if (!this.hasValidCredentials()) return;
this.makeRequest(this.endpoint('serverinfo'), this.headers)
.then(this.processServerInfo)
.finally(() => this.finishLoading());
},
processServerInfo(serverData) {
const data = this.validateResponse(serverData);
this.opcache = data.server?.php?.opcache;
if (!this.opcache) return;
this.updateOpcacheMemory();
this.updateOpcacheInterned();
},
updateOpcacheMemory() {
this.opcache_stats.opcache_hit_rate = parseFloat(
this.opcache_stats.opcache_hit_rate,
).toFixed(3);
this.opcache.memory_usage.total_memory = (
this.opcache.memory_usage.used_memory + this.opcache.memory_usage.free_memory
);
this.opcache.memory_usage.used_memory_percentage = parseFloat(
(this.opcache.memory_usage.used_memory / this.opcache.memory_usage.total_memory) * 100,
).toFixed(1);
},
updateOpcacheInterned() {
this.opcache_interned.total_memory = (
this.opcache_interned.used_memory + this.opcache_interned.free_memory
);
this.opcache_interned.used_memory_percentage = parseFloat(
(this.opcache_interned.used_memory / this.opcache_interned.total_memory) * 100,
).toFixed(5);
},
/* Tooltip generators */
opcacheStartTimeTooltip() {
let content = `${this.tt('started')} `
+ `${new Date(this.opcache_stats.start_time * 1000).toLocaleString()}`;
if (this.opcache_stats.last_restart_time) {
content = content.concat(
`<br><br>${this.tt('last-restart')} `
+ `${new Date(this.opcache_stats.last_restart_time * 1000).toLocaleString()}`,
);
}
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
opcacheStatsTooltip() {
const content = `${parseFloat(this.opcache_stats.hits).toLocaleString()} ${this.tt('hits')}<br>`
+ `${parseFloat(this.opcache_stats.misses).toLocaleString()} ${this.tt('misses')}<br><br>`
+ `${parseFloat(this.opcache_stats.num_cached_scripts).toLocaleString()} ${this.tt('scripts')}<br>`
+ `${parseFloat(this.opcache_stats.num_cached_keys).toLocaleString()} ${this.tt('keys')}<br>`
+ `${parseFloat(this.opcache_stats.max_cached_keys).toLocaleString()} ${this.tt('max-keys')}<br>`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
opcacheMemoryUsageTooltip() {
const content = `PHP opcache ${this.tt('memory-utilisation')}<br><br>`
+ `${this.convertBytes(this.opcache.memory_usage.total_memory)} ${this.tt('total')}<br>`
+ `${this.convertBytes(this.opcache.memory_usage.used_memory)} ${this.tt('used')}<br>`
+ `${this.convertBytes(this.opcache.memory_usage.free_memory)} ${this.tt('free')}<br><br>`
+ `${this.convertBytes(this.opcache.memory_usage.wasted_memory)} (`
+ `${parseFloat(this.opcache.memory_usage.current_wasted_percentage).toFixed(1)}%) ${this.tt('wasted')}`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
opcacheInternedStringsTooltip() {
const content = 'PHP opcache interned strings<br><br>'
+ `${this.convertBytes(this.opcache_interned.buffer_size)} ${this.tt('total')} ${this.tt('memory')}<br>`
+ `${this.convertBytes(this.opcache_interned.used_memory)} ${this.tt('used')} ${this.tt('memory')}<br>`
+ `${this.convertBytes(this.opcache_interned.free_memory)} ${this.tt('free')} ${this.tt('memory')}<br><br>`
+ `${parseFloat(this.opcache_interned.number_of_strings).toLocaleString()}`
+ ' strings';
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
},
created() {
this.overrideUpdateInterval = 60;
},
};
</script>
<style scoped lang="scss">
@import '@/styles/widgets/nextcloud-shared.scss';
</style>

View File

@ -0,0 +1,198 @@
<template>
<div v-if="didLoadData" class="nextcloud-widget nextcloud-stats-wrapper">
<div class="server-info sep">
<!-- server info: users -->
<div v-if="activeUsers">
<p v-tooltip="activeUsersTooltip()">
<i class="fal fa-user"></i>
<em v-html="formatNumber(storage.num_users)"></em>
<strong>{{ tt('total-users') }}</strong> <small>{{ tt('of-which') }}</small>
<em v-html="formatNumber(activeUsers.last24hours)"></em>
<strong>{{ tt('active') }}</strong> <small>({{ tt('last-24-hours') }})</small>
</p>
</div>
<hr />
<div v-if="nextcloud">
<!-- server info: apps -->
<p v-tooltip="appUpdatesTooltip()">
<i class="fal fa-browser"></i>
<em v-html="formatNumber(apps.num_installed)"></em>
<strong>{{ tt('applications') }}</strong>
<span v-if="apps.num_updates_available" class="success has-updates">
<i class="fal fa-download"></i><em>{{ apps.num_updates_available }}</em>
<strong>
{{ tt('updates-available',
{plural: apps.num_updates_available > 1 ? 's' : ''}) }}
</strong>
</span>
<small v-else data-nc-updates class="disabled">{{ tt('no-pending-updates') }}</small>
</p>
<hr />
<!-- server info: storage -->
<p v-tooltip="storagesTooltip()">
<i class="fal fa-file"></i><em v-html="formatNumber(storage.num_files)"></em>
<strong>{{ tt('files', { plural: storage.num_files > 1 ? 's' : '' }) }}</strong>&nbsp;
<small>{{ tt('in') }}</small><em>{{ storage.num_storages }}</em>
<strong>{{ tt('storages', { plural: storage.num_storages > 1 ? 's' : '' }) }}</strong>
&nbsp;&nbsp;<strong v-html="convertBytes(system.freespace)"></strong>&nbsp;
<small>{{ tt('free') }}</small>
</p>
<hr />
<!-- server info: shares -->
<p v-tooltip="sharesTooltip()">
<i class="fal fa-share"></i>
<em v-html="formatNumber(shares.num_shares)"></em>
<strong>{{ tt('local') }}</strong> <small> {{ tt('and') }}</small>
<em v-html="formatNumber(shares.num_fed_shares_sent
+ shares.num_fed_shares_received)"></em>
<strong>
{{ tt('federated-shares') }}
</strong>
</p>
<hr />
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import NextcloudMixin from '@/mixins/NextcloudMixin';
/**
* NextcloudStats widget - Shows statistics about Nextcloud usage
* Used endpoints
* - serverinfo: requires Nextcloud admin user
*/
export default {
mixins: [WidgetMixin, NextcloudMixin],
components: {},
data() {
return {
nextcloud: {
system: {
freespace: null,
apps: {
num_installed: null,
num_updates_available: 0,
app_updates: [],
},
},
storage: {
num_users: null,
num_files: null,
num_storages: null,
},
shares: {
num_shares: null,
num_shares_user: null,
num_shares_groups: null,
num_shares_link: null,
num_shares_mail: null,
num_shares_room: null,
num_shares_link_no_password: null,
num_fed_shares_sent: null,
num_fed_shares_received: null,
},
},
activeUsers: {
last5minutes: null,
last1hour: null,
last24hours: null,
},
};
},
computed: {
didLoadData() {
return !!(this?.system?.freespace);
},
// data shortcuts
system() {
return this.nextcloud.system;
},
storage() {
return this.nextcloud.storage;
},
shares() {
return this.nextcloud.shares;
},
apps() {
return this.nextcloud.system.apps;
},
},
methods: {
allowedStatuscodes() {
return [200];
},
fetchData() {
if (!this.hasValidCredentials()) return;
this.makeRequest(this.endpoint('serverinfo'), this.headers)
.then(this.processServerInfo)
.finally(this.finishLoading);
},
processServerInfo(serverResponse) {
const data = this.validateResponse(serverResponse);
this.nextcloud = data.nextcloud;
this.activeUsers = data.activeUsers;
},
/* Tooltip generators */
activeUsersTooltip() {
const content = `${parseFloat(this.activeUsers.last5minutes).toLocaleString()}`
+ ` ${this.tt('last-5-minutes')}<br>`
+ `${parseFloat(this.activeUsers.last1hour).toLocaleString()}`
+ ` ${this.tt('last-hour')}<br>`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
appUpdatesTooltip() {
let content = `<strong>${this.tt('updates-available-for')}</strong><ul>`;
Object.entries(this.system.apps.app_updates).forEach(([app, version]) => {
content += `<li>${app}: ${version}</li>`;
});
content += '</ul>';
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
storagesTooltip() {
const content = `<strong>${this.tt('storages-by-type')}</strong><ul><li>`
+ `${parseFloat(this.storage.num_storages_local).toLocaleString()} ${this.tt('local')}</li><li>`
+ `${parseFloat(this.storage.num_storages_home).toLocaleString()} ${this.tt('home')}</li><li>`
+ `${parseFloat(this.storage.num_storages_other).toLocaleString()} ${this.tt('other')}</li></ul>`
+ `${parseFloat(this.storage.num_files).toLocaleString()} ${this.tt('total-files')}`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
sharesTooltip() {
const content = `<strong>${this.tt('local-shares')}</strong><ul><li>`
+ `${parseFloat(this.shares.num_shares_user).toLocaleString()} ${this.tt('user')}</li><li>`
+ `${parseFloat(this.shares.num_shares_groups).toLocaleString()} ${this.tt('groups')}</li><li>`
+ `${parseFloat(this.shares.num_shares_mail).toLocaleString()} ${this.tt('email')}</li><li>`
+ `${parseFloat(this.shares.num_shares_room).toLocaleString()} ${this.tt('chat-room')}</li><li>`
+ `${parseFloat(this.shares.num_shares_link).toLocaleString()} ${this.tt('private-link')}</li><li>`
+ `${parseFloat(this.shares.num_shares_link_no_password).toLocaleString()} ${this.tt('public-link')}</li></ul>`
+ `<strong>${this.tt('federated-shares-ucfirst')}</strong><ul><li>`
+ `${parseFloat(this.shares.num_fed_shares_sent).toLocaleString()} ${this.tt('sent')}</li><li>`
+ `${parseFloat(this.shares.num_fed_shares_received).toLocaleString()} ${this.tt('received')}</li></ul>`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
},
created() {
this.overrideUpdateInterval = 20;
},
};
</script>
<style scoped lang="scss">
@import '@/styles/widgets/nextcloud-shared.scss';
.nextcloud-stats-wrapper {
div.server-info .nc-updates {
margin-left: .5em;
}
}
</style>

View File

@ -0,0 +1,230 @@
<template>
<div v-if="didLoadData" class="nextcloud-widget nextcloud-system-wrapper">
<div class="charts">
<!-- memory gauge -->
<div class="chart-container">
<small>{{ tt('overall') }} {{ tt('memory-utilisation') }}</small>
<GaugeChart :value="memoryGauge.value"
:baseColor="memoryGauge.background"
:gaugeColor="memoryGauge.color">
<p class="percentage">{{ memoryGauge.value }}%</p>
</GaugeChart>
<small>{{ getMemoryGaugeLabel() }}</small>
</div>
<!-- cpu load chart -->
<div>
<div
:id="cpuLoadChartId" class="load-chart"
v-tooltip="$t('widgets.glances.system-load-desc')"></div>
</div>
</div>
<div>
<!-- server info: server -->
<hr />
<p>
<i class="fal fa-server"></i>
<strong>Nextcloud</strong>
<em>{{ server.nextcloud.system.version }}</em> <small> </small>
<strong>{{ server.server.webserver }}/PHP</strong>
<em>{{ server.server.php.version }}</em>
</p>
<hr />
<!-- server info: database -->
<p>
<i class="fal fa-database"></i>
<strong>{{ server.server.database.type }}</strong>
<em>{{ server.server.database.version }}</em> <small>{{ tt('using') }}</small>
<em v-html="convertBytes(server.server.database.size)"></em>
</p>
<hr/>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import NextcloudMixin from '@/mixins/NextcloudMixin';
import GaugeChart from '@/components/Charts/Gauge';
import ChartingMixin from '@/mixins/ChartingMixin';
/**
* NextcloudSystem widget - Visualises CPU load and memory utilisation and shows server versions
* Used endpoints
* - serverinfo: requires Nextcloud admin user
*/
export default {
mixins: [WidgetMixin, NextcloudMixin, ChartingMixin],
components: { GaugeChart },
data() {
return {
server: {
server: {
database: {
type: null,
version: null,
size: null,
},
webserver: null,
php: {
version: null,
},
},
nextcloud: {
system: {
version: null,
freespace: null,
cpuload: [],
mem_total: null,
mem_free: null,
mem_percent: null,
},
},
},
memoryGauge: {
value: 0,
color: '#272f4d',
showMoreInfo: false,
moreInfo: null,
background: '#16161d',
},
};
},
computed: {
cpuLoadChartId() {
return `nextcloud-cpu-load-chart-${Math.random().toString().slice(-4)}`;
},
didLoadData() {
return !!(this.server?.nextcloud?.system?.version);
},
},
methods: {
allowedStatuscodes() {
return [200];
},
async fetchData() {
if (!this.hasValidCredentials()) return;
this.makeRequest(this.endpoint('serverinfo'), this.headers)
.then(this.processServerInfo)
.finally(() => this.finishLoading());
},
processServerInfo(serverData) {
const data = this.validateResponse(serverData);
if (!data || data.length === 0) return;
this.server.nextcloud.system = data.nextcloud?.system;
this.server.server.php.version = data.server?.php?.version;
this.server.server.database = data.server?.database;
this.server.server.webserver = data.server?.webserver;
},
updateMemoryGauge(sys) {
this.memoryGauge.value = parseFloat(
(((sys.mem_total - sys.mem_free) / sys.mem_total) * 100).toFixed(2),
);
this.memoryGauge.color = this.getMemoryGaugeColor(this.memoryGauge.value);
},
updateOpcacheMemory() {
this.opcache_stats.opcache_hit_rate = parseFloat(
this.opcache_stats.opcache_hit_rate,
).toFixed(3);
this.opcache.memory_usage.total_memory = (
this.opcache.memory_usage.used_memory + this.opcache.memory_usage.free_memory
);
this.opcache.memory_usage.used_memory_percentage = parseFloat(
(this.opcache.memory_usage.used_memory / this.opcache.memory_usage.total_memory) * 100,
).toFixed(1);
},
updateOpcacheInterned() {
this.opcache.interned_strings_usage.total_memory = (
this.opcache.interned_strings_usage.used_memory
+ this.opcache.interned_strings_usage.free_memory
);
this.opcache.interned_strings_usage.used_memory_percentage = parseFloat(
(this.opcache.interned_strings_usage.used_memory
/ this.opcache.interned_strings_usage.total_memory) * 100,
).toFixed(5);
},
getMemoryGaugeColor(memPercent) {
if (memPercent < 50) return this.getColorRgba('widget-text-color', 0.6);
if (memPercent < 60) return this.getColorRgba('warning', 0.75);
if (memPercent < 80) return this.getColorRgba('error', 0.9);
if (memPercent < 100) return this.getColorRgba('danger');
return this.getColorRgba('background');
},
getMemoryGaugeLabel() {
const sys = this.server.nextcloud.system;
return `${this.convertBytes((sys.mem_total - sys.mem_free) * 1024, 2, false)} / `
+ `${this.convertBytes(sys.mem_total * 1024, 2, false)}`;
},
updateCpuLoad(load) {
const chartData = {
labels: ['1m', '5m', '15m'],
datasets: [{ values: [load[0], load[1], load[2]] }],
};
const chartTitle = this.tt('load-averages');
this.renderCpuLoadChart(chartData, chartTitle);
},
renderCpuLoadChart(loadBarChartData, chartTitle) {
return new this.Chart(`#${this.cpuLoadChartId}`, {
title: chartTitle,
data: loadBarChartData,
type: 'bar',
height: 180,
colors: [this.getColorRgba('widget-text-color', 0.6)],
barOptions: {
spaceRatio: 0.2,
},
tooltipOptions: {
formatTooltipY: d => `${d} ${this.tt('tasks')}`,
},
});
},
},
created() {
this.overrideUpdateInterval = 30;
},
updated() {
const load = this.server?.nextcloud?.system?.cpuload;
if (load) this.updateCpuLoad(load);
const sys = this.server.nextcloud.system;
if (sys) this.updateMemoryGauge(sys);
},
};
</script>
<style scoped lang="scss">
@import '@/styles/widgets/nextcloud-shared.scss';
.nextcloud-system-wrapper {
div.charts {
> div {
float: left;
}
> div:first-child {
max-width: 44%;
small {
font-size: 12px;
color: #666666;
display: inline-block;
width: 100%;
text-align: center;
margin: .9em 0 1.4em 0;
opacity: 1;
}
small:last-child {
margin-top: 2em;
font-size: 10px;
}
}
> div:nth-child(2) {
min-width: 55%;
}
p.percentage {
color: var(--widget-text-color);
text-align: center;
position: absolute;
font-size: 1.3em;
margin: .5em 0;
width: 100%;
bottom: 0;
}
}
}
</style>

View File

@ -0,0 +1,206 @@
<template>
<div v-if="didLoadData" class="nextcloud-widget nextcloud-info-wrapper">
<!-- logo, branding, user info -->
<div>
<div class="logo">
<a :href="branding.url" target="_blank">
<img :src="branding.logo" />
</a>
<p>{{ branding.slogan }}</p>
</div>
<div class="info">
<p class="brand">{{ branding.name }}</p>
<p class="version" v-if="version.string">
<small>Nextcloud {{ tt('version') }} {{ version.string }}</small>
</p>
<p class="username">{{ user.displayName }} <em>({{ user.id }})</em></p>
<p class="login" v-tooltip="lastLoginTooltip()">
<span>{{ tt('last-login') }}</span>&nbsp;
<small>{{ getTimeAgo(user.lastLogin) }}</small>
</p>
</div>
</div>
<!-- disk space/quota -->
<div>
<div v-tooltip="quotaTooltip()">
<hr/>
<p>
<i class="fal fa-disc-drive"></i>
<strong v-if="user.quota.quota > 0">{{ tt('disk-quota') }}</strong>
<strong v-else>{{ tt('disk-space') }}</strong>
<em v-html="formatPercent(user.quota.relative)"></em>
<small>{{ tt('of') }}</small><strong v-html="convertBytes(user.quota.total)"></strong>
<span v-if="quotaChart.dataLoaded">
<PercentageChart :values="quotaChart.data" :showLegend="false" />
</span>
</p>
<hr/>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import NextcloudMixin from '@/mixins/NextcloudMixin';
import PercentageChart from '@/components/Charts/PercentageChart';
/**
* NextcloudUser widget - Displays branding and user information
* Used endpoints
* - capabilities: this delivers branding info (server name, logo, slogan, etc.)
* - user: name, last login, disk quota info
*/
export default {
mixins: [WidgetMixin, NextcloudMixin],
components: { PercentageChart },
data() {
return {
user: {
id: null,
displayName: null,
email: null,
quota: {
relative: null,
total: null,
used: null,
free: null,
quota: null,
},
},
quotaChart: {
dataLoaded: false,
data: [
{ label: null, size: null, color: null },
{ label: null, size: null, color: null },
],
},
};
},
computed: {
didLoadData() {
return !!this.user.id;
},
},
methods: {
allowedStatuscodes() {
return [100, 200];
},
fetchData() {
if (!this.hasValidCredentials()) return;
this.loadCapabilities()
.then(this.loadUser)
.finally(this.finishLoading);
},
loadUser() {
return this.makeRequest(this.endpoint('user'), this.headers)
.then(this.processUser);
},
processUser(userResponse) {
const user = this.validateResponse(userResponse);
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;
},
getQuotaChartColorUsed(percent) {
if (percent < 0.75) return this.getValueFromCss('widget-text-color');
if (percent < 0.85) return this.getValueFromCss('warning');
if (percent < 0.95) return this.getValueFromCss('error');
return this.getValueFromCss('danger');
},
updateQuotaChart() {
const used = parseFloat(this.user.quota.used / this.user.quota.total);
const free = parseFloat(this.user.quota.free / this.user.quota.total);
const d = this.quotaChart.data;
d[0] = { label: this.tt('used'), size: used, color: this.getQuotaChartColorUsed(used) };
d[1] = { label: this.tt('available'), size: free, color: this.getValueFromCss('success') };
this.quotaChart.dataLoaded = true;
},
/* Tooltip generators */
quotaTooltip() {
const quotaEnabled = this.user.quota.quota > 0;
const content = `${this.tt('quota-enabled', { not: quotaEnabled ? '' : 'not ' })}`
+ `<br><br>${this.convertBytes(this.user.quota.used)} ${this.tt('used')}<br>`
+ `${this.convertBytes(this.user.quota.free)} ${this.tt('free')}<br>`
+ `${this.convertBytes(this.user.quota.total)} ${this.tt('total')}`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
lastLoginTooltip() {
const content = new Date(this.user.lastLogin).toLocaleString();
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
},
created() {
this.overrideUpdateInterval = 120;
},
updated() {
this.updateQuotaChart();
},
};
</script>
<style scoped lang="scss">
@import '@/styles/widgets/nextcloud-shared.scss';
.nextcloud-info-wrapper {
> div:first-child {
display: flex;
}
> div:nth-child(2) {
border-top: none;
}
div.percentage-chart-wrapper {
margin: 0 .75em;
width: 5em;
position: relative;
top: .2em;
float: right;
}
div.logo {
width: 40%;
text-align: center;
img {
width: 8em;
}
p {
font-size: .9em;
opacity: .85;
}
}
div.info {
width: 56%;
p {
margin: 0 0 1rem 0;
}
p:last-child {
margin: 0;
}
p.brand {
margin: 0;
font-size: 1.35em;
font-weight: 800;
letter-spacing: 3px;
}
p.version small {
font-size: .75em;
}
p.username {
font-size: 1.1em;
em {
font-size: .9em;
}
}
p.login {
span {
font-size: .9em;
margin-right: .25em;
}
}
}
}
</style>

View File

@ -0,0 +1,202 @@
<template>
<div class="nextcloud-widget nextcloud-user-status-wrapper">
<div v-if="didLoadData" class="sep">
<!-- user statuses: list -->
<div v-for="(status, userId) in statuses" :key="userId" class="user">
<div>
<!-- user status: emoji -->
<div>
<i>{{ status.icon }}</i>
</div>
<!-- user status: message -->
<div>
<p v-tooltip="clearAtTooltip(status.clearAt)">
<strong>{{ status.userId }}</strong>&nbsp;
<small v-if="status.clearAt"><i class="fal fa-clock"></i></small>
<span v-else-if="status.message"></span><em>{{ status.message }}</em>
</p>
</div>
<!-- user status: status -->
<div>
<p>
<small :class="`status ${status.status}`">
<i v-if="status.status === 'online' || status.status === 'dnd'"
class="fas fa-circle" v-tooltip="tt(status.status)"></i>
<i v-else class="far fa-circle" v-tooltip="tt(status.status)"></i>
</small>
</p>
</div>
</div>
<hr/>
</div>
</div>
<!-- user statuses: no content -->
<div v-else class="sep"><p>{{ tt('nothing-to-show') }}</p></div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import NextcloudMixin from '@/mixins/NextcloudMixin';
// Nextcloud User Status API supports getting all user statuses at once
// or a single user's status. {fetchStrategy} determines which of these methods to use.
const fetchStrategies = {
allAtOnce: 'AllAtOnce',
oneByOne: 'OneByOne',
};
/**
* NextcloudUserStatus widget - Displays user statuses
* Used endpoints
* - capabilities: to determine if the User Status API is enabled
* - userstatus: to fetch a single or all user statuses
*/
export default {
mixins: [WidgetMixin, NextcloudMixin],
components: {},
computed: {
didLoadData() {
return !!Object.keys(this?.statuses || {}).length;
},
fetchStrategy() {
if (!this.options.fetchStrategy) {
return fetchStrategies.allAtOnce;
}
if (!Object.values(fetchStrategies).includes(this.options.fetchStrategy)) {
return fetchStrategies.allAtOnce;
}
return this.options.fetchStrategy;
},
users() {
if (!this.options.users || !Array.isArray(this.options.users)) return [];
if (this.options.users.length > 100) return this.options.users.slice(0, 100);
return this.options.users;
},
showEmpty() {
return !!this.options.showEmpty;
},
},
data() {
return {
statuses: {},
};
},
methods: {
allowedStatuscodes() {
return [100, 200];
},
async fetchData() {
if (!this.hasValidCredentials() || !this.users.length) return;
await this.loadCapabilities();
if (!this.capabilities?.userStatus) {
this.error('This Nextcloud server doesn\'t support the User Status API');
return;
}
if (this.fetchStrategy === fetchStrategies.allAtOnce) {
this.makeRequest(this.endpoint('userstatus'), this.headers)
.then(this.processStatuses)
.finally(this.finishLoading);
} else {
const promises = [];
this.newStatuses = {};
this.users.forEach((user) => {
promises.push(
this.makeRequest(`${this.endpoint('userstatus')}/${user}`, this.headers)
.then(this.processStatus),
);
});
Promise.all(promises)
.then(() => {
this.statuses = this.newStatuses;
delete this.newStatuses;
})
.finally(this.finishLoading);
}
},
processStatuses(response) {
const statuses = this.validateResponse(response);
const newStatuses = {};
Object.values(statuses).forEach((status) => {
if (!this.users.includes(status.userId)) return;
if (!status.message && !this.showEmpty) return;
newStatuses[status.userId] = status;
});
this.statuses = newStatuses;
},
processStatus(response) {
const raw = this.validateResponse(response);
const status = Array.isArray(raw) && raw.length ? raw[0] : raw;
if (status && (status.message || this.showEmpty)) {
this.newStatuses[status.userId] = status;
}
},
/* Tooltip generators */
clearAtTooltip(clearAtTime) {
const content = clearAtTime ? `${this.tt('until')}`
+ ` ${new Date(clearAtTime * 1000).toLocaleString()}` : '';
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
},
created() {
this.overrideUpdateInterval = 60;
},
};
</script>
<style scoped lang="scss">
@import '@/styles/widgets/nextcloud-shared.scss';
.nextcloud-user-status-wrapper {
.status {
float: right;
i {
position: relative;
top: .15rem;
margin: 0;
}
}
.online {
color: var(--success);
}
.offline {
color: var(--medium-grey);
}
.away {
color: var(--error);
}
.dnd {
color: var(--danger);
}
div.user > div {
display: table;
width: 100%;
> div:first-child {
width: 1.75em;
text-align: center;
> i {
font-style: normal;
}
}
> div:nth-child(2) {
p small i {
top: 0;
opacity: .5;
margin: 0;
}
}
> div {
display: table-cell;
text-align: left;
}
}
div.user hr {
margin-top: .3em;
margin-bottom: .3em;
}
div.user > div > div:last-child hr {
margin-bottom: 0;
}
}
</style>

View File

@ -321,6 +321,48 @@
@error="handleError"
:ref="widgetRef"
/>
<NextcloudNotifications
v-else-if="widgetType === 'nextcloud-notifications'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<NextcloudPhpOpcache
v-else-if="widgetType === 'nextcloud-php-opcache'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<NextcloudStats
v-else-if="widgetType === 'nextcloud-stats'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<NextcloudSystem
v-else-if="widgetType === 'nextcloud-system'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<NextcloudUser
v-else-if="widgetType === 'nextcloud-user'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<NextcloudUserStatus
v-else-if="widgetType === 'nextcloud-user-status'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<PiHoleStats
v-else-if="widgetType === 'pi-hole-stats'"
:options="widgetOptions"
@ -499,6 +541,12 @@ export default {
NdLoadHistory: () => 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'),

View File

@ -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]}<span class="decimals">${m[2]}</span>`;
},
/* 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]}<span class="decimals">${m[2]}</span>`;
},
/* 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]}<span class="decimals">${d}%</span>`;
},
/* 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);
},
},
};

View File

@ -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;
}
}

View File

@ -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;