feat(dashboard): frontend domain & tls configuration
This commit is contained in:
parent
c0584c75ae
commit
fc73184fa0
14 changed files with 118 additions and 46 deletions
|
@ -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:
|
||||
|
|
23
packages/dashboard/src/components/Form/FormSwitch.tsx
Normal file
23
packages/dashboard/src/components/Form/FormSwitch.tsx
Normal 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;
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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'];
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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[] = [];
|
||||
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
|
|
Loading…
Add table
Reference in a new issue