diff --git a/README.md b/README.md index 9f7c2f0..0394c0b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/admin/app/controllers/docs_controller.ts b/admin/app/controllers/docs_controller.ts new file mode 100644 index 0000000..ad69e2a --- /dev/null +++ b/admin/app/controllers/docs_controller.ts @@ -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" + }); + } +} \ No newline at end of file diff --git a/admin/app/controllers/home_controller.ts b/admin/app/controllers/home_controller.ts index 4c1141e..e66219b 100644 --- a/admin/app/controllers/home_controller.ts +++ b/admin/app/controllers/home_controller.ts @@ -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: { diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts new file mode 100644 index 0000000..7f1aafe --- /dev/null +++ b/admin/app/controllers/settings_controller.ts @@ -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 + } + }); + } +} \ No newline at end of file diff --git a/admin/app/controllers/system_controller.ts b/admin/app/controllers/system_controller.ts index aee3f0f..a805984 100644 --- a/admin/app/controllers/system_controller.ts +++ b/admin/app/controllers/system_controller.ts @@ -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' }) + } } \ No newline at end of file diff --git a/admin/app/services/docker_service.ts b/admin/app/services/docker_service.ts index 8c9750b..5d3068b 100644 --- a/admin/app/services/docker_service.ts +++ b/admin/app/services/docker_service.ts @@ -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,32 +74,27 @@ export class DockerService { */ async _createContainer(service: Service, containerConfig: any): Promise { - 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, Cmd: service.container_command.split(' '), @@ -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 { @@ -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 { + // 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}`, + }); + } + } } \ No newline at end of file diff --git a/admin/app/services/docs_service.ts b/admin/app/services/docs_service.ts new file mode 100644 index 0000000..31bcd89 --- /dev/null +++ b/admin/app/services/docs_service.ts @@ -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 } + } + } + } + } + } +} \ No newline at end of file diff --git a/admin/config/logger.ts b/admin/config/logger.ts index 38e7972..9f7b7dc 100644 --- a/admin/config/logger.ts +++ b/admin/config/logger.ts @@ -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 {} + export interface LoggersList extends InferLoggers { } } diff --git a/admin/database/seeders/service_seeder.ts b/admin/database/seeders/service_seeder.ts index 692ad67..94695bf 100644 --- a/admin/database/seeders/service_seeder.ts +++ b/admin/database/seeders/service_seeder.ts @@ -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() { diff --git a/admin/docs/home.md b/admin/docs/home.md new file mode 100644 index 0000000..829b744 --- /dev/null +++ b/admin/docs/home.md @@ -0,0 +1 @@ +# This is a markdown file! \ No newline at end of file diff --git a/admin/inertia/app/app.tsx b/admin/inertia/app/app.tsx index f38f59c..e76722c 100644 --- a/admin/inertia/app/app.tsx +++ b/admin/inertia/app/app.tsx @@ -1,12 +1,23 @@ /// /// -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(); - + createRoot(el).render( + + + + + + ) }, -}); \ No newline at end of file +}) diff --git a/admin/inertia/components/InstallActivityFeed.tsx b/admin/inertia/components/InstallActivityFeed.tsx new file mode 100644 index 0000000..d7a742d --- /dev/null +++ b/admin/inertia/components/InstallActivityFeed.tsx @@ -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 = ({ activity, className }) => { + return ( +
+

Installation Activity

