feat: init app installation and documentation

This commit is contained in:
Jake Turner 2025-06-30 01:43:57 -07:00
parent 7c593409be
commit 39d75c9cdf
35 changed files with 1367 additions and 337 deletions

View File

@ -50,5 +50,7 @@ Again, Project N.O.M.A.D. itself is quite lightweight - it's the tools and resou
## About Internet Usage & Privacy
Project N.O.M.A.D. is designed for offline usage. An internet connection is only required during the initial installation (to download dependencies) and if you (the user) decide to download additional tools and resources at a later time. Otherwise, N.O.M.A.D. does not require an internet connection and has ZERO built-in telemetry.
To test internet connectivity, N.O.M.A.D. attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace` and checks for a successful response.
## 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.

View File

@ -0,0 +1,23 @@
import { DocsService } from '#services/docs_service'
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
@inject()
export default class DocsController {
constructor(
private docsService: DocsService
) { }
async list({ }: HttpContext) {
const docs = await this.docsService.getDocs();
return { articles: docs };
}
async show({ params, inertia }: HttpContext) {
const content = await this.docsService.parseFile(`${params.slug}.md`);
return inertia.render('docs/show', {
content,
title: "Documentation"
});
}
}

View File

@ -8,7 +8,12 @@ export default class HomeController {
private systemService: SystemService,
) { }
async index({ inertia }: HttpContext) {
async index({ response }: HttpContext) {
// Redirect / to /home
return response.redirect().toPath('/home');
}
async home({ inertia }: HttpContext) {
const services = await this.systemService.getServices();
return inertia.render('home', {
system: {

View File

@ -0,0 +1,28 @@
import { SystemService } from '#services/system_service';
import { inject } from '@adonisjs/core';
import type { HttpContext } from '@adonisjs/core/http'
@inject()
export default class SettingsController {
constructor(
private systemService: SystemService,
) { }
async system({ inertia }: HttpContext) {
// const services = await this.systemService.getServices();
return inertia.render('settings/system', {
// system: {
// services
// }
});
}
async apps({ inertia }: HttpContext) {
const services = await this.systemService.getServices();
return inertia.render('settings/apps', {
system: {
services
}
});
}
}

View File

@ -21,9 +21,14 @@ export default class SystemController {
const result = await this.dockerService.createContainerPreflight(payload.service_name);
if (result.success) {
response.send({ message: result.message });
response.send({ success: true, message: result.message });
} else {
response.status(400).send({ error: result.message });
}
}
async simulateSSE({ response }: HttpContext) {
this.dockerService.simulateSSE();
response.send({ message: 'Started simulation of SSE' })
}
}

View File

@ -4,7 +4,9 @@ import drive from '@adonisjs/drive/services/main'
import axios from 'axios';
import logger from '@adonisjs/core/services/logger'
import transmit from '@adonisjs/transmit/services/main'
import { inject } from "@adonisjs/core";
@inject()
export class DockerService {
private docker: Docker;
@ -72,31 +74,26 @@ export class DockerService {
*/
async _createContainer(service: Service, containerConfig: any): Promise<void> {
transmit.broadcast('service-installation', {
service_name: service.service_name,
status: 'starting',
})
function sendBroadcastAndLog(status: string, message: string) {
transmit.broadcast('service-installation', {
service_name: service.service_name,
timestamp: new Date().toISOString(),
status,
message,
});
logger.info(`[DockerService] [${service.service_name}] ${status}: ${message}`);
}
sendBroadcastAndLog('initializing', '');
// Start pulling the Docker image and wait for it to complete
const pullStream = await this.docker.pull(service.container_image);
transmit.broadcast('service-installation', {
service_name: service.service_name,
status: 'pulling',
message: `Pulling Docker image ${service.container_image}...`,
});
sendBroadcastAndLog('pulling', `Pulling Docker image ${service.container_image}...`);
await new Promise(res => this.docker.modem.followProgress(pullStream, res));
transmit.broadcast('service-installation', {
service_name: service.service_name,
status: 'pulled',
message: `Docker image ${service.container_image} pulled successfully.`,
});
transmit.broadcast('service-installation', {
service_name: service.service_name,
status: 'creating',
message: `Creating Docker container for service ${service.service_name}...`,
});
sendBroadcastAndLog('pulled', `Docker image ${service.container_image} pulled successfully.`);
sendBroadcastAndLog('creating', `Creating Docker container for service ${service.service_name}...`);
const container = await this.docker.createContainer({
Image: service.container_image,
@ -107,54 +104,24 @@ export class DockerService {
ExposedPorts: containerConfig?.ExposedPorts || undefined,
});
transmit.broadcast('service-installation', {
service_name: service.service_name,
status: 'created',
message: `Docker container for service ${service.service_name} created successfully.`,
});
sendBroadcastAndLog('created', `Docker container for service ${service.service_name} created successfully.`);
if (service.service_name === 'kiwix-serve') {
transmit.broadcast('service-installation', {
service_name: service.service_name,
status: 'preinstall',
message: `Running pre-install actions for Kiwix Serve...`,
});
sendBroadcastAndLog('preinstall', `Running pre-install actions for Kiwix Serve...`);
await this._runPreinstallActions__KiwixServe();
transmit.broadcast('service-installation', {
service_name: service.service_name,
status: 'preinstall-complete',
message: `Pre-install actions for Kiwix Serve completed successfully.`,
});
sendBroadcastAndLog('preinstall-complete', `Pre-install actions for Kiwix Serve completed successfully.`);
}
transmit.broadcast('service-installation', {
service_name: service.service_name,
status: 'starting',
message: `Starting Docker container for service ${service.service_name}...`,
});
sendBroadcastAndLog('starting', `Starting Docker container for service ${service.service_name}...`);
await container.start();
transmit.broadcast('service-installation', {
service_name: service.service_name,
status: 'started',
message: `Docker container for service ${service.service_name} started successfully.`,
});
sendBroadcastAndLog('started', `Docker container for service ${service.service_name} started successfully.`);
transmit.broadcast('service-installation', {
service_name: service.service_name,
status: 'finalizing',
message: `Finalizing installation of service ${service.service_name}...`,
});
sendBroadcastAndLog('finalizing', `Finalizing installation of service ${service.service_name}...`);
service.installed = true;
await service.save();
transmit.broadcast('service-installation', {
service_name: service.service_name,
status: 'completed',
message: `Service ${service.service_name} installed successfully.`,
});
sendBroadcastAndLog('completed', `Service ${service.service_name} installation completed successfully.`);
}
async _checkIfServiceContainerExists(serviceName: string): Promise<boolean> {
@ -209,4 +176,17 @@ export class DockerService {
logger.info(`Downloaded Wikipedia ZIM file to /zim/wikipedia_en_100_mini_2025-06.zim`);
}
async simulateSSE(): Promise<void> {
// This is just a simulation of the server-sent events for testing purposes
for (let i = 0; i <= 10; i++) {
await new Promise((resolve) => setTimeout(resolve, 5000));
transmit.broadcast('service-installation', {
service_name: 'test-service',
timestamp: new Date().toISOString(),
status: i === 10 ? 'completed' : 'in-progress',
message: `Test message ${i}`,
});
}
}
}

View File

@ -0,0 +1,64 @@
import Markdoc from '@markdoc/markdoc';
import { readdir, readFile } from 'node:fs/promises';
import { join } from 'node:path';
export class DocsService {
async getDocs() {
const docsPath = join(process.cwd(), '/docs');
console.log(`Resolving docs path: ${docsPath}`);
const files = await readdir(docsPath, { withFileTypes: true });
const docs = files
.filter(file => file.isFile() && file.name.endsWith('.md'))
.map(file => file.name);
return docs;
}
parse(content: string) {
const ast = Markdoc.parse(content);
const config = this.getConfig();
const errors = Markdoc.validate(ast, config);
if (errors.length > 0) {
throw new Error(`Markdoc validation errors: ${errors.map(e => e.error).join(', ')}`);
}
return Markdoc.transform(ast, config);
}
async parseFile(filename: string) {
const fullPath = join(process.cwd(), '/docs', filename);
console.log(`Resolving file path: ${fullPath}`);
const content = await readFile(fullPath, 'utf-8')
return this.parse(content);
}
private getConfig() {
return {
tags: {
callout: {
render: 'Callout',
attributes: {
type: {
type: String,
default: 'info',
matches: ['info', 'warning', 'error', 'success']
},
title: {
type: String
}
}
},
},
nodes: {
heading: {
render: 'Heading',
attributes: {
level: { type: Number, required: true },
id: { type: String }
}
}
}
}
}
}

View File

@ -1,5 +1,5 @@
import env from '#start/env'
//import app from '@adonisjs/core/services/app'
import app from '@adonisjs/core/services/app'
import { defineConfig, targets } from '@adonisjs/core/logger'
const loggerConfig = defineConfig({
@ -15,11 +15,11 @@ const loggerConfig = defineConfig({
name: env.get('APP_NAME'),
level: env.get('LOG_LEVEL'),
transport: {
targets: targets().push(targets.pretty()).toArray(), // TODO: configure file target for production correctly
// targets()
// .pushIf(!app.inProduction, targets.pretty())
// .pushIf(app.inProduction, targets.file({ destination: 1 }))
// .toArray(),
targets:
targets()
.pushIf(!app.inProduction, targets.pretty())
.pushIf(app.inProduction, targets.file({ destination: "/app/storage/logs/admin.log" }))
.toArray(),
},
},
},
@ -32,5 +32,5 @@ export default loggerConfig
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
export interface LoggersList extends InferLoggers<typeof loggerConfig> { }
}

View File

@ -12,6 +12,14 @@ export default class ServiceSeeder extends BaseSeeder {
ui_location: '8090',
installed: false,
},
{
service_name: 'openstreetmap',
container_image: 'overv/openstreetmap-tile-server',
container_command: 'run',
container_config: "{\"HostConfig\":{\"Binds\":[\"/opt/project-nomad/storage/osm/db:/data/database\",\"/opt/project-nomad/storage/osm/tiles:/data/tiles\"],\"PortBindings\":{\"80/tcp\":[{\"HostPort\":\"9000\"}]}}}",
ui_location: '9000',
installed: false,
}
]
async run() {

1
admin/docs/home.md Normal file
View File

@ -0,0 +1 @@
# This is a markdown file!

View File

@ -1,12 +1,23 @@
/// <reference path="../../adonisrc.ts" />
/// <reference path="../../config/inertia.ts" />
import '../css/app.css';
import { createRoot } from 'react-dom/client';
import { createInertiaApp } from '@inertiajs/react';
import '../css/app.css'
import { createRoot } from 'react-dom/client'
import { createInertiaApp } from '@inertiajs/react'
import { resolvePageComponent } from '@adonisjs/inertia/helpers'
import ModalsProvider from '~/providers/ModalProvider'
import { TransmitProvider } from 'react-adonis-transmit'
import { generateUUID } from '~/lib/util'
const appName = import.meta.env.VITE_APP_NAME || 'AdonisJS'
const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.'
// Patch the global crypto object for non-HTTPS/localhost contexts
if (!window.crypto?.randomUUID) {
// @ts-ignore
if (!window.crypto) window.crypto = {}
// @ts-ignore
window.crypto.randomUUID = generateUUID
}
createInertiaApp({
progress: { color: '#5468FF' },
@ -14,15 +25,16 @@ createInertiaApp({
title: (title) => `${title} - ${appName}`,
resolve: (name) => {
return resolvePageComponent(
`../pages/${name}.tsx`,
import.meta.glob('../pages/**/*.tsx'),
)
return resolvePageComponent(`../pages/${name}.tsx`, import.meta.glob('../pages/**/*.tsx'))
},
setup({ el, App, props }) {
createRoot(el).render(<App {...props} />);
createRoot(el).render(
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
<ModalsProvider>
<App {...props} />
</ModalsProvider>
</TransmitProvider>
)
},
});
})

View File

@ -0,0 +1,66 @@
import { CheckCircleIcon } from '@heroicons/react/24/outline'
import classNames from '~/lib/classNames'
export type InstallActivityFeedProps = {
activity: Array<{
service_name: string
type:
| 'initializing'
| 'pulling'
| 'pulled'
| 'creating'
| 'created'
| 'preinstall'
| 'preinstall-complete'
| 'starting'
| 'started'
| 'finalizing'
| 'completed'
timestamp: string
message: string
}>
className?: string
}
const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className }) => {
return (
<div className={classNames('bg-white shadow-sm rounded-lg p-6', className)}>
<h2 className="text-lg font-semibold text-gray-900">Installation Activity</h2>
<ul role="list" className="mt-6 space-y-6 text-desert-green">
{activity.map((activityItem, activityItemIdx) => (
<li key={activityItem.timestamp} className="relative flex gap-x-4">
<div
className={classNames(
activityItemIdx === activity.length - 1 ? 'h-6' : '-bottom-6',
'absolute left-0 top-0 flex w-6 justify-center'
)}
>
<div className="w-px bg-gray-200" />
</div>
<>
<div className="relative flex size-6 flex-none items-center justify-center bg-transparent">
{activityItem.type === 'completed' ? (
<CheckCircleIcon aria-hidden="true" className="size-6 text-indigo-600" />
) : (
<div className="size-1.5 rounded-full bg-gray-100 ring-1 ring-gray-300" />
)}
</div>
<p className="flex-auto py-0.5 text-xs/5 text-gray-500">
<span className="font-semibold text-gray-900">{activityItem.service_name}</span> -{' '}
{activityItem.type.charAt(0).toUpperCase() + activityItem.type.slice(1)}
</p>
<time
dateTime={activityItem.timestamp}
className="flex-none py-0.5 text-xs/5 text-gray-500"
>
{activityItem.timestamp}
</time>
</>
</li>
))}
</ul>
</div>
)
}
export default InstallActivityFeed

View File

@ -0,0 +1,40 @@
interface LoadingSpinnerProps {
text?: string
fullscreen?: boolean
iconOnly?: boolean
light?: boolean
className?: string
}
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
text,
fullscreen = true,
iconOnly = false,
light = false,
className,
}) => {
if (!fullscreen) {
return (
<div className={`flex flex-col items-center justify-center ${className}`}>
<div
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-slate-400'} border-t-transparent rounded-full animate-spin`}
></div>
{!iconOnly && (
<div className={light ? 'text-white mt-2' : 'text-slate-800 mt-2'}>
{text || 'Loading...'}
</div>
)}
</div>
)
}
return (
<div className={className}>
<div className="ui active inverted dimmer">
<div className="ui text loader">{!iconOnly && <span>{text || 'Loading'}</span>}</div>
</div>
</div>
)
}
export default LoadingSpinner

View File

@ -0,0 +1,75 @@
import React, { JSX } from 'react'
import Markdoc from '@markdoc/markdoc'
// Custom components for Markdoc tags
const Callout = ({
type = 'info',
title,
children,
}: {
type?: string
title?: string
children: React.ReactNode
}) => {
const styles = {
info: 'bg-blue-50 border-blue-200 text-blue-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
error: 'bg-red-50 border-red-200 text-red-800',
success: 'bg-green-50 border-green-200 text-green-800',
}
return (
// @ts-ignore
<div className={`border-l-4 p-4 mb-4 ${styles[type]}`}>
{title && <h4 className="font-semibold mb-2">{title}</h4>}
{children}
</div>
)
}
const Heading = ({
level,
id,
children,
}: {
level: number
id: string
children: React.ReactNode
}) => {
const Tag = `h${level}` as keyof JSX.IntrinsicElements
const sizes = {
1: 'text-3xl font-bold',
2: 'text-2xl font-semibold',
3: 'text-xl font-semibold',
4: 'text-lg font-semibold',
5: 'text-base font-semibold',
6: 'text-sm font-semibold',
}
return (
// @ts-ignore
<Tag id={id} className={`${sizes[level]} mb-4 mt-6`}>
{children}
</Tag>
)
}
// Component mapping for Markdoc
const components = {
Callout,
Heading,
}
interface MarkdocRendererProps {
content: any // Markdoc transformed content
}
const MarkdocRenderer: React.FC<MarkdocRendererProps> = ({ content }) => {
return (
<div className="prose prose-lg max-w-none">
{Markdoc.renderers.react(content, React, { components })}
</div>
)
}
export default MarkdocRenderer

