feat: openwebui+ollama and zim management

This commit is contained in:
Jake Turner 2025-07-09 09:08:21 -07:00
parent 39d75c9cdf
commit 3b81e00a69
31 changed files with 1120 additions and 228 deletions

3
.gitignore vendored
View File

@ -38,3 +38,6 @@ server/temp
# Frontend assets compiled code # Frontend assets compiled code
admin/public/assets admin/public/assets
# Admin specific development files
admin/storage

View File

@ -7,7 +7,7 @@ Project N.O.M.A.D. can be installed on any Debian-based operating system (we rec
*Note: sudo/root privileges are required to run the install script* *Note: sudo/root privileges are required to run the install script*
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/CrosstalkSolutions/project-nomad/main/install/install/sh -o install_nomad.sh curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/install_nomad.sh -o install_nomad.sh
sudo bash ./install_nomad.sh sudo bash ./install_nomad.sh
``` ```

View File

@ -14,7 +14,8 @@ export default class HomeController {
} }
async home({ inertia }: HttpContext) { async home({ inertia }: HttpContext) {
const services = await this.systemService.getServices(); const services = await this.systemService.getServices({ installedOnly: true });
console.log(services)
return inertia.render('home', { return inertia.render('home', {
system: { system: {
services services

View File

@ -18,11 +18,19 @@ export default class SettingsController {
} }
async apps({ inertia }: HttpContext) { async apps({ inertia }: HttpContext) {
const services = await this.systemService.getServices(); const services = await this.systemService.getServices({ installedOnly: false });
return inertia.render('settings/apps', { return inertia.render('settings/apps', {
system: { system: {
services services
} }
}); });
} }
async zim({ inertia }: HttpContext) {
return inertia.render('settings/zim/index')
}
async zimRemote({ inertia }: HttpContext) {
return inertia.render('settings/zim/remote-explorer');
}
} }

View File

@ -11,9 +11,8 @@ export default class SystemController {
private dockerService: DockerService private dockerService: DockerService
) { } ) { }
async getServices({ response }: HttpContext) { async getServices({ }: HttpContext) {
const services = await this.systemService.getServices(); return await this.systemService.getServices({ installedOnly: true });
response.send(services);
} }
async installService({ request, response }: HttpContext) { async installService({ request, response }: HttpContext) {

View File

@ -0,0 +1,47 @@
import { ZimService } from '#services/zim_service';
import { inject } from '@adonisjs/core';
import type { HttpContext } from '@adonisjs/core/http'
@inject()
export default class ZimController {
constructor(
private zimService: ZimService
) { }
async list({ }: HttpContext) {
return await this.zimService.list();
}
async listRemote({ request }: HttpContext) {
const { start = 0, count = 12 } = request.qs();
return await this.zimService.listRemote({ start, count });
}
async downloadRemote({ request, response }: HttpContext) {
const { url } = request.body()
await this.zimService.downloadRemote(url);
response.status(200).send({
message: 'Download started successfully'
});
}
async delete({ request, response }: HttpContext) {
const { key } = request.params();
try {
await this.zimService.delete(key);
} catch (error) {
if (error.message === 'not_found') {
return response.status(404).send({
message: `ZIM file with key ${key} not found`
});
}
throw error; // Re-throw any other errors and let the global error handler catch
}
response.status(200).send({
message: 'ZIM file deleted successfully'
});
}
}

View File

@ -1,4 +1,5 @@
import { BaseModel, column, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm' import { BaseModel, belongsTo, column, hasMany, SnakeCaseNamingStrategy } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
export default class Service extends BaseModel { export default class Service extends BaseModel {
@ -14,16 +15,31 @@ export default class Service extends BaseModel {
declare container_image: string declare container_image: string
@column() @column()
declare container_command: string declare container_command: string | null
@column() @column()
declare container_config: string | null declare container_config: string | null
@column() @column({
serialize(value) {
return Boolean(value)
},
})
declare installed: boolean declare installed: boolean
@column() @column()
declare ui_location: string declare depends_on: string | null
// For services that are dependencies for other services - not intended to be installed directly by users
@column({
serialize(value) {
return Boolean(value)
},
})
declare is_dependency_service: boolean
@column()
declare ui_location: string | null
@column() @column()
declare metadata: string | null declare metadata: string | null
@ -33,4 +49,15 @@ export default class Service extends BaseModel {
@column.dateTime({ autoCreate: true, autoUpdate: true }) @column.dateTime({ autoCreate: true, autoUpdate: true })
declare updated_at: DateTime | null declare updated_at: DateTime | null
// Define a self-referential relationship for dependencies
@belongsTo(() => Service, {
foreignKey: 'depends_on',
})
declare dependency: BelongsTo<typeof Service>
@hasMany(() => Service, {
foreignKey: 'depends_on',
})
declare dependencies: HasMany<typeof Service>
} }

View File

@ -15,7 +15,7 @@ export class DockerService {
} }
async createContainerPreflight(serviceName: string): Promise<{ success: boolean; message: string }> { async createContainerPreflight(serviceName: string): Promise<{ success: boolean; message: string }> {
const service = await Service.findBy('service_name', serviceName); const service = await Service.query().where('service_name', serviceName).first();
if (!service) { if (!service) {
return { return {
success: false, success: false,
@ -33,29 +33,17 @@ export class DockerService {
// Check if a service wasn't marked as installed but has an existing container // Check if a service wasn't marked as installed but has an existing container
// This can happen if the service was created but not properly installed // This can happen if the service was created but not properly installed
// or if the container was removed manually without updating the service status. // or if the container was removed manually without updating the service status.
if (await this._checkIfServiceContainerExists(serviceName)) { // if (await this._checkIfServiceContainerExists(serviceName)) {
const removeResult = await this._removeServiceContainer(serviceName); // const removeResult = await this._removeServiceContainer(serviceName);
if (!removeResult.success) { // if (!removeResult.success) {
return { // return {
success: false, // success: false,
message: `Failed to remove existing container for service ${serviceName}: ${removeResult.message}`, // message: `Failed to remove existing container for service ${serviceName}: ${removeResult.message}`,
}; // };
} // }
} // }
// Attempt to parse any special container configuration
let containerConfig;
if (service.container_config) {
try {
containerConfig = JSON.parse(JSON.stringify(service.container_config));
} catch (error) {
return {
success: false,
message: `Failed to parse container configuration for service ${service.service_name}: ${error.message}`,
};
}
}
const containerConfig = this._parseContainerConfig(service.container_config);
this._createContainer(service, containerConfig); // Don't await this method - we will use server-sent events to notify the client of progress this._createContainer(service, containerConfig); // Don't await this method - we will use server-sent events to notify the client of progress
return { return {
@ -72,56 +60,75 @@ export class DockerService {
* @param serviceName * @param serviceName
* @returns * @returns
*/ */
async _createContainer(service: Service, containerConfig: any): Promise<void> { async _createContainer(service: Service & { dependencies?: Service[] }, containerConfig: any): Promise<void> {
try {
this._broadcastAndLog(service.service_name, 'initializing', '');
function sendBroadcastAndLog(status: string, message: string) { let dependencies = [];
transmit.broadcast('service-installation', { if (service.depends_on) {
service_name: service.service_name, const dependency = await Service.query().where('service_name', service.depends_on).first();
timestamp: new Date().toISOString(), if (dependency) {
status, dependencies.push(dependency);
message, }
});
logger.info(`[DockerService] [${service.service_name}] ${status}: ${message}`);
} }
sendBroadcastAndLog('initializing', ''); console.log('dependencies for service', service.service_name)
console.log(dependencies)
// First, check if the service has any dependencies that need to be installed first
if (dependencies && dependencies.length > 0) {
this._broadcastAndLog(service.service_name, 'checking-dependencies', `Checking dependencies for service ${service.service_name}...`);
for (const dependency of dependencies) {
if (!dependency.installed) {
this._broadcastAndLog(service.service_name, 'dependency-not-installed', `Dependency service ${dependency.service_name} is not installed. Installing it first...`);
await this._createContainer(dependency, this._parseContainerConfig(dependency.container_config));
} else {
this._broadcastAndLog(service.service_name, 'dependency-installed', `Dependency service ${dependency.service_name} is already installed.`);
}
}
}
// Start pulling the Docker image and wait for it to complete // Start pulling the Docker image and wait for it to complete
const pullStream = await this.docker.pull(service.container_image); const pullStream = await this.docker.pull(service.container_image);
sendBroadcastAndLog('pulling', `Pulling Docker image ${service.container_image}...`); this._broadcastAndLog(service.service_name, 'pulling', `Pulling Docker image ${service.container_image}...`);
await new Promise(res => this.docker.modem.followProgress(pullStream, res)); await new Promise(res => this.docker.modem.followProgress(pullStream, res));
this._broadcastAndLog(service.service_name, 'pulled', `Docker image ${service.container_image} pulled successfully.`);
sendBroadcastAndLog('pulled', `Docker image ${service.container_image} pulled successfully.`); this._broadcastAndLog(service.service_name, 'creating', `Creating Docker container for service ${service.service_name}...`);
sendBroadcastAndLog('creating', `Creating Docker container for service ${service.service_name}...`);
const container = await this.docker.createContainer({ const container = await this.docker.createContainer({
Image: service.container_image, Image: service.container_image,
Cmd: service.container_command.split(' '),
name: service.service_name, name: service.service_name,
HostConfig: containerConfig?.HostConfig || undefined, HostConfig: containerConfig?.HostConfig || undefined,
WorkingDir: containerConfig?.WorkingDir || undefined, WorkingDir: containerConfig?.WorkingDir || undefined,
ExposedPorts: containerConfig?.ExposedPorts || undefined, ExposedPorts: containerConfig?.ExposedPorts || undefined,
...(service.container_command ? { Cmd: service.container_command.split(' ') } : {}),
...(service.service_name === 'open-webui' ? { Env: ['WEBUI_AUTH=False'] } : {}), // Special case for Open WebUI to disable authentication
}); });
sendBroadcastAndLog('created', `Docker container for service ${service.service_name} created successfully.`); this._broadcastAndLog(service.service_name, 'created', `Docker container for service ${service.service_name} created successfully.`);
if (service.service_name === 'kiwix-serve') { if (service.service_name === 'kiwix-serve') {
sendBroadcastAndLog('preinstall', `Running pre-install actions for Kiwix Serve...`);
await this._runPreinstallActions__KiwixServe(); await this._runPreinstallActions__KiwixServe();
sendBroadcastAndLog('preinstall-complete', `Pre-install actions for Kiwix Serve completed successfully.`); this._broadcastAndLog(service.service_name, 'preinstall-complete', `Pre-install actions for Kiwix Serve completed successfully.`);
} else if (service.service_name === 'openstreetmap') {
await this._runPreinstallActions__OpenStreetMap(containerConfig);
this._broadcastAndLog(service.service_name, 'preinstall-complete', `Pre-install actions for OpenStreetMap completed successfully.`);
} }
sendBroadcastAndLog('starting', `Starting Docker container for service ${service.service_name}...`); this._broadcastAndLog(service.service_name, 'starting', `Starting Docker container for service ${service.service_name}...`);
await container.start(); await container.start();
sendBroadcastAndLog('started', `Docker container for service ${service.service_name} started successfully.`); this._broadcastAndLog(service.service_name, 'started', `Docker container for service ${service.service_name} started successfully.`);
sendBroadcastAndLog('finalizing', `Finalizing installation of service ${service.service_name}...`); this._broadcastAndLog(service.service_name, 'finalizing', `Finalizing installation of service ${service.service_name}...`);
service.installed = true; service.installed = true;
await service.save(); await service.save();
sendBroadcastAndLog('completed', `Service ${service.service_name} installation completed successfully.`); this._broadcastAndLog(service.service_name, 'completed', `Service ${service.service_name} installation completed successfully.`);
} catch (error) {
this._broadcastAndLog(service.service_name, 'error', `Error installing service ${service.service_name}: ${error.message}`);
throw new Error(`Failed to install service ${service.service_name}: ${error.message}`);
}
} }
async _checkIfServiceContainerExists(serviceName: string): Promise<boolean> { async _checkIfServiceContainerExists(serviceName: string): Promise<boolean> {
@ -153,13 +160,16 @@ export class DockerService {
} }
} }
async _runPreinstallActions__KiwixServe(): Promise<void> { private async _runPreinstallActions__KiwixServe(): Promise<void> {
/** /**
* At least one .zim file must be available before we can start the kiwix container. * At least one .zim file must be available before we can start the kiwix container.
* We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose. * We'll download the lightweight mini Wikipedia Top 100 zim file for this purpose.
**/ **/
const WIKIPEDIA_ZIM_URL = "https://download.kiwix.org/zim/wikipedia/wikipedia_en_100_mini_2025-06.zim" const WIKIPEDIA_ZIM_URL = "https://download.kiwix.org/zim/wikipedia/wikipedia_en_100_mini_2025-06.zim"
const PATH = '/zim/wikipedia_en_100_mini_2025-06.zim';
this._broadcastAndLog('kiwix-serve', 'preinstall', `Running pre-install actions for Kiwix Serve...`);
this._broadcastAndLog('kiwix-serve', 'preinstall', `Downloading Wikipedia ZIM file from ${WIKIPEDIA_ZIM_URL}. This may take some time...`);
const response = await axios.get(WIKIPEDIA_ZIM_URL, { const response = await axios.get(WIKIPEDIA_ZIM_URL, {
responseType: 'stream', responseType: 'stream',
}); });
@ -171,10 +181,89 @@ export class DockerService {
}); });
const disk = drive.use('fs'); const disk = drive.use('fs');
await disk.putStream('/zim/wikipedia_en_100_mini_2025-06.zim', stream); await disk.putStream(PATH, stream);
this._broadcastAndLog('kiwix-serve', 'preinstall', `Downloaded Wikipedia ZIM file to ${PATH}`);
}
logger.info(`Downloaded Wikipedia ZIM file to /zim/wikipedia_en_100_mini_2025-06.zim`); /**
* Largely follows the install instructions here: https://github.com/Overv/openstreetmap-tile-server/blob/master/README.md
*/
private async _runPreinstallActions__OpenStreetMap(containerConfig: any): Promise<void> {
const FILE_NAME = 'us-pacific-latest.osm.pbf';
const OSM_PBF_URL = `https://download.geofabrik.de/north-america/${FILE_NAME}`; // Download a small subregion for initial import
const PATH = `/osm/${FILE_NAME}`;
this._broadcastAndLog('openstreetmap', 'preinstall', `Running pre-install actions for OpenStreetMap Tile Server...`);
this._broadcastAndLog('openstreetmap', 'preinstall', `Downloading OpenStreetMap PBF file from ${OSM_PBF_URL}. This may take some time...`);
const response = await axios.get(OSM_PBF_URL, {
responseType: 'stream',
});
const stream = response.data;
stream.on('error', (error: Error) => {
logger.error(`Error downloading OpenStreetMap PBF file: ${error.message}`);
throw error;
});
const disk = drive.use('fs');
await disk.putStream(PATH, stream);
this._broadcastAndLog('openstreetmap', 'preinstall', `Downloaded OpenStreetMap PBF file to ${PATH}`);
// Do initial import of OSM data into the tile server DB
// We'll use the same containerConfig as the actual container, just with the command set to "import"
this._broadcastAndLog('openstreetmap', 'importing', `Processing initial import of OSM data. This may take some time...`);
const data = await new Promise((resolve, reject) => {
this.docker.run(containerConfig.Image, ['import'], process.stdout, containerConfig?.HostConfig || {}, {},
// @ts-ignore
(err: any, data: any, container: any) => {
if (err) {
logger.error(`Error running initial import for OpenStreetMap Tile Server: ${err.message}`);
return reject(err);
}
resolve(data);
});
});
const [output, container] = data as [any, any];
if (output?.StatusCode === 0) {
this._broadcastAndLog('openstreetmap', 'imported', `OpenStreetMap data imported successfully.`);
await container.remove();
} else {
const errorMessage = `Failed to import OpenStreetMap data. Status code: ${output?.StatusCode}. Output: ${output?.Output || 'No output'}`;
this._broadcastAndLog('openstreetmap', 'error', errorMessage);
logger.error(errorMessage);
throw new Error(errorMessage);
}
}
private _broadcastAndLog(service: string, status: string, message: string) {
transmit.broadcast('service-installation', {
service_name: service,
timestamp: new Date().toISOString(),
status,
message,
});
logger.info(`[DockerService] [${service}] ${status}: ${message}`);
}
private _parseContainerConfig(containerConfig: any): any {
if (!containerConfig) {
return {};
}
try {
// Handle the case where containerConfig is returned as an object by DB instead of a string
let toParse = containerConfig;
if (typeof containerConfig === 'object') {
toParse = JSON.stringify(containerConfig);
}
return JSON.parse(toParse);
} catch (error) {
logger.error(`Failed to parse container configuration: ${error.message}`);
throw new Error(`Invalid container configuration: ${error.message}`);
}
} }
async simulateSSE(): Promise<void> { async simulateSSE(): Promise<void> {

View File

@ -1,7 +1,15 @@
import Service from "#models/service" import Service from "#models/service"
export class SystemService { export class SystemService {
async getServices(): Promise<{ id: number; service_name: string; installed: boolean }[]> { async getServices({
return await Service.query().orderBy('service_name', 'asc').select('id', 'service_name', 'installed', 'ui_location'); installedOnly = true,
}:{
installedOnly?: boolean
}): Promise<{ id: number; service_name: string; installed: boolean }[]> {
const query = Service.query().orderBy('service_name', 'asc').select('id', 'service_name', 'installed', 'ui_location').where('is_dependency_service', false)
if (installedOnly) {
query.where('installed', true);
}
return await query;
} }
} }

View File

@ -0,0 +1,139 @@
import drive from "@adonisjs/drive/services/main";
import { ListRemoteZimFilesResponse, RawRemoteZimFileEntry, RemoteZimFileEntry, ZimFilesEntry } from "../../types/zim.js";
import axios from "axios";
import { XMLParser } from 'fast-xml-parser'
import { isRawListRemoteZimFilesResponse, isRawRemoteZimFileEntry } from "../../util/zim.js";
export class ZimService {
async list() {
const disk = drive.use('fs');
const contents = await disk.listAll('/zim')
const files: ZimFilesEntry[] = []
for (let item of contents.objects) {
if (item.isFile) {
files.push({
type: 'file',
key: item.key,
name: item.name
})
} else {
files.push({
type: 'directory',
prefix: item.prefix,
name: item.name
})
}
}
return {
files,
next: contents.paginationToken
}
}
async listRemote({ start, count }: { start: number, count: number }): Promise<ListRemoteZimFilesResponse> {
const LIBRARY_BASE_URL = 'https://browse.library.kiwix.org/catalog/v2/entries'
const res = await axios.get(LIBRARY_BASE_URL, {
params: {
start: start,
count: count,
lang: 'eng'
},
responseType: 'text'
});
const data = res.data;
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '',
textNodeName: '#text',
});
const result = parser.parse(data);
if (!isRawListRemoteZimFilesResponse(result)) {
throw new Error('Invalid response format from remote library');
}
const filtered = result.feed.entry.filter((entry: any) => {
return isRawRemoteZimFileEntry(entry);
})
const mapped: (RemoteZimFileEntry | null)[] = filtered.map((entry: RawRemoteZimFileEntry) => {
const downloadLink = entry.link.find((link: any) => {
return typeof link === 'object' && 'rel' in link && 'length' in link && 'href' in link && 'type' in link && link.type === 'application/x-zim'
});
if (!downloadLink) {
return null
}
// downloadLink['href'] will end with .meta4, we need to remove that to get the actual download URL
const download_url = downloadLink['href'].substring(0, downloadLink['href'].length - 6);
const file_name = download_url.split('/').pop() || `${entry.title}.zim`;
const sizeBytes = parseInt(downloadLink['length'], 10);
return {
id: entry.id,
title: entry.title,
updated: entry.updated,
summary: entry.summary,
size_bytes: sizeBytes || 0,
download_url: download_url,
author: entry.author.name,
file_name: file_name
}
});
// Filter out any null entries (those without a valid download link)
// or files that already exist in the local storage
const existing = await this.list();
const existingKeys = new Set(existing.files.map(file => file.name));
const withoutExisting = mapped.filter((entry): entry is RemoteZimFileEntry => entry !== null && !existingKeys.has(entry.file_name));
return {
items: withoutExisting,
has_more: result.feed.totalResults > start,
total_count: result.feed.totalResults,
};
}
async downloadRemote(url: string): Promise<void> {
if (!url.endsWith('.zim')) {
throw new Error(`Invalid ZIM file URL: ${url}. URL must end with .zim`);
}
const disk = drive.use('fs');
const response = await axios.get(url, {
responseType: 'stream'
});
if (response.status !== 200) {
throw new Error(`Failed to download remote ZIM file from ${url}`);
}
// Extract the filename from the URL
const filename = url.split('/').pop() || `downloaded-${Date.now()}.zim`;
const path = `/zim/${filename}`;
await disk.putStream(path, response.data);
}
async delete(key: string): Promise<void> {
console.log('Deleting ZIM file with key:', key);
let fileName = key;
if (!fileName.endsWith('.zim')) {
fileName += '.zim';
}
const disk = drive.use('fs');
const exists = await disk.exists(fileName);
if (!exists) {
throw new Error('not_found');
}
await disk.delete(fileName);
}
}

View File

@ -6,11 +6,13 @@ export default class extends BaseSchema {
async up() { async up() {
this.schema.createTable(this.tableName, (table) => { this.schema.createTable(this.tableName, (table) => {
table.increments('id') table.increments('id')
table.string('service_name') table.string('service_name').unique().notNullable()
table.string('container_image') table.string('container_image').notNullable()
table.string('container_command') table.string('container_command').nullable()
table.json('container_config').nullable() table.json('container_config').nullable()
table.boolean('installed').defaultTo(false) table.boolean('installed').defaultTo(false)
table.string('depends_on').nullable().references('service_name').inTable(this.tableName).onDelete('SET NULL')
table.boolean('is_dependency_service').defaultTo(false)
table.string('ui_location') table.string('ui_location')
table.json('metadata').nullable() table.json('metadata').nullable()
table.timestamp('created_at') table.timestamp('created_at')

View File

@ -11,15 +11,39 @@ export default class ServiceSeeder extends BaseSeeder {
container_config: "{\"HostConfig\":{\"Binds\":[\"/opt/project-nomad/storage/zim:/data\"],\"PortBindings\":{\"8080/tcp\":[{\"HostPort\":\"8090\"}]}},\"ExposedPorts\":{\"8080/tcp\":{}}}", container_config: "{\"HostConfig\":{\"Binds\":[\"/opt/project-nomad/storage/zim:/data\"],\"PortBindings\":{\"8080/tcp\":[{\"HostPort\":\"8090\"}]}},\"ExposedPorts\":{\"8080/tcp\":{}}}",
ui_location: '8090', ui_location: '8090',
installed: false, installed: false,
is_dependency_service: false,
depends_on: null,
}, },
{ {
service_name: 'openstreetmap', service_name: 'openstreetmap',
container_image: 'overv/openstreetmap-tile-server', container_image: 'overv/openstreetmap-tile-server',
container_command: 'run', container_command: 'run --shm-size="192m"',
container_config: "{\"HostConfig\":{\"Binds\":[\"/opt/project-nomad/storage/osm/db:/data/database\",\"/opt/project-nomad/storage/osm/tiles:/data/tiles\"],\"PortBindings\":{\"80/tcp\":[{\"HostPort\":\"9000\"}]}}}", container_config: "{\"HostConfig\":{\"Binds\":[\"/opt/project-nomad/storage/osm/db:/data/database\",\"/opt/project-nomad/storage/osm/tiles:/data/tiles\"],\"PortBindings\":{\"80/tcp\":[{\"HostPort\":\"9000\"}]}}}",
ui_location: '9000', ui_location: '9000',
installed: false, installed: false,
} is_dependency_service: false,
depends_on: null,
},
{
service_name: 'ollama',
container_image: 'ollama/ollama:latest',
container_command: 'serve',
container_config: "{\"HostConfig\":{\"Binds\":[\"/opt/project-nomad/storage/ollama:/root/.ollama\"],\"PortBindings\":{\"11434/tcp\":[{\"HostPort\":\"11434\"}]}}, \"ExposedPorts\":{\"11434/tcp\":{}}}",
ui_location: null,
installed: false,
is_dependency_service: true,
depends_on: null,
},
{
service_name: 'open-webui',
container_image: 'ghcr.io/open-webui/open-webui:main',
container_command: null,
container_config: "{\"HostConfig\":{\"Env\":[\"WEBUI_AUTH=False\"],\"Binds\":[\"/opt/project-nomad/storage/open-webui:/app/backend/data\"],\"PortBindings\": {\"8080/tcp\": [{\"HostPort\": \"3000\"}]}},\"ExposedPorts\":{\"8080/tcp\": {}}}",
ui_location: '3000',
installed: false,
is_dependency_service: false,
depends_on: 'ollama',
},
] ]
async run() { async run() {

View File

@ -1,2 +1,3 @@
import { configApp } from '@adonisjs/eslint-config' import { configApp } from '@adonisjs/eslint-config'
export default configApp() import pluginQuery from '@tanstack/eslint-plugin-query'
export default configApp(...pluginQuery.configs['flat/recommended'])

View File

@ -8,8 +8,10 @@ import { resolvePageComponent } from '@adonisjs/inertia/helpers'
import ModalsProvider from '~/providers/ModalProvider' import ModalsProvider from '~/providers/ModalProvider'
import { TransmitProvider } from 'react-adonis-transmit' import { TransmitProvider } from 'react-adonis-transmit'
import { generateUUID } from '~/lib/util' import { generateUUID } from '~/lib/util'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.' const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.'
const queryClient = new QueryClient()
// Patch the global crypto object for non-HTTPS/localhost contexts // Patch the global crypto object for non-HTTPS/localhost contexts
if (!window.crypto?.randomUUID) { if (!window.crypto?.randomUUID) {
@ -20,7 +22,7 @@ if (!window.crypto?.randomUUID) {
} }
createInertiaApp({ createInertiaApp({
progress: { color: '#5468FF' }, progress: { color: '#424420' },
title: (title) => `${title} - ${appName}`, title: (title) => `${title} - ${appName}`,
@ -30,11 +32,13 @@ createInertiaApp({
setup({ el, App, props }) { setup({ el, App, props }) {
createRoot(el).render( createRoot(el).render(
<QueryClientProvider client={queryClient}>
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}> <TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
<ModalsProvider> <ModalsProvider>
<App {...props} /> <App {...props} />
</ModalsProvider> </ModalsProvider>
</TransmitProvider> </TransmitProvider>
</QueryClientProvider>
) )
}, },
}) })

View File

@ -1,7 +1,7 @@
import * as Icons from '@heroicons/react/24/outline' import * as Icons from '@heroicons/react/24/outline'
import { useMemo } from 'react' import { useMemo } from 'react'
interface StyledButtonProps extends React.HTMLAttributes<HTMLButtonElement> { export interface StyledButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
children: React.ReactNode children: React.ReactNode
// icon should be one of the HeroIcon names, e.g. ArrowTopRightOnSquareIcon // icon should be one of the HeroIcon names, e.g. ArrowTopRightOnSquareIcon
icon?: keyof typeof Icons icon?: keyof typeof Icons

View File

@ -1,17 +1,20 @@
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react' import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
import StyledButton from './StyledButton' import StyledButton, { StyledButtonProps } from './StyledButton'
import React from 'react' import React from 'react'
import classNames from '~/lib/classNames'
interface StyledModalProps { interface StyledModalProps {
onClose?: () => void onClose?: () => void
title: string title: string
cancelText?: string cancelText?: string
confirmText?: string confirmText?: string
confirmVariant?: StyledButtonProps['variant']
open: boolean open: boolean
onCancel?: () => void onCancel?: () => void
onConfirm?: () => void onConfirm?: () => void
children: React.ReactNode children: React.ReactNode
icon?: React.ReactNode icon?: React.ReactNode
large?: boolean
} }
const StyledModal: React.FC<StyledModalProps> = ({ const StyledModal: React.FC<StyledModalProps> = ({
@ -21,9 +24,11 @@ const StyledModal: React.FC<StyledModalProps> = ({
onClose, onClose,
cancelText = 'Cancel', cancelText = 'Cancel',
confirmText = 'Confirm', confirmText = 'Confirm',
confirmVariant = 'action',
onCancel, onCancel,
onConfirm, onConfirm,
icon, icon,
large = false,
}) => { }) => {
return ( return (
<Dialog <Dialog
@ -38,10 +43,18 @@ const StyledModal: React.FC<StyledModalProps> = ({
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in" className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
/> />
<div className="fixed inset-0 z-10 w-screen overflow-y-auto"> <div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0"> <div
className={classNames(
'flex min-h-full items-end justify-center p-4 text-center sm:items-center !w-screen',
large ? 'sm:px-4' : 'sm:p-0'
)}
>
<DialogPanel <DialogPanel
transition transition
className="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:w-full sm:max-w-lg sm:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95" className={classNames(
'relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95',
large ? 'sm:max-w-7xl !w-full' : 'sm:max-w-lg'
)}
> >
<div> <div>
{icon && <div className="flex items-center justify-center">{icon}</div>} {icon && <div className="flex items-center justify-center">{icon}</div>}
@ -49,10 +62,11 @@ const StyledModal: React.FC<StyledModalProps> = ({
<DialogTitle as="h3" className="text-base font-semibold text-gray-900"> <DialogTitle as="h3" className="text-base font-semibold text-gray-900">
{title} {title}
</DialogTitle> </DialogTitle>
<div className="mt-2">{children}</div> <div className="mt-2 !h-fit">{children}</div>
</div> </div>
</div> </div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3"> <div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
{cancelText && onCancel && (
<StyledButton <StyledButton
variant="secondary" variant="secondary"
onClick={() => { onClick={() => {
@ -61,14 +75,17 @@ const StyledModal: React.FC<StyledModalProps> = ({
> >
{cancelText} {cancelText}
</StyledButton> </StyledButton>
)}
{confirmText && onConfirm && (
<StyledButton <StyledButton
variant="action" variant={confirmVariant}
onClick={() => { onClick={() => {
if (onConfirm) onConfirm() if (onConfirm) onConfirm()
}} }}
> >
{confirmText} {confirmText}
</StyledButton> </StyledButton>
)}
</div> </div>
</DialogPanel> </DialogPanel>
</div> </div>

View File

@ -1,10 +1,14 @@
import { capitalizeFirstLetter } from '~/lib/util' import { capitalizeFirstLetter } from '~/lib/util'
import classNames from '~/lib/classNames' import classNames from '~/lib/classNames'
import LoadingSpinner from '~/components/LoadingSpinner' import LoadingSpinner from '~/components/LoadingSpinner'
import React, { RefObject } from 'react'
interface StyledTableProps<T = Record<string, unknown>> { export type StyledTableProps<T extends { [key: string]: any }> = {
loading?: boolean loading?: boolean
tableProps?: React.HTMLAttributes<HTMLTableElement> tableProps?: React.HTMLAttributes<HTMLTableElement>
tableRowStyle?: React.CSSProperties
tableBodyClassName?: string
tableBodyStyle?: React.CSSProperties
data?: T[] data?: T[]
noDataText?: string noDataText?: string
onRowClick?: (record: T) => void onRowClick?: (record: T) => void
@ -16,55 +20,82 @@ interface StyledTableProps<T = Record<string, unknown>> {
}[] }[]
className?: string className?: string
rowLines?: boolean rowLines?: boolean
ref?: RefObject<HTMLDivElement | null>
containerProps?: React.HTMLAttributes<HTMLDivElement>
compact?: boolean
} }
function StyledTable<T>({ function StyledTable<T extends { [key: string]: any }>({
loading = false, loading = false,
tableProps = {}, tableProps = {},
tableRowStyle = {},
tableBodyClassName = '',
tableBodyStyle = {},
data = [], data = [],
noDataText = 'No records found', noDataText = 'No records found',
onRowClick, onRowClick,
columns = [], columns = [],
className = '', className = '',
ref,
containerProps = {},
rowLines = true,
compact = false,
}: StyledTableProps<T>) { }: StyledTableProps<T>) {
const { className: tableClassName, ...restTableProps } = tableProps const { className: tableClassName, ...restTableProps } = tableProps
const leftPadding = compact ? 'pl-2' : 'pl-4 sm:pl-6'
return ( return (
<div <div
className={classNames( className={classNames(
'w-full overflow-x-auto bg-white mt-10 ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-3 shadow-md', 'w-full overflow-x-auto bg-white mt-10 ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-3 shadow-md',
className className
)} )}
ref={ref}
{...containerProps}
> >
<table className="min-w-full" {...restTableProps}> <table className="min-w-full overflow-auto" {...restTableProps}>
<thead> <thead className='border-b border-gray-200 '>
<tr> <tr>
{columns.map((column, index) => ( {columns.map((column, index) => (
<th <th
key={index} key={index}
className="whitespace-nowrap text-left py-3.5 pl-4 pr-3text-sm font-semibold text-gray-900 sm:pl-6" className={classNames(
'whitespace-nowrap text-left text-sm font-semibold text-gray-900',
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
)}
> >
{column.title ?? capitalizeFirstLetter(column.accessor.toString())} {column.title ?? capitalizeFirstLetter(column.accessor.toString())}
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody className={tableBodyClassName} style={tableBodyStyle}>
{!loading && {!loading &&
data.length !== 0 && data.length !== 0 &&
data.map((record, recordIdx) => ( data.map((record, recordIdx) => (
<tr <tr
key={crypto.randomUUID()} data-index={'index' in record ? record.index : recordIdx}
key={record.id || recordIdx}
onClick={() => onRowClick?.(record)} onClick={() => onRowClick?.(record)}
className={onRowClick ? `cursor-pointer hover:bg-gray-100 ` : ''} style={{
...tableRowStyle,
height: 'height' in record ? record.height : 'auto',
transform:
'translateY' in record ? 'translateY(' + record.transformY + 'px)' : undefined,
}}
className={classNames(
rowLines ? 'border-b border-gray-200' : '',
onRowClick ? `cursor-pointer hover:bg-gray-100 ` : ''
)}
> >
{columns.map((column, index) => ( {columns.map((column, index) => (
<td <td
key={index} key={index}
className={classNames( className={classNames(
recordIdx === 0 ? '' : 'border-t border-transparent', 'relative text-sm whitespace-nowrap max-w-72 truncate break-words text-left',
'relative py-4 pl-4 pr-3 text-sm sm:pl-6 whitespace-nowrap max-w-72 truncate break-words', column.className || '',
column.className || '' compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
)} )}
> >
{column.render {column.render

View File

@ -1,9 +1,20 @@
import { Cog6ToothIcon, CommandLineIcon, FolderIcon } from '@heroicons/react/24/outline' import {
Cog6ToothIcon,
CommandLineIcon,
FolderIcon,
MagnifyingGlassIcon,
} from '@heroicons/react/24/outline'
import StyledSidebar from '~/components/StyledSidebar' import StyledSidebar from '~/components/StyledSidebar'
const navigation = [ const navigation = [
{ name: 'Apps', href: '/settings/apps', icon: CommandLineIcon, current: false }, { name: 'Apps', href: '/settings/apps', icon: CommandLineIcon, current: false },
{ name: 'ZIM Explorer', href: '/settings/zim', icon: FolderIcon, current: false }, { name: 'ZIM Manager', href: '/settings/zim', icon: FolderIcon, current: false },
{
name: 'Zim Remote Explorer',
href: '/settings/zim/remote-explorer',
icon: MagnifyingGlassIcon,
current: false,
},
{ name: 'System', href: '/settings/system', icon: Cog6ToothIcon, current: true }, { name: 'System', href: '/settings/system', icon: Cog6ToothIcon, current: true },
] ]

View File

@ -1,4 +1,5 @@
import axios from "axios"; import axios from "axios";
import { ListRemoteZimFilesResponse, ListZimFilesResponse, RemoteZimFileEntry } from "../../types/zim";
class API { class API {
private client; private client;
@ -32,6 +33,39 @@ class API {
throw error; throw error;
} }
} }
async listZimFiles() {
return await this.client.get<ListZimFilesResponse>("/zim/list");
}
async listRemoteZimFiles({ start = 0, count = 12 }: { start?: number; count?: number }) {
return await this.client.get<ListRemoteZimFilesResponse>("/zim/list-remote", {
params: {
start,
count
}
});
}
async downloadRemoteZimFile(url: string) {
try {
const response = await this.client.post("/zim/download-remote", { url });
return response.data;
} catch (error) {
console.error("Error downloading remote ZIM file:", error);
throw error;
}
}
async deleteZimFile(key: string) {
try {
const response = await this.client.delete(`/zim/${key}`);
return response.data;
} catch (error) {
console.error("Error deleting ZIM file:", error);
throw error;
}
}
} }
export default new API(); export default new API();

View File

@ -14,7 +14,7 @@ export function getServiceLink(ui_location: string): string {
const parsedPort = parseInt(ui_location, 10); const parsedPort = parseInt(ui_location, 10);
if (!isNaN(parsedPort)) { if (!isNaN(parsedPort)) {
// If it's a port number, return a link to the service on that port // If it's a port number, return a link to the service on that port
return `http://${window.location.origin}:${parsedPort}`; return `http://${window.location.hostname}:${parsedPort}`;
} }
// Otherwise, treat it as a path // Otherwise, treat it as a path
return `/${ui_location}`; return `/${ui_location}`;

View File

@ -5,6 +5,15 @@ export function capitalizeFirstLetter(str?: string | null): string {
return str.charAt(0).toUpperCase() + str.slice(1); return str.charAt(0).toUpperCase() + str.slice(1);
} }
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
export async function testInternetConnection(): Promise<boolean> { export async function testInternetConnection(): Promise<boolean> {
try { try {
const response = await axios.get('https://1.1.1.1/cdn-cgi/trace', { const response = await axios.get('https://1.1.1.1/cdn-cgi/trace', {

View File

@ -1,59 +1,9 @@
import { import { IconHelp, IconPlus, IconSettings, IconWifiOff } from '@tabler/icons-react'
IconBook, import { Head } from '@inertiajs/react'
IconBrandWikipedia,
IconCalculator,
IconHelp,
IconMapRoute,
IconMessageCircleSearch,
IconPlus,
IconSettings,
IconWifiOff,
} from '@tabler/icons-react'
import { Head, Link } from '@inertiajs/react'
import BouncingLogo from '~/components/BouncingLogo' import BouncingLogo from '~/components/BouncingLogo'
import AppLayout from '~/layouts/AppLayout' import AppLayout from '~/layouts/AppLayout'
import { getServiceLink } from '~/lib/navigation' import { getServiceLink } from '~/lib/navigation'
const NAV_ITEMS = [
{
label: 'AI Chat',
to: '/ai-chat',
description: 'Chat with local AI models',
icon: <IconMessageCircleSearch size={48} />,
},
{
label: 'Calculators',
to: '/calculators',
description: 'Perform various calculations',
icon: <IconCalculator size={48} />,
},
{
label: 'Ebooks',
to: '/ebooks',
description: 'Explore our collection of eBooks',
icon: <IconBook size={48} />,
},
{
label: 'Kiwix (Offline Browser)',
to: '/kiwix',
description: 'Access offline content with Kiwix',
icon: <IconWifiOff size={48} />,
},
{
label: 'OpenStreetMap',
to: '/openstreetmap',
description: 'View maps and geospatial data',
icon: <IconMapRoute size={48} />,
},
{
label: 'Wikipedia',
to: '/wikipedia',
description: 'Browse an offline Wikipedia snapshot',
icon: <IconBrandWikipedia size={48} />,
},
]
const STATIC_ITEMS = [ const STATIC_ITEMS = [
{ {
label: 'Install Apps', label: 'Install Apps',
@ -87,6 +37,8 @@ export default function Home(props: {
} }
}) { }) {
const items = [] const items = []
console.log(props.system.services)
props.system.services.map((service) => { props.system.services.map((service) => {
items.push({ items.push({
label: service.service_name, label: service.service_name,

View File

@ -0,0 +1,100 @@
import { Head, Link } from '@inertiajs/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import StyledTable from '~/components/StyledTable'
import SettingsLayout from '~/layouts/SettingsLayout'
import { ZimFilesEntry } from '../../../../types/zim'
import api from '~/lib/api'
import StyledButton from '~/components/StyledButton'
import { useModals } from '~/context/ModalContext'
import StyledModal from '~/components/StyledModal'
export default function ZimPage() {
const queryClient = useQueryClient()
const { openModal, closeAllModals } = useModals()
const { data, isLoading } = useQuery<ZimFilesEntry[]>({
queryKey: ['zim-files'],
queryFn: getFiles,
})
async function getFiles() {
const res = await api.listZimFiles()
return res.data.files
}
async function confirmDeleteFile(file: ZimFilesEntry) {
openModal(
<StyledModal
title="Confirm Delete?"
onConfirm={() => {
deleteFileMutation.mutateAsync(file)
closeAllModals()
}}
onCancel={closeAllModals}
open={true}
confirmText="Delete"
cancelText="Cancel"
confirmVariant="danger"
>
<p className="text-gray-700">
Are you sure you want to delete {file.name}? This action cannot be undone.
</p>
</StyledModal>,
'confirm-delete-file-modal'
)
}
const deleteFileMutation = useMutation({
mutationFn: async (file: ZimFilesEntry) => api.deleteZimFile(file.name.replace('.zim', '')),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['zim-files'] })
},
})
return (
<SettingsLayout>
<Head title="ZIM Manager | Project N.O.M.A.D." />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<div className="flex items-center justify-between mb-6">
<div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-4">ZIM Manager</h1>
<p className="text-gray-500 mb-4">
Manage your stored ZIM files and download new ones!
</p>
</div>
<Link href="/settings/zim/remote-explorer">
<StyledButton icon={'MagnifyingGlassIcon'}>Remote Explorer</StyledButton>
</Link>
</div>
<StyledTable<ZimFilesEntry & { actions?: any }>
className="font-semibold"
rowLines={true}
loading={isLoading}
compact
columns={[
{ accessor: 'name', title: 'Name' },
{
accessor: 'actions',
title: 'Actions',
render: (record) => (
<div className="flex space-x-2">
<StyledButton
variant="danger"
icon={'TrashIcon'}
onClick={() => {
confirmDeleteFile(record)
}}
>
Delete
</StyledButton>
</div>
),
},
]}
data={data || []}
/>
</main>
</div>
</SettingsLayout>
)
}

View File

@ -0,0 +1,173 @@
import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query'
import api from '~/lib/api'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import StyledTable from '~/components/StyledTable'
import SettingsLayout from '~/layouts/SettingsLayout'
import { Head } from '@inertiajs/react'
import { ListRemoteZimFilesResponse, RemoteZimFileEntry } from '../../../../types/zim'
import { formatBytes } from '~/lib/util'
import StyledButton from '~/components/StyledButton'
import { useModals } from '~/context/ModalContext'
import StyledModal from '~/components/StyledModal'
export default function ZimRemoteExplorer() {
const tableParentRef = useRef<HTMLDivElement>(null)
const { openModal, closeAllModals } = useModals()
const { data, fetchNextPage, isFetching, isLoading } =
useInfiniteQuery<ListRemoteZimFilesResponse>({
queryKey: ['remote-zim-files'],
queryFn: async ({ pageParam = 0 }) => {
const pageParsed = parseInt((pageParam as number).toString(), 10)
const start = isNaN(pageParsed) ? 0 : pageParsed * 12
const res = await api.listRemoteZimFiles({ start, count: 12 })
return res.data
},
initialPageParam: 0,
getNextPageParam: (_lastPage, pages) => {
if (!_lastPage.has_more) {
return undefined // No more pages to fetch
}
return pages.length
},
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
})
const flatData = useMemo(() => data?.pages.flatMap((page) => page.items) || [], [data])
const hasMore = useMemo(() => data?.pages[data.pages.length - 1]?.has_more || false, [data])
const fetchOnBottomReached = useCallback(
(parentRef?: HTMLDivElement | null) => {
if (parentRef) {
const { scrollHeight, scrollTop, clientHeight } = parentRef
//once the user has scrolled within 200px of the bottom of the table, fetch more data if we can
if (scrollHeight - scrollTop - clientHeight < 200 && !isFetching && hasMore) {
fetchNextPage()
}
}
},
[fetchNextPage, isFetching, hasMore]
)
const virtualizer = useVirtualizer({
count: flatData.length,
estimateSize: () => 48, // Estimate row height
getScrollElement: () => tableParentRef.current,
overscan: 5, // Number of items to render outside the visible area
})
//a check on mount and after a fetch to see if the table is already scrolled to the bottom and immediately needs to fetch more data
useEffect(() => {
fetchOnBottomReached(tableParentRef.current)
}, [fetchOnBottomReached])
async function confirmDownload(record: RemoteZimFileEntry) {
openModal(
<StyledModal
title="Confirm Download?"
onConfirm={() => {
downloadFile(record)
closeAllModals()
}}
onCancel={closeAllModals}
open={true}
confirmText="Download"
cancelText="Cancel"
confirmVariant="primary"
>
<p className="text-gray-700">
Are you sure you want to download <strong>{record.title}</strong>? It may take some time
for it to be available depending on the file size and your internet connection.
</p>
</StyledModal>,
'confirm-download-file-modal'
)
}
async function downloadFile(record: RemoteZimFileEntry) {
try {
await api.downloadRemoteZimFile(record.download_url)
} catch (error) {
console.error('Error downloading file:', error)
}
}
return (
<SettingsLayout>
<Head title="ZIM Remote Explorer | Project N.O.M.A.D." />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-4">ZIM Remote Explorer</h1>
<p className="text-gray-500 mb-2">
Browse and download remote ZIM files from the Kiwix repository!
</p>
<StyledTable<RemoteZimFileEntry & { actions?: any }>
data={flatData.map((i, idx) => {
const row = virtualizer.getVirtualItems().find((v) => v.index === idx)
return {
...i,
height: `${row?.size || 48}px`, // Use the size from the virtualizer
translateY: row?.start || 0,
}
})}
ref={tableParentRef}
loading={isLoading}
columns={[
{
accessor: 'title',
},
{
accessor: 'author',
},
{
accessor: 'summary',
},
{
accessor: 'updated',
render(record) {
return new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
}).format(new Date(record.updated))
},
},
{
accessor: 'size_bytes',
render(record) {
return formatBytes(record.size_bytes)
},
},
{
accessor: 'actions',
render(record, index) {
return (
<div className="flex space-x-2">
<StyledButton
icon={'ArrowDownTrayIcon'}
onClick={() => {
confirmDownload(record)
}}
>
Download
</StyledButton>
</div>
)
},
},
]}
className="relative overflow-x-auto overflow-y-auto h-[600px] w-full "
tableBodyStyle={{
position: 'relative',
height: `${virtualizer.getTotalSize()}px`,
}}
containerProps={{
onScroll: (e) => fetchOnBottomReached(e.currentTarget as HTMLDivElement),
}}
compact
rowLines
/>
</main>
</div>
</SettingsLayout>
)
}

View File

@ -27,6 +27,8 @@
"@markdoc/markdoc": "^0.5.2", "@markdoc/markdoc": "^0.5.2",
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.34.0",
"@tailwindcss/vite": "^4.1.10", "@tailwindcss/vite": "^4.1.10",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-virtual": "^3.13.12",
"@vinejs/vine": "^3.0.1", "@vinejs/vine": "^3.0.1",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
@ -34,6 +36,7 @@
"better-sqlite3": "^12.1.1", "better-sqlite3": "^12.1.1",
"dockerode": "^4.0.7", "dockerode": "^4.0.7",
"edge.js": "^6.2.1", "edge.js": "^6.2.1",
"fast-xml-parser": "^5.2.5",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"mysql2": "^3.14.1", "mysql2": "^3.14.1",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
@ -54,6 +57,7 @@
"@japa/plugin-adonisjs": "^4.0.0", "@japa/plugin-adonisjs": "^4.0.0",
"@japa/runner": "^4.2.0", "@japa/runner": "^4.2.0",
"@swc/core": "1.11.24", "@swc/core": "1.11.24",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@types/dockerode": "^3.3.41", "@types/dockerode": "^3.3.41",
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/node": "^22.15.18", "@types/node": "^22.15.18",
@ -3646,6 +3650,49 @@
"vite": "^5.2.0 || ^6" "vite": "^5.2.0 || ^6"
} }
}, },
"node_modules/@tanstack/eslint-plugin-query": {
"version": "5.81.2",
"resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.81.2.tgz",
"integrity": "sha512-h4k6P6fm5VhKP5NkK+0TTVpGGyKQdx6tk7NYYG7J7PkSu7ClpLgBihw7yzK8N3n5zPaF3IMyErxfoNiXWH/3/A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/utils": "^8.18.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.81.5",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.81.5.tgz",
"integrity": "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.81.5",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz",
"integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.81.5"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": { "node_modules/@tanstack/react-virtual": {
"version": "3.13.12", "version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
@ -6338,6 +6385,24 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-xml-parser": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^2.1.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fastest-levenshtein": { "node_modules/fastest-levenshtein": {
"version": "1.0.16", "version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
@ -10333,6 +10398,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/strnum": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz",
"integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/strtok3": { "node_modules/strtok3": {
"version": "10.3.1", "version": "10.3.1",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.1.tgz", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.1.tgz",

View File

@ -41,6 +41,7 @@
"@japa/plugin-adonisjs": "^4.0.0", "@japa/plugin-adonisjs": "^4.0.0",
"@japa/runner": "^4.2.0", "@japa/runner": "^4.2.0",
"@swc/core": "1.11.24", "@swc/core": "1.11.24",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@types/dockerode": "^3.3.41", "@types/dockerode": "^3.3.41",
"@types/luxon": "^3.6.2", "@types/luxon": "^3.6.2",
"@types/node": "^22.15.18", "@types/node": "^22.15.18",
@ -72,6 +73,8 @@
"@markdoc/markdoc": "^0.5.2", "@markdoc/markdoc": "^0.5.2",
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.34.0",
"@tailwindcss/vite": "^4.1.10", "@tailwindcss/vite": "^4.1.10",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-virtual": "^3.13.12",
"@vinejs/vine": "^3.0.1", "@vinejs/vine": "^3.0.1",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
@ -79,6 +82,7 @@
"better-sqlite3": "^12.1.1", "better-sqlite3": "^12.1.1",
"dockerode": "^4.0.7", "dockerode": "^4.0.7",
"edge.js": "^6.2.1", "edge.js": "^6.2.1",
"fast-xml-parser": "^5.2.5",
"luxon": "^3.6.1", "luxon": "^3.6.1",
"mysql2": "^3.14.1", "mysql2": "^3.14.1",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",

View File

@ -10,6 +10,7 @@ import DocsController from '#controllers/docs_controller'
import HomeController from '#controllers/home_controller' import HomeController from '#controllers/home_controller'
import SettingsController from '#controllers/settings_controller' import SettingsController from '#controllers/settings_controller'
import SystemController from '#controllers/system_controller' import SystemController from '#controllers/system_controller'
import ZimController from '#controllers/zim_controller'
import router from '@adonisjs/core/services/router' import router from '@adonisjs/core/services/router'
import transmit from '@adonisjs/transmit/services/main' import transmit from '@adonisjs/transmit/services/main'
@ -22,6 +23,8 @@ router.on('/about').renderInertia('about')
router.group(() => { router.group(() => {
router.get('/system', [SettingsController, 'system']) router.get('/system', [SettingsController, 'system'])
router.get('/apps', [SettingsController, 'apps']) router.get('/apps', [SettingsController, 'apps'])
router.get('/zim', [SettingsController, 'zim'])
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
}).prefix('/settings') }).prefix('/settings')
router.group(() => { router.group(() => {
@ -43,3 +46,10 @@ router.group(() => {
router.post('/services/install', [SystemController, 'installService']) router.post('/services/install', [SystemController, 'installService'])
router.post('/simulate-sse', [SystemController, 'simulateSSE']) router.post('/simulate-sse', [SystemController, 'simulateSSE'])
}).prefix('/api/system') }).prefix('/api/system')
router.group(() => {
router.get('/list', [ZimController, 'list'])
router.get('/list-remote', [ZimController, 'listRemote'])
router.post('/download-remote', [ZimController, 'downloadRemote'])
router.delete('/:key', [ZimController, 'delete'])
}).prefix('/api/zim')

68
admin/types/zim.ts Normal file
View File

@ -0,0 +1,68 @@
export type ZimFilesEntry =
{
type: 'file'
key: string;
name: string;
} | {
type: 'directory';
prefix: string;
name: string;
}
export type ListZimFilesResponse = {
files: ZimFilesEntry[]
next?: string
}
export type ListRemoteZimFilesResponse = {
items: RemoteZimFileEntry[];
has_more: boolean;
total_count: number;
}
export type RawRemoteZimFileEntry = {
id: string;
title: string;
updated: string;
summary: string;
language: string;
name: string;
flavour: string;
category: string;
tags: string;
articleCount: number;
mediaCount: number;
link: Record<string, string>[];
author: {
name: string
};
publisher: {
name: string;
};
'dc:issued': string;
}
export type RawListRemoteZimFilesResponse = {
'?xml': string;
feed: {
id: string;
link: string[];
title: string;
updated: string;
totalResults: number;
startIndex: number;
itemsPerPage: number;
entry: any[];
}
}
export type RemoteZimFileEntry = {
id: string;
title: string;
updated: string;
summary: string;
size_bytes: number;
download_url: string;
author: string;
file_name: string;
}

10
admin/util/zim.ts Normal file
View File

@ -0,0 +1,10 @@
import { RawListRemoteZimFilesResponse, RawRemoteZimFileEntry } from "../types/zim.js";
export function isRawListRemoteZimFilesResponse(obj: any): obj is RawListRemoteZimFilesResponse {
return obj && typeof obj === 'object' && 'feed' in obj && 'entry' in obj.feed && Array.isArray(obj.feed.entry);
}
export function isRawRemoteZimFileEntry(obj: any): obj is RawRemoteZimFileEntry {
return obj && typeof obj === 'object' && 'id' in obj && 'title' in obj && 'summary' in obj;
}

View File

@ -36,7 +36,8 @@ WAIT_FOR_IT_SCRIPT_URL="https://raw.githubusercontent.com/vishnubob/wait-for-it/
script_option_debug='true' script_option_debug='true'
install_actions='' install_actions=''
nomad_dir="/opt/project-nomad" nomad_dir="/opt/project-nomad"
accepted_terms='false'
local_ip=''
################################################################################################################################################################################################### ###################################################################################################################################################################################################
# # # #
@ -156,26 +157,53 @@ ensure_docker_installed() {
fi fi
} }
ensure_whiptail_installed() {
if ! command -v whiptail &> /dev/null; then
header_red
echo -e "${GRAY_R}#${RESET} whiptail is not installed, attempting to install it...\\n"
if command -v apt &> /dev/null; then
apt update && apt install -y whiptail
else
echo -e "${RED}#${RESET} Unsupported package manager. Please install whiptail manually."
exit 1
fi
fi
}
get_install_confirmation(){ get_install_confirmation(){
if whiptail --title "$WHIPTAIL_TITLE" --yesno "This script will install/update Project N.O.M.A.D. and its dependencies on your machine.\\n\\n Are you sure you want to continue?\\n\\nInfo:\\nVersion 1.0.0\\nAuthor: Crosstalk Solutions, LLC\\nWebsite: https://crosstalksolutions.com" 15 70; then # Make this a regular bash prompt instead of whiptail
read -p "This script will install/update Project N.O.M.A.D. and its dependencies on your machine. Are you sure you want to continue? (y/n): " choice
case "$choice" in
y|Y )
echo -e "${GREEN}#${RESET} User chose to continue with the installation." echo -e "${GREEN}#${RESET} User chose to continue with the installation."
else ;;
n|N )
echo -e "${RED}#${RESET} User chose not to continue with the installation." echo -e "${RED}#${RESET} User chose not to continue with the installation."
exit 0 exit 0
fi ;;
* )
echo "Invalid Response"
echo "User chose not to continue with the installation."
exit 0
;;
esac
}
accept_terms() {
printf "\n\n"
echo "License Agreement & Terms of Use"
echo "__________________________"
printf "\n\n"
echo "Copyright 2025 Crosstalk Solutions, LLC"
printf "\n"
echo "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:"
printf "\n"
echo "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software."
printf "\n"
echo "THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
echo -e "\n\n"
read -p "I have read and accept License Agreement & Terms of Use (y/n)? " choice
case "$choice" in
y|Y )
accepted_terms='true'
;;
n|N )
echo "License Agreement & Terms of Use not accepted. Installation cannot continue."
exit 1
;;
* )
echo "Invalid Response"
echo "License Agreement & Terms of Use not accepted. Installation cannot continue."
exit 1
;;
esac
} }
get_install_directory() { get_install_directory() {
@ -255,6 +283,21 @@ start_management_containers() {
echo -e "${GREEN}#${RESET} Management containers started successfully.\\n" echo -e "${GREEN}#${RESET} Management containers started successfully.\\n"
} }
get_local_ip() {
local_ip_address=$(hostname -I | awk '{print $1}')
if [[ -z "$local_ip_address" ]]; then
echo -e "${RED}#${RESET} Unable to determine local IP address. Please check your network configuration."
# Don't exit if we can't determine the local IP address, it's not critical for the installation
fi
}
success_message() {
echo -e "${GREEN}#${RESET} Project N.O.M.A.D installation completed successfully!\\n"
echo -e "${GREEN}#${RESET} Installation files are located at /opt/project-nomad\\n\n"
echo -e "${GREEN}#${RESET} You can now access the management interface at http://localhost:8080 or http://${local_ip_address}:8080\\n"
echo -e "${GREEN}#${RESET} Thank you for supporting Project N.O.M.A.D!\\n"
}
################################################################################################################################################################################################### ###################################################################################################################################################################################################
# # # #
# Main Script # # Main Script #
@ -266,10 +309,10 @@ check_is_debian_based
check_is_bash check_is_bash
check_has_sudo check_has_sudo
check_is_debug_mode check_is_debug_mode
ensure_whiptail_installed
# Main install # Main install
get_install_confirmation get_install_confirmation
accept_terms
ensure_docker_installed ensure_docker_installed
#get_install_directory #get_install_directory
create_nomad_directory create_nomad_directory
@ -277,6 +320,8 @@ download_wait_for_it_script
download_entrypoint_script download_entrypoint_script
download_management_compose_file download_management_compose_file
start_management_containers start_management_containers
get_local_ip
success_message
# free_space_check() { # free_space_check() {
# if [[ "$(df -B1 / | awk 'NR==2{print $4}')" -le '5368709120' ]]; then # if [[ "$(df -B1 / | awk 'NR==2{print $4}')" -le '5368709120' ]]; then

View File

@ -7,7 +7,6 @@ services:
ports: ports:
- "8080:8080" - "8080:8080"
volumes: volumes:
- /opt/project-nomad/data:/data
- /opt/project-nomad/storage:/app/storage - /opt/project-nomad/storage:/app/storage
- /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon - /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon
- ./entrypoint.sh:/usr/local/bin/entrypoint.sh - ./entrypoint.sh:/usr/local/bin/entrypoint.sh