diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index abace4f..f364291 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -434,19 +434,22 @@ export default class SystemController { return response.send({ success: true, running: result.running ?? false, stats: result.stats ?? null }) } - /** Return a custom app's current configuration in the editable form-shape. */ + /** Return an app's current configuration in the editable form-shape. */ async getCustomApp({ params, response }: HttpContext) { const service = await Service.query().where('service_name', params.name).first() if (!service) { return response.status(404).send({ error: `Service ${params.name} not found` }) } - if (!service.is_custom) { - return response.status(403).send({ error: 'Only custom apps can be edited.' }) + // Custom and curated apps are both editable; hidden dependency services (e.g. Qdrant) are not. + if (service.is_dependency_service) { + return response.status(403).send({ error: 'This service cannot be edited.' }) } return response.send({ success: true, app: this.parseCustomContainerConfig(service) }) } - /** Reconfigure a custom app: validate + guard, persist the new config, then recreate the container. */ + /** Reconfigure an app: validate + guard, persist the new config, then recreate the container. + * Works for both custom apps and curated (pre-configured) apps. Editing a curated app marks it + * user-modified so the seeder stops overwriting the user's changes. */ async updateCustomApp({ request, response }: HttpContext) { const payload = await request.validateUsing(updateCustomAppValidator) @@ -454,8 +457,9 @@ export default class SystemController { if (!service) { return response.status(404).send({ success: false, message: `Service ${payload.service_name} not found` }) } - if (!service.is_custom) { - return response.status(403).send({ success: false, message: 'Only custom apps can be edited.' }) + // Custom and curated apps are both editable; hidden dependency services (e.g. Qdrant) are not. + if (service.is_dependency_service) { + return response.status(403).send({ success: false, message: 'This service cannot be edited.' }) } // Reject duplicate host ports within the request. @@ -492,13 +496,21 @@ export default class SystemController { } } - const { containerConfig, uiLocation } = this.buildCustomContainerConfig(payload) + // Merge the form fields into the app's existing config rather than rebuilding from scratch, + // so advanced settings a curated app ships with (GPU device requests, special env, etc.) are + // preserved across an edit. + const { containerConfig, uiLocation } = this.mergeCustomContainerConfig( + service.container_config, + payload + ) service.friendly_name = payload.friendly_name service.container_image = payload.image service.container_config = JSON.stringify(containerConfig) service.ui_location = uiLocation service.category = payload.category ?? service.category ?? 'custom' if (payload.icon) service.icon = payload.icon + // Flag as user-modified so the seeder stops overwriting this app's config on future runs. + service.is_user_modified = true await service.save() const result = await this.dockerService.recreateCustomAppContainer(payload.service_name) @@ -552,6 +564,66 @@ export default class SystemController { return { containerConfig, uiLocation } } + /** + * Merge custom-app form input into an app's *existing* container config. Used by the edit path so + * editing a curated app only changes the fields exposed in the form (image/ports/volumes/env and, + * if supplied, resource caps) while preserving everything else it ships with (GPU DeviceRequests, + * User, custom HostConfig keys, etc.). Unlike buildCustomContainerConfig, resource caps are NOT + * defaulted here — a curated app intentionally left uncapped stays uncapped unless the user sets one. + */ + private mergeCustomContainerConfig( + existingRaw: string | null, + payload: { + ports?: { container: number; host: number }[] + volumes?: { host_path: string; container_path: string }[] + env?: string[] + memory_mb?: number + cpus?: number + } + ): { containerConfig: Record; uiLocation: string | null } { + const parsed = existingRaw + ? typeof existingRaw === 'object' + ? existingRaw + : JSON.parse(existingRaw as string) + : {} + // Deep clone so we never mutate the parsed source. + const containerConfig: Record = JSON.parse(JSON.stringify(parsed ?? {})) + containerConfig.HostConfig = containerConfig.HostConfig ?? {} + // Keep a restart policy if the existing config lacked one. + containerConfig.HostConfig.RestartPolicy = + containerConfig.HostConfig.RestartPolicy ?? { Name: 'unless-stopped' } + + const portBindings: Record = {} + const exposedPorts: Record = {} + for (const { container, host } of payload.ports ?? []) { + portBindings[`${container}/tcp`] = [{ HostPort: String(host) }] + exposedPorts[`${container}/tcp`] = {} + } + containerConfig.HostConfig.PortBindings = portBindings + containerConfig.ExposedPorts = exposedPorts + + const binds = (payload.volumes ?? []).map( + ({ host_path, container_path }) => `${host_path}:${container_path}` + ) + if (binds.length) containerConfig.HostConfig.Binds = binds + else delete containerConfig.HostConfig.Binds + + if (payload.env?.length) containerConfig.Env = payload.env + else delete containerConfig.Env + + // Only touch resource caps when the user explicitly set them — preserve existing/uncapped otherwise. + if (payload.memory_mb != null) { + containerConfig.HostConfig.Memory = payload.memory_mb * 1024 * 1024 + } + if (payload.cpus != null) { + containerConfig.HostConfig.NanoCpus = Math.round(payload.cpus * 1e9) + } + + const firstHostPort = payload.ports?.[0]?.host + const uiLocation = firstHostPort ? String(firstHostPort) : null + return { containerConfig, uiLocation } + } + /** Inverse of buildCustomContainerConfig: turn a stored Service into the editable form-shape. */ private parseCustomContainerConfig(service: Service) { const raw = service.container_config diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index 452122a..b532f0f 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -69,6 +69,13 @@ export default class Service extends BaseModel { }) declare is_custom: boolean + @column({ + serialize(value) { + return Boolean(value) + }, + }) + declare is_user_modified: boolean + @column() declare category: string | null diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index 760162b..19b27e0 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -1593,6 +1593,14 @@ export class DockerService { const oldInfo = await this._findContainerByName(serviceName) const oldName = `${serviceName}_old` + // Clear any stale `_old` left behind by a previous recreate that died mid-flight. Without this, + // the rename below would fail (name in use) and the rollback path would then destroy the live + // container and resurrect the stale one in its place. + const staleOld = await this._findContainerByName(oldName) + if (staleOld) { + await this.docker.getContainer(staleOld.Id).remove({ force: true }).catch(() => {}) + } + try { // Stop + rename the existing container aside as a rollback safety net. if (oldInfo) { diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index ed3e5e0..8e27d85 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -310,6 +310,7 @@ export class SystemService { 'container_image', 'available_update_version', 'is_custom', + 'is_user_modified', 'category' ) .where('is_dependency_service', false) @@ -341,6 +342,7 @@ export class SystemService { container_image: service.container_image, available_update_version: service.available_update_version, is_custom: service.is_custom, + is_user_modified: service.is_user_modified, category: service.category, }) } diff --git a/admin/database/migrations/1772000000002_add_user_modified_to_services.ts b/admin/database/migrations/1772000000002_add_user_modified_to_services.ts new file mode 100644 index 0000000..b4b359a --- /dev/null +++ b/admin/database/migrations/1772000000002_add_user_modified_to_services.ts @@ -0,0 +1,20 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + // Set when a user edits a curated (non-custom) app. Tells the seeder to stop + // overwriting that service's container_config on subsequent runs, so the user's + // customizations (e.g. a changed port) survive reboots and upgrades. + table.boolean('is_user_modified').notNullable().defaultTo(false) + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('is_user_modified') + }) + } +} diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index 6221201..dda4b08 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -7,7 +7,13 @@ import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js' type ServiceSeedRecord = Omit< ModelAttributes, - 'created_at' | 'updated_at' | 'id' | 'available_update_version' | 'update_checked_at' | 'metadata' + | 'created_at' + | 'updated_at' + | 'id' + | 'available_update_version' + | 'update_checked_at' + | 'metadata' + | 'is_user_modified' > & { metadata?: string | null } export default class ServiceSeeder extends BaseSeeder { @@ -460,7 +466,11 @@ export default class ServiceSeeder extends BaseSeeder { ] async run() { - const existingServices = await Service.query().select(['service_name', 'is_custom']) + const existingServices = await Service.query().select([ + 'service_name', + 'is_custom', + 'is_user_modified', + ]) const existingServiceMap = new Map(existingServices.map((s) => [s.service_name, s])) const newServices = ServiceSeeder.DEFAULT_SERVICES.filter( @@ -472,10 +482,11 @@ export default class ServiceSeeder extends BaseSeeder { } // Keep container_config, container_command, and metadata in sync for curated services. - // Custom services are user-defined and must never be overwritten. + // Custom services are user-defined and must never be overwritten. User-modified curated + // services (a user edited their config) are likewise left alone so the edit survives reboots. for (const service of ServiceSeeder.DEFAULT_SERVICES) { const existing = existingServiceMap.get(service.service_name) - if (existing && !existing.is_custom) { + if (existing && !existing.is_custom && !existing.is_user_modified) { await Service.query().where('service_name', service.service_name).update({ container_config: service.container_config, container_command: service.container_command ?? null, diff --git a/admin/inertia/components/CustomAppModal.tsx b/admin/inertia/components/CustomAppModal.tsx index fb04bb5..77ada8f 100644 --- a/admin/inertia/components/CustomAppModal.tsx +++ b/admin/inertia/components/CustomAppModal.tsx @@ -286,7 +286,7 @@ export default function CustomAppModal({ return ( - {/* Installed accent spine */} + {/* Installed accent spine (rounded to follow the card corners — the card no longer clips + overflow so the Manage dropdown can open above the card without being cut off) */} {service.installed ? ( -
+
) : null} {/* Top row: icon + status badge */} @@ -700,6 +701,14 @@ function AppCard({ custom ) : null} + {service.is_user_modified && !service.is_custom ? ( + + modified + + ) : null} {service.ui_location && !service.ui_location.startsWith('/') && ( :{service.ui_location} @@ -754,9 +763,7 @@ function AppCard({ } label="Restart" onClick={onRestart} /> } label="Logs" onClick={onLogs} /> } label="Stats" onClick={onStats} /> - {service.is_custom ? ( - } label="Edit" onClick={onEdit} /> - ) : null} + } label="Edit" onClick={onEdit} /> {service.is_custom ? ( } label="Update (pull latest)" onClick={onUpdate} /> ) : null} diff --git a/admin/types/services.ts b/admin/types/services.ts index 380001e..526af20 100644 --- a/admin/types/services.ts +++ b/admin/types/services.ts @@ -15,5 +15,6 @@ export type ServiceSlim = Pick< | 'container_image' | 'available_update_version' | 'is_custom' + | 'is_user_modified' | 'category' > & { status?: string }