fix: dns checker
This commit is contained in:
parent
fc9bbac372
commit
dd2a876a67
@ -317,12 +317,16 @@ export function getDomain(domain: string): string {
|
|||||||
export async function isDomainConfigured({
|
export async function isDomainConfigured({
|
||||||
id,
|
id,
|
||||||
fqdn,
|
fqdn,
|
||||||
checkOwn = false
|
checkOwn = false,
|
||||||
|
dockerId = undefined
|
||||||
}: {
|
}: {
|
||||||
id: string;
|
id: string;
|
||||||
fqdn: string;
|
fqdn: string;
|
||||||
checkOwn?: boolean;
|
checkOwn?: boolean;
|
||||||
|
dockerId: string;
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
|
|
||||||
|
console.log({checkOwn, dockerId})
|
||||||
const domain = getDomain(fqdn);
|
const domain = getDomain(fqdn);
|
||||||
const nakedDomain = domain.replace('www.', '');
|
const nakedDomain = domain.replace('www.', '');
|
||||||
const foundApp = await prisma.application.findFirst({
|
const foundApp = await prisma.application.findFirst({
|
||||||
@ -331,7 +335,10 @@ export async function isDomainConfigured({
|
|||||||
{ fqdn: { endsWith: `//${nakedDomain}` } },
|
{ fqdn: { endsWith: `//${nakedDomain}` } },
|
||||||
{ fqdn: { endsWith: `//www.${nakedDomain}` } }
|
{ fqdn: { endsWith: `//www.${nakedDomain}` } }
|
||||||
],
|
],
|
||||||
id: { not: id }
|
id: { not: id },
|
||||||
|
destinationDocker: {
|
||||||
|
id: dockerId
|
||||||
|
}
|
||||||
},
|
},
|
||||||
select: { fqdn: true }
|
select: { fqdn: true }
|
||||||
});
|
});
|
||||||
@ -343,7 +350,10 @@ export async function isDomainConfigured({
|
|||||||
{ minio: { apiFqdn: { endsWith: `//${nakedDomain}` } } },
|
{ minio: { apiFqdn: { endsWith: `//${nakedDomain}` } } },
|
||||||
{ minio: { apiFqdn: { endsWith: `//www.${nakedDomain}` } } }
|
{ minio: { apiFqdn: { endsWith: `//www.${nakedDomain}` } } }
|
||||||
],
|
],
|
||||||
id: { not: checkOwn ? undefined : id }
|
id: { not: checkOwn ? undefined : id },
|
||||||
|
destinationDocker: {
|
||||||
|
id: dockerId
|
||||||
|
}
|
||||||
},
|
},
|
||||||
select: { fqdn: true }
|
select: { fqdn: true }
|
||||||
});
|
});
|
||||||
@ -416,16 +426,13 @@ export async function checkDomainsIsValidInDNS({ hostname, fqdn, dualCerts }): P
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
console.log({domain})
|
|
||||||
const ipDomain = await dns.resolve4(domain);
|
const ipDomain = await dns.resolve4(domain);
|
||||||
console.log({ipDomain})
|
|
||||||
let ipDomainFound = false;
|
let ipDomainFound = false;
|
||||||
for (const ip of ipDomain) {
|
for (const ip of ipDomain) {
|
||||||
if (resolves.includes(ip)) {
|
if (resolves.includes(ip)) {
|
||||||
ipDomainFound = true;
|
ipDomainFound = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log({ipDomainFound})
|
|
||||||
if (ipDomainFound) return { status: 200 };
|
if (ipDomainFound) return { status: 200 };
|
||||||
throw { status: 500, message: `DNS not set correctly or propogated.<br>Please check your DNS settings.` }
|
throw { status: 500, message: `DNS not set correctly or propogated.<br>Please check your DNS settings.` }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -368,7 +368,7 @@ export async function checkDNS(request: FastifyRequest<CheckDNS>) {
|
|||||||
const { destinationDocker: { id: dockerId, remoteIpAddress, remoteEngine }, exposePort: configuredPort } = await prisma.application.findUnique({ where: { id }, include: { destinationDocker: true } })
|
const { destinationDocker: { id: dockerId, remoteIpAddress, remoteEngine }, exposePort: configuredPort } = await prisma.application.findUnique({ where: { id }, include: { destinationDocker: true } })
|
||||||
const { isDNSCheckEnabled } = await prisma.setting.findFirst({});
|
const { isDNSCheckEnabled } = await prisma.setting.findFirst({});
|
||||||
|
|
||||||
const found = await isDomainConfigured({ id, fqdn });
|
const found = await isDomainConfigured({ id, fqdn, dockerId });
|
||||||
if (found) {
|
if (found) {
|
||||||
throw { status: 500, message: `Domain ${getDomain(fqdn).replace('www.', '')} is already in use!` }
|
throw { status: 500, message: `Domain ${getDomain(fqdn).replace('www.', '')} is already in use!` }
|
||||||
}
|
}
|
||||||
|
@ -2,13 +2,13 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
|
|||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { prisma, uniqueName, asyncExecShell, getServiceImage, configureServiceType, getServiceFromDB, getContainerUsage, removeService, isDomainConfigured, saveUpdateableFields, fixType, decrypt, encrypt, getServiceMainPort, createDirectories, ComposeFile, makeLabelForServices, getFreePublicPort, getDomain, errorHandler, generatePassword, isDev, stopTcpHttpProxy, supportedServiceTypesAndVersions, executeDockerCmd, listSettings, getFreeExposedPort } from '../../../../lib/common';
|
import { prisma, uniqueName, asyncExecShell, getServiceImage, configureServiceType, getServiceFromDB, getContainerUsage, removeService, isDomainConfigured, saveUpdateableFields, fixType, decrypt, encrypt, getServiceMainPort, createDirectories, ComposeFile, makeLabelForServices, getFreePublicPort, getDomain, errorHandler, generatePassword, isDev, stopTcpHttpProxy, supportedServiceTypesAndVersions, executeDockerCmd, listSettings, getFreeExposedPort, checkDomainsIsValidInDNS } from '../../../../lib/common';
|
||||||
import { day } from '../../../../lib/dayjs';
|
import { day } from '../../../../lib/dayjs';
|
||||||
import { checkContainer, dockerInstance, isContainerExited, removeContainer } from '../../../../lib/docker';
|
import { checkContainer, dockerInstance, isContainerExited, removeContainer } from '../../../../lib/docker';
|
||||||
import cuid from 'cuid';
|
import cuid from 'cuid';
|
||||||
|
|
||||||
import type { OnlyId } from '../../../../types';
|
import type { OnlyId } from '../../../../types';
|
||||||
import type { ActivateWordpressFtp, CheckService, DeleteServiceSecret, DeleteServiceStorage, GetServiceLogs, SaveService, SaveServiceDestination, SaveServiceSecret, SaveServiceSettings, SaveServiceStorage, SaveServiceType, SaveServiceVersion, ServiceStartStop, SetWordpressSettings } from './types';
|
import type { ActivateWordpressFtp, CheckService, CheckServiceDomain, DeleteServiceSecret, DeleteServiceStorage, GetServiceLogs, SaveService, SaveServiceDestination, SaveServiceSecret, SaveServiceSettings, SaveServiceStorage, SaveServiceType, SaveServiceVersion, ServiceStartStop, SetWordpressSettings } from './types';
|
||||||
|
|
||||||
// async function startServiceNew(request: FastifyRequest<OnlyId>) {
|
// async function startServiceNew(request: FastifyRequest<OnlyId>) {
|
||||||
// try {
|
// try {
|
||||||
@ -346,22 +346,35 @@ export async function saveServiceSettings(request: FastifyRequest<SaveServiceSet
|
|||||||
return errorHandler({ status, message })
|
return errorHandler({ status, message })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export async function checkServiceDomain(request: FastifyRequest<CheckServiceDomain>) {
|
||||||
|
try {
|
||||||
|
const { id } = request.params
|
||||||
|
const { domain } = request.query
|
||||||
|
const { fqdn, dualCerts } = await prisma.service.findUnique({ where: { id }})
|
||||||
|
return await checkDomainsIsValidInDNS({ hostname: domain, fqdn, dualCerts });
|
||||||
|
} catch ({ status, message }) {
|
||||||
|
return errorHandler({ status, message })
|
||||||
|
}
|
||||||
|
}
|
||||||
export async function checkService(request: FastifyRequest<CheckService>) {
|
export async function checkService(request: FastifyRequest<CheckService>) {
|
||||||
try {
|
try {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
let { fqdn, exposePort, otherFqdns } = request.body;
|
let { fqdn, exposePort, forceSave, otherFqdns, dualCerts } = request.body;
|
||||||
|
|
||||||
if (fqdn) fqdn = fqdn.toLowerCase();
|
if (fqdn) fqdn = fqdn.toLowerCase();
|
||||||
if (otherFqdns && otherFqdns.length > 0) otherFqdns = otherFqdns.map((f) => f.toLowerCase());
|
if (otherFqdns && otherFqdns.length > 0) otherFqdns = otherFqdns.map((f) => f.toLowerCase());
|
||||||
if (exposePort) exposePort = Number(exposePort);
|
if (exposePort) exposePort = Number(exposePort);
|
||||||
|
|
||||||
let found = await isDomainConfigured({ id, fqdn });
|
const { destinationDocker: { id: dockerId, remoteIpAddress, remoteEngine }, exposePort: configuredPort } = await prisma.service.findUnique({ where: { id }, include: { destinationDocker: true } })
|
||||||
|
const { isDNSCheckEnabled } = await prisma.setting.findFirst({});
|
||||||
|
|
||||||
|
let found = await isDomainConfigured({ id, fqdn, dockerId });
|
||||||
if (found) {
|
if (found) {
|
||||||
throw { status: 500, message: `Domain ${getDomain(fqdn).replace('www.', '')} is already in use!` }
|
throw { status: 500, message: `Domain ${getDomain(fqdn).replace('www.', '')} is already in use!` }
|
||||||
}
|
}
|
||||||
if (otherFqdns && otherFqdns.length > 0) {
|
if (otherFqdns && otherFqdns.length > 0) {
|
||||||
for (const ofqdn of otherFqdns) {
|
for (const ofqdn of otherFqdns) {
|
||||||
found = await isDomainConfigured({ id, fqdn: ofqdn, checkOwn: true });
|
found = await isDomainConfigured({ id, fqdn: ofqdn, dockerId });
|
||||||
if (found) {
|
if (found) {
|
||||||
throw { status: 500, message: `Domain ${getDomain(ofqdn).replace('www.', '')} is already in use!` }
|
throw { status: 500, message: `Domain ${getDomain(ofqdn).replace('www.', '')} is already in use!` }
|
||||||
}
|
}
|
||||||
@ -371,7 +384,7 @@ export async function checkService(request: FastifyRequest<CheckService>) {
|
|||||||
if (exposePort < 1024 || exposePort > 65535) {
|
if (exposePort < 1024 || exposePort > 65535) {
|
||||||
throw { status: 500, message: `Exposed Port needs to be between 1024 and 65535.` }
|
throw { status: 500, message: `Exposed Port needs to be between 1024 and 65535.` }
|
||||||
}
|
}
|
||||||
const { destinationDocker: { id: dockerId, remoteIpAddress }, exposePort: configuredPort } = await prisma.service.findUnique({ where: { id }, include: { destinationDocker: true } })
|
|
||||||
if (configuredPort !== exposePort) {
|
if (configuredPort !== exposePort) {
|
||||||
const availablePort = await getFreeExposedPort(id, exposePort, dockerId, remoteIpAddress);
|
const availablePort = await getFreeExposedPort(id, exposePort, dockerId, remoteIpAddress);
|
||||||
if (availablePort.toString() !== exposePort.toString()) {
|
if (availablePort.toString() !== exposePort.toString()) {
|
||||||
@ -379,6 +392,11 @@ export async function checkService(request: FastifyRequest<CheckService>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (isDNSCheckEnabled && !isDev && !forceSave) {
|
||||||
|
let hostname = request.hostname.split(':')[0];
|
||||||
|
if (remoteEngine) hostname = remoteIpAddress;
|
||||||
|
return await checkDomainsIsValidInDNS({ hostname, fqdn, dualCerts });
|
||||||
|
}
|
||||||
return {}
|
return {}
|
||||||
} catch ({ status, message }) {
|
} catch ({ status, message }) {
|
||||||
return errorHandler({ status, message })
|
return errorHandler({ status, message })
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
activatePlausibleUsers,
|
activatePlausibleUsers,
|
||||||
activateWordpressFtp,
|
activateWordpressFtp,
|
||||||
checkService,
|
checkService,
|
||||||
|
checkServiceDomain,
|
||||||
deleteService,
|
deleteService,
|
||||||
deleteServiceSecret,
|
deleteServiceSecret,
|
||||||
deleteServiceStorage,
|
deleteServiceStorage,
|
||||||
@ -29,7 +30,7 @@ import {
|
|||||||
} from './handlers';
|
} from './handlers';
|
||||||
|
|
||||||
import type { OnlyId } from '../../../../types';
|
import type { OnlyId } from '../../../../types';
|
||||||
import type { ActivateWordpressFtp, CheckService, DeleteServiceSecret, DeleteServiceStorage, GetServiceLogs, SaveService, SaveServiceDestination, SaveServiceSecret, SaveServiceSettings, SaveServiceStorage, SaveServiceType, SaveServiceVersion, ServiceStartStop, SetWordpressSettings } from './types';
|
import type { ActivateWordpressFtp, CheckService, CheckServiceDomain, DeleteServiceSecret, DeleteServiceStorage, GetServiceLogs, SaveService, SaveServiceDestination, SaveServiceSecret, SaveServiceSettings, SaveServiceStorage, SaveServiceType, SaveServiceVersion, ServiceStartStop, SetWordpressSettings } from './types';
|
||||||
|
|
||||||
const root: FastifyPluginAsync = async (fastify): Promise<void> => {
|
const root: FastifyPluginAsync = async (fastify): Promise<void> => {
|
||||||
fastify.addHook('onRequest', async (request) => {
|
fastify.addHook('onRequest', async (request) => {
|
||||||
@ -44,6 +45,7 @@ const root: FastifyPluginAsync = async (fastify): Promise<void> => {
|
|||||||
|
|
||||||
fastify.get<OnlyId>('/:id/status', async (request) => await getServiceStatus(request));
|
fastify.get<OnlyId>('/:id/status', async (request) => await getServiceStatus(request));
|
||||||
|
|
||||||
|
fastify.get<CheckServiceDomain>('/:id/check', async (request) => await checkServiceDomain(request));
|
||||||
fastify.post<CheckService>('/:id/check', async (request) => await checkService(request));
|
fastify.post<CheckService>('/:id/check', async (request) => await checkService(request));
|
||||||
|
|
||||||
fastify.post<SaveServiceSettings>('/:id/settings', async (request, reply) => await saveServiceSettings(request, reply));
|
fastify.post<SaveServiceSettings>('/:id/settings', async (request, reply) => await saveServiceSettings(request, reply));
|
||||||
|
@ -25,9 +25,16 @@ export interface SaveServiceSettings extends OnlyId {
|
|||||||
dualCerts: boolean
|
dualCerts: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export interface CheckServiceDomain extends OnlyId {
|
||||||
|
Querystring: {
|
||||||
|
domain: string
|
||||||
|
}
|
||||||
|
}
|
||||||
export interface CheckService extends OnlyId {
|
export interface CheckService extends OnlyId {
|
||||||
Body: {
|
Body: {
|
||||||
fqdn: string,
|
fqdn: string,
|
||||||
|
forceSave: boolean,
|
||||||
|
dualCerts: boolean,
|
||||||
exposePort: number,
|
exposePort: number,
|
||||||
otherFqdns: Array<string>
|
otherFqdns: Array<string>
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
import { toast } from '@zerodevx/svelte-toast';
|
import { toast } from '@zerodevx/svelte-toast';
|
||||||
|
|
||||||
import { get, post } from '$lib/api';
|
import { get, post } from '$lib/api';
|
||||||
import { errorNotification } from '$lib/common';
|
import { errorNotification, getDomain } from '$lib/common';
|
||||||
import { t } from '$lib/translations';
|
import { t } from '$lib/translations';
|
||||||
import { appSession, disabledButton, status, location, setLocation } from '$lib/store';
|
import { appSession, disabledButton, status, location, setLocation } from '$lib/store';
|
||||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||||
@ -30,28 +30,63 @@
|
|||||||
import Moodle from './_Moodle.svelte';
|
import Moodle from './_Moodle.svelte';
|
||||||
|
|
||||||
const { id } = $page.params;
|
const { id } = $page.params;
|
||||||
|
|
||||||
$: isDisabled =
|
$: isDisabled =
|
||||||
!$appSession.isAdmin || $status.service.isRunning || $status.service.initialLoading;
|
!$appSession.isAdmin || $status.service.isRunning || $status.service.initialLoading;
|
||||||
|
|
||||||
|
let forceSave = false;
|
||||||
let loading = false;
|
let loading = false;
|
||||||
let loadingVerification = false;
|
let loadingVerification = false;
|
||||||
let dualCerts = service.dualCerts;
|
let dualCerts = service.dualCerts;
|
||||||
|
|
||||||
|
let nonWWWDomain = service.fqdn && getDomain(service.fqdn).replace(/^www\./, '');
|
||||||
|
let isNonWWWDomainOK = false;
|
||||||
|
let isWWWDomainOK = false;
|
||||||
|
|
||||||
|
async function isDNSValid(domain: any, isWWW: any) {
|
||||||
|
try {
|
||||||
|
await get(`/services/${id}/check?domain=${domain}`);
|
||||||
|
toast.push('DNS configuration is valid.');
|
||||||
|
isWWW ? (isWWWDomainOK = true) : (isNonWWWDomainOK = true);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
errorNotification(error);
|
||||||
|
isWWW ? (isWWWDomainOK = false) : (isNonWWWDomainOK = false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
await post(`/services/${id}/check`, {
|
await post(`/services/${id}/check`, {
|
||||||
fqdn: service.fqdn,
|
fqdn: service.fqdn,
|
||||||
|
forceSave,
|
||||||
|
dualCerts,
|
||||||
otherFqdns: service.minio?.apiFqdn ? [service.minio?.apiFqdn] : [],
|
otherFqdns: service.minio?.apiFqdn ? [service.minio?.apiFqdn] : [],
|
||||||
exposePort: service.exposePort
|
exposePort: service.exposePort
|
||||||
});
|
});
|
||||||
await post(`/services/${id}`, { ...service });
|
await post(`/services/${id}`, { ...service });
|
||||||
setLocation(service);
|
setLocation(service);
|
||||||
$disabledButton = false;
|
$disabledButton = false;
|
||||||
|
forceSave = false;
|
||||||
toast.push('Configuration saved.');
|
toast.push('Configuration saved.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
//@ts-ignore
|
||||||
|
if (error?.message.startsWith($t('application.dns_not_set_partial_error'))) {
|
||||||
|
forceSave = true;
|
||||||
|
if (dualCerts) {
|
||||||
|
isNonWWWDomainOK = await isDNSValid(getDomain(nonWWWDomain), false);
|
||||||
|
isWWWDomainOK = await isDNSValid(getDomain(`www.${nonWWWDomain}`), true);
|
||||||
|
} else {
|
||||||
|
const isWWW = getDomain(service.fqdn).includes('www.');
|
||||||
|
if (isWWW) {
|
||||||
|
isWWWDomainOK = await isDNSValid(getDomain(`www.${nonWWWDomain}`), true);
|
||||||
|
} else {
|
||||||
|
isNonWWWDomainOK = await isDNSValid(getDomain(nonWWWDomain), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return errorNotification(error);
|
return errorNotification(error);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
@ -111,8 +146,15 @@
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class:bg-pink-600={!loading}
|
class:bg-pink-600={!loading}
|
||||||
|
class:bg-orange-600={forceSave}
|
||||||
class:hover:bg-pink-500={!loading}
|
class:hover:bg-pink-500={!loading}
|
||||||
disabled={loading}>{loading ? $t('forms.saving') : $t('forms.save')}</button
|
class:hover:bg-orange-400={forceSave}
|
||||||
|
disabled={loading}
|
||||||
|
>{loading
|
||||||
|
? $t('forms.saving')
|
||||||
|
: forceSave
|
||||||
|
? $t('forms.confirm_continue')
|
||||||
|
: $t('forms.save')}</button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if service.type === 'plausibleanalytics' && $status.service.isRunning}
|
{#if service.type === 'plausibleanalytics' && $status.service.isRunning}
|
||||||
@ -235,7 +277,38 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if forceSave}
|
||||||
|
<div class="flex-col space-y-2 pt-4 text-center">
|
||||||
|
{#if isNonWWWDomainOK}
|
||||||
|
<button
|
||||||
|
class="bg-green-600 hover:bg-green-500"
|
||||||
|
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
|
||||||
|
>DNS settings for {nonWWWDomain} is OK, click to recheck.</button
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="bg-red-600 hover:bg-red-500"
|
||||||
|
on:click|preventDefault={() => isDNSValid(getDomain(nonWWWDomain), false)}
|
||||||
|
>DNS settings for {nonWWWDomain} is invalid, click to recheck.</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{#if dualCerts}
|
||||||
|
{#if isWWWDomainOK}
|
||||||
|
<button
|
||||||
|
class="bg-green-600 hover:bg-green-500"
|
||||||
|
on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
|
||||||
|
>DNS settings for www.{nonWWWDomain} is OK, click to recheck.</button
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="bg-red-600 hover:bg-red-500"
|
||||||
|
on:click|preventDefault={() => isDNSValid(getDomain(`www.${nonWWWDomain}`), true)}
|
||||||
|
>DNS settings for www.{nonWWWDomain} is invalid, click to recheck.</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="grid grid-cols-2 items-center px-10">
|
<div class="grid grid-cols-2 items-center px-10">
|
||||||
<Setting
|
<Setting
|
||||||
disabled={$status.service.isRunning}
|
disabled={$status.service.isRunning}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user