Compare commits
4 commits
develop
...
feat/socke
Author | SHA1 | Date | |
---|---|---|---|
|
76f9637d77 | ||
|
25c39b92e5 | ||
|
52a2753522 | ||
|
7f98f65bd0 |
29 changed files with 711 additions and 263 deletions
|
@ -2,5 +2,5 @@ module.exports = {
|
|||
singleQuote: true,
|
||||
semi: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 200,
|
||||
printWidth: 150,
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
35
packages/shared/src/schemas/socket.ts
Normal file
35
packages/shared/src/schemas/socket.ts
Normal 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>;
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
|
|
30
packages/worker/src/lib/socket/SocketManager.ts
Normal file
30
packages/worker/src/lib/socket/SocketManager.ts
Normal 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 };
|
|
@ -4,11 +4,13 @@ import fs from 'fs';
|
|||
import path from 'path';
|
||||
import pg from 'pg';
|
||||
import { execAsync, pathExists } from '@runtipi/shared';
|
||||
import { SocketEvent } from '@runtipi/shared/src/schemas/socket';
|
||||
import { copyDataDir, generateEnvFile } from './app.helpers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { compose } from '@/lib/docker';
|
||||
import { getEnv } from '@/lib/environment';
|
||||
import { ROOT_FOLDER, STORAGE_FOLDER } from '@/config/constants';
|
||||
import { SocketManager } from '@/lib/socket/SocketManager';
|
||||
|
||||
const getDbClient = async () => {
|
||||
const { postgresHost, postgresDatabase, postgresUsername, postgresPassword, postgresPort } = getEnv();
|
||||
|
@ -33,12 +35,14 @@ export class AppExecutors {
|
|||
this.logger = logger;
|
||||
}
|
||||
|
||||
private handleAppError = (err: unknown) => {
|
||||
private handleAppError = (err: unknown, appId: string, event: Extract<SocketEvent, { type: 'app' }>['event']) => {
|
||||
if (err instanceof Error) {
|
||||
SocketManager.emit({ type: 'app', event, data: { appId, error: err.message } });
|
||||
this.logger.error(`An error occurred: ${err.message}`);
|
||||
return { success: false, message: err.message };
|
||||
}
|
||||
|
||||
SocketManager.emit({ type: 'app', event, data: { appId, error: String(err) } });
|
||||
return { success: false, message: `An error occurred: ${err}` };
|
||||
};
|
||||
|
||||
|
@ -73,6 +77,19 @@ export class AppExecutors {
|
|||
}
|
||||
};
|
||||
|
||||
public regenerateAppEnv = async (appId: string, config: Record<string, unknown>) => {
|
||||
try {
|
||||
this.logger.info(`Regenerating app.env file for app ${appId}`);
|
||||
await this.ensureAppDir(appId);
|
||||
await generateEnvFile(appId, config);
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'generate_env_success', data: { appId } });
|
||||
return { success: true, message: `App ${appId} env file regenerated successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err, appId, 'generate_env_error');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Install an app from the repo
|
||||
* @param {string} appId - The id of the app to install
|
||||
|
@ -80,6 +97,8 @@ export class AppExecutors {
|
|||
*/
|
||||
public installApp = async (appId: string, config: Record<string, unknown>) => {
|
||||
try {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
|
||||
if (process.getuid && process.getgid) {
|
||||
this.logger.info(`Installing app ${appId} as User ID: ${process.getuid()}, Group ID: ${process.getgid()}`);
|
||||
} else {
|
||||
|
@ -134,9 +153,11 @@ export class AppExecutors {
|
|||
|
||||
this.logger.info(`Docker-compose up for app ${appId} finished`);
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'install_success', data: { appId } });
|
||||
|
||||
return { success: true, message: `App ${appId} installed successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err);
|
||||
return this.handleAppError(err, appId, 'install_error');
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -147,6 +168,7 @@ export class AppExecutors {
|
|||
*/
|
||||
public stopApp = async (appId: string, config: Record<string, unknown>, skipEnvGeneration = false) => {
|
||||
try {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
this.logger.info(`Stopping app ${appId}`);
|
||||
|
||||
await this.ensureAppDir(appId);
|
||||
|
@ -158,14 +180,19 @@ export class AppExecutors {
|
|||
await compose(appId, 'rm --force --stop');
|
||||
|
||||
this.logger.info(`App ${appId} stopped`);
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'stop_success', data: { appId } });
|
||||
|
||||
return { success: true, message: `App ${appId} stopped successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err);
|
||||
return this.handleAppError(err, appId, 'stop_error');
|
||||
}
|
||||
};
|
||||
|
||||
public startApp = async (appId: string, config: Record<string, unknown>, skipEnvGeneration = false) => {
|
||||
try {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
|
||||
const { appDataDirPath } = this.getAppPaths(appId);
|
||||
|
||||
this.logger.info(`Starting app ${appId}`);
|
||||
|
@ -186,14 +213,18 @@ export class AppExecutors {
|
|||
this.logger.error(`Error setting permissions for app ${appId}`);
|
||||
});
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'start_success', data: { appId } });
|
||||
|
||||
return { success: true, message: `App ${appId} started successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err);
|
||||
return this.handleAppError(err, appId, 'start_error');
|
||||
}
|
||||
};
|
||||
|
||||
public uninstallApp = async (appId: string, config: Record<string, unknown>) => {
|
||||
try {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
|
||||
const { appDirPath, appDataDirPath } = this.getAppPaths(appId);
|
||||
this.logger.info(`Uninstalling app ${appId}`);
|
||||
|
||||
|
@ -221,14 +252,19 @@ export class AppExecutors {
|
|||
});
|
||||
|
||||
this.logger.info(`App ${appId} uninstalled`);
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'uninstall_success', data: { appId } });
|
||||
|
||||
return { success: true, message: `App ${appId} uninstalled successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err);
|
||||
return this.handleAppError(err, appId, 'uninstall_error');
|
||||
}
|
||||
};
|
||||
|
||||
public updateApp = async (appId: string, config: Record<string, unknown>) => {
|
||||
try {
|
||||
SocketManager.emit({ type: 'app', event: 'status_change', data: { appId } });
|
||||
|
||||
const { appDirPath, repoPath } = this.getAppPaths(appId);
|
||||
this.logger.info(`Updating app ${appId}`);
|
||||
await this.ensureAppDir(appId);
|
||||
|
@ -245,20 +281,11 @@ export class AppExecutors {
|
|||
|
||||
await compose(appId, 'pull');
|
||||
|
||||
SocketManager.emit({ type: 'app', event: 'update_success', data: { appId } });
|
||||
|
||||
return { success: true, message: `App ${appId} updated successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err);
|
||||
}
|
||||
};
|
||||
|
||||
public regenerateAppEnv = async (appId: string, config: Record<string, unknown>) => {
|
||||
try {
|
||||
this.logger.info(`Regenerating app.env file for app ${appId}`);
|
||||
await this.ensureAppDir(appId);
|
||||
await generateEnvFile(appId, config);
|
||||
return { success: true, message: `App ${appId} env file regenerated successfully` };
|
||||
} catch (err) {
|
||||
return this.handleAppError(err);
|
||||
return this.handleAppError(err, appId, 'update_error');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
150
pnpm-lock.yaml
150
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'}
|
||||
|
|
|
@ -14,10 +14,11 @@ export function LoginContainer() {
|
|||
const router = useRouter();
|
||||
|
||||
const loginMutation = useAction(loginAction, {
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else if (data.success && data.totpSessionId) {
|
||||
if (data.success && data.totpSessionId) {
|
||||
setTotpSessionId(data.totpSessionId);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
|
@ -26,18 +27,27 @@ export function LoginContainer() {
|
|||
});
|
||||
|
||||
const verifyTotpMutation = useAction(verifyTotpAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.push('/dashboard');
|
||||
},
|
||||
});
|
||||
|
||||
if (totpSessionId) {
|
||||
return <TotpForm loading={verifyTotpMutation.status === 'executing'} onSubmit={(totpCode) => verifyTotpMutation.execute({ totpCode, totpSessionId })} />;
|
||||
return (
|
||||
<TotpForm
|
||||
loading={verifyTotpMutation.status === 'executing'}
|
||||
onSubmit={(totpCode) => verifyTotpMutation.execute({ totpCode, totpSessionId })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <LoginForm loading={loginMutation.status === 'executing'} onSubmit={({ email, password }) => loginMutation.execute({ username: email, password })} />;
|
||||
return (
|
||||
<LoginForm
|
||||
loading={loginMutation.status === 'executing'}
|
||||
onSubmit={({ email, password }) => loginMutation.execute({ username: email, password })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -11,14 +11,18 @@ export const RegisterContainer: React.FC = () => {
|
|||
const router = useRouter();
|
||||
|
||||
const registerMutation = useAction(registerAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onSuccess: () => {
|
||||
router.push('/dashboard');
|
||||
},
|
||||
});
|
||||
|
||||
return <RegisterForm onSubmit={({ email, password }) => registerMutation.execute({ username: email, password })} loading={registerMutation.status === 'executing'} />;
|
||||
return (
|
||||
<RegisterForm
|
||||
onSubmit={({ email, password }) => registerMutation.execute({ username: email, password })}
|
||||
loading={registerMutation.status === 'executing'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
@ -13,10 +11,10 @@ import { updateAppAction } from '@/actions/app-actions/update-app-action';
|
|||
import { updateAppConfigAction } from '@/actions/app-actions/update-app-config-action';
|
||||
import { AppLogo } from '@/components/AppLogo';
|
||||
import { AppStatus } from '@/components/AppStatus';
|
||||
import { AppStatus as AppStatusEnum } from '@/server/db/schema';
|
||||
import { castAppConfig } from '@/lib/helpers/castAppConfig';
|
||||
import { AppService } from '@/server/services/apps/apps.service';
|
||||
import { resetAppAction } from '@/actions/app-actions/reset-app-action';
|
||||
import { AppStatus as AppStatusEnum } from '@/server/db/schema';
|
||||
import { InstallModal } from '../InstallModal';
|
||||
import { StopModal } from '../StopModal';
|
||||
import { UninstallModal } from '../UninstallModal';
|
||||
|
@ -24,19 +22,20 @@ import { UpdateModal } from '../UpdateModal';
|
|||
import { UpdateSettingsModal } from '../UpdateSettingsModal/UpdateSettingsModal';
|
||||
import { AppActions } from '../AppActions';
|
||||
import { AppDetailsTabs } from '../AppDetailsTabs';
|
||||
import { FormValues } from '../InstallForm';
|
||||
import { ResetAppModal } from '../ResetAppModal';
|
||||
|
||||
interface IProps {
|
||||
app: Awaited<ReturnType<AppService['getApp']>>;
|
||||
localDomain?: string;
|
||||
}
|
||||
type OpenType = 'local' | 'domain' | 'local_domain';
|
||||
|
||||
export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
||||
const [customStatus, setCustomStatus] = React.useState<AppStatusEnum>(app.status);
|
||||
type AppDetailsContainerProps = {
|
||||
app: Awaited<ReturnType<AppService['getApp']>>;
|
||||
localDomain?: string;
|
||||
optimisticStatus: AppStatusEnum;
|
||||
setOptimisticStatus: (status: AppStatusEnum) => void;
|
||||
};
|
||||
|
||||
export const AppDetailsContainer: React.FC<AppDetailsContainerProps> = ({ app, localDomain, optimisticStatus, setOptimisticStatus }) => {
|
||||
const t = useTranslations();
|
||||
|
||||
const installDisclosure = useDisclosure();
|
||||
const uninstallDisclosure = useDisclosure();
|
||||
const stopDisclosure = useDisclosure();
|
||||
|
@ -45,130 +44,78 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
|||
const resetAppDisclosure = useDisclosure();
|
||||
|
||||
const installMutation = useAction(installAppAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
setCustomStatus(app.status);
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
setCustomStatus('running');
|
||||
toast.success(t('apps.app-details.install-success'));
|
||||
}
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onExecute: () => {
|
||||
setOptimisticStatus('installing');
|
||||
installDisclosure.close();
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallMutation = useAction(uninstallAppAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
setCustomStatus(app.status);
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
setCustomStatus('missing');
|
||||
toast.success(t('apps.app-details.uninstall-success'));
|
||||
}
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onExecute: () => {
|
||||
uninstallDisclosure.close();
|
||||
setOptimisticStatus('uninstalling');
|
||||
},
|
||||
});
|
||||
|
||||
const stopMutation = useAction(stopAppAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
setCustomStatus(app.status);
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
setCustomStatus('stopped');
|
||||
toast.success(t('apps.app-details.stop-success'));
|
||||
}
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onExecute: () => {
|
||||
stopDisclosure.close();
|
||||
setOptimisticStatus('stopping');
|
||||
},
|
||||
});
|
||||
|
||||
const startMutation = useAction(startAppAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
setCustomStatus(app.status);
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
setCustomStatus('running');
|
||||
toast.success(t('apps.app-details.start-success'));
|
||||
}
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onExecute: () => {
|
||||
setOptimisticStatus('starting');
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useAction(updateAppAction, {
|
||||
onSuccess: (data) => {
|
||||
setCustomStatus(app.status);
|
||||
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
toast.success(t('apps.app-details.update-success'));
|
||||
}
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onExecute: () => {
|
||||
updateDisclosure.close();
|
||||
setOptimisticStatus('updating');
|
||||
},
|
||||
});
|
||||
|
||||
const updateConfigMutation = useAction(updateAppConfigAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
toast.success(t('apps.app-details.update-config-success'));
|
||||
}
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onExecute: () => {
|
||||
updateSettingsDisclosure.close();
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('apps.app-details.update-config-success'));
|
||||
},
|
||||
});
|
||||
|
||||
const resetMutation = useAction(resetAppAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
resetAppDisclosure.close();
|
||||
} else {
|
||||
resetAppDisclosure.close();
|
||||
toast.success(t('apps.app-details.app-reset-success'));
|
||||
setCustomStatus('running');
|
||||
}
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onExecute: () => {
|
||||
resetAppDisclosure.open();
|
||||
setOptimisticStatus('stopping');
|
||||
},
|
||||
});
|
||||
|
||||
const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
|
||||
|
||||
const handleInstallSubmit = async (values: FormValues) => {
|
||||
setCustomStatus('installing');
|
||||
installDisclosure.close();
|
||||
installMutation.execute({ id: app.id, form: values });
|
||||
};
|
||||
|
||||
const handleUnistallSubmit = () => {
|
||||
setCustomStatus('uninstalling');
|
||||
uninstallDisclosure.close();
|
||||
uninstallMutation.execute({ id: app.id });
|
||||
};
|
||||
|
||||
const handleStopSubmit = () => {
|
||||
setCustomStatus('stopping');
|
||||
stopDisclosure.close();
|
||||
stopMutation.execute({ id: app.id });
|
||||
};
|
||||
|
||||
const handleStartSubmit = async () => {
|
||||
setCustomStatus('starting');
|
||||
startMutation.execute({ id: app.id });
|
||||
};
|
||||
|
||||
const handleUpdateSettingsSubmit = async (values: FormValues) => {
|
||||
updateSettingsDisclosure.close();
|
||||
updateConfigMutation.execute({ id: app.id, form: values });
|
||||
};
|
||||
|
||||
const handleUpdateSubmit = async () => {
|
||||
setCustomStatus('updating');
|
||||
updateDisclosure.close();
|
||||
updateMutation.execute({ id: app.id });
|
||||
};
|
||||
|
||||
const handleResetSubmit = () => {
|
||||
setCustomStatus('stopping');
|
||||
resetMutation.execute({ id: app.id });
|
||||
resetAppDisclosure.open();
|
||||
};
|
||||
|
||||
const openResetAppModal = () => {
|
||||
updateSettingsDisclosure.close();
|
||||
resetAppDisclosure.open();
|
||||
|
@ -200,19 +147,46 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
|||
|
||||
return (
|
||||
<div className="card" data-testid="app-details">
|
||||
<InstallModal onSubmit={handleInstallSubmit} isOpen={installDisclosure.isOpen} onClose={installDisclosure.close} info={app.info} />
|
||||
<StopModal onConfirm={handleStopSubmit} isOpen={stopDisclosure.isOpen} onClose={stopDisclosure.close} info={app.info} />
|
||||
<UninstallModal onConfirm={handleUnistallSubmit} isOpen={uninstallDisclosure.isOpen} onClose={uninstallDisclosure.close} info={app.info} />
|
||||
<UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.close} info={app.info} newVersion={newVersion} />
|
||||
<ResetAppModal onConfirm={handleResetSubmit} isOpen={resetAppDisclosure.isOpen} onClose={resetAppDisclosure.close} info={app.info} isLoading={resetMutation.status === 'executing'} />
|
||||
<InstallModal
|
||||
onSubmit={(values) => installMutation.execute({ id: app.id, form: values })}
|
||||
isOpen={installDisclosure.isOpen}
|
||||
onClose={installDisclosure.close}
|
||||
info={app.info}
|
||||
/>
|
||||
<StopModal
|
||||
onConfirm={() => stopMutation.execute({ id: app.id })}
|
||||
isOpen={stopDisclosure.isOpen}
|
||||
onClose={stopDisclosure.close}
|
||||
info={app.info}
|
||||
/>
|
||||
<UninstallModal
|
||||
onConfirm={() => uninstallMutation.execute({ id: app.id })}
|
||||
isOpen={uninstallDisclosure.isOpen}
|
||||
onClose={uninstallDisclosure.close}
|
||||
info={app.info}
|
||||
/>
|
||||
<UpdateModal
|
||||
onConfirm={() => updateMutation.execute({ id: app.id })}
|
||||
isOpen={updateDisclosure.isOpen}
|
||||
onClose={updateDisclosure.close}
|
||||
info={app.info}
|
||||
newVersion={newVersion}
|
||||
/>
|
||||
<ResetAppModal
|
||||
onConfirm={() => resetMutation.execute({ id: app.id })}
|
||||
isOpen={resetAppDisclosure.isOpen}
|
||||
onClose={resetAppDisclosure.close}
|
||||
info={app.info}
|
||||
isLoading={resetMutation.status === 'executing'}
|
||||
/>
|
||||
<UpdateSettingsModal
|
||||
onSubmit={handleUpdateSettingsSubmit}
|
||||
onSubmit={(values) => updateConfigMutation.execute({ id: app.id, form: values })}
|
||||
isOpen={updateSettingsDisclosure.isOpen}
|
||||
onClose={updateSettingsDisclosure.close}
|
||||
info={app.info}
|
||||
config={castAppConfig(app?.config)}
|
||||
onReset={openResetAppModal}
|
||||
status={customStatus}
|
||||
status={optimisticStatus}
|
||||
/>
|
||||
<div className="card-header d-flex flex-column flex-md-row">
|
||||
<AppLogo id={app.id} size={130} alt={app.info.name} />
|
||||
|
@ -222,7 +196,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
|||
<span className="badge bg-muted mt-2 text-white">{app.info.version}</span>
|
||||
</div>
|
||||
<span className="mt-1 text-muted text-center text-md-start mb-2">{app.info.short_desc}</span>
|
||||
<div className="mb-1">{customStatus !== 'missing' && <AppStatus status={customStatus} />}</div>
|
||||
<div className="mb-1">{optimisticStatus !== 'missing' && <AppStatus status={optimisticStatus} />}</div>
|
||||
<AppActions
|
||||
localDomain={localDomain}
|
||||
updateAvailable={updateAvailable}
|
||||
|
@ -233,9 +207,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app, localDomain }) => {
|
|||
onUninstall={uninstallDisclosure.open}
|
||||
onInstall={installDisclosure.open}
|
||||
onOpen={handleOpen}
|
||||
onStart={handleStartSubmit}
|
||||
onStart={() => startMutation.execute({ id: app.id })}
|
||||
app={app}
|
||||
status={customStatus}
|
||||
status={optimisticStatus}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -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} />;
|
||||
};
|
|
@ -0,0 +1 @@
|
|||
export { AppDetailsWrapper } from './AppDetailsWrapper';
|
|
@ -4,7 +4,7 @@ import { Metadata } from 'next';
|
|||
import { db } from '@/server/db';
|
||||
import { getTranslatorFromCookie } from '@/lib/get-translator';
|
||||
import { getSettings } from '@/server/core/TipiConfig';
|
||||
import { AppDetailsContainer } from './components/AppDetailsContainer/AppDetailsContainer';
|
||||
import { AppDetailsWrapper } from './components/AppDetailsContainer';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const translator = await getTranslatorFromCookie();
|
||||
|
@ -19,5 +19,5 @@ export default async function AppDetailsPage({ params }: { params: { id: string
|
|||
const app = await appsService.getApp(params.id);
|
||||
const settings = getSettings();
|
||||
|
||||
return <AppDetailsContainer app={app} localDomain={settings.localDomain} />;
|
||||
return <AppDetailsWrapper app={app} localDomain={settings.localDomain} />;
|
||||
}
|
||||
|
|
|
@ -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('/');
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -27,13 +27,12 @@ export const ChangeUsernameForm = ({ username }: Props) => {
|
|||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
const changeUsernameMutation = useAction(changeUsernameAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
toast.success(t('change-username.success'));
|
||||
router.push('/');
|
||||
}
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('change-username.success'));
|
||||
router.push('/');
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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'));
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -20,13 +20,12 @@ export const SettingsContainer = ({ initialValues, currentLocale }: Props) => {
|
|||
const router = useRouter();
|
||||
|
||||
const updateSettingsMutation = useAction(updateSettingsAction, {
|
||||
onSuccess: (data) => {
|
||||
if (!data.success) {
|
||||
toast.error(data.failure.reason);
|
||||
} else {
|
||||
toast.success(t('settings.settings.settings-updated'));
|
||||
router.refresh();
|
||||
}
|
||||
onError: (e) => {
|
||||
if (e.serverError) toast.error(e.serverError);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('settings.settings.settings-updated'));
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -36,7 +35,12 @@ export const SettingsContainer = ({ initialValues, currentLocale }: Props) => {
|
|||
|
||||
return (
|
||||
<div className="card-body">
|
||||
<SettingsForm initalValues={initialValues} currentLocale={currentLocale} loading={updateSettingsMutation.status === 'executing'} onSubmit={onSubmit} />
|
||||
<SettingsForm
|
||||
initalValues={initialValues}
|
||||
currentLocale={currentLocale}
|
||||
loading={updateSettingsMutation.status === 'executing'}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
23
src/app/actions/app-actions/revalidate-app.ts
Normal file
23
src/app/actions/app-actions/revalidate-app.ts
Normal 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);
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
71
src/lib/socket/useSocket.ts
Normal file
71
src/lib/socket/useSocket.ts
Normal 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]);
|
||||
};
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue