diff --git a/admin/app/models/service.ts b/admin/app/models/service.ts
index e76a28e..ccbd51d 100644
--- a/admin/app/models/service.ts
+++ b/admin/app/models/service.ts
@@ -26,6 +26,9 @@ export default class Service extends BaseModel {
@column()
declare description: string | null
+ @column()
+ declare icon: string | null // must be a TablerIcons name to be properly rendered in the UI (e.g. "IconBrandDocker")
+
@column({
serialize(value) {
return Boolean(value)
diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts
index d47c5dc..8656565 100644
--- a/admin/app/services/system_service.ts
+++ b/admin/app/services/system_service.ts
@@ -62,7 +62,7 @@ export class SystemService {
const query = Service.query()
.orderBy('friendly_name', 'asc')
- .select('id', 'service_name', 'installed', 'ui_location', 'friendly_name', 'description')
+ .select('id', 'service_name', 'installed', 'installation_status', 'ui_location', 'friendly_name', 'description', 'icon')
.where('is_dependency_service', false)
if (installedOnly) {
query.where('installed', true)
@@ -84,6 +84,7 @@ export class SystemService {
service_name: service.service_name,
friendly_name: service.friendly_name,
description: service.description,
+ icon: service.icon,
installed: service.installed,
installation_status: service.installation_status,
status: status ? status.status : 'unknown',
diff --git a/admin/database/migrations/1768453747522_update_services_add_icon.ts b/admin/database/migrations/1768453747522_update_services_add_icon.ts
new file mode 100644
index 0000000..b4063d5
--- /dev/null
+++ b/admin/database/migrations/1768453747522_update_services_add_icon.ts
@@ -0,0 +1,17 @@
+import { BaseSchema } from '@adonisjs/lucid/schema'
+
+export default class extends BaseSchema {
+ protected tableName = 'services'
+
+ async up() {
+ this.schema.alterTable(this.tableName, (table) => {
+ table.string('icon').nullable()
+ })
+ }
+
+ async down() {
+ this.schema.alterTable(this.tableName, (table) => {
+ table.dropColumn('icon')
+ })
+ }
+}
diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts
index ee2564e..1675a6d 100644
--- a/admin/database/seeders/service_seeder.ts
+++ b/admin/database/seeders/service_seeder.ts
@@ -10,6 +10,7 @@ export default class ServiceSeeder extends BaseSeeder {
service_name: DockerService.KIWIX_SERVICE_NAME,
friendly_name: 'Kiwix',
description: 'Offline Wikipedia, eBooks, and more',
+ icon: 'IconBooks',
container_image: 'ghcr.io/kiwix/kiwix-serve:3.8.1',
container_command: '*.zim --address=all',
container_config: JSON.stringify({
@@ -30,6 +31,7 @@ export default class ServiceSeeder extends BaseSeeder {
service_name: DockerService.OLLAMA_SERVICE_NAME,
friendly_name: 'Ollama',
description: 'Run local LLMs (AI models) with ease on your own hardware',
+ icon: 'IconRobot',
container_image: 'ollama/ollama:latest',
container_command: 'serve',
container_config: JSON.stringify({
@@ -50,6 +52,7 @@ 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',
+ icon: 'IconWand',
container_image: 'ghcr.io/open-webui/open-webui:main',
container_command: null,
container_config: JSON.stringify({
@@ -71,6 +74,7 @@ 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',
+ icon: 'IconChefHat',
container_image: 'ghcr.io/gchq/cyberchef:latest',
container_command: null,
container_config: JSON.stringify({
@@ -90,6 +94,7 @@ 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',
+ icon: 'IconNotes',
container_image: 'dullage/flatnotes:latest',
container_command: null,
container_config: JSON.stringify({
@@ -111,6 +116,7 @@ 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',
+ icon: 'IconSchool',
container_image: 'treehouses/kolibri:latest',
container_command: null,
container_config: JSON.stringify({
diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx
index c32b7dd..60fbdfa 100644
--- a/admin/inertia/pages/home.tsx
+++ b/admin/inertia/pages/home.tsx
@@ -1,15 +1,25 @@
-import { IconBolt, IconHelp, IconMapRoute, IconPlus, IconSettings, IconWifiOff } from '@tabler/icons-react'
+import {
+ IconBolt,
+ IconHelp,
+ IconMapRoute,
+ IconPlus,
+ IconSettings,
+ IconWifiOff,
+} from '@tabler/icons-react'
import { Head } from '@inertiajs/react'
import BouncingLogo from '~/components/BouncingLogo'
import AppLayout from '~/layouts/AppLayout'
import { getServiceLink } from '~/lib/navigation'
+import { ServiceSlim } from '../../types/services'
+import DynamicIcon, { DynamicIconName } from '~/components/DynamicIcon'
const STATIC_ITEMS = [
{
label: 'Easy Setup',
to: '/easy-setup',
target: '',
- description: "Not sure where to start? Use the setup wizard to quickly configure your N.O.M.A.D.!",
+ description:
+ 'Not sure where to start? Use the setup wizard to quickly configure your N.O.M.A.D.!',
icon: ,
installed: true,
},
@@ -49,17 +59,23 @@ const STATIC_ITEMS = [
export default function Home(props: {
system: {
- services: { id: number; service_name: string; installed: boolean; ui_location: string }[]
+ services: ServiceSlim[]
}
}) {
const items = []
- props.system.services.map((service) => {
+ props.system.services.map((service) => {
items.push({
- label: service.service_name,
- to: getServiceLink(service.ui_location),
+ label: service.friendly_name || service.service_name,
+ to: service.ui_location ? getServiceLink(service.ui_location) : '#',
target: '_blank',
- description: `Access ${service.service_name} content`,
- icon: ,
+ description:
+ service.description ||
+ `Access the ${service.friendly_name || service.service_name} application`,
+ icon: service.icon ? (
+
+ ) : (
+
+ ),
installed: service.installed,
})
})
diff --git a/admin/package-lock.json b/admin/package-lock.json
index 4a3cdaa..53a9a21 100644
--- a/admin/package-lock.json
+++ b/admin/package-lock.json
@@ -114,7 +114,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/application/-/application-8.4.1.tgz",
"integrity": "sha512-2vwO/8DoKJ9AR4Vvllz08RcomBoETc3FMf+q+ri1BVVjc76tLGV3KcYZp8+uKOuEreiK6poQ7NwJrR1P5ANA/w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@poppinss/hooks": "^7.2.5",
"@poppinss/macroable": "^1.0.4",
@@ -136,7 +135,6 @@
"integrity": "sha512-csLdMW58cwuRjdPEDE0dqwHZCT5snCh+1sQ19HPnQ/BLKPPAvQdDRdw0atoC8LVmouB8ghXVHp3SxnVxlvXYWQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@adonisjs/env": "^6.1.0",
"@antfu/install-pkg": "^0.4.1",
@@ -231,7 +229,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/config/-/config-5.0.3.tgz",
"integrity": "sha512-dO7gkYxZsrsnR8n7d5KUpyi+Q5c6BnV2rmFDqEmEjz5AkOZLLzJJJbeHgMb+M27le7ifEUoa8MRu6RED8NMsJg==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@poppinss/utils": "^6.9.4"
},
@@ -244,7 +241,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/core/-/core-6.19.0.tgz",
"integrity": "sha512-qwGuapvMLYPna89Qji/MuD9xx6qqcqc/aLrSGgoFbOzBmd8Ycc9391w7sFrrGuJpHiNLBmf1NJsY3YS2AwyX0A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@adonisjs/ace": "^13.3.0",
"@adonisjs/application": "^8.4.1",
@@ -348,7 +344,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/encryption/-/encryption-6.0.2.tgz",
"integrity": "sha512-37XqVPsZi6zXMbC0Me1/qlcTP0uE+KAtYOFx7D7Tvtz377NL/6gqxqgpW/BopgOSD+CVDXjzO/Wx3M2UrbkJRQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@poppinss/utils": "^6.7.3"
},
@@ -441,7 +436,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/fold/-/fold-10.2.0.tgz",
"integrity": "sha512-VDBGrVz2viaCsmONLKYpMMeP3ds+fw+7kofeF/z9ic6cB3d7BLEB8VcIdGkfY0FCBbLK2Btee1tNPuUF1uMlmQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@poppinss/utils": "^7.0.0-next.1",
"parse-imports": "^2.2.1"
@@ -508,7 +502,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/http-server/-/http-server-7.7.0.tgz",
"integrity": "sha512-qW1wsp7f1BqRO2qmJ8laUaq8vnLjEvhgkMusLEa2ju6RBMMsph5w3cEDTXAwQO8fSSqNXmRTzPRQ1lUm/FXq0A==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@paralleldrive/cuid2": "^2.2.2",
"@poppinss/macroable": "^1.0.4",
@@ -577,7 +570,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/logger/-/logger-6.0.6.tgz",
"integrity": "sha512-r5mLmmklSezzu3cu9QaXle2/gPNrgKpiIo+utYlwV3ITsW5JeIX/xcwwMTNM/9f1zU+SwOj5NccPTEFD3feRaw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@poppinss/utils": "^6.9.2",
"abstract-logging": "^2.0.1",
@@ -592,7 +584,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/lucid/-/lucid-21.6.1.tgz",
"integrity": "sha512-0TLCcPm9GHShJlsDAF5SHilafnvTxW25y5nD3bGJBSMEaNfGXcGRBbnyWoeNs7DsnqMCZ6ociT+0XMcKJWzQrQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@adonisjs/presets": "^2.6.4",
"@faker-js/faker": "^9.6.0",
@@ -676,7 +667,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/session/-/session-7.5.1.tgz",
"integrity": "sha512-b1E0W/1nnJfAq3Gv8yPywgsxJ7uzzOBJxxulonXI4t1eSdvJzZGNrFScfVLOcjTwlxwrEFA847tULIQxgR4Spw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@poppinss/macroable": "^1.0.4",
"@poppinss/utils": "^6.9.2"
@@ -719,7 +709,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/shield/-/shield-8.2.0.tgz",
"integrity": "sha512-RddRbs92y87GGFUgDSWD/Pg7qYHh8+MctUphFZwtbTblvDckrjZxuYyp+vmVATPuvDvK7sOlatuZHT4HQSz9zQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@poppinss/utils": "^6.9.2",
"csrf": "^3.1.0",
@@ -798,7 +787,6 @@
"resolved": "https://registry.npmjs.org/@adonisjs/vite/-/vite-4.0.0.tgz",
"integrity": "sha512-5kdE0qLIm2dj+XO0HiCohmh3tfZ+X468wkNXErQ15VS0fkDjZWns2VuiYpoToTKd4tdX/oGlpnpd8aIwAGB4ow==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@poppinss/utils": "^6.8.3",
"@vavite/multibuild": "^5.1.0",
@@ -897,7 +885,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.7.tgz",
"integrity": "sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
@@ -2206,7 +2193,6 @@
"integrity": "sha512-M2LUtHhKr4KgBfX73tDHNCD1IOmcXp9dvC+AinmRxsggIFnarsClcfjT/sXc3uNzjZW7Lk31LvcH76AxJHBmJQ==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=18.16.0"
},
@@ -2235,7 +2221,6 @@
"integrity": "sha512-e3BFn1rca/OTiagilkmRTrLVhl00iC/LrY5j4Ns/VZDONYHs9BKAbHaImxjD1zoHMEhwQEF+ce7fgMO/BK+lfg==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@japa/core": "^10.3.0",
"@japa/errors-printer": "^4.1.2",
@@ -3325,7 +3310,6 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
- "peer": true,
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.21"
@@ -3938,7 +3922,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.83.0.tgz",
"integrity": "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@tanstack/query-core": "5.83.0"
},
@@ -4239,7 +4222,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.33.tgz",
"integrity": "sha512-wzoocdnnpSxZ+6CjW4ADCK1jVmd1S/J3ArNWfn8FDDQtRm8dkDg7TA+mvek2wNrfCgwuZxqEOiB9B1XCJ6+dbw==",
"license": "MIT",
- "peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -4275,7 +4257,6 @@
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"devOptional": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4378,7 +4359,6 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -4607,7 +4587,6 @@
"resolved": "https://registry.npmjs.org/@vinejs/vine/-/vine-3.0.1.tgz",
"integrity": "sha512-ZtvYkYpZOYdvbws3uaOAvTFuvFXoQGAtmzeiXu+XSMGxi5GVsODpoI9Xu9TplEMuD/5fmAtBbKb9cQHkWkLXDQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@poppinss/macroable": "^1.0.4",
"@types/validator": "^13.12.2",
@@ -4747,7 +4726,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
- "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5123,7 +5101,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173",
@@ -6153,7 +6130,6 @@
"resolved": "https://registry.npmjs.org/edge.js/-/edge.js-6.2.1.tgz",
"integrity": "sha512-me875zh6YA0V429hywgQIpHgMvQkondv5XHaP6EsL2yIBpLcBWCl7Ba1cai0SwYhp8iD0IyV3KjpxLrnW7S2Ag==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@poppinss/inspect": "^1.0.1",
"@poppinss/macroable": "^1.0.4",
@@ -6424,7 +6400,6 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6486,7 +6461,6 @@
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -7828,7 +7802,6 @@
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz",
"integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@ioredis/commands": "1.4.0",
"cluster-key-slot": "^1.1.0",
@@ -8726,7 +8699,6 @@
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
"integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -8752,7 +8724,6 @@
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz",
"integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==",
"license": "BSD-3-Clause",
- "peer": true,
"dependencies": {
"@mapbox/geojson-rewind": "^0.5.2",
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
@@ -9747,7 +9718,6 @@
}
],
"license": "MIT",
- "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -9811,7 +9781,6 @@
"integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -10174,7 +10143,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -10199,7 +10167,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -10248,7 +10215,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -11813,7 +11779,6 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12073,7 +12038,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -12283,7 +12247,6 @@
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.0.tgz",
"integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==",
"license": "ISC",
- "peer": true,
"bin": {
"yaml": "bin.mjs"
},
diff --git a/admin/types/services.ts b/admin/types/services.ts
index a7de1d4..7b4f25d 100644
--- a/admin/types/services.ts
+++ b/admin/types/services.ts
@@ -10,4 +10,5 @@ export type ServiceSlim = Pick<
| 'ui_location'
| 'friendly_name'
| 'description'
+ | 'icon'
> & { status?: ServiceStatus }