mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
feat: openwebui+ollama and zim management
This commit is contained in:
parent
39d75c9cdf
commit
3b81e00a69
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
47
admin/app/controllers/zim_controller.ts
Normal file
47
admin/app/controllers/zim_controller.ts
Normal 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
139
admin/app/services/zim_service.ts
Normal file
139
admin/app/services/zim_service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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'])
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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}`;
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
100
admin/inertia/pages/settings/zim/index.tsx
Normal file
100
admin/inertia/pages/settings/zim/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
173
admin/inertia/pages/settings/zim/remote-explorer.tsx
Normal file
173
admin/inertia/pages/settings/zim/remote-explorer.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
77
admin/package-lock.json
generated
77
admin/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
68
admin/types/zim.ts
Normal 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
10
admin/util/zim.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user