feat(reset-app): adding a reset app from setting modal
This commit is contained in:
parent
8bfafac7bd
commit
dd32bd631f
11 changed files with 157 additions and 4 deletions
|
@ -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,18 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
|||
},
|
||||
});
|
||||
|
||||
const resetMutation = useAction(resetAppAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} 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 +162,17 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
|||
updateMutation.execute({ id: app.id });
|
||||
};
|
||||
|
||||
const handleResetSubmit = () => {
|
||||
setCustomStatus('resetting');
|
||||
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 +203,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={customStatus === 'resetting'} />
|
||||
<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} />
|
||||
|
|
|
@ -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();
|
||||
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 && (
|
||||
<Button loading={status === 'resetting'} onClick={onClickReset} className="btn-danger ms-2">
|
||||
{t('reset')}
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { ResetAppModal } from './ResetAppModal';
|
|
@ -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>
|
||||
|
|
28
src/app/actions/app-actions/reset-app-action.ts
Normal file
28
src/app/actions/app-actions/reset-app-action.ts
Normal 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);
|
||||
}
|
||||
});
|
|
@ -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}",
|
||||
|
|
|
@ -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} app ?",
|
||||
"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 :",
|
||||
|
|
|
@ -2,7 +2,7 @@ import { InferModel } from 'drizzle-orm';
|
|||
import { pgTable, pgEnum, integer, varchar, timestamp, serial, boolean, text, jsonb } from 'drizzle-orm/pg-core';
|
||||
|
||||
const updateStatusEnum = pgEnum('update_status_enum', ['SUCCESS', 'FAILED']);
|
||||
const appStatusEnum = pgEnum('app_status_enum', ['running', 'stopped', 'starting', 'stopping', 'updating', 'missing', 'installing', 'uninstalling']);
|
||||
const appStatusEnum = pgEnum('app_status_enum', ['running', 'stopped', 'starting', 'stopping', 'updating', 'missing', 'installing', 'uninstalling', 'resetting']);
|
||||
|
||||
const APP_STATUS = appStatusEnum.enumValues;
|
||||
export type AppStatus = (typeof APP_STATUS)[number];
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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, appInfo.config);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a list of all installed apps
|
||||
*/
|
||||
|
|
Loading…
Reference in a new issue