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
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*
```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
```

View File

@ -14,7 +14,8 @@ export default class HomeController {
}
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', {
system: {
services

View File

@ -18,11 +18,19 @@ export default class SettingsController {
}
async apps({ inertia }: HttpContext) {
const services = await this.systemService.getServices();
const services = await this.systemService.getServices({ installedOnly: false });
return inertia.render('settings/apps', {
system: {
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
) { }
async getServices({ response }: HttpContext) {
const services = await this.systemService.getServices();
response.send(services);
async getServices({ }: HttpContext) {
return await this.systemService.getServices({ installedOnly: true });
}
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,10 +1,11 @@
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'
export default class Service extends BaseModel {
static namingStrategy = new SnakeCaseNamingStrategy()
@column({ isPrimary: true })
@column({ isPrimary: true })
declare id: number
@column()
@ -14,16 +15,31 @@ export default class Service extends BaseModel {
declare container_image: string
@column()
declare container_command: string
declare container_command: string | null
@column()
declare container_config: string | null
@column()
@column({
serialize(value) {
return Boolean(value)
},
})
declare installed: boolean
@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()
declare metadata: string | null
@ -33,4 +49,15 @@ export default class Service extends BaseModel {
@column.dateTime({ autoCreate: true, autoUpdate: true })
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 }> {
const service = await Service.findBy('service_name', serviceName);
const service = await Service.query().where('service_name', serviceName).first();
if (!service) {
return {
success: false,
@ -33,29 +33,17 @@ export class DockerService {
// 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
// or if the container was removed manually without updating the service status.
if (await this._checkIfServiceContainerExists(serviceName)) {
const removeResult = await this._removeServiceContainer(serviceName);
if (!removeResult.success) {
return {
success: false,
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}`,
};
}
}
// if (await this._checkIfServiceContainerExists(serviceName)) {
// const removeResult = await this._removeServiceContainer(serviceName);
// if (!removeResult.success) {
// return {
// success: false,
// message: `Failed to remove existing container for service ${serviceName}: ${removeResult.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
return {
@ -72,56 +60,75 @@ export class DockerService {
* @param serviceName
* @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) {
transmit.broadcast('service-installation', {
service_name: service.service_name,
timestamp: new Date().toISOString(),
status,
message,
let dependencies = [];
if (service.depends_on) {
const dependency = await Service.query().where('service_name', service.depends_on).first();
if (dependency) {
dependencies.push(dependency);
}
}
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
const pullStream = await this.docker.pull(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));
this._broadcastAndLog(service.service_name, 'pulled', `Docker image ${service.container_image} pulled successfully.`);
this._broadcastAndLog(service.service_name, 'creating', `Creating Docker container for service ${service.service_name}...`);
const container = await this.docker.createContainer({
Image: service.container_image,
name: service.service_name,
HostConfig: containerConfig?.HostConfig || undefined,
WorkingDir: containerConfig?.WorkingDir || 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
});
logger.info(`[DockerService] [${service.service_name}] ${status}: ${message}`);
this._broadcastAndLog(service.service_name, 'created', `Docker container for service ${service.service_name} created successfully.`);
if (service.service_name === 'kiwix-serve') {
await this._runPreinstallActions__KiwixServe();
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.`);
}
this._broadcastAndLog(service.service_name, 'starting', `Starting Docker container for service ${service.service_name}...`);
await container.start();
this._broadcastAndLog(service.service_name, 'started', `Docker container for service ${service.service_name} started successfully.`);
this._broadcastAndLog(service.service_name, 'finalizing', `Finalizing installation of service ${service.service_name}...`);
service.installed = true;
await service.save();
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}`);
}
sendBroadcastAndLog('initializing', '');
// Start pulling the Docker image and wait for it to complete
const pullStream = await this.docker.pull(service.container_image);
sendBroadcastAndLog('pulling', `Pulling Docker image ${service.container_image}...`);
await new Promise(res => this.docker.modem.followProgress(pullStream, res));
sendBroadcastAndLog('pulled', `Docker image ${service.container_image} pulled successfully.`);
sendBroadcastAndLog('creating', `Creating Docker container for service ${service.service_name}...`);
const container = await this.docker.createContainer({
Image: service.container_image,
Cmd: service.container_command.split(' '),
name: service.service_name,
HostConfig: containerConfig?.HostConfig || undefined,
WorkingDir: containerConfig?.WorkingDir || undefined,
ExposedPorts: containerConfig?.ExposedPorts || undefined,
});
sendBroadcastAndLog('created', `Docker container for service ${service.service_name} created successfully.`);
if (service.service_name === 'kiwix-serve') {
sendBroadcastAndLog('preinstall', `Running pre-install actions for Kiwix Serve...`);
await this._runPreinstallActions__KiwixServe();
sendBroadcastAndLog('preinstall-complete', `Pre-install actions for Kiwix Serve completed successfully.`);
}
sendBroadcastAndLog('starting', `Starting Docker container for service ${service.service_name}...`);
await container.start();
sendBroadcastAndLog('started', `Docker container for service ${service.service_name} started successfully.`);
sendBroadcastAndLog('finalizing', `Finalizing installation of service ${service.service_name}...`);
service.installed = true;
await service.save();
sendBroadcastAndLog('completed', `Service ${service.service_name} installation completed successfully.`);
}
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.
* 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 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, {
responseType: 'stream',
});
@ -171,10 +181,89 @@ export class DockerService {
});
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> {

View File

@ -1,7 +1,15 @@
import Service from "#models/service"
export class SystemService {
async getServices(): Promise<{ id: number; service_name: string; installed: boolean }[]> {
return await Service.query().orderBy('service_name', 'asc').select('id', 'service_name', 'installed', 'ui_location');
async getServices({
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() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('service_name')
table.string('container_image')
table.string('container_command')
table.string('service_name').unique().notNullable()
table.string('container_image').notNullable()
table.string('container_command').nullable()
table.json('container_config').nullable()
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.json('metadata').nullable()
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\":{}}}",
ui_location: '8090',
installed: false,
is_dependency_service: false,
depends_on: null,
},
{
service_name: 'openstreetmap',
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\"}]}}}",
ui_location: '9000',
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() {

View File

@ -1,2 +1,3 @@
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 { TransmitProvider } from 'react-adonis-transmit'
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 queryClient = new QueryClient()
// Patch the global crypto object for non-HTTPS/localhost contexts
if (!window.crypto?.randomUUID) {
@ -20,7 +22,7 @@ if (!window.crypto?.randomUUID) {
}
createInertiaApp({
progress: { color: '#5468FF' },
progress: { color: '#424420' },
title: (title) => `${title} - ${appName}`,
@ -30,11 +32,13 @@ createInertiaApp({
setup({ el, App, props }) {
createRoot(el).render(
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
<ModalsProvider>
<App {...props} />
</ModalsProvider>
</TransmitProvider>
<QueryClientProvider client={queryClient}>
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
<ModalsProvider>
<App {...props} />
</ModalsProvider>
</TransmitProvider>
</QueryClientProvider>
)
},
})

View File

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

View File

@ -1,17 +1,20 @@
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
import StyledButton from './StyledButton'
import StyledButton, { StyledButtonProps } from './StyledButton'
import React from 'react'
import classNames from '~/lib/classNames'
interface StyledModalProps {
onClose?: () => void
title: string
cancelText?: string
confirmText?: string
confirmVariant?: StyledButtonProps['variant']
open: boolean
onCancel?: () => void
onConfirm?: () => void
children: React.ReactNode
icon?: React.ReactNode
large?: boolean
}
const StyledModal: React.FC<StyledModalProps> = ({
@ -21,9 +24,11 @@ const StyledModal: React.FC<StyledModalProps> = ({
onClose,
cancelText = 'Cancel',
confirmText = 'Confirm',
confirmVariant = 'action',
onCancel,
onConfirm,
icon,
large = false,
}) => {
return (
<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"
/>
<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
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>
{icon && <div className="flex items-center justify-center">{icon}</div>}
@ -49,26 +62,30 @@ const StyledModal: React.FC<StyledModalProps> = ({
<DialogTitle as="h3" className="text-base font-semibold text-gray-900">
{title}
</DialogTitle>
<div className="mt-2">{children}</div>
<div className="mt-2 !h-fit">{children}</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<StyledButton
variant="secondary"
onClick={() => {
if (onCancel) onCancel()
}}
>
{cancelText}
</StyledButton>
<StyledButton
variant="action"
onClick={() => {
if (onConfirm) onConfirm()
}}
>
{confirmText}
</StyledButton>
{cancelText && onCancel && (
<StyledButton
variant="secondary"
onClick={() => {
if (onCancel) onCancel()
}}
>
{cancelText}
</StyledButton>
)}
{confirmText && onConfirm && (
<StyledButton
variant={confirmVariant}
onClick={() => {
if (onConfirm) onConfirm()
}}
>
{confirmText}
</StyledButton>
)}
</div>
</DialogPanel>
</div>

View File

@ -1,10 +1,14 @@
import { capitalizeFirstLetter } from '~/lib/util'
import classNames from '~/lib/classNames'
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
tableProps?: React.HTMLAttributes<HTMLTableElement>
tableRowStyle?: React.CSSProperties
tableBodyClassName?: string
tableBodyStyle?: React.CSSProperties
data?: T[]
noDataText?: string
onRowClick?: (record: T) => void
@ -16,55 +20,82 @@ interface StyledTableProps<T = Record<string, unknown>> {
}[]
className?: string
rowLines?: boolean
ref?: RefObject<HTMLDivElement | null>
containerProps?: React.HTMLAttributes<HTMLDivElement>
compact?: boolean
}
function StyledTable<T>({
function StyledTable<T extends { [key: string]: any }>({
loading = false,
tableProps = {},
tableRowStyle = {},
tableBodyClassName = '',
tableBodyStyle = {},
data = [],
noDataText = 'No records found',
onRowClick,
columns = [],
className = '',
ref,
containerProps = {},
rowLines = true,
compact = false,
}: StyledTableProps<T>) {
const { className: tableClassName, ...restTableProps } = tableProps
const leftPadding = compact ? 'pl-2' : 'pl-4 sm:pl-6'
return (
<div
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',
className
)}
ref={ref}
{...containerProps}
>
<table className="min-w-full" {...restTableProps}>
<thead>
<table className="min-w-full overflow-auto" {...restTableProps}>
<thead className='border-b border-gray-200 '>
<tr>
{columns.map((column, index) => (
<th
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())}
</th>
))}
</tr>
</thead>
<tbody>
<tbody className={tableBodyClassName} style={tableBodyStyle}>
{!loading &&
data.length !== 0 &&
data.map((record, recordIdx) => (
<tr
key={crypto.randomUUID()}
data-index={'index' in record ? record.index : recordIdx}
key={record.id || recordIdx}
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) => (
<td
key={index}
className={classNames(
recordIdx === 0 ? '' : 'border-t border-transparent',
'relative py-4 pl-4 pr-3 text-sm sm:pl-6 whitespace-nowrap max-w-72 truncate break-words',
column.className || ''
'relative text-sm whitespace-nowrap max-w-72 truncate break-words text-left',
column.className || '',
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
)}
>
{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'
const navigation = [
{ 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 },
]

View File

@ -1,4 +1,5 @@
import axios from "axios";
import { ListRemoteZimFilesResponse, ListZimFilesResponse, RemoteZimFileEntry } from "../../types/zim";
class API {
private client;
@ -32,6 +33,39 @@ class API {
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();

View File

@ -14,7 +14,7 @@ export function getServiceLink(ui_location: string): string {
const parsedPort = parseInt(ui_location, 10);
if (!isNaN(parsedPort)) {
// 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
return `/${ui_location}`;

View File

@ -1,32 +1,41 @@
import axios from "axios";
export function capitalizeFirstLetter(str?: string | null): string {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1);
if (!str) return "";
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> {
try {
const response = await axios.get('https://1.1.1.1/cdn-cgi/trace', {
timeout: 5000,
headers: {
'Cache-Control': 'no-cache',
}
});
return response.status === 200;
} catch (error) {
console.error("Error testing internet connection:", error);
return false;
}
try {
const response = await axios.get('https://1.1.1.1/cdn-cgi/trace', {
timeout: 5000,
headers: {
'Cache-Control': 'no-cache',
}
});
return response.status === 200;
} catch (error) {
console.error("Error testing internet connection:", error);
return false;
}
}
export function generateRandomString(length: number): string {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
export function generateUUID(): string {
@ -45,5 +54,5 @@ export function generateUUID(): string {
arr[8] = (arr[8] & 0x3f) | 0x80; // Variant bits
const hex = Array.from(arr, byte => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

View File

@ -1,59 +1,9 @@
import {
IconBook,
IconBrandWikipedia,
IconCalculator,
IconHelp,
IconMapRoute,
IconMessageCircleSearch,
IconPlus,
IconSettings,
IconWifiOff,
} from '@tabler/icons-react'
import { Head, Link } from '@inertiajs/react'
import { IconHelp, 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'
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 = [
{
label: 'Install Apps',
@ -87,6 +37,8 @@ export default function Home(props: {
}
}) {
const items = []
console.log(props.system.services)
props.system.services.map((service) => {
items.push({
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",
"@tabler/icons-react": "^3.34.0",
"@tailwindcss/vite": "^4.1.10",
"@tanstack/react-query": "^5.81.5",
"@tanstack/react-virtual": "^3.13.12",
"@vinejs/vine": "^3.0.1",
"@vitejs/plugin-react": "^4.6.0",
"autoprefixer": "^10.4.21",
@ -34,6 +36,7 @@
"better-sqlite3": "^12.1.1",
"dockerode": "^4.0.7",
"edge.js": "^6.2.1",
"fast-xml-parser": "^5.2.5",
"luxon": "^3.6.1",
"mysql2": "^3.14.1",
"pino-pretty": "^13.0.0",
@ -54,6 +57,7 @@
"@japa/plugin-adonisjs": "^4.0.0",
"@japa/runner": "^4.2.0",
"@swc/core": "1.11.24",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@types/dockerode": "^3.3.41",
"@types/luxon": "^3.6.2",
"@types/node": "^22.15.18",
@ -3646,6 +3650,49 @@
"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": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
@ -6338,6 +6385,24 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"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": {
"version": "1.0.16",
"resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz",
@ -10333,6 +10398,18 @@
"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": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.1.tgz",

View File

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

View File

@ -10,6 +10,7 @@ import DocsController from '#controllers/docs_controller'
import HomeController from '#controllers/home_controller'
import SettingsController from '#controllers/settings_controller'
import SystemController from '#controllers/system_controller'
import ZimController from '#controllers/zim_controller'
import router from '@adonisjs/core/services/router'
import transmit from '@adonisjs/transmit/services/main'
@ -22,6 +23,8 @@ router.on('/about').renderInertia('about')
router.group(() => {
router.get('/system', [SettingsController, 'system'])
router.get('/apps', [SettingsController, 'apps'])
router.get('/zim', [SettingsController, 'zim'])
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
}).prefix('/settings')
router.group(() => {
@ -43,3 +46,10 @@ router.group(() => {
router.post('/services/install', [SystemController, 'installService'])
router.post('/simulate-sse', [SystemController, 'simulateSSE'])
}).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'
install_actions=''
nomad_dir="/opt/project-nomad"
accepted_terms='false'
local_ip=''
###################################################################################################################################################################################################
# #
@ -156,26 +157,53 @@ ensure_docker_installed() {
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(){
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."
else
;;
n|N )
echo -e "${RED}#${RESET} User chose not to continue with the installation."
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() {
@ -255,6 +283,21 @@ start_management_containers() {
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 #
@ -266,10 +309,10 @@ check_is_debian_based
check_is_bash
check_has_sudo
check_is_debug_mode
ensure_whiptail_installed
# Main install
get_install_confirmation
accept_terms
ensure_docker_installed
#get_install_directory
create_nomad_directory
@ -277,6 +320,8 @@ download_wait_for_it_script
download_entrypoint_script
download_management_compose_file
start_management_containers
get_local_ip
success_message
# free_space_check() {
# if [[ "$(df -B1 / | awk 'NR==2{print $4}')" -le '5368709120' ]]; then

View File

@ -7,7 +7,6 @@ services:
ports:
- "8080:8080"
volumes:
- /opt/project-nomad/data:/data
- /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
- ./entrypoint.sh:/usr/local/bin/entrypoint.sh