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, singleQuote: true,
semi: true, semi: true,
trailingComma: 'all', trailingComma: 'all',
printWidth: 200, printWidth: 150,
}; };

View file

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

View file

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

View file

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

View file

@ -60,6 +60,8 @@ services:
container_name: tipi-worker container_name: tipi-worker
image: ghcr.io/runtipi/worker:${TIPI_VERSION} image: ghcr.io/runtipi/worker:${TIPI_VERSION}
restart: unless-stopped restart: unless-stopped
ports:
- 3935:3001
healthcheck: healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck'] test: ['CMD', 'curl', '-f', 'http://localhost:3000/healthcheck']
interval: 5s 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"], "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" "ext": "js ts"
} }

View file

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

View file

@ -1,4 +1,6 @@
import { SystemEvent } from '@runtipi/shared'; import { SystemEvent } from '@runtipi/shared';
import { Server } from 'socket.io';
import http from 'node:http'; import http from 'node:http';
import path from 'node:path'; import path from 'node:path';
import Redis from 'ioredis'; import Redis from 'ioredis';
@ -9,6 +11,7 @@ import { runPostgresMigrations } from '@/lib/migrations';
import { startWorker } from './watcher/watcher'; import { startWorker } from './watcher/watcher';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { AppExecutors } from './services'; import { AppExecutors } from './services';
import { SocketManager } from './lib/socket/SocketManager';
const rootFolder = '/app'; const rootFolder = '/app';
const envFile = path.join(rootFolder, '.env'); const envFile = path.join(rootFolder, '.env');
@ -85,6 +88,16 @@ const main = async () => {
server.listen(3000, () => { server.listen(3000, () => {
startWorker(); startWorker();
}); });
const io = new Server(3001, { cors: { origin: '*' } });
io.on('connection', (socket) => {
SocketManager.addSocket(socket);
socket.on('disconnect', () => {
SocketManager.removeSocket();
});
});
} catch (e) { } catch (e) {
logger.error(e); logger.error(e);
process.exit(1); 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 path from 'path';
import pg from 'pg'; import pg from 'pg';
import { execAsync, pathExists } from '@runtipi/shared'; import { execAsync, pathExists } from '@runtipi/shared';
import { SocketEvent } from '@runtipi/shared/src/schemas/socket';
import { copyDataDir, generateEnvFile } from './app.helpers'; import { copyDataDir, generateEnvFile } from './app.helpers';
import { logger } from '@/lib/logger'; import { logger } from '@/lib/logger';
import { compose } from '@/lib/docker'; import { compose } from '@/lib/docker';
import { getEnv } from '@/lib/environment'; import { getEnv } from '@/lib/environment';
import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants'; import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants';
import { SocketManager } from '@/lib/socket/SocketManager';
const getDbClient = async () => { const getDbClient = async () => {
const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getEnv(); const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getEnv();
@ -33,12 +35,14 @@ export class AppExecutors {
this.logger = logger; this.logger = logger;
} }
private handleAppError = (err: unknown) => { private handleAppError = (err: unknown, appId: string, event: Extract<SocketEvent, { type: 'app' }>['event']) => {
if (err instanceof Error) { if (err instanceof Error) {
SocketManager.emit({ type: 'app', event, data: { appId, error: err.message } });
this.logger.error(`An error occurred: ${err.message}`); this.logger.error(`An error occurred: ${err.message}`);
return { success: false, message: 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}` }; 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 * Install an app from the repo
* @param {string} appId - The id of the app to install * @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>) => { public installApp = async (appId: string, config: Record<string, unknown>) => {
try { try {
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
if (process.getuid && process.getgid) { if (process.getuid && process.getgid) {
this.logger.info(`Installing app ${appId} as User ID: ${process.getuid()}, Group ID: ${process.getgid()}`); this.logger.info(`Installing app ${appId} as User ID: ${process.getuid()}, Group ID: ${process.getgid()}`);
} else { } else {
@ -134,9 +153,11 @@ export class AppExecutors {
this.logger.info(`Docker-compose up for app ${appId} finished`); 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` }; return { success: true, message: `App ${appId} installed successfully` };
} catch (err) { } 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) => { public stopApp = async (appId: string, config: Record<string, unknown>, skipEnvGeneration = false) => {
try { try {
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
this.logger.info(`Stopping app ${appId}`); this.logger.info(`Stopping app ${appId}`);
await this.ensureAppDir(appId); await this.ensureAppDir(appId);
@ -158,14 +180,19 @@ export class AppExecutors {
await compose(appId, 'rm --force --stop'); await compose(appId, 'rm --force --stop');
this.logger.info(`App ${appId} stopped`); this.logger.info(`App ${appId} stopped`);
SocketManager.emit({ type: 'app', event: 'stop_success', data: { appId } });
return { success: true, message: `App ${appId} stopped successfully` }; return { success: true, message: `App ${appId} stopped successfully` };
} catch (err) { } catch (err) {
return this.handleAppError(err); return this.handleAppError(err, appId, 'stop_error');
} }
}; };
public startApp = async (appId: string, config: Record<string, unknown>, skipEnvGeneration = false) => { public startApp = async (appId: string, config: Record<string, unknown>, skipEnvGeneration = false) => {
try { try {
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
const { appDataDirPath } = this.getAppPaths(appId); const { appDataDirPath } = this.getAppPaths(appId);
this.logger.info(`Starting app ${appId}`); this.logger.info(`Starting app ${appId}`);
@ -186,14 +213,18 @@ export class AppExecutors {
this.logger.error(`Error setting permissions for app ${appId}`); 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` }; return { success: true, message: `App ${appId} started successfully` };
} catch (err) { } catch (err) {
return this.handleAppError(err); return this.handleAppError(err, appId, 'start_error');
} }
}; };
public uninstallApp = async (appId: string, config: Record<string, unknown>) => { public uninstallApp = async (appId: string, config: Record<string, unknown>) => {
try { try {
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
const { appDirPath, appDataDirPath } = this.getAppPaths(appId); const { appDirPath, appDataDirPath } = this.getAppPaths(appId);
this.logger.info(`Uninstalling app ${appId}`); this.logger.info(`Uninstalling app ${appId}`);
@ -221,14 +252,19 @@ export class AppExecutors {
}); });
this.logger.info(`App ${appId} uninstalled`); this.logger.info(`App ${appId} uninstalled`);
SocketManager.emit({ type: 'app', event: 'uninstall_success', data: { appId } });
return { success: true, message: `App ${appId} uninstalled successfully` }; return { success: true, message: `App ${appId} uninstalled successfully` };
} catch (err) { } catch (err) {
return this.handleAppError(err); return this.handleAppError(err, appId, 'uninstall_error');
} }
}; };
public updateApp = async (appId: string, config: Record<string, unknown>) => { public updateApp = async (appId: string, config: Record<string, unknown>) => {
try { try {
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
const { appDirPath, repoPath } = this.getAppPaths(appId); const { appDirPath, repoPath } = this.getAppPaths(appId);
this.logger.info(`Updating app ${appId}`); this.logger.info(`Updating app ${appId}`);
await this.ensureAppDir(appId); await this.ensureAppDir(appId);
@ -245,20 +281,11 @@ export class AppExecutors {
await compose(appId, 'pull'); await compose(appId, 'pull');
SocketManager.emit({ type: 'app', event: 'update_success', data: { appId } });
return { success: true, message: `App ${appId} updated successfully` }; return { success: true, message: `App ${appId} updated successfully` };
} catch (err) { } catch (err) {
return this.handleAppError(err); return this.handleAppError(err, appId, 'update_error');
}
};
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);
} }
}; };

View file

@ -140,6 +140,9 @@ importers:
sharp: sharp:
specifier: 0.32.6 specifier: 0.32.6
version: 0.32.6 version: 0.32.6
socket.io-client:
specifier: ^4.7.2
version: 4.7.2
swr: swr:
specifier: ^2.2.4 specifier: ^2.2.4
version: 2.2.4(react@18.2.0) version: 2.2.4(react@18.2.0)
@ -424,6 +427,9 @@ importers:
pg: pg:
specifier: ^8.11.3 specifier: ^8.11.3
version: 8.11.3 version: 8.11.3
socket.io:
specifier: ^4.7.2
version: 4.7.2
systeminformation: systeminformation:
specifier: ^5.21.15 specifier: ^5.21.15
version: 5.21.15 version: 5.21.15
@ -2957,6 +2963,10 @@ packages:
p-map: 4.0.0 p-map: 4.0.0
dev: true 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: /@swc/helpers@0.5.2:
resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==}
dependencies: dependencies:
@ -3200,7 +3210,12 @@ packages:
/@types/cookie@0.4.1: /@types/cookie@0.4.1:
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==} 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: /@types/debug@4.1.7:
resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==} resolution: {integrity: sha512-9AonUzyTjXXhEOa0DnqpzZi6VHlqKMswga9EXjpXnnqxwLtdvPPtlO8evrI5D9S6asFRCQ6v+wpiUKbw+vKqyg==}
@ -3731,6 +3746,14 @@ packages:
/abbrev@1.1.1: /abbrev@1.1.1:
resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} 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: /acorn-globals@7.0.1:
resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==}
dependencies: dependencies:
@ -4186,6 +4209,11 @@ packages:
/base64-js@1.5.1: /base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} 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: /binary-extensions@2.2.0:
resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -4650,6 +4678,14 @@ packages:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
dev: true 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: /cosmiconfig@7.1.0:
resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -5097,6 +5133,45 @@ packages:
dependencies: dependencies:
once: 1.4.0 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: /enhanced-resolve@5.12.0:
resolution: {integrity: sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==} resolution: {integrity: sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
@ -10001,6 +10076,56 @@ packages:
is-fullwidth-code-point: 4.0.0 is-fullwidth-code-point: 4.0.0
dev: false 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: /source-map-js@1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -10949,6 +11074,11 @@ packages:
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
dev: false 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: /version-selector-type@3.0.0:
resolution: {integrity: sha512-PSvMIZS7C1MuVNBXl/CDG2pZq8EXy/NW2dHIdm3bVP5N0PC8utDK8ttXLXj44Gn3J0lQE3U7Mpm1estAOd+eiA==} resolution: {integrity: sha512-PSvMIZS7C1MuVNBXl/CDG2pZq8EXy/NW2dHIdm3bVP5N0PC8utDK8ttXLXj44Gn3J0lQE3U7Mpm1estAOd+eiA==}
engines: {node: '>=10.13'} engines: {node: '>=10.13'}
@ -11350,6 +11480,19 @@ packages:
signal-exit: 3.0.7 signal-exit: 3.0.7
dev: true 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: /ws@8.12.1:
resolution: {integrity: sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew==} resolution: {integrity: sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@ -11372,6 +11515,11 @@ packages:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
dev: true dev: true
/xmlhttprequest-ssl@2.0.0:
resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==}
engines: {node: '>=0.4.0'}
dev: false
/xtend@4.0.2: /xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}

View file

@ -14,10 +14,11 @@ export function LoginContainer() {
const router = useRouter(); const router = useRouter();
const loginMutation = useAction(loginAction, { const loginMutation = useAction(loginAction, {
onError: (e) => {
if (e.serverError) toast.error(e.serverError);
},
onSuccess: (data) => { onSuccess: (data) => {
if (!data.success) { if (data.success && data.totpSessionId) {
toast.error(data.failure.reason);
} else if (data.success && data.totpSessionId) {
setTotpSessionId(data.totpSessionId); setTotpSessionId(data.totpSessionId);
} else { } else {
router.push('/dashboard'); router.push('/dashboard');
@ -26,18 +27,27 @@ export function LoginContainer() {
}); });
const verifyTotpMutation = useAction(verifyTotpAction, { const verifyTotpMutation = useAction(verifyTotpAction, {
onSuccess: (data) => { onError: (e) => {
if (!data.success) { if (e.serverError) toast.error(e.serverError);
toast.error(data.failure.reason); },
} else { onSuccess: () => {
router.push('/dashboard'); router.push('/dashboard');
}
}, },
}); });
if (totpSessionId) { 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 router = useRouter();
const registerMutation = useAction(registerAction, { const registerMutation = useAction(registerAction, {
onSuccess: (data) => { onError: (e) => {
if (!data.success) { if (e.serverError) toast.error(e.serverError);
toast.error(data.failure.reason); },
} else { onSuccess: () => {
router.push('/dashboard'); 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 router = useRouter();
const resetPasswordMutation = useAction(resetPasswordAction, { const resetPasswordMutation = useAction(resetPasswordAction, {
onSuccess: (data) => { onError: (error) => {
if (!data.success) { if (error.serverError) toast.error(error.serverError);
toast.error(data.failure.reason);
}
}, },
}); });
const cancelRequestMutation = useAction(cancelResetPasswordAction, { const cancelRequestMutation = useAction(cancelResetPasswordAction, {
onSuccess: (data) => { onError: (error) => {
if (!data.success) { if (error.serverError) toast.error(error.serverError);
toast.error(data.failure.reason);
}
}, },
}); });

View file

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

View file

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

View file

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

View file

@ -20,13 +20,12 @@ export const SettingsContainer = ({ initialValues, currentLocale }: Props) => {
const router = useRouter(); const router = useRouter();
const updateSettingsMutation = useAction(updateSettingsAction, { const updateSettingsMutation = useAction(updateSettingsAction, {
onSuccess: (data) => { onError: (e) => {
if (!data.success) { if (e.serverError) toast.error(e.serverError);
toast.error(data.failure.reason); },
} else { onSuccess: () => {
toast.success(t('settings.settings.settings-updated')); toast.success(t('settings.settings.settings-updated'));
router.refresh(); router.refresh();
}
}, },
}); });
@ -36,7 +35,12 @@ export const SettingsContainer = ({ initialValues, currentLocale }: Props) => {
return ( return (
<div className="card-body"> <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> </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 errorVariables = e instanceof TranslatedError ? e.variableValues : {};
const translator = await getTranslatorFromCookie(); const translator = await getTranslatorFromCookie();
const messageTranslated = translator(message as MessageKey, errorVariables); const messageTranslated = e instanceof TranslatedError ? translator(message as MessageKey, errorVariables) : message;
return { throw new Error(messageTranslated as string);
success: false as const,
failure: {
reason: messageTranslated,
},
};
}; };

View file

@ -1,3 +1,12 @@
import { createSafeActionClient } from 'next-safe-action'; 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 * @param {NewApp} data - The data to create the app with
*/ */
public async createApp(data: NewApp) { 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]; return newApps[0];
} }

View file

@ -2,7 +2,7 @@ import validator from 'validator';
import { App } from '@/server/db/schema'; import { App } from '@/server/db/schema';
import { AppQueries } from '@/server/queries/apps/apps.queries'; import { AppQueries } from '@/server/queries/apps/apps.queries';
import { TranslatedError } from '@/server/utils/errors'; import { TranslatedError } from '@/server/utils/errors';
import { Database } from '@/server/db'; import { Database, db } from '@/server/db';
import { AppInfo } from '@runtipi/shared'; import { AppInfo } from '@runtipi/shared';
import { EventDispatcher } from '@/server/core/EventDispatcher/EventDispatcher'; import { EventDispatcher } from '@/server/core/EventDispatcher/EventDispatcher';
import { castAppConfig } from '@/lib/helpers/castAppConfig'; import { castAppConfig } from '@/lib/helpers/castAppConfig';
@ -32,7 +32,7 @@ const filterApps = (apps: AppInfo[]): AppInfo[] => apps.sort(sortApps).filter(fi
export class AppServiceClass { export class AppServiceClass {
private queries; private queries;
constructor(p: Database) { constructor(p: Database = db) {
this.queries = new AppQueries(p); this.queries = new AppQueries(p);
} }
@ -55,13 +55,15 @@ export class AppServiceClass {
try { try {
await this.queries.updateApp(app.id, { status: 'starting' }); await this.queries.updateApp(app.id, { status: 'starting' });
eventDispatcher.dispatchEventAsync({ type: 'app', command: 'start', appid: app.id, form: castAppConfig(app.config) }).then(({ success }) => { eventDispatcher
if (success) { .dispatchEventAsync({ type: 'app', command: 'start', appid: app.id, form: castAppConfig(app.config) })
this.queries.updateApp(app.id, { status: 'running' }); .then(({ success }) => {
} else { if (success) {
this.queries.updateApp(app.id, { status: 'stopped' }); this.queries.updateApp(app.id, { status: 'running' });
} } else {
}); this.queries.updateApp(app.id, { status: 'stopped' });
}
});
} catch (e) { } catch (e) {
await this.queries.updateApp(app.id, { status: 'stopped' }); await this.queries.updateApp(app.id, { status: 'stopped' });
Logger.error(e); Logger.error(e);
@ -87,7 +89,12 @@ export class AppServiceClass {
await this.queries.updateApp(appName, { status: 'starting' }); await this.queries.updateApp(appName, { status: 'starting' });
const eventDispatcher = new EventDispatcher('startApp'); 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(); await eventDispatcher.close();
if (success) { if (success) {
@ -166,18 +173,17 @@ export class AppServiceClass {
// Run script // Run script
const eventDispatcher = new EventDispatcher('installApp'); const eventDispatcher = new EventDispatcher('installApp');
const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'install', appid: id, form }); eventDispatcher.dispatchEventAsync({ type: 'app', command: 'install', appid: id, form }).then(({ success, stdout }) => {
await eventDispatcher.close(); if (success) {
this.queries.updateApp(id, { status: 'running' });
} else {
this.queries.deleteApp(id);
Logger.error(`Failed to install app ${id}: ${stdout}`);
}
if (!success) { eventDispatcher.close();
await this.queries.deleteApp(id); });
Logger.error(`Failed to install app ${id}: ${stdout}`);
throw new TranslatedError('server-messages.errors.app-failed-to-install', { id });
}
} }
const updatedApp = await this.queries.updateApp(id, { status: 'running' });
return updatedApp;
}; };
/** /**
@ -240,7 +246,12 @@ export class AppServiceClass {
await eventDispatcher.close(); await eventDispatcher.close();
if (success) { 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; return updatedApp;
} }
@ -264,16 +275,16 @@ export class AppServiceClass {
await this.queries.updateApp(id, { status: 'stopping' }); await this.queries.updateApp(id, { status: 'stopping' });
const eventDispatcher = new EventDispatcher('stopApp'); const eventDispatcher = new EventDispatcher('stopApp');
const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'stop', appid: id, form: castAppConfig(app.config) }); eventDispatcher.dispatchEventAsync({ type: 'app', command: 'stop', appid: id, form: castAppConfig(app.config) }).then(({ success, stdout }) => {
await eventDispatcher.close(); 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) { eventDispatcher.close();
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 });
}
const updatedApp = await this.queries.getApp(id); const updatedApp = await this.queries.getApp(id);
return updatedApp; return updatedApp;
@ -298,16 +309,17 @@ export class AppServiceClass {
await this.queries.updateApp(id, { status: 'uninstalling' }); await this.queries.updateApp(id, { status: 'uninstalling' });
const eventDispatcher = new EventDispatcher('uninstallApp'); const eventDispatcher = new EventDispatcher('uninstallApp');
const { success, stdout } = await eventDispatcher.dispatchEventAsync({ type: 'app', command: 'uninstall', appid: id, form: castAppConfig(app.config) }); eventDispatcher
await eventDispatcher.close(); .dispatchEventAsync({ type: 'app', command: 'uninstall', appid: id, form: castAppConfig(app.config) })
.then(({ stdout, success }) => {
if (!success) { if (success) {
await this.queries.updateApp(id, { status: 'stopped' }); this.queries.deleteApp(id);
Logger.error(`Failed to uninstall app ${id}: ${stdout}`); } else {
throw new TranslatedError('server-messages.errors.app-failed-to-uninstall', { id }); this.queries.updateApp(id, { status: 'stopped' });
} Logger.error(`Failed to uninstall app ${id}: ${stdout}`);
}
await this.queries.deleteApp(id); eventDispatcher.close();
});
return { id, status: 'missing', config: {} }; return { id, status: 'missing', config: {} };
}; };
@ -350,7 +362,12 @@ export class AppServiceClass {
await this.queries.updateApp(id, { status: 'updating' }); await this.queries.updateApp(id, { status: 'updating' });
const eventDispatcher = new EventDispatcher('updateApp'); 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(); await eventDispatcher.close();
if (success) { if (success) {