feat(supply-depot): ability to migrate Kolibri content to Gen 2 without re-download

This commit is contained in:
jakeaturner 2026-06-16 20:41:15 +00:00
parent 565bad59da
commit 2dcfb55a4e
No known key found for this signature in database
GPG Key ID: B1072EBDEECE328D
6 changed files with 478 additions and 7 deletions

View File

@ -499,6 +499,17 @@ export default class SystemController {
return response.send({ success: true, message: result.message })
}
/** Migrate downloaded content (channels + content files already on disk) from the legacy Kolibri
* install into the Gen 2 install, entirely locally. Accounts/progress are intentionally left as a
* manual step. No request payload the source/target services are fixed. */
async migrateLegacyKolibriContent({ response }: HttpContext) {
const result = await this.dockerService.migrateLegacyKolibriContent()
if (!result.success) {
return response.status(400).send({ success: false, message: result.message })
}
return response.send({ success: true, message: result.message })
}
/** Set or clear an app's custom launch URL (works for curated and custom apps). Purely a
* metadata change no container is touched. An empty/invalid value clears the override, after
* which the default host + port link is used again. */

View File

@ -17,9 +17,9 @@ import {
} from '../utils/fs.js'
import { KiwixLibraryService } from './kiwix_library_service.js'
import { SERVICE_NAMES } from '../../constants/service_names.js'
import { exec } from 'child_process'
import { exec, execFile } from 'child_process'
import { promisify } from 'util'
import { readFile, mkdir, copyFile, chown, chmod, access } from 'node:fs/promises'
import { readFile, mkdir, copyFile, chown, chmod, access, readdir } from 'node:fs/promises'
import KVStore from '#models/kv_store'
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
@ -590,6 +590,43 @@ export class DockerService {
})
}
/**
* Resolve the base path THIS admin process uses to read/write storage files (guards, enumeration,
* pre-install fs work). This differs from {@link _resolveHostStorageRoot}, which returns the *host*
* path used for child-container bind mounts:
* - Containerized admin (production): the host storage volume is bind-mounted into the admin at
* ADMIN_STORAGE_DEST (`/app/storage`); the raw host path is NOT directly reachable from inside it.
* - Admin running on the host (dev): there is no admin mount, and the resolved host storage root
* is itself directly stat-able.
* Returns whichever path the running process can actually reach, so fs lookups don't silently miss
* data that lives under a different view (the cause of a false "Gen 2 not initialised" guard in dev).
*/
private async _resolveAdminStorageBase(): Promise<string> {
try {
const containers = await this.docker.listContainers({ all: true })
let adminInfo = containers.find((c) =>
c.Names.includes(`/${DockerService.ADMIN_CONTAINER_NAME}`)
)
if (!adminInfo) {
const hn = os.hostname()
adminInfo = containers.find((c) => c.Id.startsWith(hn))
}
if (adminInfo) {
const inspected = await this.docker.getContainer(adminInfo.Id).inspect()
const hasStorageMount = (inspected.Mounts ?? []).some(
(m: any) => m.Type === 'bind' && m.Destination === DockerService.ADMIN_STORAGE_DEST
)
if (hasStorageMount) return DockerService.ADMIN_STORAGE_DEST
}
} catch (err: any) {
logger.warn(
`[DockerService] _resolveAdminStorageBase falling back to host storage root: ${err.message}`
)
}
// Dev / non-containerized admin: the host storage root is directly accessible on the host.
return this._resolveHostStorageRoot()
}
/**
* Handles the long-running process of creating a Docker container for a service.
* NOTE: This method should not be called directly. Instead, use `createContainerPreflight` to check prerequisites first
@ -1895,6 +1932,334 @@ export class DockerService {
return { success: true, message: `Service ${serviceName} uninstalled` }
}
/**
* Migrate downloaded *content* (channels + content files already on disk) from a legacy
* treehouses/kolibri:0.12.8 install into the new learningequality Gen 2 install entirely
* locally, with no internet. Kolibri's own `importchannel`/`importcontent disk` register the
* existing channel DBs and content blobs against the fresh 0.19.4 install (cross-version import
* works). Only user accounts and learner progress are NOT carried their main-DB schema jump
* can't be migrated safely so they remain a manual rebuild step in Gen 2.
*
* The running Gen 2 container does not mount the legacy home (and you can't add a mount to a live
* container), so each import runs in a one-off Gen 2-image container that binds BOTH homes:
* the writable Gen 2 home at /kolibri (its KOLIBRI_HOME) and the legacy home read-only at /legacy.
* We deliberately do NOT pass `User`: the image entrypoint drops privileges to the `kolibri` user
* and normalises ownership of /kolibri itself, exactly as the real Gen 2 container does, so imported
* files end up owned identically with no chown of our own. Gen 2 is stopped during the import to
* avoid SQLite contention on the shared db.sqlite3 and is always restarted in `finally`.
*
* Idempotent: re-running re-imports channel DBs harmlessly and `importcontent` skips blobs already
* present, so a second run is a fast no-op. One bad channel doesn't abort the rest.
*/
async migrateLegacyKolibriContent(): Promise<{ success: boolean; message: string }> {
const GEN2 = SERVICE_NAMES.KOLIBRI_GEN2
// One Gen 2 operation at a time — reuse the install lock so a migration can't race an install.
if (this.activeInstallations.has(GEN2)) {
return {
success: false,
message: 'A Gen 2 Kolibri operation is already in progress. Please wait for it to finish.',
}
}
const gen2 = await Service.query().where('service_name', GEN2).first()
if (!gen2 || !gen2.installed) {
return {
success: false,
message: 'Education Platform (Gen 2) must be installed before migrating legacy content.',
}
}
// Admin-accessible storage base: /app/storage when the admin is containerized, the host storage
// root when it runs on the host (dev). Must NOT use process.cwd()/storage, which only coincides
// with the real storage in production — in dev it points at a stale local dir and the guards below
// would wrongly report "Gen 2 not initialised". Child-container binds still use the host root.
const storageBase = await this._resolveAdminStorageBase()
const legacyHomeAdmin = join(storageBase, 'kolibri')
const gen2HomeAdmin = join(storageBase, 'kolibri-gen2')
const legacyDbDir = join(legacyHomeAdmin, 'content', 'databases')
// Gen 2's main DB only exists after it has booted once — the real guard that import targets a
// valid KOLIBRI_HOME (the `installed` flag alone doesn't prove first-boot initialisation).
const gen2Initialized = await access(join(gen2HomeAdmin, 'db.sqlite3'))
.then(() => true)
.catch(() => false)
if (!gen2Initialized) {
return {
success: false,
message:
'Gen 2 has not finished initialising yet. Open the Education Platform (Gen 2) once, then try migrating again.',
}
}
const legacyPresent = await access(legacyDbDir)
.then(() => true)
.catch(() => false)
if (!legacyPresent) {
return { success: false, message: 'No legacy Kolibri content found on disk to migrate.' }
}
// Each channel DB is stored as <channel_id>.sqlite3; the basename is the channel id.
let channelIds: string[] = []
try {
const files = await readdir(legacyDbDir)
channelIds = files
.filter((f) => f.endsWith('.sqlite3'))
.map((f) => f.replace(/\.sqlite3$/, ''))
} catch (err: any) {
return { success: false, message: `Could not read legacy channel databases: ${err.message}` }
}
if (!channelIds.length) {
return { success: false, message: 'No legacy channels found to migrate.' }
}
// Disk preflight: importcontent COPIES blobs into the Gen 2 tree (hardlinks are unsafe — the image
// entrypoint chowns KOLIBRI_HOME recursively, which would bleed through shared inodes onto the
// read-only legacy install), so we need room for, worst case, the whole legacy blob tree. Block
// up front rather than fill the disk and wedge Gen 2 on restart. Best-effort: a probe we can't run
// (non-Linux dev, missing du/df) returns null and lets the migration proceed.
const diskBlocker = await this._checkMigrationDiskSpace(legacyHomeAdmin, gen2HomeAdmin)
if (diskBlocker) {
return { success: false, message: diskBlocker }
}
// Acquire the lock and run the (potentially many-minute) import in the background, mirroring the
// install flow: the HTTP request returns immediately and progress streams over the broadcast feed.
this.activeInstallations.add(GEN2)
const hostRoot = await this._resolveHostStorageRoot()
const image = gen2.container_image
this._runKolibriContentMigration(channelIds, hostRoot, image).catch((err: any) => {
// Safety net: _runKolibriContentMigration handles its own errors and always releases the lock,
// so this only fires on a truly unexpected throw.
logger.error({ err }, '[DockerService] Kolibri content migration crashed')
this._broadcast(GEN2, 'migrate-error', `Migration failed: ${err.message}`)
this.activeInstallations.delete(GEN2)
})
return {
success: true,
message: `Migrating ${channelIds.length} channel(s) from legacy Kolibri in the background. Watch the activity feed for progress.`,
}
}
/**
* Background worker for {@link migrateLegacyKolibriContent}: stops Gen 2, imports each channel's DB
* and content from the legacy home via throwaway containers, then always restarts Gen 2 and releases
* the lock. Per-channel failures are collected and don't abort the rest. Never throws (errors are
* broadcast); the caller's `.catch` is only a safety net.
*/
private async _runKolibriContentMigration(
channelIds: string[],
hostRoot: string,
image: string
): Promise<void> {
const GEN2 = SERVICE_NAMES.KOLIBRI_GEN2
const migrated: string[] = []
const failed: { channel: string; reason: string }[] = []
this._broadcast(
GEN2,
'migrate-start',
`Migrating ${channelIds.length} channel(s) from legacy Kolibri. User accounts and learner progress are NOT migrated.`
)
// Pause Gen 2 so the import has sole access to the shared db.sqlite3. Best-effort: a 304 from
// stopping an already-stopped container is swallowed by affectContainer. Always restarted below.
await this.affectContainer(GEN2, 'stop')
this._broadcast(GEN2, 'migrate-progress', 'Paused Gen 2 during import...')
try {
for (const channelId of channelIds) {
try {
this._broadcast(GEN2, 'migrate-progress', `Importing channel database ${channelId}...`)
const ch = await this._runKolibriManageEphemeral(
['kolibri', 'manage', 'importchannel', 'disk', channelId, '/legacy'],
hostRoot,
image
)
if (ch.code !== 0) {
// Throwaway containers are removed immediately, so their logs are gone after this — keep
// the full tail server-side and surface a short snippet so an operator has something to act on.
logger.error(
{ channel: channelId, code: ch.code, logs: ch.logs },
'[DockerService] Kolibri importchannel failed'
)
const snippet = this._lastLogLines(ch.logs)
failed.push({ channel: channelId, reason: `importchannel exited ${ch.code}` })
this._broadcast(
GEN2,
'migrate-progress',
`Skipped ${channelId} — channel import failed (exit ${ch.code}).${snippet ? ` ${snippet}` : ''}`
)
continue
}
this._broadcast(
GEN2,
'migrate-progress',
`Importing content files for ${channelId} (this can take a while)...`
)
const co = await this._runKolibriManageEphemeral(
['kolibri', 'manage', 'importcontent', 'disk', channelId, '/legacy'],
hostRoot,
image
)
if (co.code !== 0) {
logger.error(
{ channel: channelId, code: co.code, logs: co.logs },
'[DockerService] Kolibri importcontent failed'
)
const snippet = this._lastLogLines(co.logs)
failed.push({ channel: channelId, reason: `importcontent exited ${co.code}` })
this._broadcast(
GEN2,
'migrate-progress',
`Channel ${channelId}: metadata imported but content copy failed (exit ${co.code}).${snippet ? ` ${snippet}` : ''}`
)
continue
}
migrated.push(channelId)
this._broadcast(GEN2, 'migrate-progress', `Imported channel ${channelId}.`)
} catch (err: any) {
failed.push({ channel: channelId, reason: err.message })
this._broadcast(GEN2, 'migrate-progress', `Error importing ${channelId}: ${err.message}`)
}
}
const caveat = 'User accounts and learner progress must be recreated manually in Gen 2.'
const summary = failed.length
? `Migrated ${migrated.length} of ${channelIds.length} channel(s); ${failed.length} failed. ${caveat}`
: `Migrated ${migrated.length} channel(s). ${caveat}`
// Distinct status (NOT 'completed') so the supply-depot auto-reload effect doesn't fire mid-feed.
this._broadcast(GEN2, 'migrate-complete', summary)
} finally {
// Always bring Gen 2 back, even if the loop threw, and release the lock. affectContainer
// returns {success:false} rather than throwing, so check it explicitly — a failed restart
// leaves Gen 2 down, and the operator must be told (the prior 'migrate-complete' reads as "all good").
const restart = await this.affectContainer(GEN2, 'start')
if (!restart.success) {
logger.error(
`[DockerService] Gen 2 did not restart after content migration: ${restart.message}`
)
this._broadcast(
GEN2,
'migrate-error',
`Migration finished but Gen 2 did not restart automatically (${restart.message}). Start it from its card.`
)
}
this.invalidateServicesStatusCache()
this.activeInstallations.delete(GEN2)
}
}
/**
* Free-space pre-flight for the content copy. Upper-bounds what the copy needs at the full size of
* the legacy `content/storage` blob tree (`du -sb`) and compares against free space on the filesystem
* backing the Gen 2 target (`df --output=avail` the Gen 2 path, not `/`, since storage may live on a
* relocated mount). Requires a small headroom margin on top. GNU coreutils ships in the prod image
* (node:22-slim); `du` exits non-zero when a subdir is unreadable but still prints the total, so we
* parse stdout off the error too. Returns a user-facing blocker string only when we can confidently
* say there isn't room; on any measurement failure it returns null so a flaky probe never blocks the
* feature (mirrors checkImageDiskSpace's "never block on lookup error" stance).
*/
private async _checkMigrationDiskSpace(
legacyHomeAdmin: string,
gen2HomeAdmin: string
): Promise<string | null> {
const HEADROOM = 1.1 // require ~10% slack over the raw copy size
const execFileAsync = promisify(execFile)
const legacyStorage = join(legacyHomeAdmin, 'content', 'storage')
const gib = (b: number) => `${(b / 1024 / 1024 / 1024).toFixed(1)} GiB`
// execFile (no shell) — paths come from env/mount resolution, so avoid shell quoting/injection.
const run = async (file: string, args: string[]): Promise<string | null> => {
try {
const { stdout } = await execFileAsync(file, args)
return stdout
} catch (err: any) {
// `du` returns non-zero on an unreadable entry but still emits the total on stdout — use it.
if (typeof err?.stdout === 'string' && err.stdout.trim()) return err.stdout
logger.warn(`[DockerService] Migration disk preflight: \`${file}\` failed: ${err.message}`)
return null
}
}
const duOut = await run('du', ['-sb', legacyStorage])
const dfOut = await run('df', ['-B1', '--output=avail', gen2HomeAdmin])
if (!duOut || !dfOut) return null
const needed = Number.parseInt(duOut.trim().split(/\s+/)[0], 10)
// `df --output=avail` prints a header line then the value; the free bytes are the last line.
const free = Number.parseInt(dfOut.trim().split('\n').pop()!.trim(), 10)
if (!Number.isFinite(needed) || !Number.isFinite(free)) return null
const required = Math.ceil(needed * HEADROOM)
if (free < required) {
return `Not enough disk space to migrate content: about ${gib(required)} is needed but only ${gib(free)} is free. Free up space and try again.`
}
return null
}
/** Last non-empty line of a captured container log tail, trimmed to a single broadcast-friendly
* snippet (kolibri surfaces the actual cause on its final stderr line). Empty string if none. */
private _lastLogLines(logs: string, max = 200): string {
const last = logs
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.pop()
if (!last) return ''
return last.length > max ? `${last.slice(0, max)}` : last
}
/**
* Run a single `kolibri manage ...` command to completion in a throwaway Gen 2-image container with
* both Kolibri homes bound (Gen 2 writable at /kolibri, legacy read-only at /legacy), capturing the
* exit code and a log tail. No `name` (avoids collisions), no ports/network (pure local disk work),
* no `User` override (the image entrypoint drops to the `kolibri` user itself). AutoRemove is left
* off because it races container.wait() and can lose the exit code we remove explicitly in finally.
*/
private async _runKolibriManageEphemeral(
cmd: string[],
hostRoot: string,
image: string
): Promise<{ code: number; logs: string }> {
const container = await this.docker.createContainer({
Image: image,
Cmd: cmd,
Env: ['KOLIBRI_HOME=/kolibri'],
Labels: {
'com.docker.compose.project': 'project-nomad-managed',
'io.project-nomad.managed': 'true',
},
HostConfig: {
AutoRemove: false,
Binds: [`${hostRoot}/kolibri-gen2:/kolibri`, `${hostRoot}/kolibri:/legacy:ro`],
},
})
try {
await container.start()
const { StatusCode } = await container.wait()
const buf = (await container.logs({
stdout: true,
stderr: true,
follow: false,
tail: 200,
timestamps: false,
})) as unknown as Buffer
return { code: StatusCode, logs: this._demuxDockerLog(buf) }
} finally {
try {
await container.remove({ force: true })
} catch (err: any) {
logger.warn(
`[DockerService] Failed to remove ephemeral Kolibri import container: ${err.message}`
)
}
}
}
/** Find a container by its managed service name (`/serviceName`), or null. */
private async _findContainerByName(serviceName: string) {
const containers = await this.docker.listContainers({ all: true })

View File

@ -253,11 +253,12 @@ A complete offline learning platform from Learning Equality. Kolibri pulls toget
**Getting content in (you re-download it):** Kolibri's content is delivered as channels you import. Open **Device → Channels → Import**, and either pull channels from Kolibri Studio (online) or import from a local drive or another Kolibri device if you already have the content files. There's a lot available, so import just the channels you need; they can be large.
**Upgrading from an older NOMAD's Kolibri:** Earlier NOMAD releases shipped a much older Kolibri (the `treehouses/kolibri:0.12.8` image). This Education Platform is a newer, upstream-official Kolibri and installs **fresh** — your old channels and learner data are **not** carried over automatically, because the two versions store data too differently to migrate safely. If you were running the old one:
**Upgrading from an older NOMAD's Kolibri:** Earlier NOMAD releases shipped a much older Kolibri (the `treehouses/kolibri:0.12.8` image). The "Gen 2" Education Platform is a newer, upstream-official Kolibri and installs **fresh**. Your **user accounts and learner progress** are **not** carried over, because the two versions store that data too differently to migrate safely — you'll recreate the admin account and any learners in the new install. Your **downloaded content** (the channels and gigabytes of videos/exercises already on disk), however, *can* be moved across locally without re-downloading. If you were running the old one:
1. Install this Education Platform from the catalog (it runs alongside the old one on a different port, so nothing is disrupted while you set it up).
2. Re-import the channels you want, as above.
3. Once you're happy with the new install, uninstall the old Kolibri from its card (it carries a **legacy** badge). Your old data folder stays on disk until you remove it.
1. Install this Education Platform from the catalog (it runs alongside the old one on a different port, so nothing is disrupted while you set it up) and open it once to finish setup.
2. **Migrate your content:** on the old Kolibri card (it carries a **legacy** badge), open **Manage → Migrate content to Gen 2**. This copies your existing channels and content files straight into the new install with **no internet**, so you don't have to re-download them. Progress shows in the activity feed; the new platform restarts briefly when it finishes. (Anything that doesn't migrate, you can still re-import manually as above.)
3. Recreate your accounts and re-enroll learners in the new install (this part can't be migrated).
4. Once you're happy with the new install, uninstall the old Kolibri from its card. Your old data folder stays on disk unless you choose to remove it.
**Your data:** Your imported channels, classes, and learner progress live in the `storage/kolibri-gen2` folder on your NOMAD. Backing up that folder backs up your whole Kolibri.

View File

@ -1098,6 +1098,24 @@ class API {
})()
}
/** Kick off the local legacy-Kolibri Gen 2 content migration. Progress streams over the
* service-installation transmit channel under the Gen 2 service name. Surfaces the backend's
* 400 message (e.g. "Gen 2 has not finished initialising yet") instead of swallowing it. */
async migrateKolibriContent() {
try {
const response = await this.client.post<{ success: boolean; message: string }>(
'/system/services/migrate-kolibri-content'
)
return response.data
} catch (error) {
if (error instanceof AxiosError && error.response?.data?.message) {
return { success: false, message: error.response.data.message }
}
console.error('Error migrating Kolibri content:', error)
return undefined
}
}
async getServiceLogs(service_name: string, tail = 200) {
return catchInternal(async () => {
const response = await this.client.get<{ success: boolean; logs: string }>(

View File

@ -40,6 +40,7 @@ import useServiceInstallationActivity from '~/hooks/useServiceInstallationActivi
import { useTransmit } from 'react-adonis-transmit'
import { BROADCAST_CHANNELS } from '../../constants/broadcast'
import { ServiceSlim } from '../../types/services'
import { SERVICE_NAMES } from '../../constants/service_names'
import { getServiceLink } from '~/lib/navigation'
import { getSupplyDepotDocLink } from '../../constants/supply_depot_docs'
import api from '~/lib/api'
@ -86,6 +87,7 @@ type Modal =
| { type: 'logs'; service: ServiceSlim }
| { type: 'stats'; service: ServiceSlim }
| { type: 'update'; service: ServiceSlim }
| { type: 'migrate-kolibri'; service: ServiceSlim }
| null
export default function SupplyDepotPage(props: { system: { services: ServiceSlim[] } }) {
@ -193,6 +195,12 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
const installedServices = filteredServices.filter((s) => s.installed)
const availableServices = filteredServices.filter((s) => !s.installed)
// Whether the new Kolibri (Gen 2) install exists — gates the "Migrate content to Gen 2" action on
// the legacy Kolibri card. Computed from the full (unfiltered) list so a search filter can't hide it.
const gen2Installed = props.system.services.some(
(s) => s.service_name === SERVICE_NAMES.KOLIBRI_GEN2 && s.installed
)
// ── Actions ───────────────────────────────────────────────────────────────
async function handleInstall(service: ServiceSlim) {
const hasWarnings =
@ -244,6 +252,23 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
else setTimeout(() => window.location.reload(), 1000)
}
// Kick off the local legacy → Gen 2 content migration. The request returns immediately; progress
// streams into the activity feed (same SERVICE_INSTALLATION channel, under the Gen 2 service name).
async function handleMigrateKolibri() {
setModal(null)
setLoading(true)
const result = await api.migrateKolibriContent()
setLoading(false)
if (!result?.success) {
showError(result?.message || 'Failed to start content migration.')
return
}
addNotification({
message: result.message || 'Content migration started. Watch the activity feed for progress.',
type: 'success',
})
}
async function handleUpdate(service: ServiceSlim) {
setOpenDropdown(null)
setLoading(true)
@ -473,6 +498,8 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
}
autoUpdateMasterEnabled={appAutoUpdateMasterEnabled}
onToggleAutoUpdate={(enabled) => handleToggleAutoUpdate(service, enabled)}
onMigrateKolibri={() => setModal({ type: 'migrate-kolibri', service })}
gen2Installed={gen2Installed}
/>
))}
</div>
@ -704,6 +731,42 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
</StyledModal>
)}
{/* Migrate legacy Kolibri content → Gen 2 */}
{modal?.type === 'migrate-kolibri' && (
<StyledModal
title="Migrate content to Gen 2"
open
onCancel={() => setModal(null)}
onConfirm={() => handleMigrateKolibri()}
confirmText="Start migration"
confirmIcon="IconCloudDownload"
confirmVariant="primary"
confirmLoading={loading}
>
<div className="space-y-3 text-sm text-text-muted">
<p>
This copies your downloaded channels and content files from the legacy Education
Platform into the Education Platform (Gen 2) entirely on this device, with no
internet required.
</p>
<Alert
type="warning"
title="Experimental"
message="This migration is experimental and may not cover every edge case (e.g. partially-downloaded channels). Your legacy install is left untouched, so you can always re-import a channel manually if something doesn't carry over."
/>
<Alert
type="warning"
title="Accounts and progress are not migrated"
message="User accounts and learner progress can't be carried across versions and must be recreated manually in Gen 2."
/>
<p className="text-xs">
Gen 2 will briefly restart during the import. Progress appears in the activity feed
below; you can keep using the rest of NOMAD while it runs.
</p>
</div>
</StyledModal>
)}
{/* Logs modal */}
{modal?.type === 'logs' && (
<ServiceLogsModal
@ -795,6 +858,9 @@ interface AppCardProps {
// Global master switch (Settings → Updates). When off, per-app toggles are inert.
autoUpdateMasterEnabled?: boolean
onToggleAutoUpdate?: (enabled: boolean) => void
// Legacy-Kolibri-only: migrate downloaded content into Gen 2. Shown only when Gen 2 is installed.
onMigrateKolibri?: () => void
gen2Installed?: boolean
}
function AppCard({
@ -818,6 +884,8 @@ function AppCard({
autoUpdateEnabled,
autoUpdateMasterEnabled,
onToggleAutoUpdate,
onMigrateKolibri,
gen2Installed,
}: AppCardProps) {
const isRunning = service.status === 'running'
const isStopped = service.installed && !isRunning
@ -975,7 +1043,7 @@ function AppCard({
{/* Open button — shown when the app has a default location or a user-set custom URL */}
{(service.ui_location || service.custom_url) && (
<a
href={getServiceLink(service.ui_location, service.custom_url)}
href={getServiceLink(service.ui_location || "", service.custom_url)}
target="_blank"
rel="noopener noreferrer"
className="flex-1"
@ -1017,6 +1085,13 @@ function AppCard({
<DropdownItem icon={<IconChartBar className="h-4 w-4" />} label="Stats" onClick={onStats} />
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
<DropdownItem icon={<IconWorld className="h-4 w-4" />} label="Set custom URL" onClick={onSetUrl} />
{service.service_name === SERVICE_NAMES.KOLIBRI && gen2Installed && onMigrateKolibri ? (
<DropdownItem
icon={<IconCloudDownload className="h-4 w-4 text-desert-green" />}
label="Migrate content to Gen 2"
onClick={onMigrateKolibri}
/>
) : null}
{!service.is_custom && onToggleAutoUpdate ? (
autoUpdateMasterEnabled ? (
<DropdownItem

View File

@ -170,6 +170,7 @@ router
router.post('/services/install', [SystemController, 'installService'])
router.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])
router.post('/services/uninstall', [SystemController, 'uninstallService'])
router.post('/services/migrate-kolibri-content', [SystemController, 'migrateLegacyKolibriContent'])
router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])
router.get('/services/preflight', [SystemController, 'preflightCheck'])
router.get('/services/suggest-port', [SystemController, 'suggestCustomPort'])