Skip to content

Commit 7c2b096

Browse files
committed
feat: container controls & convienience scripts
1 parent 5fc4907 commit 7c2b096

File tree

18 files changed

+653
-74
lines changed

18 files changed

+653
-74
lines changed

.github/workflows/docker.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,6 @@ jobs:
5757
context: ./admin
5858
file: ./admin/Dockerfile
5959
push: true
60-
tags: ghcr.io/crosstalk-solutions/project-nomad-admin:${{ inputs.version }}
60+
tags: |
61+
ghcr.io/crosstalk-solutions/project-nomad-admin:${{ inputs.version }}
62+
ghcr.io/crosstalk-solutions/project-nomad-admin:latest

README.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ sudo bash install_nomad.sh
1818
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!
1919

2020
## How It Works
21-
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
21+
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
2222
[Kiwix](https://kiwix.org/), [OpenStreetMap](https://www.openstreetmap.org/), [Ollama](https://ollama.com/), [OpenWebUI](https://openwebui.com/), and more.
2323

2424
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
5757

5858
## About Security
5959
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.
60+
61+
# Helper Scripts
62+
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`
63+
64+
###
65+
66+
###### Start Script - Starts all installed project containers
67+
```bash
68+
sudo bash /opt/project-nomad/start_nomad.sh
69+
```
70+
###
71+
72+
###### Stop Script - Stops all installed project containers
73+
```bash
74+
sudo bash /opt/project-nomad/start_nomad.sh
75+
```
76+
###
77+
78+
###### 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
79+
```bash
80+
sudo bash /opt/project-nomad/update_nomad.sh
81+
```

admin/app/controllers/system_controller.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { DockerService } from '#services/docker_service';
22
import { SystemService } from '#services/system_service'
3-
import { installServiceValidator } from '#validators/system';
3+
import { affectServiceValidator, installServiceValidator } from '#validators/system';
44
import { inject } from '@adonisjs/core'
55
import type { HttpContext } from '@adonisjs/core/http'
66

@@ -26,6 +26,17 @@ export default class SystemController {
2626
}
2727
}
2828

29+
async affectService({ request, response }: HttpContext) {
30+
const payload = await request.validateUsing(affectServiceValidator);
31+
const result = await this.dockerService.affectContainer(payload.service_name, payload.action);
32+
if (!result) {
33+
response.internalServerError({ error: 'Failed to affect service' });
34+
return;
35+
}
36+
response.send({ success: result.success, message: result.message });
37+
}
38+
39+
2940
async simulateSSE({ response }: HttpContext) {
3041
this.dockerService.simulateSSE();
3142
response.send({ message: 'Started simulation of SSE' })

admin/app/services/docker_service.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import axios from 'axios';
55
import logger from '@adonisjs/core/services/logger'
66
import transmit from '@adonisjs/transmit/services/main'
77
import { inject } from "@adonisjs/core";
8+
import { ServiceStatus } from "../../types/services.js";
89

910
@inject()
1011
export class DockerService {
@@ -18,6 +19,108 @@ export class DockerService {
1819
this.docker = new Docker({ socketPath: '/var/run/docker.sock' });
1920
}
2021

22+
async affectContainer(serviceName: string, action: 'start' | 'stop' | 'restart'): Promise<{ success: boolean; message: string }> {
23+
try {
24+
const service = await Service.query().where('service_name', serviceName).first();
25+
if (!service || !service.installed) {
26+
return {
27+
success: false,
28+
message: `Service ${serviceName} not found or not installed`,
29+
};
30+
}
31+
32+
const containers = await this.docker.listContainers({ all: true });
33+
const container = containers.find(c => c.Names.includes(`/${serviceName}`));
34+
if (!container) {
35+
return {
36+
success: false,
37+
message: `Container for service ${serviceName} not found`,
38+
};
39+
}
40+
41+
const dockerContainer = this.docker.getContainer(container.Id);
42+
if (action === 'stop') {
43+
await dockerContainer.stop();
44+
return {
45+
success: true,
46+
message: `Service ${serviceName} stopped successfully`,
47+
};
48+
}
49+
50+
if (action === 'restart') {
51+
await dockerContainer.restart();
52+
return {
53+
success: true,
54+
message: `Service ${serviceName} restarted successfully`,
55+
};
56+
}
57+
58+
if (action === 'start') {
59+
if (container.State === 'running') {
60+
return {
61+
success: true,
62+
message: `Service ${serviceName} is already running`,
63+
};
64+
}
65+
await dockerContainer.start();
66+
}
67+
68+
return {
69+
success: false,
70+
message: `Invalid action: ${action}. Use 'start', 'stop', or 'restart'.`,
71+
}
72+
} catch (error) {
73+
console.error(`Error starting service ${serviceName}: ${error.message}`);
74+
return {
75+
success: false,
76+
message: `Failed to start service ${serviceName}: ${error.message}`,
77+
};
78+
}
79+
}
80+
81+
async getServicesStatus(): Promise<{
82+
service_name: string;
83+
status: ServiceStatus;
84+
}[]> {
85+
try {
86+
const services = await Service.query().where('installed', true);
87+
if (!services || services.length === 0) {
88+
return [];
89+
}
90+
91+
const containers = await this.docker.listContainers({ all: true });
92+
const containerMap = new Map<string, Docker.ContainerInfo>();
93+
containers.forEach(container => {
94+
const name = container.Names[0].replace('/', '');
95+
if (name.startsWith('nomad_')) {
96+
containerMap.set(name, container);
97+
}
98+
});
99+
100+
const getStatus = (state: string): ServiceStatus => {
101+
switch (state) {
102+
case 'running':
103+
return 'running';
104+
case 'exited':
105+
case 'created':
106+
case 'paused':
107+
return 'stopped';
108+
default:
109+
return 'unknown';
110+
}
111+
};
112+
113+
114+
return Array.from(containerMap.entries()).map(([name, container]) => ({
115+
service_name: name,
116+
status: getStatus(container.State),
117+
}));
118+
} catch (error) {
119+
console.error(`Error fetching services status: ${error.message}`);
120+
return [];
121+
}
122+
}
123+
21124
async createContainerPreflight(serviceName: string): Promise<{ success: boolean; message: string }> {
22125
const service = await Service.query().where('service_name', serviceName).first();
23126
if (!service) {
Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,44 @@
11
import Service from "#models/service"
2+
import { inject } from "@adonisjs/core";
3+
import { DockerService } from "#services/docker_service";
4+
import { ServiceStatus } from "../../types/services.js";
25

6+
@inject()
37
export class SystemService {
8+
constructor(
9+
private dockerService: DockerService
10+
) {}
411
async getServices({
512
installedOnly = true,
613
}:{
714
installedOnly?: boolean
8-
}): Promise<{ id: number; service_name: string; installed: boolean }[]> {
15+
}): Promise<{ id: number; service_name: string; installed: boolean, status: ServiceStatus }[]> {
916
const query = Service.query().orderBy('service_name', 'asc').select('id', 'service_name', 'installed', 'ui_location').where('is_dependency_service', false)
1017
if (installedOnly) {
1118
query.where('installed', true);
1219
}
13-
return await query;
20+
21+
const services = await query;
22+
if (!services || services.length === 0) {
23+
return [];
24+
}
25+
26+
const statuses = await this.dockerService.getServicesStatus();
27+
28+
const toReturn = [];
29+
30+
for (const service of services) {
31+
const status = statuses.find(s => s.service_name === service.service_name);
32+
toReturn.push({
33+
id: service.id,
34+
service_name: service.service_name,
35+
installed: service.installed,
36+
status: status ? status.status : 'unknown',
37+
ui_location: service.ui_location || ''
38+
});
39+
}
40+
41+
return toReturn;
42+
1443
}
1544
}

admin/app/validators/system.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,9 @@ import vine from '@vinejs/vine'
22

33
export const installServiceValidator = vine.compile(vine.object({
44
service_name: vine.string().trim()
5-
}))
5+
}));
6+
7+
export const affectServiceValidator = vine.compile(vine.object({
8+
service_name: vine.string().trim(),
9+
action: vine.enum(['start', 'stop', 'restart'])
10+
}));

admin/inertia/app/app.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { TransmitProvider } from 'react-adonis-transmit'
1010
import { generateUUID } from '~/lib/util'
1111
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
1212
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
13+
import NotificationsProvider from '~/providers/NotificationProvider'
1314

1415
const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.'
1516
const queryClient = new QueryClient()
@@ -35,10 +36,12 @@ createInertiaApp({
3536
createRoot(el).render(
3637
<QueryClientProvider client={queryClient}>
3738
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
38-
<ModalsProvider>
39-
<App {...props} />
40-
<ReactQueryDevtools initialIsOpen={false} />
41-
</ModalsProvider>
39+
<NotificationsProvider>
40+
<ModalsProvider>
41+
<App {...props} />
42+
<ReactQueryDevtools initialIsOpen={false} />
43+
</ModalsProvider>
44+
</NotificationsProvider>
4245
</TransmitProvider>
4346
</QueryClientProvider>
4447
)
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { createContext, useContext } from "react";
2+
3+
export interface Notification {
4+
message: string;
5+
type: "error" | "success" | "info";
6+
duration?: number; // in milliseconds
7+
}
8+
9+
export interface NotificationContextType {
10+
notifications: Notification[];
11+
addNotification: (notification: Notification) => void;
12+
removeNotification: (id: string) => void;
13+
removeAllNotifications: () => void;
14+
}
15+
16+
export const NotificationContext = createContext<
17+
NotificationContextType | undefined
18+
>(undefined);
19+
20+
export const useNotifications = () => {
21+
const context = useContext(NotificationContext);
22+
if (!context) {
23+
throw new Error(
24+
"useNotifications must be used within a NotificationProvider"
25+
);
26+
}
27+
return context;
28+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Helper hook to show error notifications
2+
import { useNotifications } from '../context/NotificationContext';
3+
4+
const useErrorNotification = () => {
5+
const { addNotification } = useNotifications();
6+
7+
const showError = (message: string) => {
8+
addNotification({ message, type: 'error' });
9+
};
10+
11+
return { showError };
12+
};
13+
14+
export default useErrorNotification;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Helper hook to check internet connection status
2+
import { useQuery } from '@tanstack/react-query';
3+
import { useEffect, useState } from 'react';
4+
import { testInternetConnection } from '~/lib/util';
5+
6+
const useInternetStatus = () => {
7+
const [isOnline, setIsOnline] = useState<boolean>(false);
8+
const { data } = useQuery<boolean>({
9+
queryKey: ['internetStatus'],
10+
queryFn: testInternetConnection,
11+
refetchOnWindowFocus: false, // Don't refetch on window focus
12+
refetchOnReconnect: false, // Refetch when the browser reconnects
13+
refetchOnMount: false, // Don't refetch when the component mounts
14+
retry: 2, // Retry up to 2 times on failure
15+
staleTime: 1000 * 60 * 10, // Data is fresh for 10 minutes
16+
});
17+
18+
// Update the online status when data changes
19+
useEffect(() => {
20+
if (data === undefined) return; // Avoid setting state on unmounted component
21+
setIsOnline(data);
22+
}, [data]);
23+
24+
return { isOnline };
25+
};
26+
27+
export default useInternetStatus;

0 commit comments

Comments
 (0)