diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a2195b9..00aac1e 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -57,4 +57,6 @@ jobs: context: ./admin file: ./admin/Dockerfile push: true - tags: ghcr.io/crosstalk-solutions/project-nomad-admin:${{ inputs.version }} \ No newline at end of file + tags: | + ghcr.io/crosstalk-solutions/project-nomad-admin:${{ inputs.version }} + ghcr.io/crosstalk-solutions/project-nomad-admin:latest diff --git a/README.md b/README.md index b131f55..ec67489 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ sudo bash install_nomad.sh Project N.O.M.A.D. is now installed on your device! Open a browser and navigate to `http://localhost:8080` (or `http://DEVICE_IP:8080`) to start exploring! ## How It Works -From a technical standpoint, N.O.M.A.D. is primarily a management UI and API that orchestrates a goodie basket of containerized offline archive tools and resources such as +From a technical standpoint, N.O.M.A.D. is primarily a management UI ("Command Center") and API that orchestrates a goodie basket of containerized offline archive tools and resources such as [Kiwix](https://kiwix.org/), [OpenStreetMap](https://www.openstreetmap.org/), [Ollama](https://ollama.com/), [OpenWebUI](https://openwebui.com/), and more. By abstracting the installation of each of these awesome tools, N.O.M.A.D. makes getting your offline survival computer up and running a breeze! N.O.M.A.D. also includes some additional built-in handy tools, such as a ZIM library managment interface, calculators, and more. @@ -57,3 +57,25 @@ To test internet connectivity, N.O.M.A.D. attempts to make a request to Cloudfla ## About Security By design, Project N.O.M.A.D. is intended to be open and available without hurdles - it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access it's resources), you can block/open ports to control which services are exposed. + +# Helper Scripts +Once installed, Project N.O.M.A.D. has a few helper scripts should you ever need to troubleshoot issues or perform maintenance that can't be done through the Command Center. All of these scripts are found in Project N.O.M.A.D.'s install directory, `/opt/project-nomad` + +### + +###### Start Script - Starts all installed project containers +```bash +sudo bash /opt/project-nomad/start_nomad.sh +``` +### + +###### Stop Script - Stops all installed project containers +```bash +sudo bash /opt/project-nomad/start_nomad.sh +``` +### + +###### Update Script - Attempts to pull the latest images for the Command Center and its dependencies (i.e. mysql) and recreate the containers. Note: this *only* updates the Command Center containers. It does not update the installable application containers - that should be done through the Command Center UI +```bash +sudo bash /opt/project-nomad/update_nomad.sh +``` \ No newline at end of file diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index 0307f4e..f085340 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -1,6 +1,6 @@ import { DockerService } from '#services/docker_service'; import { SystemService } from '#services/system_service' -import { installServiceValidator } from '#validators/system'; +import { affectServiceValidator, installServiceValidator } from '#validators/system'; import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' @@ -26,6 +26,17 @@ export default class SystemController { } } + async affectService({ request, response }: HttpContext) { + const payload = await request.validateUsing(affectServiceValidator); + const result = await this.dockerService.affectContainer(payload.service_name, payload.action); + if (!result) { + response.internalServerError({ error: 'Failed to affect service' }); + return; + } + response.send({ success: result.success, message: result.message }); + } + + async simulateSSE({ response }: HttpContext) { this.dockerService.simulateSSE(); response.send({ message: 'Started simulation of SSE' }) diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index fcce9ad..429b882 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -5,6 +5,7 @@ import axios from 'axios'; import logger from '@adonisjs/core/services/logger' import transmit from '@adonisjs/transmit/services/main' import { inject } from "@adonisjs/core"; +import { ServiceStatus } from "../../types/services.js"; @inject() export class DockerService { @@ -18,6 +19,108 @@ export class DockerService { this.docker = new Docker({ socketPath: '/var/run/docker.sock' }); } + async affectContainer(serviceName: string, action: 'start' | 'stop' | 'restart'): Promise<{ success: boolean; message: string }> { + try { + const service = await Service.query().where('service_name', serviceName).first(); + if (!service || !service.installed) { + return { + success: false, + message: `Service ${serviceName} not found or not installed`, + }; + } + + const containers = await this.docker.listContainers({ all: true }); + const container = containers.find(c => c.Names.includes(`/${serviceName}`)); + if (!container) { + return { + success: false, + message: `Container for service ${serviceName} not found`, + }; + } + + const dockerContainer = this.docker.getContainer(container.Id); + if (action === 'stop') { + await dockerContainer.stop(); + return { + success: true, + message: `Service ${serviceName} stopped successfully`, + }; + } + + if (action === 'restart') { + await dockerContainer.restart(); + return { + success: true, + message: `Service ${serviceName} restarted successfully`, + }; + } + + if (action === 'start') { + if (container.State === 'running') { + return { + success: true, + message: `Service ${serviceName} is already running`, + }; + } + await dockerContainer.start(); + } + + return { + success: false, + message: `Invalid action: ${action}. Use 'start', 'stop', or 'restart'.`, + } + } catch (error) { + console.error(`Error starting service ${serviceName}: ${error.message}`); + return { + success: false, + message: `Failed to start service ${serviceName}: ${error.message}`, + }; + } + } + + async getServicesStatus(): Promise<{ + service_name: string; + status: ServiceStatus; + }[]> { + try { + const services = await Service.query().where('installed', true); + if (!services || services.length === 0) { + return []; + } + + const containers = await this.docker.listContainers({ all: true }); + const containerMap = new Map(); + containers.forEach(container => { + const name = container.Names[0].replace('/', ''); + if (name.startsWith('nomad_')) { + containerMap.set(name, container); + } + }); + + const getStatus = (state: string): ServiceStatus => { + switch (state) { + case 'running': + return 'running'; + case 'exited': + case 'created': + case 'paused': + return 'stopped'; + default: + return 'unknown'; + } + }; + + + return Array.from(containerMap.entries()).map(([name, container]) => ({ + service_name: name, + status: getStatus(container.State), + })); + } catch (error) { + console.error(`Error fetching services status: ${error.message}`); + return []; + } + } + async createContainerPreflight(serviceName: string): Promise<{ success: boolean; message: string }> { const service = await Service.query().where('service_name', serviceName).first(); if (!service) { diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index e738b8c..3cda3ee 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -1,15 +1,44 @@ import Service from "#models/service" +import { inject } from "@adonisjs/core"; +import { DockerService } from "#services/docker_service"; +import { ServiceStatus } from "../../types/services.js"; +@inject() export class SystemService { + constructor( + private dockerService: DockerService + ) {} async getServices({ installedOnly = true, }:{ installedOnly?: boolean - }): Promise<{ id: number; service_name: string; installed: boolean }[]> { + }): Promise<{ id: number; service_name: string; installed: boolean, status: ServiceStatus }[]> { 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; + + const services = await query; + if (!services || services.length === 0) { + return []; + } + + const statuses = await this.dockerService.getServicesStatus(); + + const toReturn = []; + + for (const service of services) { + const status = statuses.find(s => s.service_name === service.service_name); + toReturn.push({ + id: service.id, + service_name: service.service_name, + installed: service.installed, + status: status ? status.status : 'unknown', + ui_location: service.ui_location || '' + }); + } + + return toReturn; + } } \ No newline at end of file diff --git a/admin/app/validators/system.ts b/admin/app/validators/system.ts index df2a91a..8cace3d 100644 --- a/admin/app/validators/system.ts +++ b/admin/app/validators/system.ts @@ -2,4 +2,9 @@ import vine from '@vinejs/vine' export const installServiceValidator = vine.compile(vine.object({ service_name: vine.string().trim() -})) \ No newline at end of file +})); + +export const affectServiceValidator = vine.compile(vine.object({ + service_name: vine.string().trim(), + action: vine.enum(['start', 'stop', 'restart']) +})); \ No newline at end of file diff --git a/admin/inertia/app/app.tsx b/admin/inertia/app/app.tsx index 1ca0ce4..3b5911e 100644 --- a/admin/inertia/app/app.tsx +++ b/admin/inertia/app/app.tsx @@ -10,6 +10,7 @@ import { TransmitProvider } from 'react-adonis-transmit' import { generateUUID } from '~/lib/util' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import NotificationsProvider from '~/providers/NotificationProvider' const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.' const queryClient = new QueryClient() @@ -35,10 +36,12 @@ createInertiaApp({ createRoot(el).render( - - - - + + + + + + ) diff --git a/admin/inertia/context/NotificationContext.ts b/admin/inertia/context/NotificationContext.ts new file mode 100644 index 0000000..eec0954 --- /dev/null +++ b/admin/inertia/context/NotificationContext.ts @@ -0,0 +1,28 @@ +import { createContext, useContext } from "react"; + +export interface Notification { + message: string; + type: "error" | "success" | "info"; + duration?: number; // in milliseconds +} + +export interface NotificationContextType { + notifications: Notification[]; + addNotification: (notification: Notification) => void; + removeNotification: (id: string) => void; + removeAllNotifications: () => void; +} + +export const NotificationContext = createContext< + NotificationContextType | undefined +>(undefined); + +export const useNotifications = () => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error( + "useNotifications must be used within a NotificationProvider" + ); + } + return context; +}; diff --git a/admin/inertia/hooks/useErrorNotification.ts b/admin/inertia/hooks/useErrorNotification.ts new file mode 100644 index 0000000..12d2a0a --- /dev/null +++ b/admin/inertia/hooks/useErrorNotification.ts @@ -0,0 +1,14 @@ +// Helper hook to show error notifications +import { useNotifications } from '../context/NotificationContext'; + +const useErrorNotification = () => { + const { addNotification } = useNotifications(); + + const showError = (message: string) => { + addNotification({ message, type: 'error' }); + }; + + return { showError }; +}; + +export default useErrorNotification; diff --git a/admin/inertia/hooks/useInternetStatus.ts b/admin/inertia/hooks/useInternetStatus.ts new file mode 100644 index 0000000..21aee5a --- /dev/null +++ b/admin/inertia/hooks/useInternetStatus.ts @@ -0,0 +1,27 @@ +// Helper hook to check internet connection status +import { useQuery } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; +import { testInternetConnection } from '~/lib/util'; + +const useInternetStatus = () => { + const [isOnline, setIsOnline] = useState(false); + const { data } = useQuery({ + queryKey: ['internetStatus'], + queryFn: testInternetConnection, + refetchOnWindowFocus: false, // Don't refetch on window focus + refetchOnReconnect: false, // Refetch when the browser reconnects + refetchOnMount: false, // Don't refetch when the component mounts + retry: 2, // Retry up to 2 times on failure + staleTime: 1000 * 60 * 10, // Data is fresh for 10 minutes + }); + + // Update the online status when data changes + useEffect(() => { + if (data === undefined) return; // Avoid setting state on unmounted component + setIsOnline(data); + }, [data]); + + return { isOnline }; +}; + +export default useInternetStatus; \ No newline at end of file diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index e728a92..fca9e97 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -34,6 +34,16 @@ class API { } } + async affectService(service_name: string, action: "start" | "stop" | "restart") { + try { + const response = await this.client.post<{ success: boolean; message: string }>("/system/services/affect", { service_name, action }); + return response.data; + } catch (error) { + console.error("Error affecting service:", error); + throw error; + } + } + async listZimFiles() { return await this.client.get("/zim/list"); } diff --git a/admin/inertia/lib/util.ts b/admin/inertia/lib/util.ts index 65a4359..7e1d1b5 100644 --- a/admin/inertia/lib/util.ts +++ b/admin/inertia/lib/util.ts @@ -18,9 +18,6 @@ export async function testInternetConnection(): Promise { 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) { diff --git a/admin/inertia/pages/settings/apps.tsx b/admin/inertia/pages/settings/apps.tsx index bb23fba..2641419 100644 --- a/admin/inertia/pages/settings/apps.tsx +++ b/admin/inertia/pages/settings/apps.tsx @@ -10,12 +10,18 @@ import api from '~/lib/api' import { useEffect, useState } from 'react' import InstallActivityFeed, { InstallActivityFeedProps } from '~/components/InstallActivityFeed' import { useTransmit } from 'react-adonis-transmit' +import LoadingSpinner from '~/components/LoadingSpinner' +import useErrorNotification from '~/hooks/useErrorNotification' +import useInternetStatus from '~/hooks/useInternetStatus' export default function SettingsPage(props: { system: { services: ServiceSlim[] } }) { const { openModal, closeAllModals } = useModals() const { subscribe } = useTransmit() + const { showError } = useErrorNotification() + const { isOnline } = useInternetStatus() const [installActivity, setInstallActivity] = useState([]) const [isInstalling, setIsInstalling] = useState(false) + const [loading, setLoading] = useState(false) useEffect(() => { const unsubscribe = subscribe('service-installation', (data: any) => { @@ -70,6 +76,11 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] async function installService(serviceName: string) { try { + if (!isOnline) { + showError('You must have an internet connection to install services.') + return + } + setIsInstalling(true) const response = await api.installService(serviceName) if (!response.success) { @@ -77,11 +88,121 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[] } } catch (error) { console.error('Error installing service:', error) + showError(`Failed to install service: ${error.message || 'Unknown error'}`) } finally { setIsInstalling(false) } } + const AppActions = ({ record }: { record: ServiceSlim }) => { + if (!record) return null + if (!record.installed) { + return ( +
+ handleInstallService(record)} + disabled={isInstalling || !isOnline} + loading={isInstalling} + > + Install + +
+ ) + } + + async function handleAffectAction(action: 'start' | 'stop' | 'restart') { + try { + setLoading(true) + const response = await api.affectService(record.service_name, action) + if (!response.success) { + throw new Error(response.message) + } + + closeAllModals() + + setTimeout(() => { + setLoading(false) + window.location.reload() // Reload the page to reflect changes + }, 3000) // Add small delay to allow for the action to complete + } catch (error) { + console.error(`Error affecting service ${record.service_name}:`, error) + showError(`Failed to ${action} service: ${error.message || 'Unknown error'}`) + } + } + + return ( +
+ { + window.open(getServiceLink(record.ui_location || 'unknown'), '_blank') + }} + > + Open + + {record.status && record.status !== 'unknown' && ( + <> + { + openModal( + + handleAffectAction(record.status === 'running' ? 'stop' : 'start') + } + onCancel={closeAllModals} + open={true} + confirmText={record.status === 'running' ? 'Stop' : 'Start'} + cancelText="Cancel" + > +

+ Are you sure you want to {record.status === 'running' ? 'stop' : 'start'}{' '} + {record.service_name}? +

+
, + `${record.service_name}-affect-modal` + ) + }} + disabled={isInstalling} + > + {record.status === 'running' ? 'Stop' : 'Start'} +
+ {record.status === 'running' && ( + { + openModal( + handleAffectAction('restart')} + onCancel={closeAllModals} + open={true} + confirmText={'Restart'} + cancelText="Cancel" + > +

+ Are you sure you want to restart {record.service_name}? +

+
, + `${record.service_name}-affect-modal` + ) + }} + disabled={isInstalling} + > + Restart +
+ )} + + )} +
+ ) + } + return ( @@ -91,61 +212,41 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]

