feat: edit curated apps + fix dropdown clip + stale _old rollback
This commit is contained in:
parent
be434d755a
commit
02c33b277e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -15,5 +15,6 @@ export type ServiceSlim = Pick<
|
|||
| 'container_image'
|
||||
| 'available_update_version'
|
||||
| 'is_custom'
|
||||
| 'is_user_modified'
|
||||
| 'category'
|
||||
> & { status?: string }
|
||||
|
|
|
|||
Loading…
Reference in New Issue