feat: replace legacy Kolibri image default with latest v19 image (#1019)
* feat: replace legacy Kolibri image default with latest v19 image * feat(supply-depot): add content migration instructions for Edu Platform Gen 1 to 2
This commit is contained in:
parent
1b040c8b9a
commit
8982d93a31
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export const SUPPLY_DEPOT_DOC_ANCHORS: Record<string, string> = {
|
|||
[SERVICE_NAMES.VAULTWARDEN]: 'vaultwarden',
|
||||
[SERVICE_NAMES.JELLYFIN]: 'jellyfin',
|
||||
[SERVICE_NAMES.MESHTASTIC_WEB]: 'meshtastic-web',
|
||||
[SERVICE_NAMES.KOLIBRI]: 'kolibri',
|
||||
[SERVICE_NAMES.KOLIBRI_GEN2]: 'kolibri',
|
||||
[SERVICE_NAMES.MESHCORE_WEB]: 'meshcore-web',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -243,6 +243,26 @@ A browser-based control panel for [Meshtastic](https://meshtastic.org) devices.
|
|||
|
||||
**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**.
|
||||
|
||||
**Importing content:** 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.
|
||||
|
||||
**Migrating content from Education Platform (Gen 1):** Earlier NOMAD releases shipped a much older Kolibri (the `treehouses/kolibri:0.12.8` image). The Education Platform "Gen 2" 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 and want to import your existing channels into the new one, here's the process:
|
||||
|
||||
1. Install "Education Platform (Gen 2)" from the catalog (it runs alongside the old one on a different port, so nothing is disrupted while you set it up).
|
||||
2. Launch the new one, walk through the setup wizard, then from the sidebar menu, navigate to **Device > Channels > Import**. Choose the "Local network or internet" option, and then "Add new device". In the dialog that appears, enter the IP address of your NOMAD with the old Education Platform port (8300 by default, so for example `http://192.168.1.36:8300`), give it a name (anything you'd like), and click "Add", and then "Continue".
|
||||
3. You can now select individual channels from the old Education Platform, or choose "Select entire channels instead" to import everything at once. Click "Import" when ready, and the transfer will start.
|
||||
3. Once you're happy with the new install and have any content copied over, uninstall the old Education Platform from its card (it carries a **legacy** badge). It's also recommended to choose to remove the old image and data volume when uninstalling to avoid confusion and free up space, but if you want to keep it around for a while just in case, that's totally fine too.
|
||||
|
||||
**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.
|
||||
## MeshCore Web {% #meshcore-web %}
|
||||
|
||||
A browser-based client for [MeshCore](https://meshcore.co.uk) radios. MeshCore is another take on off-grid, long-range LoRa mesh messaging, a sibling to Meshtastic: small radios that form their own network and pass text and location for miles with no cell service, no internet, and no fees. This app is how you configure a MeshCore radio and read and send messages from a full-size screen. If you're not already running MeshCore gear, the Meshtastic client above is the more common starting point. This one is here for people who use MeshCore.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Head, router } from '@inertiajs/react'
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconArrowUp,
|
||||
IconBook,
|
||||
IconBox,
|
||||
|
|
@ -44,6 +45,7 @@ import { getServiceLink } from '~/lib/navigation'
|
|||
import { getSupplyDepotDocLink } from '../../constants/supply_depot_docs'
|
||||
import api from '~/lib/api'
|
||||
import { toTitleCase } from '../../app/utils/misc'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names'
|
||||
|
||||
function extractTag(containerImage: string): string {
|
||||
if (!containerImage) return ''
|
||||
|
|
@ -168,7 +170,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
.then((res) => {
|
||||
if (res) setPreflight(res)
|
||||
})
|
||||
.catch(() => {}) // non-fatal; proceed without warnings
|
||||
.catch(() => { }) // non-fatal; proceed without warnings
|
||||
.finally(() => setPreflightLoading(false))
|
||||
}, [modal])
|
||||
|
||||
|
|
@ -193,6 +195,16 @@ 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 educationGen2Installed = props.system.services.some(
|
||||
(s) => s.service_name === SERVICE_NAMES.KOLIBRI_GEN2 && s.installed
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Education Gen 2 installed:", educationGen2Installed)
|
||||
}, [educationGen2Installed])
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
async function handleInstall(service: ServiceSlim) {
|
||||
const hasWarnings =
|
||||
|
|
@ -420,11 +432,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors cursor-pointer border ${
|
||||
activeCategory === cat.id
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors cursor-pointer border ${activeCategory === cat.id
|
||||
? 'bg-desert-green text-white border-desert-green'
|
||||
: 'bg-surface-secondary text-text-muted border-desert-stone-lighter hover:text-text-primary hover:border-desert-stone-light'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
|
|
@ -473,6 +484,8 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
}
|
||||
autoUpdateMasterEnabled={appAutoUpdateMasterEnabled}
|
||||
onToggleAutoUpdate={(enabled) => handleToggleAutoUpdate(service, enabled)}
|
||||
migrationInstructionsHref={(service.service_name.startsWith(SERVICE_NAMES.KOLIBRI) && educationGen2Installed) ? getSupplyDepotDocLink(SERVICE_NAMES.KOLIBRI) || undefined : undefined}
|
||||
migrationInstructionsText={(service.service_name === SERVICE_NAMES.KOLIBRI) ? 'How to migrate content to Gen 2' : "How to migrate content from Gen 1"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -795,6 +808,8 @@ interface AppCardProps {
|
|||
// Global master switch (Settings → Updates). When off, per-app toggles are inert.
|
||||
autoUpdateMasterEnabled?: boolean
|
||||
onToggleAutoUpdate?: (enabled: boolean) => void
|
||||
migrationInstructionsHref?: string
|
||||
migrationInstructionsText?: string
|
||||
}
|
||||
|
||||
function AppCard({
|
||||
|
|
@ -818,6 +833,8 @@ function AppCard({
|
|||
autoUpdateEnabled,
|
||||
autoUpdateMasterEnabled,
|
||||
onToggleAutoUpdate,
|
||||
migrationInstructionsHref,
|
||||
migrationInstructionsText,
|
||||
}: AppCardProps) {
|
||||
const isRunning = service.status === 'running'
|
||||
const isStopped = service.installed && !isRunning
|
||||
|
|
@ -852,11 +869,10 @@ 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 ${
|
||||
service.installed
|
||||
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 (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) */}
|
||||
|
|
@ -930,6 +946,14 @@ function AppCard({
|
|||
modified
|
||||
</span>
|
||||
) : null}
|
||||
{service.is_deprecated ? (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium bg-desert-orange-lighter text-desert-orange-dark border border-desert-orange-light"
|
||||
title="This is a legacy version that's no longer maintained. Install the current Education Platform from the catalog, then uninstall this one."
|
||||
>
|
||||
legacy
|
||||
</span>
|
||||
) : null}
|
||||
{uiPort && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-secondary text-text-muted font-mono">
|
||||
{uiIsHttps ? '🔒 ' : ''}:{uiPort}
|
||||
|
|
@ -967,7 +991,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"
|
||||
|
|
@ -1009,6 +1033,20 @@ 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} />
|
||||
{
|
||||
migrationInstructionsHref ? (
|
||||
<a
|
||||
href={migrationInstructionsHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer text-text-primary hover:bg-surface-secondary"
|
||||
>
|
||||
<IconBook className="h-4 w-4" />
|
||||
{migrationInstructionsText || 'Migration instructions'}
|
||||
</a>
|
||||
) : (null)
|
||||
}
|
||||
{!service.is_custom && onToggleAutoUpdate ? (
|
||||
autoUpdateMasterEnabled ? (
|
||||
<DropdownItem
|
||||
|
|
@ -1041,7 +1079,7 @@ function AppCard({
|
|||
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
|
||||
{service.is_custom ? (
|
||||
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Delete" onClick={onDelete} danger />
|
||||
): (
|
||||
) : (
|
||||
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Uninstall" onClick={onUninstall} danger />
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1079,11 +1117,10 @@ function DropdownItem({
|
|||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer ${
|
||||
danger
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer ${danger
|
||||
? 'text-desert-red hover:bg-desert-red/10'
|
||||
: 'text-text-primary hover:bg-surface-secondary'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -18,5 +18,6 @@ export type ServiceSlim = Pick<
|
|||
| 'auto_update_enabled'
|
||||
| 'is_custom'
|
||||
| 'is_user_modified'
|
||||
| 'is_deprecated'
|
||||
| 'category'
|
||||
> & { status?: string }
|
||||
|
|
|
|||
Loading…
Reference in New Issue