Manage the applications that are available in your Project N.O.M.A.D. instance.

- - className="font-semibold" - rowLines={true} - columns={[ - { accessor: 'service_name', title: 'Name' }, - { - accessor: 'ui_location', - title: 'Location', - render: (record) => ( - - {record.ui_location} - - ), - }, - { - accessor: 'installed', - title: 'Installed?', - render: (record) => (record.installed ? 'Yes' : 'No'), - }, - { - accessor: 'actions', - title: 'Actions', - render: (record) => ( -
- {record.installed ? ( - { - window.open(getServiceLink(record.ui_location), '_blank') - }} - > - Open - - ) : ( - handleInstallService(record)} - disabled={isInstalling} - loading={isInstalling} - > - Install - - )} -
- ), - }, - ]} - data={props.system.services} - /> + {loading && } + {!loading && ( + + className="font-semibold" + rowLines={true} + columns={[ + { accessor: 'service_name', title: 'Name' }, + { + accessor: 'ui_location', + title: 'Location', + render: (record) => ( + + {record.ui_location} + + ), + }, + { + accessor: 'installed', + title: 'Installed?', + render: (record) => (record.installed ? 'Yes' : 'No'), + }, + { + accessor: 'actions', + title: 'Actions', + render: (record) => , + }, + ]} + data={props.system.services} + /> + )} {installActivity.length > 0 && ( )} diff --git a/admin/inertia/providers/NotificationProvider.tsx b/admin/inertia/providers/NotificationProvider.tsx new file mode 100644 index 0000000..e12df7d --- /dev/null +++ b/admin/inertia/providers/NotificationProvider.tsx @@ -0,0 +1,77 @@ +import { useState } from 'react' +import { NotificationContext, Notification } from '../context/NotificationContext' +import { + CheckCircleIcon, + InformationCircleIcon, + ExclamationTriangleIcon, +} from '@heroicons/react/24/outline' + +const NotificationsProvider = ({ children }: { children: React.ReactNode }) => { + const [notifications, setNotifications] = useState<(Notification & { id: string })[]>([]) + + const addNotification = (newNotif: Notification) => { + const { message, type, duration = 5000 } = newNotif + const id = crypto.randomUUID() + setNotifications((prev) => [...prev, { id, message, type, duration }]) + + if (duration > 0) { + setTimeout(() => { + removeNotification(id) + }, duration) + } + } + + const removeNotification = (id: string) => { + setNotifications(notifications.filter((n) => n.id !== id)) + } + + const removeAllNotifications = () => { + setNotifications([]) + } + + const Icon = ({ type }: { type: string }) => { + switch (type) { + case 'error': + return + case 'success': + return + case 'info': + return + default: + return + } + } + + return ( + + {children} +
+ {notifications.map((notification) => ( +
removeNotification(notification.id)} + > +
+
+ +
+
+

{notification.message}

+
+
+
+ ))} +
+
+ ) +} + +export default NotificationsProvider diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 7508c5a..f6fd87b 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -43,6 +43,7 @@ router.group(() => { router.group(() => { router.get('/services', [SystemController, 'getServices']) + router.post('/services/affect', [SystemController, 'affectService']) router.post('/services/install', [SystemController, 'installService']) router.post('/simulate-sse', [SystemController, 'simulateSSE']) }).prefix('/api/system') diff --git a/admin/types/services.ts b/admin/types/services.ts index 1cd3734..d9df282 100644 --- a/admin/types/services.ts +++ b/admin/types/services.ts @@ -1,4 +1,5 @@ import Service from "#models/service"; -export type ServiceSlim = Pick; \ No newline at end of file +export type ServiceStatus = 'unknown' | 'running' | 'stopped'; +export type ServiceSlim = Pick & { status?: ServiceStatus }; \ No newline at end of file diff --git a/install/install_nomad.sh b/install/install_nomad.sh index 4ed783f..87d02e9 100644 --- a/install/install_nomad.sh +++ b/install/install_nomad.sh @@ -33,6 +33,7 @@ MANAGEMENT_COMPOSE_FILE_URL="https://raw.githubusercontent.com/Crosstalk-Solutio ENTRYPOINT_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/entrypoint.sh" START_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/start_nomad.sh" STOP_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/stop_nomad.sh" +UPDATE_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/master/install/update_nomad.sh" WAIT_FOR_IT_SCRIPT_URL="https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" script_option_debug='true' @@ -160,7 +161,6 @@ ensure_docker_installed() { } get_install_confirmation(){ - # 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 ) @@ -276,11 +276,12 @@ download_entrypoint_script() { echo -e "${GREEN}#${RESET} entrypoint script downloaded successfully to $entrypoint_script_path.\\n" } -download_start_stop_scripts() { +download_helper_scripts() { local start_script_path="${nomad_dir}/start_nomad.sh" local stop_script_path="${nomad_dir}/stop_nomad.sh" + local update_script_path="${nomad_dir}/update_nomad.sh" - echo -e "${YELLOW}#${RESET} Downloading start and stop scripts...\\n" + echo -e "${YELLOW}#${RESET} Downloading helper scripts...\\n" if ! curl -fsSL "$START_SCRIPT_URL" -o "$start_script_path"; then echo -e "${RED}#${RESET} Failed to download the start script. Please check the URL and try again." exit 1 @@ -293,7 +294,12 @@ download_start_stop_scripts() { fi chmod +x "$stop_script_path" - echo -e "${GREEN}#${RESET} Start and stop scripts downloaded successfully to $start_script_path and $stop_script_path.\\n" + if ! curl -fsSL "$UPDATE_SCRIPT_URL" -o "$update_script_path"; then + echo -e "${RED}#${RESET} Failed to download the update script. Please check the URL and try again." + exit 1 + fi + + echo -e "${GREEN}#${RESET} Helper scripts downloaded successfully to $start_script_path, $stop_script_path, and $update_script_path.\\n" } start_management_containers() { @@ -341,7 +347,7 @@ ensure_docker_installed create_nomad_directory download_wait_for_it_script download_entrypoint_script -download_start_stop_scripts +download_helper_scripts download_management_compose_file start_management_containers get_local_ip diff --git a/install/update_nomad.sh b/install/update_nomad.sh new file mode 100644 index 0000000..75222a7 --- /dev/null +++ b/install/update_nomad.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +# Project N.O.M.A.D. Update Script + +################################################################################################################################################################################################### + +# Script | Project N.O.M.A.D. Update Script +# Version | 1.0.1 +# Author | Crosstalk Solutions, LLC +# Website | https://crosstalksolutions.com + +################################################################################################################################################################################################### +# # +# Color Codes # +# # +################################################################################################################################################################################################### + +RESET='\033[0m' +YELLOW='\033[1;33m' +WHITE_R='\033[39m' # Same as GRAY_R for terminals with white background. +GRAY_R='\033[39m' +RED='\033[1;31m' # Light Red. +GREEN='\033[1;32m' # Light Green. + +################################################################################################################################################################################################### +# # +# Functions # +# # +################################################################################################################################################################################################### + +check_has_sudo() { + if sudo -n true 2>/dev/null; then + echo -e "${GREEN}#${RESET} User has sudo permissions.\\n" + else + echo "User does not have sudo permissions" + header_red + echo -e "${RED}#${RESET} This script requires sudo permissions to run. Please run the script with sudo.\\n" + echo -e "${RED}#${RESET} For example: sudo bash $(basename "$0")" + exit 1 + fi +} + +check_is_bash() { + if [[ -z "$BASH_VERSION" ]]; then + header_red + echo -e "${RED}#${RESET} This script requires bash to run. Please run the script using bash.\\n" + echo -e "${RED}#${RESET} For example: bash $(basename "$0")" + exit 1 + fi + echo -e "${GREEN}#${RESET} This script is running in bash.\\n" +} + +check_is_debian_based() { + if [[ ! -f /etc/debian_version ]]; then + header_red + echo -e "${RED}#${RESET} This script is designed to run on Debian-based systems only.\\n" + echo -e "${RED}#${RESET} Please run this script on a Debian-based system and try again." + exit 1 + fi + echo -e "${GREEN}#${RESET} This script is running on a Debian-based system.\\n" +} + +get_update_confirmation(){ + read -p "This script will update Project N.O.M.A.D. and its dependencies on your machine. No data loss is expected, but you should always back up your data before proceeding. 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 update." + ;; + n|N ) + echo -e "${RED}#${RESET} User chose not to continue with the update." + exit 0 + ;; + * ) + echo "Invalid Response" + echo "User chose not to continue with the update." + exit 0 + ;; + esac +} + +ensure_docker_installed_and_running() { + if ! command -v docker &> /dev/null; then + echo -e "${RED}#${RESET} Docker is not installed. This is unexpected, as Project N.O.M.A.D. requires Docker to run. Did you mean to use the install script instead of the update script?" + exit 1 + fi + + if ! systemctl is-active --quiet docker; then + echo -e "${RED}#${RESET} Docker is not running. Attempting to start Docker..." + sudo systemctl start docker + if ! systemctl is-active --quiet docker; then + echo -e "${RED}#${RESET} Failed to start Docker. Please start Docker and try again." + exit 1 + fi + fi +} + +ensure_docker_compose_file_exists() { + if [ ! -f "/opt/project-nomad/docker-compose-management.yml" ]; then + echo -e "${RED}#${RESET} docker-compose-management.yml file not found. Please ensure it exists at /opt/project-nomad/docker-compose-management.yml." + exit 1 + fi +} + +force_recreate() { + echo -e "${YELLOW}#${RESET} Forcing recreation of containers..." + docker-compose -f /opt/project-nomad/docker-compose-management.yml up -d --force-recreate +} + +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} Project N.O.M.A.D's Command Center should automatically start whenever your device reboots. However, if you need to start it manually, you can always do so by running: ${WHITE_R}${nomad_dir}/start_nomad.sh${RESET}\\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 # +# # +################################################################################################################################################################################################### + +# Pre-flight checks +check_is_debian_based +check_is_bash +check_has_sudo + +# Main update +get_update_confirmation +ensure_docker_installed_and_running +ensure_docker_compose_file_exists +force_recreate +get_local_ip +success_message