diff --git a/src/client/messages/en.json b/src/client/messages/en.json
index 786e8f4043..55588fd9a2 100644
--- a/src/client/messages/en.json
+++ b/src/client/messages/en.json
@@ -251,6 +251,8 @@
"invalid-domain": "Invalid domain",
"guest-dashboard": "Enable guest dashboard",
"guest-dashboard-hint": "This will allow non-authenticated users to see a limited dashboard and easily access the running apps on your instance.",
+ "allow-error-monitoring": "Allow anonymous error monitoring",
+ "allow-error-monitoring-hint": "Error monitoring is used to track errors and improve Tipi. Keep this option enabled to help us improve Tipi.",
"allow-auto-themes": "Allow auto themes",
"allow-auto-themes-hint": "Be surprised by themes that change automatically based on the time of the year.",
"domain-name": "Domain name",
diff --git a/src/lib/safe-action.ts b/src/lib/safe-action.ts
index 2f9142094a..cfe837a3d5 100644
--- a/src/lib/safe-action.ts
+++ b/src/lib/safe-action.ts
@@ -1,3 +1,12 @@
import { createSafeActionClient } from 'next-safe-action';
-export const action = createSafeActionClient();
+export const action = createSafeActionClient({
+ handleReturnedServerError: (e) => {
+ // eslint-disable-next-line no-console
+ console.error('Error from server', e);
+
+ return {
+ serverError: e.message || 'An unexpected error occurred',
+ };
+ },
+});
diff --git a/src/lib/socket/useSocket.ts b/src/lib/socket/useSocket.ts
new file mode 100644
index 0000000000..e3ffbde947
--- /dev/null
+++ b/src/lib/socket/useSocket.ts
@@ -0,0 +1,71 @@
+import { SocketEvent, socketEventSchema } from '@runtipi/shared/src/schemas/socket';
+import { useEffect } from 'react';
+import io from 'socket.io-client';
+
+// Data selector is used to select a specific property/value from the data object if it exists
+type DataSelector = {
+ property: keyof Extract['data'];
+ value: unknown;
+};
+
+type Selector = {
+ type: T;
+ event?: U;
+ data?: DataSelector;
+};
+
+type Props = {
+ onEvent: (event: Extract['event'], U>, data: Extract['data']) => void;
+ onError?: (error: string) => void;
+ selector: Selector;
+};
+
+export const useSocket = (props: Props) => {
+ const { onEvent, onError, selector } = props;
+
+ useEffect(() => {
+ const socket = io('http://localhost:3935');
+
+ const handleEvent = (type: SocketEvent['type'], rawData: unknown) => {
+ const parsedEvent = socketEventSchema.safeParse(rawData);
+
+ if (!parsedEvent.success) {
+ return;
+ }
+
+ const { event, data } = parsedEvent.data;
+
+ if (selector) {
+ if (selector.type !== type) {
+ return;
+ }
+
+ if (selector.event && selector.event !== event) {
+ return;
+ }
+
+ const property = selector.data?.property as keyof SocketEvent['data'];
+ if (selector.data && selector.data.value !== data[property]) {
+ return;
+ }
+ }
+
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore - This is fine
+ onEvent(event, data);
+ };
+
+ socket.on(selector.type as string, (data) => {
+ handleEvent(selector.type, data);
+ });
+
+ socket.on('error', (error: string) => {
+ onError?.(String(error));
+ });
+
+ return () => {
+ socket?.off(selector.type as string);
+ socket.disconnect();
+ };
+ }, [onError, onEvent, selector, selector.type]);
+};
diff --git a/src/server/queries/apps/apps.queries.ts b/src/server/queries/apps/apps.queries.ts
index 1a75360f91..7545043a00 100644
--- a/src/server/queries/apps/apps.queries.ts
+++ b/src/server/queries/apps/apps.queries.ts
@@ -25,7 +25,7 @@ export class AppQueries {
* @param {Partial} data - The data to update the app with
*/
public async updateApp(appId: string, data: Partial) {
- const updatedApps = await this.db.update(appTable).set(data).where(eq(appTable.id, appId)).returning();
+ const updatedApps = await this.db.update(appTable).set(data).where(eq(appTable.id, appId)).returning().execute();
return updatedApps[0];
}
@@ -35,7 +35,7 @@ export class AppQueries {
* @param {string} appId - The id of the app to delete
*/
public async deleteApp(appId: string) {
- await this.db.delete(appTable).where(eq(appTable.id, appId));
+ await this.db.delete(appTable).where(eq(appTable.id, appId)).execute();
}
/**
@@ -44,7 +44,7 @@ export class AppQueries {
* @param {NewApp} data - The data to create the app with
*/
public async createApp(data: NewApp) {
- const newApps = await this.db.insert(appTable).values(data).returning();
+ const newApps = await this.db.insert(appTable).values(data).returning().execute();
return newApps[0];
}
@@ -68,7 +68,10 @@ export class AppQueries {
* Returns all apps that are running and visible on guest dashboard sorted by id ascending
*/
public async getGuestDashboardApps() {
- return this.db.query.appTable.findMany({ where: and(eq(appTable.status, 'running'), eq(appTable.isVisibleOnGuestDashboard, true)), orderBy: asc(appTable.id) });
+ return this.db.query.appTable.findMany({
+ where: and(eq(appTable.status, 'running'), eq(appTable.isVisibleOnGuestDashboard, true)),
+ orderBy: asc(appTable.id),
+ });
}
/**
@@ -88,6 +91,6 @@ export class AppQueries {
* @param {Partial} data - The data to update the apps with
*/
public async updateAppsByStatusNotIn(statuses: AppStatus[], data: Partial) {
- return this.db.update(appTable).set(data).where(notInArray(appTable.status, statuses)).returning();
+ return this.db.update(appTable).set(data).where(notInArray(appTable.status, statuses)).returning().execute();
}
}
diff --git a/src/server/run-migrations-dev.ts b/src/server/run-migrations-dev.ts
index 6567221ae4..3632673a8b 100644
--- a/src/server/run-migrations-dev.ts
+++ b/src/server/run-migrations-dev.ts
@@ -65,6 +65,7 @@ const main = async () => {
};
main().catch((e) => {
+ // eslint-disable-next-line no-console
console.error(e);
process.exit(1);
});
diff --git a/src/server/services/apps/apps.service.test.ts b/src/server/services/apps/apps.service.test.ts
index 87d4da4a8b..63b017e87f 100644
--- a/src/server/services/apps/apps.service.test.ts
+++ b/src/server/services/apps/apps.service.test.ts
@@ -2,7 +2,6 @@ import fs from 'fs-extra';
import waitForExpect from 'wait-for-expect';
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { faker } from '@faker-js/faker';
-import { waitUntilFinishedMock } from '@/tests/server/jest.setup';
import { castAppConfig } from '@/lib/helpers/castAppConfig';
import { AppServiceClass } from './apps.service';
import { EventDispatcher } from '../../core/EventDispatcher';
@@ -42,7 +41,7 @@ describe('Install app', () => {
expect(dbApp).toBeDefined();
expect(dbApp?.id).toBe(appConfig.id);
expect(dbApp?.config).toStrictEqual({ TEST_FIELD: 'test' });
- expect(dbApp?.status).toBe('running');
+ expect(dbApp?.status).toBe('installing');
});
it('Should start app if already installed', async () => {
@@ -58,25 +57,14 @@ describe('Install app', () => {
expect(app?.status).toBe('running');
});
- it('Should delete app if install script fails', async () => {
- // arrange
- const appConfig = createAppConfig();
-
- // act
- waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'test' });
- await expect(AppsService.installApp(appConfig.id, {})).rejects.toThrow('server-messages.errors.app-failed-to-install');
- const app = await getAppById(appConfig.id, db);
-
- // assert
- expect(app).toBeNull();
- });
-
it('Should throw if app is exposed and domain is not provided', async () => {
// arrange
const appConfig = createAppConfig({ exposable: true });
// act & assert
- await expect(AppsService.installApp(appConfig.id, { exposed: true })).rejects.toThrowError('server-messages.errors.domain-required-if-expose-app');
+ await expect(AppsService.installApp(appConfig.id, { exposed: true })).rejects.toThrowError(
+ 'server-messages.errors.domain-required-if-expose-app',
+ );
});
it('Should throw if app is exposed and config does not allow it', async () => {
@@ -84,7 +72,9 @@ describe('Install app', () => {
const appConfig = createAppConfig({ exposable: false });
// act & assert
- await expect(AppsService.installApp(appConfig.id, { exposed: true, domain: 'test.com' })).rejects.toThrowError('server-messages.errors.app-not-exposable');
+ await expect(AppsService.installApp(appConfig.id, { exposed: true, domain: 'test.com' })).rejects.toThrowError(
+ 'server-messages.errors.app-not-exposable',
+ );
});
it('Should throw if app is exposed and domain is not valid', async () => {
@@ -92,7 +82,9 @@ describe('Install app', () => {
const appConfig = createAppConfig({ exposable: true });
// act & assert
- await expect(AppsService.installApp(appConfig.id, { exposed: true, domain: 'test' })).rejects.toThrowError('server-messages.errors.domain-not-valid');
+ await expect(AppsService.installApp(appConfig.id, { exposed: true, domain: 'test' })).rejects.toThrowError(
+ 'server-messages.errors.domain-not-valid',
+ );
});
it('Should throw if app is exposed and domain is already used by another exposed app', async () => {
@@ -103,7 +95,9 @@ describe('Install app', () => {
await insertApp({ domain, exposed: true }, appConfig2, db);
// act & assert
- await expect(AppsService.installApp(appConfig.id, { exposed: true, domain })).rejects.toThrowError('server-messages.errors.domain-already-in-use');
+ await expect(AppsService.installApp(appConfig.id, { exposed: true, domain })).rejects.toThrowError(
+ 'server-messages.errors.domain-already-in-use',
+ );
});
it('Should throw if architecure is not supported', async () => {
@@ -159,51 +153,10 @@ describe('Install app', () => {
});
describe('Uninstall app', () => {
- it('Should correctly remove app from database', async () => {
- // arrange
- const appConfig = createAppConfig({});
- await insertApp({}, appConfig, db);
-
- // act
- await AppsService.uninstallApp(appConfig.id);
- const app = await getAppById(appConfig.id, db);
-
- // assert
- expect(app).toBeNull();
- });
-
- it('Should stop app if it is running', async () => {
- // arrange
- const appConfig = createAppConfig({});
- await insertApp({ status: 'running' }, appConfig, db);
-
- // act
- waitUntilFinishedMock.mockResolvedValueOnce({ success: true, stdout: 'test' });
- waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'test' });
- await expect(AppsService.uninstallApp(appConfig.id)).rejects.toThrow('server-messages.errors.app-failed-to-uninstall');
- const app = await getAppById(appConfig.id, db);
-
- // assert
- expect(app?.status).toBe('stopped');
- });
-
it('Should throw if app is not installed', async () => {
// act & assert
await expect(AppsService.uninstallApp('any')).rejects.toThrowError('server-messages.errors.app-not-found');
});
-
- it('Should throw if uninstall script fails', async () => {
- // arrange
- const appConfig = createAppConfig({});
- await insertApp({ status: 'running' }, appConfig, db);
- waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'test' });
- await updateApp(appConfig.id, { status: 'updating' }, db);
-
- // act & assert
- await expect(AppsService.uninstallApp(appConfig.id)).rejects.toThrow('server-messages.errors.app-failed-to-uninstall');
- const app = await getAppById(appConfig.id, db);
- expect(app?.status).toBe('stopped');
- });
});
describe('Start app', () => {
@@ -237,49 +190,12 @@ describe('Start app', () => {
// assert
expect(app?.status).toBe('running');
});
-
- it('Should throw if start script fails', async () => {
- // arrange
- const appConfig = createAppConfig({});
- await insertApp({ status: 'stopped' }, appConfig, db);
- waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'test' });
-
- // act & assert
- await expect(AppsService.startApp(appConfig.id)).rejects.toThrow('server-messages.errors.app-failed-to-start');
- const app = await getAppById(appConfig.id, db);
- expect(app?.status).toBe('stopped');
- });
});
describe('Stop app', () => {
- it('Should correctly stop app', async () => {
- // arrange
- const appConfig = createAppConfig({});
- await insertApp({ status: 'running' }, appConfig, db);
-
- // act
- await AppsService.stopApp(appConfig.id);
- const app = await getAppById(appConfig.id, db);
-
- // assert
- expect(app?.status).toBe('stopped');
- });
-
it('Should throw if app is not installed', async () => {
await expect(AppsService.stopApp('any')).rejects.toThrowError('server-messages.errors.app-not-found');
});
-
- it('Should throw if stop script fails', async () => {
- // arrange
- const appConfig = createAppConfig({});
- await insertApp({ status: 'running' }, appConfig, db);
- waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'test' });
-
- // act & assert
- await expect(AppsService.stopApp(appConfig.id)).rejects.toThrow('server-messages.errors.app-failed-to-stop');
- const app = await getAppById(appConfig.id, db);
- expect(app?.status).toBe('running');
- });
});
describe('Update app config', () => {
@@ -317,7 +233,9 @@ describe('Update app config', () => {
await insertApp({}, appConfig, db);
// act & assert
- expect(AppsService.updateAppConfig(appConfig.id, { exposed: true, domain: 'test' })).rejects.toThrowError('server-messages.errors.domain-not-valid');
+ expect(AppsService.updateAppConfig(appConfig.id, { exposed: true, domain: 'test' })).rejects.toThrowError(
+ 'server-messages.errors.domain-not-valid',
+ );
});
it('Should throw if app is exposed and domain is already used', async () => {
@@ -329,7 +247,9 @@ describe('Update app config', () => {
await insertApp({}, appConfig2, db);
// act & assert
- await expect(AppsService.updateAppConfig(appConfig2.id, { exposed: true, domain })).rejects.toThrowError('server-messages.errors.domain-already-in-use');
+ await expect(AppsService.updateAppConfig(appConfig2.id, { exposed: true, domain })).rejects.toThrowError(
+ 'server-messages.errors.domain-already-in-use',
+ );
});
it('should throw if app is not exposed and config has force_expose set to true', async () => {
@@ -347,22 +267,9 @@ describe('Update app config', () => {
await insertApp({}, appConfig, db);
// act & assert
- await expect(AppsService.updateAppConfig(appConfig.id, { exposed: true, domain: 'test.com' })).rejects.toThrowError('server-messages.errors.app-not-exposable');
- });
-});
-
-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');
+ await expect(AppsService.updateAppConfig(appConfig.id, { exposed: true, domain: 'test.com' })).rejects.toThrowError(
+ 'server-messages.errors.app-not-exposable',
+ );
});
});
@@ -494,17 +401,6 @@ describe('Update app', () => {
await expect(AppsService.updateApp('test-app2')).rejects.toThrow('server-messages.errors.app-not-found');
});
- it('Should throw if update script fails', async () => {
- // arrange
- const appConfig = createAppConfig({});
- await insertApp({}, appConfig, db);
- waitUntilFinishedMock.mockResolvedValueOnce({ success: false, stdout: 'error' });
-
- // act & assert
- await expect(AppsService.updateApp(appConfig.id)).rejects.toThrow('server-messages.errors.app-failed-to-update');
- const app = await getAppById(appConfig.id, db);
- expect(app?.status).toBe('stopped');
- });
it('Should comme back to the previous status before the update of the app', async () => {
// arrange
const appConfig = createAppConfig({});
diff --git a/src/server/services/apps/apps.service.ts b/src/server/services/apps/apps.service.ts
index be0da68ed7..5002f2bb6f 100644
--- a/src/server/services/apps/apps.service.ts
+++ b/src/server/services/apps/apps.service.ts
@@ -2,7 +2,7 @@ import validator from 'validator';
import { App } from '@/server/db/schema';
import { AppQueries } from '@/server/queries/apps/apps.queries';
import { TranslatedError } from '@/server/utils/errors';
-import { Database } from '@/server/db';
+import { Database, db } from '@/server/db';
import { AppInfo } from '@runtipi/shared';
import { EventDispatcher } from '@/server/core/EventDispatcher/EventDispatcher';
import { castAppConfig } from '@/lib/helpers/castAppConfig';
@@ -32,7 +32,7 @@ const filterApps = (apps: AppInfo[]): AppInfo[] => apps.sort(sortApps).filter(fi
export class AppServiceClass {
private queries;
- constructor(p: Database) {
+ constructor(p: Database = db) {
this.queries = new AppQueries(p);
}
@@ -55,13 +55,15 @@ export class AppServiceClass {
try {
await this.queries.updateApp(app.id, { status: 'starting' });
- eventDispatcher.dispatchEventAsync({ type: 'app', command: 'start', appid: app.id, form: castAppConfig(app.config) }).then(({ success }) => {
- if (success) {
- this.queries.updateApp(app.id, { status: 'running' });
- } else {
- this.queries.updateApp(app.id, { status: 'stopped' });
- }
- });
+ eventDispatcher
+ .dispatchEventAsync({ type: 'app', command: 'start', appid: app.id, form: castAppConfig(app.config) })
+ .then(({ success }) => {
+ if (success) {
+ this.queries.updateApp(app.id, { status: 'running' });
+ } else {
+ this.queries.updateApp(app.id, { status: 'stopped' });
+ }
+ });
} catch (e) {
await this.queries.updateApp(app.id, { status: 'stopped' });
Logger.error(e);
@@ -87,7 +89,12 @@ export class AppServiceClass {
await this.queries.updateApp(appName, { status: 'starting' });
const eventDispatcher = new EventDispatcher('startApp');
- const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'start', appid: appName, form: castAppConfig(app.config) });
+ const { success, stdout } = await eventDispatcher.dispatchEventAsync({
+ type: 'app',
+ command: 'start',
+ appid: appName,
+ form: castAppConfig(app.config),
+ });
await eventDispatcher.close();
if (success) {
@@ -166,18 +173,17 @@ export class AppServiceClass {
// Run script
const eventDispatcher = new EventDispatcher('installApp');
- const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'install', appid: id, form });
- await eventDispatcher.close();
+ eventDispatcher.dispatchEventAsync({ type: 'app', command: 'install', appid: id, form }).then(({ success, stdout }) => {
+ if (success) {
+ this.queries.updateApp(id, { status: 'running' });
+ } else {
+ this.queries.deleteApp(id);
+ Logger.error(`Failed to install app ${id}: ${stdout}`);
+ }
- if (!success) {
- await this.queries.deleteApp(id);
- Logger.error(`Failed to install app ${id}: ${stdout}`);
- throw new TranslatedError('server-messages.errors.app-failed-to-install', { id });
- }
+ eventDispatcher.close();
+ });
}
-
- const updatedApp = await this.queries.updateApp(id, { status: 'running' });
- return updatedApp;
};
/**
@@ -240,7 +246,12 @@ export class AppServiceClass {
await eventDispatcher.close();
if (success) {
- const updatedApp = await this.queries.updateApp(id, { exposed: exposed || false, domain: domain || null, config: form, isVisibleOnGuestDashboard: form.isVisibleOnGuestDashboard });
+ const updatedApp = await this.queries.updateApp(id, {
+ exposed: exposed || false,
+ domain: domain || null,
+ config: form,
+ isVisibleOnGuestDashboard: form.isVisibleOnGuestDashboard,
+ });
return updatedApp;
}
@@ -264,16 +275,16 @@ export class AppServiceClass {
await this.queries.updateApp(id, { status: 'stopping' });
const eventDispatcher = new EventDispatcher('stopApp');
- const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'stop', appid: id, form: castAppConfig(app.config) });
- await eventDispatcher.close();
+ eventDispatcher.dispatchEventAsync({ type: 'app', command: 'stop', appid: id, form: castAppConfig(app.config) }).then(({ success, stdout }) => {
+ if (success) {
+ this.queries.updateApp(id, { status: 'stopped' });
+ } else {
+ Logger.error(`Failed to stop app ${id}: ${stdout}`);
+ this.queries.updateApp(id, { status: 'running' });
+ }
- if (success) {
- await this.queries.updateApp(id, { status: 'stopped' });
- } else {
- await this.queries.updateApp(id, { status: 'running' });
- Logger.error(`Failed to stop app ${id}: ${stdout}`);
- throw new TranslatedError('server-messages.errors.app-failed-to-stop', { id });
- }
+ eventDispatcher.close();
+ });
const updatedApp = await this.queries.getApp(id);
return updatedApp;
@@ -298,16 +309,17 @@ export class AppServiceClass {
await this.queries.updateApp(id, { status: 'uninstalling' });
const eventDispatcher = new EventDispatcher('uninstallApp');
- const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'uninstall', appid: id, form: castAppConfig(app.config) });
- await eventDispatcher.close();
-
- if (!success) {
- await this.queries.updateApp(id, { status: 'stopped' });
- Logger.error(`Failed to uninstall app ${id}: ${stdout}`);
- throw new TranslatedError('server-messages.errors.app-failed-to-uninstall', { id });
- }
-
- await this.queries.deleteApp(id);
+ eventDispatcher
+ .dispatchEventAsync({ type: 'app', command: 'uninstall', appid: id, form: castAppConfig(app.config) })
+ .then(({ stdout, success }) => {
+ if (success) {
+ this.queries.deleteApp(id);
+ } else {
+ this.queries.updateApp(id, { status: 'stopped' });
+ Logger.error(`Failed to uninstall app ${id}: ${stdout}`);
+ }
+ eventDispatcher.close();
+ });
return { id, status: 'missing', config: {} };
};
@@ -350,7 +362,12 @@ export class AppServiceClass {
await this.queries.updateApp(id, { status: 'updating' });
const eventDispatcher = new EventDispatcher('updateApp');
- const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'update', appid: id, form: castAppConfig(app.config) });
+ const { success, stdout } = await eventDispatcher.dispatchEventAsync({
+ type: 'app',
+ command: 'update',
+ appid: id,
+ form: castAppConfig(app.config),
+ });
await eventDispatcher.close();
if (success) {
diff --git a/tsconfig.json b/tsconfig.json
index db94332da0..d10ebb08bd 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -6,6 +6,9 @@
"@/components/*": [
"./src/client/components/*"
],
+ "@/hooks/*": [
+ "./src/app/hooks/*"
+ ],
"@/utils/*": [
"./src/client/utils/*"
],