View File

@ -0,0 +1,81 @@
import * as Icons from '@heroicons/react/24/outline'
import { useMemo } from 'react'
interface StyledButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
children: React.ReactNode
// icon should be one of the HeroIcon names, e.g. ArrowTopRightOnSquareIcon
icon?: keyof typeof Icons
disabled?: boolean
variant?: 'primary' | 'secondary' | 'danger' | 'action'
loading?: boolean
}
const StyledButton: React.FC<StyledButtonProps> = ({
children,
icon,
variant = 'primary',
loading = false,
...props
}) => {
const isDisabled = useMemo(() => {
return props.disabled || loading
}, [props.disabled, loading])
const IconComponent = () => {
if (!icon) return null
const Icon = Icons[icon]
return Icon ? <Icon className="h-4 w-4 mr-2" /> : null
}
const getBgColors = () => {
// if primary, use desert-green
if (variant === 'primary') {
return 'bg-desert-green hover:bg-desert-green-light text-white'
}
// if secondary, use outlined styles
if (variant === 'secondary') {
return 'bg-transparent border border-desert-green text-desert-green hover:bg-desert-green-light'
}
// if danger, use red styles
if (variant === 'danger') {
return 'bg-red-600 hover:bg-red-700 text-white'
}
// if action, use orange styles
if (variant === 'action') {
return 'bg-desert-orange hover:bg-desert-orange-light text-white'
}
}
const onClickHandler = (e: React.MouseEvent<HTMLButtonElement>) => {
if (isDisabled) {
e.preventDefault()
return
}
props.onClick?.(e)
}
return (
<button
type="button"
className={`block rounded-md ${getBgColors()} px-3 py-2 text-center text-sm font-semibold shadow-sm cursor-pointer disabled:opacity-50 disabled:pointer-events-none`}
{...props}
disabled={isDisabled}
onClick={onClickHandler}
>
{loading ? (
<Icons.EllipsisHorizontalCircleIcon className="h-5 w-5 animate-spin text-white" />
) : icon ? (
<div className="flex flex-row items-center justify-center">
<IconComponent />
{children}
</div>
) : (
children
)}
</button>
)
}
export default StyledButton

View File

