feat(supply-depot): ability to migrate Kolibri content to Gen 2 without re-download
This commit is contained in:
parent
565bad59da
commit
2dcfb55a4e
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }>(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
Loading…
Reference in New Issue