Merge branch 'cchalop1-feat/reset-app' into develop

This commit is contained in:
Nicolas Meienberger 2023-11-18 10:25:27 +01:00
commit 56acfbbb64
11 changed files with 2115 additions and 6 deletions

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,7 @@ import { AppStatus } from '@/components/AppStatus';
import { AppStatus as AppStatusEnum } from '@/server/db/schema';
import { castAppConfig } from '@/lib/helpers/castAppConfig';
import { AppService } from '@/server/services/apps/apps.service';
import { resetAppAction } from '@/actions/app-actions/reset-app-action';
import { InstallModal } from '../InstallModal';
import { StopModal } from '../StopModal';
import { UninstallModal } from '../UninstallModal';
@ -24,6 +25,7 @@ import { UpdateSettingsModal } from '../UpdateSettingsModal/UpdateSettingsModal'
import { AppActions } from '../AppActions';
import { AppDetailsTabs } from '../AppDetailsTabs';
import { FormValues } from '../InstallForm';
import { ResetAppModal } from '../ResetAppModal.tsx';
interface IProps {
app: Awaited<ReturnType<AppService['getApp']>>;
@ -40,6 +42,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
const stopDisclosure = useDisclosure();
const updateDisclosure = useDisclosure();
const updateSettingsDisclosure = useDisclosure();
const resetAppDisclosure = useDisclosure();
const installMutation = useAction(installAppAction, {
onSuccess: (data) => {
@ -111,6 +114,19 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
},
});
const resetMutation = useAction(resetAppAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
resetAppDisclosure.close();
} else {
resetAppDisclosure.close();
toast.success(t('apps.app-details.app-reset-success'));
setCustomStatus('running');
}
},
});
const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
const handleInstallSubmit = async (values: FormValues) => {
@ -147,6 +163,17 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
updateMutation.execute({ id: app.id });
};
const handleResetSubmit = () => {
setCustomStatus('stopping');
resetMutation.execute({ id: app.id });
resetAppDisclosure.open();
};
const openResetAppModal = () => {
updateSettingsDisclosure.close();
resetAppDisclosure.open();
};
const handleOpen = (type: OpenType) => {
let url = '';
const { https } = app.info;
@ -177,12 +204,15 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
<StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.close} info={app.info} />
<UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.close} info={app.info} />
<UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} info={app.info} newVersion={newVersion} />
<ResetAppModal onConfirm={handleResetSubmit} isOpen={resetAppDisclosure.isOpen} onClose={resetAppDisclosure.close} info={app.info} isLoading={resetMutation.status === 'executing'} />
<UpdateSettingsModal
onSubmit={handleUpdateSettingsSubmit}
isOpen={updateSettingsDisclosure.isOpen}
onClose={updateSettingsDisclosure.close}
info={app.info}
config={castAppConfig(app?.config)}
onReset={openResetAppModal}
status={customStatus}
/>
<div className="card-header d-flex flex-column flex-md-row">
<AppLogo id={app.id} size={130} alt={app.info.name} />

View file

@ -9,6 +9,7 @@ import { type FormField, type AppInfo } from '@runtipi/shared';
import { Switch } from '@/components/ui/Switch';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { AppStatus } from '@/server/db/schema';
import { validateAppConfig } from '../../utils/validators';
interface IProps {
@ -17,6 +18,8 @@ interface IProps {
initalValues?: { [key: string]: unknown };
info: AppInfo;
loading?: boolean;
onReset?: () => void;
status?: AppStatus;
}
export type FormValues = {
@ -29,7 +32,7 @@ export type FormValues = {
const hiddenTypes = ['random'];
const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type);
export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, initalValues, loading }) => {
export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, initalValues, loading, onReset, status }) => {
const t = useTranslations('apps.app-details.install-form');
const {
register,
@ -56,6 +59,11 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
}
}, [initalValues, isDirty, setValue]);
const onClickReset = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
if (onReset) onReset();
};
const renderField = (field: FormField) => {
const label = (
<>
@ -166,6 +174,11 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
<Button loading={loading} type="submit" className="btn-success">
{initalValues ? t('submit-update') : t('sumbit-install')}
</Button>
{initalValues && onReset && (
<Button loading={status === 'resetting'} onClick={onClickReset} className="btn-danger ms-2">
{t('reset')}
</Button>
)}
</form>
);
};

View file

