Files
cloudron-box/dashboard/src/views/AppsView.vue

535 lines
17 KiB
Vue
Raw Normal View History

2025-02-03 14:50:06 +01:00
<script setup>
2024-12-29 00:36:48 +01:00
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, computed, useTemplateRef, onMounted, onUnmounted, inject } from 'vue';
import { Button, Menu, SingleSelect, Icon, TableView, TextInput, ProgressBar } from '@cloudron/pankow';
import { API_ORIGIN, APP_TYPES, HSTATES, ISTATES, RSTATES } from '../constants.js';
2024-12-29 00:36:48 +01:00
import AppsModel from '../models/AppsModel.js';
2025-01-03 15:06:41 +01:00
import ApplinksModel from '../models/ApplinksModel.js';
2025-01-05 12:52:11 +01:00
import DomainsModel from '../models/DomainsModel.js';
2025-01-21 17:08:09 +01:00
import ApplinkDialog from '../components/ApplinkDialog.vue';
2025-04-23 15:32:42 +02:00
import PostInstallDialog from '../components/PostInstallDialog.vue';
2024-12-29 00:36:48 +01:00
2025-01-31 21:02:48 +01:00
const appsModel = AppsModel.create();
const domainsModel = DomainsModel.create();
const applinksModel = ApplinksModel.create();
2024-12-29 00:36:48 +01:00
2025-01-02 13:21:59 +01:00
const VIEW_TYPE = {
LIST: 'list',
GRID: 'grid',
};
let refreshInterval;
2025-02-03 14:50:06 +01:00
const ready = ref(false);
const filter = ref('');
const profile = inject('profile');
2025-02-03 14:50:06 +01:00
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'
2024-12-29 00:36:48 +01:00
},
2025-02-03 14:50:06 +01:00
label: {
label: 'Label',
2025-04-23 10:54:15 +02:00
sort: (a, b, fullA, fullB) => {
if (!fullA || !fullA) return -1;
const checkA = fullA.label || fullA.subdomain || fullA.fqdn;
const checkB = fullB.label || fullB.subdomain || fullB.fqdn;
return checkA < checkB ? -1 : (checkA > checkB ? 1 : 0);
},
2024-12-29 00:36:48 +01:00
},
2025-04-23 10:54:15 +02:00
fqdn: {
2025-02-03 14:50:06 +01:00
label: 'Location',
2025-03-26 16:04:58 +01:00
sort: true,
hideMobile: true,
2024-12-29 19:19:03 +01:00
},
2025-03-31 22:28:17 +02:00
status: {
label: 'Status',
hideMobile: true,
2025-04-23 10:54:15 +02:00
sort: (a, b, fullA, fullB) => {
if (!fullA || !fullA) return -1;
const checkA = fullA.installationState + '-' + fullA.runState + '-' + fullA.health;
const checkB = fullB.installationState + '-' + fullB.runState + '-' + fullB.health;
return checkA < checkB ? -1 : (checkA > checkB ? 1 : 0);
2025-03-31 22:28:17 +02:00
},
},
2025-02-03 14:50:06 +01:00
appTitle: {
label: 'App Title',
2025-03-26 16:04:58 +01:00
hideMobile: true,
2025-04-23 10:54:15 +02:00
sort: (a, b, fullA, fullB) => {
if (!fullA || !fullA) return -1;
const checkA = fullA.manifest.title;
const checkB = fullB.manifest.title;
return checkA < checkB ? -1 : (checkA > checkB ? 1 : 0);
},
2024-12-29 00:36:48 +01:00
},
2025-02-03 14:50:06 +01:00
sso: {
label: 'Login',
2025-03-26 16:04:58 +01:00
hideMobile: true,
2025-04-23 10:54:15 +02:00
sort: (a, b, fullA, fullB) => {
if (!fullA || !fullA) return -1;
const checkA = fullA.type === APP_TYPES.LINK ? 0 : (fullA.ssoAuth && fullA.manifest.addons.oidc ? 1 : (fullA.ssoAuth && (!fullA.manifest.addons.oidc && !fullA.manifest.addons.email) ? 2 : (!fullA.ssoAuth && !fullA.manifest.addons.email ? 3 : 4)));
const checkB = fullB.type === APP_TYPES.LINK ? 0 : (fullB.ssoAuth && fullB.manifest.addons.oidc ? 1 : (fullB.ssoAuth && (!fullB.manifest.addons.oidc && !fullB.manifest.addons.email) ? 2 : (!fullB.ssoAuth && !fullB.manifest.addons.email ? 3 : 4)));
return checkA - checkB;
},
2025-02-03 14:50:06 +01:00
},
2025-05-20 10:39:37 +02:00
checklist: {},
2025-02-03 14:50:06 +01:00
actions: {}
};
2025-01-03 15:06:41 +01:00
const actionMenuModel = ref([]);
const actionMenuElement = useTemplateRef('actionMenuElement');
function onActionMenu(app, event) {
actionMenuModel.value = [{
icon: 'fa-solid fa-arrow-up',
label: t('app.updateAvailableTooltip'),
visible: !!app.updateInfo,
href: `#/app/${app.id}/updates`,
}, {
separator: true,
visible: !!app.updateInfo,
}, {
icon: 'fa-solid fa-align-left',
label: t('app.logsActionTooltip'),
visible: app.type !== APP_TYPES.LINK,
target: '_blank',
href: '/logs.html?appId=' + app.id,
}, {
icon: 'fa-solid fa-terminal',
label: t('app.terminalActionTooltip'),
visible: app.type !== APP_TYPES.PROXIED && app.type !== APP_TYPES.LINK,
target: '_blank',
href: '/terminal.html?id=' + app.id,
}, {
icon: 'fa-solid fa-folder',
label: t('app.filemanagerActionTooltip'),
visible: !!app.manifest?.addons?.localstorage,
target: '_blank',
href: '/filemanager.html#/home/app/' + app.id,
}, {
icon: 'fa-solid fa-cog',
label: t('app.configureTooltip'),
href: `#/app/${app.id}/info`,
}];
actionMenuElement.value.open(event, event.currentTarget);
}
2025-02-03 14:50:06 +01:00
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;
if (stateFilter.value === 'update_available') return a.updateInfo;
2025-02-03 14:50:06 +01:00
return a.runState === RSTATES.RUNNING && (a.health !== HSTATES.HEALTHY || a.installationState !== ISTATES.INSTALLED); // not responding
});
});
const applinkDialog = useTemplateRef('applinkDialog');
2025-04-23 15:32:42 +02:00
const postInstallDialog = useTemplateRef('postInstallDialog');
2025-02-03 14:50:06 +01:00
2025-02-17 13:33:47 +01:00
// hook for applinks otherwise it is a link
function openAppEdit(app, event) {
if (app.type === APP_TYPES.LINK) {
applinkDialog.value.open(app);
event.preventDefault();
}
2025-02-21 20:58:43 +01:00
event.stopPropagation();
2025-02-03 14:50:06 +01:00
}
function onOpenApp(app, event) {
function stopEvent() {
event.stopPropagation();
event.preventDefault();
}
2025-02-03 14:50:06 +01:00
if (app.installationState !== ISTATES.INSTALLED) {
if (app.installationState === ISTATES.ERROR && isOperator(app)) window.location.href = `#/app/${app.id}/repair`;
return stopEvent();
}
2025-01-05 12:52:11 +01:00
2025-02-03 14:50:06 +01:00
// app.health can also be null to indicate insufficient data
if (!app.health) return stopEvent();
if (app.runState === RSTATES.STOPPED) return stopEvent();
2025-01-05 12:52:11 +01:00
2025-02-03 14:50:06 +01:00
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();
}
2025-01-02 13:21:59 +01:00
2025-04-23 15:32:42 +02:00
if (localStorage['confirmPostInstall_' + app.id]) {
postInstallDialog.value.open(app, true);
return stopEvent();
}
2025-02-03 14:50:06 +01:00
}
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);
2025-02-03 14:50:06 +01:00
// amend properties to mimick full app
for (const applink of applinks) {
applink.type = APP_TYPES.LINK;
2025-03-10 16:19:55 +01:00
applink.fqdn = applink.upstreamUri.replace('https://', '');
2025-02-03 14:50:06 +01:00
applink.manifest = { addons: {}};
applink.installationState = ISTATES.INSTALLED;
applink.runState = RSTATES.RUNNING;
applink.health = HSTATES.HEALTHY;
2025-03-01 11:44:38 +01:00
applink.iconUrl = `${API_ORIGIN}/api/v1/applinks/${applink.id}/icon?access_token=${localStorage.token}&ts=${applink.ts}`;
2025-02-03 14:50:06 +01:00
applink.accessLevel = profile.value.isAtLeastAdmin ? 'admin' : 'user';
result.push(applink);
2024-12-29 00:36:48 +01:00
}
2025-02-03 14:50:06 +01:00
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 () => {
await refreshApps();
const [error, result] = await domainsModel.list();
2025-02-03 14:50:06 +01:00
if (error) return console.error(error);
domainFilterOptions.value = domainFilterOptions.value.concat(result.map(d => { d.id = d.domain; return d; }));
2025-02-03 14:50:06 +01:00
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);
});
2024-12-29 00:36:48 +01:00
</script>
<template>
<div class="content">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<ApplinkDialog ref="applinkDialog" @success="refreshApps"/>
2025-04-23 15:32:42 +02:00
<PostInstallDialog ref="postInstallDialog"/>
<h1 class="view-header">
{{ $t('apps.title') }}
<div style="display: flex; gap: 4px; flex-wrap: wrap; margin-top: 10px;">
2025-03-26 10:32:37 +01:00
<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-if="!ready">
<ProgressBar mode="indeterminate" :show-label="false" :slim="true"/>
</div>
<div v-else>
<TransitionGroup name="grid-animation" tag="div" class="grid" v-if="viewType === VIEW_TYPE.GRID">
2025-01-31 23:10:30 +01:00
<a v-for="app in filteredApps" :key="app.id" class="grid-item" @click="onOpenApp(app, $event)" :href="'https://' + app.fqdn" target="_blank">
<img :alt="app.label || app.subdomain || app.fqdn" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
<div class="grid-item-label" v-fit-text>{{ app.label || app.subdomain || app.fqdn }}</div>
2025-05-20 10:19:21 +02:00
<div class="grid-item-task-label">{{ AppsModel.installationStateLabel(app) }}</div>
<ProgressBar v-if="app.progress && isOperator(app)" :busy="true" :value="Math.max(10, app.progress)" :show-label="false" class="apps-progress"/>
<a class="config" v-show="isOperator(app)" @click="openAppEdit(app, $event)" :href="`#/app/${app.id}/info`" :title="$t('app.configureTooltip')"><Icon icon="fa-solid fa-cog" /></a>
2025-05-20 10:39:37 +02:00
<div class="grid-item-indictors">
<a class="grid-item-update-indicator" v-if="app.updateInfo" @click.stop :href="isOperator(app) ? `#/app/${app.id}/updates` : null" v-tooltip="$t('app.updateAvailableTooltip')"><i class="fa-fw fa-solid fa-arrow-up"/></a>
2025-05-20 10:39:37 +02:00
<a class="grid-item-checklist-indicator" v-if="AppsModel.pendingChecklistItems(app)" @click.stop :href="isOperator(app) ? `#/app/${app.id}/info` : null"><Icon icon="fa-solid fa-triangle-exclamation"/></a>
</div>
</a>
</TransitionGroup>
<div class="list" v-if="viewType === VIEW_TYPE.LIST">
2025-01-31 23:10:30 +01:00
<TableView :columns="listColumns" :model="filteredApps">
2025-02-17 13:33:47 +01:00
<template #icon="app">
<a :href="'https://' + app.fqdn" target="_blank">
<img :alt="app.label || app.subdomain || app.fqdn" class="list-icon" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
</a>
</template>
2025-02-17 13:33:47 +01:00
<template #label="app">
<a :href="'https://' + app.fqdn" target="_blank">
{{ app.label || app.subdomain || app.fqdn }}
</a>
</template>
2025-02-17 13:33:47 +01:00
<template #appTitle="app">
{{ app.manifest.title }}
</template>
2025-04-23 10:54:15 +02:00
<template #fqdn="app">
2025-02-17 13:33:47 +01:00
<a :href="'https://' + app.fqdn" target="_blank">
{{ app.fqdn }}
</a>
</template>
2025-02-17 13:33:47 +01:00
<template #status="app">
<div class="list-status">
2025-05-20 10:19:21 +02:00
{{ AppsModel.installationStateLabel(app) }}
<ProgressBar v-if="app.progress && isOperator(app)" :busy="true" :value="Math.max(10, app.progress)" :show-label="false" class="apps-progress"/>
</div>
</template>
2025-05-20 10:39:37 +02:00
<template #checklist="app">
<a class="list-item-checklist-indicator" v-if="AppsModel.pendingChecklistItems(app)" :href="`#/app/${app.id}/info`"><Icon icon="fa-solid fa-triangle-exclamation"/></a>
</template>
2025-02-17 13:33:47 +01:00
<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>
2025-02-17 13:33:47 +01:00
<template #actions="app">
<div style="text-align: right;">
<Button tool plain secondary @click.capture="onActionMenu(app, $event)" icon="fa-solid fa-ellipsis" />
</div>
</template>
</TableView>
</div>
<div class="empty-placeholder" v-if="apps.length === 0">
2025-04-08 14:23:54 +02:00
<!-- 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>
2025-04-08 14:23:54 +02:00
<div v-else>
<h4>{{ $t('apps.noAccess.title') }}</h4>
<h5>{{ $t('apps.noAccess.description') }}</h5>
</div>
</div>
</div>
</div>
</template>
2024-12-29 00:36:48 +01:00
<style scoped>
2025-01-02 13:21:59 +01:00
.grid-animation-move,
2024-12-29 19:19:03 +01:00
.grid-animation-enter-active,
.grid-animation-leave-active {
transition: all 0.2s ease;
}
2025-01-02 13:21:59 +01:00
.grid-animation-enter-from {
opacity: 0;
transform: translateY(30px);
}
2024-12-29 19:19:03 +01:00
.grid-animation-leave-to {
opacity: 0;
2025-01-02 13:21:59 +01:00
transform: translateY(-30px);
}
.list-icon {
width: 32px;
height: 32px;
}
2025-01-05 13:33:05 +01:00
.list-status {
2025-01-02 19:04:07 +01:00
position: relative;
2025-01-05 13:33:05 +01:00
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: 4px;
2025-01-02 19:04:07 +01:00
}
2024-12-29 00:36:48 +01:00
.grid {
display: flex;
height: 100%;
width: 100%;
transition: 300ms;
flex-wrap: wrap;
justify-content: start;
align-content: start;
}
2025-01-02 13:21:59 +01:00
.grid-item {
2024-12-29 00:36:48 +01:00
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);
}
2025-01-02 13:21:59 +01:00
.grid-item:focus,
.grid-item:hover {
2024-12-29 00:36:48 +01:00
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1);
background-color: var(--pankow-color-background-hover) !important;
2024-12-29 00:36:48 +01:00
text-decoration: none;
}
2025-01-02 13:21:59 +01:00
.grid-item img {
2024-12-29 00:36:48 +01:00
width: 80px;
height: 80px;
object-fit: cover;
2024-12-29 00:36:48 +01:00
}
2025-01-02 19:04:07 +01:00
.grid-item-label {
2024-12-29 00:36:48 +01:00
font-size: 18px;
font-weight: 100;
2025-01-02 19:04:07 +01:00
margin: 5px 0 5px 0;
2024-12-29 00:36:48 +01:00
color: var(--pankow-text-color);
text-wrap: nowrap;
2024-12-29 00:36:48 +01:00
}
.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;
color: var(--pankow-color-primary-hover);
2024-12-29 00:36:48 +01:00
}
@media (hover: none) {
.config {
opacity: 1;
}
}
2025-01-02 13:21:59 +01:00
.grid-item:focus .config,
.grid-item:hover .config {
2024-12-29 00:36:48 +01:00
opacity: 1;
}
2025-05-20 10:39:37 +02:00
.grid-item-indictors {
position: absolute;
2025-05-20 10:39:37 +02:00
left: 0;
top: 0;
}
.grid-item-update-indicator {
color: var(--pankow-color-success);
font-size: 18px;
2025-05-20 10:39:37 +02:00
width: 40px;
height: 40px;
border-top-right-radius: 10px;
display: flex;
justify-content: center;
align-items: center;
}
2025-05-20 10:39:37 +02:00
.grid-item-update-indicator:focus,
.grid-item-update-indicator:hover {
opacity: 1;
color: var(--pankow-color-success-hover);
}
2025-05-20 10:39:37 +02:00
.grid-item-checklist-indicator {
color: var(--pankow-color-danger);
border-radius: 50px;
font-size: 18px;
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
}
.list-item-checklist-indicator {
color: var(--pankow-color-danger);
}
.list-item-checklist-indicator:focus,
.list-item-checklist-indicator:hover,
.grid-item-checklist-indicator:focus,
.grid-item-checklist-indicator:hover {
color: var(--pankow-color-danger-hover);
}
.empty-placeholder {
2025-04-08 14:23:54 +02:00
font-size: 18px;
margin: 10px;
}
2024-12-29 00:36:48 +01:00
</style>