@ -0,0 +1,80 @@
import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from '@headlessui/react'
import StyledButton from './StyledButton'
import React from 'react'
interface StyledModalProps {
onClose?: () => void
title: string
cancelText?: string
confirmText?: string
open: boolean
onCancel?: () => void
onConfirm?: () => void
children: React.ReactNode
icon?: React.ReactNode
}
const StyledModal: React.FC<StyledModalProps> = ({
children,
title,
open,
onClose,
cancelText = 'Cancel',
confirmText = 'Confirm',
onCancel,
onConfirm,
icon,
}) => {
return (
<Dialog
open={open}
onClose={() => {
if (onClose) onClose()
}}
className="relative z-50"
>
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
/>
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<DialogPanel
transition
className="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all data-[closed]:translate-y-4 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in sm:my-8 sm:w-full sm:max-w-lg sm:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95"
>
<div>
{icon && <div className="flex items-center justify-center">{icon}</div>}
<div className="mt-3 text-center sm:mt-5">
<DialogTitle as="h3" className="text-base font-semibold text-gray-900">
{title}
</DialogTitle>
<div className="mt-2">{children}</div>
</div>
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-flow-row-dense sm:grid-cols-2 sm:gap-3">
<StyledButton
variant="secondary"
onClick={() => {
if (onCancel) onCancel()
}}
>
{cancelText}
</StyledButton>
<StyledButton
variant="action"
onClick={() => {
if (onConfirm) onConfirm()
}}
>
{confirmText}
</StyledButton>
</div>
</DialogPanel>
</div>
</div>
</Dialog>
)
}
export default StyledModal

View File

@ -0,0 +1,125 @@
import { useMemo, useState } from 'react'
import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react'
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'
import classNames from '~/lib/classNames'
import { IconArrowLeft } from '@tabler/icons-react'
interface StyledSidebarProps {
title: string
items: Array<{
name: string
href: string
icon: React.ElementType
current: boolean
}>
}
const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
const [sidebarOpen, setSidebarOpen] = useState(false)
const currentPath = useMemo(() => {
if (typeof window === 'undefined') return ''
return window.location.pathname
}, [])
const ListItem = (item: {
name: string
href: string
icon: React.ElementType
current: boolean
}) => {
return (
<li key={item.name}>
<a
href={item.href}
className={classNames(
item.current
? 'bg-desert-green text-white'
: 'text-black hover:bg-desert-green-light hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
)}
>
<item.icon aria-hidden="true" className="size-6 shrink-0" />
{item.name}
</a>
</li>
)
}
const Sidebar = () => {
return (
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-desert-sand px-6 ring-1 ring-white/5 pt-4 shadow-md">
<div className="flex h-16 shrink-0 items-center">
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-16 w-16" />
<h1 className="ml-3 text-xl font-semibold text-black">{title}</h1>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{items.map((item) => (
<ListItem key={item.name} {...item} current={currentPath === item.href} />
))}
<li className="ml-2 mt-4">
<a
href="/home"
className="flex flex-row items-center gap-x-3 text-desert-green text-sm font-semibold"
>
<IconArrowLeft aria-hidden="true" className="size-6 shrink-0" />
Back to Home
</a>
</li>
</ul>
</li>
</ul>
</nav>
</div>
)
}
return (
<>
<button
type="button"
className="absolute left-4 top-4 z-50 xl:hidden"
onClick={() => setSidebarOpen(true)}
>
<Bars3Icon aria-hidden="true" className="size-8" />
</button>
{/* Mobile sidebar */}
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 xl:hidden">
<DialogBackdrop
transition
className="fixed inset-0 bg-black/10 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
/>
<div className="fixed inset-0 flex">
<DialogPanel
transition
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:-translate-x-full"
>
<TransitionChild>
<div className="absolute left-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
<button
type="button"
onClick={() => setSidebarOpen(false)}
className="-m-2.5 p-2.5"
>
<span className="sr-only">Close sidebar</span>
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
</button>
</div>
</TransitionChild>
<Sidebar />
</DialogPanel>
</div>
</Dialog>
{/* Desktop sidebar */}
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col">
<Sidebar />
</div>
</>
)
}
export default StyledSidebar

View File

@ -0,0 +1,97 @@
import { capitalizeFirstLetter } from '~/lib/util'
import classNames from '~/lib/classNames'
import LoadingSpinner from '~/components/LoadingSpinner'
interface StyledTableProps<T = Record<string, unknown>> {
loading?: boolean
tableProps?: React.HTMLAttributes<HTMLTableElement>
data?: T[]
noDataText?: string
onRowClick?: (record: T) => void
columns?: {
accessor: keyof T
title?: React.ReactNode
render?: (record: T, index: number) => React.ReactNode
className?: string
}[]
className?: string
rowLines?: boolean
}
function StyledTable<T>({
loading = false,
tableProps = {},
data = [],
noDataText = 'No records found',
onRowClick,
columns = [],
className = '',
}: StyledTableProps<T>) {
const { className: tableClassName, ...restTableProps } = tableProps
return (
<div
className={classNames(
'w-full overflow-x-auto bg-white mt-10 ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-3 shadow-md',
className
)}
>
<table className="min-w-full" {...restTableProps}>
<thead>
<tr>
{columns.map((column, index) => (
<th
key={index}
className="whitespace-nowrap text-left py-3.5 pl-4 pr-3text-sm font-semibold text-gray-900 sm:pl-6"
>
{column.title ?? capitalizeFirstLetter(column.accessor.toString())}
</th>
))}
</tr>
</thead>
<tbody>
{!loading &&
data.length !== 0 &&
data.map((record, recordIdx) => (
<tr
key={crypto.randomUUID()}
onClick={() => onRowClick?.(record)}
className={onRowClick ? `cursor-pointer hover:bg-gray-100 ` : ''}
>
{columns.map((column, index) => (
<td
key={index}
className={classNames(
recordIdx === 0 ? '' : 'border-t border-transparent',
'relative py-4 pl-4 pr-3 text-sm sm:pl-6 whitespace-nowrap max-w-72 truncate break-words',
column.className || ''
)}
>
{column.render
? column.render(record, index)
: (record[column.accessor] as React.ReactNode)}
</td>
))}
</tr>
))}
{!loading && data.length === 0 && (
<tr>
<td colSpan={columns.length} className="!text-center ">
{noDataText}
</td>
</tr>
)}
{loading && (
<tr className="!h-16">
<td colSpan={columns.length} className="!text-center">
<LoadingSpinner fullscreen={false} />
</td>
</tr>
)}
</tbody>
</table>
</div>
)
}
export default StyledTable

View File

@ -0,0 +1,19 @@
import { createContext, useContext, ReactNode } from 'react'
interface ModalContextProps {
openModal: (content: ReactNode, id: string, preventClose?: boolean) => void
closeModal: (id: string) => void
closeAllModals: () => void
_getCurrentModals: () => Record<string, ReactNode>
preventCloseOnOverlayClick?: boolean
}
export const ModalContext = createContext<ModalContextProps | undefined>(undefined)
export const useModals = () => {
const context = useContext(ModalContext)
if (!context) {
throw new Error('useModal must be used within a ModalProvider')
}
return context
}

View File

