461 lines
14 KiB
Vue
461 lines
14 KiB
Vue
<script setup>
|
|
|
|
import { ref, computed, useTemplateRef, onMounted, onUnmounted } from 'vue';
|
|
import { Button, ButtonGroup, SingleSelect, Icon, TableView, TextInput } from 'pankow';
|
|
import { API_ORIGIN, APP_TYPES, HSTATES, ISTATES, RSTATES } from '../constants.js';
|
|
import AppsModel from '../models/AppsModel.js';
|
|
import ApplinksModel from '../models/ApplinksModel.js';
|
|
import DomainsModel from '../models/DomainsModel.js';
|
|
import ProfileModel from '../models/ProfileModel.js';
|
|
import ApplinkDialog from '../components/ApplinkDialog.vue';
|
|
|
|
const appsModel = AppsModel.create();
|
|
const domainsModel = DomainsModel.create();
|
|
const applinksModel = ApplinksModel.create();
|
|
const profileModel = ProfileModel.create();
|
|
|
|
const VIEW_TYPE = {
|
|
LIST: 'list',
|
|
GRID: 'grid',
|
|
};
|
|
|
|
let refreshInterval;
|
|
|
|
const ready = ref(false);
|
|
const filter = ref('');
|
|
const profile = ref({});
|
|
const apps = ref([]);
|
|
const viewType = ref((localStorage.appsView && (localStorage.appsView === VIEW_TYPE.GRID || localStorage.appsView === VIEW_TYPE.LIST)) ? localStorage.appsView : VIEW_TYPE.GRID);
|
|
const tagFilter = ref('');
|
|
const tagFilterOptions = ref([{
|
|
id: '',
|
|
name: 'All Tags',
|
|
}]);
|
|
const domainFilter = ref('');
|
|
const domainFilterOptions = ref([{
|
|
id: '',
|
|
domain: 'All Domains',
|
|
}]);
|
|
const stateFilter = ref('');
|
|
const stateFilterOptions = [
|
|
{ id: '', label: 'All States' },
|
|
{ id: 'running', label: 'Running' },
|
|
{ id: 'stopped', label: 'Stopped' },
|
|
{ id: 'update_available', label: 'Update Available' },
|
|
{ id: 'not_responding', label: 'Not Responding' },
|
|
];
|
|
const listColumns = {
|
|
icon: {
|
|
width: '32px'
|
|
},
|
|
label: {
|
|
label: 'Label',
|
|
sort: true
|
|
},
|
|
domain: {
|
|
label: 'Location',
|
|
sort: true,
|
|
hideMobile: true,
|
|
},
|
|
status: {
|
|
label: 'Status',
|
|
hideMobile: true,
|
|
sort: (a, b) => {
|
|
// TODO we need pankow fix to pass full object a,b instead of just the property
|
|
if (!a || !b) return -1;
|
|
return a.installationState < b.installationState ? -1 : (a.installationState > b.installationState ? 1 : 0);
|
|
},
|
|
},
|
|
appTitle: {
|
|
label: 'App Title',
|
|
sort: true,
|
|
hideMobile: true,
|
|
},
|
|
sso: {
|
|
label: 'Login',
|
|
sort: true,
|
|
hideMobile: true,
|
|
},
|
|
actions: {}
|
|
};
|
|
|
|
const filteredApps = computed(() => {
|
|
return apps.value.filter(a => {
|
|
return a.fqdn.indexOf(filter.value) !== -1;
|
|
}).filter(a => {
|
|
if (!domainFilter.value) return true;
|
|
return a.domain === domainFilter.value;
|
|
}).filter(a => {
|
|
if (!tagFilter.value) return true;
|
|
return a.tags.indexOf(tagFilter.value) !== -1;
|
|
}).filter(a => {
|
|
if (!stateFilter.value) return true;
|
|
|
|
if (stateFilter.value === 'running') return a.runState === RSTATES.RUNNING && a.health === HSTATES.HEALTHY && a.installationState === ISTATES.INSTALLED;
|
|
if (stateFilter.value === 'stopped') return a.runState === RSTATES.STOPPED;
|
|
|
|
// TODO implement this
|
|
// if (stateFilter.value === 'update_available') return !!(Client.getConfig().update[a.id] && Client.getConfig().update[a.id].manifest.version && Client.getConfig().update[a.id].manifest.version !== a.manifest.version);
|
|
if (stateFilter.value === 'update_available') return false;
|
|
|
|
return a.runState === RSTATES.RUNNING && (a.health !== HSTATES.HEALTHY || a.installationState !== ISTATES.INSTALLED); // not responding
|
|
});
|
|
});
|
|
|
|
const installationStateLabel = AppsModel.installationStateLabel;
|
|
const installationActive = AppsModel.installationActive;
|
|
const appProgressMessage = AppsModel.appProgressMessage;
|
|
|
|
const applinkDialog = useTemplateRef('applinkDialog');
|
|
|
|
// hook for applinks otherwise it is a link
|
|
function openAppEdit(app, event) {
|
|
if (app.type === APP_TYPES.LINK) {
|
|
applinkDialog.value.open(app);
|
|
event.preventDefault();
|
|
}
|
|
|
|
event.stopPropagation();
|
|
}
|
|
|
|
function onOpenApp(app, event) {
|
|
function stopEvent() {
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
}
|
|
|
|
if (app.installationState !== ISTATES.INSTALLED) {
|
|
if (app.installationState === ISTATES.ERROR && isOperator(app)) window.location.href = `#/app/${app.id}/repair`;
|
|
return stopEvent();
|
|
}
|
|
|
|
// app.health can also be null to indicate insufficient data
|
|
if (!app.health) return stopEvent();
|
|
if (app.runState === RSTATES.STOPPED) return stopEvent();
|
|
|
|
if (app.health === HSTATES.UNHEALTHY || app.health === HSTATES.ERROR || app.health === HSTATES.DEAD) {
|
|
if (isOperator(app)) window.location.href = `#/app/${app.id}/repair`;
|
|
return stopEvent();
|
|
}
|
|
|
|
// TODO
|
|
// if (app.pendingPostInstallConfirmation && $scope.appPostInstallConfirm) {
|
|
// $scope.appPostInstallConfirm.show(app);
|
|
// return stopEvent();
|
|
// }
|
|
}
|
|
|
|
function isOperator(app) {
|
|
return app.accessLevel === 'operator' || app.accessLevel === 'admin';
|
|
}
|
|
|
|
async function refreshApps() {
|
|
const [error, result] = await appsModel.list();
|
|
if (error) return console.error(error);
|
|
|
|
const [applinkError, applinks] = await applinksModel.list();
|
|
if (applinkError) return console.error(applinkError);
|
|
|
|
// amend properties to mimick full app
|
|
for (const applink of applinks) {
|
|
applink.type = APP_TYPES.LINK;
|
|
applink.fqdn = applink.upstreamUri.replace('https://', '');
|
|
applink.manifest = { addons: {}};
|
|
applink.installationState = ISTATES.INSTALLED;
|
|
applink.runState = RSTATES.RUNNING;
|
|
applink.health = HSTATES.HEALTHY;
|
|
applink.iconUrl = `${API_ORIGIN}/api/v1/applinks/${applink.id}/icon?access_token=${localStorage.token}&ts=${applink.ts}`;
|
|
applink.accessLevel = profile.value.isAtLeastAdmin ? 'admin' : 'user';
|
|
|
|
result.push(applink);
|
|
}
|
|
|
|
apps.value = result;
|
|
|
|
// gets all tags used by all apps, flattens the arrays and new Set() will dedupe
|
|
const tags = [...new Set(apps.value.map(a => a.tags).flat())].map(t => { return { id: t, name: t }; });
|
|
tagFilterOptions.value = [{ id: '', name: 'All Tags', }].concat(tags);
|
|
}
|
|
|
|
function toggleView() {
|
|
viewType.value = viewType.value === VIEW_TYPE.LIST ? VIEW_TYPE.GRID : VIEW_TYPE.LIST;
|
|
localStorage.appsView = viewType.value;
|
|
}
|
|
|
|
onMounted(async () => {
|
|
let [error, result] = await profileModel.get();
|
|
if (error) return console.error(error);
|
|
|
|
profile.value = result;
|
|
|
|
await refreshApps();
|
|
|
|
[error, result] = await domainsModel.list();
|
|
if (error) return console.error(error);
|
|
|
|
domainFilterOptions.value = domainFilterOptions.value.concat(result.map(d => { d.id = d.domain; return d; }));
|
|
domainFilter.value = domainFilterOptions.value[0].id;
|
|
|
|
stateFilter.value = stateFilterOptions[0].id;
|
|
tagFilter.value = tagFilterOptions.value[0].id;
|
|
|
|
ready.value = true;
|
|
|
|
refreshInterval = setInterval(refreshApps, 5000);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
clearInterval(refreshInterval);
|
|
});
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div class="content">
|
|
<ApplinkDialog ref="applinkDialog" @success="refreshApps()"/>
|
|
|
|
<h1 class="view-header">
|
|
{{ $t('apps.title') }}
|
|
<div style="display: flex; gap: 4px; flex-wrap: wrap; margin-top: 10px;">
|
|
<TextInput v-model="filter" :placeholder="$t('apps.searchPlaceholder')" />
|
|
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :options="tagFilterOptions" option-key="id" option-label="name" v-model="tagFilter" />
|
|
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :options="stateFilterOptions" option-key="id" v-model="stateFilter" />
|
|
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :options="domainFilterOptions" option-key="id" option-label="domain" v-model="domainFilter" />
|
|
<Button tool outline secondary @click="toggleView()" :icon="viewType === VIEW_TYPE.GRID ? 'fas fa-list' : 'fas fa-grip'"></Button>
|
|
</div>
|
|
</h1>
|
|
|
|
<div v-show="ready">
|
|
<TransitionGroup name="grid-animation" tag="div" class="grid" v-if="viewType === VIEW_TYPE.GRID">
|
|
<a v-for="app in filteredApps" :key="app.id" class="grid-item" @click="onOpenApp(app, $event)" :href="'https://' + app.fqdn" target="_blank">
|
|
<a class="config" v-show="isOperator(app)" @click="openAppEdit(app, $event)" :href="`#/app/${app.id}/info`"><Icon icon="fa-solid fa-cog" /></a>
|
|
<img :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
|
|
<div class="grid-item-label">{{ app.label || app.subdomain || app.fqdn }}</div>
|
|
<div class="grid-item-task-label">{{ installationStateLabel(app) }}</div>
|
|
<div class="apps-progress" v-show="app.progress && isOperator(app)">
|
|
<div class="apps-progress-filled" :style="{ width: app.progress+'%' }"></div>
|
|
</div>
|
|
</a>
|
|
</TransitionGroup>
|
|
|
|
<div class="list" v-if="viewType === VIEW_TYPE.LIST">
|
|
<TableView :columns="listColumns" :model="filteredApps">
|
|
<template #icon="app">
|
|
<a :href="'https://' + app.fqdn" target="_blank">
|
|
<img class="list-icon" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
|
|
</a>
|
|
</template>
|
|
<template #label="app">
|
|
<a :href="'https://' + app.fqdn" target="_blank">
|
|
{{ app.label || app.subdomain || app.fqdn }}
|
|
</a>
|
|
</template>
|
|
<template #appTitle="app">
|
|
{{ app.manifest.title }}
|
|
</template>
|
|
<template #domain="app">
|
|
<a :href="'https://' + app.fqdn" target="_blank">
|
|
{{ app.fqdn }}
|
|
</a>
|
|
</template>
|
|
<template #status="app">
|
|
<div class="list-status">
|
|
{{ installationStateLabel(app) }}
|
|
<div class="apps-progress" v-show="app.progress && isOperator(app)">
|
|
<div class="apps-progress-filled" :style="{ width: app.progress+'%' }"></div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #sso="app">
|
|
<div v-show="app.type !== APP_TYPES.LINK">
|
|
<Icon icon="fa-brands fa-openid" v-show="app.ssoAuth && app.manifest.addons.oidc" v-tooltip="$t('apps.auth.openid')" />
|
|
<Icon icon="fas fa-user" v-show="app.ssoAuth && (!app.manifest.addons.oidc && !app.manifest.addons.email)" v-tooltip="$t('apps.auth.sso')" />
|
|
<Icon icon="far fa-user" v-show="!app.ssoAuth && !app.manifest.addons.email" v-tooltip="$t('apps.auth.nosso')" />
|
|
<Icon icon="fas fa-envelope" v-show="app.manifest.addons.email" v-tooltip="$t('apps.auth.email')" />
|
|
</div>
|
|
</template>
|
|
<template #actions="app">
|
|
<div class="table-actions">
|
|
<ButtonGroup>
|
|
<Button small secondary tool v-if="app.type !== APP_TYPES.LINK" :href="'/logs.html?appId=' + app.id" target="_blank" v-tooltip="$t('app.logsActionTooltip')" icon="fas fa-align-left"></Button>
|
|
<Button small secondary tool v-if="app.type !== APP_TYPES.PROXIED && app.type !== APP_TYPES.LINK" :href="'/terminal.html?id=' + app.id" target="_blank" v-tooltip="$t('app.terminalActionTooltip')" icon="fa fa-terminal"></Button>
|
|
<Button small secondary tool v-if="app.manifest.addons.localstorage" :href="'/filemanager.html#/home/app/' + app.id" target="_blank" v-tooltip="$t('app.filemanagerActionTooltip')" icon="fas fa-folder"></Button>
|
|
</ButtonGroup>
|
|
|
|
<Button small secondary tool @click="openAppEdit(app, $event)" :href="`#/app/${app.id}/info`" icon="fa-solid fa-cog"></Button>
|
|
</div>
|
|
</template>
|
|
</TableView>
|
|
</div>
|
|
|
|
<div class="empty-placeholder" v-if="apps.length === 0">
|
|
<!-- admins or not-->
|
|
<div v-if="profile.isAtLeastAdmin">
|
|
<h4>{{ $t('apps.noApps.title') }}</h4>
|
|
<h5 v-html="$t('apps.noApps.description', { appStoreLink: '#/appstore' })"></h5>
|
|
</div>
|
|
<div v-else>
|
|
<h4>{{ $t('apps.noAccess.title') }}</h4>
|
|
<h5>{{ $t('apps.noAccess.description') }}</h5>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
|
|
.grid-animation-move,
|
|
.grid-animation-enter-active,
|
|
.grid-animation-leave-active {
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.grid-animation-enter-from {
|
|
opacity: 0;
|
|
transform: translateY(30px);
|
|
}
|
|
|
|
.grid-animation-leave-to {
|
|
opacity: 0;
|
|
transform: translateY(-30px);
|
|
}
|
|
|
|
.list-icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
}
|
|
|
|
.list-status {
|
|
position: relative;
|
|
height: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.grid-item-task-label {
|
|
opacity: 0.7;
|
|
font-size: 12px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
color: var(--pankow-text-color);
|
|
}
|
|
|
|
.apps-progress {
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 5px;
|
|
text-align: center;
|
|
border-radius: 10px;
|
|
color: var(--pankow-text-color);
|
|
}
|
|
|
|
.apps-progress-filled {
|
|
background-color: var(--pankow-color-primary);
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
height: 100%;
|
|
border-radius: 10px;
|
|
z-index: 1;
|
|
background-image: linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);
|
|
background-size: 40px 40px;
|
|
animation: apps-progress-bar-stripes 1s linear infinite;
|
|
transition: width 300ms;
|
|
}
|
|
|
|
@keyframes apps-progress-bar-stripes {
|
|
from {
|
|
background-position: 40px 0;
|
|
}
|
|
to {
|
|
background-position: 0 0;
|
|
}
|
|
}
|
|
|
|
.grid {
|
|
display: flex;
|
|
height: 100%;
|
|
width: 100%;
|
|
transition: 300ms;
|
|
flex-wrap: wrap;
|
|
justify-content: start;
|
|
align-content: start;
|
|
}
|
|
|
|
.grid-item {
|
|
position: relative;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
width: 190px;
|
|
height: 180px;
|
|
margin: 10px;
|
|
overflow: hidden;
|
|
border-radius: 10px;
|
|
background-color: var(--card-background);
|
|
}
|
|
|
|
.grid-item:focus,
|
|
.grid-item:hover {
|
|
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1);
|
|
background-color: var(--pankow-color-background-hover) !important;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.grid-item img {
|
|
width: 80px;
|
|
height: 80px;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.grid-item-label {
|
|
font-size: 18px;
|
|
font-weight: 100;
|
|
margin: 5px 0 5px 0;
|
|
color: var(--pankow-text-color);
|
|
}
|
|
|
|
.config {
|
|
position: absolute;
|
|
color: var(--pankow-text-color);
|
|
font-size: 18px;
|
|
cursor: pointer;
|
|
width: 50px;
|
|
height: 50px;
|
|
border-top-right-radius: 10px;
|
|
right: 0;
|
|
top: 0;
|
|
opacity: 0;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
}
|
|
|
|
.config:focus,
|
|
.config:hover {
|
|
opacity: 1;
|
|
}
|
|
|
|
@media (hover: none) {
|
|
.config {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.grid-item:focus .config,
|
|
.grid-item:hover .config {
|
|
opacity: 1;
|
|
}
|
|
|
|
.empty-placeholder {
|
|
font-size: 18px;
|
|
margin: 10px;
|
|
}
|
|
|
|
</style>
|