feat: container controls & convienience scripts

This commit is contained in:
Jake Turner 2025-08-08 15:06:40 -07:00 committed by Jake Turner
parent 5fc490715d
commit 7c2b0964dc
18 changed files with 653 additions and 74 deletions

View File

@ -57,4 +57,6 @@ jobs:
context: ./admin
file: ./admin/Dockerfile
push: true
tags: ghcr.io/crosstalk-solutions/project-nomad-admin:${{ inputs.version }}
tags: |
ghcr.io/crosstalk-solutions/project-nomad-admin:${{ inputs.version }}
ghcr.io/crosstalk-solutions/project-nomad-admin:latest

View File

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

View File

@ -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' })

View File

@ -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<string, Docker.ContainerInfo>();
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) {

View File

@ -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;
}
}

View File

@ -2,4 +2,9 @@ import vine from '@vinejs/vine'
export const installServiceValidator = vine.compile(vine.object({
service_name: vine.string().trim()
}))
}));
export const affectServiceValidator = vine.compile(vine.object({
service_name: vine.string().trim(),
action: vine.enum(['start', 'stop', 'restart'])
}));

View File

@ -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(
<QueryClientProvider client={queryClient}>
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
<NotificationsProvider>
<ModalsProvider>
<App {...props} />
<ReactQueryDevtools initialIsOpen={false} />
</ModalsProvider>
</NotificationsProvider>
</TransmitProvider>
</QueryClientProvider>
)

View File

@ -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;
};

View File

@ -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;

View File

@ -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<boolean>(false);
const { data } = useQuery<boolean>({
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;

View File

@ -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<ListZimFilesResponse>("/zim/list");
}

View File

@ -18,9 +18,6 @@ export async function testInternetConnection(): Promise<boolean> {
try {
const response = await axios.get('https://1.1.1.1/cdn-cgi/trace', {
timeout: 5000,
headers: {
'Cache-Control': 'no-cache',
}
});
return response.status === 200;
} catch (error) {

View File

@ -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<InstallActivityFeedProps['activity']>([])
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 (
<div className="flex space-x-2">
<StyledButton
icon={'ArrowDownTrayIcon'}
variant="action"
onClick={() => handleInstallService(record)}
disabled={isInstalling || !isOnline}
loading={isInstalling}
>
Install
</StyledButton>
</div>
)
}
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 (
<div className="flex space-x-2">
<StyledButton
icon={'ArrowTopRightOnSquareIcon'}
onClick={() => {
window.open(getServiceLink(record.ui_location || 'unknown'), '_blank')
}}
>
Open
</StyledButton>
{record.status && record.status !== 'unknown' && (
<>
<StyledButton
icon={record.status === 'running' ? 'StopIcon' : 'PlayIcon'}
variant={record.status === 'running' ? 'action' : undefined}
onClick={() => {
openModal(
<StyledModal
title={`${record.status === 'running' ? 'Stop' : 'Start'} Service?`}
onConfirm={() =>
handleAffectAction(record.status === 'running' ? 'stop' : 'start')
}
onCancel={closeAllModals}
open={true}
confirmText={record.status === 'running' ? 'Stop' : 'Start'}
cancelText="Cancel"
>
<p className="text-gray-700">
Are you sure you want to {record.status === 'running' ? 'stop' : 'start'}{' '}
{record.service_name}?
</p>
</StyledModal>,
`${record.service_name}-affect-modal`
)
}}
disabled={isInstalling}
>
{record.status === 'running' ? 'Stop' : 'Start'}
</StyledButton>
{record.status === 'running' && (
<StyledButton
icon="ArrowPathIcon"
variant="action"
onClick={() => {
openModal(
<StyledModal
title={'Restart Service?'}
onConfirm={() => handleAffectAction('restart')}
onCancel={closeAllModals}
open={true}
confirmText={'Restart'}
cancelText="Cancel"
>
<p className="text-gray-700">
Are you sure you want to restart {record.service_name}?
</p>
</StyledModal>,
`${record.service_name}-affect-modal`
)
}}
disabled={isInstalling}
>
Restart
</StyledButton>
)}
</>
)}
</div>
)
}
return (
<SettingsLayout>
<Head title="App Settings | Project N.O.M.A.D." />
@ -91,6 +212,8 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
<p className="text-gray-500 mb-4">
Manage the applications that are available in your Project N.O.M.A.D. instance.
</p>
{loading && <LoadingSpinner fullscreen />}
{!loading && (
<StyledTable<ServiceSlim & { actions?: any }>
className="font-semibold"
rowLines={true}
@ -101,7 +224,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
title: 'Location',
render: (record) => (
<a
href={getServiceLink(record.ui_location)}
href={getServiceLink(record.ui_location || 'unknown')}
target="_blank"
rel="noopener noreferrer"
className="text-desert-green hover:underline font-semibold"
@ -118,34 +241,12 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
{
accessor: 'actions',
title: 'Actions',
render: (record) => (
<div className="flex space-x-2">
{record.installed ? (
<StyledButton
icon={'ArrowTopRightOnSquareIcon'}
onClick={() => {
window.open(getServiceLink(record.ui_location), '_blank')
}}
>
Open
</StyledButton>
) : (
<StyledButton
icon={'ArrowDownTrayIcon'}
variant="action"
onClick={() => handleInstallService(record)}
disabled={isInstalling}
loading={isInstalling}
>
Install
</StyledButton>
)}
</div>
),
render: (record) => <AppActions record={record} />,
},
]}
data={props.system.services}
/>
)}
{installActivity.length > 0 && (
<InstallActivityFeed activity={installActivity} className="mt-8" />
)}

View File

@ -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 <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />
case 'success':
return <CheckCircleIcon className="h-5 w-5 text-green-500" />
case 'info':
return <InformationCircleIcon className="h-5 w-5 text-blue-500" />
default:
return <InformationCircleIcon className="h-5 w-5 text-blue-500" />
}
}
return (
<NotificationContext.Provider
value={{
notifications,
addNotification,
removeNotification,
removeAllNotifications,
}}
>
{children}
<div className="!fixed bottom-16 right-0 p-4 z-[9999]">
{notifications.map((notification) => (
<div
key={notification.id}
className={`mb-4 p-4 rounded shadow-md border border-slate-300 bg-white max-w-96`}
onClick={() => removeNotification(notification.id)}
>
<div className="flex flex-row justify-between items-center">
<div className="mr-2">
<Icon type={notification.type} />
</div>
<div>
<p className="">{notification.message}</p>
</div>
</div>
</div>
))}
</div>
</NotificationContext.Provider>
)
}
export default NotificationsProvider

View File

@ -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')

View File

@ -1,4 +1,5 @@
import Service from "#models/service";
export type ServiceSlim = Pick<Service, 'id' | 'service_name' | 'installed' | 'ui_location'>;
export type ServiceStatus = 'unknown' | 'running' | 'stopped';
export type ServiceSlim = Pick<Service, 'id' | 'service_name' | 'installed' | 'ui_location'> & { status?: ServiceStatus };

View File

@ -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

142
install/update_nomad.sh Normal file
View File

@ -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