feat: edit curated apps + fix dropdown clip + stale _old rollback

This commit is contained in:
Chris Sherwood 2026-06-04 09:21:32 -07:00 committed by jakeaturner
parent be434d755a
commit 02c33b277e
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
9 changed files with 146 additions and 18 deletions

View File

@ -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<string, any>; 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<string, any> = 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<string, [{ HostPort: string }]> = {}
const exposedPorts: Record<string, {}> = {}
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

View File

@ -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

View File

@ -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) {

View File

@ -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,
})
}

View File

@ -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')
})
}
}

View File

@ -7,7 +7,13 @@ import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
type ServiceSeedRecord = Omit<
ModelAttributes<Service>,
'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,

View File

@ -286,7 +286,7 @@ export default function CustomAppModal({
return (
<StyledModal
title={isEdit ? 'Edit Custom App' : 'Add Custom App'}
title={isEdit ? 'Edit App' : 'Add Custom App'}
open={open}
onCancel={handleClose}
cancelText="Cancel"

View File

@ -629,15 +629,16 @@ function AppCard({
return (
<div
className={`relative flex flex-col rounded-xl border p-4 bg-surface-primary shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 overflow-hidden ${
className={`relative flex flex-col rounded-xl border p-4 bg-surface-primary shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${
service.installed
? 'border-desert-stone-light'
: 'border-desert-stone-lighter hover:border-desert-stone-light'
}`}
>
{/* 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 ? (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-desert-green" />
<div className="absolute left-0 top-0 bottom-0 w-1 bg-desert-green rounded-l-xl" />
) : null}
{/* Top row: icon + status badge */}
@ -700,6 +701,14 @@ function AppCard({
custom
</span>
) : null}
{service.is_user_modified && !service.is_custom ? (
<span
className="text-xs px-2 py-0.5 rounded-full font-medium bg-desert-tan-lighter text-desert-tan-dark border border-desert-tan-light"
title="You've customized this app, so it won't be overwritten by catalog updates."
>
modified
</span>
) : null}
{service.ui_location && !service.ui_location.startsWith('/') && (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-secondary text-text-muted font-mono">
:{service.ui_location}
@ -754,9 +763,7 @@ function AppCard({
<DropdownItem icon={<IconRefresh className="h-4 w-4" />} label="Restart" onClick={onRestart} />
<DropdownItem icon={<IconFileText className="h-4 w-4" />} label="Logs" onClick={onLogs} />
<DropdownItem icon={<IconChartBar className="h-4 w-4" />} label="Stats" onClick={onStats} />
{service.is_custom ? (
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
) : null}
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
{service.is_custom ? (
<DropdownItem icon={<IconCloudDownload className="h-4 w-4" />} label="Update (pull latest)" onClick={onUpdate} />
) : null}

View File

@ -15,5 +15,6 @@ export type ServiceSlim = Pick<
| 'container_image'
| 'available_update_version'
| 'is_custom'
| 'is_user_modified'
| 'category'
> & { status?: string }