Compare commits

...

4 commits

Author SHA1 Message Date
Nicolas Meienberger
76f9637d77 refactor(dashboard): use onError on server actions 2023-12-06 21:52:49 +01:00
Nicolas Meienberger
25c39b92e5 refactor(dashboard): react to socket events and update app status 2023-12-06 21:07:04 +01:00
Nicolas Meienberger
52a2753522 feat(worker): add socket manager 2023-12-06 21:07:04 +01:00
Nicolas Meienberger
7f98f65bd0 chore(deps): install socket.io 2023-12-06 21:06:48 +01:00
29 changed files with 711 additions and 263 deletions

View file

@ -2,5 +2,5 @@ module.exports = {
singleQuote: true,
semi: true,
trailingComma: 'all',
printWidth: 200,
printWidth: 150,
};

View file

@ -60,6 +60,9 @@ services:
context: .
dockerfile: ./packages/worker/Dockerfile.dev
container_name: tipi-worker
user: root
ports:
- 3935:3001
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck']
interval: 5s

View file

@ -60,6 +60,8 @@ services:
context: .
dockerfile: ./packages/worker/Dockerfile
container_name: tipi-worker
ports:
- 3935:3001
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck']
interval: 5s

View file

@ -4,14 +4,13 @@
"description": "A homeserver for everyone",
"scripts": {
"knip": "knip",
"prepare": "mkdir -p state && echo \"{}\" > state/system-info.json && echo \"random-seed\" > state/seed",
"test": "dotenv -e .env.test -- jest --colors",
"test:e2e": "NODE_ENV=test dotenv -e .env -e .env.e2e -- playwright test",
"test:e2e:ui": "NODE_ENV=test dotenv -e .env -e .env.e2e -- playwright test --ui",
"test:client": "jest --colors --selectProjects client --",
"test:server": "jest --colors --selectProjects server --",
"test:vite": "dotenv -e .env.test -- vitest run --coverage",
"dev": "next dev",
"dev": "DEBUG=socket.io:client* next dev",
"dev:watcher": "pnpm -r --filter cli dev",
"db:migrate": "NODE_ENV=development dotenv -e .env.local -- tsx ./src/server/run-migrations-dev.ts",
"lint": "next lint",
@ -20,8 +19,8 @@
"start": "NODE_ENV=production node server.js",
"start:dev-container": "./.devcontainer/filewatcher.sh && npm run start:dev",
"start:rc": "docker compose -f docker-compose.rc.yml --env-file .env up --build",
"start:dev": "npm run prepare && docker compose -f docker-compose.dev.yml up --build",
"start:prod": "npm run prepare && docker compose --env-file ./.env -f docker-compose.prod.yml up --build",
"start:dev": "docker compose -f docker-compose.dev.yml up --build",
"start:prod": "docker compose --env-file ./.env -f docker-compose.prod.yml up --build",
"start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres:14",
"version": "echo $npm_package_version",
"release:rc": "./scripts/deploy/release-rc.sh",
@ -76,6 +75,7 @@
"sass": "^1.69.5",
"semver": "^7.5.4",
"sharp": "0.32.6",
"socket.io-client": "^4.7.2",
"swr": "^2.2.4",
"tslib": "^2.6.2",
"uuid": "^9.0.1",

View file

@ -60,6 +60,8 @@ services:
container_name: tipi-worker
image: ghcr.io/runtipi/worker:${TIPI_VERSION}
restart: unless-stopped
ports:
- 3935:3001
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck']
interval: 5s

View file

@ -0,0 +1,35 @@
import { z } from 'zod';
export const socketEventSchema = z.union([
z.object({
type: z.literal('app'),
event: z.union([
z.literal('status_change'),
z.literal('install_success'),
z.literal('install_error'),
z.literal('uninstall_success'),
z.literal('uninstall_error'),
z.literal('update_success'),
z.literal('update_error'),
z.literal('start_success'),
z.literal('start_error'),
z.literal('stop_success'),
z.literal('stop_error'),
z.literal('generate_env_success'),
z.literal('generate_env_error'),
]),
data: z.object({
appId: z.string(),
error: z.string().optional(),
}),
}),
z.object({
type: z.literal('dummy'),
event: z.literal('dummy_event'),
data: z.object({
dummy: z.string(),
}),
}),
]);
export type SocketEvent = z.infer<typeof socketEventSchema>;

View file

@ -1,5 +1,5 @@
{
"watch": ["src"],
"exec": "NODE_ENV=development tsx ./src/index.ts",
"exec": "NODE_ENV=development DEBUG=socket.io:client* tsx ./src/index.ts",
"ext": "js ts"
}

View file

@ -35,6 +35,7 @@
"dotenv": "^16.3.1",
"ioredis": "^5.3.2",
"pg": "^8.11.3",
"socket.io": "^4.7.2",
"systeminformation": "^5.21.15",
"web-push": "^3.6.6",
"zod": "^3.22.4"

View file

@ -1,4 +1,6 @@
import { SystemEvent } from '@runtipi/shared';
import { Server } from 'socket.io';
import http from 'node:http';
import path from 'node:path';
import Redis from 'ioredis';
@ -9,6 +11,7 @@ import { runPostgresMigrations } from '@/lib/migrations';
import { startWorker } from './watcher/watcher';
import { logger } from '@/lib/logger';
import { AppExecutors } from './services';
import { SocketManager } from './lib/socket/SocketManager';
const rootFolder = '/app';
const envFile = path.join(rootFolder, '.env');
@ -85,6 +88,16 @@ const main = async () => {
server.listen(3000, () => {
startWorker();
});
const io = new Server(3001, { cors: { origin: '*' } });
io.on('connection', (socket) => {
SocketManager.addSocket(socket);
socket.on('disconnect', () => {
SocketManager.removeSocket();
});
});
} catch (e) {
logger.error(e);
process.exit(1);

View file

@ -0,0 +1,30 @@
import { SocketEvent } from '@runtipi/shared/src/schemas/socket';
import { Socket } from 'socket.io';
import { logger } from '../logger';
class SocketManager {
private socket: Socket | null = null;
addSocket(socket: Socket) {
if (!this.socket) {
this.socket = socket;
}
}
removeSocket() {
this.socket = null;
}
emit(event: SocketEvent) {
if (!this.socket) {
logger.warn('Socket is not connected');
return;
}
this.socket.emit(event.type, event);
}
}
const instance = new SocketManager();
export { instance as SocketManager };

View file

@ -4,11 +4,13 @@ import fs from 'fs';
import path from 'path';
import pg from 'pg';
import { execAsync, pathExists } from '@runtipi/shared';
import { SocketEvent } from '@runtipi/shared/src/schemas/socket';
import { copyDataDir, generateEnvFile } from './app.helpers';
import { logger } from '@/lib/logger';
import { compose } from '@/lib/docker';
import { getEnv } from '@/lib/environment';
import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants';
import { SocketManager } from '@/lib/socket/SocketManager';
const getDbClient = async () => {
const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getEnv();
@ -33,12 +35,14 @@ export class AppExecutors {
this.logger = logger;
}
private handleAppError = (err: unknown) => {
private handleAppError = (err: unknown, appId: string, event: Extract<SocketEvent, { type: 'app' }>['event']) => {
if (err instanceof Error) {
SocketManager.emit({ type: 'app', event, data: { appId, error: err.message } });
this.logger.error(`An error occurred: ${err.message}`);
return { success: false, message: err.message };
}
SocketManager.emit({ type: 'app', event, data: { appId, error: String(err) } });
return { success: false, message: `An error occurred: ${err}` };
};
@ -73,6 +77,19 @@ export class AppExecutors {
}
};
public regenerateAppEnv = async (appId: string, config: Record<string, unknown>) => {
try {
this.logger.info(`Regenerating app.env file for app ${appId}`);
await this.ensureAppDir(appId);
await generateEnvFile(appId, config);
SocketManager.emit({ type: 'app', event: 'generate_env_success', data: { appId } });
return { success: true, message: `App ${appId} env file regenerated successfully` };
} catch (err) {
return this.handleAppError(err, appId, 'generate_env_error');
}
};
/**
* Install an app from the repo
* @param {string} appId - The id of the app to install
@ -80,6 +97,8 @@ export class AppExecutors {
*/
public installApp = async (appId: string, config: Record<string, unknown>) => {
try {
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
if (process.getuid && process.getgid) {
this.logger.info(`Installing app ${appId} as User ID: ${process.getuid()}, Group ID: ${process.getgid()}`);
} else {
@ -134,9 +153,11 @@ export class AppExecutors {
this.logger.info(`Docker-compose up for app ${appId} finished`);
SocketManager.emit({ type: 'app', event: 'install_success', data: { appId } });
return { success: true, message: `App ${appId} installed successfully` };
} catch (err) {
return this.handleAppError(err);
return this.handleAppError(err, appId, 'install_error');
}
};
@ -147,6 +168,7 @@ export class AppExecutors {
*/
public stopApp = async (appId: string, config: Record<string, unknown>, skipEnvGeneration = false) => {
try {
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
this.logger.info(`Stopping app ${appId}`);
await this.ensureAppDir(appId);
@ -158,14 +180,19 @@ export class AppExecutors {
await compose(appId, 'rm --force --stop');
this.logger.info(`App ${appId} stopped`);
SocketManager.emit({ type: 'app', event: 'stop_success', data: { appId } });
return { success: true, message: `App ${appId} stopped successfully` };
} catch (err) {
return this.handleAppError(err);
return this.handleAppError(err, appId, 'stop_error');
}
};
public startApp = async (appId: string, config: Record<string, unknown>, skipEnvGeneration = false) => {
try {
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
const { appDataDirPath } = this.getAppPaths(appId);
this.logger.info(`Starting app ${appId}`);
@ -186,14 +213,18 @@ export class AppExecutors {
this.logger.error(`Error setting permissions for app ${appId}`);
});
SocketManager.emit({ type: 'app', event: 'start_success', data: { appId } });
return { success: true, message: `App ${appId} started successfully` };
} catch (err) {
return this.handleAppError(err);
return this.handleAppError(err, appId, 'start_error');
}
};
public uninstallApp = async (appId: string, config: Record<string, unknown>) => {
try {
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
const { appDirPath, appDataDirPath } = this.getAppPaths(appId);
this.logger.info(`Uninstalling app ${appId}`);
@ -221,14 +252,19 @@ export class AppExecutors {
});
this.logger.info(`App ${appId} uninstalled`);
SocketManager.emit({ type: 'app', event: 'uninstall_success', data: { appId } });
return { success: true, message: `App ${appId} uninstalled successfully` };
} catch (err) {
return this.handleAppError(err);
return this.handleAppError(err, appId, 'uninstall_error');
}
};
public updateApp = async (appId: string, config: Record<string, unknown>) => {
try {
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
const { appDirPath, repoPath } = this.getAppPaths(appId);
this.logger.info(`Updating app ${appId}`);
await this.ensureAppDir(appId);
@ -245,20 +281,11 @@ export class AppExecutors {
await compose(appId, 'pull');
SocketManager.emit({ type: 'app', event: 'update_success', data: { appId } });
return { success: true, message: `App ${appId} updated successfully` };
} catch (err) {
return this.handleAppError(err);
}
};
public regenerateAppEnv = async (appId: string, config: Record<string, unknown>) => {
try {
this.logger.info(`Regenerating app.env file for app ${appId}`);
await this.ensureAppDir(appId);
await generateEnvFile(appId, config);
return { success: true, message: `App ${appId} env file regenerated successfully` };
} catch (err) {
return this.handleAppError(err);
return this.handleAppError(err, appId, 'update_error');
}
};

View file

@ -140,6 +140,9 @@ importers:
sharp:
specifier: 0.32.6
version: 0.32.6
socket.io-client:
specifier: ^4.7.2
version: 4.7.2
swr:
specifier: ^2.2.4
version: 2.2.4(react@18.2.0)
@ -424,6 +427,9 @@ importers:
pg:
specifier: ^8.11.3
version: 8.11.3
socket.io:
specifier: ^4.7.2
version: 4.7.2
systeminformation:
specifier: ^5.21.15
version: 5.21.15
@ -2957,6 +2963,10 @@ packages:
p-map: 4.0.0
dev: true
/@socket.io/component-emitter@3.1.0:
resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==}
dev: false
/@swc/helpers@0.5.2:
resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==}
dependencies:
@ -3200,7 +3210,12 @@ packages:
/@types/cookie@0.4.1:
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
dev: true
/@types/cors@2.8.17:
resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
dependencies:
'@types/node': 20.8.10
dev: false
/@types/debug@4.1.7:
resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
@ -3731,6 +3746,14 @@ packages:
/abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
/accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
dependencies:
mime-types: 2.1.35
negotiator: 0.6.3
dev: false
/acorn-globals@7.0.1:
resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==}
dependencies:
@ -4186,6 +4209,11 @@ packages:
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
/base64id@2.0.0:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
dev: false
/binary-extensions@2.2.0:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'}
@ -4650,6 +4678,14 @@ packages:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
dev: true
/cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
dependencies:
object-assign: 4.1.1
vary: 1.1.2
dev: false
/cosmiconfig@7.1.0:
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
engines: {node: '>=10'}
@ -5097,6 +5133,45 @@ packages:
dependencies:
once: 1.4.0
/engine.io-client@6.5.3:
resolution: {integrity: sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==}
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.4
engine.io-parser: 5.2.1
ws: 8.11.0
xmlhttprequest-ssl: 2.0.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/engine.io-parser@5.2.1:
resolution: {integrity: sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==}
engines: {node: '>=10.0.0'}
dev: false
/engine.io@6.5.4:
resolution: {integrity: sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==}
engines: {node: '>=10.2.0'}
dependencies:
'@types/cookie': 0.4.1
'@types/cors': 2.8.17
'@types/node': 20.8.10
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.4.2
cors: 2.8.5
debug: 4.3.4
engine.io-parser: 5.2.1
ws: 8.11.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/enhanced-resolve@5.12.0:
resolution: {integrity: sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==}
engines: {node: '>=10.13.0'}
@ -10001,6 +10076,56 @@ packages:
is-fullwidth-code-point: 4.0.0
dev: false
/socket.io-adapter@2.5.2:
resolution: {integrity: sha512-87C3LO/NOMc+eMcpcxUBebGjkpMDkNBS9tf7KJqcDsmL936EChtVva71Dw2q4tQcuVC+hAUy4an2NO/sYXmwRA==}
dependencies:
ws: 8.11.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
dev: false
/socket.io-client@4.7.2:
resolution: {integrity: sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w==}
engines: {node: '>=10.0.0'}
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.4
engine.io-client: 6.5.3
socket.io-parser: 4.2.4
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/socket.io-parser@4.2.4:
resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
engines: {node: '>=10.0.0'}
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.4
transitivePeerDependencies:
- supports-color
dev: false
/socket.io@4.7.2:
resolution: {integrity: sha512-bvKVS29/I5fl2FGLNHuXlQaUH/BlzX1IN6S+NKLNZpBsPZIDH+90eQmCs2Railn4YUiww4SzUedJ6+uzwFnKLw==}
engines: {node: '>=10.2.0'}
dependencies:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.5
debug: 4.3.4
engine.io: 6.5.4
socket.io-adapter: 2.5.2
socket.io-parser: 4.2.4
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/source-map-js@1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
@ -10949,6 +11074,11 @@ packages:
engines: {node: '>= 0.10'}
dev: false
/vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
dev: false
/version-selector-type@3.0.0:
resolution: {integrity: sha512-PSvMIZS7C1MuVNBXl/CDG2pZq8EXy/NW2dHIdm3bVP5N0PC8utDK8ttXLXj44Gn3J0lQE3U7Mpm1estAOd+eiA==}
engines: {node: '>=10.13'}
@ -11350,6 +11480,19 @@ packages:
signal-exit: 3.0.7
dev: true
/ws@8.11.0:
resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
dev: false
/ws@8.12.1:
resolution: {integrity: sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew==}
engines: {node: '>=10.0.0'}
@ -11372,6 +11515,11 @@ packages:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
dev: true
/xmlhttprequest-ssl@2.0.0:
resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==}
engines: {node: '>=0.4.0'}
dev: false
/xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}

View file

@ -14,10 +14,11 @@ export function LoginContainer() {
const router = useRouter();
const loginMutation = useAction(loginAction, {
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else if (data.success && data.totpSessionId) {
if (data.success && data.totpSessionId) {
setTotpSessionId(data.totpSessionId);
} else {
router.push('/dashboard');
@ -26,18 +27,27 @@ export function LoginContainer() {
});
const verifyTotpMutation = useAction(verifyTotpAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
router.push('/dashboard');
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onSuccess: () => {
router.push('/dashboard');
},
});
if (totpSessionId) {
return <TotpForm loading={verifyTotpMutation.status === 'executing'} onSubmit={(totpCode) => verifyTotpMutation.execute({ totpCode, totpSessionId })} />;
return (
<TotpForm
loading={verifyTotpMutation.status === 'executing'}
onSubmit={(totpCode) => verifyTotpMutation.execute({ totpCode, totpSessionId })}
/>
);
}
return <LoginForm loading={loginMutation.status === 'executing'} onSubmit={({ email, password }) => loginMutation.execute({ username: email, password })} />;
return (
<LoginForm
loading={loginMutation.status === 'executing'}
onSubmit={({ email, password }) => loginMutation.execute({ username: email, password })}
/>
);
}

View file

@ -11,14 +11,18 @@ export const RegisterContainer: React.FC = () => {
const router = useRouter();
const registerMutation = useAction(registerAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
router.push('/dashboard');
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onSuccess: () => {
router.push('/dashboard');
},
});
return <RegisterForm onSubmit={({ email, password }) => registerMutation.execute({ username: email, password })} loading={registerMutation.status === 'executing'} />;
return (
<RegisterForm
onSubmit={({ email, password }) => registerMutation.execute({ username: email, password })}
loading={registerMutation.status === 'executing'}
/>
);
};

View file

@ -15,18 +15,14 @@ export const ResetPasswordContainer: React.FC = () => {
const router = useRouter();
const resetPasswordMutation = useAction(resetPasswordAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
}
onError: (error) => {
if (error.serverError) toast.error(error.serverError);
},
});
const cancelRequestMutation = useAction(cancelResetPasswordAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
}
onError: (error) => {
if (error.serverError) toast.error(error.serverError);
},
});

