🚧 Refactor + more widgets

* ♻️ segment into smaller widgets, improve mixin
* ♻️ change NextcloudInfo to NextcloudUser
  * a small widget showing branding and uesr info, including quota
*  add NextcloudNotifications widget
  * show and delete Nextcloud notifications
*  add NextcloudUserStatus widget
  * display user statuses of selected users
*  add NextcloudStats widget (admin only)
  * display Nextcloud usage statistics (users, files, shares)
*  add NextcloudSystem widget (admin only)
  * visualise cpu load and memory utilisation, show server versions
*  add NextcloudPhpOpcache widget (admin only)
  * show statistics about php opcache performance
*  add a misc helper for formatting nunbers
* 🌐 add translations to widget templates
* 🌐 add translation entries for en
* 🍱 add scss styles file, shared by all widgets
This commit is contained in:
Marcell Fülöp 2022-06-19 12:06:43 +00:00
parent a43988f3cd
commit 821af62426
12 changed files with 1558 additions and 442 deletions

View File

@ -304,10 +304,76 @@
"up": "Up",
"down": "Down"
},
"nextcloud-info": {
"label-version": "Nextcloud version",
"label-last-login": "Last login",
"updates-available": "update{plural} available"
"nextcloud": {
"active": "active",
"and": "and",
"applications": "applications",
"available": "available",
"away": "Away",
"cache-full": "CACHE FULL",
"chat-room": "chat room",
"delete-all": "Deleta all",
"delete-notification": "Delete notification",
"disabled": "disabled",
"disk-quota": "Disk Quota",
"disk-space": "Disk Space",
"dnd": "Do Not Distrub",
"email": "email",
"enabled": "enabled",
"federated-shares-ucfirst": "Federated shares",
"federated-shares": "federated shares",
"files": "file{plural}",
"free": "free",
"groups": "groups",
"hit-rate": "hit rate",
"hits": "hits",
"home": "home",
"in": "in",
"keys": "keys",
"last-24-hours": "last 24 hours",
"last-5-minutes": "in the last 5 minutes",
"last-hour": "in the last hour",
"last-login": "Last login",
"last-restart": "Last restart",
"load-averages": "Load Averages over all CPU cores",
"local-shares": "Local shares",
"local": "local",
"max-keys": "max keys",
"memory-used": "memory-used",
"memory-utilisation": "memory utilisation",
"memory": "memory",
"misses": "misses",
"no-notifications": "No notifications",
"no-pending-updates": "no pending updates",
"nothing-to-show": "Nothing to show here at this time",
"of-which": "of which",
"of": "of",
"offline": "Offline",
"online": "Online",
"other": "other",
"overall": "Ovarall",
"private-link": "private link",
"public-link": "public link",
"quota-enabled": "Disk Quota is {not}enabled for this user",
"received": "received",
"scripts": "scripts",
"sent": "sent",
"started": "Started",
"storages-by-type": "Storages by type",
"storages": "storage{plural}",
"strings-use": "strings use",
"tasks": "Tasks",
"total-files": "total files",
"total-users": "total users",
"total": "total",
"until": "Until",
"updates-available-for": "Updates are available for",
"updates-available": "update{plural} available",
"used": "used",
"user": "user",
"using": "using",
"version": "version",
"wasted": "wasted"
}
}
}

View File

