From 565bad59da7fe6f7b59cb9579694e8e8284c36cc Mon Sep 17 00:00:00 2001 From: jakeaturner Date: Mon, 15 Jun 2026 21:18:11 +0000 Subject: [PATCH] feat: replace legacy Kolibri image default with latest v19 image --- admin/app/models/service.ts | 10 +++++ admin/app/services/system_service.ts | 7 +++ admin/constants/service_names.ts | 1 + admin/constants/supply_depot_docs.ts | 1 + .../1776300000001_deprecate_legacy_kolibri.ts | 43 +++++++++++++++++++ admin/database/seeders/service_seeder.ts | 29 ++++++++++--- admin/docs/supply-depot-apps.md | 20 +++++++++ admin/inertia/pages/easy-setup/index.tsx | 2 +- admin/inertia/pages/supply-depot.tsx | 8 ++++ admin/types/services.ts | 1 + 10 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 admin/database/migrations/1776300000001_deprecate_legacy_kolibri.ts diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index 08c7711..8d192ef 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -85,6 +85,16 @@ export default class Service extends BaseModel { @column() declare category: string | null + // When true the service is sunset: hidden from the install catalog unless it is already + // installed (see SystemService.getServices). Lets a deprecated app stay manageable for users who + // still run it while keeping new users from installing it. + @column({ + serialize(value) { + return Boolean(value) + }, + }) + declare is_deprecated: boolean + @column() declare source_repo: string | null diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index d058851..7e7412b 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -333,9 +333,15 @@ export class SystemService { 'auto_update_enabled', 'is_custom', 'is_user_modified', + 'is_deprecated', 'category' ) .where('is_dependency_service', false) + // Deprecated/sunset apps stay visible only while still installed, so the user can manage and + // uninstall them — they never reappear in the install catalog once removed. + .where((q) => { + q.where('is_deprecated', false).orWhere('installed', true) + }) if (installedOnly) { query.where('installed', true) } @@ -367,6 +373,7 @@ export class SystemService { auto_update_enabled: service.auto_update_enabled, is_custom: service.is_custom, is_user_modified: service.is_user_modified, + is_deprecated: service.is_deprecated, category: service.category, }) } diff --git a/admin/constants/service_names.ts b/admin/constants/service_names.ts index 01d3299..b481e6d 100644 --- a/admin/constants/service_names.ts +++ b/admin/constants/service_names.ts @@ -5,6 +5,7 @@ export const SERVICE_NAMES = { CYBERCHEF: 'nomad_cyberchef', FLATNOTES: 'nomad_flatnotes', KOLIBRI: 'nomad_kolibri', + KOLIBRI_GEN2: 'nomad_kolibri_2', // Supply Depot — curated catalog (ports 8400–8499) STIRLING_PDF: 'nomad_stirling_pdf', FILEBROWSER: 'nomad_filebrowser', diff --git a/admin/constants/supply_depot_docs.ts b/admin/constants/supply_depot_docs.ts index e9531db..4c204e1 100644 --- a/admin/constants/supply_depot_docs.ts +++ b/admin/constants/supply_depot_docs.ts @@ -17,6 +17,7 @@ export const SUPPLY_DEPOT_DOC_ANCHORS: Record = { [SERVICE_NAMES.VAULTWARDEN]: 'vaultwarden', [SERVICE_NAMES.JELLYFIN]: 'jellyfin', [SERVICE_NAMES.MESHTASTIC_WEB]: 'meshtastic-web', + [SERVICE_NAMES.KOLIBRI_GEN2]: 'kolibri', } // Returns the in-app docs link for a service, or null if it has no documentation section. diff --git a/admin/database/migrations/1776300000001_deprecate_legacy_kolibri.ts b/admin/database/migrations/1776300000001_deprecate_legacy_kolibri.ts new file mode 100644 index 0000000..571b036 --- /dev/null +++ b/admin/database/migrations/1776300000001_deprecate_legacy_kolibri.ts @@ -0,0 +1,43 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' +import { SERVICE_NAMES } from '../../constants/service_names.js' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + // Generic deprecation flag (reusable for future sunsets): a deprecated service is hidden from + // the install catalog unless it is already installed — see SystemService.getServices(). + this.schema.alterTable(this.tableName, (table) => { + table.boolean('is_deprecated').notNullable().defaultTo(false) + }) + + // Sunset the legacy treehouses/kolibri:0.12.8 entry, replaced by the learningequality Gen 2 + // entry seeded as `nomad_kolibri_2`. The seeder is additive + sync-existing and never deletes, + // so without this step every existing deployment keeps an orphaned `nomad_kolibri` row and can + // still install the dead 6-year-old image. Conditional handling keeps it data-safe: + this.defer(async (db) => { + // Never installed → just an orphaned catalog row; drop it outright. + await db + .from(this.tableName) + .where('service_name', SERVICE_NAMES.KOLIBRI) + .where('installed', false) + .delete() + + // Currently installed → a running 0.12.8 container holds port 8300 + a bind mount. Keep the + // row (it's Nomad's only handle to open/stop/uninstall that container) but flag it deprecated + // so it shows a "Legacy" badge and drops out of the catalog once the user uninstalls it. + await db + .from(this.tableName) + .where('service_name', SERVICE_NAMES.KOLIBRI) + .where('installed', true) + .update({ is_deprecated: true }) + }) + } + + async down() { + // Note: the legacy-row deletion in up() is a one-way data change and is not restored here. + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('is_deprecated') + }) + } +} diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index ae7eac8..6a57a6e 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -14,6 +14,7 @@ type ServiceSeedRecord = Omit< | 'update_checked_at' | 'metadata' | 'is_user_modified' + | 'is_deprecated' | 'custom_url' | 'auto_update_enabled' | 'available_update_first_seen_at' @@ -163,24 +164,38 @@ export default class ServiceSeeder extends BaseSeeder { depends_on: null, }, { - service_name: SERVICE_NAMES.KOLIBRI, - friendly_name: 'Education Platform', + // "Kolibri Gen 2" — the upstream-official learningequality image replacing the ~6-year-old + // community treehouses/kolibri:0.12.8. This is a distinct catalog entry (own service_name, + // volume, and ports), not an in-place upgrade: the new image uses a different repo, mounts at + // /kolibri instead of /root/.kolibri, and crosses 7 minor versions of Kolibri's own data + // schema. Existing 0.12.8 installs are sunset via the deprecate-legacy-kolibri migration and + // keep running on 8300 until uninstalled; content is re-imported into the fresh Gen 2 install. + service_name: SERVICE_NAMES.KOLIBRI_GEN2, + friendly_name: 'Education Platform (Gen 2)', powered_by: 'Kolibri', display_order: 2, description: 'Interactive learning platform with video courses and exercises', icon: 'IconSchool', - container_image: 'treehouses/kolibri:0.12.8', + container_image: 'learningequality/kolibri:0.19.4', source_repo: 'https://github.com/learningequality/kolibri', container_command: null, container_config: JSON.stringify({ HostConfig: { RestartPolicy: { Name: 'unless-stopped' }, - PortBindings: { '8080/tcp': [{ HostPort: '8300' }] }, - Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri:/root/.kolibri`], + // 8080 = web UI. 8311 = zip-content server (interactive exercises / HTML5 apps), served + // from a separate "alternate origin" the browser connects to DIRECTLY. KOLIBRI_ZIP_CONTENT_PORT + // sets the port Kolibri both LISTENS on inside the container AND advertises in content URLs, + // so the internal port, the published host port, and that env value must all be identical + // (8311) — otherwise content URLs point at a host port that doesn't route to the listener + // and every content page fails with ERR_CONNECTION_REFUSED. The image's default 8081 is + // unused here. The image refuses to start without /kolibri mounted (KOLIBRI_HOME = /kolibri). + PortBindings: { '8080/tcp': [{ HostPort: '8310' }], '8311/tcp': [{ HostPort: '8311' }] }, + Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri-gen2:/kolibri`], }, - ExposedPorts: { '8080/tcp': {} }, + ExposedPorts: { '8080/tcp': {}, '8311/tcp': {} }, + Env: ['KOLIBRI_ZIP_CONTENT_PORT=8311'], }), - ui_location: '8300', + ui_location: '8310', installed: false, installation_status: 'idle', is_dependency_service: false, diff --git a/admin/docs/supply-depot-apps.md b/admin/docs/supply-depot-apps.md index bd9eb0a..d666e07 100644 --- a/admin/docs/supply-depot-apps.md +++ b/admin/docs/supply-depot-apps.md @@ -242,3 +242,23 @@ A browser-based control panel for [Meshtastic](https://meshtastic.org) devices. **Your data:** There's nothing to set up or store on your NOMAD for this app. Your radio's settings live on the radio itself, and this app's preferences live in your browser. There's no NOMAD folder to manage. **Works offline:** Fully offline, which is the entire point of Meshtastic. The app is served from your NOMAD, and talking to your radios happens over your local network or radio, never the internet. The only online bits are the links in the footer (Vercel, legal), which don't matter for using your mesh. + +## Education Platform (Kolibri) {% #kolibri %} + +A complete offline learning platform from Learning Equality. Kolibri pulls together video lessons, exercises, and readings into structured channels, organizes them into classes and lessons, tracks learner progress, and works entirely on your NOMAD with no internet. It's built for schools and learners in places with little or no connectivity. + +**Official site:** [learningequality.org/kolibri](https://learningequality.org/kolibri) · **Source:** [github.com/learningequality/kolibri](https://github.com/learningequality/kolibri) + +**First time you open it, you'll go through a quick setup wizard.** Pick your facility type and create the **admin account** (this is the super-user that manages the whole device, so give it a real password and keep track of it). Once you're in, you import learning content as **channels**. + +**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: + +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. + +**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. + +**Works offline:** Fully offline once content is imported, that's what Kolibri is for. The only step that uses the internet is importing channels from Kolibri Studio; everything after that, browsing lessons, doing exercises, tracking progress, runs entirely on your NOMAD. diff --git a/admin/inertia/pages/easy-setup/index.tsx b/admin/inertia/pages/easy-setup/index.tsx index c1fa42e..da97cf8 100644 --- a/admin/inertia/pages/easy-setup/index.tsx +++ b/admin/inertia/pages/easy-setup/index.tsx @@ -61,7 +61,7 @@ function buildCoreCapabilities(aiAssistantName: string): Capability[] { 'Interactive exercises and quizzes', 'Progress tracking for learners', ], - services: [SERVICE_NAMES.KOLIBRI], + services: [SERVICE_NAMES.KOLIBRI_GEN2], icon: 'IconSchool', }, { diff --git a/admin/inertia/pages/supply-depot.tsx b/admin/inertia/pages/supply-depot.tsx index 889f588..f73bc2f 100644 --- a/admin/inertia/pages/supply-depot.tsx +++ b/admin/inertia/pages/supply-depot.tsx @@ -930,6 +930,14 @@ function AppCard({ modified ) : null} + {service.is_deprecated ? ( + + legacy + + ) : null} {uiPort && ( {uiIsHttps ? '🔒 ' : ''}:{uiPort} diff --git a/admin/types/services.ts b/admin/types/services.ts index f953648..0883c90 100644 --- a/admin/types/services.ts +++ b/admin/types/services.ts @@ -18,5 +18,6 @@ export type ServiceSlim = Pick< | 'auto_update_enabled' | 'is_custom' | 'is_user_modified' + | 'is_deprecated' | 'category' > & { status?: string }