+
    + {activity.map((activityItem, activityItemIdx) => ( +
  • +
    +
    +
    + <> +
    + {activityItem.type === 'completed' ? ( +
  • + ))} +
+
+ ) +} + +export default InstallActivityFeed diff --git a/admin/inertia/components/LoadingSpinner.tsx b/admin/inertia/components/LoadingSpinner.tsx new file mode 100644 index 0000000..0569cf2 --- /dev/null +++ b/admin/inertia/components/LoadingSpinner.tsx @@ -0,0 +1,40 @@ +interface LoadingSpinnerProps { + text?: string + fullscreen?: boolean + iconOnly?: boolean + light?: boolean + className?: string +} + +const LoadingSpinner: React.FC = ({ + text, + fullscreen = true, + iconOnly = false, + light = false, + className, +}) => { + if (!fullscreen) { + return ( +
+
+ {!iconOnly && ( +
+ {text || 'Loading...'} +
+ )} +
+ ) + } + + return ( +
+
+
{!iconOnly && {text || 'Loading'}}
+
+
+ ) +} + +export default LoadingSpinner diff --git a/admin/inertia/components/MarkdocRenderer.tsx b/admin/inertia/components/MarkdocRenderer.tsx new file mode 100644 index 0000000..7549b07 --- /dev/null +++ b/admin/inertia/components/MarkdocRenderer.tsx @@ -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 +
+ {title &&

{title}

} + {children} +
+ ) +} + +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 + + {children} + + ) +} + +// Component mapping for Markdoc +const components = { + Callout, + Heading, +} + +interface MarkdocRendererProps { + content: any // Markdoc transformed content +} + +const MarkdocRenderer: React.FC = ({ content }) => { + return ( +
+ {Markdoc.renderers.react(content, React, { components })} +
+ ) +} + +export default MarkdocRenderer diff --git a/admin/inertia/components/StyledButton.tsx b/admin/inertia/components/StyledButton.tsx new file mode 100644 index 0000000..fa84183 --- /dev/null +++ b/admin/inertia/components/StyledButton.tsx @@ -0,0 +1,81 @@ +import * as Icons from '@heroicons/react/24/outline' +import { useMemo } from 'react' + +interface StyledButtonProps extends React.HTMLAttributes { + 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 = ({ + 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 ? : 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) => { + if (isDisabled) { + e.preventDefault() + return + } + props.onClick?.(e) + } + + return ( + + ) +} + +export default StyledButton diff --git a/admin/inertia/components/StyledModal.tsx b/admin/inertia/components/StyledModal.tsx new file mode 100644 index 0000000..15b51dd --- /dev/null +++ b/admin/inertia/components/StyledModal.tsx @@ -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 = ({ + children, + title, + open, + onClose, + cancelText = 'Cancel', + confirmText = 'Confirm', + onCancel, + onConfirm, + icon, +}) => { + return ( + { + if (onClose) onClose() + }} + className="relative z-50" + > + +
+
+ +
+ {icon &&
{icon}
} +
+ + {title} + +
{children}
+
+
+
+ { + if (onCancel) onCancel() + }} + > + {cancelText} + + { + if (onConfirm) onConfirm() + }} + > + {confirmText} + +
+
+
+
+
+ ) +} + +export default StyledModal diff --git a/admin/inertia/components/StyledSidebar.tsx b/admin/inertia/components/StyledSidebar.tsx new file mode 100644 index 0000000..8f1e097 --- /dev/null +++ b/admin/inertia/components/StyledSidebar.tsx @@ -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 = ({ 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 ( +
  • + + +
  • + ) + } + + const Sidebar = () => { + return ( +
    +
    + Project Nomad Logo +

    {title}

    +
    + +
    + ) + } + + return ( + <> + + {/* Mobile sidebar */} + + + +
    + + +
    + +
    +
    + +
    +
    +
    + {/* Desktop sidebar */} +
    + +
    + + ) +} + +export default StyledSidebar diff --git a/admin/inertia/components/StyledTable.tsx b/admin/inertia/components/StyledTable.tsx new file mode 100644 index 0000000..88964ad --- /dev/null +++ b/admin/inertia/components/StyledTable.tsx @@ -0,0 +1,97 @@ +import { capitalizeFirstLetter } from '~/lib/util' +import classNames from '~/lib/classNames' +import LoadingSpinner from '~/components/LoadingSpinner' + +interface StyledTableProps> { + loading?: boolean + tableProps?: React.HTMLAttributes + 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({ + loading = false, + tableProps = {}, + data = [], + noDataText = 'No records found', + onRowClick, + columns = [], + className = '', +}: StyledTableProps) { + const { className: tableClassName, ...restTableProps } = tableProps + + return ( +
    + + + + {columns.map((column, index) => ( + + ))} + + + + {!loading && + data.length !== 0 && + data.map((record, recordIdx) => ( + onRowClick?.(record)} + className={onRowClick ? `cursor-pointer hover:bg-gray-100 ` : ''} + > + {columns.map((column, index) => ( + + ))} + + ))} + {!loading && data.length === 0 && ( + + + + )} + {loading && ( + + + + )} + +
    + {column.title ?? capitalizeFirstLetter(column.accessor.toString())} +
    + {column.render + ? column.render(record, index) + : (record[column.accessor] as React.ReactNode)} +
    + {noDataText} +
    + +
    +
    + ) +} + +export default StyledTable diff --git a/admin/inertia/context/ModalContext.ts b/admin/inertia/context/ModalContext.ts new file mode 100644 index 0000000..1d11e04 --- /dev/null +++ b/admin/inertia/context/ModalContext.ts @@ -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 + preventCloseOnOverlayClick?: boolean +} + +export const ModalContext = createContext(undefined) + +export const useModals = () => { + const context = useContext(ModalContext) + if (!context) { + throw new Error('useModal must be used within a ModalProvider') + } + return context +} diff --git a/admin/inertia/layouts/DocsLayout.tsx b/admin/inertia/layouts/DocsLayout.tsx new file mode 100644 index 0000000..a45eab1 --- /dev/null +++ b/admin/inertia/layouts/DocsLayout.tsx @@ -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>([]) + + // 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 ( +
    + + {children} +
    + ) +} diff --git a/admin/inertia/layouts/SettingsLayout.tsx b/admin/inertia/layouts/SettingsLayout.tsx new file mode 100644 index 0000000..c94de80 --- /dev/null +++ b/admin/inertia/layouts/SettingsLayout.tsx @@ -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 ( +
    + + {children} +
    + ) +} diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts new file mode 100644 index 0000000..e186a61 --- /dev/null +++ b/admin/inertia/lib/api.ts @@ -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(); \ No newline at end of file diff --git a/admin/inertia/lib/navigation.ts b/admin/inertia/lib/navigation.ts index db67108..3085320 100644 --- a/admin/inertia/lib/navigation.ts +++ b/admin/inertia/lib/navigation.ts @@ -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}`; diff --git a/admin/inertia/lib/transmit.ts b/admin/inertia/lib/transmit.ts deleted file mode 100644 index e3f0da1..0000000 --- a/admin/inertia/lib/transmit.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Transmit } from '@adonisjs/transmit-client' - -export const transmit = new Transmit({ - baseUrl: window.location.origin -}) diff --git a/admin/inertia/lib/util.ts b/admin/inertia/lib/util.ts new file mode 100644 index 0000000..b22498c --- /dev/null +++ b/admin/inertia/lib/util.ts @@ -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 { + 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)}`; +} \ No newline at end of file diff --git a/admin/inertia/pages/docs/show.tsx b/admin/inertia/pages/docs/show.tsx new file mode 100644 index 0000000..d54ed34 --- /dev/null +++ b/admin/inertia/pages/docs/show.tsx @@ -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 ( + + +
    +

    {title}

    + +
    +
    + ) +} diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx index 73f6aa9..b96d953 100644 --- a/admin/inertia/pages/home.tsx +++ b/admin/inertia/pages/home.tsx @@ -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: , + installed: true, + }, + { + label: 'Docs', + to: '/docs/home', + target: '', description: 'Read Project N.O.M.A.D. manuals and guides', icon: , installed: true, }, { label: 'Settings', - to: '/settings', + to: '/settings/system', + target: '', description: 'Configure your N.O.M.A.D. settings', icon: , 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: , installed: service.installed, @@ -93,7 +105,7 @@ export default function Home(props: {
    {items.map((item) => ( - +
    ([]) + 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( + { + installService(service.service_name) + closeAllModals() + }} + onCancel={closeAllModals} + open={true} + confirmText="Install" + cancelText="Cancel" + > +

    + 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. +

    +
    , + '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 ( + + +
    +
    +

    Apps

    +

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

    + + className="font-semibold" + rowLines={true} + columns={[ + { accessor: 'service_name', title: 'Name' }, + { + accessor: 'ui_location', + title: 'Location', + render: (record) => ( +
    + {record.ui_location} + + ), + }, + { + accessor: 'installed', + title: 'Installed?', + render: (record) => (record.installed ? 'Yes' : 'No'), + }, + { + accessor: 'actions', + title: 'Actions', + render: (record) => ( +
    + {record.installed ? ( + { + window.open(getServiceLink(record.ui_location), '_blank') + }} + > + Open + + ) : ( + handleInstallService(record)} + disabled={isInstalling} + loading={isInstalling} + > + Install + + )} +
    + ), + }, + ]} + data={props.system.services} + /> + {installActivity.length > 0 && ( + + )} +
    +
    + + ) +} diff --git a/admin/inertia/pages/settings.tsx b/admin/inertia/pages/settings/system.tsx similarity index 58% rename from admin/inertia/pages/settings.tsx rename to admin/inertia/pages/settings/system.tsx index 6a1bec2..06cf09f 100644 --- a/admin/inertia/pages/settings.tsx +++ b/admin/inertia/pages/settings/system.tsx @@ -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 ( -
    - - - -
    - - -
    - -
    -
    - - {/* Sidebar component, swap this element with another sidebar if you like */} -
    -
    - Project Nomad Logo -
    - -
    -
    -
    -
    - - {/* Static sidebar for desktop */} -
    - {/* Sidebar component, swap this element with another sidebar if you like */} -
    -
    - Your Company -
    - -
    -
    - + +
    {/* Sticky search header */} -
    + {/*
    -
    +
    */}
    -

    Account Settings

    - -
    - {/* Secondary navigation */} - -
    - +

    Settings

    {/* Settings forms */}
    @@ -526,6 +305,6 @@ export default function SettingsPage() {
    -
    + ) } diff --git a/admin/inertia/providers/ModalProvider.tsx b/admin/inertia/providers/ModalProvider.tsx new file mode 100644 index 0000000..ebd23f0 --- /dev/null +++ b/admin/inertia/providers/ModalProvider.tsx @@ -0,0 +1,54 @@ +import { useState, Fragment } from 'react' +import { ModalContext } from '~/context/ModalContext' + +export interface ModalsProviderProps { + children: React.ReactNode +} + +const ModalsProvider: React.FC = ({ children }) => { + const [modals, setModals] = useState>({}) + const [, setPreventCloseOnOverlayClick] = useState(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 ( + + {children} + {Object.keys(modals).length === 0 ? null : ( + <> + {Object.entries(modals).map(([key, node]) => ( + {node} + ))} + + )} + + ) +} + +export default ModalsProvider diff --git a/admin/package-lock.json b/admin/package-lock.json index d7ed3c8..ef614ba 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -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", diff --git a/admin/package.json b/admin/package.json index 281028b..26bb478 100644 --- a/admin/package.json +++ b/admin/package.json @@ -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", diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 8fc53b3..2f9ec21 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -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') \ No newline at end of file diff --git a/admin/types/services.ts b/admin/types/services.ts new file mode 100644 index 0000000..1cd3734 --- /dev/null +++ b/admin/types/services.ts @@ -0,0 +1,4 @@ +import Service from "#models/service"; + + +export type ServiceSlim = Pick; \ No newline at end of file diff --git a/install/install_nomad.sh b/install/install_nomad.sh index 95ad1eb..6e1da0a 100644 --- a/install/install_nomad.sh +++ b/install/install_nomad.sh @@ -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"