🔂 Merge master into branch
This commit is contained in:
@@ -30,7 +30,7 @@
|
||||
<!-- License -->
|
||||
<h3>{{ $t('app-info.license') }}</h3>
|
||||
{{ $t('app-info.license-under') }} <a href="https://github.com/Lissy93/dashy/blob/master/LICENSE">MIT X11</a>.
|
||||
Copyright <a href="https://aliciasykes.com">Alicia Sykes</a> © 2021.<br>
|
||||
Copyright <a href="https://aliciasykes.com">Alicia Sykes</a> © {{new Date().getFullYear()}}.<br>
|
||||
{{ $t('app-info.licence-third-party') }} <a href="https://github.com/Lissy93/dashy/blob/master/.github/LEGAL.md">{{ $t('app-info.licence-third-party-link') }}</a>.<br>
|
||||
{{ $t('app-info.list-contributors') }} <a href="https://github.com/Lissy93/dashy/blob/master/docs/credits.md">{{ $t('app-info.list-contributors-link') }}</a>.
|
||||
<!-- App Version -->
|
||||
|
||||
@@ -115,7 +115,9 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.jsonData = this.config;
|
||||
const jsonData = { ...this.config };
|
||||
jsonData.sections = jsonData.sections.map(({ filteredItems, ...section }) => section);
|
||||
this.jsonData = jsonData;
|
||||
if (!this.allowWriteToDisk) this.saveMode = 'local';
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -94,6 +94,7 @@ export default {
|
||||
const raw = rawAppConfig;
|
||||
const isEmptyObject = (obj) => (typeof obj === 'object' && Object.keys(obj).length === 0);
|
||||
const isEmpty = (value) => (value === undefined || isEmptyObject(value));
|
||||
|
||||
// Delete empty values
|
||||
Object.keys(raw).forEach(key => {
|
||||
if (isEmpty(raw[key])) delete raw[key];
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="edit-multi-pages-inner" v-if="allowViewConfig">
|
||||
<h3>{{ $t('interactive-editor.menu.edit-page-info-btn') }}</h3>
|
||||
<FormSchema
|
||||
:schema="schema"
|
||||
:schema="customSchema"
|
||||
v-model="formData"
|
||||
@submit.prevent="saveToState"
|
||||
class="multi-page-form"
|
||||
@@ -49,6 +49,32 @@ export default {
|
||||
pages() {
|
||||
return this.$store.getters.pages;
|
||||
},
|
||||
/* Make a custom schema object, using fields from ConfigSchema */
|
||||
customSchema() {
|
||||
return {
|
||||
type: 'array',
|
||||
title: this.schema.title,
|
||||
description: this.schema.description,
|
||||
items: {
|
||||
title: this.schema.items.title,
|
||||
type: this.schema.items.type,
|
||||
additionalProperties: this.schema.items.additionalProperties,
|
||||
required: this.schema.items.required,
|
||||
properties: {
|
||||
name: this.schema.items.properties.name,
|
||||
path: this.schema.items.properties.path,
|
||||
displayData: {
|
||||
title: 'Display (see documentation for more options)',
|
||||
description: '',
|
||||
type: 'object',
|
||||
properties: {
|
||||
hideForGuests: this.schema.items.properties.displayData.properties.hideForGuests,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
allowViewConfig() {
|
||||
return this.$store.getters.permissions.allowViewConfig;
|
||||
},
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
:target="anchorTarget"
|
||||
:class="`item ${makeClassList}`"
|
||||
v-tooltip="getTooltipOptions()"
|
||||
rel="noopener noreferrer" tabindex="0"
|
||||
:rel="`${item.rel || 'noopener noreferrer'}`"
|
||||
tabindex="0"
|
||||
:id="`link-${item.id}`"
|
||||
:style="customStyle"
|
||||
>
|
||||
@@ -255,8 +256,12 @@ export default {
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
word-break: keep-all;
|
||||
overflow: hidden;
|
||||
span.text {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,6 +390,7 @@ p.description {
|
||||
font-size: .9em;
|
||||
line-height: 1rem;
|
||||
height: 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<script>
|
||||
import IconBurger from '@/assets/interface-icons/burger-menu.svg';
|
||||
import { makePageSlug } from '@/utils/ConfigHelpers';
|
||||
import { checkPageVisibility } from '@/utils/CheckPageVisibility';
|
||||
|
||||
export default {
|
||||
name: 'Nav',
|
||||
@@ -45,10 +46,11 @@ export default {
|
||||
computed: {
|
||||
/* Get links to sub-pages, and combine with nav-links */
|
||||
allLinks() {
|
||||
const subPages = this.$store.getters.pages.map((subPage) => ({
|
||||
path: makePageSlug(subPage.name, 'home'),
|
||||
title: subPage.name,
|
||||
}));
|
||||
const subPages = this.$store.getters.pages.filter((page) => checkPageVisibility(page))
|
||||
.map((subPage) => ({
|
||||
path: makePageSlug(subPage.name, 'home'),
|
||||
title: subPage.name,
|
||||
}));
|
||||
const navLinks = this.links || [];
|
||||
return [...navLinks, ...subPages];
|
||||
},
|
||||
|
||||
@@ -319,6 +319,10 @@ div.action-buttons {
|
||||
min-width: 6rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 1rem 0.5rem 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ export default {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
width: fit-content;
|
||||
margin: 0.25rem auto;
|
||||
padding: 0.1rem 0.25rem;
|
||||
|
||||
150
src/components/Widgets/GlCpuSpeedometer.vue
Normal file
150
src/components/Widgets/GlCpuSpeedometer.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="glances-cpu-gauge-wrapper">
|
||||
<GaugeChart class="gl-speedometer" :value="gaugeValue"
|
||||
:baseColor="baseColor" :shadowColor="shadowColor" :gaugeColor="gaugeColor"
|
||||
:startAngle="startAngle" :endAngle="endAngle" :innerRadius="innerRadius"
|
||||
:separatorThickness="separatorThickness">
|
||||
<p class="percentage">{{ gaugeValue }}%</p>
|
||||
</GaugeChart>
|
||||
<p class="show-more-btn" @click="toggleMoreInfo">
|
||||
{{ showMoreInfo ? $t('widgets.general.show-less') : $t('widgets.general.cpu-details') }}
|
||||
</p>
|
||||
<div class="more-info" v-if="moreInfo && showMoreInfo">
|
||||
<div class="more-info-row" v-for="(info, key) in moreInfo" :key="key">
|
||||
<p class="label">{{ info.label }}</p>
|
||||
<p class="value">{{ info.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WidgetMixin from '@/mixins/WidgetMixin';
|
||||
import GlancesMixin from '@/mixins/GlancesMixin';
|
||||
import GaugeChart from '@/components/Charts/Gauge';
|
||||
import { capitalize } from '@/utils/MiscHelpers';
|
||||
|
||||
export default {
|
||||
mixins: [WidgetMixin, GlancesMixin],
|
||||
components: {
|
||||
GaugeChart,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
gaugeValue: 0,
|
||||
baseColor: '#101010ED',
|
||||
shadowColor: '#00000000',
|
||||
gaugeColor: [
|
||||
{ offset: 0, color: '#20e253' },
|
||||
{ offset: 35, color: '#f6f000' },
|
||||
{ offset: 65, color: '#fca016' },
|
||||
{ offset: 90, color: '#f80363' },
|
||||
],
|
||||
showMoreInfo: false,
|
||||
moreInfo: null,
|
||||
startAngle: -135,
|
||||
endAngle: 135,
|
||||
innerRadius: 80,
|
||||
separatorThickness: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
endpoint() {
|
||||
return this.makeGlancesUrl('cpu');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
processData(cpuData) {
|
||||
this.gaugeValue = cpuData.total;
|
||||
const moreInfo = [];
|
||||
const ignore = ['total', 'cpucore', 'time_since_update',
|
||||
'interrupts', 'soft_interrupts', 'ctx_switches', 'syscalls'];
|
||||
Object.keys(cpuData).forEach((key) => {
|
||||
if (!ignore.includes(key) && cpuData[key]) {
|
||||
moreInfo.push({ label: capitalize(key), value: `${cpuData[key].toFixed(1)}%` });
|
||||
}
|
||||
});
|
||||
this.moreInfo = moreInfo;
|
||||
},
|
||||
toggleMoreInfo() {
|
||||
this.showMoreInfo = !this.showMoreInfo;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.overrideUpdateInterval = 2;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.glances-cpu-gauge-wrapper {
|
||||
max-width: 15rem;
|
||||
margin: 0rem auto;
|
||||
|
||||
p.percentage {
|
||||
color: var(--widget-text-color);
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
font-size: 1.3rem;
|
||||
margin: 3.5rem 0;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.more-info {
|
||||
background: var(--widget-accent-color);
|
||||
border-radius: var(--curve-factor);
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0.5rem auto;
|
||||
|
||||
.more-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
p.label, p.value {
|
||||
color: var(--widget-text-color);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
p.value {
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px dashed var(--widget-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.show-more-btn {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
width: fit-content;
|
||||
margin: -1.1rem auto 0 auto;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
color: var(--widget-text-color);
|
||||
opacity: var(--dimming-factor);
|
||||
border-radius: var(--curve-factor);
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--widget-text-color);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
background: var(--widget-text-color);
|
||||
color: var(--widget-background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
/* global override for the Guage tick lines */
|
||||
.gl-speedometer svg line {
|
||||
stroke: var(--widget-text-color);
|
||||
opacity: .3;
|
||||
}
|
||||
</style>
|
||||
150
src/components/Widgets/GlMemSpeedometer.vue
Normal file
150
src/components/Widgets/GlMemSpeedometer.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="glances-cpu-gauge-wrapper">
|
||||
<GaugeChart class="gl-speedometer" :value="gaugeValue"
|
||||
:baseColor="baseColor" :shadowColor="shadowColor" :gaugeColor="gaugeColor"
|
||||
:startAngle="startAngle" :endAngle="endAngle" :innerRadius="innerRadius"
|
||||
:separatorThickness="separatorThickness">
|
||||
<p class="percentage">{{ gaugeValue }}%</p>
|
||||
</GaugeChart>
|
||||
<p class="show-more-btn" @click="toggleMoreInfo">
|
||||
{{ showMoreInfo ? $t('widgets.general.show-less') : $t('widgets.general.mem-details') }}
|
||||
</p>
|
||||
<div class="more-info" v-if="moreInfo && showMoreInfo">
|
||||
<div class="more-info-row" v-for="(info, key) in moreInfo" :key="key">
|
||||
<p class="label">{{ info.label }}</p>
|
||||
<p class="value">{{ info.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WidgetMixin from '@/mixins/WidgetMixin';
|
||||
import GlancesMixin from '@/mixins/GlancesMixin';
|
||||
import GaugeChart from '@/components/Charts/Gauge';
|
||||
import { capitalize, convertBytes } from '@/utils/MiscHelpers';
|
||||
|
||||
export default {
|
||||
mixins: [WidgetMixin, GlancesMixin],
|
||||
components: {
|
||||
GaugeChart,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
gaugeValue: 0,
|
||||
baseColor: '#101010ED',
|
||||
shadowColor: '#00000000',
|
||||
gaugeColor: [
|
||||
{ offset: 0, color: '#20e253' },
|
||||
{ offset: 35, color: '#f6f000' },
|
||||
{ offset: 65, color: '#fca016' },
|
||||
{ offset: 90, color: '#f80363' },
|
||||
],
|
||||
showMoreInfo: false,
|
||||
moreInfo: null,
|
||||
startAngle: -135,
|
||||
endAngle: 135,
|
||||
innerRadius: 80,
|
||||
separatorThickness: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
endpoint() {
|
||||
return this.makeGlancesUrl('mem');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
processData(memData) {
|
||||
this.gaugeValue = memData.percent;
|
||||
const moreInfo = [];
|
||||
const ignore = ['percent'];
|
||||
Object.keys(memData).forEach((key) => {
|
||||
if (!ignore.includes(key) && memData[key]) {
|
||||
moreInfo.push({ label: capitalize(key), value: convertBytes(memData[key]) });
|
||||
}
|
||||
});
|
||||
this.moreInfo = moreInfo;
|
||||
},
|
||||
toggleMoreInfo() {
|
||||
this.showMoreInfo = !this.showMoreInfo;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.overrideUpdateInterval = 2;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.glances-cpu-gauge-wrapper {
|
||||
max-width: 15rem;
|
||||
margin: 0rem auto;
|
||||
|
||||
p.percentage {
|
||||
color: var(--widget-text-color);
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
font-size: 1.3rem;
|
||||
margin: 3.5rem 0;
|
||||
width: 100%;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.more-info {
|
||||
background: var(--widget-accent-color);
|
||||
border-radius: var(--curve-factor);
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin: 0.5rem auto;
|
||||
|
||||
.more-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
p.label,
|
||||
p.value {
|
||||
color: var(--widget-text-color);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
p.value {
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px dashed var(--widget-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.show-more-btn {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
width: fit-content;
|
||||
margin: -1.1rem auto 0 auto;
|
||||
padding: 0.1rem 0.25rem;
|
||||
border: 1px solid transparent;
|
||||
color: var(--widget-text-color);
|
||||
opacity: var(--dimming-factor);
|
||||
border-radius: var(--curve-factor);
|
||||
|
||||
&:hover {
|
||||
border: 1px solid var(--widget-text-color);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:active {
|
||||
background: var(--widget-text-color);
|
||||
color: var(--widget-background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
/* global override for the Guage tick lines */
|
||||
.gl-speedometer svg line {
|
||||
stroke: var(--widget-text-color);
|
||||
opacity: .3;
|
||||
}
|
||||
</style>
|
||||
108
src/components/Widgets/HackernewsTrending.vue
Normal file
108
src/components/Widgets/HackernewsTrending.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="hackernews-wrapper">
|
||||
<!-- Hackernews Trending Posts-->
|
||||
<div class="posts-wrapper" v-if="trendingPosts">
|
||||
<div class="post-row" v-for="(trendingPosts, index) in trendingPosts" :key="index">
|
||||
<a class="post-top" :href="trendingPosts.originURL">
|
||||
<div class="post-title-wrap">
|
||||
<p class="post-title">{{ trendingPosts.title }}</p>
|
||||
<p class="post-date">
|
||||
{{ formatDate(trendingPosts.time) }}
|
||||
</p>
|
||||
<p class="post-score" v-if="trendingPosts.score">score: {{ trendingPosts.score }}</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// import axios from 'axios';
|
||||
import WidgetMixin from '@/mixins/WidgetMixin';
|
||||
import { widgetApiEndpoints } from '@/utils/defaults';
|
||||
|
||||
export default {
|
||||
mixins: [WidgetMixin],
|
||||
data() {
|
||||
return {
|
||||
trendingPosts: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
stories() {
|
||||
// This can be `beststories`, `topstories` or newstories
|
||||
// TODO: display error message if another string not matching the keywords was insert
|
||||
return this.options.stories || 'topstories';
|
||||
},
|
||||
limit() {
|
||||
return this.options.limit || 10;
|
||||
},
|
||||
endpoint() {
|
||||
return `${widgetApiEndpoints.hackernewsTrending}/${this.stories}.json`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchData() {
|
||||
this.makeRequest(this.endpoint).then(this.fetchPostDetails);
|
||||
},
|
||||
async fetchPostDetails(data) {
|
||||
const topPosts = data.slice(0, this.limit);
|
||||
const allData = topPosts.map((post) => {
|
||||
const url = `${widgetApiEndpoints.hackernewsTrending}/item/${post}.json`;
|
||||
return this.makeRequest(url);
|
||||
});
|
||||
Promise.all(allData).then((resolvedPostValues) => {
|
||||
this.trendingPosts = resolvedPostValues.map((element, index) => {
|
||||
const trendingPost = { ...element, originURL: `https://news.ycombinator.com/item?id=${topPosts.at(index)}` };
|
||||
return trendingPost;
|
||||
});
|
||||
});
|
||||
},
|
||||
formatDate(unixTime) {
|
||||
const date = new Date(unixTime * 1000);
|
||||
// Then specify how you want your dates to be formatted
|
||||
return new Intl.DateTimeFormat('default', { dateStyle: 'long' }).format(date);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hackernews-wrapper {
|
||||
.meta-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
margin: 0.25rem 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.post-row {
|
||||
border-top: 1px dashed var(--widget-text-color);
|
||||
padding: 0.5rem 0 0.25rem 0;
|
||||
.post-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
p.post-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
color: var(--widget-text-color);
|
||||
}
|
||||
p.post-date {
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
opacity: var(--dimming-factor);
|
||||
color: var(--widget-text-color);
|
||||
}
|
||||
p.post-score {
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
opacity: var(--dimming-factor);
|
||||
color: var(--widget-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -74,6 +74,7 @@ export default {
|
||||
this.jokeLine2 = data.delivery;
|
||||
} else if (this.jokeType === 'single') {
|
||||
this.jokeLine1 = data.joke;
|
||||
this.jokeLine2 = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -94,7 +94,7 @@ export default {
|
||||
}
|
||||
},
|
||||
processData(data) {
|
||||
this.data = data.data.sort((a, b) => a.vmid > b.vmid);
|
||||
this.data = data.data.sort((a, b) => Number(a.vmid) > Number(b.vmid));
|
||||
if (this.hideTemplates) {
|
||||
this.data = this.data.filter(item => item.template !== 1);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,8 @@ export default {
|
||||
const formatType = (ht) => capitalize(ht.replaceAll('_', ' '));
|
||||
holidays.forEach((holiday) => {
|
||||
results.push({
|
||||
name: holiday.name[0].text,
|
||||
name: holiday.name
|
||||
.filter(p => p.lang === this.options.lang)[0].text || holiday.name[0].text,
|
||||
date: makeDate(holiday.date),
|
||||
type: formatType(holiday.holidayType),
|
||||
observed: holiday.observedOn ? makeDate(holiday.observedOn) : '',
|
||||
|
||||
109
src/components/Widgets/RescueTime.vue
Normal file
109
src/components/Widgets/RescueTime.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="rescuetime-wrapper">
|
||||
<div class="title-row">
|
||||
<p class="title-rank">Rank</p>
|
||||
<p class="title-name">Category</p>
|
||||
<p class="title-time">Time spent</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="(category, indx) in categories"
|
||||
:key="indx"
|
||||
class="category-row"
|
||||
>
|
||||
<p class="category-rank">{{ category.rank }}</p>
|
||||
<p class="category-name">{{ indx }}</p>
|
||||
<p class="category-time">{{ category.minutes }} min</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
import WidgetMixin from '@/mixins/WidgetMixin';
|
||||
import { widgetApiEndpoints } from '@/utils/defaults';
|
||||
|
||||
export default {
|
||||
mixins: [WidgetMixin],
|
||||
data() {
|
||||
return {
|
||||
categories: [],
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.checkOptions();
|
||||
},
|
||||
computed: {
|
||||
endpoint() {
|
||||
const todaystring = this.getDate();
|
||||
return `${widgetApiEndpoints.rescueTime}?key=${this.options.apiKey}&restrict_begin=${todaystring}&restrict_end=${todaystring}&restrict_kind=overview&format=json`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
fetchData() {
|
||||
this.makeRequest(this.endpoint).then(this.processData);
|
||||
},
|
||||
processData(data) {
|
||||
const formateddata = this.calculateTimeCategories(data);
|
||||
this.categories = formateddata;
|
||||
},
|
||||
checkOptions() {
|
||||
const ops = this.options;
|
||||
if (!ops.apiKey) this.error('Missing API key for RescueTime');
|
||||
},
|
||||
getDate() {
|
||||
const today = new Date();
|
||||
let day = today.getDate();
|
||||
let month = today.getMonth() + 1;
|
||||
const year = today.getFullYear();
|
||||
if (day < 10) {
|
||||
day = `0${day}`;
|
||||
}
|
||||
if (month < 10) {
|
||||
month = `0${month}`;
|
||||
}
|
||||
return `${day}-${month}-${year}`;
|
||||
},
|
||||
calculateTimeCategories(timeArray) {
|
||||
const results = {};
|
||||
for (let i = 0; i < timeArray.rows.length; i += 1) {
|
||||
const [rank, seconds, persons, category] = timeArray.rows[i];
|
||||
const minutes = (parseInt(seconds, 10) / 60).toFixed(2);
|
||||
results[category] = { minutes, rank, persons };
|
||||
}
|
||||
return results;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rescuetime-wrapper {
|
||||
padding: 0.5rem 0;
|
||||
.title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
p {
|
||||
margin: 0.25rem 0;
|
||||
color: var(--widget-text-color);
|
||||
font-weight: 700;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1px dashed var(--widget-text-color);
|
||||
}
|
||||
}
|
||||
.category-rank {
|
||||
font-weight: 700;
|
||||
}
|
||||
.category-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
p {
|
||||
margin: 0.25rem 0;
|
||||
color: var(--widget-text-color);
|
||||
opacity: var(--dimming-factor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
242
src/components/Widgets/UptimeKuma.vue
Normal file
242
src/components/Widgets/UptimeKuma.vue
Normal file
@@ -0,0 +1,242 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="monitors">
|
||||
<div v-for="(monitor, index) in monitors" :key="index" class="item-wrapper">
|
||||
<div class="item monitor-row">
|
||||
<div class="title-title"><span class="text">{{ monitor.name }}</span></div>
|
||||
<div class="monitors-container">
|
||||
<div class="status-container">
|
||||
<span class="status-pill" :class="[monitor.statusClass]">{{ monitor.status }}</span>
|
||||
</div>
|
||||
<div class="status-container">
|
||||
<span class="response-time">{{ monitor.responseTime }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="errorMessage">
|
||||
<div class="error-message">
|
||||
<span class="text">{{ errorMessage }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* A simple example which you can use as a template for creating your own widget.
|
||||
* Takes two optional parameters (`text` and `count`), and fetches a list of images
|
||||
* from dummyapis.com, then renders the results to the UI.
|
||||
*/
|
||||
import WidgetMixin from '@/mixins/WidgetMixin';
|
||||
|
||||
export default {
|
||||
mixins: [WidgetMixin],
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
monitors: null,
|
||||
errorMessage: null,
|
||||
errorMessageConstants: {
|
||||
missingApiKey: 'No API key set',
|
||||
missingUrl: 'No URL set',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.fetchData();
|
||||
},
|
||||
computed: {
|
||||
/* Get API key for access to instance */
|
||||
apiKey() {
|
||||
const { apiKey } = this.options;
|
||||
|
||||
return apiKey;
|
||||
},
|
||||
/* Get instance URL */
|
||||
url() {
|
||||
const { url } = this.options;
|
||||
|
||||
return url;
|
||||
},
|
||||
/* Create authorisation header for the instance from the apiKey */
|
||||
authHeaders() {
|
||||
if (!this.options.apiKey) {
|
||||
return {};
|
||||
}
|
||||
const encoded = window.btoa(`:${this.options.apiKey}`);
|
||||
return { Authorization: `Basic ${encoded}` };
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
/* The update() method extends mixin, used to update the data.
|
||||
* It's called by parent component, when the user presses update
|
||||
*/
|
||||
update() {
|
||||
this.startLoading();
|
||||
this.fetchData();
|
||||
},
|
||||
/* Make the data request to the computed API endpoint */
|
||||
fetchData() {
|
||||
const { authHeaders, url } = this;
|
||||
|
||||
if (!this.optionsValid({ authHeaders, url })) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.makeRequest(url, authHeaders)
|
||||
.then(this.processData);
|
||||
},
|
||||
/* Convert API response data into a format to be consumed by the UI */
|
||||
processData(response) {
|
||||
const monitorRows = this.getMonitorRows(response);
|
||||
|
||||
const monitors = new Map();
|
||||
|
||||
for (let index = 0; index < monitorRows.length; index += 1) {
|
||||
const row = monitorRows[index];
|
||||
this.processRow(row, monitors);
|
||||
}
|
||||
|
||||
this.monitors = Array.from(monitors.values());
|
||||
},
|
||||
getMonitorRows(response) {
|
||||
return response.split('\n').filter(row => row.startsWith('monitor_'));
|
||||
},
|
||||
processRow(row, monitors) {
|
||||
const dataType = this.getRowDataType(row);
|
||||
const monitorName = this.getRowMonitorName(row);
|
||||
|
||||
if (!monitors.has(monitorName)) {
|
||||
monitors.set(monitorName, { name: monitorName });
|
||||
}
|
||||
|
||||
const monitor = monitors.get(monitorName);
|
||||
const value = this.getRowValue(row);
|
||||
|
||||
const updated = this.setMonitorValue(dataType, monitor, value);
|
||||
|
||||
monitors.set(monitorName, updated);
|
||||
},
|
||||
setMonitorValue(key, monitor, value) {
|
||||
const copy = { ...monitor };
|
||||
switch (key) {
|
||||
case 'monitor_cert_days_remaining': {
|
||||
copy.certDaysRemaining = value;
|
||||
break;
|
||||
}
|
||||
case 'monitor_cert_is_valid': {
|
||||
copy.certValid = value;
|
||||
break;
|
||||
}
|
||||
case 'monitor_response_time': {
|
||||
copy.responseTime = value;
|
||||
break;
|
||||
}
|
||||
case 'monitor_status': {
|
||||
copy.status = value === '1' ? 'Up' : 'Down';
|
||||
copy.statusClass = copy.status.toLowerCase();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return copy;
|
||||
},
|
||||
getRowValue(row) {
|
||||
return this.getValueWithRegex(row, /\b\d+\b$/);
|
||||
},
|
||||
getRowMonitorName(row) {
|
||||
return this.getValueWithRegex(row, /monitor_name="([^"]+)"/);
|
||||
},
|
||||
getRowDataType(row) {
|
||||
return this.getValueWithRegex(row, /^(.*?)\{/);
|
||||
},
|
||||
getValueWithRegex(string, regex) {
|
||||
const result = string.match(regex);
|
||||
|
||||
const isArray = Array.isArray(result);
|
||||
|
||||
if (!isArray) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return result.length > 1 ? result[1] : result[0];
|
||||
},
|
||||
optionsValid({ url, authHeaders }) {
|
||||
const errors = [];
|
||||
if (url === undefined) {
|
||||
errors.push(this.errorMessageConstants.missingUrl);
|
||||
}
|
||||
|
||||
if (authHeaders === undefined) {
|
||||
errors.push(this.errorMessageConstants.missingApiKey);
|
||||
}
|
||||
|
||||
if (errors.length === 0) { return true; }
|
||||
|
||||
this.errorMessage = errors.join('\n');
|
||||
return false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.status-pill {
|
||||
border-radius: 50em;
|
||||
box-sizing: border-box;
|
||||
font-size: 0.75em;
|
||||
display: inline-block;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
padding: .35em .65em;
|
||||
margin: 1em 0.5em;
|
||||
min-width: 64px;
|
||||
|
||||
&.up {
|
||||
background-color: rgb(92, 221, 139);
|
||||
color: black;
|
||||
}
|
||||
|
||||
&.down {
|
||||
background-color: rgb(220, 53, 69);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
div.item.monitor-row:hover {
|
||||
background-color: var(--item-background);
|
||||
color: var(--current-color);
|
||||
opacity: 1;
|
||||
|
||||
div.title-title>span.text {
|
||||
color: var(--current-color);
|
||||
}
|
||||
}
|
||||
|
||||
.monitors-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.monitor-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.35em 0.5em;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -46,7 +46,12 @@ export default {
|
||||
return this.options.units || 'metric';
|
||||
},
|
||||
endpoint() {
|
||||
const { apiKey, city } = this.options;
|
||||
const {
|
||||
apiKey, city, lat, lon,
|
||||
} = this.options;
|
||||
if (lat && lon) {
|
||||
return `${widgetApiEndpoints.weather}?lat=${lat}&lon=${lon}&appid=${apiKey}&units=${this.units}`;
|
||||
}
|
||||
return `${widgetApiEndpoints.weather}?q=${city}&appid=${apiKey}&units=${this.units}`;
|
||||
},
|
||||
tempDisplayUnits() {
|
||||
@@ -106,7 +111,11 @@ export default {
|
||||
checkProps() {
|
||||
const ops = this.options;
|
||||
if (!ops.apiKey) this.error('Missing API key for OpenWeatherMap');
|
||||
if (!ops.city) this.error('A city name is required to fetch weather');
|
||||
|
||||
if ((!ops.lat || !ops.lon) && !ops.city) {
|
||||
this.error('A city name or lat + lon is required to fetch weather');
|
||||
}
|
||||
|
||||
if (ops.units && ops.units !== 'metric' && ops.units !== 'imperial') {
|
||||
this.error('Invalid units specified, must be either \'metric\' or \'imperial\'');
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ export default {
|
||||
icon: day.weather[0].icon,
|
||||
main: day.weather[0].main,
|
||||
description: day.weather[0].description,
|
||||
temp: this.processTemp(day.temp.day),
|
||||
temp: this.processTemp(day.main.temp),
|
||||
info: this.makeWeatherData(day),
|
||||
});
|
||||
});
|
||||
@@ -109,15 +109,15 @@ export default {
|
||||
makeWeatherData(data) {
|
||||
return [
|
||||
[
|
||||
{ label: 'Min Temp', value: this.processTemp(data.temp.min) },
|
||||
{ label: 'Max Temp', value: this.processTemp(data.temp.max) },
|
||||
{ label: 'Feels Like', value: this.processTemp(data.feels_like.day) },
|
||||
{ label: 'Min Temp', value: this.processTemp(data.main.temp_min) },
|
||||
{ label: 'Max Temp', value: this.processTemp(data.main.temp_max) },
|
||||
{ label: 'Feels Like', value: this.processTemp(data.main.feels_like) },
|
||||
],
|
||||
[
|
||||
{ label: 'Pressure', value: `${data.pressure}hPa` },
|
||||
{ label: 'Humidity', value: `${data.humidity}%` },
|
||||
{ label: 'wind', value: `${data.speed}${this.speedDisplayUnits}` },
|
||||
{ label: 'clouds', value: `${data.clouds}%` },
|
||||
{ label: 'Pressure', value: `${data.main.pressure}hPa` },
|
||||
{ label: 'Humidity', value: `${data.main.humidity}%` },
|
||||
{ label: 'wind', value: `${data.wind.speed}${this.speedDisplayUnits}` },
|
||||
{ label: 'clouds', value: `${data.clouds.all}%` },
|
||||
],
|
||||
];
|
||||
},
|
||||
|
||||
@@ -67,18 +67,21 @@ const COMPAT = {
|
||||
'gl-alerts': 'GlAlerts',
|
||||
'gl-current-cores': 'GlCpuCores',
|
||||
'gl-current-cpu': 'GlCpuGauge',
|
||||
'gl-cpu-speedometer': 'GlCpuSpeedometer',
|
||||
'gl-cpu-history': 'GlCpuHistory',
|
||||
'gl-disk-io': 'GlDiskIo',
|
||||
'gl-disk-space': 'GlDiskSpace',
|
||||
'gl-ip-address': 'GlIpAddress',
|
||||
'gl-load-history': 'GlLoadHistory',
|
||||
'gl-current-mem': 'GlMemGauge',
|
||||
'gl-mem-speedometer': 'GlMemSpeedometer',
|
||||
'gl-mem-history': 'GlMemHistory',
|
||||
'gl-network-interfaces': 'GlNetworkInterfaces',
|
||||
'gl-network-traffic': 'GlNetworkTraffic',
|
||||
'gl-system-load': 'GlSystemLoad',
|
||||
'gl-cpu-temp': 'GlCpuTemp',
|
||||
'health-checks': 'HealthChecks',
|
||||
'hackernews-trending': 'HackernewsTrending',
|
||||
'gluetun-status': 'GluetunStatus',
|
||||
iframe: 'IframeWidget',
|
||||
image: 'ImageWidget',
|
||||
@@ -103,6 +106,7 @@ const COMPAT = {
|
||||
'proxmox-lists': 'Proxmox',
|
||||
'public-holidays': 'PublicHolidays',
|
||||
'public-ip': 'PublicIp',
|
||||
'rescue-time': 'RescueTime',
|
||||
'rss-feed': 'RssFeed',
|
||||
sabnzbd: 'Sabnzbd',
|
||||
'sports-scores': 'SportsScores',
|
||||
@@ -111,6 +115,7 @@ const COMPAT = {
|
||||
'synology-download': 'SynologyDownload',
|
||||
'system-info': 'SystemInfo',
|
||||
'tfl-status': 'TflStatus',
|
||||
'uptime-kuma': 'UptimeKuma',
|
||||
'wallet-balance': 'WalletBalance',
|
||||
weather: 'Weather',
|
||||
'weather-forecast': 'WeatherForecast',
|
||||
@@ -201,14 +206,16 @@ export default {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '@/styles/media-queries.scss';
|
||||
@import "@/styles/media-queries.scss";
|
||||
|
||||
.widget-base {
|
||||
position: relative;
|
||||
padding: 0.75rem 0.5rem 0.5rem 0.5rem;
|
||||
background: var(--widget-base-background);
|
||||
box-shadow: var(--widget-base-shadow, none);
|
||||
|
||||
// Refresh and full-page action buttons
|
||||
button.action-btn {
|
||||
button.action-btn {
|
||||
height: 1rem;
|
||||
min-width: auto;
|
||||
width: 1.75rem;
|
||||
@@ -219,21 +226,26 @@ export default {
|
||||
border: none;
|
||||
opacity: var(--dimming-factor);
|
||||
color: var(--widget-text-color);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: var(--widget-background-color);
|
||||
}
|
||||
|
||||
&.update-btn {
|
||||
right: -0.25rem;
|
||||
}
|
||||
|
||||
&.open-btn {
|
||||
right: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional widget label
|
||||
.widget-label {
|
||||
color: var(--widget-text-color);
|
||||
}
|
||||
|
||||
// Actual widget container
|
||||
.widget-wrap {
|
||||
&.has-error {
|
||||
@@ -241,9 +253,11 @@ export default {
|
||||
opacity: 0.5;
|
||||
border-radius: var(--curve-factor);
|
||||
background: #ffff0040;
|
||||
|
||||
&:hover { background: none; }
|
||||
}
|
||||
}
|
||||
|
||||
// Error message output
|
||||
.widget-error {
|
||||
p.error-msg {
|
||||
@@ -252,12 +266,14 @@ export default {
|
||||
font-size: 1rem;
|
||||
margin: 0 auto 0.5rem auto;
|
||||
}
|
||||
|
||||
p.error-output {
|
||||
font-family: var(--font-monospace);
|
||||
color: var(--widget-text-color);
|
||||
font-size: 0.85rem;
|
||||
margin: 0.5rem auto;
|
||||
}
|
||||
|
||||
p.retry-link {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
@@ -266,14 +282,17 @@ export default {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Loading spinner
|
||||
.loading {
|
||||
margin: 0.2rem auto;
|
||||
text-align: center;
|
||||
|
||||
svg.loader {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
// Hide widget contents while loading
|
||||
&.is-loading {
|
||||
.widget-wrap {
|
||||
@@ -281,5 +300,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user