diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts index ccbd51d..e87ec46 100644 --- a/admin/app/models/service.ts +++ b/admin/app/models/service.ts @@ -26,6 +26,12 @@ export default class Service extends BaseModel { @column() declare description: string | null + @column() + declare powered_by: string | null + + @column() + declare display_order: number | null + @column() declare icon: string | null // must be a TablerIcons name to be properly rendered in the UI (e.g. "IconBrandDocker") diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index b20124b..4a2df42 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -61,6 +61,7 @@ export class SystemService { await this._syncContainersWithDatabase() // Sync up before fetching to ensure we have the latest status const query = Service.query() + .orderBy('display_order', 'asc') .orderBy('friendly_name', 'asc') .select( 'id', @@ -70,7 +71,9 @@ export class SystemService { 'ui_location', 'friendly_name', 'description', - 'icon' + 'icon', + 'powered_by', + 'display_order' ) .where('is_dependency_service', false) if (installedOnly) { @@ -98,6 +101,8 @@ export class SystemService { installation_status: service.installation_status, status: status ? status.status : 'unknown', ui_location: service.ui_location || '', + powered_by: service.powered_by, + display_order: service.display_order, }) } diff --git a/admin/database/migrations/1769300000001_add_powered_by_and_display_order_to_services.ts b/admin/database/migrations/1769300000001_add_powered_by_and_display_order_to_services.ts new file mode 100644 index 0000000..3a1cdc4 --- /dev/null +++ b/admin/database/migrations/1769300000001_add_powered_by_and_display_order_to_services.ts @@ -0,0 +1,19 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.string('powered_by').nullable() + table.integer('display_order').nullable().defaultTo(100) + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('powered_by') + table.dropColumn('display_order') + }) + } +} diff --git a/admin/database/migrations/1769300000002_update_services_friendly_names.ts b/admin/database/migrations/1769300000002_update_services_friendly_names.ts new file mode 100644 index 0000000..ee13205 --- /dev/null +++ b/admin/database/migrations/1769300000002_update_services_friendly_names.ts @@ -0,0 +1,113 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'services' + + async up() { + // Update existing services with new friendly names and powered_by values + await this.db.rawQuery(` + UPDATE services SET + friendly_name = 'Information Library', + powered_by = 'Kiwix', + display_order = 1, + description = 'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias' + WHERE service_name = 'nomad_kiwix_serve' + `) + + await this.db.rawQuery(` + UPDATE services SET + friendly_name = 'Education Platform', + powered_by = 'Kolibri', + display_order = 2, + description = 'Interactive learning platform with video courses and exercises' + WHERE service_name = 'nomad_kolibri' + `) + + await this.db.rawQuery(` + UPDATE services SET + friendly_name = 'AI Assistant', + powered_by = 'Open WebUI + Ollama', + display_order = 3, + description = 'Local AI chat that runs entirely on your hardware - no internet required' + WHERE service_name = 'nomad_open_webui' + `) + + await this.db.rawQuery(` + UPDATE services SET + friendly_name = 'Notes', + powered_by = 'FlatNotes', + display_order = 10, + description = 'Simple note-taking app with local storage' + WHERE service_name = 'nomad_flatnotes' + `) + + await this.db.rawQuery(` + UPDATE services SET + friendly_name = 'Data Tools', + powered_by = 'CyberChef', + display_order = 11, + description = 'Swiss Army knife for data encoding, encryption, and analysis' + WHERE service_name = 'nomad_cyberchef' + `) + + await this.db.rawQuery(` + UPDATE services SET + display_order = 100 + WHERE service_name = 'nomad_ollama' + `) + } + + async down() { + // Revert to original names + await this.db.rawQuery(` + UPDATE services SET + friendly_name = 'Kiwix', + powered_by = NULL, + display_order = NULL, + description = 'Offline Wikipedia, eBooks, and more' + WHERE service_name = 'nomad_kiwix_serve' + `) + + await this.db.rawQuery(` + UPDATE services SET + friendly_name = 'Kolibri', + powered_by = NULL, + display_order = NULL, + description = 'An offline-first education platform for schools and learners' + WHERE service_name = 'nomad_kolibri' + `) + + await this.db.rawQuery(` + UPDATE services SET + friendly_name = 'Open WebUI', + powered_by = NULL, + display_order = NULL, + description = 'A web interface for interacting with local AI models served by Ollama' + WHERE service_name = 'nomad_open_webui' + `) + + await this.db.rawQuery(` + UPDATE services SET + friendly_name = 'FlatNotes', + powered_by = NULL, + display_order = NULL, + description = 'A simple note-taking app that stores all files locally' + WHERE service_name = 'nomad_flatnotes' + `) + + await this.db.rawQuery(` + UPDATE services SET + friendly_name = 'CyberChef', + powered_by = NULL, + display_order = NULL, + description = 'The Cyber Swiss Army Knife - a web app for encryption, encoding, and data analysis' + WHERE service_name = 'nomad_cyberchef' + `) + + await this.db.rawQuery(` + UPDATE services SET + display_order = NULL + WHERE service_name = 'nomad_ollama' + `) + } +} diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index 4c18204..7697816 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -10,8 +10,10 @@ export default class ServiceSeeder extends BaseSeeder { private static DEFAULT_SERVICES: Omit, 'created_at' | 'updated_at' | 'metadata' | 'id'>[] = [ { service_name: DockerService.KIWIX_SERVICE_NAME, - friendly_name: 'Kiwix', - description: 'Offline Wikipedia, eBooks, and more', + friendly_name: 'Information Library', + powered_by: 'Kiwix', + display_order: 1, + description: 'Offline access to Wikipedia, medical references, how-to guides, and encyclopedias', icon: 'IconBooks', container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1', container_command: '*.zim --address=all', @@ -32,6 +34,8 @@ export default class ServiceSeeder extends BaseSeeder { { service_name: DockerService.OLLAMA_SERVICE_NAME, friendly_name: 'Ollama', + powered_by: null, + display_order: 100, // Dependency service, not shown directly description: 'Run local LLMs (AI models) with ease on your own hardware', icon: 'IconRobot', container_image: 'ollama/ollama:latest', @@ -52,8 +56,10 @@ export default class ServiceSeeder extends BaseSeeder { }, { service_name: DockerService.OPEN_WEBUI_SERVICE_NAME, - friendly_name: 'Open WebUI', - description: 'A web interface for interacting with local AI models served by Ollama', + friendly_name: 'AI Assistant', + powered_by: 'Open WebUI + Ollama', + display_order: 3, + description: 'Local AI chat that runs entirely on your hardware - no internet required', icon: 'IconWand', container_image: 'ghcr.io/open-webui/open-webui:main', container_command: null, @@ -74,8 +80,10 @@ export default class ServiceSeeder extends BaseSeeder { }, { service_name: DockerService.CYBERCHEF_SERVICE_NAME, - friendly_name: 'CyberChef', - description: 'The Cyber Swiss Army Knife - a web app for encryption, encoding, and data analysis', + friendly_name: 'Data Tools', + powered_by: 'CyberChef', + display_order: 11, + description: 'Swiss Army knife for data encoding, encryption, and analysis', icon: 'IconChefHat', container_image: 'ghcr.io/gchq/cyberchef:latest', container_command: null, @@ -94,8 +102,10 @@ export default class ServiceSeeder extends BaseSeeder { }, { service_name: DockerService.FLATNOTES_SERVICE_NAME, - friendly_name: 'FlatNotes', - description: 'A simple note-taking app that stores all files locally', + friendly_name: 'Notes', + powered_by: 'FlatNotes', + display_order: 10, + description: 'Simple note-taking app with local storage', icon: 'IconNotes', container_image: 'dullage/flatnotes:latest', container_command: null, @@ -116,8 +126,10 @@ export default class ServiceSeeder extends BaseSeeder { }, { service_name: DockerService.KOLIBRI_SERVICE_NAME, - friendly_name: 'Kolibri', - description: 'An offline-first education platform for schools and learners', + friendly_name: 'Education Platform', + powered_by: 'Kolibri', + display_order: 2, + description: 'Interactive learning platform with video courses and exercises', icon: 'IconSchool', container_image: 'treehouses/kolibri:latest', container_command: null, diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx index 60fbdfa..ec4d47d 100644 --- a/admin/inertia/pages/home.tsx +++ b/admin/inertia/pages/home.tsx @@ -13,7 +13,20 @@ import { getServiceLink } from '~/lib/navigation' import { ServiceSlim } from '../../types/services' import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon' -const STATIC_ITEMS = [ +// Maps is a Core Capability (display_order: 4) +const MAPS_ITEM = { + label: 'Maps', + to: '/maps', + target: '', + description: 'View offline maps', + icon: , + installed: true, + displayOrder: 4, + poweredBy: null, +} + +// System items shown after all apps +const SYSTEM_ITEMS = [ { label: 'Easy Setup', to: '/easy-setup', @@ -22,6 +35,8 @@ const STATIC_ITEMS = [ 'Not sure where to start? Use the setup wizard to quickly configure your N.O.M.A.D.!', icon: , installed: true, + displayOrder: 50, + poweredBy: null, }, { label: 'Install Apps', @@ -30,6 +45,8 @@ const STATIC_ITEMS = [ description: 'Not seeing your favorite app? Install it here!', icon: , installed: true, + displayOrder: 51, + poweredBy: null, }, { label: 'Docs', @@ -38,14 +55,8 @@ const STATIC_ITEMS = [ description: 'Read Project N.O.M.A.D. manuals and guides', icon: , installed: true, - }, - { - label: 'Maps', - to: '/maps', - target: '', - description: 'View offline maps', - icon: , - installed: true, + displayOrder: 52, + poweredBy: null, }, { label: 'Settings', @@ -54,33 +65,59 @@ const STATIC_ITEMS = [ description: 'Configure your N.O.M.A.D. settings', icon: , installed: true, + displayOrder: 53, + poweredBy: null, }, ] +interface DashboardItem { + label: string + to: string + target: string + description: string + icon: React.ReactNode + installed: boolean + displayOrder: number + poweredBy: string | null +} + export default function Home(props: { system: { services: ServiceSlim[] } }) { - const items = [] - props.system.services.map((service) => { - items.push({ - label: service.friendly_name || service.service_name, - to: service.ui_location ? getServiceLink(service.ui_location) : '#', - target: '_blank', - description: - service.description || - `Access the ${service.friendly_name || service.service_name} application`, - icon: service.icon ? ( - - ) : ( - - ), - installed: service.installed, - }) - }) + const items: DashboardItem[] = [] - items.push(...STATIC_ITEMS) + // Add installed services (non-dependency services only) + props.system.services + .filter((service) => service.installed && service.ui_location) + .forEach((service) => { + items.push({ + label: service.friendly_name || service.service_name, + to: service.ui_location ? getServiceLink(service.ui_location) : '#', + target: '_blank', + description: + service.description || + `Access the ${service.friendly_name || service.service_name} application`, + icon: service.icon ? ( + + ) : ( + + ), + installed: service.installed, + displayOrder: service.display_order ?? 100, + poweredBy: service.powered_by ?? null, + }) + }) + + // Add Maps as a Core Capability + items.push(MAPS_ITEM) + + // Add system items + items.push(...SYSTEM_ITEMS) + + // Sort all items by display order + items.sort((a, b) => a.displayOrder - b.displayOrder) return ( @@ -94,6 +131,9 @@ export default function Home(props: { >
{item.icon}

{item.label}

+ {item.poweredBy && ( +

Powered by {item.poweredBy}

+ )}

{item.description}

diff --git a/admin/types/services.ts b/admin/types/services.ts index a761ffa..244b5e7 100644 --- a/admin/types/services.ts +++ b/admin/types/services.ts @@ -10,4 +10,6 @@ export type ServiceSlim = Pick< | 'friendly_name' | 'description' | 'icon' + | 'powered_by' + | 'display_order' > & { status?: string }