mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-03-28 03:29:25 +01:00
feat: init app installation and documentation
This commit is contained in:
parent
7c593409be
commit
39d75c9cdf
|
|
@ -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.
|
||||
|
|
|
|||
23
admin/app/controllers/docs_controller.ts
Normal file
23
admin/app/controllers/docs_controller.ts
Normal 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"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
28
admin/app/controllers/settings_controller.ts
Normal file
28
admin/app/controllers/settings_controller.ts
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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' })
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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,
|
||||
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<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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
64
admin/app/services/docs_service.ts
Normal file
64
admin/app/services/docs_service.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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> { }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1
admin/docs/home.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
# This is a markdown 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>
|
||||
)
|
||||
},
|
||||
});
|
||||
})
|
||||
|
|
|
|||
66
admin/inertia/components/InstallActivityFeed.tsx
Normal file
66
admin/inertia/components/InstallActivityFeed.tsx
Normal 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
|
||||
40
admin/inertia/components/LoadingSpinner.tsx
Normal file
40
admin/inertia/components/LoadingSpinner.tsx
Normal 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
|
||||
75
admin/inertia/components/MarkdocRenderer.tsx
Normal file
75
admin/inertia/components/MarkdocRenderer.tsx
Normal 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
|
||||
81
admin/inertia/components/StyledButton.tsx
Normal file
81
admin/inertia/components/StyledButton.tsx
Normal 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
|
||||
80
admin/inertia/components/StyledModal.tsx
Normal file
80
admin/inertia/components/StyledModal.tsx
Normal 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
|
||||
125
admin/inertia/components/StyledSidebar.tsx
Normal file
125
admin/inertia/components/StyledSidebar.tsx
Normal 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
|
||||
97
admin/inertia/components/StyledTable.tsx
Normal file
97
admin/inertia/components/StyledTable.tsx
Normal 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
|
||||
19
admin/inertia/context/ModalContext.ts
Normal file
19
admin/inertia/context/ModalContext.ts
Normal 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
|
||||
}
|
||||
36
admin/inertia/layouts/DocsLayout.tsx
Normal file
36
admin/inertia/layouts/DocsLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
admin/inertia/layouts/SettingsLayout.tsx
Normal file
17
admin/inertia/layouts/SettingsLayout.tsx
Normal 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
37
admin/inertia/lib/api.ts
Normal 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();
|
||||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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
49
admin/inertia/lib/util.ts
Normal 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)}`;
|
||||
}
|
||||
15
admin/inertia/pages/docs/show.tsx
Normal file
15
admin/inertia/pages/docs/show.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
156
admin/inertia/pages/settings/apps.tsx
Normal file
156
admin/inertia/pages/settings/apps.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
54
admin/inertia/providers/ModalProvider.tsx
Normal file
54
admin/inertia/providers/ModalProvider.tsx
Normal 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
167
admin/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
4
admin/types/services.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import Service from "#models/service";
|
||||
|
||||
|
||||
export type ServiceSlim = Pick<Service, 'id' | 'service_name' | 'installed' | 'ui_location'>;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user