🎉 Add Nextcloud widget

Add a widget supporting the `capabilites`, `user`
and `serverinfo` Nextcloud APIs.

Basic branding, user and quota information is always displayed
and when the provided credentials are for and admin user then
server information is also displayed.

APIs:
* [capabilities](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html#capabilities-api)
* [user](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-api-overview.html#user-metadata)
* [serverinfo](https://github.com/nextcloud/serverinfo)
This commit is contained in:
Marcell Fülöp 2022-06-11 23:41:40 +00:00
parent e24fa10f0f
commit 0bf6fee180
5 changed files with 501 additions and 0 deletions

View File

@ -303,6 +303,10 @@
"remaining": "Remaining",
"up": "Up",
"down": "Down"
},
"nextcloud-info": {
"label-version": "Nextcloud version",
"label-last-login": "Last login"
}
}
}

View File

@ -0,0 +1,398 @@
<template>
<div class="nextcloud-info-wrapper">
<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">
{{ $t('widgets.nextcloud-info.label-version') }} {{ version.string }}
</p>
<p class="username">{{ user.displayName }} <em v-if="user.id">({{ user.id }})</em></p>
<p class="login" v-tooltip="lastLoginTooltip()">
{{ $t('widgets.nextcloud-info.label-last-login') }}&nbsp;
<small>{{ getTimeAgo(user.lastLogin) }}</small>
</p>
</div>
</div>
<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">
<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>
<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('updates available') }}</strong>
</span>
<span v-else >
{{ $t('no pending updates') }}
</span>
</p>
<hr />
<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 />
<p v-tooltip="sharesTooltip()">
<i class="fal fa-share"></i>
<em>{{ formatNumber(server.nextcloud.shares.num_shares) }}</em>
<strong>{{ $t('shares') }}</strong> <small> {{ $t('and') }}</small>
<em>
{{ formatNumber(server.nextcloud.shares.num_fed_shares_sent) }}
/ {{ formatNumber(server.nextcloud.shares.num_fed_shares_received) }}
</em>
<strong>{{ $t('federated shares') }}</strong>
</p>
<hr />
<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 />
<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 { NcdUsr, NcdServer } from '@/utils/ncd';
const NextcloudSchema = {
branding: {
name: null,
logo: null,
url: null,
slogan: null,
},
version: {
string: null,
edition: null,
},
user: {
id: null,
isAdmin: false,
displayName: null,
email: null,
quota: {
relative: null,
total: null,
used: null,
free: null,
quota: 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() {
const promise = this.fetchCapabilities()
.then(() => this.makeRequest(this.endpoint('user'), this.headers))
// //.then(() => NcdUsr)
.then(this.processUser);
await promise;
if (this.user.isAdmin) {
promise.then(() => this.makeRequest(this.endpoint('serverinfo'), this.headers))
// //promise.then(() => NcdServer)
.then(this.processServerInfo);
}
promise.finally(() => this.finishLoading());
},
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');
},
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: '
+ ` ${Object.keys(this.server.nextcloud.system.apps.app_updates).join(', ')}`;
return {
content, 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><sup>*</sup>Federated shares: sent/received';
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;
}
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 0 .23rem 0;
font-size: 135%;
font-weight: 800;
letter-spacing: 3px;
}
p.version {
font-size: 80%;
opacity: .66;
}
p.username {
font-size: 110%;
em {
font-size: 90%;
}
}
p.login {
font-size: 90%;
small {
opacity: .75;
margin-left: .25rem;
}
}
}
div.server-info {
span[data-has-updates] {
color: var(--success);
padding-left: .75rem;
}
}
}
</style>

View File

@ -321,6 +321,13 @@
@error="handleError"
:ref="widgetRef"
/>
<NextcloudInfo
v-else-if="widgetType === 'nextcloud-info'"
:options="widgetOptions"
@loading="setLoaderState"
@error="handleError"
:ref="widgetRef"
/>
<PiHoleStats
v-else-if="widgetType === 'pi-hole-stats'"
:options="widgetOptions"
@ -499,6 +506,7 @@ 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'),
PiHoleStats: () => import('@/components/Widgets/PiHoleStats.vue'),
PiHoleTopQueries: () => import('@/components/Widgets/PiHoleTopQueries.vue'),
PiHoleTraffic: () => import('@/components/Widgets/PiHoleTraffic.vue'),

View File

@ -0,0 +1,82 @@
import { serviceEndpoints } from '@/utils/defaults';
import { convertBytes, formatNumber, getTimeAgo } from '@/utils/MiscHelpers';
// //import { NcdCap } from '@/utils/ncd';
/** Reusable mixin for Nextcloud widgets */
export default {
data() {
return {
capabilities: {
notifications: null,
activity: null,
},
capabilitiesLastUpdated: 0,
};
},
computed: {
hostname() {
if (!this.options.hostname) this.error('A hostname is required');
return this.options.hostname;
},
username() {
if (!this.options.username) this.error('A username is required');
return this.options.username;
},
password() {
if (!this.options.password) this.error('An app-password is required');
return this.options.password;
},
headers() {
return {
'OCS-APIREQUEST': true,
Accept: 'application/json',
Authorization: `Basic ${window.btoa(`${this.username}:${this.password}`)}`,
};
},
proxyReqEndpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
return `${baseUrl}${serviceEndpoints.corsProxy}`;
},
},
methods: {
endpoint(id) {
const endpoints = {
capabilities: `${this.hostname}/ocs/v1.php/cloud/capabilities`,
user: `${this.hostname}/ocs/v1.php/cloud/users/${this.username}`,
serverinfo: `${this.hostname}/ocs/v2.php/apps/serverinfo/api/v1/info`,
};
return endpoints[id];
},
fetchCapabilities() {
const promise = Promise.resolve();
if ((new Date().getTime()) - this.capabilitiesLastUpdated > 3600000) {
promise.then(() => this.makeRequest(this.endpoint('capabilities'), this.headers))
// //promise.then(() => NcdCap)
.then(this.processCapabilities);
}
return promise;
},
processCapabilities(data) {
const ocdata = data?.ocs?.data;
if (!ocdata) {
this.error('Invalid response');
return;
}
this.branding = ocdata?.capabilities?.theming;
this.capabilities.notifications = ocdata?.capabilities?.notifications?.['ocs-endpoints'];
this.capabilities.activity = ocdata?.capabilities?.activity?.apiv2;
this.version.string = ocdata?.version?.string;
this.version.edition = ocdata?.version?.edition;
this.capabilitiesLastUpdated = new Date().getTime();
},
formatNumber(number) {
return formatNumber(number);
},
convertBytes(bytes) {
return convertBytes(bytes);
},
getTimeAgo(time) {
return getTimeAgo(time);
},
},
};

View File

@ -105,6 +105,15 @@ 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) => {
if (number > -1000 && number < 1000) return number;
const k = 1000;
const units = ['', 'K', 'M', 'B', 'T'];
const i = Math.floor(Math.log(number) / Math.log(k));
return `${(number / (k ** i)).toFixed(1)}${units[i]}`;
};
/* Round price to appropriate number of decimals */
export const roundPrice = (price) => {