⤴ Rebased from master

This commit is contained in:
Alicia Sykes
2023-06-11 11:30:55 +01:00
102 changed files with 7607 additions and 1856 deletions

View File

@@ -1,41 +1,40 @@
<template>
<modal :name="modalName" :resizable="true" width="55%" height="80%" classes="dashy-modal">
<div class="about-modal">
<router-link to="/about" class="title"><h2>App Info</h2></router-link>
<router-link to="/about" class="title"><h2>{{ $t('app-info.title') }}</h2></router-link>
<!-- Error Log -->
<h3>Error Log</h3>
<h3>{{ $t('app-info.error-log') }}</h3>
<pre v-if="errorLog" class="logs"><code>{{ errorLog }}</code></pre>
<p v-else>No recent errors detected :)</p>
<p v-else>{{ $t('app-info.no-errors') }} :)</p>
<hr />
<!-- Getting Help -->
<h3>Help & Support</h3>
For getting support with running or configuring Dashy, see the <a href="https://github.com/Lissy93/dashy/discussions">Discussions</a>
<h3>{{ $t('app-info.help-support') }}</h3>
{{ $t('app-info.help-support-description') }} <a href="https://github.com/Lissy93/dashy/discussions">{{ $t('app-info.help-support-discussions') }}</a>
<!-- Please help out :) -->
<h3>Supporting Dashy</h3>
For ways that you can get involved, check out the <a href="https://github.com/Lissy93/dashy/blob/master/docs/contributing.md">Contributing</a> page.
<h3>{{ $t('app-info.support-dashy') }}</h3>
{{ $t('app-info.support-dashy-description') }} <a href="https://github.com/Lissy93/dashy/blob/master/docs/contributing.md">{{ $t('app-info.support-dashy-link') }}</a>.
<!-- Bug Reports -->
<h3>Report a Bug</h3>
If you think you've found a bug, then please <a href="https://github.com/Lissy93/dashy/issues/new/choose">raise an Issue</a>.
<h3>{{ $t('app-info.report-bug') }}</h3>
{{ $t('app-info.report-bug-description') }} <a href="https://github.com/Lissy93/dashy/issues/new/choose">{{ $t('app-info.report-bug-link') }}</a>.
<!-- Source and Docs Links -->
<h3>More Info</h3>
Source: <a href="https://github.com/lissy93/dashy">github.com/lissy93/dashy</a><br>
Documentation: <a href="https://dashy.to/docs">dashy.to/docs</a>
<h3>{{ $t('app-info.more-info') }}</h3>
{{ $t('app-info.source') }}: <a href="https://github.com/lissy93/dashy">github.com/lissy93/dashy</a><br>
{{ $t('app-info.documentation') }}: <a href="https://dashy.to/docs">dashy.to/docs</a>
<!-- Privacy & Security -->
<h3>Privacy & Security</h3>
For a break-down of how your data is managed by Dashy, see
the <a href="https://github.com/Lissy93/dashy/blob/master/docs/privacy.md">Privacy Policy</a>.<br>
For advise in securing your dashboard, you can reference the
<a href="https://github.com/Lissy93/dashy/blob/master/docs/management.md">Management Docs</a>.<br>
If you've found a potential security issue, report it following our
<a href="https://github.com/Lissy93/dashy/blob/master/.github/SECURITY.md">Security Policy</a>
<h3>{{ $t('app-info.privacy-and-security') }}</h3>
{{ $t('app-info.privacy-and-security-l1') }} <a href="https://github.com/Lissy93/dashy/blob/master/docs/privacy.md">{{ $t('app-info.privacy-and-security-privacy-policy') }}</a>.<br>
{{ $t('app-info.privacy-and-security-advice') }}
<a href="https://github.com/Lissy93/dashy/blob/master/docs/management.md">{{ $t('app-info.privacy-and-security-advice-link') }}</a>.<br>
{{ $t('app-info.privacy-and-security-security-issue') }}
<a href="https://github.com/Lissy93/dashy/blob/master/.github/SECURITY.md">{{ $t('app-info.privacy-and-security-security-policy') }}</a>
<!-- License -->
<h3>License</h3>
Licensed under <a href="https://github.com/Lissy93/dashy/blob/master/LICENSE">MIT X11</a>.
<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>
For licenses for third-party modules, please see <a href="https://github.com/Lissy93/dashy/blob/master/.github/LEGAL.md">Legal</a>.<br>
For the full list of contributors and thanks, see <a href="https://github.com/Lissy93/dashy/blob/master/docs/credits.md">Credits</a>.
{{ $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 -->
<h3>Version</h3>
<h3>{{ $t('app-info.version') }}</h3>
<AppVersion class="app-version" />
</div>
</modal>

View File

@@ -9,7 +9,7 @@
{{ $t('cloud-sync.intro-l2') }}
<br>
{{ $t('cloud-sync.intro-l3') }}
<a href="https://github.com/Lissy93/dashy/blob/master/docs/backup-restore.md">docs</a>
<a href="https://github.com/Lissy93/dashy/blob/master/docs/backup-restore.md">{{ $t('cloud-sync.intro-docs') }}</a>.
</p>
</div>
<!-- Create or update a backup form -->

View File

@@ -54,12 +54,8 @@
<AppVersion />
</div>
<!-- Display note if Config disabled, or if on mobile -->
<p v-if="!enableConfig" class="config-disabled-note">
Some configuration features have been disabled by your administrator
</p>
<p class="small-screen-note" style="display: none;">
You are using a very small screen, and some screens in this menu may not be optimal
</p>
<p v-if="!enableConfig" class="config-disabled-note">{{ $t('config.disabled-note') }}</p>
<p class="small-screen-note" style="display: none;">{{ $t('config.small-screen-note') }}</p>
<div class="config-note">
<span>{{ $t('config.backup-note') }}</span>
</div>

View File

@@ -2,7 +2,7 @@
<div class="css-editor-outer">
<!-- Add raw custom CSS -->
<div class="style-section css-wrapper">
<h3>Custom CSS</h3>
<h3>{{ $t('config.custom-css.title') }}</h3>
<textarea class="css-editor" v-model="customCss" />
<Button class="save-button" :click="save">{{ $t('config.css-save-btn') }}</Button>
<p class="quick-note">
@@ -12,7 +12,7 @@
</div>
<!-- Theme Selector -->
<div class="style-section base-theme-wrapper">
<h3>Base Theme</h3>
<h3>{{ $t('config.custom-css.base-theme') }}</h3>
<ThemeSelector :hidePallete="true" />
</div>
<!-- UI color configurator -->

View File

@@ -70,7 +70,7 @@ export default {
const filename = 'dashy_conf.yml';
const config = this.convertJsonToYaml();
const element = document.createElement('a');
element.setAttribute('href', `data:text/plain;charset=utf-8, ${encodeURIComponent(config)}`);
element.setAttribute('href', `data:text/plain;charset=utf-8,${encodeURIComponent(config)}`);
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);

View File

@@ -3,7 +3,8 @@
classes="dashy-modal">
<div slot="top-right" @click="hide()">Close</div>
<a @click="hide()" class="close-button" title="Close">x</a>
<iframe v-if="url" :src="url" @keydown.esc="close" class="frame" allow="fullscreen" />
<iframe v-if="url" :src="url" @keydown.esc="close" class="frame"
allow="fullscreen; clipboard-write" />
<div v-else class="no-url">No URL Specified</div>
</modal>
</template>

View File

@@ -14,16 +14,16 @@
:style="customStyle"
>
<!-- Item Text -->
<div :class="`tile-title ${!item.icon? 'bounce no-icon': ''}`" :id="`tile-${item.id}`" >
<div :class="`tile-title ${!itemIcon? 'bounce no-icon': ''}`" :id="`tile-${item.id}`" >
<span class="text">{{ item.title }}</span>
<p class="description">{{ item.description }}</p>
</div>
<!-- Item Icon -->
<Icon :icon="item.icon" :url="item.url" :size="size" :color="item.color"
<Icon :icon="itemIcon" :url="item.url" :size="size" :color="item.color"
v-bind:style="customStyles" class="bounce" />
<!-- Small icon, showing opening method on hover -->
<ItemOpenMethodIcon class="opening-method-icon"
:isSmall="!item.icon || size === 'small'"
:isSmall="!itemIcon || size === 'small'"
:openingMethod="accumulatedTarget" position="bottom right"
:hotkey="item.hotkey" />
<!-- Status indicator dot (if enabled) showing weather service is available -->
@@ -65,7 +65,6 @@ import MoveItemTo from '@/components/InteractiveEditor/MoveItemTo';
import ContextMenu from '@/components/LinkItems/ItemContextMenu';
import StoreKeys from '@/utils/StoreMutations';
import ItemMixin from '@/mixins/ItemMixin';
// import { targetValidator } from '@/utils/ConfigHelpers';
import EditModeIcon from '@/assets/interface-icons/interactive-editor-edit-mode.svg';
import { modalNames } from '@/utils/defaults';
@@ -89,6 +88,10 @@ export default {
EditModeIcon,
},
computed: {
/* Returns either item.icon, or appConfig.defaultIcon, or null */
itemIcon() {
return this.item.icon || this.$store.getters.appConfig?.defaultIcon;
},
makeColumnCount() {
if ((this.sectionDisplayData || {}).itemCountX) return this.sectionDisplayData.itemCountX;
if (this.sectionWidth < 380) return 1;
@@ -101,8 +104,7 @@ export default {
/* Based on item props, adjust class names */
makeClassList() {
const { isAddNew, isEditMode, size } = this;
const { icon } = this.item;
return `size-${size} ${!icon ? 'short' : ''} `
return `size-${size} ${!this.itemIcon ? 'short' : ''} `
+ `${isAddNew ? 'add-new' : ''} ${isEditMode ? 'is-edit-mode' : ''}`;
},
/* Used by certain themes (material), to show animated CSS icon */

View File

@@ -202,7 +202,7 @@ export default {
/* For a given URL, return the hostname only. Used for favicon and generative icons */
getHostName(url) {
try {
return new URL(url).hostname.split('.').slice(-2).join('.');
return new URL(url).hostname;
} catch (e) {
ErrorHandler('Unable to format URL');
return url;

View File

@@ -389,7 +389,9 @@ export default {
justify-content: space-around;
.widget-base {
min-width: 10rem;
width: stretch;
width: -webkit-fill-available;
width: -moz-available;
}
}
}

View File

@@ -48,7 +48,7 @@ export default {
},
computed: {
subItemTooltip() {
return this.title;
return this.item.title;
},
},
data() {

View File

@@ -9,9 +9,12 @@
© {{defaultInfo.date}}.
Get the <a :href="defaultInfo.repoUrl">Source Code</a>.
</span>
<!-- Config info -->
<span class="path-to-config">
<span>
Using: {{ $store.state.currentConfigId || 'Default Config' }}
{{ $t('footer.dev-by') }} <a :href="authorUrl">{{authorName}}</a>.
{{ $t('footer.licensed-under') }} <a :href="licenseUrl">{{license}}</a>
{{ showCopyright? '©': '' }} {{date}}.
{{ $t('footer.get-the') }} <a :href="repoUrl">{{ $t('footer.source-code') }}</a>.
</span>
</footer>
</template>

View File

@@ -3,7 +3,7 @@
<div id="loading" v-if="isLoading" :class="c" @click="c = 'hide'">
<h2>Dashy</h2>
<div class="inner-container">
<p>Loading</p>
<p>{{ $t('splash-screen.loading')}}</p>
<span class="dots-cont">
<span class="dot dot-1"></span>
<span class="dot dot-2"></span>

View File

@@ -39,6 +39,11 @@ export default {
showSeconds() {
return !this.options.hideSeconds;
},
use12Hour() {
if (typeof this.options.use12Hour === 'boolean') return this.options.use12Hour;
// this is the default, it gets computed by the DateTimeFormat implementation
return Intl.DateTimeFormat(this.timeFormat, { timeZone: this.timeZone, hour: 'numeric' }).resolvedOptions().hour12 ?? false;
},
},
methods: {
update() {
@@ -52,12 +57,17 @@ export default {
hour: 'numeric',
minute: 'numeric',
...(this.showSeconds && { second: 'numeric' }),
...(this.use12Hour && { hourCycle: 'h12' }),
}).format();
},
/* Get and format the date */
setDate() {
this.date = new Date().toLocaleDateString(this.timeFormat, {
weekday: 'long', day: 'numeric', year: 'numeric', month: 'short',
weekday: 'long',
day: 'numeric',
year: 'numeric',
month: 'short',
timeZone: this.timeZone,
});
},
},

View File

@@ -0,0 +1,74 @@
<template>
<div class="custom-search">
<input type="text" v-model="query"
@keyup.enter="search(defaultEngine)"
@keyup.stop @keydown.stop
:placeholder="placeholder">
<div class="buttons">
<button
v-for="(engine, key) in engines" :key="key"
v-on:click="search(engine)">
{{ engine.title }}
</button>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
query: '',
};
},
computed: {
placeholder() {
return this.options.placeholder || '';
},
engines() {
return this.options.engines || [];
},
defaultEngine() {
return this.engines[0];
},
},
methods: {
search(engine) {
if (engine !== undefined && this.query !== '') {
window.open(engine.url + this.query, '_blank');
}
},
},
};
</script>
<style scoped lang="scss">
.custom-search {
font-size: 1.2rem;
input {
width: 80%;
margin: 1rem 10%;
padding: 0.5rem;
font-size: 1.2rem;
}
.buttons {
text-align:center;
button{
margin: 0.5rem;
padding: 0.5rem;
border: none;
color: var(--item-text-color);
background: var(--item-background);
font-size: 1.2rem;
}
}
}
</style>

View File

@@ -92,7 +92,7 @@ export default {
},
endpoint() {
return `${widgetApiEndpoints.cveVulnerabilities}?${this.sortBy}${this.limit}`
+ `${this.minScore}${this.vendorId}${this.hasExploit}`;
+ `${this.minScore}${this.vendorId}${this.productId}${this.hasExploit}`;
},
proxyReqEndpoint() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;

View File

@@ -0,0 +1,238 @@
<template>
<div class="droneci-builds-wrapper" v-if="builds">
<div
class="build-row"
v-for="build in builds" :key="build.id"
v-tooltip="infoTooltip(build)"
>
<div class="status">
<p :class="build.build.status">{{ build.build.status | formatStatus }}</p>
<span v-if="build.build.status == 'running'">
{{ build.build.started*1000 | formatTimeAgo }} ago
</span>
<span v-else-if="build.build.status != 'pending' ">
{{ formatBuildDuration(build) }}
</span>
<span v-else>
{{ build.build.created*1000 | formatTimeAgo }} ago
</span>
</div>
<div class="info">
<div class="build-name">
{{ build.name }}
<a
class="droneci-build-number"
:href="build.baseurl + '/' + build.slug + '/' +build.build.number"
target="_blank"
>{{ build.build.number }}</a>
</div>
<div class="build-desc">
<span class="droneci-extra">
<template v-if="build.build.event == 'pull_request'">
<a
:href="build.build.link"
target="_blank"
class="droneci-extra-info"
>#{{ formatPrId(build.build.link) }}</a> to
</template>
<template v-else-if="build.build.event == 'push'">
<a
:href="build.build.link"
target="_blank"
class="droneci-extra-info"
>push</a> to
</template>
<a
:href="build.git_http_url"
target="_blank"
class="droneci-extra-info"
>
{{ build.build.target }}
</a>
</span>
</div>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { getTimeAgo, getTimeDifference, timestampToDateTime } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
builds: null,
};
},
filters: {
formatStatus(status) {
let symbol = '';
if (status === 'success') symbol = '✔';
if (status === 'failure' || status === 'error' || status === 'killed') symbol = '✘';
if (status === 'running') symbol = '❖';
if (status === 'skipped') symbol = '↠';
return `${symbol}`;
},
formatDate(timestamp) {
return timestampToDateTime(timestamp);
},
formatTimeAgo(timestamp) {
return getTimeAgo(timestamp);
},
},
computed: {
/* API endpoint, either for self-hosted or managed instance */
endpointBuilds() {
if (!this.options.host) this.error('drone.ci Host is required');
return `${this.options.host}/api/user/builds`;
},
endpointRepoInfo() {
if (!this.options.host) this.error('drone.ci Host is required');
return `${this.options.host}/api/repos/${this.options.repo}`;
},
endpointRepoBuilds() {
if (!this.options.host) this.error('drone.ci Host is required');
return `${this.options.host}/api/repos/${this.options.repo}/builds`;
},
repo() {
if (this.options.repo) return this.options.repo;
return false;
},
apiKey() {
if (!this.options.apiKey) {
this.error('An API key is required, please see the docs for more info');
}
return this.options.apiKey;
},
},
methods: {
/* Fetch new data, configured by updateInterval */
update() {
this.startLoading();
this.fetchData();
this.finishLoading();
},
/* Make GET request to Drone CI API endpoint */
fetchData() {
const authHeaders = { Authorization: `Bearer ${this.apiKey}` };
if (this.repo !== false) {
this.makeRequest(this.endpointRepoInfo, authHeaders).then(
(repoInfo) => {
this.makeRequest(this.endpointRepoBuilds, authHeaders).then(
(buildInfo) => {
this.processRepoBuilds(repoInfo, buildInfo);
},
);
},
);
} else {
this.makeRequest(this.endpointBuilds, authHeaders).then(
(response) => { this.processBuilds(response); },
);
}
},
/* Assign data variables to the returned data */
processBuilds(data) {
const results = data.slice(0, this.options.limit)
.map((obj) => ({ ...obj, baseurl: this.options.host }));
this.builds = results;
},
processRepoBuilds(repo, builds) {
const results = builds.slice(0, this.options.limit)
.map((obj) => ({ build: { ...obj }, baseurl: this.options.host, ...repo }));
this.builds = results;
},
infoTooltip(build) {
const content = `<b>Trigger:</b> ${build.build.event} by ${build.build.trigger}<br>`
+ `<b>Repo:</b> ${build.slug}<br>`
+ `<b>Branch:</b> ${build.build.target}<br>`;
return {
content, html: true, trigger: 'hover focus', delay: 250, classes: 'build-info-tt',
};
},
formatPrId(link) {
return link.split('/').pop();
},
formatBuildDuration(build) {
return getTimeDifference(build.build.started * 1000, build.build.finished * 1000);
},
},
};
</script>
<style scoped lang="scss">
.droneci-builds-wrapper {
color: var(--widget-text-color);
.build-row {
display: grid;
grid-template-columns: 1fr 2.5fr;
justify-content: left;
align-items: center;
padding: 0.25rem 0;
.status {
font-size: 1rem;
font-weight: bold;
p {
margin: 0;
color: var(--info);
&.success { color: var(--success); }
&.failure { color: var(--danger); }
&.error { color: var(--danger); }
&.running { color: var(--neutral); }
}
span {
font-size: 0.75rem;
color: var(--secondary);
}
}
.info {
div.build-name {
margin: 0.25rem 0;
font-weight: bold;
color: var(--widget-text-color);
a, a:hover, a:visited, a:active {
color: inherit;
text-decoration: none;
}
.droneci-build-number::before {
content: "#";
}
}
div.build-desc {
margin: 0;
font-size: 0.85rem;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
a, a:hover, a:visited, a:active {
color: inherit;
text-decoration: none;
}
.droneci-extra {
.droneci-extra-info {
margin: 0.25em;
padding: 0em 0.25em;
background: var(--item-background);
border: 1px solid var(--primary);
border-radius: 5px;
}
}
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>
<style lang="scss">
.build-info-tt {
min-width: 20rem;
}
</style>

View File

@@ -2,7 +2,7 @@
<div class="glances-temp-wrapper" v-if="tempData">
<div class="temp-row" v-for="sensor in tempData" :key="sensor.label">
<p class="label">{{ sensor.label | formatLbl }}</p>
<p :class="`temp range-${sensor.color}`">{{ sensor.value | formatVal }}</p>
<p :class="`temp range-${sensor.color}`">{{ sensor.value | formatVal(sensor.unit) }}</p>
</div>
</div>
</template>
@@ -10,7 +10,7 @@
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import GlancesMixin from '@/mixins/GlancesMixin';
import { capitalize, fahrenheitToCelsius } from '@/utils/MiscHelpers';
import { capitalize, celsiusToFahrenheit, fahrenheitToCelsius } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin, GlancesMixin],
@@ -29,28 +29,105 @@ export default {
formatLbl(lbl) {
return capitalize(lbl);
},
formatVal(val) {
return `${Math.round(val)}°C`;
formatVal(val, unit) {
switch (unit) {
case 'R':
return `${Math.round(val)} rpm`;
case '%':
return `${Math.round(val)}%`;
default:
return `${Math.round(val)}°${unit}`;
}
},
},
methods: {
getDesiredUnits() {
return this.options.units ?? 'C';
},
getDisplayValue(rawValue, units) {
const desiredUnits = this.getDesiredUnits();
if (units === desiredUnits) {
return rawValue;
}
return desiredUnits === 'C'
? fahrenheitToCelsius(rawValue)
: celsiusToFahrenheit(rawValue);
},
getCelsiusValue(rawValue, units) {
if (units !== 'F' && units !== 'C') {
return Number.NaN;
}
return units === 'C' ? rawValue : fahrenheitToCelsius(rawValue);
},
getFahrenheitValue(rawValue, units) {
if (units !== 'F' && units !== 'C') {
return Number.NaN;
}
return units === 'F' ? rawValue : celsiusToFahrenheit(rawValue);
},
getTempColor(temp) {
if (temp <= 50) return 'green';
if (temp > 50 && temp < 75) return 'yellow';
if (temp >= 75) return 'red';
return 'grey';
},
getPercentageColor(percentage) {
if (percentage < 20) return 'red';
if (percentage < 50) return 'orange';
if (percentage < 75) return 'yellow';
return 'green';
},
processData(sensorData) {
const results = [];
sensorData.forEach((sensor) => {
const tempC = sensor.unit === 'F' ? fahrenheitToCelsius(sensor.value) : sensor.value;
results.push({
label: sensor.label,
value: tempC,
color: this.getTempColor(tempC),
});
});
this.tempData = results;
this.tempData = sensorData.map(sensor => {
switch (sensor.unit) {
case 'F':
case 'C':
return this.processTemperatureSensor(sensor);
case 'R':
return this.processFanSensor(sensor);
case '%':
return this.processBatterySensor(sensor);
default:
// Justification: This is a recoverable error that developers
// should nevertheless be warned about.
// eslint-disable-next-line
console.warn('Unrecognized unit', sensor.unit);
return null;
}
}).filter(Boolean);
},
processBatterySensor({ label, unit, value }) {
const color = this.getPercentageColor(value);
return {
color,
label,
unit,
value,
};
},
processFanSensor({ label, unit, value }) {
return {
color: 'grey',
label,
unit,
value,
};
},
processTemperatureSensor({ label, unit, value: originalValue }) {
const celsiusValue = this.getCelsiusValue(originalValue, unit);
const color = this.getTempColor(celsiusValue);
const displayValue = this.getDisplayValue(originalValue, unit);
const displayUnits = this.getDesiredUnits();
return {
color,
label,
unit: displayUnits,
value: displayValue,
};
},
},
};

View File

@@ -43,9 +43,6 @@ export default {
},
},
methods: {
fetchData() {
this.makeRequest(this.endpoint).then(this.processData);
},
processData(diskData) {
this.disks = diskData;
},

View File

@@ -1,18 +1,20 @@
<template>
<div class="health-checks-wrapper" v-if="crons">
<div
class="cron-row"
v-for="cron in crons" :key="cron.id"
v-tooltip="pingTimeTooltip(cron)"
<template
v-for="cron in crons"
>
<div class="status">
<div class="status" v-bind:key="cron.id + 'status'">
<p :class="cron.status">{{ cron.status | formatStatus }}</p>
</div>
<div class="info">
<div
class="info"
v-tooltip="pingTimeTooltip(cron)"
v-bind:key="cron.id + 'info'"
>
<p class="cron-name">{{ cron.name }}</p>
<p class="cron-desc">{{ cron.desc }}</p>
</div>
</div>
</template>
</div>
</template>
@@ -35,6 +37,8 @@ export default {
if (status === 'up') symbol = '✔';
if (status === 'down') symbol = '✘';
if (status === 'new') symbol = '❖';
if (status === 'paused') symbol = '⏸';
if (status === 'running') symbol = '▶';
return `${symbol} ${capitalize(status)}`;
},
formatDate(timestamp) {
@@ -51,6 +55,9 @@ export default {
if (!this.options.apiKey) {
this.error('An API key is required, please see the docs for more info');
}
if (typeof this.options.apiKey === 'string') {
return [this.options.apiKey];
}
return this.options.apiKey;
},
},
@@ -58,14 +65,18 @@ export default {
/* Make GET request to CoinGecko API endpoint */
fetchData() {
this.overrideProxyChoice = true;
const authHeaders = { 'X-Api-Key': this.apiKey };
this.makeRequest(this.endpoint, authHeaders).then(
(response) => { this.processData(response); },
);
const results = [];
this.apiKey.forEach((key) => {
const authHeaders = { 'X-Api-Key': key };
this.makeRequest(this.endpoint, authHeaders).then(
(response) => { this.processData(response, results); },
);
});
results.sort((a, b) => ((a.name > b.name) ? 1 : -1));
this.crons = results;
},
/* Assign data variables to the returned data */
processData(data) {
const results = [];
processData(data, results) {
data.checks.forEach((cron) => {
results.push({
id: cron.slug,
@@ -78,7 +89,7 @@ export default {
url: this.makeUrl(cron.unique_key),
});
});
this.crons = results;
return results;
},
makeUrl(cronId) {
const base = this.options.host || 'https://healthchecks.io';
@@ -99,40 +110,40 @@ export default {
<style scoped lang="scss">
.health-checks-wrapper {
display: grid;
justify-content: center;
grid-template-columns: 1fr 2fr;
color: var(--widget-text-color);
.cron-row {
display: flex;
justify-content: center;
align-items: center;
padding: 0.25rem 0;
.status {
min-width: 5rem;
font-size: 1.2rem;
padding: 0.25rem 0;
.status {
min-width: 5rem;
font-size: 1.2rem;
font-weight: bold;
p {
margin: 0;
color: var(--info);
&.up { color: var(--success); }
&.down { color: var(--danger); }
&.new { color: var(--widget-text-color); }
&.running { color: var(--warning); }
&.paused { color: var(--info); }
}
}
.info {
p.cron-name {
margin: 0.25rem 0;
font-weight: bold;
p {
margin: 0;
color: var(--info);
&.up { color: var(--success); }
&.down { color: var(--danger); }
&.new { color: var(--neutral); }
}
color: var(--widget-text-color);
}
.info {
p.cron-name {
margin: 0.25rem 0;
font-weight: bold;
color: var(--widget-text-color);
}
p.cron-desc {
margin: 0;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
p.cron-desc {
margin: 0;
color: var(--widget-text-color);
opacity: var(--dimming-factor);
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
</style>

View File

@@ -5,6 +5,7 @@
:src="frameUrl"
:id="frameId"
title="Iframe Widget"
allow="fullscreen; clipboard-write"
:style="frameHeight ? `height: ${frameHeight}px` : ''"
/>
</div>
@@ -15,6 +16,9 @@ import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
data: () => ({
updateCount: 0,
}),
computed: {
/* Gets users specified URL to load into the iframe */
frameUrl() {
@@ -23,7 +27,7 @@ export default {
this.error('Iframe widget expects a URL');
return null;
}
return usersChoice;
return `${usersChoice}${this.updatePathParam}`;
},
frameHeight() {
return this.options.frameHeight;
@@ -32,11 +36,16 @@ export default {
frameId() {
return `iframe-${btoa(this.frameUrl || 'empty').substring(0, 16)}`;
},
/* Generate a URL param, to be updated in order to re-fetch image */
updatePathParam() {
return this.updateCount ? `#dashy-update-${this.updateCount}` : '';
},
},
methods: {
/* Refreshes iframe contents, called by parent */
update() {
this.startLoading();
this.updateCount += 1;
(document.getElementById(this.frameId) || {}).src = this.frameUrl;
this.finishLoading();
},
@@ -48,7 +57,7 @@ export default {
.iframe-widget {
iframe {
width: 100%;
min-height: 240px;
min-height: 80px;
border: 0;
}
}

View File

@@ -1,6 +1,6 @@
<template>
<div class="image-widget">
<img :src="imagePath" class="embedded-image" />
<img :src="imagePath" :style="imageDimensions" class="embedded-image" />
</div>
</template>
@@ -9,10 +9,46 @@ import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
data: () => ({
updateCount: 0,
}),
computed: {
/* The path to image to render */
imagePath() {
if (!this.options.imagePath) this.error('You must specify an imagePath');
return this.options.imagePath;
return `${this.options.imagePath}${this.updatePathParam}`;
},
/* If set, apply users specified image dimensions */
imageDimensions() {
// Skip if neither set
if (!this.options.imageWidth && !this.options.imageHeight) return null;
// Apply correct units to input val, if needed
const makeDimensionsUnit = (userVal) => {
if (!userVal) { // Nothing set, use auto
return 'auto';
} else if (!Number.isNaN(Number(userVal))) { // Number set, add px
return `${userVal}px`;
} else { // Value is string, likely already includes units
return userVal;
}
};
// Return CSS values for width and height
return `
width: ${makeDimensionsUnit(this.options.imageWidth)};
height: ${makeDimensionsUnit(this.options.imageHeight)};
`;
},
/* Generate a URL param, to be updated in order to re-fetch image */
updatePathParam() {
return this.updateCount ? `#dashy-update-${this.updateCount}` : '';
},
},
methods: {
/* In order to re-fetch the image, we much update the URL with an arbitrary hash */
update() {
this.startLoading();
this.updateCount += 1;
this.finishLoading();
},
},
};

View File

@@ -0,0 +1,102 @@
<template>
<div class="linkding-outer-wrapper">
<div class="linkding-wrapper" v-if="links">
<ul>
<li
v-for="link in links"
v-bind:key="link.id"
class="lingkding-link"
>
<a :href="link.url" target="_blank">
<span class="linktext" v-tooltip="link.description">{{link.title}}</span>
</a>
</li>
</ul>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
links: null,
};
},
computed: {
endpoint() {
if (!this.options.host) this.error('linkgding Host is required');
return `${this.options.host}/api/bookmarks`;
},
apiKey() {
if (!this.options.apiKey) this.error('linkgding apiKey is required');
return this.options.apiKey;
},
filtertags() {
return this.options.tags;
},
},
methods: {
update() {
this.startLoading();
this.fetchData();
this.finishLoading();
},
fetchData() {
const authHeaders = { Authorization: `Token ${this.apiKey}` };
this.makeRequest(this.endpoint, authHeaders).then(
(response) => { this.processData(response); },
);
},
processData(data) {
const self = this;
const fltr = (entry) => {
if (self.filtertags === null) return true;
for (let i = 0; i < self.filtertags.length; i += 1) {
if (entry.tag_names.includes(self.filtertags[i])) return true;
}
return false;
};
this.links = data.results.filter(
entry => fltr(entry),
);
},
},
};
</script>
<style scoped lang="scss">
.linkdign-wrapper {
}
</style>
<style scoped lang="scss">
.linkding-wrapper {
ul {
list-style: none;
padding: 0px;
color: var(--widget-text-color);
li {
opacity: var(--dimming-factor);
a, a:hover, a:visited, a:active {
font-weight: bold;
color: var(--widget-text-color);
}
span.linktext {
color: var(--widget-text-color);
}
padding-top:0.2em;
padding-bottom:0.2em;
&:before
{
content: '🔗';
margin: 0 0.7em; /* any design */
}
}
}
}
</style>

View File

@@ -0,0 +1,353 @@
<template>
<div class="mvg-wrapper" v-if="departures">
<template
v-for="departure in departures"
>
<div class="departure" v-bind:key="departure.key" v-tooltip="mvgTooltipDeparture(departure)">
<span :class="{live: departure.live}">
{{ departure.realtimeDepartureTime | formatDepartureTime }}
</span>
</div>
<div class='line'
v-bind:key="departure.key + 'line'"
>
<div
class="transport"
:class="['type-' + departure.transportType,
'line-' + departure.label,
]"
>{{ departure.label }}</div>
<div
class='destination'
v-tooltip="mvgTooltipDestination(departure)"
:class="{cancelled: departure.cancelled}">{{ departure.destination }}</div>
<span class="delay"
:class="{'has-delay': departure.realtimeDepartureTime > departure.plannedDepartureTime}"
>{{ Math.max(0,
(departure.realtimeDepartureTime - departure.plannedDepartureTime)/60000) }}</span>
<span class="occupancy"
:class="'occupancy-' + departure.occupancy"
v-if="departure.occupancy != 'UNKNOWN'"
v-tooltip="departure.occupancy"
></span>
</div>
</template>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
import { timestampToTime } from '@/utils/MiscHelpers';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
departures: null,
locationSearch: null,
};
},
created() {
if (!this.isLocationId) {
this.makeRequest(this.endpointLocation).then(
(response) => {
const stations = response.filter((r) => r.type === 'STATION');
if (stations.length > 0) {
this.location = stations[0].globalId;
this.fetchData();
} else {
this.error('Cannot find station for specified string');
}
},
);
} else {
this.location = this.options.location;
}
},
filters: {
formatDepartureTime(timestamp) {
const msDifference = new Date(timestamp).getTime() - new Date().getTime();
const diff = Math.max(0, Math.round(msDifference / 60000));
return diff;
},
},
computed: {
isLocationId() {
if (!this.options.location) {
this.error('Location is required');
}
if (typeof this.options.location !== 'string') this.error('Location can only be a string');
if (this.options.location.startsWith('de:09162:')) return true;
return false;
},
offset() {
if (this.options.offset) return this.options.offset;
return 0;
},
limit() {
return this.options.limit || 10;
},
endpointDeparture() {
return `${widgetApiEndpoints.mvg}/departure?globalId=${this.location}&limit=30&offsetInMinutes=${this.offset}&transportTypes=UBAHN,TRAM,BUS,SBAHN`;
},
endpointLocation() {
return `${widgetApiEndpoints.mvg}/location?query=${encodeURIComponent(this.options.location)}`;
},
},
methods: {
update() {
this.startLoading();
this.fetchData();
this.finishLoading();
},
fetchData() {
if (this.location !== undefined) {
this.makeRequest(this.endpointDeparture).then(
(response) => { this.processData(response); },
);
}
},
/* Assign data variables to the returned data */
processData(data) {
let i = 0;
const results = [];
data
.filter(this.filter_results)
.sort(this.sort_results)
.slice(0, this.limit).forEach((dep) => {
results.push({ ...dep, key: `mvg-dep-${this.location}-${i}` });
i += 1;
});
this.departures = results;
},
ensure_array(value) {
if (typeof value === 'string') {
return [value];
}
return value;
},
filter_results(value) {
if (!this.options.filters) return true;
let useEntry = (
(!this.options.filters.line)
|| this.ensure_array(this.options.filters.line).includes(value.label)
);
useEntry = useEntry
&& (
(!this.options.filters.product)
|| this.ensure_array(this.options.filters.product)
.some(x => x.toLowerCase() === value.transportType.toLowerCase())
);
useEntry = useEntry
&& (
(!this.options.filters.destination)
|| this.ensure_array(this.options.filters.destination)
.some(x => x.toLowerCase() === value.destination.toLowerCase())
);
return useEntry;
},
sort_results(a, b) {
const depa = a.realtimeDepartureTime ? a.realtimeDepartureTime : a.plannedDepartureTime;
const depb = b.realtimeDepartureTime ? b.realtimeDepartureTime : b.plannedDepartureTime;
if (depa > depb) return 1;
if (depa < depb) return -1;
if (a.label < b.label) return 1;
if (a.label > b.label) return -1;
if (a.destination < b.destination) return 1;
if (a.destination > b.destination) return -1;
return 0;
},
makeUrl(cronId) {
const base = this.options.host || 'https://healthchecks.io';
return `${base}/checks/${cronId}/details`;
},
mvgTooltipDeparture(data) {
let departureDetails = '';
if (data.realtime) {
departureDetails += `Live: ${timestampToTime(data.realtimeDepartureTime)}<br />`;
}
departureDetails += `Planned: ${timestampToTime(data.plannedDepartureTime)}<br />`;
if (data.realtime) {
departureDetails += 'Live!<br />';
}
return {
content: departureDetails, html: true, trigger: 'hover', delay: 250, classes: 'mvg-info-tt',
};
},
mvgTooltipDestination(data) {
let departureDetails = `<b>Infos:</b><br />${data.messages.join('<br />')}`;
if (data.platform) {
departureDetails += `Platform: ${data.platform}<br />`;
}
if (data.cancelled) {
departureDetails += '<b>Cancelled!</b><br />';
}
return {
content: departureDetails, html: true, trigger: 'hover', delay: 250, classes: 'mvg-info-tt',
};
},
},
};
</script>
<style scoped lang="scss">
.mvg-wrapper {
display: grid;
justify-content: left;
grid-template-columns: 1fr 9fr;
color: var(--widget-text-color);
padding: 0.25rem 0;
grid-row-gap: 0.4em;
.departure {
min-width: 1rem;
font-size: 1.1rem;
font-weight: bold;
text-align: right;
margin-right: 0.2rem;
span.live {
color: var(--success);
}
}
.line {
background-color: #FFFFFF;
margin: 0;
padding-right: 0.2em;
border-radius: 0.2em;
display: grid;
grid-template-columns: 2.2em 1fr minmax(1.5em,max-content) 0.75em;
.type-UBAHN {
border: 0px;
}
.type-SBAHN {
border: 0px;
}
.type-BUS {
}
.type-TRAM {
}
.transport{
border-top-left-radius: 0.2em 0.2em;
border-bottom-left-radius: 0.2em 0.2em;
margin: 0em;
padding: 0.15em 0;
color: #FFFFFF;
margin-right: 0.40em;
text-align: center;
span {
min-width: 2em;
display: inline-block;
}
&.line-U1 {
background-color: #468447;
}
&.line-U2 {
background-color: #dd3d4d;
}
&.line-U3 {
background-color: #ef8824;
}
&.line-U4 {
background-color: #04af90;
}
&.line-U5 {
background-color: #b78730;
}
&.line-U6 {
background-color: #0472b3;
}
&.line-S1 {
background-color: #79c6e7;
}
&.line-S2 {
background-color: #9bc04c;
}
&.line-S3 {
background-color: #942d8d;
}
&.line-S4 {
background-color: #d4214d;
}
&.line-S5 {
background-color: #03a074;
}
&.line-S6 {
background-color: #03a074;
}
&.line-S7 {
background-color: #964438;
}
&.line-S8 {
background-color: #000000;
}
&.type-BUS {
background-color: #0d5c70;
}
}
.destination{
border-radius: 0.2em;
width: 100%;
background-color: #FFFFFF;
color: #000;
padding-top: 0.15em;
padding-bottom: 0.15em;
white-space: nowrap;
overflow: hidden;
span.cancelled {
color: var(--danger);
text-decoration: line-through;
}
span.destination {
overflow: clip;
margin-right: 0.25em;
width: 75%;
display: inline-block;
}
}
.delay{
padding: 0.15em;
font-weight: bold;
&.has-delay{
padding: 0.15em;
background-color: var(--danger);
color: #FFF;
border-radius: 0.2em;
}
}
.delay::before{
content: "+";
}
.occupancy{
display: inline-block;
padding: 0 0.15em;
border-radius: 0.2em;
&.occupancy-LOW {
color: green;
}
&.occupancy-MEDIUM {
color: orange;
}
&.occupancy-HIGH {
color: red;
}
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
</style>
<style lang="scss">
.ping-times-tt {
min-width: 20rem;
}
</style>
<style lang="scss">
.mvg-info-tt {
min-width: 20rem;
}
</style>

View File

@@ -0,0 +1,372 @@
<template>
<div class="mvg-connections-outer-wrapper">
<div class="mvg-connections-header" v-if="showTitle">{{ connectionName }}</div>
<div class="mvg-wrapper" v-if="connections">
<div
v-for="connection in connections"
v-bind:key="connection.uniqueId"
class="line"
v-tooltip="mvgTooltipConnection(connection)"
>
<div
class="departure"
>
<span class="time"
>
{{connection.parts[0].from.plannedDeparture | formatTime}}
</span>
<span class="delay"
:class="{'has-delay': connection.parts[0].from.departureDelayInMinutes > 0}"
>{{ Math.max(parseInt(connection.parts[0].from.departureDelayInMinutes) || 0, 0) }}</span>
</div>
<div
class="changes"
>
<template
v-for="(part,index) in connection.parts"
>
<span
v-if="index > 0"
v-bind:key="'change-' + index"
class="change"
v-tooltip="part.from.name"
></span>
<span
v-bind:key="'transport-' + index"
:class="['type-' + part.line.transportType,
'line-' + part.line.label,
]"
v-if="part.line.transportType != 'PEDESTRIAN'"
class="transport"
>{{part.line.label}}</span>
<span v-else
v-bind:key="'transport-' + index"
>🚶</span>
</template>
</div>
<span class="time">
{{Date.parse(connection.parts[connection.parts.length-1]
.to.plannedDeparture) - Date.parse(connection.parts[0]
.from.plannedDeparture) | formatDuration}}
</span>
</div>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
connections: null,
locationSearch: null,
connectionName: null,
defaultTitle: 'Connection',
locations: {
origin: undefined,
destination: undefined,
},
};
},
created() {
const promStart = this.getLocationId(this.start);
const promEnd = this.getLocationId(this.end);
Promise.all([promStart, promEnd]).then(
(results) => {
[this.locations.origin, this.locations.destination] = results.map((r) => r[0]);
this.defaultTitle = `${this.locations.origin.name} - ${this.locations.destination.name}`;
this.fetchData();
},
);
},
filters: {
formatDepartureTime(timestamp) {
const msDifference = new Date(timestamp).getTime() - new Date().getTime();
const diff = Math.max(0, Math.round(msDifference / 60000));
return diff;
},
formatTime(str) {
const d = new Date(Date.parse(str));
function ii(i) {
let s = `${i}`;
if (s.length < 2) s = `0${s}`;
return s;
}
return `${ii(d.getHours())}:${ii(d.getMinutes())}`;
},
formatDuration(val) {
function ii(i) {
let s = `${i}`;
if (s.length < 2) s = `0${s}`;
return s;
}
return `${Math.floor(val / 3600000)}:${ii(Math.floor(val / 60000))}`;
},
},
computed: {
start() {
return this.options.from || this.options.start || this.options.origin || 'Marienplatz';
},
end() {
return this.options.to || this.options.end || this.options.destination || 'Giesing';
},
title() {
if (this.options.title) {
return this.options.title;
}
return this.defaultTitle;
},
showTitle() {
return (this.options.header) ? this.options.header : true;
},
transportTypes() {
if (this.options.transportations) {
return this.options.transportations.join(',');
}
return 'UBAHN,TRAM,BUS,SBAHN';
},
},
methods: {
formatPoint(point, typ) {
if (point.type === 'ADDRESS' || point.type === 'POI') {
return `${typ}Latitude=${point.latitude}&${typ}Longitude=${point.longitude}`;
}
return `${typ}StationGlobalId=${point.globalId}`;
},
isLocationId(loc) {
if (!loc) {
this.error('Location is required');
}
if (typeof loc !== 'string') this.error('Location can only be a string');
return (loc.startsWith('de:09162:'));
},
getLocationId(loc) {
return this.makeRequest(this.getEndpointLocation(loc));
},
getEndpointLocation(loc) {
return `${widgetApiEndpoints.mvg}/location?query=${encodeURIComponent(loc)}`;
},
endpointConnection() {
return `${widgetApiEndpoints.mvg}/connection?${this.formatPoint(this.locations.origin, 'origin')}&${this.formatPoint(this.locations.destination, 'destination')}&routingDateTime=${(new Date()).toISOString()}&offsetInMinutes=${this.offset}&transportTypes=${this.transportTypes}`;
},
update() {
this.startLoading();
this.fetchData();
this.finishLoading();
},
fetchData() {
if (this.locations.origin !== undefined
&& this.locations.destination !== undefined) {
this.makeRequest(this.endpointConnection()).then(
(response) => { this.processData(response); },
);
}
},
/* Assign data variables to the returned data */
processData(data) {
this.connections = data;
},
ensure_array(value) {
if (typeof value === 'string') {
return [value];
}
return value;
},
mvgTooltipConnection(data) {
let connectionDetails = '';
const self = this;
function addStep(step) {
connectionDetails += `<b>${self.$options.filters.formatTime(step.plannedDeparture)}</b>
<span class="delay">+${Math.max(parseInt(step.departureDelayInMinutes, 10) || 0, 0)}</span>
<span>${step.name}</span>`;
}
addStep(data.parts[0].from);
data.parts.forEach((part) => {
addStep(part.to);
});
return {
content: connectionDetails, html: true, trigger: 'hover', delay: 250, classes: 'mvg-connection-detail',
};
},
},
};
</script>
<style scoped lang="scss">
.mvg-header {
color: var(--widget-text-color);
font-size:1.2em;
}
.mvg-wrapper {
display: grid;
justify-content: left;
grid-template-columns: 100%;
color: var(--widget-text-color);
padding: 0.25rem 0;
grid-row-gap: 0.4em;
.departure {
min-width: 1rem;
font-size: 1.1rem;
font-weight: bold;
text-align: right;
margin-right: 0.2rem;
span.live {
color: var(--success);
}
}
.line {
margin: 0em;
padding: 0em;
border-radius: 0.2em;
display: grid;
grid-template-columns: 2fr 5fr 0.75fr;
.changes {
text-align: center;
}
.type-UBAHN {
border: 0px;
}
.type-SBAHN {
border: 0px;
}
.type-BUS {
}
.type-TRAM {
background-color: #dd3d4d;
}
.transport{
border-radius: 0.2em;
margin: 0em;
padding: 0.15em 0.15em;
color: #FFFFFF;
margin-right: 0.40em;
margin-left: 0.40em;
text-align: center;
span {
min-width: 2em;
display: inline-block;
}
&.line-Fussweg {
text-indent: 100%;
white-space: nowrap;
overflow: hidden;
}
&.line-U1 {
background-color: #468447;
}
&.line-U2 {
background-color: #dd3d4d;
}
&.line-U3 {
background-color: #ef8824;
}
&.line-U4 {
background-color: #04af90;
}
&.line-U5 {
background-color: #b78730;
}
&.line-U6 {
background-color: #0472b3;
}
&.line-S1 {
background-color: #79c6e7;
}
&.line-S2 {
background-color: #9bc04c;
}
&.line-S3 {
background-color: #942d8d;
}
&.line-S4 {
background-color: #d4214d;
}
&.line-S5 {
background-color: #03a074;
}
&.line-S6 {
background-color: #03a074;
}
&.line-S7 {
background-color: #964438;
}
&.line-S8 {
background-color: #000000;
}
&.type-BUS {
background-color: #0d5c70;
}
}
.destination{
border-radius: 0.2em;
width: 100%;
background-color: #FFFFFF;
color: #000;
padding-top: 0.15em;
padding-bottom: 0.15em;
white-space: nowrap;
overflow: hidden;
span.cancelled {
color: var(--danger);
text-decoration: line-through;
}
span.destination {
overflow: clip;
margin-right: 0.25em;
width: 75%;
display: inline-block;
}
}
.delay{
padding: 0.15em;
font-weight: bold;
&.has-delay{
padding: 0.15em;
background-color: var(--danger);
color: #FFF;
border-radius: 0.2em;
}
}
.delay::before{
content: "+";
}
.occupancy{
display: inline-block;
padding: 0 0.15em;
border-radius: 0.2em;
&.occupancy-LOW {
color: green;
}
&.occupancy-MEDIUM {
color: orange;
}
&.occupancy-HIGH {
color: red;
}
}
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
</style>
<style lang="scss">
.ping-times-tt {
min-width: 20rem;
}
</style>
<style lang="scss">
.mvg-connection-detail .tooltip-inner {
min-width: 20rem;
display: grid;
grid-template-columns: 2fr 1fr 6fr;
}
</style>

View File

@@ -40,8 +40,12 @@ export default {
if (!usersChoice) this.error('You must specify the hostname for your Pi-Hole server');
return usersChoice || 'http://pi.hole';
},
apiKey() {
if (!this.options.apiKey) this.error('API Key is required, please see the docs');
return this.options.apiKey;
},
endpoint() {
return `${this.hostname}/admin/api.php`;
return `${this.hostname}/admin/api.php?summary&auth=${this.apiKey}`;
},
hideStatus() { return this.options.hideStatus; },
hideChart() { return this.options.hideChart; },
@@ -57,7 +61,11 @@ export default {
fetchData() {
this.makeRequest(this.endpoint)
.then((response) => {
this.processData(response);
if (Array.isArray(response)) {
this.error('Got success, but found no results, possible authorization error');
} else {
this.processData(response);
}
});
},
/* Assign data variables to the returned data */

View File

@@ -23,8 +23,12 @@ export default {
if (!usersChoice) this.error('You must specify the hostname for your Pi-Hole server');
return usersChoice || 'http://pi.hole';
},
apiKey() {
if (!this.options.apiKey) this.error('API Key is required, please see the docs');
return this.options.apiKey;
},
endpoint() {
return `${this.hostname}/admin/api.php?overTimeData10mins`;
return `${this.hostname}/admin/api.php?overTimeData10mins&auth=${this.apiKey}`;
},
},
methods: {
@@ -38,7 +42,9 @@ export default {
});
},
validate(response) {
if (!response.ads_over_time || !response.domains_over_time) {
if (Array.isArray(response)) {
this.error('Got success, but found no results, possible authorization error');
} else if (!response.ads_over_time || !response.domains_over_time) {
this.error('Expected data was not returned from Pi-Hole');
return false;
} else if (response.ads_over_time.length < 1) {

View File

@@ -0,0 +1,156 @@
<template>
<div class="proxmox-list">
<div class="proxmox-title" v-if="title">
<a v-if="titleAsLink" class="proxmox-link" :href="clusterUrl" target="_blank">
{{ title }}
</a>
<span v-if="!titleAsLink">{{ title }}</span>
</div>
<div v-for="(item, key) in data" :key="key" class="proxmox-row">
<div v-if="item.node" class="proxmox-cell">{{ item.node }}</div>
<div v-if="item.name" class="proxmox-cell">{{ item.name }}</div>
<div class="proxmox-cell proxmox-status"><span :class="item.status"></span></div>
</div>
<div class="proxmox-footer" v-if="footer">
<a v-if="footerAsLink" class="proxmox-link" :href="clusterUrl" target="_blank">
{{ footer }}
</a>
<span v-if="!footerAsLink">{{ title }}</span>
</div>
</div>
</template>
<script>
import WidgetMixin from '@/mixins/WidgetMixin';
export default {
mixins: [WidgetMixin],
components: {},
data() {
return {
data: [],
};
},
computed: {
clusterUrl() {
if (!this.options.cluster_url) this.error('The cluster URL is required.');
return this.options.cluster_url || '';
},
userName() {
if (!this.options.user_name) this.error('The user name is required.');
return this.options.user_name || '';
},
tokenName() {
if (!this.options.token_name) this.error('The token name is required.');
return this.options.token_name || '';
},
tokenUuid() {
if (!this.options.token_uuid) this.error('The token uuid is required.');
return this.options.token_uuid || '';
},
node() {
return this.options.node || '';
},
nodeData() {
return this.options.node_data || false;
},
hideTemplates() {
return this.options.hide_templates || false;
},
title() {
return this.options.title || '';
},
titleAsLink() {
return this.options.title_as_link || false;
},
footer() {
return this.options.footer || '';
},
footerAsLink() {
return this.options.footer_as_link || false;
},
endpoint() {
if (!this.node) {
return `${this.clusterUrl}/api2/json/nodes`;
}
if (this.nodeData) {
return `${this.clusterUrl}/api2/json/nodes/${this.node}/${this.nodeData}`;
}
return '';
},
authHeaders() {
if (this.userName && this.tokenName && this.tokenUuid) {
return { Authorization: `PVEAPIToken=${this.userName}!${this.tokenName}=${this.tokenUuid}` };
}
return false;
},
},
methods: {
fetchData() {
const auth = this.authHeaders;
if (auth) {
this.startLoading();
this.makeRequest(this.endpoint, auth).then(this.processData);
}
},
processData(data) {
this.data = data.data.sort((a, b) => a.vmid > b.vmid);
if (this.hideTemplates) {
this.data = this.data.filter(item => item.template !== 1);
}
this.finishLoading();
},
},
};
</script>
<style scoped lang="scss">
.proxmox-list {
.proxmox-title, .proxmox-footer {
outline: 2px solid transparent;
border: 1px solid var(--outline-color);
border-radius: var(--curve-factor);
box-shadow: var(--item-shadow);
color: var(--item-text-color);
margin: .5rem;
padding: 0.3rem;
background: var(--item-background);
text-align: center;
a {
text-decoration: none;
color: var(--item-text-color);
}
}
.proxmox-row {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--widget-text-color);
font-size: 1.1rem;
.proxmox-cell {
display: inline-block;
}
.proxmox-status{
.online, .running {
width: 0.8rem;
height: 0.8rem;
border-radius: 50%;
background-color: var(--success);
display: block;
}
}
.proxmox-link {
display: inline-block;
padding: 0.2rem;
margin: 0.1rem 0.2rem;
}
&:not(:last-child) {
border-bottom: 1px dashed var(--widget-text-color);
}
}
}
</style>

View File

@@ -54,10 +54,17 @@ export default {
const then = new Date((now.setMonth(now.getMonth() + this.monthsToShow)));
return `${then.getDate()}-${then.getMonth() + 1}-${then.getFullYear()}`;
},
region() {
if (this.options?.state) {
return `&region=${this.options.state}`;
}
return '';
},
endpoint() {
return `${widgetApiEndpoints.holidays}`
+ `&fromDate=${this.startDate}&toDate=${this.endDate}`
+ `&country=${this.country}&holidayType=${this.holidayType}`;
+ `&country=${this.country}&holidayType=${this.holidayType}`
+ `${this.region}`;
},
},
methods: {

View File

@@ -31,6 +31,7 @@
</template>
<script>
import * as Parser from 'rss-parser';
import WidgetMixin from '@/mixins/WidgetMixin';
import { widgetApiEndpoints } from '@/utils/defaults';
@@ -47,11 +48,14 @@ export default {
/* The URL to users atom-format RSS feed */
rssUrl() {
if (!this.options.rssUrl) this.error('Missing feed URL');
return encodeURIComponent(this.options.rssUrl || '');
return this.options.rssUrl || '';
},
apiKey() {
return this.options.apiKey;
},
parseLocally() {
return this.options.parseLocally;
},
limit() {
const usersChoice = this.options.limit;
if (usersChoice) return usersChoice;
@@ -73,8 +77,12 @@ export default {
const limit = this.limit && this.apiKey ? `&count=${this.limit}` : '';
const orderBy = this.orderBy && this.apiKey ? `&order_by=${this.orderBy}` : '';
const direction = this.orderDirection ? `&order_dir=${this.orderDirection}` : '';
return `${widgetApiEndpoints.rssToJson}?rss_url=${this.rssUrl}`
+ `${apiKey}${limit}${orderBy}${direction}`;
if (this.parseLocally) {
return this.rssUrl;
} else {
return `${widgetApiEndpoints.rssToJson}?rss_url=${encodeURIComponent(this.rssUrl)}`
+ `${apiKey}${limit}${orderBy}${direction}`;
}
},
},
filters: {
@@ -88,31 +96,53 @@ export default {
},
},
methods: {
/* Make GET request to Rss2Json */
/* Make GET request to whatever endpoint we are using */
fetchData() {
this.makeRequest(this.endpoint).then(this.processData);
},
/* Assign data variables to the returned data */
processData(data) {
const { feed, items } = data;
this.meta = {
title: feed.title,
link: feed.link,
author: feed.author,
description: feed.description,
image: feed.image,
};
async processData(data) {
if (this.parseLocally) {
const parser = new Parser();
const {
link, title, items, author, description, image,
} = await parser.parseString(data);
this.meta = {
title,
link,
author,
description,
image,
};
this.processItems(items);
} else {
const { feed, items } = data;
this.meta = {
title: feed.title,
link: feed.link,
author: feed.author,
description: feed.description,
image: feed.image,
};
this.processItems(items);
}
},
processItems(items) {
const posts = [];
items.forEach((post) => {
let { length } = items;
if (this.limit) {
length = this.limit;
}
for (let i = 0; length > i; i += 1) {
posts.push({
title: post.title,
description: post.description,
image: post.thumbnail,
author: post.author,
date: post.pubDate,
link: post.link,
title: items[i].title,
description: items[i].description,
image: items[i].thumbnail,
author: items[i].author,
date: items[i].pubDate,
link: items[i].link,
});
});
}
this.posts = posts;
},
},

View File

@@ -52,10 +52,12 @@ const COMPAT = {
clock: 'Clock',
'crypto-price-chart': 'CryptoPriceChart',
'crypto-watch-list': 'CryptoWatchList',
'custom-search': 'CustomSearch',
'cve-vulnerabilities': 'CveVulnerabilities',
'domain-monitor': 'DomainMonitor',
'code-stats': 'CodeStats',
'covid-stats': 'CovidStats',
'drone-ci': 'DroneCi',
embed: 'EmbedWidget',
'eth-gas-prices': 'EthGasPrices',
'exchange-rates': 'ExchangeRates',
@@ -82,6 +84,9 @@ const COMPAT = {
image: 'ImageWidget',
joke: 'Jokes',
'mullvad-status': 'MullvadStatus',
mvg: 'Mvg',
linkding: 'Linkding',
'mvg-connection': 'MvgConnection',
'nd-cpu-history': 'NdCpuHistory',
'nd-load-history': 'NdLoadHistory',
'nd-ram-history': 'NdRamHistory',
@@ -95,6 +100,7 @@ const COMPAT = {
'pi-hole-stats': 'PiHoleStats',
'pi-hole-top-queries': 'PiHoleTopQueries',
'pi-hole-traffic': 'PiHoleTraffic',
'proxmox-lists': 'Proxmox',
'public-holidays': 'PublicHolidays',
'public-ip': 'PublicIp',
'rss-feed': 'RssFeed',

View File

@@ -1,8 +1,8 @@
<template>
<div class="xkcd-wrapper">
<div class="xkcd-wrapper" v-tooltip="toolTip(alt)">
<h3 class="xkcd-title">{{ title }}</h3>
<a :href="`https://xkcd.com/${comicNum}/`">
<img :src="image" :alt="alt" class="xkcd-comic" />
<img :src="image" :alt="alt" class="xkcd-comic"/>
</a>
</div>
</template>
@@ -59,6 +59,12 @@ export default {
this.alt = data.alt;
this.comicNum = data.num;
},
toolTip(alt) {
const content = alt;
return {
content, html: false, trigger: 'hover focus', delay: 250, classes: 'xkcd-alt-tt',
};
},
},
};
</script>
@@ -80,3 +86,8 @@ export default {
}
</style>
<style lang="scss">
.xkcd-alt-tt {
min-width: 20rem;
}
</style>

View File

@@ -20,10 +20,10 @@
</div>
<!-- Show links for switching back to Home / Minimal views -->
<div class="switch-view-buttons">
<router-link to="/home">
<router-link to="/home/">
<IconHome class="view-icon" v-tooltip="$t('alternate-views.default')" />
</router-link>
<router-link to="/minimal">
<router-link to="/minimal/">
<IconMinimalView class="view-icon" v-tooltip="$t('alternate-views.minimal')" />
</router-link>
</div>

View File

@@ -1,6 +1,6 @@
<template>
<div class="web-content" :id="id">
<iframe :src="url" allow="fullscreen" />
<iframe :src="url" allow="fullscreen; clipboard-write" />
</div>
</template>