diff --git a/.prettierrc.js b/.prettierrc.js index 18502e8f..8a6a2a01 100644 --- a/.prettierrc.js +++ b/.prettierrc.js @@ -2,5 +2,5 @@ module.exports = { singleQuote: true, semi: true, trailingComma: 'all', - printWidth: 200, + printWidth: 150, }; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5f85b16d..3949e3ce 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 309eaf22..a1fd3edf 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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 diff --git a/package.json b/package.json index 7d74a515..eb817ddb 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/cli/assets/docker-compose.yml b/packages/cli/assets/docker-compose.yml index 58b3d5d0..2602db44 100644 --- a/packages/cli/assets/docker-compose.yml +++ b/packages/cli/assets/docker-compose.yml @@ -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 diff --git a/packages/shared/src/schemas/socket.ts b/packages/shared/src/schemas/socket.ts new file mode 100644 index 00000000..910a6952 --- /dev/null +++ b/packages/shared/src/schemas/socket.ts @@ -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; diff --git a/packages/worker/nodemon.json b/packages/worker/nodemon.json index 8e05f778..11acf9a8 100644 --- a/packages/worker/nodemon.json +++ b/packages/worker/nodemon.json @@ -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" } diff --git a/packages/worker/package.json b/packages/worker/package.json index f04633cb..8b3c4973 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -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" diff --git a/packages/worker/src/index.ts b/packages/worker/src/index.ts index 5c56afb1..045de623 100644 --- a/packages/worker/src/index.ts +++ b/packages/worker/src/index.ts @@ -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); diff --git a/packages/worker/src/lib/socket/SocketManager.ts b/packages/worker/src/lib/socket/SocketManager.ts new file mode 100644 index 00000000..a1c922f2 --- /dev/null +++ b/packages/worker/src/lib/socket/SocketManager.ts @@ -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 }; diff --git a/packages/worker/src/services/app/app.executors.ts b/packages/worker/src/services/app/app.executors.ts index bc3cf952..fa813c8c 100644 --- a/packages/worker/src/services/app/app.executors.ts +++ b/packages/worker/src/services/app/app.executors.ts @@ -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['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) => { + 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) => { 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, 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, 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) => { 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) => { 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) => { - 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'); } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e48d698f..6099d3d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/src/app/(auth)/login/components/LoginContainer/LoginContainer.tsx b/src/app/(auth)/login/components/LoginContainer/LoginContainer.tsx index 067f874e..1c389563 100644 --- a/src/app/(auth)/login/components/LoginContainer/LoginContainer.tsx +++ b/src/app/(auth)/login/components/LoginContainer/LoginContainer.tsx @@ -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 verifyTotpMutation.execute({ totpCode, totpSessionId })} />; + return ( + verifyTotpMutation.execute({ totpCode, totpSessionId })} + /> + ); } - return loginMutation.execute({ username: email, password })} />; + return ( + loginMutation.execute({ username: email, password })} + /> + ); } diff --git a/src/app/(auth)/register/components/RegisterContainer/RegisterContainer.tsx b/src/app/(auth)/register/components/RegisterContainer/RegisterContainer.tsx index 5941105a..2df0a287 100644 --- a/src/app/(auth)/register/components/RegisterContainer/RegisterContainer.tsx +++ b/src/app/(auth)/register/components/RegisterContainer/RegisterContainer.tsx @@ -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 registerMutation.execute({ username: email, password })} loading={registerMutation.status === 'executing'} />; + return ( + registerMutation.execute({ username: email, password })} + loading={registerMutation.status === 'executing'} + /> + ); }; diff --git a/src/app/(auth)/reset-password/components/ResetPasswordContainer/ResetPasswordContainer.tsx b/src/app/(auth)/reset-password/components/ResetPasswordContainer/ResetPasswordContainer.tsx index a0bb6fa5..422bd199 100644 --- a/src/app/(auth)/reset-password/components/ResetPasswordContainer/ResetPasswordContainer.tsx +++ b/src/app/(auth)/reset-password/components/ResetPasswordContainer/ResetPasswordContainer.tsx @@ -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); }, }); 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 ba6526c9..e4ccb589 100644 --- a/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsContainer.tsx +++ b/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsContainer.tsx @@ -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>; - localDomain?: string; -} type OpenType = 'local' | 'domain' | 'local_domain'; -export const AppDetailsContainer: React.FC = ({ app, localDomain }) => { - const [customStatus, setCustomStatus] = React.useState(app.status); +type AppDetailsContainerProps = { + app: Awaited>; + localDomain?: string; + optimisticStatus: AppStatusEnum; + setOptimisticStatus: (status: AppStatusEnum) => void; +}; +export const AppDetailsContainer: React.FC = ({ 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 = ({ 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 = ({ app, localDomain }) => { return (
- - - - - + installMutation.execute({ id: app.id, form: values })} + isOpen={installDisclosure.isOpen} + onClose={installDisclosure.close} + info={app.info} + /> + stopMutation.execute({ id: app.id })} + isOpen={stopDisclosure.isOpen} + onClose={stopDisclosure.close} + info={app.info} + /> + uninstallMutation.execute({ id: app.id })} + isOpen={uninstallDisclosure.isOpen} + onClose={uninstallDisclosure.close} + info={app.info} + /> + updateMutation.execute({ id: app.id })} + isOpen={updateDisclosure.isOpen} + onClose={updateDisclosure.close} + info={app.info} + newVersion={newVersion} + /> + resetMutation.execute({ id: app.id })} + isOpen={resetAppDisclosure.isOpen} + onClose={resetAppDisclosure.close} + info={app.info} + isLoading={resetMutation.status === 'executing'} + /> 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} />
@@ -222,7 +196,7 @@ export const AppDetailsContainer: React.FC = ({ app, localDomain }) => { {app.info.version}
{app.info.short_desc} -
{customStatus !== 'missing' && }
+
{optimisticStatus !== 'missing' && }
= ({ app, localDomain }) => { onUninstall={uninstallDisclosure.open} onInstall={installDisclosure.open} onOpen={handleOpen} - onStart={handleStartSubmit} + onStart={() => startMutation.execute({ id: app.id })} app={app} - status={customStatus} + status={optimisticStatus} />
diff --git a/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsWrapper.tsx b/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsWrapper.tsx new file mode 100644 index 00000000..8c4d8d4a --- /dev/null +++ b/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/AppDetailsWrapper.tsx @@ -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>; + localDomain?: string; +} + +export const AppDetailsWrapper = (props: IProps) => { + const { app } = props; + const t = useTranslations(); + const [optimisticStatus, setOptimisticStatus] = useOptimistic(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 ; +}; diff --git a/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/index.ts b/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/index.ts index e69de29b..ff74643b 100644 --- a/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/index.ts +++ b/src/app/(dashboard)/app-store/[id]/components/AppDetailsContainer/index.ts @@ -0,0 +1 @@ +export { AppDetailsWrapper } from './AppDetailsWrapper'; diff --git a/src/app/(dashboard)/app-store/[id]/page.tsx b/src/app/(dashboard)/app-store/[id]/page.tsx index 1332b51c..9ff30573 100644 --- a/src/app/(dashboard)/app-store/[id]/page.tsx +++ b/src/app/(dashboard)/app-store/[id]/page.tsx @@ -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 { 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 ; + return ; } diff --git a/src/app/(dashboard)/settings/components/ChangePasswordForm/ChangePasswordForm.tsx b/src/app/(dashboard)/settings/components/ChangePasswordForm/ChangePasswordForm.tsx index 85bb9eb7..05fffe98 100644 --- a/src/app/(dashboard)/settings/components/ChangePasswordForm/ChangePasswordForm.tsx +++ b/src/app/(dashboard)/settings/components/ChangePasswordForm/ChangePasswordForm.tsx @@ -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('/'); }, }); diff --git a/src/app/(dashboard)/settings/components/ChangeUsernameForm/ChangeUsernameForm.tsx b/src/app/(dashboard)/settings/components/ChangeUsernameForm/ChangeUsernameForm.tsx index 924226c8..9de9d4f5 100644 --- a/src/app/(dashboard)/settings/components/ChangeUsernameForm/ChangeUsernameForm.tsx +++ b/src/app/(dashboard)/settings/components/ChangeUsernameForm/ChangeUsernameForm.tsx @@ -27,13 +27,12 @@ export const ChangeUsernameForm = ({ username }: Props) => { type FormValues = z.infer; 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('/'); }, }); diff --git a/src/app/(dashboard)/settings/components/OtpForm/OtpForm.tsx b/src/app/(dashboard)/settings/components/OtpForm/OtpForm.tsx index 5591a06b..27dc100b 100644 --- a/src/app/(dashboard)/settings/components/OtpForm/OtpForm.tsx +++ b/src/app/(dashboard)/settings/components/OtpForm/OtpForm.tsx @@ -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')); }, }); diff --git a/src/app/(dashboard)/settings/components/SettingsContainer/SettingsContainer.tsx b/src/app/(dashboard)/settings/components/SettingsContainer/SettingsContainer.tsx index b7bc96b7..84d71c0e 100644 --- a/src/app/(dashboard)/settings/components/SettingsContainer/SettingsContainer.tsx +++ b/src/app/(dashboard)/settings/components/SettingsContainer/SettingsContainer.tsx @@ -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 (
- +
); }; diff --git a/src/app/actions/app-actions/revalidate-app.ts b/src/app/actions/app-actions/revalidate-app.ts new file mode 100644 index 00000000..637e71f4 --- /dev/null +++ b/src/app/actions/app-actions/revalidate-app.ts @@ -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); + } +}); diff --git a/src/app/actions/utils/handle-action-error.ts b/src/app/actions/utils/handle-action-error.ts index 78c66a99..b190227f 100644 --- a/src/app/actions/utils/handle-action-error.ts +++ b/src/app/actions/utils/handle-action-error.ts @@ -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); }; diff --git a/src/lib/safe-action.ts b/src/lib/safe-action.ts index 2f914209..cfe837a3 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 00000000..e3ffbde9 --- /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 1a75360f..03611743 100644 --- a/src/server/queries/apps/apps.queries.ts +++ b/src/server/queries/apps/apps.queries.ts @@ -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]; } diff --git a/src/server/services/apps/apps.service.ts b/src/server/services/apps/apps.service.ts index be0da68e..5002f2bb 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) {