@ -1,381 +0,0 @@
<template>
<div class="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>{{ $t('widgets.nextcloud-info.label-version') }} {{ version.string }}</small>
</p>
<p class="username">{{ user.displayName }} <em v-if="user.id">({{ user.id }})</em></p>
<p class="login" v-tooltip="lastLoginTooltip()">
<span>{{ $t('widgets.nextcloud-info.label-last-login') }}</span>&nbsp;
<small>{{ getTimeAgo(user.lastLogin) }}</small>
</p>
</div>
</div>
<!-- disk quota -->
<div v-if="user.quota.quota > 0" v-tooltip="quotaTooltip()">
<p>
<i class="fal fa-disc-drive"></i>
<strong>{{ $t('Disk Quota') }}</strong>
<em>{{ user.quota.relative }}%</em>&nbsp;
<small>of</small> <strong>{{ convertBytes(user.quota.total) }}</strong>
</p>
</div>
<div v-if="user.isAdmin" class="server-info">
<!-- server info: users -->
<div>
<p v-tooltip="activeUsersTooltip()">
<i class="fal fa-user"></i>
<em>{{ formatNumber(server.nextcloud.storage.num_users) }}</em>
<strong>{{ $t('total users') }}</strong> <small>{{ $t('of which') }}</small>
<em>{{ formatNumber(server.activeUsers.last24hours) }}</em>
<strong>{{ $t('active') }}</strong> <small>({{ $t('last 24 hours') }})</small>
</p>
</div>
<hr />
<div>
<!-- server info: apps -->
<p>
<i class="fal fa-browser"></i>
<em>{{ formatNumber(server.nextcloud.system.apps.num_installed) }}</em>
<strong>{{ $t('applications') }}</strong>
<span v-if="server.nextcloud.system.apps.num_updates_available"
data-has-updates v-tooltip="appUpdatesTooltip()">
<i class="fal fa-download"></i>
<em>{{ server.nextcloud.system.apps.num_updates_available }}</em>
<strong>
{{ $t('widgets.nextcloud-info.updates-available',
{plural: server.nextcloud.system.apps.num_updates_available > 1 ? 's' : ''}) }}
</strong>
</span>
<span v-else >
{{ $t('no pending updates') }}
</span>
</p>
<hr />
<!-- server info: storage -->
<p v-tooltip="storagesTooltip()">
<i class="fal fa-file"></i>
<em>{{ formatNumber(server.nextcloud.storage.num_files) }}</em>
<strong>{{ $t('files') }}</strong> <small>{{ $t('in') }}</small>
<em>{{ server.nextcloud.storage.num_storages }}</em>
<strong>{{ $t('storages') }}</strong>&nbsp;&nbsp;|
<strong>{{ convertBytes(server.nextcloud.system.freespace) }}</strong>&nbsp;
<small>{{ $t('free') }}</small>
</p>
<hr />
<!-- server info: shares -->
<p v-tooltip="sharesTooltip()">
<i class="fal fa-share"></i>
<em>{{ formatNumber(server.nextcloud.shares.num_shares) }}</em>
<strong>{{ $t('autonomous') }}</strong> <small> {{ $t('and') }}</small>
<em>
{{ formatNumber(server.nextcloud.shares.num_fed_shares_sent +
server.nextcloud.shares.num_fed_shares_received) }}
</em>
<strong>{{ $t('federated shares') }}</strong>
</p>
<hr />
<!-- server info: server -->
<p>
<i class="fal fa-server"></i>
<strong>{{ $t('Nextcloud') }}</strong>
<em>{{ server.nextcloud.system.version }}</em>&nbsp;|
<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>{{ $t('using') }}</small>
<em>{{ convertBytes(server.server.database.size) }}</em>
</p>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import NextcloudMixin from '@/mixins/NextcloudMixin';
import { convertBytes } from '@/utils/MiscHelpers';
// //import { NcdServer } from '@/utils/ncd';
const NextcloudSchema = {
branding: {
name: null,
logo: null,
url: null,
slogan: null,
},
version: {
string: null,
edition: null,
},
server: {
server: {
webserver: null,
php: {
version: null,
},
opCache: {
enabled: false,
full: false,
stats: {
num_cached_scripts: null,
num_cached_keys: null,
max_cached_keys: null,
hits: null,
start_time: null,
last_restart_time: 0,
misses: null,
opcache_hit_rate: null,
},
memory: {
used_memory: null,
free_memory: null,
wasted_memory: null,
current_wasted_percentage: null,
},
},
database: {
type: null,
version: null,
size: null,
},
},
nextcloud: {
system: {
version: null,
freespace: null,
cpuload: [],
mem_total: null,
mem_free: 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,
},
},
};
export default {
mixins: [WidgetMixin, NextcloudMixin],
components: {},
data() {
return NextcloudSchema;
},
methods: {
async fetchData() {
await this.loadCapabilities();
await this.loadUser();
if (this.user.isAdmin) {
this.processServerInfo(
await this.makeRequest(this.endpoint('serverinfo'), this.headers),
// //NcdServer,
);
}
this.finishLoading();
},
processServerInfo(serverData) {
const data = serverData?.ocs?.data;
if (!data) {
this.error('Invalid response');
return;
}
this.server.nextcloud = data?.nextcloud;
this.server.server.php.version = data?.server?.php?.version;
this.server.server.opCache.enabled = data?.server?.php?.opcache?.opcache_enabled;
this.server.server.opCache.full = data?.server?.php?.opcache?.cache_full;
this.server.server.opCache.stats = data?.server?.php?.opcache?.opcache_statistics;
this.server.server.database = data?.server?.database;
this.server.server.webserver = data?.server?.webserver;
this.server.activeUsers = data?.activeUsers;
},
quotaTooltip() {
const content = `${convertBytes(this.user.quota.used)} used<br>`
+ `${convertBytes(this.user.quota.free)} free<br>`
+ `${convertBytes(this.user.quota.total)} total`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-user-quota',
};
},
lastLoginTooltip() {
const content = new Date(this.user.lastLogin).toLocaleString();
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
activeUsersTooltip() {
const content = `${this.server.activeUsers.last5minutes} in the last 5 minutes<br>`
+ `${this.server.activeUsers.last1hour} in the last 1 hour<br>`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
appUpdatesTooltip() {
const content = 'Updates are available for:<br><br>'
+ ` ${Object.entries(this.server.nextcloud.system.apps.app_updates).join('<br>')}`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
storagesTooltip() {
const content = 'Storages by type:<br><br>'
+ `${this.server.nextcloud.storage.num_storages_local} local<br>`
+ `${this.server.nextcloud.storage.num_storages_home} home<br>`
+ `${this.server.nextcloud.storage.num_storages_other} other<br><br>`
+ `${this.server.nextcloud.storage.num_files} files`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
sharesTooltip() {
const content = 'Autonomous shares:<br><br>'
+ `${this.server.nextcloud.shares.num_shares_user} user<br>`
+ `${this.server.nextcloud.shares.num_shares_groups} groups<br>`
+ `${this.server.nextcloud.shares.num_shares_mail} email<br>`
+ `${this.server.nextcloud.shares.num_shares_room} chat room<br>`
+ `${this.server.nextcloud.shares.num_shares_link} private link<br>`
+ `${this.server.nextcloud.shares.num_shares_link_no_password} public link<br>`
+ '<br>Federated shares:<br><br>'
+ `${this.server.nextcloud.shares.num_fed_shares_sent} sent<br>`
+ `${this.server.nextcloud.shares.num_fed_shares_received} received<br>`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'nc-tooltip',
};
},
created() {
this.overrideUpdateInterval = 120;
},
},
};
</script>
<style scoped lang="scss">
.nextcloud-info-wrapper {
> div:first-child {
display: flex;
}
> div:not(:first-child) {
border-top: 1px dashed var(--widget-text-color);
width: 96%;
padding: .4rem 0;
margin: auto;
> div:not(:first-child) {
width: 100%;
position: relative;
}
}
p {
color: var(--widget-text-color);
margin: 0.5rem 0;
}
> div:first-child {
min-height: 8em;
}
p i {
font-size: 110%;
min-width: 18px;
text-align: center;
}
p em {
font-size: 110%;
margin: 0 4px;
font-weight: 800;
}
strong {
font-weight: 800;
font-size: 105%;
margin-left: .25rem;
}
small {
opacity: .66;
}
hr {
color: var(--widget-text-color);
border: none;
border-top: 1px solid;
margin-top: 0.8rem;
margin-bottom: 0.8rem;
opacity: .25;
}
div.logo {
width: 40%;
text-align: center;
img {
width: 8rem;
}
p {
font-size: 90%;
opacity: .85;
}
}
div.info {
width: 56%;
p {
margin: 0 0 1rem 0;
}
p.brand {
margin: 0;
font-size: 135%;
font-weight: 800;
letter-spacing: 3px;
}
p.version small {
font-size: 75%;
}
p.username {
font-size: 110%;
em {
font-size: 90%;
}
}
p.login {
span {
font-size: 90%;
margin-right: .25rem;
}
}
}
div.server-info {
span[data-has-updates] {
color: var(--success);
margin-left: 0.5rem;
}
}
}
</style>

View File

@ -0,0 +1,192 @@
<template>
<div class="nextcloud-widget nextcloud-status-wrapper">
<div v-if="notifications.length" class="sep">
<!-- group actions: delete all -->
<p v-if="canDeleteNotification('delete-all')">
<span class="action group-action" @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';
// //import { NcdNotif } from '@/utils/ncd';
/**
* 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: [],
};
},
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)
// //Promise.resolve(NcdNotif)
.then(this.processNotifications)
.finally(this.finishLoading);
},
processNotifications(response) {
const notifications = this.validateResponse(response);
this.notifications = [];
notifications.forEach((notification) => {
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')
// //Promise.resolve()
.then(() => {
this.notifications = [];
});
},
deleteNotification(id) {
this.makeRequest(`${this.endpoint('notifications')}/${id}`, this.headers, 'DELETE')
// //Promise.resolve()
.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: .25rem;
}
small.date {
background: var(--widget-text-color);
color: var(--widget-accent-color);
border-radius: 4px;
padding: .15rem .3rem;
margin: .25rem .25rem .25rem 0;
display: inline-block;
font-weight: bold;
opacity: .66;
}
span.group-action {
float: right;
}
span.action, span a.action {
cursor: pointer;
margin: .1rem 0 .1rem .5rem;
padding: .15rem;
border-radius: 4px;
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: 16px;
height: 16px;
position: relative;
top: 1rem;
opacity: .75;
}
}
}
div hr {
margin-top: 0.3rem;
margin-bottom: 0;
}
}
</style>

View File

@ -0,0 +1,205 @@
<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';
const NextcloudSystemSchema = {
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,
},
},
};
/**
* NextcloudPhpOpcache widget - Shows statistics about PHP opcache performance
* Used endpoints
* - serverinfo: requires Nextcloud admin user
*/
export default {
mixins: [WidgetMixin, NextcloudMixin],
components: {},
data() {
return NextcloudSystemSchema;
},
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,203 @@
<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" data-nc-updates class="success">
<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';
// //import { NcdServer } from '@/utils/ncd';
/**
* NextcloudStats widget - Shows statistics about Nextcloud usage
* Used endpoints
* - serverinfo: requires Nextcloud admin user
*/
const NextcloudStatsSchema = {
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,
},
};
export default {
mixins: [WidgetMixin, NextcloudMixin],
components: {},
data() {
return NextcloudStatsSchema;
},
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)
// //Promise.resolve(NcdServer)
.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 {
[data-nc-updates] {
margin-left: 0.5rem;
}
}
}
</style>

View File

@ -0,0 +1,234 @@
<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';
// //import { NcdServer } from '@/utils/ncd';
const NextcloudSystemSchema = {
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',
},
};
/**
* 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 NextcloudSystemSchema;
},
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)
// //Promise.resolve(NcdServer)
.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.$nextTick();
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 '#f6f000';
if (memPercent < 80) return '#fca016';
if (memPercent < 100) return '#f80363';
return '#272f4d';
},
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: #999999;
display: inline-block;
width: 100%;
text-align: center;
margin: .69rem 0 1rem 0;
}
small:last-child {
margin-top: 16px;
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.3rem;
margin: 0.5rem 0;
width: 100%;
bottom: 0;
}
}
}
</style>

View File

@ -0,0 +1,193 @@
<template>
<div v-if="user.id" 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 v-if="user.id">({{ 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="quotaChartData">
<PercentageChart :values="quotaChartData" :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';
import { convertBytes } from '@/utils/MiscHelpers';
// //import { NcdUsr } from '@/utils/ncd';
/**
* 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,
},
},
quotaChartData: null,
};
},
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)
// //return Promise.resolve(NcdUsr)
.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;
const used = parseFloat(this.user.quota.used / this.user.quota.total);
const free = parseFloat(this.user.quota.free / this.user.quota.total);
this.quotaChartData = [
{ label: this.tt('used'), size: used, color: this.getQuotaChartColor(used) },
{ label: this.tt('available'), size: free, color: '#20e253' },
];
},
getQuotaChartColor(percent) {
if (percent < 0.75) return '#272f4d';
if (percent < 0.85) return '#fce216';
if (percent < 0.95) return '#ff9000';
return '#f80363';
},
/* Tooltip generators */
quotaTooltip() {
const quotaEnabled = this.user.quota.quota > 0;
const content = `${this.tt('quota-enabled', { not: quotaEnabled ? '' : 'not ' })}`
+ `<br><br>${convertBytes(this.user.quota.used)} ${this.tt('used')}<br>`
+ `${convertBytes(this.user.quota.free)} ${this.tt('free')}<br>`
+ `${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;
},
};
</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 0.75rem;
width: 5em;
position: relative;
top: 0.2rem;
float: right;
}
div.logo {
width: 40%;
text-align: center;
img {
width: 8rem;
}
p {
font-size: 90%;
opacity: .85;
}
}
div.info {
width: 56%;
p {
margin: 0 0 1rem 0;
}
p:last-child {
margin: 0;
}
p.brand {
margin: 0;
font-size: 135%;
font-weight: 800;
letter-spacing: 3px;
}
p.version small {
font-size: 75%;
}
p.username {
font-size: 110%;
em {
font-size: 90%;
}
}
p.login {
span {
font-size: 90%;
margin-right: .25rem;
}
}
}
}
</style>

View File

@ -0,0 +1,195 @@
<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 ' + getStatusClass(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';
// //import { NcdStatusAll, NcdStatusSingle } from '@/utils/ncd';
// 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;
},
},
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)
// //Promise.resolve(NcdStatusAll)
.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)
// //Promise.resolve(NcdStatusSingle)
.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;
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) {
this.newStatuses[status.userId] = status;
}
},
getStatusClass(status) {
switch (status) {
case 'online': return 'success';
case 'away': return 'warning';
case 'dnd': return 'danger';
case 'offline': default: return 'disabled';
}
},
/* 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: .25rem;
margin: 0;
}
}
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 {
color: #aaaaaa;
top: 0;
opacity: .5;
margin: 0;
}
}
> div {
display: table-cell;
text-align: left;
}
}
div.user hr {
margin-top: 0.3rem;
margin-bottom: 0.3rem;
}
}
</style>

View File

@ -321,8 +321,43 @@
@error="handleError"
:ref="widgetRef"
/>
<NextcloudInfo
v-else-if="widgetType === 'nextcloud-info'"
<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"
@ -506,7 +541,12 @@ export default {
NdLoadHistory: () => import('@/components/Widgets/NdLoadHistory.vue'),
NdRamHistory: () => import('@/components/Widgets/NdRamHistory.vue'),
NewsHeadlines: () => import('@/components/Widgets/NewsHeadlines.vue'),
NextcloudInfo: () => import('@/components/Widgets/NextcloudInfo.vue'),
NextcloudNotifications: () => import('@/components/Widgets/NextcloudNotifications.vue'),
NextcloudPhpOpcache: () => import('@/components/Widgets/NextcloudPhpOpcache.vue'),
NextcloudStats: () => import('@/components/Widgets/NextcloudStats.vue'),
NextcloudSystem: () => import('@/components/Widgets/NextcloudSystem.vue'),
NextcloudUser: () => import('@/components/Widgets/NextcloudUser.vue'),
NextcloudUserStatus: () => import('@/components/Widgets/NextcloudUserStatus.vue'),
PiHoleStats: () => import('@/components/Widgets/PiHoleStats.vue'),
PiHoleTopQueries: () => import('@/components/Widgets/PiHoleTopQueries.vue'),
PiHoleTraffic: () => import('@/components/Widgets/PiHoleTraffic.vue'),

View File

@ -1,47 +1,64 @@
import { serviceEndpoints } from '@/utils/defaults';
import { convertBytes, formatNumber, getTimeAgo } from '@/utils/MiscHelpers';
// //import { NcdCap, NcdUsr } from '@/utils/ncd';
import {
convertBytes, formatNumber, getTimeAgo, timestampToDateTime,
} from '@/utils/MiscHelpers';
// //import { NcdCap } from '@/utils/ncd';
/** Reusable mixin for Nextcloud widgets */
/**
* Reusable mixin for Nextcloud widgets
* Nextcloud APIs
* - capabilities: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html#capabilities-api
* - userstatus: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-status-api.html#user-status-retrieve-statuses
* - user: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html#user-metadata
* - notifications: https://github.com/nextcloud/notifications/blob/master/docs/ocs-endpoint-v2.md
* - serverinfo: https://github.com/nextcloud/serverinfo
*/
export default {
data() {
return {
validCredentials: null,
capabilities: {
notifications: null,
activity: null,
notifications: {
enabled: null,
features: [],
},
userStatus: null,
},
capabilitiesLastUpdated: 0,
user: {
id: null,
isAdmin: false,
displayName: null,
email: null,
quota: {
relative: null,
total: null,
used: null,
free: null,
quota: null,
},
branding: {
name: null,
logo: null,
url: null,
slogan: null,
},
version: {
string: null,
edition: null,
},
};
},
computed: {
/* The user provided Nextcloud hostname */
hostname() {
if (!this.options.hostname) this.error('A hostname is required');
return this.options.hostname;
},
/* The user provided Nextcloud username */
username() {
if (!this.options.username) this.error('A username is required');
return this.options.username;
},
/* The user provided Nextcloud password */
password() {
if (!this.options.password) this.error('An app-password is required');
// reject Nextcloud user passord (enforce 'app-password')
if (!/^([a-z0-9]{5}-){4}[a-z0-9]{5}$/i.test(this.options.password)) {
this.error('Please use an app-password for this widget, not your login password.');
this.error('Please use a Nextcloud app-password, not your login password.');
return '';
}
return this.options.password;
},
/* HTTP headers for Nextcloud API requests */
headers() {
return {
'OCS-APIREQUEST': true,
@ -49,6 +66,7 @@ export default {
Authorization: `Basic ${window.btoa(`${this.username}:${this.password}`)}`,
};
},
/* TTL for data delivered by the capabilities endpoint, ms */
capabilitiesTtl() {
return (parseInt(this.options.capabilitiesTtl, 10) || 3600) * 1000;
},
@ -58,6 +76,7 @@ export default {
},
},
methods: {
/* Nextcloud API endpoints */
endpoint(id) {
switch (id) {
case 'capabilities':
@ -65,10 +84,65 @@ export default {
return `${this.hostname}/ocs/v1.php/cloud/capabilities`;
case 'user':
return `${this.hostname}/ocs/v1.php/cloud/users/${this.username}`;
case 'userstatus':
return `${this.hostname}/ocs/v2.php/apps/user_status/api/v1/statuses`;
case 'serverinfo':
return `${this.hostname}/ocs/v2.php/apps/serverinfo/api/v1/info`;
case 'notifications':
return `${this.hostname}/ocs/v2.php/apps/notifications/api/v2/notifications`;
}
},
/* Helper for widgets to terminate {fetchData} early */
hasValidCredentials() {
return this.validCredentials !== false
&& this.username.length > 0
&& this.password.length > 0;
},
/* Primary handler for every Nextcloud API response */
validateResponse(response) {
const data = response?.ocs?.data;
let meta = response?.ocs?.meta;
const error = response?.error; // Dashy error when cors-proxied
if (error && error.status) {
meta = { statuscode: error.status };
}
if (!meta || !meta.statuscode || !data) {
this.error('Invalid response');
}
switch (meta.statuscode) {
case 401:
this.validCredentials = false;
this.error(
`Access denied for user ${this.username}.`
+ ' Note that some Nextcloud widgets only work with an admin user.',
);
break;
case 429:
this.validCredentials = false;
this.error(
'The server indicated \'rate-limit reached\' error (HTTP 429).'
+ ' The server-info API may return this error for incorrect user/password.',
);
break;
case 993:
case 997:
case 998:
this.validCredentials = false;
this.error(
'The provided app-password is not permitted to access the requested resource or it has'
+ ' been revoked, or the username/password combination is incorrect',
);
break;
default:
this.validCredentials = true;
if (!this.allowedStatuscodes().includes(meta.statuscode)) {
this.error('Unexpected response');
}
break;
}
return data;
},
/* Process the capabilities endpoint if {capabilitiesTtl} has expired */
loadCapabilities() {
if ((new Date().getTime()) - this.capabilitiesLastUpdated > this.capabilitiesTtl) {
return this.makeRequest(this.endpoint('capabilities'), this.headers)
@ -77,44 +151,59 @@ export default {
}
return Promise.resolve();
},
processCapabilities(data) {
const ocdata = data?.ocs?.data;
if (!ocdata) {
this.error('Invalid response');
return;
}
/* Update the sate based on the capabilites response */
processCapabilities(capResponse) {
const ocdata = this.validateResponse(capResponse);
const capNotif = ocdata?.capabilities?.notifications?.['ocs-endpoints'];
this.branding = ocdata?.capabilities?.theming;
this.capabilities.notifications = ocdata?.capabilities?.notifications?.['ocs-endpoints'];
this.capabilities.activity = ocdata?.capabilities?.activity?.apiv2;
this.capabilities.notifications.enabled = !!(capNotif?.length);
this.capabilities.notifications.features = capNotif || [];
this.capabilities.userStatus = !!(ocdata?.capabilities?.user_status?.enabled);
this.version.string = ocdata?.version?.string;
this.version.edition = ocdata?.version?.edition;
this.capabilitiesLastUpdated = new Date().getTime();
},
loadUser() {
return this.makeRequest(this.endpoint('user'), this.headers).then(this.processUser);
// //return Promise.resolve(NcdUsr).then(this.processUser);
},
processUser(userData) {
const user = userData?.ocs?.data;
if (!user) {
this.error('Invalid response');
return;
}
this.user.id = user.id;
this.user.email = user.email;
this.user.quota = user.quota;
this.user.displayName = user.displayname;
this.user.lastLogin = user.lastLogin;
this.user.isAdmin = user.groups && user.groups.includes('admin');
},
formatNumber(number) {
return formatNumber(number);
},
convertBytes(bytes) {
return convertBytes(bytes);
},
/* Shared template helpers */
getTimeAgo(time) {
return getTimeAgo(time);
},
formatDateTime(time) {
return timestampToDateTime(time);
},
/* Add additional formatting to {MiscHelpers.convertBytes()} */
convertBytes(bytes, decimals = 2, formatHtml = true) {
const formatted = convertBytes(bytes, decimals).toString();
if (!formatHtml) return formatted;
const m = formatted.match(/(-?[0-9]+)((\.[0-9]+)?\s(([KMGTPEZY]B|Bytes)))/);
return `${m[1]}<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(/([0-9]+)((\.[0-9]+)?([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,77 @@
.nextcloud-widget {
p {
color: var(--widget-text-color);
margin: 0.5rem 0;
}
a {
color: var(--widget-text-color);
}
p i {
font-size: 110%;
min-width: 22px;
text-align: center;
}
p em {
font-size: 110%;
margin: 0 4px;
font-weight: 800;
}
strong {
font-weight: 800;
font-size: 105%;
margin-left: .25rem;
}
small {
opacity: .66;
}
hr {
color: var(--widget-text-color);
border: none;
border-top: 1px solid;
margin-top: 0.8rem;
margin-bottom: 0.8rem;
opacity: .25;
clear: both;
}
div.sep {
border-top: 1px dashed var(--widget-text-color);
width: 100%;
padding: .4rem 0;
margin: .85em 0 0 0;
> div:not(:first-child) {
width: 100%;
position: relative;
}
}
.success {
color: var(--success);
}
.warning {
color: #ff9000;
}
.danger {
color: var(--danger);
}
.disabled {
color: #818181;
}
::v-deep span.decimals {
font-size: 85%;
}
::v-deep div.percentage-chart {
margin: 0;
}
}

View File

@ -105,14 +105,17 @@ export const convertBytes = (bytes, decimals = 2) => {
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / (k ** i)).toFixed(decimals))} ${sizes[i]}`;
};
/* Returns a numbers shortened version with suffixes for thousand, million, billion
and trillion, e.g. 105_411 => 105.4K, 4_294_967_295 => 4.3B */
export const formatNumber = (number) => {
/* Round a number to thousands, millions, billions or trillions and suffix
* with K, M, B or T respectively, e.g. 4_294_967_295 => 4.3B */
export const formatNumber = (number, decimals = 1) => {
if (number > -1000 && number < 1000) return number;
const k = 1000;
const units = ['', 'K', 'M', 'B', 'T'];
const k = 1000;
const i = Math.floor(Math.log(number) / Math.log(k));
return `${(number / (k ** i)).toFixed(1)}${units[i]}`;
const f = number / (k ** i);
const d = f.toFixed(decimals) % 1.0 === 0 ? 0 : decimals; // number of decimals, omit .0
return `${f.toFixed(d)}${units[i]}`;
};
/* Round price to appropriate number of decimals */