Adds an (optional) status check feature, plus some refactoring

This commit is contained in:
Alicia Sykes 2021-06-14 20:44:07 +01:00
parent 195d433f75
commit 0b1f66b7b7
8 changed files with 254 additions and 67 deletions

View File

@ -12,7 +12,7 @@ import Header from '@/components/PageStrcture/Header.vue';
import Footer from '@/components/PageStrcture/Footer.vue';
import LoadingScreen from '@/components/PageStrcture/LoadingScreen.vue';
import Defaults, { localStorageKeys, splashScreenTime } from '@/utils/defaults';
import conf from '../public/conf.yml';
import { config, appConfig, pageInfo } from '@/utils/ConfigAccumalator';
export default {
name: 'app',
@ -21,48 +21,18 @@ export default {
Footer,
LoadingScreen,
},
provide: {
config,
},
data() {
return {
// pageInfo: this.getPageInfo(conf.pageInfo),
showFooter: Defaults.visibleComponents.footer,
isLoading: true,
appConfig,
pageInfo,
};
},
computed: {
pageInfo() {
return this.getPageInfo(conf.pageInfo);
},
appConfig() {
if (localStorage[localStorageKeys.APP_CONFIG]) {
return JSON.parse(localStorage[localStorageKeys.APP_CONFIG]);
} else if (conf.appConfig) {
return conf.appConfig;
} else {
return Defaults.appConfig;
}
},
},
methods: {
/* Returns either page info from the config, or default values */
getPageInfo(pageInfo) {
const defaults = Defaults.pageInfo;
let localPageInfo;
try {
localPageInfo = JSON.parse(localStorage[localStorageKeys.PAGE_INFO]);
} catch (e) {
localPageInfo = {};
}
if (pageInfo) {
return {
title: localPageInfo.title || pageInfo.title || defaults.title,
description: localPageInfo.description || pageInfo.description || defaults.description,
navLinks: localPageInfo.navLinks || pageInfo.navLinks || defaults.navLinks,
footerText: localPageInfo.footerText || pageInfo.footerText || defaults.footerText,
};
}
return defaults;
},
getFooterText() {
if (this.pageInfo && this.pageInfo.footerText) {
return this.pageInfo.footerText;

View File

@ -20,12 +20,20 @@
<!-- Small icon, showing opening method on hover -->
<ItemOpenMethodIcon class="opening-method-icon" :isSmall="!icon" :openingMethod="target"
:position="itemSize === 'medium'? 'bottom right' : 'top right'"/>
<StatusIndicator
class="status-indicator"
v-if="enableStatusCheck"
:statusSuccess="statusResponse ? statusResponse.successStatus : undefined"
:statusText="statusResponse ? statusResponse.message : undefined"
/>
</a>
</template>
<script>
import axios from 'axios';
import Icon from '@/components/LinkItems/ItemIcon.vue';
import ItemOpenMethodIcon from '@/components/LinkItems/ItemOpenMethodIcon';
import StatusIndicator from '@/components/LinkItems/StatusIndicator';
export default {
name: 'Item',
@ -44,6 +52,7 @@ export default {
validator: (value) => ['newtab', 'sametab', 'iframe'].indexOf(value) !== -1,
},
itemSize: String,
enableStatusCheck: Boolean,
},
data() {
return {
@ -52,11 +61,13 @@ export default {
color: this.color,
background: this.backgroundColor,
},
statusResponse: undefined,
};
},
components: {
Icon,
ItemOpenMethodIcon,
StatusIndicator,
},
methods: {
/* Called when an item is clicked, manages the opening of iframe & resets the search field */
@ -88,9 +99,11 @@ export default {
trigger: 'hover focus',
hideOnTargetClick: true,
html: false,
placement: this.statusResponse ? 'left' : 'auto',
delay: { show: 600, hide: 200 },
};
},
/* Used by certain themes, which display an icon with animated CSS */
getUnicodeOpeningIcon() {
switch (this.target) {
case 'newtab': return '"\\f360"';
@ -99,9 +112,24 @@ export default {
default: return '"\\f054"';
}
},
checkWebsiteStatus() {
const baseUrl = process.env.VUE_APP_DOMAIN || window.location.origin;
const endpoint = `${baseUrl}/ping?url=${this.url}`;
axios.get(endpoint)
.then((response) => {
if (response.data) this.statusResponse = response.data;
})
.catch(() => {
this.statusResponse = {
statusText: 'Failed to make request',
statusSuccess: false,
};
});
},
},
mounted() {
this.manageTitleEllipse();
if (this.enableStatusCheck) this.checkWebsiteStatus();
},
};
</script>
@ -122,6 +150,7 @@ export default {
box-shadow: var(--item-shadow);
cursor: pointer;
text-decoration: none;
position: relative;
&:hover {
box-shadow: var(--item-hover-shadow);
background: var(--item-background-hover);
@ -175,6 +204,13 @@ export default {
}
}
/* Colored dot showing service status */
.status-indicator {
position: absolute;
top: 0;
right: 0;
}
.opening-method-icon {
display: none; // Hidden by default, visible on hover
}

View File

@ -28,6 +28,7 @@
:color="item.color"
:backgroundColor="item.backgroundColor"
:itemSize="newItemSize"
:enableStatusCheck="shouldEnableStatusCheck(item.statusCheck)"
@itemClicked="$emit('itemClicked')"
@triggerModal="triggerModal"
/>
@ -49,6 +50,7 @@ import IframeModal from '@/components/LinkItems/IframeModal.vue';
export default {
name: 'ItemGroup',
inject: ['config'],
props: {
groupId: String,
title: String,
@ -92,6 +94,10 @@ export default {
modalChanged(changedTo) {
this.$emit('change-modal-visibility', changedTo);
},
shouldEnableStatusCheck(itemPreference) {
const globalPreference = this.config.appConfig.statusCheck || false;
return itemPreference !== undefined ? itemPreference : globalPreference;
},
},
};
</script>

View File

@ -0,0 +1,122 @@
<template>
<div
v-tooltip="{
content: statusText || otherStatusText, classes: ['status-tooltip', `tip-${color()}`] }"
class="indicator"
@click="showToast()">
<div :class="`dot dot-${color()}`">
<span><span></span></span>
</div>
</div>
</template>
<script>
export default {
name: 'StatusIndicator',
props: {
statusText: String,
statusSuccess: Boolean,
},
methods: {
/* Returns a color, based on success status */
color() {
switch (this.statusSuccess) {
case undefined: return ((new Date() - this.startTime) > 2000) ? 'grey' : 'yellow';
case true: return 'green'; // Success!
default: return 'red'; // Not success, therefore failure
}
},
},
data() {
return {
startTime: new Date(), // Used for timeout
otherStatusText: 'Checking...', // Used before server has responded
};
},
mounted() {
setTimeout(() => {
if (!this.statusText) this.otherStatusText = 'Request timed out';
}, 2000);
},
};
</script>
<style scoped lang="scss">
.indicator {
padding: 5px;
transition: all .2s ease-in-out;
cursor: help;
&:hover {
transform: scale(1.25);
filter: saturate(2);
opacity: 1;
}
}
@keyframes pulse {
0% { opacity: .75; transform: scale(1); }
25% { opacity: 0.75; transform: scale(1); }
100% { opacity: 0; transform: scale(1.8); }
}
@keyframes applyOpacity {
50% { opacity: 0.9; }
to { opacity: 0.8; }
}
.dot {
border-radius: 50%;
height: 12px;
width: 12px;
animation: applyOpacity 1s ease-in 8s forwards;
> span, > span span, > span span:after {
animation: pulse 1s linear 0.5s 2;
border-radius: 50%;
display: block;
height: 12px;
width: 12px;
content: '';
}
&.dot-green {
background-color: var(--success);
span, span:after {
background-color: var(--success);
opacity: 0.4;
}
}
&.dot-red {
background-color: var(--danger);
span, span:after {
background-color: var(--danger);
opacity: 0.4;
}
}
&.dot-yellow {
background-color: var(--warning);
span, span:after {
background-color: var(--warning);
opacity: 0.4;
}
}
&.dot-grey {
background-color: var(--medium-grey);
span, span:after {
background-color: var(--medium-grey);
opacity: 0.4;
}
}
}
</style>
<style lang="scss">
.status-tooltip {
background: var(--background-darker) !important;
font-size: 1rem;
z-index: 10;
&.tip-green { border: 1px solid var(--success); }
&.tip-yellow { border: 1px solid var(--warning); }
&.tip-red { border: 1px solid var(--danger); }
}
</style>

View File

@ -1,12 +1,14 @@
import Vue from 'vue';
/* Import component Vue plugins, used throughout the app */
import VTooltip from 'v-tooltip'; // A Vue directive for Popper.js, tooltip component
import VModal from 'vue-js-modal'; // Modal component
import VSelect from 'vue-select'; // Select dropdown component
import VTabs from 'vue-material-tabs'; // Tab view component, used on the config page
import Toasted from 'vue-toasted'; // Toast component, used to show confirmation notifications
import { toastedOptions } from './utils/defaults';
import App from './App.vue';
import Dashy from './App.vue';
import router from './router';
import './registerServiceWorker';
@ -20,5 +22,5 @@ Vue.config.productionTip = false;
new Vue({
router,
render: (awesome) => awesome(App),
render: (awesome) => awesome(Dashy),
}).$mount('#app');

View File

@ -1,36 +1,15 @@
import Vue from 'vue';
import Router from 'vue-router';
import Home from './views/Home.vue';
import Login from './views/Login.vue';
import conf from '../public/conf.yml'; // Main site configuration
import { pageInfo as defaultPageInfo, localStorageKeys } from './utils/defaults';
import { isLoggedIn } from './utils/Auth';
import Home from '@/views/Home.vue';
import Login from '@/views/Login.vue';
import { isLoggedIn } from '@/utils/Auth';
import { appConfig, pageInfo, sections } from '@/utils/ConfigAccumalator';
Vue.use(Router);
const { sections, pageInfo, appConfig } = conf;
let localPageInfo;
try {
localPageInfo = JSON.parse(localStorage[localStorageKeys.PAGE_INFO]);
} catch (e) {
localPageInfo = undefined;
}
let localAppConfig;
try {
localAppConfig = JSON.parse(localStorage[localStorageKeys.APP_CONFIG]);
} catch (e) {
localAppConfig = undefined;
}
const config = {
sections: sections || [],
pageInfo: localPageInfo || pageInfo || defaultPageInfo,
appConfig: localAppConfig || appConfig || {},
};
const isAuthenticated = () => {
const users = config.appConfig.auth;
const users = appConfig.auth;
return (!users || isLoggedIn(users));
};
@ -40,7 +19,11 @@ const router = new Router({
path: '/',
name: 'home',
component: Home,
props: config,
props: {
appConfig,
pageInfo,
sections,
},
meta: {
title: pageInfo.title || 'Home Page',
metaTags: [
@ -56,7 +39,7 @@ const router = new Router({
name: 'login',
component: Login,
props: {
appConfig: config.appConfig,
appConfig,
},
beforeEnter: (to, from, next) => {
if (isAuthenticated()) router.push({ path: '/' });

View File

@ -0,0 +1,58 @@
/**
* Reads the users config from `conf.yml`, and combines it with any local preferences
* Also ensures that any missing attributes are populated with defaults, and the
* object is structurally sound, to avoid any error if the user is missing something
* The main config object is make up of three parts: appConfig, pageInfo and sections
*/
import Defaults, { localStorageKeys } from '@/utils/defaults';
import conf from '../../public/conf.yml';
export const appConfig = (() => {
if (localStorage[localStorageKeys.APP_CONFIG]) {
return JSON.parse(localStorage[localStorageKeys.APP_CONFIG]);
} else if (conf.appConfig) {
return conf.appConfig;
} else {
return Defaults.appConfig;
}
})();
export const pageInfo = (() => {
const defaults = Defaults.pageInfo;
let localPageInfo;
try {
localPageInfo = JSON.parse(localStorage[localStorageKeys.PAGE_INFO]);
} catch (e) {
localPageInfo = {};
}
const pi = conf.pageInfo || defaults; // The page info object to return
pi.title = localPageInfo.title || conf.pageInfo.title || defaults.title;
pi.description = localPageInfo.description || conf.pageInfo.description || defaults.description;
pi.navLinks = localPageInfo.navLinks || conf.pageInfo.navLinks || defaults.navLinks;
pi.footerText = localPageInfo.footerText || conf.pageInfo.footerText || defaults.footerText;
return pi;
})();
export const sections = (() => {
// If the user has stored sections in local storage, return those
const localSections = localStorage[localStorageKeys.CONF_SECTIONS];
if (localSections) {
try {
const json = JSON.parse(localSections);
if (json.length >= 1) return json;
} catch (e) {
// The data in local storage has been malformed, will return conf.sections instead
}
}
// If the function hasn't yet returned, then return the config file sections
return conf.sections;
})();
export const config = (() => {
const result = {
appConfig,
pageInfo,
sections,
};
return result;
})();

View File

@ -95,6 +95,11 @@
"default": false,
"description": "Display a loading screen when the app is launched"
},
"statusCheck": {
"type": "boolean",
"default": false,
"description": "Displays an online/ offline status for each of your services"
},
"auth": {
"type": "array",
"description": "Usernames and hashed credentials for frontend authentication",
@ -256,6 +261,11 @@
"provider": {
"type": "string",
"description": "Provider name, e.g. Microsoft"
},
"statusCheck": {
"type": "boolean",
"default": false,
"description": "Whether or not to display online/ offline status for this service. Will override appConfig.statusCheck"
}
}
}