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 }