@ -0,0 +1,36 @@
import { Cog6ToothIcon, CommandLineIcon, FolderIcon } from '@heroicons/react/24/outline'
import { useEffect, useState } from 'react'
import StyledSidebar from '~/components/StyledSidebar'
import api from '~/lib/api'
const navigation = [
{ name: 'Apps', href: '/settings/apps', icon: CommandLineIcon, current: false },
{ name: 'ZIM Explorer', href: '/settings/zim', icon: FolderIcon, current: false },
{ name: 'System', href: '/settings/system', icon: Cog6ToothIcon, current: true },
]
export default function DocsLayout({ children }: { children: React.ReactNode }) {
const [docs, setDocs] = useState<Array<{ title: string; slug: string }>>([])
// Fetch docs when the component mounts
useEffect(() => {
fetchDocs()
}, [])
async function fetchDocs() {
try {
const data = await api.listDocs()
console.log('Fetched docs:', data)
setDocs(data)
} catch (error) {
console.error('Error fetching docs:', error)
}
}
return (
<div className="min-h-screen flex flex-row bg-stone-50/90">
<StyledSidebar title="Documentation" items={navigation} />
{children}
</div>
)
}

View File

@ -0,0 +1,17 @@
import { Cog6ToothIcon, CommandLineIcon, FolderIcon } from '@heroicons/react/24/outline'
import StyledSidebar from '~/components/StyledSidebar'
const navigation = [
{ name: 'Apps', href: '/settings/apps', icon: CommandLineIcon, current: false },
{ name: 'ZIM Explorer', href: '/settings/zim', icon: FolderIcon, current: false },
{ name: 'System', href: '/settings/system', icon: Cog6ToothIcon, current: true },
]
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex flex-row bg-stone-50/90">
<StyledSidebar title="Settings" items={navigation} />
{children}
</div>
)
}

37
admin/inertia/lib/api.ts Normal file
View File

@ -0,0 +1,37 @@
import axios from "axios";
class API {
private client;
constructor() {
this.client = axios.create({
baseURL: "/api",
headers: {
"Content-Type": "application/json",
},
});
}
async listDocs() {
try {
const response = await this.client.get<{ articles: Array<{ title: string; slug: string }> }>("/docs/list");
return response.data.articles;
} catch (error) {
console.error("Error listing docs:", error);
throw error;
}
}
async installService(service_name: string) {
try {
const response = await this.client.post<{ success: boolean; message: string }>("/system/services/install", { service_name });
return response.data;
} catch (error) {
console.error("Error installing service:", error);
throw error;
}
}
}
export default new API();

View File