View file

@ -1,5 +1,3 @@
'use client';
import React from 'react';
import { toast } from 'react-hot-toast';
import { useTranslations } from 'next-intl';
@ -13,10 +11,10 @@ import { updateAppAction } from '@/actions/app-actions/update-app-action';
import { updateAppConfigAction } from '@/actions/app-actions/update-app-config-action';
import { AppLogo } from '@/components/AppLogo';
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 { AppStatus as AppStatusEnum } from '@/server/db/schema';
import { InstallModal } from '../InstallModal';
import { StopModal } from '../StopModal';
import { UninstallModal } from '../UninstallModal';
@ -24,19 +22,20 @@ import { UpdateModal } from '../UpdateModal';
import { UpdateSettingsModal } from '../UpdateSettingsModal/UpdateSettingsModal';
import { AppActions } from '../AppActions';
import { AppDetailsTabs } from '../AppDetailsTabs';
import { FormValues } from '../InstallForm';
import { ResetAppModal } from '../ResetAppModal';
interface IProps {
app: Awaited<ReturnType<AppService['getApp']>>;
localDomain?: string;
}
type OpenType = 'local' | 'domain' | 'local_domain';
export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
const [customStatus, setCustomStatus] = React.useState<AppStatusEnum>(app.status);
type AppDetailsContainerProps = {
app: Awaited<ReturnType<AppService['getApp']>>;
localDomain?: string;
optimisticStatus: AppStatusEnum;
setOptimisticStatus: (status: AppStatusEnum) => void;
};
export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, localDomain, optimisticStatus, setOptimisticStatus }) => {
const t = useTranslations();
const installDisclosure = useDisclosure();
const uninstallDisclosure = useDisclosure();
const stopDisclosure = useDisclosure();
@ -45,130 +44,78 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
const resetAppDisclosure = useDisclosure();
const installMutation = useAction(installAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('running');
toast.success(t('apps.app-details.install-success'));
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onExecute: () => {
setOptimisticStatus('installing');
installDisclosure.close();
},
});
const uninstallMutation = useAction(uninstallAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('missing');
toast.success(t('apps.app-details.uninstall-success'));
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onExecute: () => {
uninstallDisclosure.close();
setOptimisticStatus('uninstalling');
},
});
const stopMutation = useAction(stopAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('stopped');
toast.success(t('apps.app-details.stop-success'));
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onExecute: () => {
stopDisclosure.close();
setOptimisticStatus('stopping');
},
});
const startMutation = useAction(startAppAction, {
onSuccess: (data) => {
if (!data.success) {
setCustomStatus(app.status);
toast.error(data.failure.reason);
} else {
setCustomStatus('running');
toast.success(t('apps.app-details.start-success'));
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onExecute: () => {
setOptimisticStatus('starting');
},
});
const updateMutation = useAction(updateAppAction, {
onSuccess: (data) => {
setCustomStatus(app.status);
if (!data.success) {
toast.error(data.failure.reason);
} else {
toast.success(t('apps.app-details.update-success'));
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onExecute: () => {
updateDisclosure.close();
setOptimisticStatus('updating');
},
});
const updateConfigMutation = useAction(updateAppConfigAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
toast.success(t('apps.app-details.update-config-success'));
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onExecute: () => {
updateSettingsDisclosure.close();
},
onSuccess: () => {
toast.success(t('apps.app-details.update-config-success'));
},
});
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');
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onExecute: () => {
resetAppDisclosure.open();
setOptimisticStatus('stopping');
},
});
const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
const handleInstallSubmit = async (values: FormValues) => {
setCustomStatus('installing');
installDisclosure.close();
installMutation.execute({ id: app.id, form: values });
};
const handleUnistallSubmit = () => {
setCustomStatus('uninstalling');
uninstallDisclosure.close();
uninstallMutation.execute({ id: app.id });
};
const handleStopSubmit = () => {
setCustomStatus('stopping');
stopDisclosure.close();
stopMutation.execute({ id: app.id });
};
const handleStartSubmit = async () => {
setCustomStatus('starting');
startMutation.execute({ id: app.id });
};
const handleUpdateSettingsSubmit = async (values: FormValues) => {
updateSettingsDisclosure.close();
updateConfigMutation.execute({ id: app.id, form: values });
};
const handleUpdateSubmit = async () => {
setCustomStatus('updating');
updateDisclosure.close();
updateMutation.execute({ id: app.id });
};
const handleResetSubmit = () => {
setCustomStatus('stopping');
resetMutation.execute({ id: app.id });
resetAppDisclosure.open();
};
const openResetAppModal = () => {
updateSettingsDisclosure.close();
resetAppDisclosure.open();
@ -200,19 +147,46 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
return (
<div className="card" data-testid="app-details">
<InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.close} info={app.info} />
<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'} />
<InstallModal
onSubmit={(values) => installMutation.execute({ id: app.id, form: values })}
isOpen={installDisclosure.isOpen}
onClose={installDisclosure.close}
info={app.info}
/>
<StopModal
onConfirm={() => stopMutation.execute({ id: app.id })}
isOpen={stopDisclosure.isOpen}
onClose={stopDisclosure.close}
info={app.info}
/>
<UninstallModal
onConfirm={() => uninstallMutation.execute({ id: app.id })}
isOpen={uninstallDisclosure.isOpen}
onClose={uninstallDisclosure.close}
info={app.info}
/>
<UpdateModal
onConfirm={() => updateMutation.execute({ id: app.id })}
isOpen={updateDisclosure.isOpen}
onClose={updateDisclosure.close}
info={app.info}
newVersion={newVersion}
/>
<ResetAppModal
onConfirm={() => resetMutation.execute({ id: app.id })}
isOpen={resetAppDisclosure.isOpen}
onClose={resetAppDisclosure.close}
info={app.info}
isLoading={resetMutation.status === 'executing'}
/>
<UpdateSettingsModal
onSubmit={handleUpdateSettingsSubmit}
onSubmit={(values) => updateConfigMutation.execute({ id: app.id, form: values })}
isOpen={updateSettingsDisclosure.isOpen}
onClose={updateSettingsDisclosure.close}
info={app.info}
config={castAppConfig(app?.config)}
onReset={openResetAppModal}
status={customStatus}
status={optimisticStatus}
/>
<div className="card-header d-flex flex-column flex-md-row">
<AppLogo id={app.id} size={130} alt={app.info.name} />
@ -222,7 +196,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
<span className="badge bg-muted mt-2 text-white">{app.info.version}</span>
</div>
<span className="mt-1 text-muted text-center text-md-start mb-2">{app.info.short_desc}</span>
<div className="mb-1">{customStatus !== 'missing' && <AppStatus status={customStatus} />}</div>
<div className="mb-1">{optimisticStatus !== 'missing' && <AppStatus status={optimisticStatus} />}</div>
<AppActions
localDomain={localDomain}
updateAvailable={updateAvailable}
@ -233,9 +207,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
onUninstall={uninstallDisclosure.open}
onInstall={installDisclosure.open}
onOpen={handleOpen}
onStart={handleStartSubmit}
onStart={() => startMutation.execute({ id: app.id })}
app={app}
status={customStatus}
status={optimisticStatus}
/>
</div>
</div>

View file

@ -0,0 +1,88 @@
'use client';
import React, { startTransition, useOptimistic } from 'react';
import { useSocket } from '@/lib/socket/useSocket';
import { AppStatus } from '@/server/db/schema';
import { AppService } from '@/server/services/apps/apps.service';
import { useTranslations } from 'next-intl';
import toast from 'react-hot-toast';
import { useAction } from 'next-safe-action/hook';
import { revalidateAppAction } from '@/actions/app-actions/revalidate-app';
import { AppDetailsContainer } from './AppDetailsContainer';
interface IProps {
app: Awaited<ReturnType<AppService['getApp']>>;
localDomain?: string;
}
export const AppDetailsWrapper = (props: IProps) => {
const { app } = props;
const t = useTranslations();
const [optimisticStatus, setOptimisticStatus] = useOptimistic<AppStatus>(app.status);
const revalidateAppMutation = useAction(revalidateAppAction);
const changeStatus = (status: AppStatus) => {
startTransition(() => {
setOptimisticStatus(status);
});
};
useSocket({
onEvent: (event, data) => {
if (data.error) {
// eslint-disable-next-line no-console
console.error(data.error);
}
switch (event) {
case 'install_success':
toast.success(t('apps.app-details.install-success'));
changeStatus('running');
break;
case 'install_error':
toast.error(t('server-messages.errors.app-failed-to-install', { id: app.id }));
changeStatus('missing');
break;
case 'start_success':
toast.success(t('apps.app-details.start-success'));
changeStatus('running');
break;
case 'start_error':
toast.error(t('server-messages.errors.app-failed-to-start', { id: app.id }));
changeStatus('stopped');
break;
case 'stop_success':
toast.success(t('apps.app-details.stop-success'));
changeStatus('stopped');
break;
case 'stop_error':
toast.error(t('server-messages.errors.app-failed-to-stop', { id: app.id }));
changeStatus('running');
break;
case 'uninstall_success':
toast.success(t('apps.app-details.uninstall-success'));
changeStatus('missing');
break;
case 'uninstall_error':
toast.error(t('server-messages.errors.app-failed-to-uninstall', { id: app.id }));
changeStatus('stopped');
break;
case 'update_success':
toast.success(t('apps.app-details.update-success'));
changeStatus('running');
break;
case 'update_error':
toast.error(t('server-messages.errors.app-failed-to-update', { id: app.id }));
changeStatus('stopped');
break;
default:
break;
}
revalidateAppMutation.execute({ id: app.id });
},
selector: { type: 'app', data: { property: 'appId', value: app.id } },
});
return <AppDetailsContainer app={app} optimisticStatus={optimisticStatus} setOptimisticStatus={setOptimisticStatus} />;
};

View file

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

View file

@ -4,7 +4,7 @@ import { Metadata } from 'next';
import { db } from '@/server/db';
import { getTranslatorFromCookie } from '@/lib/get-translator';
import { getSettings } from '@/server/core/TipiConfig';
import { AppDetailsContainer } from './components/AppDetailsContainer/AppDetailsContainer';
import { AppDetailsWrapper } from './components/AppDetailsContainer';
export async function generateMetadata(): Promise<Metadata> {
const translator = await getTranslatorFromCookie();
@ -19,5 +19,5 @@ export default async function AppDetailsPage({ params }: { params: { id: string
const app = await appsService.getApp(params.id);
const settings = getSettings();
return <AppDetailsContainer app={app} localDomain={settings.localDomain} />;
return <AppDetailsWrapper app={app} localDomain={settings.localDomain} />;
}

View file

@ -34,13 +34,12 @@ export const ChangePasswordForm = () => {
const router = useRouter();
const changePasswordMutation = useAction(changePasswordAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
toast.success(t('password-change-success'));
router.push('/');
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onSuccess: () => {
toast.success(t('password-change-success'));
router.push('/');
},
});

View file

@ -27,13 +27,12 @@ export const ChangeUsernameForm = ({ username }: Props) => {
type FormValues = z.infer<typeof schema>;
const changeUsernameMutation = useAction(changeUsernameAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
toast.success(t('change-username.success'));
router.push('/');
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onSuccess: () => {
toast.success(t('change-username.success'));
router.push('/');
},
});

View file

@ -29,28 +29,26 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
onExecute: () => {
setupOtpDisclosure.close();
},
onError: (e) => {
setPassword('');
if (e.serverError) toast.error(e.serverError);
},
onSuccess: (data) => {
if (!data.success) {
setPassword('');
toast.error(data.failure.reason);
} else {
setKey(data.key);
setUri(data.uri);
}
setKey(data.key);
setUri(data.uri);
},
});
const setupTotpMutation = useAction(setupTotpAction, {
onSuccess: (data) => {
if (!data.success) {
setTotpCode('');
toast.error(data.failure.reason);
} else {
setTotpCode('');
setKey('');
setUri('');
toast.success(t('2fa-enable-success'));
}
onError: (e) => {
setTotpCode('');
if (e.serverError) toast.error(e.serverError);
},
onSuccess: () => {
setTotpCode('');
setKey('');
setUri('');
toast.success(t('2fa-enable-success'));
},
});
@ -58,13 +56,12 @@ export const OtpForm = (props: { totpEnabled: boolean }) => {
onExecute: () => {
disableOtpDisclosure.close();
},
onSuccess: (data) => {
if (!data.success) {
setPassword('');
toast.error(data.failure.reason);
} else {
toast.success(t('2fa-disable-success'));
}
onError: (e) => {
setPassword('');
if (e.serverError) toast.error(e.serverError);
},
onSuccess: () => {
toast.success(t('2fa-disable-success'));
},
});

View file

@ -20,13 +20,12 @@ export const SettingsContainer = ({ initialValues, currentLocale }: Props) => {
const router = useRouter();
const updateSettingsMutation = useAction(updateSettingsAction, {
onSuccess: (data) => {
if (!data.success) {
toast.error(data.failure.reason);
} else {
toast.success(t('settings.settings.settings-updated'));
router.refresh();
}
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onSuccess: () => {
toast.success(t('settings.settings.settings-updated'));
router.refresh();
},
});
@ -36,7 +35,12 @@ export const SettingsContainer = ({ initialValues, currentLocale }: Props) => {
return (
<div className="card-body">
<SettingsForm initalValues={initialValues} currentLocale={currentLocale} loading={updateSettingsMutation.status === 'executing'} onSubmit={onSubmit} />
<SettingsForm
initalValues={initialValues}
currentLocale={currentLocale}
loading={updateSettingsMutation.status === 'executing'}
onSubmit={onSubmit}
/>
</div>
);
};

View file

@ -0,0 +1,23 @@
'use server';
import { z } from 'zod';
import { action } from '@/lib/safe-action';
import { revalidatePath } from 'next/cache';
import { handleActionError } from '../utils/handle-action-error';
const input = z.object({ id: z.string() });
/**
* Given an app id, revalidates the app and app store pages on demand.
*/
export const revalidateAppAction = action(input, async ({ id }) => {
try {
revalidatePath('/apps');
revalidatePath(`/app/${id}`);
revalidatePath(`/app-store/${id}`);
return { success: true };
} catch (e) {
return handleActionError(e);
}
});

View file

@ -9,12 +9,7 @@ export const handleActionError = async (e: unknown) => {
const errorVariables = e instanceof TranslatedError ? e.variableValues : {};
const translator = await getTranslatorFromCookie();
const messageTranslated = translator(message as MessageKey, errorVariables);
const messageTranslated = e instanceof TranslatedError ? translator(message as MessageKey, errorVariables) : message;
return {
success: false as const,
failure: {
reason: messageTranslated,
},
};
throw new Error(messageTranslated as string);
};

View file

@ -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',
};
},
});

View file

@ -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<T> = {
property: keyof Extract<SocketEvent, { type: T }>['data'];
value: unknown;
};
type Selector<T, U> = {
type: T;
event?: U;
data?: DataSelector<T>;
};
type Props<T, U> = {
onEvent: (event: Extract<Extract<SocketEvent, { type: T }>['event'], U>, data: Extract<SocketEvent, { type: T }>['data']) => void;
onError?: (error: string) => void;
selector: Selector<T, U>;
};
export const useSocket = <T extends SocketEvent['type'], U extends SocketEvent['event']>(props: Props<T, U>) => {
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]);
};

View file

@ -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];
}

View file

@ -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) {