Files
cloudron-box/dashboard/src/components/app/Location.vue
T
2025-08-07 21:05:40 +02:00

259 lines
10 KiB
Vue

<script setup>
import { ref, onMounted, computed } from 'vue';
import { Button, SingleSelect, InputGroup, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
import { isValidDomain } from '@cloudron/pankow/utils';
import { ISTATES } from '../../constants.js';
import PortBindings from '../PortBindings.vue';
import AppsModel from '../../models/AppsModel.js';
import DomainsModel from '../../models/DomainsModel.js';
const props = defineProps([ 'app' ]);
const appsModel = AppsModel.create();
const domainsModel = DomainsModel.create();
const domains = ref([]);
const busy = ref(false);
const errorMessage = ref('');
const errorObject = ref({});
const overwriteDns = ref(false);
const needsOverwriteDns = ref(false);
const domain = ref('');
const subdomain = ref('');
const secondaryDomains = ref({});
const aliases = ref([]);
const redirects =ref([]);
const tcpPorts = ref({});
const udpPorts = ref({});
const domainProvider = computed(() => {
const tmp = domains.value.find((d) => d.domain === domain.value);
return tmp ? tmp.provider : '';
});
function isNoopOrManual(domain) {
const tmp = domains.value.find(d => d.domain === domain);
return tmp ? (tmp.provider === 'noop' || tmp.provider === 'manual') : false;
}
function onAddAlias() {
aliases.value.push({
domain: domains.value[0].domain,
subdomain: ''
});
}
function onRemoveAlias(index) {
aliases.value.splice(index, 1);
}
function onAddRedirect() {
redirects.value.push({
domain: domains.value[0].domain,
subdomain: ''
});
}
const formValid = computed(() => {
if (!domain.value) return false;
const checkForDomains = [{
domain: domain.value,
subdomain: subdomain.value,
}];
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].subdomain });
for (const d of aliases.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
for (const d of redirects.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
if (checkForDomains.find(d => !isValidDomain((d.subdomain ? (d.subdomain + '.') : '') + d.domain))) return false;
return true;
});
function onRemoveRedirect(index) {
redirects.value.splice(index, 1);
}
async function onSubmit() {
busy.value = true;
errorMessage.value = '';
errorObject.value = {};
needsOverwriteDns.value = false;
const checkForDomains = [{
domain: domain.value,
subdomain: subdomain.value,
}];
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].subdomain });
for (const d of aliases.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
for (const d of redirects.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
for (const d of checkForDomains) {
const [error, result] = await domainsModel.checkRecords(d.domain, d.subdomain);
if (error) {
errorMessage.value = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
if (result.needsOverwrite && !overwriteDns.value) {
busy.value = false;
needsOverwriteDns.value = true;
return;
}
}
// only use enabled ports
const ports = {};
const portsCombined = Object.assign(tcpPorts.value || {}, udpPorts.value || {});
for (const env in portsCombined) {
if (portsCombined[env].enabled) {
ports[env] = portsCombined[env].value;
}
}
const data = {
overwriteDns: overwriteDns.value,
subdomain: subdomain.value,
domain: domain.value,
ports: ports,
secondaryDomains: secondaryDomains.value,
redirectDomains: redirects.value,
aliasDomains: aliases.value,
};
const [error] = await appsModel.configure(props.app.id, 'location', data);
if (error) {
errorMessage.value = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
busy.value = false;
}
onMounted(async () => {
const [error, result] = await domainsModel.list();
if (error) return console.error(error);
domains.value = result;
subdomain.value = props.app.subdomain;
domain.value = props.app.domain;
// map manifest httpPorts into existing secondary domains from app
secondaryDomains.value = props.app.manifest.httpPorts || {};
for (const d of props.app.secondaryDomains) {
if (!secondaryDomains.value[d.environmentVariable]) continue;
secondaryDomains.value[d.environmentVariable].domain = d.domain;
secondaryDomains.value[d.environmentVariable].subdomain = d.subdomain;
}
aliases.value = props.app.aliasDomains;
redirects.value = props.app.redirectDomains;
tcpPorts.value = props.app.manifest.tcpPorts;
udpPorts.value = props.app.manifest.udpPorts;
// ensure we have value property
for (const p in tcpPorts.value) {
tcpPorts.value[p].value = tcpPorts.value[p].value || tcpPorts.value[p].defaultValue;
tcpPorts.value[p].enabled = false;
}
for (const p in udpPorts.value) {
udpPorts.value[p].value = udpPorts.value[p].value || udpPorts.value[p].defaultValue;
udpPorts.value[p].enabled = false;
}
for (const p in props.app.portBindings) {
if (tcpPorts.value[p]) {
tcpPorts.value[p].value = props.app.portBindings[p].hostPort;
tcpPorts.value[p].enabled = true;
} else if (udpPorts.value[p]) {
udpPorts.value[p].value = props.app.portBindings[p].hostPort;
udpPorts.value[p].enabled = true;
}
else console.error(`Portbinding ${p} not known in manifest!`);
}
});
</script>
<template>
<div>
<form @submit.prevent="onSubmit()" autocomplete="off" novalidate>
<fieldset :disabled="busy">
<input type="submit" style="display: none;" :disabled="(app.error && app.error.details.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid"/>
<FormGroup>
<label>{{ $t('app.location.location') }}</label>
<InputGroup style="flex-grow: 1">
<TextInput style="flex-grow: 1" v-model="subdomain" :placeholder="$t('app.location.locationPlaceholder')"/>
<SingleSelect :disabled="busy" :options="domains" v-model="domain" option-key="domain" option-label="domain" :search-threshold="10"/>
</InputGroup>
<div class="warning-label" v-if="isNoopOrManual(domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((subdomain ? subdomain + '.' : '') + domain) })"></div>
</FormGroup>
<FormGroup v-for="item in secondaryDomains" :key="item.containerPort">
<label :for="'secondaryDomainInput' + item.containerPort">{{ item.title }}</label>
<small>{{ item.description }}</small>
<InputGroup style="flex-grow: 1">
<TextInput style="flex-grow: 1" :id="'secondaryDomainInput' + item.containerPort" v-model="item.subdomain" :placeholder="$t('appstore.installDialog.locationPlaceholder')"/>
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="domain" :search-threshold="10"/>
</InputGroup>
<div class="warning-label" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></div>
</FormGroup>
<PortBindings v-model:tcp="tcpPorts" v-model:udp="udpPorts" :error="errorObject" :domain-provider="domainProvider"/>
<div v-if="app.manifest.multiDomain">
<label>{{ $t('app.location.aliases') }} <sup><a href="https://docs.cloudron.io/apps/#aliases" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-if="aliases.length === 0">{{ $t('app.location.noAliases') }}</div>
<div v-for="(item, index) in aliases" :key="item" style="margin-bottom: 10px">
<div style="display: flex; gap: 10px;">
<InputGroup style="flex-grow: 1">
<TextInput style="flex-grow: 1" v-model="item.subdomain" :placeholder="$t('app.location.locationPlaceholder')"/>
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="domain" :search-threshold="10"/>
</InputGroup>
<Button danger tool :disabled="busy" icon="fa-solid fa-trash" @click="onRemoveAlias(index)"/>
</div>
<p class="text-warning" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></p>
</div>
<div class="actionable" v-if="!busy" @click="onAddAlias()">{{ $t('app.location.addAliasAction') }}</div>
</div>
<div>
<label>{{ $t('app.location.redirections') }} <sup><a href="https://docs.cloudron.io/apps/#redirections" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-if="redirects.length === 0">{{ $t('app.location.noRedirections') }}</div>
<div v-for="(item, index) in redirects" :key="item" style="margin-bottom: 10px;">
<div style="display: flex; gap: 10px;">
<InputGroup style="flex-grow: 1">
<TextInput style="flex-grow: 1" v-model="item.subdomain" :placeholder="$t('app.location.locationPlaceholder')"/>
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="domain" :search-threshold="10"/>
</InputGroup>
<Button danger tool :disabled="busy" icon="fa-solid fa-trash" @click="onRemoveRedirect(index)"/>
</div>
<p class="text-warning" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></p>
</div>
<div class="actionable" v-if="!busy" @click="onAddRedirect()">{{ $t('app.location.addRedirectionAction') }}</div>
</div>
</fieldset>
</form>
<div class="has-error" v-if="errorMessage">{{ errorMessage }}</div>
<br/>
<Checkbox v-if="needsOverwriteDns" v-model="overwriteDns" :label="$t('app.location.dnsoverwrite')"/>
<Button @click="onSubmit()" :loading="busy" :disabled="busy || (app.error && app.error.details.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid">{{ $t('app.location.saveAction') }}</Button>
</div>
</template>