@ -14,7 +14,7 @@ export function getServiceLink(ui_location: string): string {
const parsedPort = parseInt(ui_location, 10);
if (!isNaN(parsedPort)) {
// If it's a port number, return a link to the service on that port
return `http://localhost:${parsedPort}`;
return `http://${window.location.origin}:${parsedPort}`;
}
// Otherwise, treat it as a path
return `/${ui_location}`;

View File

@ -1,5 +0,0 @@
import { Transmit } from '@adonisjs/transmit-client'
export const transmit = new Transmit({
baseUrl: window.location.origin
})

49
admin/inertia/lib/util.ts Normal file
View File

@ -0,0 +1,49 @@
import axios from "axios";
export function capitalizeFirstLetter(str?: string | null): string {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1);
}
export async function testInternetConnection(): Promise<boolean> {
try {
const response = await axios.get('https://1.1.1.1/cdn-cgi/trace', {
timeout: 5000,
headers: {
'Cache-Control': 'no-cache',
}
});
return response.status === 200;
} catch (error) {
console.error("Error testing internet connection:", error);
return false;
}
}
export function generateRandomString(length: number): string {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
}
export function generateUUID(): string {
const arr = new Uint8Array(16);
if (window.crypto && window.crypto.getRandomValues) {
window.crypto.getRandomValues(arr);
} else {
// Fallback for non-secure contexts where window.crypto is not available
// This is not cryptographically secure, but can be used for non-critical purposes
for (let i = 0; i < 16; i++) {
arr[i] = Math.floor(Math.random() * 256);
}
}
arr[6] = (arr[6] & 0x0f) | 0x40; // Version 4
arr[8] = (arr[8] & 0x3f) | 0x80; // Variant bits
const hex = Array.from(arr, byte => byte.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}

View File

@ -0,0 +1,15 @@
import { Head } from '@inertiajs/react'
import MarkdocRenderer from '~/components/MarkdocRenderer'
import DocsLayout from '~/layouts/DocsLayout'
export default function Show({ content, title }: { content: any; title: string }) {
return (
<DocsLayout>
<Head title={`${title} | Documentation | Project N.O.M.A.D.`} />
<div className="xl:pl-80 py-6">
<h1 className='font-semibold text-xl'>{title}</h1>
<MarkdocRenderer content={content} />
</div>
</DocsLayout>
)
}

View File

@ -5,6 +5,7 @@ import {
IconHelp,
IconMapRoute,
IconMessageCircleSearch,
IconPlus,
IconSettings,
IconWifiOff,
} from '@tabler/icons-react'
@ -55,15 +56,25 @@ const NAV_ITEMS = [
const STATIC_ITEMS = [
{
label: 'Help',
to: '/help',
label: 'Install Apps',
to: '/settings/apps',
target: '',
description: 'Not seeing your favorite app? Install it here!',
icon: <IconPlus size={48} />,
installed: true,
},
{
label: 'Docs',
to: '/docs/home',
target: '',
description: 'Read Project N.O.M.A.D. manuals and guides',
icon: <IconHelp size={48} />,
installed: true,
},
{
label: 'Settings',
to: '/settings',
to: '/settings/system',
target: '',
description: 'Configure your N.O.M.A.D. settings',
icon: <IconSettings size={48} />,
installed: true,
@ -80,6 +91,7 @@ export default function Home(props: {
items.push({
label: service.service_name,
to: getServiceLink(service.ui_location),
target: '_blank',
description: `Access ${service.service_name} content`,
icon: <IconWifiOff size={48} />,
installed: service.installed,
@ -93,7 +105,7 @@ export default function Home(props: {
<Head title="Project N.O.M.A.D Command Center" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
{items.map((item) => (
<a key={item.label} href={item.to} target="_blank">
<a key={item.label} href={item.to} target={item.target}>
<div
key={item.label}
className="rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-black text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer"

View File

@ -0,0 +1,156 @@
import { Head } from '@inertiajs/react'
import StyledTable from '~/components/StyledTable'
import SettingsLayout from '~/layouts/SettingsLayout'
import { ServiceSlim } from '../../../types/services'
import { getServiceLink } from '~/lib/navigation'
import StyledButton from '~/components/StyledButton'
import { useModals } from '~/context/ModalContext'
import StyledModal from '~/components/StyledModal'
import api from '~/lib/api'
import { useEffect, useState } from 'react'
import InstallActivityFeed, { InstallActivityFeedProps } from '~/components/InstallActivityFeed'
import { useTransmit } from 'react-adonis-transmit'
export default function SettingsPage(props: { system: { services: ServiceSlim[] } }) {
const { openModal, closeAllModals } = useModals()
const { subscribe } = useTransmit()
const [installActivity, setInstallActivity] = useState<InstallActivityFeedProps['activity']>([])
const [isInstalling, setIsInstalling] = useState(false)
useEffect(() => {
const unsubscribe = subscribe('service-installation', (data: any) => {
console.log('Received service installation message:', data)
setInstallActivity((prev) => [
...prev,
{
service_name: data.service_name ?? 'unknown',
type: data.status ?? 'unknown',
timestamp: new Date().toISOString(),
message: data.message ?? 'No message provided',
},
])
})
return () => {
unsubscribe()
}
}, [])
useEffect(() => {
if (installActivity.length === 0) return
if (installActivity.some((activity) => activity.type === 'completed')) {
// If any activity is completed, we can clear the installActivity state
setTimeout(() => {
window.location.reload() // Reload the page to reflect changes
}, 3000) // Clear after 3 seconds
}
}, [installActivity])
const handleInstallService = (service: ServiceSlim) => {
openModal(
<StyledModal
title="Install Service?"
onConfirm={() => {
installService(service.service_name)
closeAllModals()
}}
onCancel={closeAllModals}
open={true}
confirmText="Install"
cancelText="Cancel"
>
<p className="text-gray-700">
Are you sure you want to install {service.service_name}? This will start the service and
make it available in your Project N.O.M.A.D. instance. It may take some time to complete.
</p>
</StyledModal>,
'install-service-modal'
)
}
async function installService(serviceName: string) {
try {
setIsInstalling(true)
const response = await api.installService(serviceName)
if (!response.success) {
throw new Error(response.message)
}
} catch (error) {
console.error('Error installing service:', error)
} finally {
setIsInstalling(false)
}
}
return (
<SettingsLayout>
<Head title="App Settings | 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">Apps</h1>
<p className="text-gray-500 mb-4">
Manage the applications that are available in your Project N.O.M.A.D. instance.
</p>
<StyledTable<ServiceSlim & { actions?: any }>
className="font-semibold"
rowLines={true}
columns={[
{ accessor: 'service_name', title: 'Name' },
{
accessor: 'ui_location',
title: 'Location',
render: (record) => (
<a
href={getServiceLink(record.ui_location)}
target="_blank"
rel="noopener noreferrer"
className="text-desert-green hover:underline font-semibold"
>
{record.ui_location}
</a>
),
},
{
accessor: 'installed',
title: 'Installed?',
render: (record) => (record.installed ? 'Yes' : 'No'),
},
{
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>
),
},
]}
data={props.system.services}
/>
{installActivity.length > 0 && (
<InstallActivityFeed activity={installActivity} className="mt-8" />
)}
</main>
</div>
</SettingsLayout>
)
}

View File

@ -1,218 +1,16 @@
import { useState } from 'react'
import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react'
import {
ChartBarSquareIcon,
Cog6ToothIcon,
FolderIcon,
GlobeAltIcon,
ServerIcon,
SignalIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'
import { Bars3Icon, MagnifyingGlassIcon } from '@heroicons/react/20/solid'
import { ChevronDownIcon } from '@heroicons/react/16/solid'
import classNames from '~/lib/classNames'
import { Head } from "@inertiajs/react";
const navigation = [
{ name: 'Projects', href: '#', icon: FolderIcon, current: false },
{ name: 'Deployments', href: '#', icon: ServerIcon, current: false },
{ name: 'Activity', href: '#', icon: SignalIcon, current: false },
{ name: 'Domains', href: '#', icon: GlobeAltIcon, current: false },
{ name: 'Usage', href: '#', icon: ChartBarSquareIcon, current: false },
{ name: 'Settings', href: '#', icon: Cog6ToothIcon, current: true },
]
const teams = [
{ id: 1, name: 'Planetaria', href: '#', initial: 'P', current: false },
{ id: 2, name: 'Protocol', href: '#', initial: 'P', current: false },
{ id: 3, name: 'Tailwind Labs', href: '#', initial: 'T', current: false },
]
const secondaryNavigation = [
{ name: 'Account', href: '#', current: true },
{ name: 'Notifications', href: '#', current: false },
{ name: 'Billing', href: '#', current: false },
{ name: 'Teams', href: '#', current: false },
{ name: 'Integrations', href: '#', current: false },
]
import { Head } from '@inertiajs/react'
import SettingsLayout from '~/layouts/SettingsLayout'
export default function SettingsPage() {
const [sidebarOpen, setSidebarOpen] = useState(false)
return (
<div>
<Dialog open={sidebarOpen} onClose={setSidebarOpen} className="relative z-50 xl:hidden">
<DialogBackdrop
transition
className="fixed inset-0 bg-gray-900/80 transition-opacity duration-300 ease-linear data-[closed]:opacity-0"
/>
<div className="fixed inset-0 flex">
<DialogPanel
transition
className="relative mr-16 flex w-full max-w-xs flex-1 transform transition duration-300 ease-in-out data-[closed]:-translate-x-full"
>
<TransitionChild>
<div className="absolute left-full top-0 flex w-16 justify-center pt-5 duration-300 ease-in-out data-[closed]:opacity-0">
<button
type="button"
onClick={() => setSidebarOpen(false)}
className="-m-2.5 p-2.5"
>
<span className="sr-only">Close sidebar</span>
<XMarkIcon aria-hidden="true" className="size-6 text-white" />
</button>
</div>
</TransitionChild>
{/* Sidebar component, swap this element with another sidebar if you like */}
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-gray-900 px-6 ring-1 ring-white/10">
<div className="flex h-16 shrink-0 items-center">
<img src="/project_nomad_logo.png" alt="Project Nomad Logo" className="h-12 w-12" />
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<a
href={item.href}
className={classNames(
item.current
? 'bg-gray-800 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
)}
>
<item.icon aria-hidden="true" className="size-6 shrink-0" />
{item.name}
</a>
</li>
))}
</ul>
</li>
<li>
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
<ul role="list" className="-mx-2 mt-2 space-y-1">
{teams.map((team) => (
<li key={team.name}>
<a
href={team.href}
className={classNames(
team.current
? 'bg-gray-800 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-gray-700 bg-gray-800 text-[0.625rem] font-medium text-gray-400 group-hover:text-white">
{team.initial}
</span>
<span className="truncate">{team.name}</span>
</a>
</li>
))}
</ul>
</li>
<li className="-mx-6 mt-auto">
<a
href="#"
className="flex items-center gap-x-4 px-6 py-3 text-sm/6 font-semibold text-white hover:bg-gray-800"
>
<img
alt=""
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
className="size-8 rounded-full bg-gray-800"
/>
<span className="sr-only">Your profile</span>
<span aria-hidden="true">Tom Cook</span>
</a>
</li>
</ul>
</nav>
</div>
</DialogPanel>
</div>
</Dialog>
{/* Static sidebar for desktop */}
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col">
{/* Sidebar component, swap this element with another sidebar if you like */}
<div className="flex grow flex-col gap-y-5 overflow-y-auto bg-black/10 px-6 ring-1 ring-white/5">
<div className="flex h-16 shrink-0 items-center">
<img
alt="Your Company"
src="https://tailwindcss.com/plus-assets/img/logos/mark.svg?color=indigo&shade=500"
className="h-8 w-auto"
/>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1">
{navigation.map((item) => (
<li key={item.name}>
<a
href={item.href}
className={classNames(
item.current
? 'bg-gray-800 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
)}
>
<item.icon aria-hidden="true" className="size-6 shrink-0" />
{item.name}
</a>
</li>
))}
</ul>
</li>
<li>
<div className="text-xs/6 font-semibold text-gray-400">Your teams</div>
<ul role="list" className="-mx-2 mt-2 space-y-1">
{teams.map((team) => (
<li key={team.name}>
<a
href={team.href}
className={classNames(
team.current
? 'bg-gray-800 text-white'
: 'text-gray-400 hover:bg-gray-800 hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
)}
>
<span className="flex size-6 shrink-0 items-center justify-center rounded-lg border border-gray-700 bg-gray-800 text-[0.625rem] font-medium text-gray-400 group-hover:text-white">
{team.initial}
</span>
<span className="truncate">{team.name}</span>
</a>
</li>
))}
</ul>
</li>
<li className="-mx-6 mt-auto">
<a
href="#"
className="flex items-center gap-x-4 px-6 py-3 text-sm/6 font-semibold text-white hover:bg-gray-800"
>
<img
alt=""
src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
className="size-8 rounded-full bg-gray-800"
/>
<span className="sr-only">Your profile</span>
<span aria-hidden="true">Tom Cook</span>
</a>
</li>
</ul>
</nav>
</div>
</div>
<SettingsLayout>
<Head title="Settings | Project N.O.M.A.D." />
<div className="xl:pl-72">
{/* Sticky search header */}
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-6 border-b border-white/5 bg-gray-900 px-4 shadow-sm sm:px-6 lg:px-8">
{/* <div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-6 border-b border-white/5 bg-gray-900 px-4 shadow-sm sm:px-6 lg:px-8">
<button
type="button"
onClick={() => setSidebarOpen(true)}
@ -237,29 +35,10 @@ export default function SettingsPage() {
/>
</form>
</div>
</div>
</div> */}
<main>
<h1 className="sr-only">Account Settings</h1>
<header className="border-b border-white/5">
{/* Secondary navigation */}
<nav className="flex overflow-x-auto py-4">
<ul
role="list"
className="flex min-w-full flex-none gap-x-6 px-4 text-sm/6 font-semibold text-gray-400 sm:px-6 lg:px-8"
>
{secondaryNavigation.map((item) => (
<li key={item.name}>
<a href={item.href} className={item.current ? 'text-indigo-400' : ''}>
{item.name}
</a>
</li>
))}
</ul>
</nav>
</header>
<h1 className="sr-only">Settings</h1>
{/* Settings forms */}
<div className="divide-y divide-white/5">
<div className="grid max-w-7xl grid-cols-1 gap-x-8 gap-y-10 px-4 py-16 sm:px-6 md:grid-cols-3 lg:px-8">
@ -526,6 +305,6 @@ export default function SettingsPage() {
</div>
</main>
</div>
</div>
</SettingsLayout>
)
}

View File

@ -0,0 +1,54 @@
import { useState, Fragment } from 'react'
import { ModalContext } from '~/context/ModalContext'
export interface ModalsProviderProps {
children: React.ReactNode
}
const ModalsProvider: React.FC<ModalsProviderProps> = ({ children }) => {
const [modals, setModals] = useState<Record<string, React.ReactNode>>({})
const [, setPreventCloseOnOverlayClick] = useState<boolean>(false)
const openModal = (content: React.ReactNode, id: string, preventClose = false) => {
setModals((prev) => ({
...prev,
[id]: content,
}))
setPreventCloseOnOverlayClick(preventClose)
}
const closeModal = (id: string) => {
setModals((prev) => {
const newModals = { ...prev }
delete newModals[id]
return newModals
})
if (Object.keys(modals).length === 1) {
setPreventCloseOnOverlayClick(false) // reset if last modal is closed
}
}
const closeAllModals = () => {
setModals({})
setPreventCloseOnOverlayClick(false) // reset
}
const _getCurrentModals = () => {
return modals
}
return (
<ModalContext.Provider value={{ openModal, closeModal, closeAllModals, _getCurrentModals }}>
{children}
{Object.keys(modals).length === 0 ? null : (
<>
{Object.entries(modals).map(([key, node]) => (
<Fragment key={key}>{node}</Fragment>
))}
</>
)}
</ModalContext.Provider>
)
}
export default ModalsProvider

167
admin/package-lock.json generated
View File

@ -24,6 +24,7 @@
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"@inertiajs/react": "^2.0.13",
"@markdoc/markdoc": "^0.5.2",
"@tabler/icons-react": "^3.34.0",
"@tailwindcss/vite": "^4.1.10",
"@vinejs/vine": "^3.0.1",
@ -38,6 +39,7 @@
"pino-pretty": "^13.0.0",
"postcss": "^8.5.6",
"react": "^19.1.0",
"react-adonis-transmit": "^1.0.1",
"react-dom": "^19.1.0",
"reflect-metadata": "^0.2.2",
"tailwindcss": "^4.1.10",
@ -2326,6 +2328,31 @@
"node": ">=8"
}
},
"node_modules/@markdoc/markdoc": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@markdoc/markdoc/-/markdoc-0.5.2.tgz",
"integrity": "sha512-clrqWpJ3+S8PXigRE+zBIs6LVZYXaJ7JTDFq1CcCWc4xpoB2kz+9qRUQQ4vXtLUjQ8ige1BGdMruV6gM/2gloA==",
"license": "MIT",
"engines": {
"node": ">=14.7.0"
},
"optionalDependencies": {
"@types/linkify-it": "^3.0.1",
"@types/markdown-it": "12.2.3"
},
"peerDependencies": {
"@types/react": "*",
"react": "*"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@ -3823,6 +3850,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/linkify-it": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/luxon": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
@ -3830,6 +3864,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "12.2.3",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-12.2.3.tgz",
"integrity": "sha512-GKMHFfv3458yYy+v/N8gjufHO6MSZKCOXpZc5GXIWWy8uldwfmPn98vp81gZ5f9SVw8YYBctgfJ22a2d7AOMeQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@types/linkify-it": "*",
"@types/mdurl": "*"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT",
"optional": true
},
"node_modules/@types/node": {
"version": "22.15.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.33.tgz",
@ -3862,7 +3914,7 @@
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@ -5306,7 +5358,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/data-uri-to-buffer": {
@ -5508,6 +5560,19 @@
"node": ">= 8.0"
}
},
"node_modules/dockerode/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/dotenv": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
@ -7960,6 +8025,18 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.4.tgz",
@ -8416,6 +8493,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
@ -9248,6 +9334,69 @@
"node": ">=0.10.0"
}
},
"node_modules/react-adonis-transmit": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/react-adonis-transmit/-/react-adonis-transmit-1.0.1.tgz",
"integrity": "sha512-S6vOylr4YqOIZiQrgYL73l99o3NUAqR23h2woq26GfJzwglaay9n9Pwwshp2Xp55oNYsfN3t1HxC0LH3zOGI5A==",
"license": "MIT",
"dependencies": {
"@adonisjs/transmit-client": "^1.0.0",
"react": "^18.0.0",
"react-test-renderer": "^18.0.0"
},
"peerDependencies": {
"@adonisjs/transmit-client": ">=1.0.0",
"react": ">=16.8.0"
}
},
"node_modules/react-adonis-transmit/node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-adonis-transmit/node_modules/react-shallow-renderer": {
"version": "16.15.0",
"resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
"integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==",
"license": "MIT",
"dependencies": {
"object-assign": "^4.1.1",
"react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
},
"peerDependencies": {
"react": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-adonis-transmit/node_modules/react-test-renderer": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.3.1.tgz",
"integrity": "sha512-KkAgygexHUkQqtvvx/otwxtuFu5cVjfzTCtjXLH9boS19/Nbtg84zS7wIQn39G8IlrhThBpQsMKkq5ZHZIYFXA==",
"license": "MIT",
"dependencies": {
"react-is": "^18.3.1",
"react-shallow-renderer": "^16.15.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^18.3.1"
}
},
"node_modules/react-adonis-transmit/node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@ -9264,7 +9413,6 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"devOptional": true,
"license": "MIT"
},
"node_modules/react-refresh": {
@ -10763,19 +10911,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",

View File

@ -69,6 +69,7 @@
"@headlessui/react": "^2.2.4",
"@heroicons/react": "^2.2.0",
"@inertiajs/react": "^2.0.13",
"@markdoc/markdoc": "^0.5.2",
"@tabler/icons-react": "^3.34.0",
"@tailwindcss/vite": "^4.1.10",
"@vinejs/vine": "^3.0.1",
@ -83,6 +84,7 @@
"pino-pretty": "^13.0.0",
"postcss": "^8.5.6",
"react": "^19.1.0",
"react-adonis-transmit": "^1.0.1",
"react-dom": "^19.1.0",
"reflect-metadata": "^0.2.2",
"tailwindcss": "^4.1.10",

View File

@ -6,19 +6,40 @@
| The routes file is used for defining the HTTP routes.
|
*/
import DocsController from '#controllers/docs_controller'
import HomeController from '#controllers/home_controller'
import SettingsController from '#controllers/settings_controller'
import SystemController from '#controllers/system_controller'
import router from '@adonisjs/core/services/router'
import transmit from '@adonisjs/transmit/services/main'
transmit.registerRoutes()
transmit.registerRoutes();
router.get('/home', [HomeController, 'index']);
router.get('/', [HomeController, 'index']);
router.get('/home', [HomeController, 'home']);
router.on('/about').renderInertia('about')
router.on('/settings').renderInertia('settings')
router.group(() => {
router.get('/system', [SettingsController, 'system'])
router.get('/apps', [SettingsController, 'apps'])
}).prefix('/settings')
router.group(() => {
router.get('/:slug', [DocsController, 'show'])
router.get('/', ({ inertia }) => {
return inertia.render('Docs/Index', {
title: "Documentation",
content: "Welcome to the documentation!"
});
});
}).prefix('/docs')
router.group(() => {
router.get('/list', [DocsController, 'list'])
}).prefix('/api/docs')
router.group(() => {
router.get('/services', [SystemController, 'getServices'])
router.post('/install-service', [SystemController, 'installService'])
router.post('/services/install', [SystemController, 'installService'])
router.post('/simulate-sse', [SystemController, 'simulateSSE'])
}).prefix('/api/system')

4
admin/types/services.ts Normal file
View File

@ -0,0 +1,4 @@
import Service from "#models/service";
export type ServiceSlim = Pick<Service, 'id' | 'service_name' | 'installed' | 'ui_location'>;

View File

@ -198,6 +198,13 @@ create_nomad_directory(){
echo -e "${YELLOW}#${RESET} Creating directory for Project N.O.M.A.D at $nomad_dir...\\n"
sudo mkdir -p "$nomad_dir"
sudo chown "$(whoami):$(whoami)" "$nomad_dir"
# Also ensure the directory has a /storage/logs/ subdirectory
sudo mkdir -p "${nomad_dir}/storage/logs"
# Create a admin.log file in the logs directory
sudo touch "${nomad_dir}/storage/logs/admin.log"
echo -e "${GREEN}#${RESET} Directory created successfully.\\n"
else
echo -e "${GREEN}#${RESET} Directory $nomad_dir already exists.\\n"