From f0e3f39a587a211cac2c289ece4a0c7a5dadd635 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 27 Dec 2024 14:38:21 +0100 Subject: [PATCH] feat(apps): allow user to give a specific custom port for the app --- packages/backend/src/app.service.ts | 4 +- .../drizzle/0016_cloudy_norman_osborn.sql | 1 + .../legacy/0014_mushy_william_stryker.sql | 15 - .../legacy/0015_careful_aaron_stack.sql | 1 - .../drizzle/legacy/0016_blue_landau.sql | 5 - .../database/drizzle/meta/0016_snapshot.json | 401 ++++++++++++++++++ .../core/database/drizzle/meta/_journal.json | 7 + .../src/core/database/drizzle/schema.ts | 1 + .../app-lifecycle/app-lifecycle.controller.ts | 4 +- .../app-lifecycle/app-lifecycle.service.ts | 13 +- .../app-lifecycle/dto/app-lifecycle.dto.ts | 12 +- .../backend/src/modules/apps/app.helpers.ts | 2 +- .../backend/src/modules/apps/dto/app.dto.ts | 1 + .../src/modules/i18n/translations/en.json | 2 + .../backend/src/modules/queue/queue.entity.ts | 2 +- .../src/modules/queue/queue.factory.ts | 2 +- packages/backend/src/schemas/queue-schemas.ts | 33 -- packages/backend/src/swagger.json | 21 +- packages/frontend/src/api-client/types.gen.ts | 4 + .../src/components/ui/Input/Input.tsx | 61 +-- .../install-form/form-validators.ts | 12 +- .../components/install-form/install-form.tsx | 19 +- .../containers/app-actions/app-actions.tsx | 4 +- 23 files changed, 509 insertions(+), 118 deletions(-) create mode 100644 packages/backend/src/core/database/drizzle/0016_cloudy_norman_osborn.sql delete mode 100644 packages/backend/src/core/database/drizzle/legacy/0014_mushy_william_stryker.sql delete mode 100644 packages/backend/src/core/database/drizzle/legacy/0015_careful_aaron_stack.sql delete mode 100644 packages/backend/src/core/database/drizzle/legacy/0016_blue_landau.sql create mode 100644 packages/backend/src/core/database/drizzle/meta/0016_snapshot.json delete mode 100644 packages/backend/src/schemas/queue-schemas.ts diff --git a/packages/backend/src/app.service.ts b/packages/backend/src/app.service.ts index ce7b617e10..641cf6e055 100644 --- a/packages/backend/src/app.service.ts +++ b/packages/backend/src/app.service.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import { Injectable } from '@nestjs/common'; -import Sentry from '@sentry/nestjs'; +import * as Sentry from '@sentry/nestjs'; import { z } from 'zod'; import { LATEST_RELEASE_URL } from './common/constants'; import { execAsync } from './common/helpers/exec-helpers'; @@ -11,7 +11,6 @@ import { FilesystemService } from './core/filesystem/filesystem.service'; import { LoggerService } from './core/logger/logger.service'; import { SocketManager } from './core/socket/socket.service'; import { AppStoreService } from './modules/app-stores/app-store.service'; -import { AppsRepository } from './modules/apps/apps.repository'; import { MarketplaceService } from './modules/marketplace/marketplace.service'; import { RepoEventsQueue } from './modules/queue/entities/repo-events'; @@ -25,7 +24,6 @@ export class AppService { private readonly socketManager: SocketManager, private readonly filesystem: FilesystemService, private readonly appStoreService: AppStoreService, - private readonly appsRepository: AppsRepository, private readonly marketplaceService: MarketplaceService, private readonly databaseService: DatabaseService, ) {} diff --git a/packages/backend/src/core/database/drizzle/0016_cloudy_norman_osborn.sql b/packages/backend/src/core/database/drizzle/0016_cloudy_norman_osborn.sql new file mode 100644 index 0000000000..5e4caafadd --- /dev/null +++ b/packages/backend/src/core/database/drizzle/0016_cloudy_norman_osborn.sql @@ -0,0 +1 @@ +ALTER TABLE "app" ADD COLUMN "port" integer; \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/legacy/0014_mushy_william_stryker.sql b/packages/backend/src/core/database/drizzle/legacy/0014_mushy_william_stryker.sql deleted file mode 100644 index 72dca8cd0c..0000000000 --- a/packages/backend/src/core/database/drizzle/legacy/0014_mushy_william_stryker.sql +++ /dev/null @@ -1,15 +0,0 @@ -CREATE TABLE IF NOT EXISTS "app_store" ( - "id" serial PRIMARY KEY NOT NULL, - "hash" varchar NOT NULL, - "name" varchar NOT NULL, - "enabled" boolean DEFAULT true NOT NULL, - "url" varchar NOT NULL, - "branch" varchar DEFAULT 'main' NOT NULL, - "createdAt" timestamp DEFAULT now() NOT NULL, - "updatedAt" timestamp DEFAULT now() NOT NULL, - CONSTRAINT "app_store_hash_unique" UNIQUE("hash") -); ---> statement-breakpoint -DROP TABLE IF EXISTS "migrations" CASCADE;--> statement-breakpoint -DROP TABLE IF EXISTS "update" CASCADE;--> statement-breakpoint -ALTER TABLE "app" ADD COLUMN IF NOT EXISTS "app_store_id" integer; diff --git a/packages/backend/src/core/database/drizzle/legacy/0015_careful_aaron_stack.sql b/packages/backend/src/core/database/drizzle/legacy/0015_careful_aaron_stack.sql deleted file mode 100644 index f86599dfbd..0000000000 --- a/packages/backend/src/core/database/drizzle/legacy/0015_careful_aaron_stack.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "app_store" ADD COLUMN IF NOT EXISTS "deleted" boolean DEFAULT false NOT NULL; diff --git a/packages/backend/src/core/database/drizzle/legacy/0016_blue_landau.sql b/packages/backend/src/core/database/drizzle/legacy/0016_blue_landau.sql deleted file mode 100644 index 4d1f0bc40e..0000000000 --- a/packages/backend/src/core/database/drizzle/legacy/0016_blue_landau.sql +++ /dev/null @@ -1,5 +0,0 @@ -DO $$ BEGIN - ALTER TABLE "app" ADD CONSTRAINT "app_app_store_id_app_store_id_fk" FOREIGN KEY ("app_store_id") REFERENCES "public"."app_store"("id") ON DELETE no action ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; diff --git a/packages/backend/src/core/database/drizzle/meta/0016_snapshot.json b/packages/backend/src/core/database/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000000..0bc36dae6d --- /dev/null +++ b/packages/backend/src/core/database/drizzle/meta/0016_snapshot.json @@ -0,0 +1,401 @@ +{ + "id": "bc9468c8-4acb-4969-9873-9cf8562bc3fe", + "prevId": "c98e7f88-641b-4653-9209-1032249c1f09", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.app": { + "name": "app", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "app_status_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'stopped'" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "exposed": { + "name": "exposed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "domain": { + "name": "domain", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "is_visible_on_guest_dashboard": { + "name": "is_visible_on_guest_dashboard", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "open_port": { + "name": "open_port", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "exposed_local": { + "name": "exposed_local", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "app_store_slug": { + "name": "app_store_slug", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "app_name": { + "name": "app_name", + "type": "varchar", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "app_app_store_slug_app_store_slug_fk": { + "name": "app_app_store_slug_app_store_slug_fk", + "tableFrom": "app", + "tableTo": "app_store", + "columnsFrom": [ + "app_store_slug" + ], + "columnsTo": [ + "slug" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_store": { + "name": "app_store", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "app_store_hash_unique": { + "name": "app_store_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.link": { + "name": "link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "link_user_id_user_id_fk": { + "name": "link_user_id_user_id_fk", + "tableFrom": "link", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password": { + "name": "password", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "operator": { + "name": "operator", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "salt": { + "name": "salt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "has_seen_welcome": { + "name": "has_seen_welcome", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.app_status_enum": { + "name": "app_status_enum", + "schema": "public", + "values": [ + "running", + "stopped", + "installing", + "uninstalling", + "stopping", + "starting", + "missing", + "updating", + "resetting", + "restarting", + "backing_up", + "restoring" + ] + }, + "public.update_status_enum": { + "name": "update_status_enum", + "schema": "public", + "values": [ + "FAILED", + "SUCCESS" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/meta/_journal.json b/packages/backend/src/core/database/drizzle/meta/_journal.json index 61bd432b36..8ba2d19dc2 100644 --- a/packages/backend/src/core/database/drizzle/meta/_journal.json +++ b/packages/backend/src/core/database/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1735220946368, "tag": "0015_unusual_newton_destine", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1735306634382, + "tag": "0016_cloudy_norman_osborn", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/backend/src/core/database/drizzle/schema.ts b/packages/backend/src/core/database/drizzle/schema.ts index a257b7921d..f820734622 100644 --- a/packages/backend/src/core/database/drizzle/schema.ts +++ b/packages/backend/src/core/database/drizzle/schema.ts @@ -50,6 +50,7 @@ export const app = pgTable('app', { domain: varchar(), isVisibleOnGuestDashboard: boolean('is_visible_on_guest_dashboard').default(false).notNull(), openPort: boolean('open_port').default(true).notNull(), + port: integer(), exposedLocal: boolean('exposed_local').default(true).notNull(), appStoreSlug: varchar('app_store_slug') .notNull() diff --git a/packages/backend/src/modules/app-lifecycle/app-lifecycle.controller.ts b/packages/backend/src/modules/app-lifecycle/app-lifecycle.controller.ts index ac17d38c65..dd7a2f142d 100644 --- a/packages/backend/src/modules/app-lifecycle/app-lifecycle.controller.ts +++ b/packages/backend/src/modules/app-lifecycle/app-lifecycle.controller.ts @@ -48,9 +48,7 @@ export class AppLifecycleController { @Patch(':urn/update-config') async updateAppConfig(@Param('urn') urn: string, @Body() body: AppFormBody) { - const form = appFormSchema.parse(body); - - return this.appLifecycleService.updateAppConfig({ appUrn: castAppUrn(urn), form }); + return this.appLifecycleService.updateAppConfig({ appUrn: castAppUrn(urn), form: body }); } @Patch('update-all') diff --git a/packages/backend/src/modules/app-lifecycle/app-lifecycle.service.ts b/packages/backend/src/modules/app-lifecycle/app-lifecycle.service.ts index 42f04347dd..b6256bded1 100644 --- a/packages/backend/src/modules/app-lifecycle/app-lifecycle.service.ts +++ b/packages/backend/src/modules/app-lifecycle/app-lifecycle.service.ts @@ -14,7 +14,7 @@ import { AppsRepository } from '../apps/apps.repository'; import { AppsService } from '../apps/apps.service'; import { BackupManager } from '../backups/backup.manager'; import { MarketplaceService } from '../marketplace/marketplace.service'; -import { type AppEventFormInput, AppEventsQueue, appEventResultSchema, appEventSchema } from '../queue/entities/app-events'; +import { AppEventsQueue, appEventResultSchema, appEventSchema } from '../queue/entities/app-events'; import { AppLifecycleCommandFactory } from './app-lifecycle-command.factory'; import { appFormSchema } from './dto/app-lifecycle.dto'; @@ -71,7 +71,7 @@ export class AppLifecycleService { }); } - async installApp(params: { appUrn: AppUrn; form: AppEventFormInput }): Promise { + async installApp(params: { appUrn: AppUrn; form: unknown }): Promise { const { appUrn, form } = params; const { demoMode, version, architecture } = this.config.getConfig(); @@ -83,7 +83,8 @@ export class AppLifecycleService { return this.startApp({ appUrn }); } - const { exposed, exposedLocal, openPort, domain, isVisibleOnGuestDashboard } = form; + const parsedForm = appFormSchema.parse(form); + const { exposed, exposedLocal, openPort, domain, isVisibleOnGuestDashboard } = parsedForm; const apps = await this.appRepository.getApps(); if (demoMode && apps.length >= 6) { @@ -133,7 +134,8 @@ export class AppLifecycleService { const createdApp = await this.appRepository.createApp({ appName, status: 'installing', - config: form, + config: parsedForm, + port: parsedForm.port ?? appInfo.port, version: appInfo.tipi_version, exposed: exposed || false, domain: domain || null, @@ -144,7 +146,7 @@ export class AppLifecycleService { }); // Send install command to the queue - this.appEventsQueue.publish({ appUrn, command: 'install', form }).then(async ({ success, message }) => { + this.appEventsQueue.publish({ appUrn, command: 'install', form: parsedForm }).then(async ({ success, message }) => { if (success) { this.logger.info(`App ${appUrn} installed successfully`); await this.socketManager.emit({ type: 'app', event: 'install_success', data: { appUrn, appStatus: 'running' } }); @@ -335,6 +337,7 @@ export class AppLifecycleService { exposed: exposed ?? false, exposedLocal: parsedForm.exposedLocal ?? false, openPort: parsedForm.openPort, + port: parsedForm.port ?? appInfo.port, domain: domain || null, config: parsedForm, isVisibleOnGuestDashboard: parsedForm.isVisibleOnGuestDashboard ?? false, diff --git a/packages/backend/src/modules/app-lifecycle/dto/app-lifecycle.dto.ts b/packages/backend/src/modules/app-lifecycle/dto/app-lifecycle.dto.ts index 9d4e6510d3..2068081172 100644 --- a/packages/backend/src/modules/app-lifecycle/dto/app-lifecycle.dto.ts +++ b/packages/backend/src/modules/app-lifecycle/dto/app-lifecycle.dto.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; export const appFormSchema = z .object({ + port: z.coerce.number().optional(), exposed: z.boolean().optional(), exposedLocal: z.boolean().optional(), openPort: z.boolean().optional().default(true), @@ -12,7 +13,16 @@ export const appFormSchema = z .extend({}) .catchall(z.unknown()); -export class AppFormBody extends createZodDto(appFormSchema) {} +export class AppFormBody extends createZodDto( + z.object({ + port: z.string().optional(), + exposed: z.boolean().optional(), + exposedLocal: z.boolean().optional(), + openPort: z.boolean().optional(), + domain: z.string().optional(), + isVisibleOnGuestDashboard: z.boolean().optional(), + }), +) {} export class UninstallAppBody extends createZodDto(z.object({ removeBackups: z.boolean() })) {} diff --git a/packages/backend/src/modules/apps/app.helpers.ts b/packages/backend/src/modules/apps/app.helpers.ts index bfd5f7865e..1163542a33 100644 --- a/packages/backend/src/modules/apps/app.helpers.ts +++ b/packages/backend/src/modules/apps/app.helpers.ts @@ -44,7 +44,7 @@ export class AppHelpers { const { appName, appStoreId } = extractAppUrn(appUrn); // Default always present env variables - envMap.set('APP_PORT', String(config.port)); + envMap.set('APP_PORT', form.port ? String(form.port) : String(config.port)); envMap.set('APP_ID', appUrn); envMap.set('ROOT_FOLDER_HOST', rootFolderHost); envMap.set('APP_DATA_DIR', path.join(userSettings.appDataPath, appStoreId, appName)); diff --git a/packages/backend/src/modules/apps/dto/app.dto.ts b/packages/backend/src/modules/apps/dto/app.dto.ts index 81572afe6e..17475bf50e 100644 --- a/packages/backend/src/modules/apps/dto/app.dto.ts +++ b/packages/backend/src/modules/apps/dto/app.dto.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; export class AppDto extends createZodDto( z.object({ id: z.number(), + port: z.number().nullable(), status: z.enum(APP_STATUS), createdAt: z.string().optional(), updatedAt: z.string().optional(), diff --git a/packages/backend/src/modules/i18n/translations/en.json b/packages/backend/src/modules/i18n/translations/en.json index 271df64720..c18ec8f326 100644 --- a/packages/backend/src/modules/i18n/translations/en.json +++ b/packages/backend/src/modules/i18n/translations/en.json @@ -69,6 +69,7 @@ "APP_INSTALL_FORM_ERROR_REGEX": "{{label}} must match the pattern {{pattern}}", "APP_INSTALL_FORM_ERROR_REQUIRED": "{{label}} is required", "APP_INSTALL_FORM_ERROR_URL": "{{label}} must be a valid URL", + "APP_INSTALL_FORM_ERROR_PORT": "Port must be a number between 1024 and 65535", "APP_INSTALL_FORM_EXPOSE_APP": "Expose app on the internet", "APP_INSTALL_FORM_OPEN_PORT": "Open port", "APP_INSTALL_FORM_OPEN_PORT_HINT": "Open a port on the host? This app will be accessible at {{internalIp}}:{{port}}. (Easiest but less secure)", @@ -80,6 +81,7 @@ "APP_INSTALL_FORM_TITLE": "Install {{name}}", "APP_INSTALL_FORM_GENERAL": "General", "APP_INSTALL_FORM_REVERSE_PROXY": "Reverse proxy", + "APP_INSTALL_FORM_PORT_HINT": "Port on which the app will be accessible", "APP_INSTALL_SUCCESS": "App {{id}} installed successfully", "APP_LOGS_TAB_FOLLOW": "Follow logs", "APP_LOGS_TAB_MAX_LINES": "Max lines:", diff --git a/packages/backend/src/modules/queue/queue.entity.ts b/packages/backend/src/modules/queue/queue.entity.ts index bd3e82b747..a08fc702ea 100644 --- a/packages/backend/src/modules/queue/queue.entity.ts +++ b/packages/backend/src/modules/queue/queue.entity.ts @@ -1,5 +1,5 @@ import type { LoggerService } from '@/core/logger/logger.service'; -import Sentry from '@sentry/nestjs'; +import * as Sentry from '@sentry/nestjs'; import cron from 'node-cron'; import type { Connection, RPCClient } from 'rabbitmq-client'; import { type ZodSchema, z } from 'zod'; diff --git a/packages/backend/src/modules/queue/queue.factory.ts b/packages/backend/src/modules/queue/queue.factory.ts index fd06952089..6bc4c310d4 100644 --- a/packages/backend/src/modules/queue/queue.factory.ts +++ b/packages/backend/src/modules/queue/queue.factory.ts @@ -1,6 +1,6 @@ import { LoggerService } from '@/core/logger/logger.service'; import { Injectable } from '@nestjs/common'; -import Sentry from '@sentry/nestjs'; +import * as Sentry from '@sentry/nestjs'; import { Connection } from 'rabbitmq-client'; import { type ZodSchema, z } from 'zod'; import { Queue } from './queue.entity'; diff --git a/packages/backend/src/schemas/queue-schemas.ts b/packages/backend/src/schemas/queue-schemas.ts deleted file mode 100644 index f6d7c47336..0000000000 --- a/packages/backend/src/schemas/queue-schemas.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { appFormSchema } from '@/modules/app-lifecycle/dto/app-lifecycle.dto'; -import { z } from 'zod'; - -export const EVENT_TYPES = { - SYSTEM: 'system', - REPO: 'repo', - APP: 'app', -} as const; - -export type EventType = (typeof EVENT_TYPES)[keyof typeof EVENT_TYPES]; - -const updateAppCommandSchema = z.object({ - command: z.literal('update'), - appid: z.string(), - form: appFormSchema, - performBackup: z.boolean(), -}); - -const restoreAppCommandSchema = z.object({ - command: z.literal('restore'), - appid: z.string(), - filename: z.string(), -}); - -const systemCommandSchema = z.object({ - type: z.literal(EVENT_TYPES.SYSTEM), - command: z.literal('system_info'), -}); - -export const eventResultSchema = z.object({ - success: z.boolean(), - stdout: z.string(), -}); diff --git a/packages/backend/src/swagger.json b/packages/backend/src/swagger.json index 8cea5e8c6f..1f5fc6501d 100644 --- a/packages/backend/src/swagger.json +++ b/packages/backend/src/swagger.json @@ -1991,6 +1991,10 @@ "id": { "type": "number" }, + "port": { + "type": "number", + "nullable": true + }, "status": { "type": "string", "enum": [ @@ -2040,6 +2044,7 @@ }, "required": [ "id", + "port", "status", "version", "exposed", @@ -2167,6 +2172,10 @@ "id": { "type": "number" }, + "port": { + "type": "number", + "nullable": true + }, "status": { "type": "string", "enum": [ @@ -2216,6 +2225,7 @@ }, "required": [ "id", + "port", "status", "version", "exposed", @@ -2485,6 +2495,10 @@ "id": { "type": "number" }, + "port": { + "type": "number", + "nullable": true + }, "status": { "type": "string", "enum": [ @@ -2534,6 +2548,7 @@ }, "required": [ "id", + "port", "status", "version", "exposed", @@ -2978,6 +2993,9 @@ "AppFormBody": { "type": "object", "properties": { + "port": { + "type": "string" + }, "exposed": { "type": "boolean" }, @@ -2985,8 +3003,7 @@ "type": "boolean" }, "openPort": { - "type": "boolean", - "default": true + "type": "boolean" }, "domain": { "type": "string" diff --git a/packages/frontend/src/api-client/types.gen.ts b/packages/frontend/src/api-client/types.gen.ts index f28966cbf7..7194694711 100644 --- a/packages/frontend/src/api-client/types.gen.ts +++ b/packages/frontend/src/api-client/types.gen.ts @@ -77,6 +77,7 @@ export type AppContextDto = { }; export type AppFormBody = { + port?: string; exposed?: boolean; exposedLocal?: boolean; openPort?: boolean; @@ -132,6 +133,7 @@ export type GetAppBackupsDto = { export type GetAppDto = { app?: { id: number; + port: number | null; status: | 'running' | 'stopped' @@ -255,6 +257,7 @@ export type GuestAppsDto = { installed: Array<{ app: { id: number; + port: number | null; status: | 'running' | 'stopped' @@ -387,6 +390,7 @@ export type MyAppsDto = { installed: Array<{ app: { id: number; + port: number | null; status: | 'running' | 'stopped' diff --git a/packages/frontend/src/components/ui/Input/Input.tsx b/packages/frontend/src/components/ui/Input/Input.tsx index b794539e75..82d9e12513 100644 --- a/packages/frontend/src/components/ui/Input/Input.tsx +++ b/packages/frontend/src/components/ui/Input/Input.tsx @@ -1,48 +1,29 @@ import clsx from 'clsx'; import React from 'react'; -interface IProps { - placeholder?: string; +interface IProps extends React.InputHTMLAttributes { error?: string; label?: string | React.ReactNode; - className?: string; isInvalid?: boolean; - type?: HTMLInputElement['type']; - onChange?: (e: React.ChangeEvent) => void; - name?: string; - onBlur?: (e: React.FocusEvent) => void; - disabled?: boolean; - value?: string; - readOnly?: boolean; - maxLength?: number; } -export const Input = React.forwardRef( - ({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled, readOnly, maxLength }, ref) => ( -
- {label && ( - - )} - - {error &&
{error}
} -
- ), -); +export const Input = React.forwardRef(({ name, label, error, type = 'text', className, isInvalid, ...rest }, ref) => ( +
+ {label && ( + + )} + + {error &&
{error}
} +
+)); diff --git a/packages/frontend/src/modules/app/components/install-form/form-validators.ts b/packages/frontend/src/modules/app/components/install-form/form-validators.ts index 10b2a76772..21f131834d 100644 --- a/packages/frontend/src/modules/app/components/install-form/form-validators.ts +++ b/packages/frontend/src/modules/app/components/install-form/form-validators.ts @@ -6,7 +6,7 @@ type ValidationError = { params?: Record; }; -export const validateField = (field: FormField, value: string | undefined | boolean): ValidationError | undefined => { +export const validateField = (field: FormField, value: unknown): ValidationError | undefined => { if (field.required && !value && typeof value !== 'boolean') { return { messageKey: 'APP_INSTALL_FORM_ERROR_REQUIRED', params: { label: field.label } }; } @@ -73,7 +73,7 @@ export const validateField = (field: FormField, value: string | undefined | bool return undefined; }; -const validateDomain = (domain?: string | boolean): ValidationError | undefined => { +const validateDomain = (domain?: unknown): ValidationError | undefined => { if (typeof domain !== 'string' || !validator.isFQDN(domain || '')) { return { messageKey: 'APP_INSTALL_FORM_ERROR_FQDN', params: { label: String(domain) } }; } @@ -81,8 +81,8 @@ const validateDomain = (domain?: string | boolean): ValidationError | undefined return undefined; }; -export const validateAppConfig = (values: Record, fields: FormField[]) => { - const { exposed, domain, ...config } = values; +export const validateAppConfig = (values: Record, fields: FormField[]) => { + const { exposed, openPort, domain, port, ...config } = values; const errors: Record = {}; @@ -102,5 +102,9 @@ export const validateAppConfig = (values: Record = ({ formFields = [], info, onSubmit, control, } = useForm({}); const watchExposed = watch('exposed', false); + const watchOpenPort = watch('openPort', true); useEffect(() => { if (info.force_expose) { @@ -136,6 +138,21 @@ export const InstallForm: React.FC = ({ formFields = [], info, onSubmit, /> )} /> + {watchOpenPort && ( +
+ + {t('APP_INSTALL_FORM_PORT_HINT')} +
+ )} { {(app?.openPort || !info.dynamic_config) && ( handleOpen('local')}> - {hostname}:{info.port} + {hostname}:{app?.port ?? info.port} )} @@ -200,7 +200,7 @@ export const AppActions = ({ app, info, localDomain, metadata }: IProps) => { if (typeof window !== 'undefined') { // Current domain const domain = window.location.hostname; - url = `${protocol}://${domain}:${info.port}${info.url_suffix || ''}`; + url = `${protocol}://${domain}:${app?.port ?? info.port}${info.url_suffix || ''}`; } if (type === 'domain' && app?.domain) {