@ -0,0 +1,38 @@
import { IconAlertTriangle } from '@tabler/icons-react';
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { Button } from '@/components/ui/Button';
interface IProps {
info: AppInfo;
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
isLoading?: boolean;
}
export const ResetAppModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm, isLoading }) => {
const t = useTranslations('apps.app-details.reset-app-form');
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent type="danger" size="sm">
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
</DialogHeader>
<DialogDescription className="text-center py-4">
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
<h3>{t('warning')}</h3>
<div className="text-muted">{t('subtitle')}</div>
</DialogDescription>
<DialogFooter>
<Button loading={isLoading} onClick={onConfirm} className="btn-danger">
{t('submit')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1 @@
export { ResetAppModal } from './ResetAppModal';

View file

@ -3,6 +3,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/compon
import { useTranslations } from 'next-intl';
import { AppInfo } from '@runtipi/shared';
import { ScrollArea } from '@/components/ui/ScrollArea';
import { AppStatus } from '@/server/db/schema';
import { InstallForm, type FormValues } from '../InstallForm';
interface IProps {
@ -11,9 +12,11 @@ interface IProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (values: FormValues) => void;
onReset: () => void;
status?: AppStatus;
}
export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit }) => {
export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, onReset, status }) => {
const t = useTranslations('apps.app-details.update-settings-form');
return (
@ -24,7 +27,7 @@ export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, on
</DialogHeader>
<ScrollArea maxHeight={500}>
<DialogDescription>
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config }} />
<InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config }} onReset={onReset} status={status} />
</DialogDescription>
</ScrollArea>
</DialogContent>

View file

@ -0,0 +1,28 @@
'use server';
import { action } from '@/lib/safe-action';
import { db } from '@/server/db';
import { AppServiceClass } from '@/server/services/apps/apps.service';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({
id: z.string(),
});
export const resetAppAction = action(input, async ({ id }) => {
try {
const appsService = new AppServiceClass(db);
await appsService.resetApp(id);
revalidatePath('/apps');
revalidatePath(`/app/${id}`);
revalidatePath(`/app-store/${id}`);
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View file

@ -107,6 +107,7 @@
},
"apps": {
"status-running": "Running",
"status-reseting": "Resetting",
"status-stopped": "Stopped",
"status-starting": "Starting",
"status-stopping": "Stopping",
@ -135,6 +136,7 @@
"update-success": "App updated successfully",
"start-success": "App started successfully",
"update-config-success": "App config updated successfully. Restart the app to apply the changes",
"app-reset-success": "App reset successfully",
"version": "Version",
"description": "Description",
"base-info": "Base info",
@ -182,6 +184,7 @@
"choose-option": "Choose an option...",
"sumbit-install": "Install",
"submit-update": "Update",
"reset": "Reset app",
"errors": {
"required": "{label} is required",
"regex": "{label} does not match the pattern {pattern}",

View file

@ -108,6 +108,7 @@
"apps": {
"status-running": "Running",
"status-stopped": "Stopped",
"status-resetting": "Resetting",
"status-starting": "Starting",
"status-stopping": "Stopping",
"status-updating": "Updating",
@ -136,6 +137,7 @@
"start-success": "App started successfully",
"update-config-success": "App config updated successfully. Restart the app to apply the changes",
"version": "Version",
"app-reset-success": "App reset successfully",
"description": "Description",
"base-info": "Base info",
"source-code": "Source code",
@ -183,6 +185,7 @@
"choose-option": "Choose an option...",
"sumbit-install": "Install",
"submit-update": "Update",
"reset": "Reset app",
"errors": {
"required": "{label} is required",
"regex": "{label} must match the pattern {pattern}",
@ -208,6 +211,12 @@
"warning": "Are you sure? This action cannot be undone.",
"submit": "Uninstall"
},
"reset-app-form": {
"title": "Reset {name} ?",
"subtitle": "All data for this app will be lost.",
"warning": "Are you sure? This action cannot be undone.",
"submit": "Reset"
},
"update-form": {
"title": "Update {name} ?",
"subtitle1": "Update app to latest verion :",

View file

@ -351,6 +351,21 @@ describe('Update app config', () => {
});
});
describe('Reset app', () => {
it('Should correctly reset app', async () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({ status: 'running' }, appConfig, db);
// act
await AppsService.resetApp(appConfig.id);
const app = await getAppById(appConfig.id, db);
// assert
expect(app?.status).toBe('running');
});
});
describe('Get app config', () => {
it('Should correctly get app config', async () => {
// arrange

View file

@ -366,6 +366,20 @@ export class AppServiceClass {
return updatedApp;
};
/**
* Reset App with the specified ID
*
* @param {string} id - ID of the app to reset
* @throws {Error} - If the app is not found or if the update process fails.
*/
public resetApp = async (id: string) => {
const appInfo = await this.getApp(id);
await this.stopApp(id);
await this.uninstallApp(id);
await this.installApp(id, castAppConfig(appInfo.config));
};
/**
* Returns a list of all installed apps
*/