diff --git a/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsContainer.tsx b/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsContainer.tsx index e98d0c97..a5a3fdb2 100644 --- a/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsContainer.tsx +++ b/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsContainer.tsx @@ -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>; @@ -40,6 +42,7 @@ export const AppDetailsContainer: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ app, localDomain }) => { +
diff --git a/src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx b/src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx index c0e7d1e1..5ba9086b 100644 --- a/src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx +++ b/src/app/(dashboard)/app-store/[id]/components/InstallForm/InstallForm.tsx @@ -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 = ({ formFields, info, onSubmit, initalValues, loading }) => { +export const InstallForm: React.FC = ({ 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 = ({ formFields, info, onSubmit, init } }, [initalValues, isDirty, setValue]); + const onClickReset = (e: React.MouseEvent) => { + e.preventDefault(); + onReset(); + }; + const renderField = (field: FormField) => { const label = ( <> @@ -166,6 +174,11 @@ export const InstallForm: React.FC = ({ formFields, info, onSubmit, init + {initalValues && ( + + )} ); }; diff --git a/src/app/(dashboard)/app-store/[id]/components/ResetAppModal.tsx/ResetAppModal.tsx b/src/app/(dashboard)/app-store/[id]/components/ResetAppModal.tsx/ResetAppModal.tsx new file mode 100644 index 00000000..61bff723 --- /dev/null +++ b/src/app/(dashboard)/app-store/[id]/components/ResetAppModal.tsx/ResetAppModal.tsx @@ -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 = ({ info, isOpen, onClose, onConfirm, isLoading }) => { + const t = useTranslations('apps.app-details.reset-app-form'); + + return ( + + + +
{t('title', { name: info.name })}
+
+ + +

{t('warning')}

+
{t('subtitle')}
+
+ + + +
+
+ ); +}; diff --git a/src/app/(dashboard)/app-store/[id]/components/ResetAppModal.tsx/index.ts b/src/app/(dashboard)/app-store/[id]/components/ResetAppModal.tsx/index.ts new file mode 100644 index 00000000..8d60293f --- /dev/null +++ b/src/app/(dashboard)/app-store/[id]/components/ResetAppModal.tsx/index.ts @@ -0,0 +1 @@ +export { ResetAppModal } from './ResetAppModal'; diff --git a/src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx b/src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx index a70930a0..3b039a69 100644 --- a/src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx +++ b/src/app/(dashboard)/app-store/[id]/components/UpdateSettingsModal/UpdateSettingsModal.tsx @@ -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 = ({ info, config, isOpen, onClose, onSubmit }) => { +export const UpdateSettingsModal: React.FC = ({ 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 = ({ info, config, isOpen, on - + diff --git a/src/app/actions/app-actions/reset-app-action.ts b/src/app/actions/app-actions/reset-app-action.ts new file mode 100644 index 00000000..0c20d3e7 --- /dev/null +++ b/src/app/actions/app-actions/reset-app-action.ts @@ -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); + } +}); diff --git a/src/client/messages/en-US.json b/src/client/messages/en-US.json index 0b3d4829..295669fe 100644 --- a/src/client/messages/en-US.json +++ b/src/client/messages/en-US.json @@ -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}", diff --git a/src/client/messages/en.json b/src/client/messages/en.json index e5c9bb68..7caca80f 100644 --- a/src/client/messages/en.json +++ b/src/client/messages/en.json @@ -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 :", diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 8417c344..a3ad809b 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -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]; diff --git a/src/server/services/apps/apps.service.test.ts b/src/server/services/apps/apps.service.test.ts index 5e86d485..ee7ef57d 100644 --- a/src/server/services/apps/apps.service.test.ts +++ b/src/server/services/apps/apps.service.test.ts @@ -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 diff --git a/src/server/services/apps/apps.service.ts b/src/server/services/apps/apps.service.ts index 62e741b8..179f5aeb 100644 --- a/src/server/services/apps/apps.service.ts +++ b/src/server/services/apps/apps.service.ts @@ -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 */