feat(dashboard): frontend domain & tls configuration

This commit is contained in:
Nicolas Meienberger 2022-09-01 21:11:41 +00:00 committed by Nicolas Meienberger
parent c0584c75ae
commit fc73184fa0
14 changed files with 118 additions and 46 deletions

View file

@ -20,7 +20,6 @@ services:
tipi-db:
container_name: tipi-db
image: postgres:latest
user: 1000:1000
restart: on-failure
stop_grace_period: 1m
volumes:

View file

@ -0,0 +1,23 @@
import React from 'react';
import { Input, Switch } from '@chakra-ui/react';
import clsx from 'clsx';
interface IProps {
placeholder?: string;
type?: Parameters<typeof Input>[0]['type'];
label?: string;
className?: string;
size?: Parameters<typeof Input>[0]['size'];
checked?: boolean;
}
const FormSwitch: React.FC<IProps> = ({ placeholder, type, label, className, size, ...rest }) => {
return (
<div className={clsx('transition-all', className)}>
{label && <label className="mr-2">{label}</label>}
<Switch isChecked={rest.checked} type={type} placeholder={placeholder} size={size} {...rest} />
</div>
);
};
export default FormSwitch;

View file

@ -1,12 +1,13 @@
import validator from 'validator';
import { FieldTypesEnum, FormField } from '../../generated/graphql';
import { IFormValues } from '../../modules/Apps/components/InstallForm';
const validateField = (field: FormField, value: string): string | undefined => {
const validateField = (field: FormField, value: string | undefined | boolean): string | undefined => {
if (field.required && !value) {
return `${field.label} is required`;
}
if (!value) {
if (!value || typeof value !== 'string') {
return;
}
@ -59,12 +60,24 @@ const validateField = (field: FormField, value: string): string | undefined => {
}
};
export const validateAppConfig = (values: Record<string, string>, fields: FormField[]) => {
const validateDomain = (domain?: string): string | undefined => {
if (!validator.isFQDN(domain || '')) {
return `${domain} must be a valid domain`;
}
};
export const validateAppConfig = (values: IFormValues, fields: FormField[]) => {
const { exposed, domain, ...config } = values;
const errors: any = {};
fields.forEach((field) => {
errors[field.env_variable] = validateField(field, values[field.env_variable]);
errors[field.env_variable] = validateField(field, config[field.env_variable]);
});
if (exposed) {
errors.domain = validateDomain(domain);
}
return errors;
};

View file

@ -72,8 +72,8 @@ export type AppInfo = {
};
export type AppInputType = {
domain?: InputMaybe<Scalars['String']>;
exposed?: InputMaybe<Scalars['Boolean']>;
domain: Scalars['String'];
exposed: Scalars['Boolean'];
form: Scalars['JSONObject'];
id: Scalars['String'];
};

View file

@ -4,6 +4,8 @@ query GetApp($appId: String!) {
status
config
version
exposed
domain
updateInfo {
current
latest
@ -23,6 +25,7 @@ query GetApp($appId: String!) {
categories
url_suffix
https
exposable
form_fields {
type
label

View file

@ -41,7 +41,7 @@ const ActionButton: React.FC<BtnProps> = (props) => {
};
const AppActions: React.FC<IProps> = ({ app, status, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate, onCancel, updateAvailable, onUpdateSettings }) => {
const hasSettings = Object.keys(app.form_fields).length > 0;
const hasSettings = Object.keys(app.form_fields).length > 0 || app.exposable;
const buttons: JSX.Element[] = [];

View file

@ -2,6 +2,7 @@ import { Button } from '@chakra-ui/react';
import React from 'react';
import { Form, Field } from 'react-final-form';
import FormInput from '../../../components/Form/FormInput';
import FormSwitch from '../../../components/Form/FormSwitch';
import { validateAppConfig } from '../../../components/Form/validators';
import { AppInfo, FormField } from '../../../generated/graphql';
@ -9,12 +10,19 @@ interface IProps {
formFields: AppInfo['form_fields'];
onSubmit: (values: Record<string, unknown>) => void;
initalValues?: Record<string, string>;
exposable?: boolean | null;
}
export type IFormValues = {
exposed?: boolean;
domain?: string;
[key: string]: string | boolean | undefined;
};
const hiddenTypes = ['random'];
const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type);
const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues }) => {
const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues, exposable }) => {
const renderField = (field: FormField) => {
return (
<Field
@ -25,18 +33,41 @@ const InstallForm: React.FC<IProps> = ({ formFields, onSubmit, initalValues }) =
);
};
const renderExposeForm = (isExposedChecked?: boolean) => {
return (
<>
<Field key="exposed" name="exposed" type="checkbox" render={({ input }) => <FormSwitch className="mb-3" label="Expose app ?" {...input} />} />
{isExposedChecked && (
<>
<Field
key="domain"
name="domain"
render={({ input, meta }) => <FormInput className="mb-3" error={meta.error} isInvalid={meta.invalid && (meta.submitError || meta.submitFailed)} label="Domain name" {...input} />}
/>
<span className="text-sm">
Make sur the domain contains an <strong>A</strong> record pointing to your IP.
</span>
</>
)}
</>
);
};
return (
<Form<Record<string, string>>
<Form<IFormValues>
initialValues={initalValues}
onSubmit={onSubmit}
validateOnBlur={true}
validate={(values) => validateAppConfig(values, formFields)}
render={({ handleSubmit, validating, submitting }) => (
render={({ handleSubmit, validating, submitting, values }) => (
<form className="flex flex-col" onSubmit={handleSubmit}>
{formFields.filter(typeFilter).map(renderField)}
<Button isLoading={validating || submitting} className="self-end mb-2" colorScheme="green" type="submit">
{initalValues ? 'Update' : 'Install'}
</Button>
<>
{formFields.filter(typeFilter).map(renderField)}
{exposable && renderExposeForm(values.exposed)}
<Button isLoading={validating || submitting} className="self-end mb-2" colorScheme="green" type="submit">
{initalValues ? 'Update' : 'Install'}
</Button>
</>
</form>
)}
/>

View file

@ -18,7 +18,7 @@ const InstallModal: React.FC<IProps> = ({ app, isOpen, onClose, onSubmit }) => {
<ModalHeader>Install {app.name}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<InstallForm onSubmit={onSubmit} formFields={app.form_fields} />
<InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} />
</ModalBody>
</ModalContent>
</Modal>

View file

@ -7,11 +7,13 @@ interface IProps {
app: AppInfo;
config: App['config'];
isOpen: boolean;
exposed?: boolean;
domain?: string;
onClose: () => void;
onSubmit: (values: Record<string, any>) => void;
}
const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, onSubmit }) => {
const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, onSubmit, exposed, domain }) => {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
@ -19,7 +21,7 @@ const UpdateSettingsModal: React.FC<IProps> = ({ app, config, isOpen, onClose, o
<ModalHeader>Update {app.name} config</ModalHeader>
<ModalCloseButton />
<ModalBody>
<InstallForm onSubmit={onSubmit} formFields={app.form_fields} initalValues={config} />
<InstallForm onSubmit={onSubmit} formFields={app.form_fields} exposable={app.exposable} initalValues={{ ...config, exposed, domain }} />
</ModalBody>
</ModalContent>
</Modal>

View file

@ -23,9 +23,10 @@ import {
useUpdateAppMutation,
} from '../../../generated/graphql';
import UpdateModal from '../components/UpdateModal';
import { IFormValues } from '../components/InstallForm';
interface IProps {
app?: Pick<App, 'status' | 'config' | 'version' | 'updateInfo'>;
app?: Pick<App, 'status' | 'config' | 'version' | 'updateInfo' | 'exposed' | 'domain'>;
info: AppInfo;
}
@ -61,11 +62,12 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
}
};
const handleInstallSubmit = async (values: Record<string, any>) => {
const handleInstallSubmit = async (values: IFormValues) => {
installDisclosure.onClose();
const { exposed, domain, ...form } = values;
try {
await install({
variables: { input: { form: values, id: info.id } },
variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } },
optimisticResponse: { installApp: { id: info.id, status: AppStatusEnum.Installing, __typename: 'App' } },
});
} catch (error) {
@ -99,12 +101,13 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
}
};
const handleUpdateSettingsSubmit = async (values: Record<string, any>) => {
const handleUpdateSettingsSubmit = async (values: IFormValues) => {
try {
await updateConfig({ variables: { input: { form: values, id: info.id } } });
const { exposed, domain, ...form } = values;
await updateConfig({ variables: { input: { form, id: info.id, exposed: exposed || false, domain: domain || '' } } });
toast({
title: 'Success',
description: 'App config updated successfully',
description: 'App config updated successfully. Restart the app to apply the changes.',
position: 'top',
status: 'success',
});
@ -133,7 +136,9 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
const { https } = info;
const protocol = https ? 'https' : 'http';
window.open(`${protocol}://${internalIp}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
if (typeof window !== 'undefined') {
window.open(`${protocol}://${internalIp}:${info.port}${info.url_suffix || ''}`, '_blank', 'noreferrer');
}
};
const version = [info?.version || 'unknown', app?.version ? `(${app.version})` : ''].join(' ');
@ -183,7 +188,15 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
<InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.onClose} app={info} />
<UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.onClose} app={info} />
<StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.onClose} app={info} />
<UpdateSettingsModal onSubmit={handleUpdateSettingsSubmit} isOpen={updateSettingsDisclosure.isOpen} onClose={updateSettingsDisclosure.onClose} app={info} config={app?.config} />
<UpdateSettingsModal
onSubmit={handleUpdateSettingsSubmit}
isOpen={updateSettingsDisclosure.isOpen}
onClose={updateSettingsDisclosure.onClose}
app={info}
config={app?.config}
exposed={app?.exposed}
domain={app?.domain}
/>
<UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} newVersion={newVersion} />
</div>
</SlideFade>

View file

@ -56,7 +56,7 @@ class App extends BaseEntity {
updatedAt!: Date;
@Field(() => Boolean)
@Column({ type: 'boolean', default: false, nullable: false })
@Column({ type: 'boolean', default: false })
exposed!: boolean;
@Field(() => String)

View file

@ -50,9 +50,9 @@ export default class AppsResolver {
@Authorized()
@Mutation(() => App)
async updateAppConfig(@Arg('input', () => AppInputType) input: AppInputType): Promise<App> {
const { id, form } = input;
const { id, form, exposed, domain } = input;
return AppsService.updateAppConfig(id, form);
return AppsService.updateAppConfig(id, form, exposed, domain);
}
@Authorized()

View file

@ -142,18 +142,6 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
generateEnvFile(app);
app = (await App.findOne({ where: { id } })) as App;
// Restart app
try {
await App.update({ id }, { status: AppStatusEnum.STOPPING });
await runAppScript(['stop', id]);
await App.update({ id }, { status: AppStatusEnum.STARTING });
await runAppScript(['start', id]);
await App.update({ id }, { status: AppStatusEnum.RUNNING });
} catch (e) {
await App.update({ id }, { status: AppStatusEnum.STOPPED });
throw e;
}
return app;
};
@ -212,7 +200,7 @@ const getApp = async (id: string): Promise<App> => {
let app = await App.findOne({ where: { id } });
if (!app) {
app = { id, status: AppStatusEnum.MISSING, config: {} } as App;
app = { id, status: AppStatusEnum.MISSING, config: {}, exposed: false, domain: '' } as App;
}
return app;

View file

@ -147,11 +147,11 @@ class AppInputType {
@Field(() => GraphQLJSONObject)
form!: Record<string, string>;
@Field(() => Boolean, { nullable: true })
exposed?: boolean;
@Field(() => Boolean)
exposed!: boolean;
@Field(() => String, { nullable: true })
domain?: string;
@Field(() => String)
domain!: string;
}
export { ListAppsResonse, AppInfo, AppInputType };