Merge branch 'main' into test-merge

This commit is contained in:
nlovlyn 2026-03-23 18:19:41 -07:00 committed by GitHub
commit d86b9caddb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
105 changed files with 1956 additions and 867 deletions

View File

@ -12,3 +12,6 @@ contact_links:
- name: 🤝 Contributing Guide
url: https://github.com/Crosstalk-Solutions/project-nomad/blob/main/CONTRIBUTING.md
about: Learn how to contribute to Project N.O.M.A.D.
- name: 📅 Roadmap
url: https://roadmap.projectnomad.us
about: See our public roadmap, vote on features, and suggest new ones

View File

@ -6,13 +6,17 @@ body:
- type: markdown
attributes:
value: |
Thanks for your interest in improving Project N.O.M.A.D.!
Thanks for your interest in improving Project N.O.M.A.D.! Before you submit a feature request, consider checking our [roadmap](https://roadmap.projectnomad.us) to see if it's already planned or in progress. You're welcome to suggest new ideas there if you don't plan on opening PRs yourself.
**Please note:** Feature requests are not guaranteed to be implemented. All requests are evaluated based on alignment with the project's goals, feasibility, and community demand.
**Before submitting:**
- Search existing feature requests to avoid duplicates
- Search existing feature requests and our [roadmap](https://roadmap.projectnomad.us) to avoid duplicates
- Consider if this aligns with N.O.M.A.D.'s mission: offline-first knowledge and education
- Consider the technical feasibility of the feature: N.O.M.A.D. is designed to be containerized and run on a wide range of hardware, so features that require heavy resources (aside from GPU-intensive tasks) or complex host configurations may be less likely to be implemented
- Consider the scope of the feature: Small, focused enhancements that can be implemented incrementally are more likely to be implemented than large, broad features that would require significant development effort or have an unclear path forward
- If you're able to contribute code, testing, or documentation, that significantly increases the chances of your feature being implemented
- type: dropdown
id: feature-category

View File

@ -0,0 +1,51 @@
name: Build Disk Collector Image
on:
workflow_dispatch:
inputs:
version:
description: 'Semantic version to label the Docker image under (no "v" prefix, e.g. "1.2.3")'
required: true
type: string
tag_latest:
description: 'Also tag this image as :latest?'
required: false
type: boolean
default: false
jobs:
check_authorization:
name: Check authorization to publish new Docker image
runs-on: ubuntu-latest
outputs:
isAuthorized: ${{ steps.check-auth.outputs.is_authorized }}
steps:
- name: check-auth
id: check-auth
run: echo "is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}" >> $GITHUB_OUTPUT
build:
name: Build disk-collector image
needs: check_authorization
if: needs.check_authorization.outputs.isAuthorized == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: install/sidecar-disk-collector
push: true
tags: |
ghcr.io/crosstalk-solutions/project-nomad-disk-collector:${{ inputs.version }}
ghcr.io/crosstalk-solutions/project-nomad-disk-collector:v${{ inputs.version }}
${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest' || '' }}

View File

@ -1,4 +1,4 @@
name: Build Docker Image
name: Build Primary Docker Image
on:
workflow_dispatch:

View File

@ -0,0 +1,51 @@
name: Build Sidecar Updater Image
on:
workflow_dispatch:
inputs:
version:
description: 'Semantic version to label the Docker image under (no "v" prefix, e.g. "1.2.3")'
required: true
type: string
tag_latest:
description: 'Also tag this image as :latest?'
required: false
type: boolean
default: false
jobs:
check_authorization:
name: Check authorization to publish new Docker image
runs-on: ubuntu-latest
outputs:
isAuthorized: ${{ steps.check-auth.outputs.is_authorized }}
steps:
- name: check-auth
id: check-auth
run: echo "is_authorized=${{ contains(secrets.DEPLOYMENT_AUTHORIZED_USERS, github.triggering_actor) }}" >> $GITHUB_OUTPUT
build:
name: Build sidecar-updater image
needs: check_authorization
if: needs.check_authorization.outputs.isAuthorized == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: install/sidecar-updater
push: true
tags: |
ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:${{ inputs.version }}
ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:v${{ inputs.version }}
${{ inputs.tag_latest && 'ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest' || '' }}

View File

@ -45,7 +45,14 @@ COPY --from=production-deps /app/node_modules /app/node_modules
COPY --from=build /app/build /app
# Copy root package.json for version info
COPY package.json /app/version.json
# Copy docs and README for access within the container
COPY admin/docs /app/docs
COPY README.md /app/README.md
# Copy entrypoint script and ensure it's executable
COPY install/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8080
CMD ["node", "./bin/server.js"]
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@ -21,13 +21,16 @@ Project N.O.M.A.D. can be installed on any Debian-based operating system (we rec
*Note: sudo/root privileges are required to run the install script*
#### Quick Install
#### Quick Install (Debian-based OS Only)
```bash
sudo apt-get update && sudo apt-get install -y curl && curl -fsSL https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/install_nomad.sh -o install_nomad.sh && sudo bash install_nomad.sh
```
Project N.O.M.A.D. is now installed on your device! Open a browser and navigate to `http://localhost:8080` (or `http://DEVICE_IP:8080`) to start exploring!
### Advanced Installation
For more control over the installation process, copy and paste the [Docker Compose template](https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yaml) into a `docker-compose.yml` file and customize it to your liking (be sure to replace any placeholders with your actual values). Then, run `docker compose up -d` to start the Command Center and its dependencies. Note: this method is recommended for advanced users only, as it requires familiarity with Docker and manual configuration before starting.
## How It Works
N.O.M.A.D. is a management UI ("Command Center") and API that orchestrates a collection of containerized tools and resources via [Docker](https://www.docker.com/). It handles installation, configuration, and updates for everything — so you don't have to.
@ -80,7 +83,7 @@ To run LLM's and other included AI tools:
- OS: Debian-based (Ubuntu recommended)
- Stable internet connection (required during install only)
**For detailed build recommendations at three price points ($200$800+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
**For detailed build recommendations at three price points ($150$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
Again, Project N.O.M.A.D. itself is quite lightweight - it's the tools and resources you choose to install with N.O.M.A.D. that will determine the specs required for your unique deployment

View File

@ -15,4 +15,9 @@ export default class DownloadsController {
const payload = await request.validateUsing(downloadJobsByFiletypeSchema)
return this.downloadService.listDownloadJobs(payload.params.filetype)
}
async removeJob({ params }: HttpContext) {
await this.downloadService.removeFailedJob(params.jobId)
return { success: true }
}
}

View File

@ -83,7 +83,7 @@ export default class MapsController {
})
}
const styles = await this.mapService.generateStylesJSON(request.host())
const styles = await this.mapService.generateStylesJSON(request.host(), request.protocol())
return response.json(styles)
}

View File

@ -39,6 +39,10 @@ export default class SettingsController {
return inertia.render('settings/legal');
}
async support({ inertia }: HttpContext) {
return inertia.render('settings/support');
}
async maps({ inertia }: HttpContext) {
const baseAssetsCheck = await this.mapService.ensureBaseAssets();
const regionFiles = await this.mapService.listRegions();

View File

@ -113,6 +113,11 @@ export default class SystemController {
return await this.systemService.subscribeToReleaseNotes(reqData.email);
}
async getDebugInfo({}: HttpContext) {
const debugInfo = await this.systemService.getDebugInfo()
return { debugInfo }
}
async checkServiceUpdates({ response }: HttpContext) {
await CheckServiceUpdatesJob.dispatch()
response.send({ success: true, message: 'Service update check dispatched' })

View File

@ -1,4 +1,4 @@
import { Job } from 'bullmq'
import { Job, UnrecoverableError } from 'bullmq'
import { QueueService } from '#services/queue_service'
import { EmbedJobWithProgress } from '../../types/rag.js'
import { RagService } from '#services/rag_service'
@ -42,7 +42,15 @@ export class EmbedFileJob {
const ragService = new RagService(dockerService, ollamaService)
try {
// Check if Ollama and Qdrant services are ready
// Check if Ollama and Qdrant services are installed and ready
// Use UnrecoverableError for "not installed" so BullMQ won't retry —
// retrying 30x when the service doesn't exist just wastes Redis connections
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
if (!ollamaUrl) {
logger.warn('[EmbedFileJob] Ollama is not installed. Skipping embedding for: %s', fileName)
throw new UnrecoverableError('Ollama service is not installed. Install AI Assistant to enable file embeddings.')
}
const existingModels = await ollamaService.getModels()
if (!existingModels) {
logger.warn('[EmbedFileJob] Ollama service not ready yet. Will retry...')
@ -51,8 +59,8 @@ export class EmbedFileJob {
const qdrantUrl = await dockerService.getServiceURL('nomad_qdrant')
if (!qdrantUrl) {
logger.warn('[EmbedFileJob] Qdrant service not ready yet. Will retry...')
throw new Error('Qdrant service not ready yet')
logger.warn('[EmbedFileJob] Qdrant is not installed. Skipping embedding for: %s', fileName)
throw new UnrecoverableError('Qdrant service is not installed. Install AI Assistant to enable file embeddings.')
}
logger.info(`[EmbedFileJob] Services ready. Processing file: ${fileName}`)

View File

@ -82,14 +82,17 @@ export class RunDownloadJob {
const zimService = new ZimService(dockerService)
await zimService.downloadRemoteSuccessCallback([url], true)
// Dispatch an embedding job for the downloaded ZIM file
try {
await EmbedFileJob.dispatch({
fileName: url.split('/').pop() || '',
filePath: filepath,
})
} catch (error) {
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
// Only dispatch embedding job if AI Assistant (Ollama) is installed
const ollamaUrl = await dockerService.getServiceURL('nomad_ollama')
if (ollamaUrl) {
try {
await EmbedFileJob.dispatch({
fileName: url.split('/').pop() || '',
filePath: filepath,
})
} catch (error) {
console.error(`[RunDownloadJob] Error dispatching EmbedFileJob for URL ${url}:`, error)
}
}
} else if (filetype === 'map') {
const mapsService = new MapService()

View File

@ -691,6 +691,7 @@ export class DockerService {
const runtimes = dockerInfo.Runtimes || {}
if ('nvidia' in runtimes) {
logger.info('[DockerService] NVIDIA container runtime detected via Docker API')
await this._persistGPUType('nvidia')
return { type: 'nvidia' }
}
} catch (error) {
@ -722,12 +723,26 @@ export class DockerService {
)
if (amdCheck.trim()) {
logger.info('[DockerService] AMD GPU detected via lspci')
await this._persistGPUType('amd')
return { type: 'amd' }
}
} catch (error) {
// lspci not available, continue
}
// Last resort: check if we previously detected a GPU and it's likely still present.
// This handles cases where live detection fails transiently (e.g., Docker daemon
// hiccup, runtime temporarily unavailable) but the hardware hasn't changed.
try {
const savedType = await KVStore.getValue('gpu.type')
if (savedType === 'nvidia' || savedType === 'amd') {
logger.info(`[DockerService] No GPU detected live, but KV store has '${savedType}' from previous detection. Using saved value.`)
return { type: savedType as 'nvidia' | 'amd' }
}
} catch {
// KV store not available, continue
}
logger.info('[DockerService] No GPU detected')
return { type: 'none' }
} catch (error) {
@ -736,6 +751,15 @@ export class DockerService {
}
}
private async _persistGPUType(type: 'nvidia' | 'amd'): Promise<void> {
try {
await KVStore.setValue('gpu.type', type)
logger.info(`[DockerService] Persisted GPU type '${type}' to KV store`)
} catch (error) {
logger.warn(`[DockerService] Failed to persist GPU type: ${error.message}`)
}
}
/**
* Discover AMD GPU DRI devices dynamically.
* Returns an array of device configurations for Docker.
@ -853,6 +877,45 @@ export class DockerService {
this._broadcast(serviceName, 'update-creating', `Creating updated container...`)
const hostConfig = inspectData.HostConfig || {}
// Re-run GPU detection for Ollama so updates always reflect the current GPU environment.
// This handles cases where the NVIDIA Container Toolkit was installed after the initial
// Ollama setup, and ensures DeviceRequests are always built fresh rather than relying on
// round-tripping the Docker inspect format back into the create API.
let updatedDeviceRequests: any[] | undefined = undefined
if (serviceName === SERVICE_NAMES.OLLAMA) {
const gpuResult = await this._detectGPUType()
if (gpuResult.type === 'nvidia') {
this._broadcast(
serviceName,
'update-gpu-config',
`NVIDIA container runtime detected. Configuring updated container with GPU support...`
)
updatedDeviceRequests = [
{
Driver: 'nvidia',
Count: -1,
Capabilities: [['gpu']],
},
]
} else if (gpuResult.type === 'amd') {
this._broadcast(
serviceName,
'update-gpu-config',
`AMD GPU detected. ROCm GPU acceleration is not yet supported — using CPU-only configuration.`
)
} else if (gpuResult.toolkitMissing) {
this._broadcast(
serviceName,
'update-gpu-config',
`NVIDIA GPU detected but NVIDIA Container Toolkit is not installed. Using CPU-only configuration. Install the toolkit and reinstall AI Assistant for GPU acceleration: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html`
)
} else {
this._broadcast(serviceName, 'update-gpu-config', `No GPU detected. Using CPU-only configuration.`)
}
}
const newContainerConfig: any = {
Image: newImage,
name: serviceName,
@ -865,7 +928,7 @@ export class DockerService {
Binds: hostConfig.Binds || undefined,
PortBindings: hostConfig.PortBindings || undefined,
RestartPolicy: hostConfig.RestartPolicy || undefined,
DeviceRequests: hostConfig.DeviceRequests || undefined,
DeviceRequests: serviceName === SERVICE_NAMES.OLLAMA ? updatedDeviceRequests : (hostConfig.DeviceRequests || undefined),
Devices: hostConfig.Devices || undefined,
},
NetworkingConfig: inspectData.NetworkSettings?.Networks

View File

@ -12,7 +12,7 @@ export class DownloadService {
async listDownloadJobs(filetype?: string): Promise<DownloadJobWithProgress[]> {
// Get regular file download jobs (zim, map, etc.)
const queue = this.queueService.getQueue(RunDownloadJob.queue)
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed'])
const fileJobs = await queue.getJobs(['waiting', 'active', 'delayed', 'failed'])
const fileDownloads = fileJobs.map((job) => ({
jobId: job.id!.toString(),
@ -20,11 +20,13 @@ export class DownloadService {
progress: parseInt(job.progress.toString(), 10),
filepath: normalize(job.data.filepath),
filetype: job.data.filetype,
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
failedReason: job.failedReason || undefined,
}))
// Get Ollama model download jobs
const modelQueue = this.queueService.getQueue(DownloadModelJob.queue)
const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed'])
const modelJobs = await modelQueue.getJobs(['waiting', 'active', 'delayed', 'failed'])
const modelDownloads = modelJobs.map((job) => ({
jobId: job.id!.toString(),
@ -32,6 +34,8 @@ export class DownloadService {
progress: parseInt(job.progress.toString(), 10),
filepath: job.data.modelName || 'Unknown Model', // Use model name as filepath
filetype: 'model',
status: (job.failedReason ? 'failed' : 'active') as 'active' | 'failed',
failedReason: job.failedReason || undefined,
}))
const allDownloads = [...fileDownloads, ...modelDownloads]
@ -39,7 +43,22 @@ export class DownloadService {
// Filter by filetype if specified
const filtered = allDownloads.filter((job) => !filetype || job.filetype === filetype)
// Sort so actively downloading items (progress > 0) appear first, then by progress descending
return filtered.sort((a, b) => b.progress - a.progress)
// Sort: active downloads first (by progress desc), then failed at the bottom
return filtered.sort((a, b) => {
if (a.status === 'failed' && b.status !== 'failed') return 1
if (a.status !== 'failed' && b.status === 'failed') return -1
return b.progress - a.progress
})
}
async removeFailedJob(jobId: string): Promise<void> {
for (const queueName of [RunDownloadJob.queue, DownloadModelJob.queue]) {
const queue = this.queueService.getQueue(queueName)
const job = await queue.getJob(jobId)
if (job) {
await job.remove()
return
}
}
}
}

View File

@ -260,7 +260,7 @@ export class MapService implements IMapService {
}
}
async generateStylesJSON(host: string | null = null): Promise<BaseStylesFile> {
async generateStylesJSON(host: string | null = null, protocol: string = 'http'): Promise<BaseStylesFile> {
if (!(await this.checkBaseAssetsExist())) {
throw new Error('Base map assets are missing from storage/maps')
}
@ -281,8 +281,8 @@ export class MapService implements IMapService {
* e.g. user is accessing from "example.com", but we would by default generate "localhost:8080/..." so maps would
* fail to load.
*/
const sources = this.generateSourcesArray(host, regions)
const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir)
const sources = this.generateSourcesArray(host, regions, protocol)
const baseUrl = this.getPublicFileBaseUrl(host, this.basemapsAssetsDir, protocol)
const styles = await this.generateStylesFile(
rawStyles,
@ -342,9 +342,9 @@ export class MapService implements IMapService {
return await listDirectoryContentsRecursive(this.baseDirPath)
}
private generateSourcesArray(host: string | null, regions: FileEntry[]): BaseStylesFile['sources'][] {
private generateSourcesArray(host: string | null, regions: FileEntry[], protocol: string = 'http'): BaseStylesFile['sources'][] {
const sources: BaseStylesFile['sources'][] = []
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles')
const baseUrl = this.getPublicFileBaseUrl(host, 'pmtiles', protocol)
for (const region of regions) {
if (region.type === 'file' && region.name.endsWith('.pmtiles')) {
@ -433,7 +433,7 @@ export class MapService implements IMapService {
/*
* Gets the appropriate public URL for a map asset depending on environment
*/
private getPublicFileBaseUrl(specifiedHost: string | null, childPath: string): string {
private getPublicFileBaseUrl(specifiedHost: string | null, childPath: string, protocol: string = 'http'): string {
function getHost() {
try {
const localUrlRaw = env.get('URL')
@ -447,7 +447,7 @@ export class MapService implements IMapService {
}
const host = specifiedHost || getHost()
const withProtocol = host.startsWith('http') ? host : `http://${host}`
const withProtocol = host.startsWith('http') ? host : `${protocol}://${host}`
const baseUrlPath =
process.env.NODE_ENV === 'production' ? childPath : urlJoin(this.mapStoragePath, childPath)

View File

@ -410,6 +410,117 @@ export class SystemService {
}
}
async getDebugInfo(): Promise<string> {
const appVersion = SystemService.getAppVersion()
const environment = process.env.NODE_ENV || 'unknown'
const [systemInfo, services, internetStatus, versionCheck] = await Promise.all([
this.getSystemInfo(),
this.getServices({ installedOnly: false }),
this.getInternetStatus().catch(() => null),
this.checkLatestVersion().catch(() => null),
])
const lines: string[] = [
'Project NOMAD Debug Info',
'========================',
`App Version: ${appVersion}`,
`Environment: ${environment}`,
]
if (systemInfo) {
const { cpu, mem, os, disk, fsSize, uptime, graphics } = systemInfo
lines.push('')
lines.push('System:')
if (os.distro) lines.push(` OS: ${os.distro}`)
if (os.hostname) lines.push(` Hostname: ${os.hostname}`)
if (os.kernel) lines.push(` Kernel: ${os.kernel}`)
if (os.arch) lines.push(` Architecture: ${os.arch}`)
if (uptime?.uptime) lines.push(` Uptime: ${this._formatUptime(uptime.uptime)}`)
lines.push('')
lines.push('Hardware:')
if (cpu.brand) {
lines.push(` CPU: ${cpu.brand} (${cpu.cores} cores)`)
}
if (mem.total) {
const total = this._formatBytes(mem.total)
const used = this._formatBytes(mem.total - (mem.available || 0))
const available = this._formatBytes(mem.available || 0)
lines.push(` RAM: ${total} total, ${used} used, ${available} available`)
}
if (graphics.controllers && graphics.controllers.length > 0) {
for (const gpu of graphics.controllers) {
const vram = gpu.vram ? ` (${gpu.vram} MB VRAM)` : ''
lines.push(` GPU: ${gpu.model}${vram}`)
}
} else {
lines.push(' GPU: None detected')
}
// Disk info — try disk array first, fall back to fsSize
const diskEntries = disk.filter((d) => d.totalSize > 0)
if (diskEntries.length > 0) {
for (const d of diskEntries) {
const size = this._formatBytes(d.totalSize)
const type = d.tran?.toUpperCase() || (d.rota ? 'HDD' : 'SSD')
lines.push(` Disk: ${size}, ${Math.round(d.percentUsed)}% used, ${type}`)
}
} else if (fsSize.length > 0) {
const realFs = fsSize.filter((f) => f.fs.startsWith('/dev/'))
const seen = new Set<number>()
for (const f of realFs) {
if (seen.has(f.size)) continue
seen.add(f.size)
lines.push(` Disk: ${this._formatBytes(f.size)}, ${Math.round(f.use)}% used`)
}
}
}
const installed = services.filter((s) => s.installed)
lines.push('')
if (installed.length > 0) {
lines.push('Installed Services:')
for (const svc of installed) {
lines.push(` ${svc.friendly_name} (${svc.service_name}): ${svc.status}`)
}
} else {
lines.push('Installed Services: None')
}
if (internetStatus !== null) {
lines.push('')
lines.push(`Internet Status: ${internetStatus ? 'Online' : 'Offline'}`)
}
if (versionCheck?.success) {
const updateMsg = versionCheck.updateAvailable
? `Yes (${versionCheck.latestVersion} available)`
: `No (${versionCheck.currentVersion} is latest)`
lines.push(`Update Available: ${updateMsg}`)
}
return lines.join('\n')
}
private _formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h ${minutes}m`
if (hours > 0) return `${hours}h ${minutes}m`
return `${minutes}m`
}
private _formatBytes(bytes: number, decimals = 1): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i]
}
async updateSetting(key: KVStoreKey, value: any): Promise<void> {
if ((value === '' || value === undefined || value === null) && KV_STORE_SCHEMA[key] === 'string') {
await KVStore.clearValue(key)
@ -468,10 +579,21 @@ export class SystemService {
return []
}
// Deduplicate: same device path mounted in multiple places (Docker bind-mounts)
// Keep the entry with the largest size — that's the real partition
const deduped = new Map<string, NomadDiskInfoRaw['fsSize'][0]>()
for (const entry of fsSize) {
const existing = deduped.get(entry.fs)
if (!existing || entry.size > existing.size) {
deduped.set(entry.fs, entry)
}
}
const dedupedFsSize = Array.from(deduped.values())
return diskLayout.blockdevices
.filter((disk) => disk.type === 'disk') // Only physical disks
.map((disk) => {
const filesystems = getAllFilesystems(disk, fsSize)
const filesystems = getAllFilesystems(disk, dedupedFsSize)
// Across all partitions
const totalUsed = filesystems.reduce((sum, p) => sum + (p.used || 0), 0)

View File

@ -88,10 +88,29 @@ export async function doResumableDownload({
let lastProgressTime = Date.now()
let lastDownloadedBytes = startByte
// Stall detection: if no data arrives for 5 minutes, abort the download
const STALL_TIMEOUT_MS = 5 * 60 * 1000
let stallTimer: ReturnType<typeof setTimeout> | null = null
const clearStallTimer = () => {
if (stallTimer) {
clearTimeout(stallTimer)
stallTimer = null
}
}
const resetStallTimer = () => {
clearStallTimer()
stallTimer = setTimeout(() => {
cleanup(new Error('Download stalled - no data received for 5 minutes'))
}, STALL_TIMEOUT_MS)
}
// Progress tracking stream to monitor data flow
const progressStream = new Transform({
transform(chunk: Buffer, _: any, callback: Function) {
downloadedBytes += chunk.length
resetStallTimer()
// Update progress tracking
const now = Date.now()
@ -118,6 +137,7 @@ export async function doResumableDownload({
// Handle errors and cleanup
const cleanup = (error?: Error) => {
clearStallTimer()
progressStream.destroy()
response.data.destroy()
writeStream.destroy()
@ -136,6 +156,7 @@ export async function doResumableDownload({
})
writeStream.on('finish', async () => {
clearStallTimer()
if (onProgress) {
onProgress({
downloadedBytes,
@ -151,7 +172,8 @@ export async function doResumableDownload({
resolve(filepath)
})
// Pipe: response -> progressStream -> writeStream
// Start stall timer and pipe: response -> progressStream -> writeStream
resetStallTimer()
response.data.pipe(progressStream).pipe(writeStream)
})
}

View File

@ -138,14 +138,13 @@ export function matchesDevice(fsPath: string, deviceName: string): boolean {
// Remove /dev/ and /dev/mapper/ prefixes
const normalized = fsPath.replace('/dev/mapper/', '').replace('/dev/', '')
// Direct match
// Direct match (covers /dev/sda1 ↔ sda1, /dev/nvme0n1p1 ↔ nvme0n1p1)
if (normalized === deviceName) {
return true
}
// LVM volumes use dashes instead of slashes
// e.g., ubuntu--vg-ubuntu--lv matches the device name
if (fsPath.includes(deviceName)) {
// LVM/device-mapper: e.g., /dev/mapper/ubuntu--vg-ubuntu--lv contains "ubuntu--lv"
if (fsPath.startsWith('/dev/mapper/') && fsPath.includes(deviceName)) {
return true
}

View File

@ -65,8 +65,23 @@ export default class QueueWork extends BaseCommand {
}
)
worker.on('failed', (job, err) => {
worker.on('failed', async (job, err) => {
this.logger.error(`[${queueName}] Job failed: ${job?.id}, Error: ${err.message}`)
// If this was a Wikipedia download, mark it as failed in the DB
if (job?.data?.filetype === 'zim' && job?.data?.url?.includes('wikipedia_en_')) {
try {
const { DockerService } = await import('#services/docker_service')
const { ZimService } = await import('#services/zim_service')
const dockerService = new DockerService()
const zimService = new ZimService(dockerService)
await zimService.onWikipediaDownloadComplete(job.data.url, false)
} catch (e: any) {
this.logger.error(
`[${queueName}] Failed to update Wikipedia status: ${e.message}`
)
}
}
})
worker.on('completed', (job) => {

View File

@ -18,7 +18,7 @@ const loggerConfig = defineConfig({
targets:
targets()
.pushIf(!app.inProduction, targets.pretty())
.pushIf(app.inProduction, targets.file({ destination: "/app/storage/logs/admin.log" }))
.pushIf(app.inProduction, targets.file({ destination: "/app/storage/logs/admin.log", mkdir: true }))
.toArray(),
},
},

View File

@ -3,7 +3,7 @@ import { defineConfig } from '@adonisjs/transmit'
import { redis } from '@adonisjs/transmit/transports'
export default defineConfig({
pingInterval: false,
pingInterval: '30s',
transport: {
driver: redis({
host: env.get('REDIS_HOST'),

View File

@ -1,3 +1,3 @@
import { KVStoreKey } from "../types/kv_store.js";
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'system.earlyAccess', 'ai.assistantCustomName'];
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName'];

View File

@ -13,12 +13,12 @@ No — that's the whole point. Once your content is downloaded, everything works
### What hardware do I need?
N.O.M.A.D. is designed for capable hardware, especially if you want to use the AI features. Recommended:
- Modern multi-core CPU
- Modern multi-core CPU (AMD Ryzen 7 with Radeon graphics is the community sweet spot)
- 16GB+ RAM (32GB+ for best AI performance)
- SSD storage (size depends on content — 500GB minimum, 2TB+ recommended)
- SSD storage (size depends on content — 500GB minimum, 1TB+ recommended)
- NVIDIA or AMD GPU recommended for faster AI responses
**For detailed build recommendations at three price points ($200$800+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
**For detailed build recommendations at three price points ($150$1,000+), see the [Hardware Guide](https://www.projectnomad.us/hardware).**
### How much storage do I need?
It depends on what you download:

View File

@ -1,5 +1,38 @@
# Release Notes
## Version 1.30.0 - March 20, 2026
### Features
- **Night Ops**: Added our most requested feature — a dark mode theme for the Command Center interface! Activate it from the footer and enjoy the sleek new look during your late-night missions. Thanks @chriscrosstalk for the contribution!
- **Debug Info**: Added a new "Debug Info" modal accessible from the footer that provides detailed system and application information for troubleshooting and support. Thanks @chriscrosstalk for the contribution!
- **Support the Project**: Added a new "Support the Project" page in settings with links to community resources, donation options, and ways to contribute.
- **Install**: The main Nomad image is now fully self-contained and directly usable with Docker Compose, allowing for more flexible and customizable installations without relying on external scripts. The image remains fully backwards compatible with existing installations, and the install script has been updated to reflect the simpler deployment process.
### Bug Fixes
- **Settings**: Storage usage display now prefers real block devices over tempfs. Thanks @Bortlesboat for the fix!
- **Settings**: Fixed an issue where device matching and mount entry deduplication logic could cause incorrect storage usage reporting and missing devices in storage displays.
- **Maps**: The Maps page now respects the request protocol (http vs https) to ensure map tiles load correctly. Thanks @davidgross for the bug report!
- **Knowledge Base**: Fixed an issue where file embedding jobs could cause a retry storm if the Ollama service was unavailable. Thanks @skyam25 for the bug report!
- **Curated Collections**: Fixed some broken links in the curated collections definitions (maps and ZIM files) that were causing some resources to fail to download.
- **Easy Setup**: Fixed an issue where the "Start Here" badge would persist even after visiting the Easy Setup Wizard for the first time. Thanks @chriscrosstalk for the fix!
- **UI**: Fixed an issue where the loading spinner could look strange in certain use cases.
- **System Updates**: Fixed an issue where the update banner would persist even after the system was updated successfully. Thanks @chriscrosstalk for the fix!
- **Performance**: Various small memory leak fixes and performance improvements across the UI to ensure a smoother experience.
### Improvements
- **Ollama**: Improved GPU detection logic to ensure the latest GPU config is always passed to the Ollama container on update
- **Ollama**: The detected GPU type is now persisted in the database for more reliable configuration and troubleshooting across updates and restarts. Thanks @chriscrosstalk for the contribution!
- **Downloads**: Users can now dismiss failed download notifications to reduce clutter in the UI. Thanks @chriscrosstalk for the contribution!
- **Logging**: Changed the default log level to "info" to reduce noise and focus on important messages. Thanks @traxeon for the suggestion!
- **Logging**: Nomad's internal logger now creates it's own log directory on startup if it doesn't already exist to prevent errors on fresh installs where the logs directory hasn't been created yet.
- **Dozzle**: Dozzle shell access and container actions are now disabled by default. Thanks @traxeon for the recommendation!
- **MySQL & Redis**: Removed port exposure to host by default for improved security. Ports can still be exposed manually if needed. Thanks @traxeon for the recommendation!
- **Dependencies**: Various dependency updates to close security vulnerabilities and improve stability
- **Utility Scripts**: Added a check for the expected Docker Compose version (v2) in all utility scripts to provide clearer error messages and guidance if the environment is not set up correctly.
- **Utility Scripts**: Added an additional warning to the installation script to inform about potential overwriting of existing customized configurations and the importance of backing up data before running the installation script again.
- **Documentation**: Updated installation instructions to reflect the new option for manual deployment via Docker Compose without the install script.
## Version 1.29.0 - March 11, 2026
### Features

View File

@ -1,281 +0,0 @@
# Project NOMAD Security Audit Report
**Date:** 2026-03-08
**Version audited:** v1.28.0 (main branch)
**Auditor:** Claude Code (automated + manual review)
**Target:** Pre-launch security review
---
## Executive Summary
Project NOMAD's codebase is **reasonably clean for a LAN appliance**, with no critical authentication bypasses or remote code execution vulnerabilities. However, there are **4 findings that should be fixed before public launch** — all are straightforward path traversal and SSRF issues with known fix patterns already used elsewhere in the codebase.
| Severity | Count | Summary |
|----------|-------|---------|
| **HIGH** | 4 | Path traversal (3), SSRF (1) |
| **MEDIUM** | 5 | Dozzle shell, unvalidated settings read, content update URL injection, verbose errors, no rate limiting |
| **LOW** | 5 | CSRF disabled, CORS wildcard, debug logging, npm dep CVEs, hardcoded HMAC |
| **INFO** | 2 | No auth by design, Docker socket exposure by design |
---
## Scans Performed
| Scan | Tool | Result |
|------|------|--------|
| Dependency audit | `npm audit` | 2 CVEs (1 high, 1 moderate) |
| Secret scan | Manual grep (passwords, keys, tokens, certs) | Clean — all secrets from env vars |
| SAST | Semgrep (security-audit, OWASP, nodejs rulesets) | 0 findings (AdonisJS not in rulesets) |
| Docker config review | Manual review of compose, Dockerfiles, scripts | 2 actionable findings |
| Code review | Manual review of services, controllers, validators | 4 path traversal + 1 SSRF |
| API endpoint audit | Manual review of all 60+ routes | Attack surface documented |
| DAST (OWASP ZAP) | Skipped — Docker Desktop not running | Recommended as follow-up |
---
## FIX BEFORE LAUNCH
### 1. Path Traversal — ZIM File Delete (HIGH)
**File:** `admin/app/services/zim_service.ts:329-342`
**Endpoint:** `DELETE /api/zim/:filename`
The `filename` parameter flows into `path.join()` with no directory containment check. An attacker can delete `.zim` files outside the storage directory:
```
DELETE /api/zim/..%2F..%2Fsome-file.zim
```
**Fix:** Resolve the full path and verify it starts with the expected storage directory:
```typescript
async delete(file: string): Promise<void> {
let fileName = file
if (!fileName.endsWith('.zim')) {
fileName += '.zim'
}
const basePath = join(process.cwd(), ZIM_STORAGE_PATH)
const fullPath = resolve(basePath, fileName)
// Prevent path traversal
if (!fullPath.startsWith(basePath)) {
throw new Error('Invalid filename')
}
// ... rest of delete logic
}
```
This pattern is already used correctly in `rag_service.ts:deleteFileBySource()`.
---
### 2. Path Traversal — Map File Delete (HIGH)
**File:** `admin/app/services/map_service.ts` (delete method)
**Endpoint:** `DELETE /api/maps/:filename`
Identical pattern to the ZIM delete. Same fix — resolve path, verify `startsWith(basePath)`.
---
### 3. Path Traversal — Documentation Read (HIGH)
**File:** `admin/app/services/docs_service.ts:61-83`
**Endpoint:** `GET /docs/:slug`
The `slug` parameter flows into `path.join(this.docsPath, filename)` with no containment check. An attacker can read arbitrary `.md` files on the filesystem:
```
GET /docs/..%2F..%2F..%2Fetc%2Fpasswd
```
Limited by the mandatory `.md` extension, but could still read sensitive markdown files outside the docs directory (like CLAUDE.md, README.md, etc.).
**Fix:**
```typescript
const basePath = this.docsPath
const fullPath = path.resolve(basePath, filename)
if (!fullPath.startsWith(path.resolve(basePath))) {
throw new Error('Invalid document slug')
}
```
---
### 4. SSRF — Download Endpoints (HIGH)
**File:** `admin/app/validators/common.ts`
**Endpoints:** `POST /api/zim/download-remote`, `POST /api/maps/download-remote`, `POST /api/maps/download-base-assets`, `POST /api/maps/download-remote-preflight`
The download endpoints accept user-supplied URLs and the server fetches from them. Without validation, an attacker on the LAN (or via CSRF since `shield.ts` disables CSRF protection) could make NOMAD fetch from co-located services:
- `http://localhost:3306` (MySQL)
- `http://localhost:6379` (Redis)
- `http://169.254.169.254/` (cloud metadata — if NOMAD is ever cloud-hosted)
**Fix:** Added `assertNotPrivateUrl()` that blocks loopback and link-local addresses before any download is initiated. Called in all download controllers.
**Scope note:** RFC1918 private addresses (10.x, 172.16-31.x, 192.168.x) are intentionally **allowed** because NOMAD is a LAN appliance and users may host content mirrors on their local network. The `require_tld: false` VineJS option is preserved so URLs like `http://my-nas:8080/file.zim` remain valid.
```typescript
const blockedPatterns = [
/^localhost$/,
/^127\.\d+\.\d+\.\d+$/,
/^0\.0\.0\.0$/,
/^169\.254\.\d+\.\d+$/, // Link-local / cloud metadata
/^\[::1\]$/,
/^\[?fe80:/i, // IPv6 link-local
]
```
---
## FIX AFTER LAUNCH (Medium Priority)
### 5. Dozzle Web Shell Access (MEDIUM)
**File:** `install/management_compose.yaml:56`
```yaml
- DOZZLE_ENABLE_SHELL=true
```
Dozzle on port 9999 is bound to all interfaces with shell access enabled. Anyone on the LAN can open a web shell into containers, including `nomad_admin` which has the Docker socket mounted. This creates a path from "LAN access" → "container shell" → "Docker socket" → "host root."
**Fix:** Set `DOZZLE_ENABLE_SHELL=false`. Log viewing and container restart functionality are preserved.
---
### 6. Unvalidated Settings Key Read (MEDIUM)
**File:** `admin/app/controllers/settings_controller.ts`
**Endpoint:** `GET /api/system/settings?key=...`
The `updateSetting` endpoint validates the key against an enum, but `getSetting` accepts any arbitrary key string. Currently harmless since the KV store only contains settings data, but could leak sensitive info if new keys are added.
**Fix:** Apply the same enum validation to the read endpoint.
---
### 7. Content Update URL Injection (MEDIUM)
**File:** `admin/app/validators/common.ts:72-88`
**Endpoint:** `POST /api/content-updates/apply`
The `download_url` comes directly from the client request body. An attacker can supply any URL and NOMAD will download from it. The URL should be looked up server-side from the content manifest instead.
**Fix:** Validate `download_url` against the cached manifest, or apply the same loopback/link-local protections as finding #4 (already applied in this PR).
---
### 8. Verbose Error Messages (MEDIUM)
**Files:** `rag_controller.ts`, `docker_service.ts`, `system_update_service.ts`
Several controllers return raw `error.message` in API responses, potentially leaking internal paths, stack details, or Docker error messages to the client.
**Fix:** Return generic error messages in production. Log the details server-side.
---
### 9. No Rate Limiting (MEDIUM)
Zero rate limiting across all 60+ endpoints. While acceptable for a LAN appliance, some endpoints are particularly abusable:
- `POST /api/benchmark/run` — spins up Docker containers for CPU/memory/disk stress tests
- `POST /api/rag/upload` — file uploads (20MB limit per bodyparser config)
- `POST /api/system/services/affect` — can stop/start any service repeatedly
**Fix:** Consider basic rate limiting on the benchmark and service control endpoints (e.g., 1 benchmark per minute, service actions throttled to prevent rapid cycling).
---
## LOW PRIORITY / ACCEPTED RISK
### 10. CSRF Protection Disabled (LOW)
**File:** `admin/config/shield.ts`
CSRF is disabled, meaning any website a LAN user visits could fire requests at NOMAD's API. This amplifies findings 1-4 — path traversal and SSRF could be triggered by a malicious webpage, not just direct LAN access.
**Assessment:** Acceptable for a LAN appliance with no auth system. Enabling CSRF would require significant auth/session infrastructure changes.
### 11. CORS Wildcard with Credentials (LOW)
**File:** `admin/config/cors.ts`
`origin: ['*']` with `credentials: true`. Standard for LAN appliances.
### 12. npm Dependency CVEs (LOW)
```
tar <=7.5.9 HIGH Hardlink Path Traversal via Drive-Relative Linkpath
ajv <6.14.0 MODERATE ReDoS when using $data option
```
Both fixable via `npm audit fix`. Low practical risk since these are build/dev dependencies not directly exposed to user input.
**Fix:** Run `npm audit fix` and commit the updated lockfile.
### 13. Hardcoded HMAC Secret (LOW)
**File:** `admin/app/services/benchmark_service.ts:35`
The benchmark HMAC secret `'nomad-benchmark-v1-2026'` is hardcoded in open-source code. Anyone can forge leaderboard submissions.
**Assessment:** Accepted risk. The leaderboard has compensating controls (rate limiting, plausibility validation, hardware fingerprint dedup). The secret stops casual abuse, not determined attackers.
### 14. Production Debug Logging (LOW)
**File:** `install/management_compose.yaml:22`
```yaml
LOG_LEVEL=debug
```
Debug logging in production can expose internal state in log files.
**Fix:** Change to `LOG_LEVEL=info` for production compose template.
---
## INFORMATIONAL (By Design)
### No Authentication
All 60+ API endpoints are unauthenticated. This is by design — NOMAD is a LAN appliance and the network boundary is the access control. Issue #73 tracks the edge case of public IP interfaces.
### Docker Socket Exposure
The `nomad_admin` container mounts `/var/run/docker.sock`. This is necessary for NOMAD's core functionality (managing Docker containers). The socket is not exposed to the network — only the admin container can use it.
---
## Recommendations Summary
| Priority | Action | Effort |
|----------|--------|--------|
| **Before launch** | Fix 3 path traversals (ZIM delete, Map delete, Docs read) | ~30 min |
| **Before launch** | Add SSRF protection to download URL validators | ~1 hour |
| **Soon after** | Disable Dozzle shell access | 1 line change |
| **Soon after** | Validate settings key on read endpoint | ~15 min |
| **Soon after** | Sanitize error messages in responses | ~30 min |
| **Nice to have** | Run `npm audit fix` | 5 min |
| **Nice to have** | Change production log level to info | 1 line change |
| **Follow-up** | OWASP ZAP dynamic scan against NOMAD3 | ~1 hour |
---
## What Went Right
- **No hardcoded secrets** — all credentials properly use environment variables
- **No command injection** — Docker operations use the Docker API (dockerode), not shell commands
- **No SQL injection** — all database queries use AdonisJS Lucid ORM with parameterized queries
- **No eval/Function** — no dynamic code execution anywhere
- **RAG service already has the correct fix pattern**`deleteFileBySource()` uses `resolve()` + `startsWith()` for path containment
- **Install script generates strong random passwords** — uses `/dev/urandom` for APP_KEY and DB passwords
- **No privileged containers** — GPU passthrough uses DeviceRequests, not --privileged
- **Health checks don't leak data** — internal-only calls

View File

@ -11,6 +11,7 @@ import { generateUUID } from '~/lib/util'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import NotificationsProvider from '~/providers/NotificationProvider'
import { ThemeProvider } from '~/providers/ThemeProvider'
import { UsePageProps } from '../../types/system'
const appName = import.meta.env.VITE_APP_NAME || 'Project N.O.M.A.D.'
@ -38,14 +39,16 @@ createInertiaApp({
const showDevtools = ['development', 'staging'].includes(environment)
createRoot(el).render(
<QueryClientProvider client={queryClient}>
<TransmitProvider baseUrl={window.location.origin} enableLogging={true}>
<NotificationsProvider>
<ModalsProvider>
<App {...props} />
{showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />}
</ModalsProvider>
</NotificationsProvider>
</TransmitProvider>
<ThemeProvider>
<TransmitProvider baseUrl={window.location.origin} enableLogging={environment === 'development'}>
<NotificationsProvider>
<ModalsProvider>
<App {...props} />
{showDevtools && <ReactQueryDevtools initialIsOpen={false} buttonPosition='bottom-left' />}
</ModalsProvider>
</NotificationsProvider>
</TransmitProvider>
</ThemeProvider>
</QueryClientProvider>
)
},

View File

@ -2,6 +2,8 @@ import useDownloads, { useDownloadsProps } from '~/hooks/useDownloads'
import HorizontalBarChart from './HorizontalBarChart'
import { extractFileName } from '~/lib/util'
import StyledSectionHeader from './StyledSectionHeader'
import { IconAlertTriangle, IconX } from '@tabler/icons-react'
import api from '~/lib/api'
interface ActiveDownloadProps {
filetype?: useDownloadsProps['filetype']
@ -9,7 +11,12 @@ interface ActiveDownloadProps {
}
const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps) => {
const { data: downloads } = useDownloads({ filetype })
const { data: downloads, invalidate } = useDownloads({ filetype })
const handleDismiss = async (jobId: string) => {
await api.removeDownloadJob(jobId)
invalidate()
}
return (
<>
@ -17,22 +24,50 @@ const ActiveDownloads = ({ filetype, withHeader = false }: ActiveDownloadProps)
<div className="space-y-4">
{downloads && downloads.length > 0 ? (
downloads.map((download) => (
<div className="bg-desert-white rounded-lg p-4 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<HorizontalBarChart
items={[
{
label: extractFileName(download.filepath) || download.url,
value: download.progress,
total: '100%',
used: `${download.progress}%`,
type: download.filetype,
},
]}
/>
<div
key={download.jobId}
className={`bg-desert-white rounded-lg p-4 border shadow-sm hover:shadow-lg transition-shadow ${
download.status === 'failed'
? 'border-red-300'
: 'border-desert-stone-light'
}`}
>
{download.status === 'failed' ? (
<div className="flex items-center gap-2">
<IconAlertTriangle className="w-5 h-5 text-red-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{extractFileName(download.filepath) || download.url}
</p>
<p className="text-xs text-red-600 mt-0.5">
Download failed{download.failedReason ? `: ${download.failedReason}` : ''}
</p>
</div>
<button
onClick={() => handleDismiss(download.jobId)}
className="flex-shrink-0 p-1 rounded hover:bg-red-100 transition-colors"
title="Dismiss failed download"
>
<IconX className="w-4 h-4 text-red-400 hover:text-red-600" />
</button>
</div>
) : (
<HorizontalBarChart
items={[
{
label: extractFileName(download.filepath) || download.url,
value: download.progress,
total: '100%',
used: `${download.progress}%`,
type: download.filetype,
},
]}
/>
)}
</div>
))
) : (
<p className="text-gray-500">No active downloads</p>
<p className="text-text-muted">No active downloads</p>
)}
</div>
</>

View File

@ -35,7 +35,7 @@ const ActiveEmbedJobs = ({ withHeader = false }: ActiveEmbedJobsProps) => {
</div>
))
) : (
<p className="text-gray-500">No files are currently being processed</p>
<p className="text-text-muted">No files are currently being processed</p>
)}
</div>
</>

View File

@ -33,7 +33,7 @@ const ActiveModelDownloads = ({ withHeader = false }: ActiveModelDownloadsProps)
</div>
))
) : (
<p className="text-gray-500">No active model downloads</p>
<p className="text-text-muted">No active model downloads</p>
)}
</div>
</>

View File

@ -43,7 +43,7 @@ export default function Alert({
}
const getIconColor = () => {
if (variant === 'solid') return 'text-desert-white'
if (variant === 'solid') return 'text-white'
switch (type) {
case 'warning':
return 'text-desert-orange'
@ -81,15 +81,15 @@ export default function Alert({
case 'solid':
variantStyles.push(
type === 'warning'
? 'bg-desert-orange text-desert-white border border-desert-orange-dark'
? 'bg-desert-orange text-white border border-desert-orange-dark'
: type === 'error'
? 'bg-desert-red text-desert-white border border-desert-red-dark'
? 'bg-desert-red text-white border border-desert-red-dark'
: type === 'success'
? 'bg-desert-olive text-desert-white border border-desert-olive-dark'
? 'bg-desert-olive text-white border border-desert-olive-dark'
: type === 'info'
? 'bg-desert-green text-desert-white border border-desert-green-dark'
? 'bg-desert-green text-white border border-desert-green-dark'
: type === 'info-inverted'
? 'bg-desert-tan text-desert-white border border-desert-tan-dark'
? 'bg-desert-tan text-white border border-desert-tan-dark'
: ''
)
return classNames(baseStyles, 'shadow-lg', ...variantStyles)
@ -112,7 +112,7 @@ export default function Alert({
}
const getTitleColor = () => {
if (variant === 'solid') return 'text-desert-white'
if (variant === 'solid') return 'text-white'
switch (type) {
case 'warning':
@ -131,7 +131,7 @@ export default function Alert({
}
const getMessageColor = () => {
if (variant === 'solid') return 'text-desert-white text-opacity-90'
if (variant === 'solid') return 'text-white text-opacity-90'
switch (type) {
case 'warning':
@ -149,7 +149,7 @@ export default function Alert({
const getCloseButtonStyles = () => {
if (variant === 'solid') {
return 'text-desert-white hover:text-desert-white hover:bg-black hover:bg-opacity-20'
return 'text-white hover:text-white hover:bg-black hover:bg-opacity-20'
}
switch (type) {

View File

@ -9,18 +9,18 @@ interface BouncingDotsProps {
export default function BouncingDots({ text, containerClassName, textClassName }: BouncingDotsProps) {
return (
<div className={clsx("flex items-center justify-center gap-2", containerClassName)}>
<span className={clsx("text-gray-600", textClassName)}>{text}</span>
<span className={clsx("text-text-secondary", textClassName)}>{text}</span>
<span className="flex gap-1 mt-1">
<span
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
style={{ animationDelay: '0ms' }}
/>
<span
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
style={{ animationDelay: '150ms' }}
/>
<span
className="w-1.5 h-1.5 bg-gray-600 rounded-full animate-bounce"
className="w-1.5 h-1.5 bg-text-secondary rounded-full animate-bounce"
style={{ animationDelay: '300ms' }}
/>
</span>

View File

@ -0,0 +1,103 @@
import { useEffect, useState } from 'react'
import { IconBug, IconCopy, IconCheck } from '@tabler/icons-react'
import StyledModal from './StyledModal'
import api from '~/lib/api'
interface DebugInfoModalProps {
open: boolean
onClose: () => void
}
export default function DebugInfoModal({ open, onClose }: DebugInfoModalProps) {
const [debugText, setDebugText] = useState('')
const [loading, setLoading] = useState(false)
const [copied, setCopied] = useState(false)
useEffect(() => {
if (!open) return
setLoading(true)
setCopied(false)
api.getDebugInfo().then((text) => {
if (text) {
const browserLine = `Browser: ${navigator.userAgent}`
setDebugText(text + '\n' + browserLine)
} else {
setDebugText('Failed to load debug info. Please try again.')
}
setLoading(false)
}).catch(() => {
setDebugText('Failed to load debug info. Please try again.')
setLoading(false)
})
}, [open])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(debugText)
} catch {
// Fallback for older browsers
const textarea = document.querySelector<HTMLTextAreaElement>('#debug-info-text')
if (textarea) {
textarea.select()
document.execCommand('copy')
}
}
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<StyledModal
open={open}
onClose={onClose}
title="Debug Info"
icon={<IconBug className="size-8 text-desert-green" />}
cancelText="Close"
onCancel={onClose}
>
<p className="text-sm text-gray-500 mb-3 text-left">
This is non-sensitive system info you can share when reporting issues.
No passwords, IPs, or API keys are included.
</p>
<textarea
id="debug-info-text"
readOnly
value={loading ? 'Loading...' : debugText}
rows={18}
className="w-full font-mono text-xs text-black bg-gray-50 border border-gray-200 rounded-md p-3 resize-none focus:outline-none text-left"
/>
<div className="mt-3 flex items-center justify-between">
<button
onClick={handleCopy}
disabled={loading}
className="inline-flex items-center gap-1.5 rounded-md bg-desert-green px-3 py-1.5 text-sm font-semibold text-white hover:bg-desert-green-dark transition-colors disabled:opacity-50"
>
{copied ? (
<>
<IconCheck className="size-4" />
Copied!
</>
) : (
<>
<IconCopy className="size-4" />
Copy to Clipboard
</>
)}
</button>
<a
href="https://github.com/Crosstalk-Solutions/project-nomad/issues"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-desert-green hover:underline"
>
Open a GitHub Issue
</a>
</div>
</StyledModal>
)
}

View File

@ -63,7 +63,7 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
large
>
<div className="flex flex-col pb-4">
<p className="text-gray-700 mb-8">
<p className="text-text-secondary mb-8">
Enter the URL of the map region file you wish to download. The URL must be publicly
reachable and end with .pmtiles. A preflight check will be run to verify the file's
availability, type, and approximate size.
@ -76,11 +76,11 @@ const DownloadURLModal: React.FC<DownloadURLModalProps> = ({
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<div className="min-h-24 max-h-96 overflow-y-auto bg-gray-50 p-4 rounded border border-gray-300 text-left">
<div className="min-h-24 max-h-96 overflow-y-auto bg-surface-secondary p-4 rounded border border-border-default text-left">
{messages.map((message, idx) => (
<p
key={idx}
className="text-sm text-gray-900 font-mono leading-relaxed break-words mb-3"
className="text-sm text-text-primary font-mono leading-relaxed break-words mb-3"
>
{message}
</p>

View File

@ -1,15 +1,31 @@
import { useState } from 'react'
import { usePage } from '@inertiajs/react'
import { UsePageProps } from '../../types/system'
import ThemeToggle from '~/components/ThemeToggle'
import { IconBug } from '@tabler/icons-react'
import DebugInfoModal from './DebugInfoModal'
export default function Footer() {
const { appVersion } = usePage().props as unknown as UsePageProps
const [debugModalOpen, setDebugModalOpen] = useState(false)
return (
<footer className="">
<div className="flex justify-center border-t border-gray-900/10 py-4">
<p className="text-sm/6 text-gray-600">
<footer>
<div className="flex items-center justify-center gap-3 border-t border-border-subtle py-4">
<p className="text-sm/6 text-text-secondary">
Project N.O.M.A.D. Command Center v{appVersion}
</p>
<span className="text-gray-300">|</span>
<button
onClick={() => setDebugModalOpen(true)}
className="text-sm/6 text-gray-500 hover:text-desert-green flex items-center gap-1 cursor-pointer"
>
<IconBug className="size-3.5" />
Debug Info
</button>
<ThemeToggle />
</div>
<DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />
</footer>
)
}

View File

@ -94,7 +94,7 @@ export default function HorizontalBarChart({
className={classNames(
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
item.value > 15
? 'left-3 text-desert-white drop-shadow-md'
? 'left-3 text-white drop-shadow-md'
: 'right-3 text-desert-green'
)}
>

View File

@ -31,8 +31,8 @@ export type InstallActivityFeedProps = {
const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, className, withHeader = false }) => {
return (
<div className={classNames('bg-white shadow-sm rounded-lg p-6', className)}>
{withHeader && <h2 className="text-lg font-semibold text-gray-900">Installation Activity</h2>}
<div className={classNames('bg-surface-primary shadow-sm rounded-lg p-6', className)}>
{withHeader && <h2 className="text-lg font-semibold text-text-primary">Installation Activity</h2>}
<ul role="list" className={classNames("space-y-6 text-desert-green", withHeader ? 'mt-6' : '')}>
{activity.map((activityItem, activityItemIdx) => (
<li key={activityItem.timestamp} className="relative flex gap-x-4">
@ -42,7 +42,7 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
'absolute left-0 top-0 flex w-6 justify-center'
)}
>
<div className="w-px bg-gray-200" />
<div className="w-px bg-border-subtle" />
</div>
<>
<div className="relative flex size-6 flex-none items-center justify-center bg-transparent">
@ -51,16 +51,16 @@ const InstallActivityFeed: React.FC<InstallActivityFeedProps> = ({ activity, cla
) : activityItem.type === 'update-rollback' ? (
<IconCircleX aria-hidden="true" className="size-6 text-red-500" />
) : (
<div className="size-1.5 rounded-full bg-gray-100 ring-1 ring-gray-300" />
<div className="size-1.5 rounded-full bg-surface-secondary ring-1 ring-border-default" />
)}
</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> -{' '}
<p className="flex-auto py-0.5 text-xs/5 text-text-muted">
<span className="font-semibold text-text-primary">{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"
className="flex-none py-0.5 text-xs/5 text-text-muted"
>
{activityItem.timestamp}
</time>

View File

@ -15,12 +15,12 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
}) => {
if (!fullscreen) {
return (
<div className={`flex flex-col items-center justify-center ${className}`}>
<div className="flex flex-col items-center justify-center">
<div
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-slate-400'} border-t-transparent rounded-full animate-spin`}
className={`w-8 h-8 border-[3px] ${light ? 'border-white' : 'border-text-muted'} border-t-transparent rounded-full animate-spin ${className || ''}`}
></div>
{!iconOnly && (
<div className={light ? 'text-white mt-2' : 'text-slate-800 mt-2'}>
<div className={light ? 'text-white mt-2' : 'text-text-primary mt-2'}>
{text || 'Loading...'}
</div>
)}

View File

@ -9,14 +9,14 @@ const ProgressBar = ({ progress, speed }: { progress: number; speed?: string })
return (
<div className="flex flex-col">
<div className="relative w-full h-2 bg-gray-200 rounded">
<div className="relative w-full h-2 bg-border-subtle rounded">
<div
className="absolute top-0 left-0 h-full bg-desert-green rounded"
style={{ width: `${progress}%` }}
/>
</div>
{speed && (
<div className="mt-1 text-sm text-gray-500">
<div className="mt-1 text-sm text-text-muted">
Est. Speed: {speed}
</div>
)}

View File

@ -81,7 +81,7 @@ export default function StorageProjectionBar({
className={classNames(
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
projectedTotalPercent > 15
? 'left-3 text-desert-white drop-shadow-md'
? 'left-3 text-white drop-shadow-md'
: 'right-3 text-desert-green'
)}
>

View File

@ -56,9 +56,9 @@ const StyledButton: React.FC<StyledButtonProps> = ({
switch (variant) {
case 'primary':
return clsx(
'bg-desert-green text-desert-white',
'hover:bg-desert-green-dark hover:shadow-lg',
'active:bg-desert-green-darker',
'bg-desert-green text-white',
'hover:bg-btn-green-hover hover:shadow-lg',
'active:bg-btn-green-active',
'disabled:bg-desert-green-light disabled:text-desert-stone-light',
baseTransition,
baseHover
@ -66,7 +66,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
case 'secondary':
return clsx(
'bg-desert-tan text-desert-white',
'bg-desert-tan text-white',
'hover:bg-desert-tan-dark hover:shadow-lg',
'active:bg-desert-tan-dark',
'disabled:bg-desert-tan-lighter disabled:text-desert-stone-light',
@ -76,7 +76,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
case 'danger':
return clsx(
'bg-desert-red text-desert-white',
'bg-desert-red text-white',
'hover:bg-desert-red-dark hover:shadow-lg',
'active:bg-desert-red-dark',
'disabled:bg-desert-red-lighter disabled:text-desert-stone-light',
@ -86,7 +86,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
case 'action':
return clsx(
'bg-desert-orange text-desert-white',
'bg-desert-orange text-white',
'hover:bg-desert-orange-light hover:shadow-lg',
'active:bg-desert-orange-dark',
'disabled:bg-desert-orange-lighter disabled:text-desert-stone-light',
@ -96,7 +96,7 @@ const StyledButton: React.FC<StyledButtonProps> = ({
case 'success':
return clsx(
'bg-desert-olive text-desert-white',
'bg-desert-olive text-white',
'hover:bg-desert-olive-dark hover:shadow-lg',
'active:bg-desert-olive-dark',
'disabled:bg-desert-olive-lighter disabled:text-desert-stone-light',
@ -116,8 +116,8 @@ const StyledButton: React.FC<StyledButtonProps> = ({
case 'outline':
return clsx(
'bg-transparent border-2 border-desert-green text-desert-green',
'hover:bg-desert-green hover:text-desert-white hover:border-desert-green-dark',
'active:bg-desert-green-dark active:border-desert-green-darker',
'hover:bg-desert-green hover:text-white hover:border-btn-green-hover',
'active:bg-btn-green-hover active:border-btn-green-active',
'disabled:border-desert-green-lighter disabled:text-desert-stone-light',
baseTransition,
baseHover

View File

@ -48,7 +48,7 @@ const StyledModal: React.FC<StyledModalProps> = ({
>
<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"
className="fixed inset-0 bg-black/50 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
@ -60,14 +60,14 @@ const StyledModal: React.FC<StyledModalProps> = ({
<DialogPanel
transition
className={classNames(
'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:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95',
'relative transform overflow-hidden rounded-lg bg-surface-primary 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:p-6 data-[closed]:sm:translate-y-0 data-[closed]:sm:scale-95',
large ? 'sm:max-w-7xl !w-full' : 'sm:max-w-lg'
)}
>
<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">
<DialogTitle as="h3" className="text-base font-semibold text-text-primary">
{title}
</DialogTitle>
<div className="mt-2 !h-fit">{children}</div>

View File

@ -1,10 +1,12 @@
import { useMemo, useState } from 'react'
import { Dialog, DialogBackdrop, DialogPanel, TransitionChild } from '@headlessui/react'
import classNames from '~/lib/classNames'
import { IconArrowLeft } from '@tabler/icons-react'
import { IconArrowLeft, IconBug } from '@tabler/icons-react'
import { usePage } from '@inertiajs/react'
import { UsePageProps } from '../../types/system'
import { IconMenu2, IconX } from '@tabler/icons-react'
import ThemeToggle from '~/components/ThemeToggle'
import DebugInfoModal from './DebugInfoModal'
type SidebarItem = {
name: string
@ -21,6 +23,7 @@ interface StyledSidebarProps {
const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [debugModalOpen, setDebugModalOpen] = useState(false)
const { appVersion } = usePage().props as unknown as UsePageProps
const currentPath = useMemo(() => {
@ -37,7 +40,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
className={classNames(
item.current
? 'bg-desert-green text-white'
: 'text-black hover:bg-desert-green-light hover:text-white',
: 'text-text-primary hover:bg-desert-green-light hover:text-white',
'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold'
)}
>
@ -53,7 +56,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
<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>
<h1 className="ml-3 text-xl font-semibold text-text-primary">{title}</h1>
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
@ -75,8 +78,16 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
</li>
</ul>
</nav>
<div className="mb-4 text-center text-sm text-gray-600">
<div className="mb-4 flex flex-col items-center gap-1 text-sm text-text-secondary">
<p>Project N.O.M.A.D. Command Center v{appVersion}</p>
<button
onClick={() => setDebugModalOpen(true)}
className="mt-1 text-gray-500 hover:text-desert-green inline-flex items-center gap-1 cursor-pointer"
>
<IconBug className="size-3.5" />
Debug Info
</button>
<ThemeToggle />
</div>
</div>
)
@ -123,6 +134,7 @@ const StyledSidebar: React.FC<StyledSidebarProps> = ({ title, items }) => {
<div className="hidden xl:fixed xl:inset-y-0 xl:z-50 xl:flex xl:w-72 xl:flex-col">
<Sidebar />
</div>
<DebugInfoModal open={debugModalOpen} onClose={() => setDebugModalOpen(false)} />
</>
)
}

View File

@ -74,19 +74,19 @@ function StyledTable<T extends { [key: string]: any }>({
return (
<div
className={classNames(
'w-full overflow-x-auto bg-white ring-1 ring-gray-300 sm:mx-0 sm:rounded-lg p-1 shadow-md',
'w-full overflow-x-auto bg-surface-primary ring-1 ring-border-default sm:mx-0 sm:rounded-lg p-1 shadow-md',
className
)}
ref={ref}
{...containerProps}
>
<table className="min-w-full overflow-auto" {...restTableProps}>
<thead className='border-b border-gray-200 '>
<thead className='border-b border-border-subtle '>
<tr>
{expandable && (
<th
className={classNames(
'whitespace-nowrap text-left font-semibold text-gray-900 w-12',
'whitespace-nowrap text-left font-semibold text-text-primary w-12',
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
)}
/>
@ -95,7 +95,7 @@ function StyledTable<T extends { [key: string]: any }>({
<th
key={index}
className={classNames(
'whitespace-nowrap text-left font-semibold text-gray-900',
'whitespace-nowrap text-left font-semibold text-text-primary',
compact ? `${leftPadding} py-2` : `${leftPadding} py-4 pr-3`
)}
>
@ -121,8 +121,8 @@ function StyledTable<T extends { [key: string]: any }>({
'translateY' in record ? 'translateY(' + record.transformY + 'px)' : undefined,
}}
className={classNames(
rowLines ? 'border-b border-gray-200' : '',
onRowClick ? `cursor-pointer hover:bg-gray-100 ` : ''
rowLines ? 'border-b border-border-subtle' : '',
onRowClick ? `cursor-pointer hover:bg-surface-secondary ` : ''
)}
>
{expandable && (
@ -134,7 +134,7 @@ function StyledTable<T extends { [key: string]: any }>({
onClick={(e) => toggleRowExpansion(record, recordIdx, e)}
>
<button
className="text-gray-500 hover:text-gray-700 focus:outline-none"
className="text-text-muted hover:text-text-primary focus:outline-none"
aria-label={isExpanded ? 'Collapse row' : 'Expand row'}
>
<svg
@ -172,7 +172,7 @@ function StyledTable<T extends { [key: string]: any }>({
))}
</tr>
{expandable && isExpanded && (
<tr className="bg-gray-50">
<tr className="bg-surface-secondary">
<td colSpan={columns.length + 1}>
{expandable.expandedRowRender(record, recordIdx)}
</td>
@ -183,7 +183,7 @@ function StyledTable<T extends { [key: string]: any }>({
})}
{!loading && data.length === 0 && (
<tr>
<td colSpan={columns.length + (expandable ? 1 : 0)} className="!text-center py-8 text-gray-500">
<td colSpan={columns.length + (expandable ? 1 : 0)} className="!text-center py-8 text-text-muted">
{noDataText}
</td>
</tr>

View File

@ -0,0 +1,24 @@
import { IconSun, IconMoon } from '@tabler/icons-react'
import { useThemeContext } from '~/providers/ThemeProvider'
interface ThemeToggleProps {
compact?: boolean
}
export default function ThemeToggle({ compact = false }: ThemeToggleProps) {
const { theme, toggleTheme } = useThemeContext()
const isDark = theme === 'dark'
return (
<button
onClick={toggleTheme}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm transition-colors
text-desert-stone hover:text-desert-green-darker"
aria-label={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
title={isDark ? 'Switch to Day Ops' : 'Switch to Night Ops'}
>
{isDark ? <IconSun className="size-4" /> : <IconMoon className="size-4" />}
{!compact && <span>{isDark ? 'Day Ops' : 'Night Ops'}</span>}
</button>
)
}

View File

@ -88,7 +88,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-lg bg-white shadow-xl transition-all">
<Dialog.Panel className="w-full max-w-4xl transform overflow-hidden rounded-lg bg-surface-primary shadow-xl transition-all">
{/* Header */}
<div className="bg-desert-green px-6 py-4">
<div className="flex items-center justify-between">
@ -101,7 +101,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
<Dialog.Title className="text-xl font-semibold text-white">
{category.name}
</Dialog.Title>
<p className="text-sm text-gray-200">{category.description}</p>
<p className="text-sm text-text-muted">{category.description}</p>
</div>
</div>
<button
@ -115,7 +115,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
{/* Content */}
<div className="p-6">
<p className="text-gray-600 mb-6">
<p className="text-text-secondary mb-6">
Select a tier based on your storage capacity and needs. Higher tiers include all content from lower tiers.
</p>
@ -138,30 +138,30 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
'border-2 rounded-lg p-5 cursor-pointer transition-all',
isSelected
? 'border-desert-green bg-desert-green/5 shadow-md'
: 'border-gray-200 hover:border-desert-green/50 hover:shadow-sm'
: 'border-border-subtle hover:border-desert-green/50 hover:shadow-sm'
)}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-lg font-semibold text-gray-900">
<h3 className="text-lg font-semibold text-text-primary">
{tier.name}
</h3>
{includedTierName && (
<span className="text-xs text-gray-500">
<span className="text-xs text-text-muted">
(includes {includedTierName})
</span>
)}
</div>
<p className="text-gray-600 text-sm mb-3">{tier.description}</p>
<p className="text-text-secondary text-sm mb-3">{tier.description}</p>
{/* Resources preview - only show this tier's own resources */}
<div className="bg-gray-50 rounded p-3">
<p className="text-xs text-gray-500 mb-2 font-medium">
<div className="bg-surface-secondary rounded p-3">
<p className="text-xs text-text-muted mb-2 font-medium">
{includedTierName ? (
<>
{ownResourceCount} additional {ownResourceCount === 1 ? 'resource' : 'resources'}
<span className="text-gray-400"> (plus everything in {includedTierName})</span>
<span className="text-text-muted"> (plus everything in {includedTierName})</span>
</>
) : (
<>{ownResourceCount} {ownResourceCount === 1 ? 'resource' : 'resources'} included</>
@ -172,8 +172,8 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
<div key={idx} className="flex items-start text-sm">
<IconCheck size={14} className="text-desert-green mr-1.5 mt-0.5 flex-shrink-0" />
<div>
<span className="text-gray-700">{resource.title}</span>
<span className="text-gray-400 text-xs ml-1">
<span className="text-text-primary">{resource.title}</span>
<span className="text-text-muted text-xs ml-1">
({formatBytes(resource.size_mb * 1024 * 1024, 0)})
</span>
</div>
@ -184,14 +184,14 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
</div>
<div className="ml-4 text-right flex-shrink-0">
<div className="text-lg font-semibold text-gray-900">
<div className="text-lg font-semibold text-text-primary">
{formatBytes(totalSize, 1)}
</div>
<div className={classNames(
'w-6 h-6 rounded-full border-2 flex items-center justify-center mt-2 ml-auto',
isSelected
? 'border-desert-green bg-desert-green'
: 'border-gray-300'
: 'border-border-default'
)}>
{isSelected && <IconCheck size={16} className="text-white" />}
</div>
@ -203,7 +203,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
</div>
{/* Info note */}
<div className="mt-6 flex items-start gap-2 text-sm text-gray-500 bg-blue-50 p-3 rounded">
<div className="mt-6 flex items-start gap-2 text-sm text-text-muted bg-blue-50 p-3 rounded">
<IconInfoCircle size={18} className="text-blue-500 flex-shrink-0 mt-0.5" />
<p>
You can change your selection at any time. Click Submit to confirm your choice.
@ -212,7 +212,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
</div>
{/* Footer */}
<div className="bg-gray-50 px-6 py-4 flex justify-end gap-3">
<div className="bg-surface-secondary px-6 py-4 flex justify-end gap-3">
<button
onClick={handleSubmit}
disabled={!localSelectedSlug}
@ -220,7 +220,7 @@ const TierSelectionModal: React.FC<TierSelectionModalProps> = ({
'px-4 py-2 rounded-md font-medium transition-colors',
localSelectedSlug
? 'bg-desert-green text-white hover:bg-desert-green/90'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-border-default text-text-muted cursor-not-allowed'
)}
>
Submit

View File

@ -60,12 +60,12 @@ export default function UpdateServiceModal({
icon={<IconArrowUp className="h-12 w-12 text-desert-green" />}
>
<div className="space-y-4">
<p className="text-gray-700">
<p className="text-text-primary">
Update <strong>{record.friendly_name || record.service_name}</strong> from{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">{currentTag}</code> to{' '}
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-sm">{selectedVersion}</code>?
<code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{currentTag}</code> to{' '}
<code className="bg-surface-secondary px-1.5 py-0.5 rounded text-sm">{selectedVersion}</code>?
</p>
<p className="text-sm text-gray-500">
<p className="text-sm text-text-muted">
Your data and configuration will be preserved during the update.
{versions.find((v) => v.tag === selectedVersion)?.releaseUrl && (
<>
@ -95,14 +95,14 @@ export default function UpdateServiceModal({
<>
<div className="mt-3 max-h-48 overflow-y-auto border rounded-lg divide-y">
{loadingVersions ? (
<div className="p-4 text-center text-gray-500 text-sm">Loading versions...</div>
<div className="p-4 text-center text-text-muted text-sm">Loading versions...</div>
) : versions.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">No other versions available</div>
<div className="p-4 text-center text-text-muted text-sm">No other versions available</div>
) : (
versions.map((v) => (
<label
key={v.tag}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 cursor-pointer"
className="flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary cursor-pointer"
>
<input
type="radio"
@ -112,7 +112,7 @@ export default function UpdateServiceModal({
onChange={() => setSelectedVersion(v.tag)}
className="text-desert-green focus:ring-desert-green"
/>
<span className="text-sm font-medium text-gray-900">{v.tag}</span>
<span className="text-sm font-medium text-text-primary">{v.tag}</span>
{v.isLatest && (
<span className="text-xs bg-desert-green/10 text-desert-green px-2 py-0.5 rounded-full">
Latest
@ -133,7 +133,7 @@ export default function UpdateServiceModal({
))
)}
</div>
<p className="mt-2 text-sm text-gray-500">
<p className="mt-2 text-sm text-text-muted">
It's not recommended to upgrade to a new major version (e.g. 1.8.2 &rarr; 2.0.0) unless you have verified compatibility with your current configuration. Always review the release notes and test in a staging environment if possible.
</p>
</>

View File

@ -1,7 +1,7 @@
import { formatBytes } from '~/lib/util'
import { WikipediaOption, WikipediaCurrentSelection } from '../../types/downloads'
import classNames from 'classnames'
import { IconCheck, IconDownload, IconWorld } from '@tabler/icons-react'
import { IconCheck, IconDownload, IconWorld, IconAlertTriangle } from '@tabler/icons-react'
import StyledButton from './StyledButton'
import LoadingSpinner from './LoadingSpinner'
@ -29,32 +29,45 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
// Determine which option to highlight
const highlightedOptionId = selectedOptionId ?? currentSelection?.optionId ?? null
// Check if current selection is downloading
// Check if current selection is downloading or failed
const isDownloading = currentSelection?.status === 'downloading'
const isFailed = currentSelection?.status === 'failed'
return (
<div className="w-full">
{/* Header with Wikipedia branding */}
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-sm">
<IconWorld className="w-6 h-6 text-gray-700" />
<IconWorld className="w-6 h-6 text-text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">Wikipedia</h3>
<p className="text-sm text-gray-500">Select your preferred Wikipedia package</p>
<h3 className="text-xl font-semibold text-text-primary">Wikipedia</h3>
<p className="text-sm text-text-muted">Select your preferred Wikipedia package</p>
</div>
</div>
{/* Downloading status message */}
{isDownloading && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center gap-2">
<LoadingSpinner fullscreen={false} iconOnly className="size-5" />
<LoadingSpinner fullscreen={false} iconOnly className="size-4" />
<span className="text-sm text-blue-700">
Downloading Wikipedia... This may take a while for larger packages.
</span>
</div>
)}
{/* Failed status message */}
{isFailed && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center justify-between">
<div className="flex items-center gap-2">
<IconAlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0" />
<span className="text-sm text-red-700">
Wikipedia download failed. Select a package and try again.
</span>
</div>
</div>
)}
{/* Options grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{options.map((option) => {
@ -63,6 +76,8 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
currentSelection?.optionId === option.id && currentSelection?.status === 'installed'
const isCurrentDownloading =
currentSelection?.optionId === option.id && currentSelection?.status === 'downloading'
const isCurrentFailed =
currentSelection?.optionId === option.id && currentSelection?.status === 'failed'
const isPending = selectedOptionId === option.id && selectedOptionId !== currentSelection?.optionId
return (
@ -78,7 +93,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
? 'border-desert-green bg-desert-green/10'
: isSelected
? 'border-lime-500 bg-lime-50'
: 'border-gray-200 bg-white hover:border-gray-300'
: 'border-border-subtle bg-surface-primary hover:border-border-default'
)}
>
{/* Status badges */}
@ -100,12 +115,18 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
Downloading
</span>
)}
{isCurrentFailed && (
<span className="text-xs bg-red-500 text-white px-2 py-0.5 rounded-full flex items-center gap-1">
<IconAlertTriangle size={12} />
Failed
</span>
)}
</div>
{/* Option content */}
<div className="pr-16 flex flex-col h-full">
<h4 className="text-lg font-semibold text-gray-900 mb-1">{option.name}</h4>
<p className="text-sm text-gray-600 mb-3 flex-grow">{option.description}</p>
<h4 className="text-lg font-semibold text-text-primary mb-1">{option.name}</h4>
<p className="text-sm text-text-secondary mb-3 flex-grow">{option.description}</p>
<div className="flex items-center gap-3">
{/* Radio indicator */}
<div
@ -115,7 +136,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
? isInstalled
? 'border-desert-green bg-desert-green'
: 'border-lime-500 bg-lime-500'
: 'border-gray-300'
: 'border-border-default'
)}
>
{isSelected && <IconCheck size={12} className="text-white" />}
@ -123,7 +144,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
<span
className={classNames(
'text-sm font-medium px-2 py-1 rounded',
option.size_mb === 0 ? 'bg-gray-100 text-gray-500' : 'bg-gray-100 text-gray-700'
option.size_mb === 0 ? 'bg-surface-secondary text-text-muted' : 'bg-surface-secondary text-text-secondary'
)}
>
{option.size_mb === 0 ? 'No download' : formatBytes(option.size_mb * 1024 * 1024, 1)}
@ -136,7 +157,7 @@ const WikipediaSelector: React.FC<WikipediaSelectorProps> = ({
</div>
{/* Submit button for Content Explorer mode */}
{showSubmitButton && selectedOptionId && selectedOptionId !== currentSelection?.optionId && (
{showSubmitButton && selectedOptionId && (selectedOptionId !== currentSelection?.optionId || isFailed) && (
<div className="mt-4 flex justify-end">
<StyledButton
variant="primary"

View File

@ -85,19 +85,19 @@ export default function ChatInterface({
}
return (
<div className="flex-1 flex flex-col min-h-0 bg-white shadow-sm">
<div className="flex-1 flex flex-col min-h-0 bg-surface-primary shadow-sm">
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
{messages.length === 0 ? (
<div className="h-full flex items-center justify-center">
<div className="text-center max-w-md">
<IconWand className="h-16 w-16 text-desert-green mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium text-gray-700 mb-2">Start a conversation</h3>
<p className="text-gray-500 text-sm">
<h3 className="text-lg font-medium text-text-primary mb-2">Start a conversation</h3>
<p className="text-text-muted text-sm">
Interact with your installed language models directly in the Command Center.
</p>
{chatSuggestionsEnabled && chatSuggestions && chatSuggestions.length > 0 && !chatSuggestionsLoading && (
<div className="mt-8">
<h4 className="text-sm font-medium text-gray-600 mb-2">Suggestions:</h4>
<h4 className="text-sm font-medium text-text-secondary mb-2">Suggestions:</h4>
<div className="flex flex-col gap-2">
{chatSuggestions.map((suggestion, index) => (
<button
@ -109,7 +109,7 @@ export default function ChatInterface({
textareaRef.current?.focus()
}, 0)
}}
className="px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm text-gray-700 transition-colors"
className="px-4 py-2 bg-surface-secondary hover:bg-surface-secondary rounded-lg text-sm text-text-primary transition-colors"
>
{suggestion}
</button>
@ -120,7 +120,7 @@ export default function ChatInterface({
{/* Display bouncing dots while loading suggestions */}
{chatSuggestionsEnabled && chatSuggestionsLoading && <BouncingDots text="Thinking" containerClassName="mt-8" />}
{!chatSuggestionsEnabled && (
<div className="mt-8 text-sm text-gray-500">
<div className="mt-8 text-sm text-text-muted">
Need some inspiration? Enable chat suggestions in settings to get started with example prompts.
</div>
)}
@ -144,7 +144,7 @@ export default function ChatInterface({
{isLoading && (
<div className="flex gap-4 justify-start">
<ChatAssistantAvatar />
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-gray-100 text-gray-800">
<div className="max-w-[70%] rounded-lg px-4 py-3 bg-surface-secondary text-text-primary">
<BouncingDots text="Thinking" />
</div>
</div>
@ -154,7 +154,7 @@ export default function ChatInterface({
</>
)}
</div>
<div className="border-t border-gray-200 bg-white px-6 py-4 flex-shrink-0 min-h-[90px]">
<div className="border-t border-border-subtle bg-surface-primary px-6 py-4 flex-shrink-0 min-h-[90px]">
<form onSubmit={handleSubmit} className="flex gap-3 items-end">
<div className="flex-1 relative">
<textarea
@ -163,7 +163,7 @@ export default function ChatInterface({
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={`Type your message to ${aiAssistantName}... (Shift+Enter for new line)`}
className="w-full resize-none rounded-lg border border-gray-300 px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-gray-50 disabled:text-gray-500"
className="w-full resize-none rounded-lg border border-border-default px-4 py-3 pr-12 focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent disabled:bg-surface-secondary disabled:text-text-muted"
rows={1}
disabled={isLoading}
style={{ maxHeight: '200px' }}
@ -175,7 +175,7 @@ export default function ChatInterface({
className={classNames(
'p-3 rounded-lg transition-all duration-200 flex-shrink-0 mb-2',
!input.trim() || isLoading
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
? 'bg-border-default text-text-muted cursor-not-allowed'
: 'bg-desert-green text-white hover:bg-desert-green/90 hover:scale-105'
)}
>
@ -187,7 +187,7 @@ export default function ChatInterface({
</button>
</form>
{!rewriteModelAvailable && (
<div className="text-sm text-gray-500 mt-2">
<div className="text-sm text-text-muted mt-2">
The {DEFAULT_QUERY_REWRITE_MODEL} model is not installed. Consider{' '}
<button
onClick={() => setDownloadDialogOpen(true)}
@ -210,7 +210,7 @@ export default function ChatInterface({
onCancel={() => setDownloadDialogOpen(false)}
onClose={() => setDownloadDialogOpen(false)}
>
<p className="text-gray-700">
<p className="text-text-primary">
This will dispatch a background download job for{' '}
<span className="font-mono font-medium">{DEFAULT_QUERY_REWRITE_MODEL}</span> and may take some time to complete. The model
will be used to rewrite queries for improved RAG retrieval performance.

View File

@ -12,7 +12,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
<div
className={classNames(
'max-w-[70%] rounded-lg px-4 py-3',
message.role === 'user' ? 'bg-desert-green text-white' : 'bg-gray-100 text-gray-800'
message.role === 'user' ? 'bg-desert-green text-white' : 'bg-surface-secondary text-text-primary'
)}
>
{message.isThinking && message.thinking && (
@ -27,13 +27,13 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
</div>
)}
{!message.isThinking && message.thinking && (
<details className="mb-3 rounded border border-gray-200 bg-gray-50 text-xs">
<summary className="cursor-pointer px-3 py-2 font-medium text-gray-500 hover:text-gray-700 select-none">
<details className="mb-3 rounded border border-border-subtle bg-surface-secondary text-xs">
<summary className="cursor-pointer px-3 py-2 font-medium text-text-muted hover:text-text-primary select-none">
{message.thinkingDuration !== undefined
? `Thought for ${message.thinkingDuration}s`
: 'Reasoning'}
</summary>
<div className="px-3 pb-3 prose prose-xs max-w-none text-gray-600 max-h-48 overflow-y-auto border-t border-gray-200 pt-2">
<div className="px-3 pb-3 prose prose-xs max-w-none text-text-secondary max-h-48 overflow-y-auto border-t border-border-subtle pt-2">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{message.thinking}</ReactMarkdown>
</div>
</details>
@ -77,7 +77,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
h2: ({ children }) => <h2 className="text-lg font-bold mb-2">{children}</h2>,
h3: ({ children }) => <h3 className="text-base font-bold mb-2">{children}</h3>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-gray-400 pl-4 italic my-2">
<blockquote className="border-l-4 border-border-default pl-4 italic my-2">
{children}
</blockquote>
),
@ -105,7 +105,7 @@ export default function ChatMessageBubble({ message }: ChatMessageBubbleProps) {
<div
className={classNames(
'text-xs mt-2',
message.role === 'user' ? 'text-white/70' : 'text-gray-500'
message.role === 'user' ? 'text-white/70' : 'text-text-muted'
)}
>
{message.timestamp.toLocaleTimeString([], {

View File

@ -23,7 +23,7 @@ export default function ChatModal({ open, onClose }: ChatModalProps) {
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel
transition
className="relative bg-white rounded-xl shadow-2xl w-full max-w-7xl h-[85vh] flex overflow-hidden transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
className="relative bg-surface-primary rounded-xl shadow-2xl w-full max-w-7xl h-[85vh] flex overflow-hidden transition-all data-[closed]:scale-95 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
>
<Chat enabled={open} isInModal onClose={onClose} suggestionsEnabled={parseBoolean(settings.data?.value)} />
</DialogPanel>

View File

@ -39,8 +39,8 @@ export default function ChatSidebar({
}
return (
<div className="w-64 bg-gray-50 border-r border-gray-200 flex flex-col h-full">
<div className="p-4 border-b border-gray-200 h-[75px] flex items-center justify-center">
<div className="w-64 bg-surface-secondary border-r border-border-subtle flex flex-col h-full">
<div className="p-4 border-b border-border-subtle h-[75px] flex items-center justify-center">
<StyledButton onClick={onNewChat} icon="IconPlus" variant="primary" fullWidth>
New Chat
</StyledButton>
@ -48,7 +48,7 @@ export default function ChatSidebar({
<div className="flex-1 overflow-y-auto">
{sessions.length === 0 ? (
<div className="p-4 text-center text-gray-500 text-sm">No previous chats</div>
<div className="p-4 text-center text-text-muted text-sm">No previous chats</div>
) : (
<div className="p-2 space-y-1">
{sessions.map((session) => (
@ -59,14 +59,14 @@ export default function ChatSidebar({
'w-full text-left px-3 py-2 rounded-lg transition-colors group',
activeSessionId === session.id
? 'bg-desert-green text-white'
: 'hover:bg-gray-200 text-gray-700'
: 'hover:bg-surface-secondary text-text-primary'
)}
>
<div className="flex items-start gap-2">
<IconMessage
className={classNames(
'h-5 w-5 mt-0.5 shrink-0',
activeSessionId === session.id ? 'text-white' : 'text-gray-400'
activeSessionId === session.id ? 'text-white' : 'text-text-muted'
)}
/>
<div className="flex-1 min-w-0">
@ -75,7 +75,7 @@ export default function ChatSidebar({
<div
className={classNames(
'text-xs truncate mt-0.5',
activeSessionId === session.id ? 'text-white/80' : 'text-gray-500'
activeSessionId === session.id ? 'text-white/80' : 'text-text-muted'
)}
>
{session.lastMessage}

View File

@ -106,7 +106,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
cancelText='Cancel'
confirmVariant='primary'
>
<p className='text-gray-700'>
<p className='text-text-primary'>
This will scan the NOMAD's storage directories for any new files and queue them for processing. This is useful if you've manually added files to the storage or want to ensure everything is up to date.
This may cause a temporary increase in resource usage if new files are found and being processed. Are you sure you want to proceed?
</p>
@ -117,18 +117,18 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-gray-200 shrink-0">
<h2 className="text-2xl font-semibold text-gray-800">Knowledge Base</h2>
<div className="bg-surface-primary rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-border-subtle shrink-0">
<h2 className="text-2xl font-semibold text-text-primary">Knowledge Base</h2>
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
className="p-2 hover:bg-surface-secondary rounded-lg transition-colors"
>
<IconX className="h-6 w-6 text-gray-500" />
<IconX className="h-6 w-6 text-text-muted" />
</button>
</div>
<div className="overflow-y-auto flex-1 p-6">
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden">
<div className="p-6">
<FileUploader
ref={fileUploaderRef}
@ -151,7 +151,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
</StyledButton>
</div>
</div>
<div className="border-t bg-white p-6">
<div className="border-t bg-surface-primary p-6">
<h3 className="text-lg font-semibold text-desert-green mb-4">
Why upload documents to your Knowledge Base?
</h3>
@ -232,7 +232,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
accessor: 'source',
title: 'File Name',
render(record) {
return <span className="text-gray-700">{sourceToDisplayName(record.source)}</span>
return <span className="text-text-primary">{sourceToDisplayName(record.source)}</span>
},
},
{
@ -244,7 +244,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
if (isConfirming) {
return (
<div className="flex items-center gap-2 justify-end">
<span className="text-sm text-gray-600">Remove from knowledge base?</span>
<span className="text-sm text-text-secondary">Remove from knowledge base?</span>
<StyledButton
variant='danger'
size='sm'

View File

@ -159,7 +159,7 @@ export default function Chat({
cancelText="Cancel"
confirmVariant="danger"
>
<p className="text-gray-700">
<p className="text-text-primary">
Are you sure you want to delete all chat sessions? This action cannot be undone and all
conversations will be permanently deleted.
</p>
@ -345,7 +345,7 @@ export default function Chat({
return (
<div
className={classNames(
'flex border border-gray-200 overflow-hidden shadow-sm w-full',
'flex border border-border-subtle overflow-hidden shadow-sm w-full',
isInModal ? 'h-full rounded-lg' : 'h-screen'
)}
>
@ -358,17 +358,17 @@ export default function Chat({
isInModal={isInModal}
/>
<div className="flex-1 flex flex-col min-h-0">
<div className="px-6 py-3 border-b border-gray-200 bg-gray-50 flex items-center justify-between h-[75px] flex-shrink-0">
<h2 className="text-lg font-semibold text-gray-800">
<div className="px-6 py-3 border-b border-border-subtle bg-surface-secondary flex items-center justify-between h-[75px] flex-shrink-0">
<h2 className="text-lg font-semibold text-text-primary">
{activeSession?.title || 'New Chat'}
</h2>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label htmlFor="model-select" className="text-sm text-gray-600">
<label htmlFor="model-select" className="text-sm text-text-secondary">
Model:
</label>
{isLoadingModels ? (
<div className="text-sm text-gray-500">Loading models...</div>
<div className="text-sm text-text-muted">Loading models...</div>
) : installedModels.length === 0 ? (
<div className="text-sm text-red-600">No models installed</div>
) : (
@ -376,7 +376,7 @@ export default function Chat({
id="model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="px-3 py-1.5 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-white"
className="px-3 py-1.5 border border-border-default rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-desert-green focus:border-transparent bg-surface-primary"
>
{installedModels.map((model) => (
<option key={model.name} value={model.name}>
@ -393,9 +393,9 @@ export default function Chat({
onClose()
}
}}
className="rounded-lg hover:bg-gray-100 transition-colors"
className="rounded-lg hover:bg-surface-secondary transition-colors"
>
<IconX className="h-6 w-6 text-gray-500" />
<IconX className="h-6 w-6 text-text-muted" />
</button>
)}
</div>

View File

@ -31,11 +31,11 @@ const Input: React.FC<InputProps> = ({
<div className={classNames(className)}>
<label
htmlFor={name}
className={classNames("block text-base/6 font-medium text-gray-700", labelClassName)}
className={classNames("block text-base/6 font-medium text-text-primary", labelClassName)}
>
{label}{required ? "*" : ""}
</label>
{helpText && <p className="mt-1 text-sm text-gray-500">{helpText}</p>}
{helpText && <p className="mt-1 text-sm text-text-muted">{helpText}</p>}
<div className={classNames("mt-1.5", containerClassName)}>
<div className="relative">
{leftIcon && (
@ -49,7 +49,7 @@ const Input: React.FC<InputProps> = ({
placeholder={props.placeholder || label}
className={classNames(
inputClassName,
"block w-full rounded-md bg-white px-3 py-2 text-base text-gray-900 border border-gray-400 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6",
"block w-full rounded-md bg-surface-primary px-3 py-2 text-base text-text-primary border border-border-default placeholder:text-text-muted focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-primary sm:text-sm/6",
leftIcon ? "pl-10" : "pl-3",
error ? "!border-red-500 focus:outline-red-500 !bg-red-100" : ""
)}

View File

@ -26,12 +26,12 @@ export default function Switch({
{label && (
<label
htmlFor={switchId}
className="text-base font-medium text-gray-900 cursor-pointer"
className="text-base font-medium text-text-primary cursor-pointer"
>
{label}
</label>
)}
{description && <p className="text-sm text-gray-500 mt-1">{description}</p>}
{description && <p className="text-sm text-text-muted mt-1">{description}</p>}
</div>
)}
<div className="flex items-center ml-4">
@ -45,7 +45,7 @@ export default function Switch({
className={clsx(
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent',
'transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-desert-green focus:ring-offset-2',
checked ? 'bg-desert-green' : 'bg-gray-200',
checked ? 'bg-desert-green' : 'bg-border-default',
disabled ? 'opacity-50 cursor-not-allowed' : ''
)}
>

View File

@ -9,11 +9,11 @@ interface BackToHomeHeaderProps {
export default function BackToHomeHeader({ className, children }: BackToHomeHeaderProps) {
return (
<div className={classNames('flex border-b border-gray-900/10 p-4', className)}>
<div className={classNames('flex border-b border-border-subtle p-4', className)}>
<div className="justify-self-start">
<Link href="/home" className="flex items-center">
<IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-gray-600">Back to Home</p>
<p className="text-lg text-text-secondary">Back to Home</p>
</Link>
</div>
<div className="flex-grow flex flex-col justify-center">{children}</div>

View File

@ -23,7 +23,7 @@ export default function MapComponent() {
width: '100%',
height: '100vh',
}}
mapStyle={`http://${window.location.hostname}:${window.location.port}/api/maps/styles`}
mapStyle={`${window.location.protocol}//${window.location.hostname}:${window.location.port}/api/maps/styles`}
mapLib={maplibregl}
initialViewState={{
longitude: -101,

View File

@ -9,11 +9,11 @@ export function Table({ children }: { children: React.ReactNode }) {
}
export function TableHead({ children }: { children: React.ReactNode }) {
return <thead className="bg-desert-green-dark">{children}</thead>
return <thead className="bg-desert-green">{children}</thead>
}
export function TableBody({ children }: { children: React.ReactNode }) {
return <tbody className="divide-y divide-desert-tan-lighter/50 bg-white">{children}</tbody>
return <tbody className="divide-y divide-desert-tan-lighter/50 bg-surface-primary">{children}</tbody>
}
export function TableRow({ children }: { children: React.ReactNode }) {
@ -22,7 +22,7 @@ export function TableRow({ children }: { children: React.ReactNode }) {
export function TableHeader({ children }: { children: React.ReactNode }) {
return (
<th className="px-5 py-3 text-left text-sm font-semibold text-desert-white tracking-wide">
<th className="px-5 py-3 text-left text-sm font-semibold text-white tracking-wide">
{children}
</th>
)

View File

@ -45,8 +45,8 @@ export default function InfoCard({ title, icon, data, variant = 'default' }: Inf
/>
<div className="relative flex items-center gap-3">
{icon && <div className="text-desert-white opacity-80">{icon}</div>}
<h3 className="text-lg font-bold text-desert-white uppercase tracking-wide">{title}</h3>
{icon && <div className="text-white opacity-80">{icon}</div>}
<h3 className="text-lg font-bold text-white uppercase tracking-wide">{title}</h3>
</div>
<div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8">
<div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />

View File

@ -3,39 +3,119 @@
@theme {
--color-desert-white: #f6f6f4;
--color-desert-sand: #f7eedc;
--color-desert-green-darker: #2a2a15;
--color-desert-green-dark: #353518;
--color-desert-green: #424420;
--color-desert-green-light: #babaaa;
--color-desert-green-lighter: #d4d4c8;
--color-desert-orange-dark: #8a3d0f;
--color-desert-orange: #a84a12;
--color-desert-orange-light: #c85815;
--color-desert-orange-lighter: #e69556;
--color-desert-tan-dark: #6b5d4f;
--color-desert-tan: #8b7355;
--color-desert-tan-light: #a8927a;
--color-desert-tan-lighter: #c9b99f;
--color-desert-red-dark: #7a2e2e;
--color-desert-red: #994444;
--color-desert-red-light: #b05555;
--color-desert-red-lighter: #d88989;
--color-desert-olive-dark: #5a5c3a;
--color-desert-olive: #6d7042;
--color-desert-olive-light: #858a55;
--color-desert-olive-lighter: #a5ab7d;
--color-desert-stone-dark: #5c5c54;
--color-desert-stone: #75756a;
--color-desert-stone-light: #8f8f82;
--color-desert-stone-lighter: #afafa5;
/* Semantic surface/text tokens (for replacing generic gray/white Tailwind classes) */
--color-surface-primary: #ffffff;
--color-surface-secondary: #f9fafb;
--color-surface-elevated: #ffffff;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-text-muted: #9ca3af;
--color-border-default: #d1d5db;
--color-border-subtle: #e5e7eb;
/* Button interactive states (green hover/active swap conflicts with text color inversion) */
--color-btn-green-hover: #353518;
--color-btn-green-active: #2a2a15;
}
body {
background-color: var(--color-desert-sand);
color: var(--color-text-primary);
transition: background-color 0.2s ease, color 0.2s ease;
}
/* Night Ops — warm charcoal dark mode */
[data-theme="dark"] {
/* Backgrounds: light sand → warm charcoal */
--color-desert-sand: #1c1b16;
--color-desert-white: #2a2918;
/* Text greens: dark text → light text for readability */
--color-desert-green-darker: #f7eedc;
--color-desert-green-dark: #e8dfc8;
/* Accent green: slightly brighter for dark bg visibility */
--color-desert-green: #525530;
/* Light variants → dark variants (hover bg, disabled states) */
--color-desert-green-light: #3a3c24;
--color-desert-green-lighter: #2d2e1c;
/* Orange: brighter for contrast on dark surfaces */
--color-desert-orange-dark: #c85815;
--color-desert-orange: #c85815;
--color-desert-orange-light: #e69556;
--color-desert-orange-lighter: #f0b87a;
/* Tan: lightened for readability */
--color-desert-tan-dark: #c9b99f;
--color-desert-tan: #a8927a;
--color-desert-tan-light: #8b7355;
--color-desert-tan-lighter: #6b5d4f;
/* Red: lightened for dark bg */
--color-desert-red-dark: #d88989;
--color-desert-red: #b05555;
--color-desert-red-light: #994444;
--color-desert-red-lighter: #7a2e2e;
/* Olive: lightened */
--color-desert-olive-dark: #a5ab7d;
--color-desert-olive: #858a55;
--color-desert-olive-light: #6d7042;
--color-desert-olive-lighter: #5a5c3a;
/* Stone: lightened */
--color-desert-stone-dark: #afafa5;
--color-desert-stone: #8f8f82;
--color-desert-stone-light: #75756a;
--color-desert-stone-lighter: #5c5c54;
/* Semantic surface overrides */
--color-surface-primary: #2a2918;
--color-surface-secondary: #353420;
--color-surface-elevated: #3d3c2a;
--color-text-primary: #f7eedc;
--color-text-secondary: #afafa5;
--color-text-muted: #8f8f82;
--color-border-default: #424420;
--color-border-subtle: #353420;
/* Button interactive states: darker green for hover/active on dark bg */
--color-btn-green-hover: #474a28;
--color-btn-green-active: #3a3c24;
color-scheme: dark;
}

View File

@ -0,0 +1,82 @@
import { NomadDiskInfo } from '../../types/system'
import { Systeminformation } from 'systeminformation'
import { formatBytes } from '~/lib/util'
type DiskDisplayItem = {
label: string
value: number
total: string
used: string
subtext: string
totalBytes: number
usedBytes: number
}
/** Get all valid disks formatted for display (settings/system page) */
export function getAllDiskDisplayItems(
disks: NomadDiskInfo[] | undefined,
fsSize: Systeminformation.FsSizeData[] | undefined
): DiskDisplayItem[] {
const validDisks = disks?.filter((d) => d.totalSize > 0) || []
if (validDisks.length > 0) {
return validDisks.map((disk) => ({
label: disk.name || 'Unknown',
value: disk.percentUsed || 0,
total: formatBytes(disk.totalSize),
used: formatBytes(disk.totalUsed),
subtext: `${formatBytes(disk.totalUsed || 0)} / ${formatBytes(disk.totalSize || 0)}`,
totalBytes: disk.totalSize,
usedBytes: disk.totalUsed,
}))
}
if (fsSize && fsSize.length > 0) {
const seen = new Set<number>()
const uniqueFs = fsSize.filter((fs) => {
if (fs.size <= 0 || seen.has(fs.size)) return false
seen.add(fs.size)
return true
})
const realDevices = uniqueFs.filter((fs) => fs.fs.startsWith('/dev/'))
const displayFs = realDevices.length > 0 ? realDevices : uniqueFs
return displayFs.map((fs) => ({
label: fs.fs || 'Unknown',
value: fs.use || 0,
total: formatBytes(fs.size),
used: formatBytes(fs.used),
subtext: `${formatBytes(fs.used)} / ${formatBytes(fs.size)}`,
totalBytes: fs.size,
usedBytes: fs.used,
}))
}
return []
}
/** Get primary disk info for storage projection (easy-setup page) */
export function getPrimaryDiskInfo(
disks: NomadDiskInfo[] | undefined,
fsSize: Systeminformation.FsSizeData[] | undefined
): { totalSize: number; totalUsed: number } | null {
const validDisks = disks?.filter((d) => d.totalSize > 0) || []
if (validDisks.length > 0) {
const diskWithRoot = validDisks.find((d) =>
d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage')
)
const primary =
diskWithRoot || validDisks.reduce((a, b) => (b.totalSize > a.totalSize ? b : a))
return { totalSize: primary.totalSize, totalUsed: primary.totalUsed }
}
if (fsSize && fsSize.length > 0) {
const realDevices = fsSize.filter((fs) => fs.fs.startsWith('/dev/'))
const primary =
realDevices.length > 0
? realDevices.reduce((a, b) => (b.size > a.size ? b : a))
: fsSize[0]
return { totalSize: primary.size, totalUsed: primary.used }
}
return null
}

View File

@ -17,7 +17,11 @@ const useDownloads = (props: useDownloadsProps) => {
const queryData = useQuery({
queryKey: queryKey,
queryFn: () => api.listDownloadJobs(props.filetype),
refetchInterval: 2000, // Refetch every 2 seconds to get updated progress
refetchInterval: (query) => {
const data = query.state.data
// Only poll when there are active downloads; otherwise use a slower interval
return data && data.length > 0 ? 2000 : 30000
},
enabled: props.enabled ?? true,
})

View File

@ -7,7 +7,11 @@ const useEmbedJobs = (props: { enabled?: boolean } = {}) => {
const queryData = useQuery({
queryKey: ['embed-jobs'],
queryFn: () => api.getActiveEmbedJobs().then((data) => data ?? []),
refetchInterval: 2000,
refetchInterval: (query) => {
const data = query.state.data
// Only poll when there are active jobs; otherwise use a slower interval
return data && data.length > 0 ? 2000 : 30000
},
enabled: props.enabled ?? true,
})

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useTransmit } from 'react-adonis-transmit'
export type OllamaModelDownload = {
@ -10,6 +10,7 @@ export type OllamaModelDownload = {
export default function useOllamaModelDownloads() {
const { subscribe } = useTransmit()
const [downloads, setDownloads] = useState<Map<string, OllamaModelDownload>>(new Map())
const timeoutsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
useEffect(() => {
const unsubscribe = subscribe('ollama-model-download', (data: OllamaModelDownload) => {
@ -19,13 +20,15 @@ export default function useOllamaModelDownloads() {
if (data.percent >= 100) {
// If download is complete, keep it for a short time before removing to allow UI to show 100% progress
updated.set(data.model, data)
setTimeout(() => {
const timeout = setTimeout(() => {
timeoutsRef.current.delete(timeout)
setDownloads((current) => {
const next = new Map(current)
next.delete(data.model)
return next
})
}, 2000)
timeoutsRef.current.add(timeout)
} else {
updated.set(data.model, data)
}
@ -36,7 +39,10 @@ export default function useOllamaModelDownloads() {
return () => {
unsubscribe()
timeoutsRef.current.forEach(clearTimeout)
timeoutsRef.current.clear()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe])
const downloadsArray = Array.from(downloads.values())

View File

@ -0,0 +1,47 @@
import { useState, useEffect, useCallback } from 'react'
import api from '~/lib/api'
export type Theme = 'light' | 'dark'
const STORAGE_KEY = 'nomad:theme'
function getInitialTheme(): Theme {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'dark' || stored === 'light') return stored
} catch {}
return 'light'
}
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(getInitialTheme)
const setTheme = useCallback((newTheme: Theme) => {
setThemeState(newTheme)
document.documentElement.setAttribute('data-theme', newTheme)
try {
localStorage.setItem(STORAGE_KEY, newTheme)
} catch {}
// Fire-and-forget KV store sync for cross-device persistence
api.updateSetting('ui.theme', newTheme).catch(() => {})
}, [])
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const next = prev === 'light' ? 'dark' : 'light'
document.documentElement.setAttribute('data-theme', next)
try {
localStorage.setItem(STORAGE_KEY, next)
} catch {}
api.updateSetting('ui.theme', next).catch(() => {})
return next
})
}, [])
// Apply theme on mount
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
}, [])
return { theme, setTheme, toggleTheme }
}

View File

@ -18,7 +18,7 @@ export default function AppLayout({ children }: { children: React.ReactNode }) {
window.location.pathname !== '/home' && (
<Link href="/home" className="absolute top-60 md:top-48 left-4 flex items-center">
<IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-gray-600">Back to Home</p>
<p className="text-lg text-text-secondary">Back to Home</p>
</Link>
)}
<div

View File

@ -4,6 +4,7 @@ import {
IconDashboard,
IconFolder,
IconGavel,
IconHeart,
IconMapRoute,
IconSettings,
IconTerminal2,
@ -41,11 +42,12 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
current: false,
},
{ name: 'System', href: '/settings/system', icon: IconSettings, current: false },
{ name: 'Support the Project', href: '/settings/support', icon: IconHeart, current: false },
{ name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },
]
return (
<div className="min-h-screen flex flex-row bg-stone-50/90">
<div className="min-h-screen flex flex-row bg-surface-secondary/90">
<StyledSidebar title="Settings" items={navigation} />
{children}
</div>

View File

@ -211,6 +211,13 @@ class API {
})()
}
async getDebugInfo() {
return catchInternal(async () => {
const response = await this.client.get<{ debugInfo: string }>('/system/debug-info')
return response.data.debugInfo
})()
}
async getInternetStatus() {
return catchInternal(async () => {
const response = await this.client.get<boolean>('/system/internet-status')
@ -525,6 +532,12 @@ class API {
})()
}
async removeDownloadJob(jobId: string): Promise<void> {
return catchInternal(async () => {
await this.client.delete(`/downloads/jobs/${jobId}`)
})()
}
async runBenchmark(type: BenchmarkType, sync: boolean = false) {
return catchInternal(async () => {
const response = await this.client.post<RunBenchmarkResponse>(

View File

@ -25,7 +25,7 @@ export default function EasySetupWizardComplete() {
/>
)}
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-white rounded-md shadow-md p-6">
<div className="bg-surface-primary rounded-md shadow-md p-6">
<StyledSectionHeader title="App Installation Activity" className=" mb-4" />
<InstallActivityFeed
activity={installActivity}

View File

@ -16,6 +16,7 @@ import StorageProjectionBar from '~/components/StorageProjectionBar'
import { useNotifications } from '~/context/NotificationContext'
import useInternetStatus from '~/hooks/useInternetStatus'
import { useSystemInfo } from '~/hooks/useSystemInfo'
import { getPrimaryDiskInfo } from '~/hooks/useDiskDisplayData'
import classNames from 'classnames'
import type { CategoryWithStatus, SpecTier, SpecResource } from '../../../types/collections'
import { resolveTierResources } from '~/lib/collections'
@ -296,34 +297,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
])
// Get primary disk/filesystem info for storage projection
// Try disk array first (Linux/production), fall back to fsSize (Windows/dev)
// Filter out invalid disks (totalSize === 0) and prefer disk with root mount or largest valid disk
const getPrimaryDisk = () => {
if (!systemInfo?.disk || systemInfo.disk.length === 0) return null
// Filter to only valid disks with actual storage
const validDisks = systemInfo.disk.filter((d) => d.totalSize > 0)
if (validDisks.length === 0) return null
// Prefer disk containing root mount (/) or /storage mount
const diskWithRoot = validDisks.find((d) =>
d.filesystems?.some((fs) => fs.mount === '/' || fs.mount === '/storage')
)
if (diskWithRoot) return diskWithRoot
// Fall back to largest valid disk
return validDisks.reduce((largest, current) =>
current.totalSize > largest.totalSize ? current : largest
)
}
const primaryDisk = getPrimaryDisk()
const primaryFs = systemInfo?.fsSize?.[0]
const storageInfo = primaryDisk
? { totalSize: primaryDisk.totalSize, totalUsed: primaryDisk.totalUsed }
: primaryFs
? { totalSize: primaryFs.size, totalUsed: primaryFs.used }
: null
const storageInfo = getPrimaryDiskInfo(systemInfo?.disk, systemInfo?.fsSize)
const canProceedToNextStep = () => {
if (!isOnline) return false // Must be online to proceed
@ -444,7 +418,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<nav aria-label="Progress" className="px-6 pt-6">
<ol
role="list"
className="divide-y divide-gray-300 rounded-md md:flex md:divide-y-0 md:justify-between border border-desert-green"
className="divide-y divide-border-default rounded-md md:flex md:divide-y-0 md:justify-between border border-desert-green"
>
{steps.map((step, stepIdx) => (
<li key={step.number} className="relative md:flex-1 md:flex md:justify-center">
@ -454,7 +428,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-desert-green">
<IconCheck aria-hidden="true" className="size-6 text-white" />
</span>
<span className="ml-4 text-lg font-medium text-gray-900">{step.label}</span>
<span className="ml-4 text-lg font-medium text-text-primary">{step.label}</span>
</span>
</div>
) : currentStep === step.number ? (
@ -470,10 +444,10 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
) : (
<div className="group flex items-center md:justify-center">
<span className="flex items-center px-6 py-2 text-sm font-medium">
<span className="flex size-10 shrink-0 items-center justify-center rounded-full border-2 border-gray-300">
<span className="text-gray-500">{step.number}</span>
<span className="flex size-10 shrink-0 items-center justify-center rounded-full border-2 border-border-default">
<span className="text-text-muted">{step.number}</span>
</span>
<span className="ml-4 text-lg font-medium text-gray-500">{step.label}</span>
<span className="ml-4 text-lg font-medium text-text-muted">{step.label}</span>
</span>
</div>
)}
@ -489,7 +463,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
fill="none"
viewBox="0 0 22 80"
preserveAspectRatio="none"
className={`size-full ${currentStep > step.number ? 'text-desert-green' : 'text-gray-300'}`}
className={`size-full ${currentStep > step.number ? 'text-desert-green' : 'text-text-muted'}`}
>
<path
d="M0 -2L20 40L0 82"
@ -565,7 +539,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
? 'border-desert-green bg-desert-green/20 cursor-default'
: selected
? 'border-desert-green bg-desert-green shadow-md cursor-pointer'
: 'border-desert-stone-light bg-white hover:border-desert-green hover:shadow-sm cursor-pointer'
: 'border-desert-stone-light bg-surface-primary hover:border-desert-green hover:shadow-sm cursor-pointer'
)}
>
<div className="flex items-start justify-between">
@ -574,7 +548,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<h3
className={classNames(
'text-xl font-bold',
installed ? 'text-gray-700' : selected ? 'text-white' : 'text-gray-900'
installed ? 'text-text-primary' : selected ? 'text-white' : 'text-text-primary'
)}
>
{capability.name}
@ -588,7 +562,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<p
className={classNames(
'text-sm mt-0.5',
installed ? 'text-gray-500' : selected ? 'text-green-100' : 'text-gray-500'
installed ? 'text-text-muted' : selected ? 'text-green-100' : 'text-text-muted'
)}
>
Powered by {capability.technicalName}
@ -596,7 +570,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<p
className={classNames(
'text-sm mt-3',
installed ? 'text-gray-600' : selected ? 'text-white' : 'text-gray-600'
installed ? 'text-text-secondary' : selected ? 'text-white' : 'text-text-secondary'
)}
>
{capability.description}
@ -605,7 +579,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<ul
className={classNames(
'mt-3 space-y-1',
installed ? 'text-gray-600' : selected ? 'text-white' : 'text-gray-600'
installed ? 'text-text-secondary' : selected ? 'text-white' : 'text-text-secondary'
)}
>
{capability.features.map((feature, idx) => (
@ -661,15 +635,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return (
<div className="space-y-8">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">What do you want NOMAD to do?</h2>
<p className="text-gray-600">
<h2 className="text-3xl font-bold text-text-primary mb-2">What do you want NOMAD to do?</h2>
<p className="text-text-secondary">
Select the capabilities you need. You can always add more later.
</p>
</div>
{allInstalled ? (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">
<p className="text-text-secondary text-lg">
All available capabilities are already installed!
</p>
<StyledButton
@ -685,7 +659,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{/* Core Capabilities */}
{existingCoreCapabilities.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-700 mb-4">Core Capabilities</h3>
<h3 className="text-lg font-semibold text-text-primary mb-4">Core Capabilities</h3>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{existingCoreCapabilities.map((capability) =>
renderCapabilityCard(capability, true)
@ -701,11 +675,11 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
onClick={() => setShowAdditionalTools(!showAdditionalTools)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-md font-medium text-gray-500">Additional Tools</h3>
<h3 className="text-md font-medium text-text-muted">Additional Tools</h3>
{showAdditionalTools ? (
<IconChevronUp size={20} className="text-gray-400" />
<IconChevronUp size={20} className="text-text-muted" />
) : (
<IconChevronDown size={20} className="text-gray-400" />
<IconChevronDown size={20} className="text-text-muted" />
)}
</button>
{showAdditionalTools && (
@ -726,8 +700,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
const renderStep2 = () => (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose Map Regions</h2>
<p className="text-gray-600">
<h2 className="text-3xl font-bold text-text-primary mb-2">Choose Map Regions</h2>
<p className="text-text-secondary">
Select map region collections to download for offline use. You can always download more
regions later.
</p>
@ -763,7 +737,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
</div>
) : (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">No map collections available at this time.</p>
<p className="text-text-secondary text-lg">No map collections available at this time.</p>
</div>
)}
</div>
@ -779,8 +753,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Choose Content</h2>
<p className="text-gray-600">
<h2 className="text-3xl font-bold text-text-primary mb-2">Choose Content</h2>
<p className="text-text-secondary">
{isAiSelected && isInformationSelected
? 'Select AI models and content categories for offline use.'
: isAiSelected
@ -795,12 +769,12 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{isAiSelected && (
<div className="mb-8">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm">
<IconCpu className="w-6 h-6 text-gray-700" />
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
<IconCpu className="w-6 h-6 text-text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">AI Models</h3>
<p className="text-sm text-gray-500">Select models to download for offline AI</p>
<h3 className="text-xl font-semibold text-text-primary">AI Models</h3>
<p className="text-sm text-text-muted">Select models to download for offline AI</p>
</div>
</div>
@ -818,7 +792,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
'p-4 rounded-lg border-2 transition-all cursor-pointer',
selectedAiModels.includes(model.name)
? 'border-desert-green bg-desert-green shadow-md'
: 'border-desert-stone-light bg-white hover:border-desert-green hover:shadow-sm',
: 'border-desert-stone-light bg-surface-primary hover:border-desert-green hover:shadow-sm',
!isOnline && 'opacity-50 cursor-not-allowed'
)}
>
@ -827,7 +801,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<h4
className={classNames(
'text-lg font-semibold mb-1',
selectedAiModels.includes(model.name) ? 'text-white' : 'text-gray-900'
selectedAiModels.includes(model.name) ? 'text-white' : 'text-text-primary'
)}
>
{model.name}
@ -835,7 +809,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<p
className={classNames(
'text-sm mb-2',
selectedAiModels.includes(model.name) ? 'text-white' : 'text-gray-600'
selectedAiModels.includes(model.name) ? 'text-white' : 'text-text-secondary'
)}
>
{model.description}
@ -846,7 +820,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
'text-xs',
selectedAiModels.includes(model.name)
? 'text-green-100'
: 'text-gray-500'
: 'text-text-muted'
)}
>
Size: {model.tags[0].size}
@ -870,8 +844,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
))}
</div>
) : (
<div className="text-center py-8 bg-gray-50 rounded-lg">
<p className="text-gray-600">No recommended AI models available at this time.</p>
<div className="text-center py-8 bg-surface-secondary rounded-lg">
<p className="text-text-secondary">No recommended AI models available at this time.</p>
</div>
)}
</div>
@ -881,7 +855,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{isInformationSelected && (
<>
{/* Divider between AI Models and Wikipedia */}
{isAiSelected && <hr className="my-8 border-gray-200" />}
{isAiSelected && <hr className="my-8 border-border-subtle" />}
<div className="mb-8">
{isLoadingWikipedia ? (
@ -905,15 +879,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{isInformationSelected && (
<>
{/* Divider between Wikipedia and Additional Content */}
<hr className="my-8 border-gray-200" />
<hr className="my-8 border-border-subtle" />
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm">
<IconBooks className="w-6 h-6 text-gray-700" />
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
<IconBooks className="w-6 h-6 text-text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">Additional Content</h3>
<p className="text-sm text-gray-500">Curated collections for offline reference</p>
<h3 className="text-xl font-semibold text-text-primary">Additional Content</h3>
<p className="text-sm text-text-muted">Curated collections for offline reference</p>
</div>
</div>
@ -955,7 +929,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
{/* Show message if no capabilities requiring content are selected */}
{!isAiSelected && !isInformationSelected && (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">
<p className="text-text-secondary text-lg">
No content-based capabilities selected. You can skip this step or go back to select
capabilities that require content.
</p>
@ -976,8 +950,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return (
<div className="space-y-6">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold text-gray-900 mb-2">Review Your Selections</h2>
<p className="text-gray-600">Review your choices before starting the setup process.</p>
<h2 className="text-3xl font-bold text-text-primary mb-2">Review Your Selections</h2>
<p className="text-text-secondary">Review your choices before starting the setup process.</p>
</div>
{!hasSelections ? (
@ -990,8 +964,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
) : (
<div className="space-y-6">
{selectedServices.length > 0 && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
Capabilities to Install
</h3>
<ul className="space-y-2">
@ -1000,9 +974,9 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
.map((capability) => (
<li key={capability.id} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">
<span className="text-text-primary">
{capability.name}
<span className="text-gray-400 text-sm ml-2">
<span className="text-text-muted text-sm ml-2">
({capability.technicalName})
</span>
</span>
@ -1013,8 +987,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
)}
{selectedMapCollections.length > 0 && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
Map Collections to Download ({selectedMapCollections.length})
</h3>
<ul className="space-y-2">
@ -1023,7 +997,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
return (
<li key={slug} className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">{collection?.name || slug}</span>
<span className="text-text-primary">{collection?.name || slug}</span>
</li>
)
})}
@ -1032,8 +1006,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
)}
{selectedTiers.size > 0 && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
Content Categories ({selectedTiers.size})
</h3>
{Array.from(selectedTiers.entries()).map(([categorySlug, tier]) => {
@ -1044,16 +1018,16 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<div key={categorySlug} className="mb-4 last:mb-0">
<div className="flex items-center mb-2">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-900 font-medium">
<span className="text-text-primary font-medium">
{category.name} - {tier.name}
</span>
<span className="text-gray-500 text-sm ml-2">
<span className="text-text-muted text-sm ml-2">
({resources.length} files)
</span>
</div>
<ul className="ml-7 space-y-1">
{resources.map((resource, idx) => (
<li key={idx} className="text-sm text-gray-600">
<li key={idx} className="text-sm text-text-secondary">
{resource.title}
</li>
))}
@ -1065,17 +1039,17 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
)}
{selectedWikipedia && selectedWikipedia !== 'none' && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">Wikipedia</h3>
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">Wikipedia</h3>
{(() => {
const option = wikipediaState?.options.find((o) => o.id === selectedWikipedia)
return option ? (
<div className="flex items-center justify-between">
<div className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">{option.name}</span>
<span className="text-text-primary">{option.name}</span>
</div>
<span className="text-gray-500 text-sm">
<span className="text-text-muted text-sm">
{option.size_mb > 0
? `${(option.size_mb / 1024).toFixed(1)} GB`
: 'No download'}
@ -1087,8 +1061,8 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
)}
{selectedAiModels.length > 0 && (
<div className="bg-white rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-gray-900 mb-4">
<div className="bg-surface-primary rounded-lg border-2 border-desert-stone-light p-6">
<h3 className="text-xl font-semibold text-text-primary mb-4">
AI Models to Download ({selectedAiModels.length})
</h3>
<ul className="space-y-2">
@ -1098,10 +1072,10 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
<li key={modelName} className="flex items-center justify-between">
<div className="flex items-center">
<IconCheck size={20} className="text-desert-green mr-2" />
<span className="text-gray-700">{modelName}</span>
<span className="text-text-primary">{modelName}</span>
</div>
{model?.tags?.[0]?.size && (
<span className="text-gray-500 text-sm">{model.tags[0].size}</span>
<span className="text-text-muted text-sm">{model.tags[0].size}</span>
)}
</li>
)
@ -1135,7 +1109,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
/>
)}
<div className="max-w-7xl mx-auto px-4 py-8">
<div className="bg-white rounded-md shadow-md">
<div className="bg-surface-primary rounded-md shadow-md">
{renderStepIndicator()}
{storageInfo && (
<div className="px-6 pt-4">
@ -1165,7 +1139,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
</StyledButton>
)}
<p className="text-sm text-gray-600">
<p className="text-sm text-text-secondary">
{(() => {
const count = [...CORE_CAPABILITIES, ...ADDITIONAL_TOOLS].filter((cap) =>
cap.services.some((s) => selectedServices.includes(s))

View File

@ -97,7 +97,7 @@ export default function Home(props: {
const { data: easySetupVisited } = useSystemSetting({
key: 'ui.hasVisitedEasySetup'
})
const shouldHighlightEasySetup = easySetupVisited?.value ? easySetupVisited?.value !== 'true' : false
const shouldHighlightEasySetup = easySetupVisited?.value ? String(easySetupVisited.value) !== 'true' : false
// Add installed services (non-dependency services only)
props.system.services
@ -161,7 +161,7 @@ export default function Home(props: {
return (
<a key={item.label} href={item.to} target={item.target}>
<div className="relative 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 text-center px-4">
<div className="relative rounded border-desert-green border-2 bg-desert-green hover:bg-transparent hover:text-text-primary text-white transition-colors shadow-sm h-48 flex flex-col items-center justify-center cursor-pointer text-center px-4">
{shouldHighlight && (
<span className="absolute top-2 right-2 flex items-center justify-center">
<span

View File

@ -20,10 +20,10 @@ export default function Maps(props: {
<Head title="Maps" />
<div className="relative w-full h-screen overflow-hidden">
{/* Nav and alerts are overlayed */}
<div className="absolute top-0 left-0 right-0 z-50 flex justify-between p-4 bg-gray-50 backdrop-blur-sm shadow-sm">
<div className="absolute top-0 left-0 right-0 z-50 flex justify-between p-4 bg-surface-secondary backdrop-blur-sm shadow-sm">
<Link href="/home" className="flex items-center">
<IconArrowLeft className="mr-2" size={24} />
<p className="text-lg text-gray-600">Back to Home</p>
<p className="text-lg text-text-secondary">Back to Home</p>
</Link>
<Link href="/settings/maps" className='mr-4'>
<StyledButton variant="primary" icon="IconSettings">

View File

@ -90,7 +90,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
confirmVariant="primary"
icon={<IconDownload className="h-12 w-12 text-desert-green" />}
>
<p className="text-gray-700">
<p className="text-text-primary">
Are you sure you want to install {service.friendly_name || 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.
@ -214,7 +214,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
confirmText={'Force Reinstall'}
cancelText="Cancel"
>
<p className="text-gray-700">
<p className="text-text-primary">
Are you sure you want to force reinstall {record.service_name}? This will{' '}
<strong>WIPE ALL DATA</strong> for this service and cannot be undone. You should
only do this if the service is malfunctioning and other troubleshooting steps have
@ -285,7 +285,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
confirmText={record.status === 'running' ? 'Stop' : 'Start'}
cancelText="Cancel"
>
<p className="text-gray-700">
<p className="text-text-primary">
Are you sure you want to {record.status === 'running' ? 'stop' : 'start'}{' '}
{record.service_name}?
</p>
@ -311,7 +311,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
confirmText={'Restart'}
cancelText="Cancel"
>
<p className="text-gray-700">
<p className="text-text-primary">
Are you sure you want to restart {record.service_name}?
</p>
</StyledModal>,
@ -338,7 +338,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-4xl font-semibold">Apps</h1>
<p className="text-gray-500 mt-1">
<p className="text-text-muted mt-1">
Manage the applications that are available in your Project N.O.M.A.D. instance. Nightly update checks will automatically detect when new versions of these apps are available.
</p>
</div>
@ -364,7 +364,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
return (
<div className="flex flex-col">
<p>{record.friendly_name || record.service_name}</p>
<p className="text-sm text-gray-500">{record.description}</p>
<p className="text-sm text-text-muted">{record.description}</p>
</div>
)
},
@ -398,7 +398,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
if (record.available_update_version) {
return (
<div className="flex items-center gap-1.5">
<span className="text-gray-500">{currentTag}</span>
<span className="text-text-muted">{currentTag}</span>
<IconArrowUp className="h-4 w-4 text-desert-green" />
<span className="text-desert-green font-semibold">
{record.available_update_version}
@ -406,7 +406,7 @@ export default function SettingsPage(props: { system: { services: ServiceSlim[]
</div>
)
}
return <span className="text-gray-600">{currentTag}</span>
return <span className="text-text-secondary">{currentTag}</span>
},
},
{

View File

@ -1,5 +1,5 @@
import { Head, Link, usePage } from '@inertiajs/react'
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import SettingsLayout from '~/layouts/SettingsLayout'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import CircularGauge from '~/components/systeminfo/CircularGauge'
@ -40,6 +40,7 @@ export default function BenchmarkPage(props: {
const aiInstalled = useServiceInstalledStatus(SERVICE_NAMES.OLLAMA)
const [progress, setProgress] = useState<BenchmarkProgressWithID | null>(null)
const [isRunning, setIsRunning] = useState(props.benchmark.status !== 'idle')
const refetchLatestRef = useRef<(() => void) | null>(null)
const [showDetails, setShowDetails] = useState(false)
const [showHistory, setShowHistory] = useState(false)
const [showAIRequiredAlert, setShowAIRequiredAlert] = useState(false)
@ -60,6 +61,7 @@ export default function BenchmarkPage(props: {
},
initialData: props.benchmark.latestResult,
})
refetchLatestRef.current = refetchLatest
// Fetch all benchmark results for history
const { data: benchmarkHistory } = useQuery({
@ -306,14 +308,15 @@ export default function BenchmarkPage(props: {
setProgress(data)
if (data.status === 'completed' || data.status === 'error') {
setIsRunning(false)
refetchLatest()
refetchLatestRef.current?.()
}
})
return () => {
unsubscribe()
}
}, [subscribe, refetchLatest])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe])
const formatBytes = (bytes: number) => {
const gb = bytes / (1024 * 1024 * 1024)

View File

@ -12,16 +12,16 @@ export default function LegalPage() {
{/* License Agreement */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">License Agreement</h2>
<p className="text-gray-700 mb-3">Copyright 2024-2026 Crosstalk Solutions, LLC</p>
<p className="text-gray-700 mb-3">
<p className="text-text-primary mb-3">Copyright 2024-2026 Crosstalk Solutions, LLC</p>
<p className="text-text-primary mb-3">
Licensed under the Apache License, Version 2.0 (the &quot;License&quot;);
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
</p>
<p className="text-gray-700 mb-3">
<p className="text-text-primary mb-3">
<a href="https://www.apache.org/licenses/LICENSE-2.0" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">https://www.apache.org/licenses/LICENSE-2.0</a>
</p>
<p className="text-gray-700">
<p className="text-text-primary">
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an &quot;AS IS&quot; BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -33,11 +33,11 @@ export default function LegalPage() {
{/* Third-Party Software */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Third-Party Software Attribution</h2>
<p className="text-gray-700 mb-4">
<p className="text-text-primary mb-4">
Project N.O.M.A.D. integrates the following open source projects. We are grateful to
their developers and communities:
</p>
<ul className="space-y-3 text-gray-700">
<ul className="space-y-3 text-text-primary">
<li>
<strong>Kiwix</strong> - Offline Wikipedia and content reader (GPL-3.0 License)
<br />
@ -74,10 +74,10 @@ export default function LegalPage() {
{/* Privacy Statement */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Privacy Statement</h2>
<p className="text-gray-700 mb-3">
<p className="text-text-primary mb-3">
Project N.O.M.A.D. is designed with privacy as a core principle:
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700">
<ul className="list-disc list-inside space-y-2 text-text-primary">
<li><strong>Zero Telemetry:</strong> N.O.M.A.D. does not collect, transmit, or store any usage data, analytics, or telemetry.</li>
<li><strong>Local-First:</strong> All your data, downloaded content, AI conversations, and notes remain on your device.</li>
<li><strong>No Accounts Required:</strong> N.O.M.A.D. operates without user accounts or authentication by default.</li>
@ -88,17 +88,17 @@ export default function LegalPage() {
{/* Content Disclaimer */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Content Disclaimer</h2>
<p className="text-gray-700 mb-3">
<p className="text-text-primary mb-3">
Project N.O.M.A.D. provides tools to download and access content from third-party sources
including Wikipedia, Wikibooks, medical references, educational platforms, and other
publicly available resources.
</p>
<p className="text-gray-700 mb-3">
<p className="text-text-primary mb-3">
Crosstalk Solutions, LLC does not create, control, verify, or guarantee the accuracy,
completeness, or reliability of any third-party content. The inclusion of any content
does not constitute an endorsement.
</p>
<p className="text-gray-700">
<p className="text-text-primary">
Users are responsible for evaluating the appropriateness and accuracy of any content
they download and use.
</p>
@ -107,15 +107,15 @@ export default function LegalPage() {
{/* Medical Disclaimer */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Medical and Emergency Information Disclaimer</h2>
<p className="text-gray-700 mb-3">
<p className="text-text-primary mb-3">
Some content available through N.O.M.A.D. includes medical references, first aid guides,
and emergency preparedness information. This content is provided for general
informational purposes only.
</p>
<p className="text-gray-700 mb-3 font-semibold">
<p className="text-text-primary mb-3 font-semibold">
This information is NOT a substitute for professional medical advice, diagnosis, or treatment.
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700 mb-3">
<ul className="list-disc list-inside space-y-2 text-text-primary mb-3">
<li>Always seek the advice of qualified health providers with questions about medical conditions.</li>
<li>Never disregard professional medical advice or delay seeking it because of something you read in offline content.</li>
<li>In a medical emergency, call emergency services immediately if available.</li>
@ -126,15 +126,15 @@ export default function LegalPage() {
{/* Data Storage Notice */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-4">Data Storage</h2>
<p className="text-gray-700 mb-3">
<p className="text-text-primary mb-3">
All data associated with Project N.O.M.A.D. is stored locally on your device:
</p>
<ul className="list-disc list-inside space-y-2 text-gray-700">
<ul className="list-disc list-inside space-y-2 text-text-primary">
<li><strong>Installation Directory:</strong> /opt/project-nomad</li>
<li><strong>Downloaded Content:</strong> /opt/project-nomad/storage</li>
<li><strong>Application Data:</strong> Stored in Docker volumes on your local system</li>
</ul>
<p className="text-gray-700 mt-3">
<p className="text-text-primary mt-3">
You maintain full control over your data. Uninstalling N.O.M.A.D. or deleting these
directories will permanently remove all associated data.
</p>

View File

@ -104,7 +104,7 @@ export default function MapsManager(props: {
cancelText="Cancel"
confirmVariant="danger"
>
<p className="text-gray-700">
<p className="text-text-secondary">
Are you sure you want to delete {file.name}? This action cannot be undone.
</p>
</StyledModal>,
@ -136,7 +136,7 @@ export default function MapsManager(props: {
cancelText="Cancel"
confirmVariant="primary"
>
<p className="text-gray-700">
<p className="text-text-secondary">
Are you sure you want to download <strong>{isCollection ? record.name : record}</strong>?
It may take some time for it to be available depending on the file size and your internet
connection.
@ -180,7 +180,7 @@ export default function MapsManager(props: {
<div className="flex items-center justify-between">
<div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-2">Maps Manager</h1>
<p className="text-gray-500">Manage your stored map files and explore new regions!</p>
<p className="text-text-muted">Manage your stored map files and explore new regions!</p>
</div>
<div className="flex space-x-4">
@ -220,7 +220,7 @@ export default function MapsManager(props: {
/>
))}
{curatedCollections && curatedCollections.length === 0 && (
<p className="text-gray-500">No curated collections available.</p>
<p className="text-text-muted">No curated collections available.</p>
)}
</div>
<div className="mt-12 mb-6 flex items-center justify-between">

View File

@ -82,7 +82,7 @@ export default function ModelsPage(props: {
confirmText="Reinstall"
cancelText="Cancel"
>
<p className="text-gray-700">
<p className="text-text-primary">
This will recreate the {aiAssistantName} container with GPU support enabled.
Your downloaded models will be preserved. The service will be briefly
unavailable during reinstall.
@ -190,7 +190,7 @@ export default function ModelsPage(props: {
cancelText="Cancel"
confirmVariant="primary"
>
<p className="text-gray-700">
<p className="text-text-primary">
Are you sure you want to delete this model? You will need to download it again if you want
to use it in the future.
</p>
@ -224,7 +224,7 @@ export default function ModelsPage(props: {
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-4">{aiAssistantName}</h1>
<p className="text-gray-500 mb-4">
<p className="text-text-muted mb-4">
Easily manage the {aiAssistantName}'s settings and installed models. We recommend
starting with smaller models first to see how they perform on your system before moving
on to larger ones.
@ -259,7 +259,7 @@ export default function ModelsPage(props: {
)}
<StyledSectionHeader title="Settings" className="mt-8 mb-4" />
<div className="bg-white rounded-lg border-2 border-gray-200 p-6">
<div className="bg-surface-primary rounded-lg border-2 border-border-subtle p-6">
<div className="space-y-4">
<Switch
checked={chatSuggestionsEnabled}
@ -300,7 +300,7 @@ export default function ModelsPage(props: {
debouncedSetQuery(e.target.value)
}}
className="w-1/3"
leftIcon={<IconSearch className="w-5 h-5 text-gray-400" />}
leftIcon={<IconSearch className="w-5 h-5 text-text-muted" />}
/>
<StyledButton
variant="secondary"
@ -323,7 +323,7 @@ export default function ModelsPage(props: {
return (
<div className="flex flex-col">
<p className="text-lg font-semibold">{record.name}</p>
<p className="text-sm text-gray-500">{record.description}</p>
<p className="text-sm text-text-muted">{record.description}</p>
</div>
)
},
@ -342,49 +342,49 @@ export default function ModelsPage(props: {
expandable={{
expandedRowRender: (record) => (
<div className="pl-14">
<div className="bg-white overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-white">
<div className="bg-surface-primary overflow-hidden">
<table className="min-w-full divide-y divide-border-subtle">
<thead className="bg-surface-primary">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Tag
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Input Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Context Size
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Model Size
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<tbody className="bg-surface-primary divide-y divide-border-subtle">
{record.tags.map((tag, tagIndex) => {
const isInstalled = props.models.installedModels.some(
(mod) => mod.name === tag.name
)
return (
<tr key={tagIndex} className="hover:bg-slate-50">
<tr key={tagIndex} className="hover:bg-surface-secondary">
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-gray-900">
<span className="text-sm font-medium text-text-primary">
{tag.name}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">{tag.input || 'N/A'}</span>
<span className="text-sm text-text-secondary">{tag.input || 'N/A'}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">
<span className="text-sm text-text-secondary">
{tag.context || 'N/A'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-gray-600">{tag.size || 'N/A'}</span>
<span className="text-sm text-text-secondary">{tag.size || 'N/A'}</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StyledButton

View File

@ -0,0 +1,110 @@
import { Head } from '@inertiajs/react'
import { IconExternalLink } from '@tabler/icons-react'
import SettingsLayout from '~/layouts/SettingsLayout'
export default function SupportPage() {
return (
<SettingsLayout>
<Head title="Support the Project | Project N.O.M.A.D." />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6 max-w-4xl">
<h1 className="text-4xl font-semibold mb-4">Support the Project</h1>
<p className="text-text-muted mb-10 text-lg">
Project NOMAD is 100% free and open source no subscriptions, no paywalls, no catch.
If you'd like to help keep the project going, here are a few ways to show your support.
</p>
{/* Ko-fi */}
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-3">Buy Us a Coffee</h2>
<p className="text-text-muted mb-4">
Every contribution helps fund development, server costs, and new content packs for NOMAD.
Even a small donation goes a long way.
</p>
<a
href="https://ko-fi.com/crosstalk"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-[#FF5E5B] hover:bg-[#e54e4b] text-white font-semibold rounded-lg transition-colors"
>
Support on Ko-fi
<IconExternalLink size={18} />
</a>
</section>
{/* Rogue Support */}
<section className="mb-12">
<h2 className="text-2xl font-semibold mb-3">Need Help With Your Home Network?</h2>
<a
href="https://roguesupport.com"
target="_blank"
rel="noopener noreferrer"
className="block mb-4 rounded-lg overflow-hidden hover:opacity-90 transition-opacity"
>
<img
src="/rogue-support-banner.png"
alt="Rogue Support — Conquer Your Home Network"
className="w-full"
/>
</a>
<p className="text-text-muted mb-4">
Rogue Support is a networking consultation service for home users.
Think of it as Uber for computer networking expert help when you need it.
</p>
<a
href="https://roguesupport.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-blue-600 hover:underline font-medium"
>
Visit RogueSupport.com
<IconExternalLink size={16} />
</a>
</section>
{/* Other Ways to Help */}
<section className="mb-10">
<h2 className="text-2xl font-semibold mb-3">Other Ways to Help</h2>
<ul className="space-y-2 text-text-muted">
<li>
<a
href="https://github.com/Crosstalk-Solutions/project-nomad"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Star the project on GitHub
</a>
{' '} it helps more people discover NOMAD
</li>
<li>
<a
href="https://github.com/Crosstalk-Solutions/project-nomad/issues"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Report bugs and suggest features
</a>
{' '} every report makes NOMAD better
</li>
<li>Share NOMAD with someone who'd use it word of mouth is the best marketing</li>
<li>
<a
href="https://discord.com/invite/crosstalksolutions"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Join the Discord community
</a>
{' '} hang out, share your build, help other users
</li>
</ul>
</section>
</main>
</div>
</SettingsLayout>
)
}

View File

@ -3,6 +3,7 @@ import { Head } from '@inertiajs/react'
import SettingsLayout from '~/layouts/SettingsLayout'
import { SystemInformationResponse } from '../../../types/system'
import { formatBytes } from '~/lib/util'
import { getAllDiskDisplayItems } from '~/hooks/useDiskDisplayData'
import CircularGauge from '~/components/systeminfo/CircularGauge'
import HorizontalBarChart from '~/components/HorizontalBarChart'
import InfoCard from '~/components/systeminfo/InfoCard'
@ -71,7 +72,7 @@ export default function SettingsPage(props: {
confirmText="Reinstall"
cancelText="Cancel"
>
<p className="text-gray-700">
<p className="text-text-primary">
This will recreate the AI Assistant container with GPU support enabled.
Your downloaded models will be preserved. The service will be briefly
unavailable during reinstall.
@ -105,42 +106,7 @@ export default function SettingsPage(props: {
: `${uptimeMinutes}m`
// Build storage display items - fall back to fsSize when disk array is empty
// (Same approach as Easy Setup wizard fix from PR #90)
const validDisks = info?.disk?.filter((d) => d.totalSize > 0) || []
let storageItems: {
label: string
value: number
total: string
used: string
subtext: string
}[] = []
if (validDisks.length > 0) {
storageItems = validDisks.map((disk) => ({
label: disk.name || 'Unknown',
value: disk.percentUsed || 0,
total: disk.totalSize ? formatBytes(disk.totalSize) : 'N/A',
used: disk.totalUsed ? formatBytes(disk.totalUsed) : 'N/A',
subtext: `${formatBytes(disk.totalUsed || 0)} / ${formatBytes(disk.totalSize || 0)}`,
}))
} else if (info?.fsSize && info.fsSize.length > 0) {
// Deduplicate by size (same physical disk mounted in multiple places shows identical sizes)
const seen = new Set<number>()
const uniqueFs = info.fsSize.filter((fs) => {
if (fs.size <= 0 || seen.has(fs.size)) return false
seen.add(fs.size)
return true
})
// Prefer real block devices (/dev/), exclude virtual filesystems (efivarfs, tmpfs, etc.)
const realDevices = uniqueFs.filter((fs) => fs.fs.startsWith('/dev/'))
const displayFs = realDevices.length > 0 ? realDevices : uniqueFs
storageItems = displayFs.map((fs) => ({
label: fs.fs || 'Unknown',
value: fs.use || 0,
total: formatBytes(fs.size),
used: formatBytes(fs.used),
subtext: `${formatBytes(fs.used)} / ${formatBytes(fs.size)}`,
}))
}
const storageItems = getAllDiskDisplayItems(info?.disk, info?.fsSize)
return (
<SettingsLayout>
@ -313,7 +279,7 @@ export default function SettingsPage(props: {
style={{ width: `${memoryUsagePercent}%` }}
></div>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-desert-white drop-shadow-md z-10">
<span className="text-sm font-bold text-white drop-shadow-md z-10">
{memoryUsagePercent}% Utilized
</span>
</div>

View File

@ -107,7 +107,7 @@ function ContentUpdatesSection() {
<div className="mt-8">
<StyledSectionHeader title="Content Updates" />
<div className="bg-white rounded-lg border shadow-md overflow-hidden p-6">
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden p-6">
<div className="flex items-center justify-between">
<p className="text-desert-stone-dark">
Check if newer versions of your installed ZIM files and maps are available.
@ -258,7 +258,13 @@ export default function SystemUpdatePage(props: { system: Props }) {
// Check if update is complete or errored
if (response.stage === 'complete') {
// Give a moment for the new container to fully start
// Re-check version so the KV store clears the stale "update available" flag
// before we reload, otherwise the banner shows "current → current"
try {
await api.checkLatestVersion(true)
} catch {
// Non-critical - page reload will still work
}
setTimeout(() => {
window.location.reload()
}, 2000)
@ -431,7 +437,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
/>
</div>
)}
<div className="bg-white rounded-lg border shadow-md overflow-hidden">
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden">
<div className="p-8 text-center">
<div className="flex justify-center mb-4">{getStatusIcon()}</div>
@ -526,7 +532,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
</div>
)}
</div>
<div className="border-t bg-white p-6">
<div className="border-t bg-surface-primary p-6">
<h3 className="text-lg font-semibold text-desert-green mb-4">
What happens during an update?
</h3>
@ -596,7 +602,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
/>
</div>
<StyledSectionHeader title="Early Access" className="mt-8" />
<div className="bg-white rounded-lg border shadow-md overflow-hidden mt-6 p-6">
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden mt-6 p-6">
<Switch
checked={earlyAccessSetting.data?.value || false}
onChange={(newVal) => {
@ -608,7 +614,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
/>
</div>
<ContentUpdatesSection />
<div className="bg-white rounded-lg border shadow-md overflow-hidden py-6 mt-12">
<div className="bg-surface-primary rounded-lg border shadow-md overflow-hidden py-6 mt-12">
<div className="flex flex-col md:flex-row justify-between items-center p-8 gap-y-8 md:gap-y-0 gap-x-8">
<div>
<h2 className="max-w-xl text-lg font-bold text-desert-green sm:text-xl lg:col-span-7">
@ -648,7 +654,7 @@ export default function SystemUpdatePage(props: { system: Props }) {
{showLogs && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white rounded-lg shadow-2xl max-w-4xl w-full max-h-[80vh] flex flex-col">
<div className="bg-surface-primary rounded-lg shadow-2xl max-w-4xl w-full max-h-[80vh] flex flex-col">
<div className="p-6 border-b border-desert-stone-light flex justify-between items-center">
<h3 className="text-xl font-bold text-desert-green">Update Logs</h3>
<button

View File

@ -39,7 +39,7 @@ export default function ZimPage() {
cancelText="Cancel"
confirmVariant="danger"
>
<p className="text-gray-700">
<p className="text-text-secondary">
Are you sure you want to delete {file.name}? This action cannot be undone.
</p>
</StyledModal>,
@ -62,7 +62,7 @@ export default function ZimPage() {
<div className="flex items-center justify-between">
<div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-2">Content Manager</h1>
<p className="text-gray-500">
<p className="text-text-muted">
Manage your stored content files.
</p>
</div>
@ -94,7 +94,7 @@ export default function ZimPage() {
accessor: 'summary',
title: 'Summary',
render: (record) => (
<span className="text-gray-600 text-sm line-clamp-2">
<span className="text-text-secondary text-sm line-clamp-2">
{record.summary || '—'}
</span>
),

View File

@ -159,7 +159,7 @@ export default function ZimRemoteExplorer() {
cancelText="Cancel"
confirmVariant="primary"
>
<p className="text-gray-700">
<p className="text-text-primary">
Are you sure you want to download{' '}
<strong>{record.title}</strong>? It may take some time for it
to be available depending on the file size and your internet connection. The Kiwix
@ -277,7 +277,7 @@ export default function ZimRemoteExplorer() {
<div className="flex justify-between items-center">
<div className="flex flex-col">
<h1 className="text-4xl font-semibold mb-2">Content Explorer</h1>
<p className="text-gray-500">Browse and download content for offline reading!</p>
<p className="text-text-muted">Browse and download content for offline reading!</p>
</div>
</div>
{!isOnline && (
@ -310,13 +310,13 @@ export default function ZimRemoteExplorer() {
{/* Wikipedia Selector */}
{isLoadingWikipedia ? (
<div className="mt-8 bg-white rounded-lg border border-gray-200 p-6">
<div className="mt-8 bg-surface-primary rounded-lg border border-border-subtle p-6">
<div className="flex justify-center py-6">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-desert-green"></div>
</div>
</div>
) : wikipediaState && wikipediaState.options.length > 0 ? (
<div className="mt-8 bg-white rounded-lg border border-gray-200 p-6">
<div className="mt-8 bg-surface-primary rounded-lg border border-border-subtle p-6">
<WikipediaSelector
options={wikipediaState.options}
currentSelection={wikipediaState.currentSelection}
@ -332,12 +332,12 @@ export default function ZimRemoteExplorer() {
{/* Tiered Category Collections */}
<div className="flex items-center gap-3 mt-8 mb-4">
<div className="w-10 h-10 rounded-full bg-white border border-gray-200 flex items-center justify-center shadow-sm">
<IconBooks className="w-6 h-6 text-gray-700" />
<div className="w-10 h-10 rounded-full bg-surface-primary border border-border-subtle flex items-center justify-center shadow-sm">
<IconBooks className="w-6 h-6 text-text-primary" />
</div>
<div>
<h3 className="text-xl font-semibold text-gray-900">Additional Content</h3>
<p className="text-sm text-gray-500">Curated collections for offline reference</p>
<h3 className="text-xl font-semibold text-text-primary">Additional Content</h3>
<p className="text-sm text-text-muted">Curated collections for offline reference</p>
</div>
</div>
{categories && categories.length > 0 ? (
@ -363,7 +363,7 @@ export default function ZimRemoteExplorer() {
/>
</>
) : (
<p className="text-gray-500 mt-4">No curated content categories available.</p>
<p className="text-text-muted mt-4">No curated content categories available.</p>
)}
<StyledSectionHeader title="Browse the Kiwix Library" className="mt-12 mb-4" />
<div className="flex justify-start mt-4">
@ -377,7 +377,7 @@ export default function ZimRemoteExplorer() {
debouncedSetQuery(e.target.value)
}}
className="w-1/3"
leftIcon={<IconSearch className="w-5 h-5 text-gray-400" />}
leftIcon={<IconSearch className="w-5 h-5 text-text-muted" />}
/>
</div>
<StyledTable<RemoteZimFileEntry & { actions?: any }>

View File

@ -61,7 +61,7 @@ const NotificationsProvider = ({ children }: { children: React.ReactNode }) => {
{notifications.map((notification) => (
<div
key={notification.id}
className={`mb-4 p-4 rounded shadow-md border border-slate-300 bg-white max-w-96`}
className={`mb-4 p-4 rounded shadow-md border border-border-default bg-surface-primary max-w-96`}
onClick={() => removeNotification(notification.id)}
>
<div className="flex flex-row items-start gap-3">

View File

@ -0,0 +1,27 @@
import { createContext, useContext } from 'react'
import { useTheme, Theme } from '~/hooks/useTheme'
interface ThemeContextType {
theme: Theme
setTheme: (theme: Theme) => void
toggleTheme: () => void
}
const ThemeContext = createContext<ThemeContextType>({
theme: 'light',
setTheme: () => {},
toggleTheme: () => {},
})
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const themeState = useTheme()
return (
<ThemeContext.Provider value={themeState}>
{children}
</ThemeContext.Provider>
)
}
export function useThemeContext() {
return useContext(ThemeContext)
}

View File

@ -44,7 +44,7 @@
"cheerio": "^1.2.0",
"dockerode": "^4.0.7",
"edge.js": "^6.2.1",
"fast-xml-parser": "^5.3.8",
"fast-xml-parser": "^5.5.6",
"fuse.js": "^7.1.0",
"luxon": "^3.6.1",
"maplibre-gl": "^4.7.1",
@ -64,9 +64,9 @@
"remark-gfm": "^4.0.1",
"sharp": "^0.34.5",
"stopword": "^3.1.5",
"systeminformation": "^5.30.8",
"systeminformation": "^5.31.0",
"tailwindcss": "^4.1.10",
"tar": "^7.5.10",
"tar": "^7.5.11",
"tesseract.js": "^7.0.0",
"url-join": "^5.0.0",
"yaml": "^2.8.0"
@ -4379,6 +4379,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4395,6 +4396,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4411,6 +4413,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
@ -4427,6 +4430,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4443,6 +4447,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4459,6 +4464,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4475,6 +4481,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4491,6 +4498,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4507,6 +4515,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -4523,6 +4532,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@ -6842,9 +6852,9 @@
}
},
"node_modules/cheerio/node_modules/undici": {
"version": "7.20.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz",
"integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==",
"version": "7.24.3",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.3.tgz",
"integrity": "sha512-eJdUmK/Wrx2d+mnWWmwwLRyA7OQCkLap60sk3dOK4ViZR7DKwwptwuIvFBg2HaiP9ESaEdhtpSymQPvytpmkCA==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@ -8609,10 +8619,10 @@
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "5.3.8",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.8.tgz",
"integrity": "sha512-53jIF4N6u/pxvaL1eb/hEZts/cFLWZ92eCfLrNyCI0k38lettCG/Bs40W9pPwoPXyHQlKu2OUbQtiEIZK/J6Vw==",
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
@ -8621,6 +8631,23 @@
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.6",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz",
"integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.1.3",
"strnum": "^2.1.2"
},
"bin": {
@ -12928,6 +12955,21 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -15201,10 +15243,9 @@
}
},
"node_modules/systeminformation": {
"version": "5.30.8",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.30.8.tgz",
"integrity": "sha512-imB8LwJCc2DkufKlSRHfzbjhheGzpg1P31A4c55IKTq/ll6Agn1rhBOY+WmS/hyg5inGFp7AyZIK0gvq5rFO2Q==",
"license": "MIT",
"version": "5.31.0",
"resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.0.tgz",
"integrity": "sha512-z5pjzvC8UnQJ/iu34z+mo3lAeMzTGdArjPQoG5uPyV5XY4BY+M6ZcRTl4XnZqudz6sP713LhWMKv6e0kGFGCgQ==",
"os": [
"darwin",
"linux",
@ -15265,9 +15306,9 @@
}
},
"node_modules/tar": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz",
"integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==",
"version": "7.5.11",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz",
"integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
@ -15802,9 +15843,9 @@
}
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT",
"engines": {
"node": ">=18.17"

View File

@ -96,7 +96,7 @@
"cheerio": "^1.2.0",
"dockerode": "^4.0.7",
"edge.js": "^6.2.1",
"fast-xml-parser": "^5.3.8",
"fast-xml-parser": "^5.5.6",
"fuse.js": "^7.1.0",
"luxon": "^3.6.1",
"maplibre-gl": "^4.7.1",
@ -116,9 +116,9 @@
"remark-gfm": "^4.0.1",
"sharp": "^0.34.5",
"stopword": "^3.1.5",
"systeminformation": "^5.30.8",
"systeminformation": "^5.31.0",
"tailwindcss": "^4.1.10",
"tar": "^7.5.10",
"tar": "^7.5.11",
"tesseract.js": "^7.0.0",
"url-join": "^5.0.0",
"yaml": "^2.8.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

View File

@ -11,6 +11,17 @@
<title inertia>Project N.O.M.A.D</title>
<script>
(function() {
try {
var theme = localStorage.getItem('nomad:theme');
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
}
} catch(e) {}
})();
</script>
@stack('dumper')
@viteReactRefresh()
@inertiaHead()

View File

@ -54,6 +54,7 @@ router
router.get('/zim', [SettingsController, 'zim'])
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
router.get('/benchmark', [SettingsController, 'benchmark'])
router.get('/support', [SettingsController, 'support'])
})
.prefix('/settings')
@ -91,6 +92,7 @@ router
.group(() => {
router.get('/jobs', [DownloadsController, 'index'])
router.get('/jobs/:filetype', [DownloadsController, 'filetype'])
router.delete('/jobs/:jobId', [DownloadsController, 'removeJob'])
})
.prefix('/api/downloads')
@ -135,6 +137,7 @@ router
router
.group(() => {
router.get('/debug-info', [SystemController, 'getDebugInfo'])
router.get('/info', [SystemController, 'getSystemInfo'])
router.get('/internet-status', [SystemController, 'getInternetStatus'])
router.get('/services', [SystemController, 'getServices'])

View File

@ -41,6 +41,8 @@ export type DownloadJobWithProgress = {
progress: number
filepath: string
filetype: string
status?: 'active' | 'failed'
failedReason?: string
}
// Wikipedia selector types

View File

@ -7,7 +7,9 @@ export const KV_STORE_SCHEMA = {
'system.latestVersion': 'string',
'system.earlyAccess': 'boolean',
'ui.hasVisitedEasySetup': 'boolean',
'ui.theme': 'string',
'ai.assistantCustomName': 'string',
'gpu.type': 'string',
} as const
type KVTagToType<T extends string> = T extends 'boolean' ? boolean : string

View File

@ -557,10 +557,10 @@
},
{
"id": "devdocs_en_react",
"version": "2026-01",
"version": "2026-02",
"title": "React Documentation",
"description": "React library reference and tutorials",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_react_2026-01.zim",
"url": "https://download.kiwix.org/zim/devdocs/devdocs_en_react_2026-02.zim",
"size_mb": 3
},
{

View File

@ -220,14 +220,6 @@
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/delaware_2025-12.pmtiles",
"size_mb": 400
},
{
"id": "district_of_columbia",
"version": "2025-12",
"title": "District_Of_Columbia",
"description": "Topographic maps for the state of District_Of_Columbia",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/district_of_columbia_2025-12.pmtiles",
"size_mb": 400
},
{
"id": "florida",
"version": "2025-12",
@ -367,11 +359,19 @@
"size_mb": 400
},
{
"id": "indianamichigan",
"id": "indiana",
"version": "2025-12",
"title": "Indianamichigan",
"description": "Topographic maps for the state of Indianamichigan",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/indianamichigan_2025-12.pmtiles",
"title": "Indiana",
"description": "Topographic maps for the state of Indiana",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/indiana_2025-12.pmtiles",
"size_mb": 400
},
{
"id": "michigan",
"version": "2025-12",
"title": "Michigan",
"description": "Topographic maps for the state of Michigan",
"url": "https://github.com/Crosstalk-Solutions/project-nomad-maps/raw/refs/heads/master/pmtiles/michigan_2025-12.pmtiles",
"size_mb": 400
},
{

View File

@ -1,5 +1,5 @@
{
"spec_version": "2026-02-11",
"spec_version": "2026-03-23",
"options": [
{
"id": "none",
@ -45,9 +45,9 @@
"id": "all-maxi",
"name": "Complete Wikipedia (Full)",
"description": "The complete experience with all images and media.",
"size_mb": 102000,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_maxi_2024-01.zim",
"version": "2024-01"
"size_mb": 115000,
"url": "https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_maxi_2026-02.zim",
"version": "2026-02"
}
]
}

View File

@ -3,11 +3,9 @@
set -e
echo "Starting entrypoint script..."
echo "Running wait-for-it.sh to ensure MySQL is ready..."
# Use wait-for-it.sh to wait for MySQL to be available
# wait-for-it.sh <host>:<port> [-t timeout] [-- command args]
/usr/local/bin/wait-for-it.sh ${DB_HOST}:${DB_PORT} -t 60 -- echo "MySQL is up and running!"
# Ensure required storage directories exist (volume may be freshly mounted)
mkdir -p /app/storage/logs /app/storage/kb_uploads
# Run AdonisJS migrations
echo "Running AdonisJS migrations..."

View File

@ -31,15 +31,11 @@ GREEN='\033[1;32m' # Light Green.
WHIPTAIL_TITLE="Project N.O.M.A.D Installation"
NOMAD_DIR="/opt/project-nomad"
MANAGEMENT_COMPOSE_FILE_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/management_compose.yaml"
ENTRYPOINT_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/entrypoint.sh"
SIDECAR_UPDATER_DOCKERFILE_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/sidecar-updater/Dockerfile"
SIDECAR_UPDATER_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/sidecar-updater/update-watcher.sh"
START_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/start_nomad.sh"
STOP_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/stop_nomad.sh"
UPDATE_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/update_nomad.sh"
WAIT_FOR_IT_SCRIPT_URL="https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh"
COLLECT_DISK_INFO_SCRIPT_URL="https://raw.githubusercontent.com/Crosstalk-Solutions/project-nomad/refs/heads/main/install/collect_disk_info.sh"
script_option_debug='true'
accepted_terms='false'
local_ip_address=''
@ -218,6 +214,15 @@ ensure_docker_installed() {
fi
}
check_docker_compose() {
# Check if 'docker compose' (v2 plugin) is available
if ! docker compose version &>/dev/null; then
echo -e "${RED}#${RESET} Docker Compose v2 is not installed or not available as a Docker plugin."
echo -e "${YELLOW}#${RESET} This script requires 'docker compose' (v2), not 'docker-compose' (v1)."
echo -e "${YELLOW}#${RESET} Please read the Docker documentation at https://docs.docker.com/compose/install/ for instructions on how to install Docker Compose v2."
exit 1
fi
}
setup_nvidia_container_toolkit() {
# This function attempts to set up NVIDIA GPU support but is non-blocking
@ -355,7 +360,9 @@ setup_nvidia_container_toolkit() {
}
get_install_confirmation(){
read -p "This script will install/update Project N.O.M.A.D. and its dependencies on your machine. Are you sure you want to continue? (y/N): " choice
echo -e "${YELLOW}#${RESET} This script will install Project N.O.M.A.D. and its dependencies on your machine."
echo -e "${YELLOW}#${RESET} If you already have Project N.O.M.A.D. installed with customized config or data, please be aware that running this installation script may overwrite existing files and configurations. It is highly recommended to back up any important data/configs before proceeding."
read -p "Are you sure you want to continue? (y/N): " choice
case "$choice" in
y|Y )
echo -e "${GREEN}#${RESET} User chose to continue with the installation."
@ -407,12 +414,6 @@ create_nomad_directory(){
sudo touch "${NOMAD_DIR}/storage/logs/admin.log"
}
create_disk_info_file() {
# Disk info file MUST be created before the admin container starts.
# Otherwise, Docker will assume we meant to mount a directory and will create an empty directory at the mount point
echo '{}' > /tmp/nomad-disk-info.json
}
download_management_compose_file() {
local compose_file_path="${NOMAD_DIR}/compose.yml"
@ -439,30 +440,6 @@ download_management_compose_file() {
echo -e "${GREEN}#${RESET} Docker compose file configured successfully.\\n"
}
download_wait_for_it_script() {
local wait_for_it_script_path="${NOMAD_DIR}/wait-for-it.sh"
echo -e "${YELLOW}#${RESET} Downloading wait-for-it script...\\n"
if ! curl -fsSL "$WAIT_FOR_IT_SCRIPT_URL" -o "$wait_for_it_script_path"; then
echo -e "${RED}#${RESET} Failed to download the wait-for-it script. Please check the URL and try again."
exit 1
fi
chmod +x "$wait_for_it_script_path"
echo -e "${GREEN}#${RESET} wait-for-it script downloaded successfully to $wait_for_it_script_path.\\n"
}
download_entrypoint_script() {
local entrypoint_script_path="${NOMAD_DIR}/entrypoint.sh"
echo -e "${YELLOW}#${RESET} Downloading entrypoint script...\\n"
if ! curl -fsSL "$ENTRYPOINT_SCRIPT_URL" -o "$entrypoint_script_path"; then
echo -e "${RED}#${RESET} Failed to download the entrypoint script. Please check the URL and try again."
exit 1
fi
chmod +x "$entrypoint_script_path"
echo -e "${GREEN}#${RESET} entrypoint script downloaded successfully to $entrypoint_script_path.\\n"
}
download_sidecar_files() {
# Create sidecar-updater directory if it doesn't exist
if [[ ! -d "${NOMAD_DIR}/sidecar-updater" ]]; then
@ -489,24 +466,6 @@ download_sidecar_files() {
echo -e "${GREEN}#${RESET} Sidecar updater script downloaded successfully to $sidecar_script_path.\\n"
}
download_and_start_collect_disk_info_script() {
local collect_disk_info_script_path="${NOMAD_DIR}/collect_disk_info.sh"
echo -e "${YELLOW}#${RESET} Downloading collect_disk_info script...\\n"
if ! curl -fsSL "$COLLECT_DISK_INFO_SCRIPT_URL" -o "$collect_disk_info_script_path"; then
echo -e "${RED}#${RESET} Failed to download the collect_disk_info script. Please check the URL and try again."
exit 1
fi
chmod +x "$collect_disk_info_script_path"
echo -e "${GREEN}#${RESET} collect_disk_info script downloaded successfully to $collect_disk_info_script_path.\\n"
# Start script in background and store PID for easy removal on uninstall
echo -e "${YELLOW}#${RESET} Starting collect_disk_info script in the background...\\n"
nohup bash "$collect_disk_info_script_path" > /dev/null 2>&1 &
echo $! > "${NOMAD_DIR}/nomad-collect-disk-info.pid"
echo -e "${GREEN}#${RESET} collect_disk_info script started successfully.\\n"
}
download_helper_scripts() {
local start_script_path="${NOMAD_DIR}/start_nomad.sh"
local stop_script_path="${NOMAD_DIR}/stop_nomad.sh"
@ -633,14 +592,12 @@ check_is_debug_mode
get_install_confirmation
accept_terms
ensure_docker_installed
check_docker_compose
setup_nvidia_container_toolkit
get_local_ip
create_nomad_directory
download_wait_for_it_script
download_entrypoint_script
download_sidecar_files
download_helper_scripts
download_and_start_collect_disk_info_script
download_management_compose_file
start_management_containers
verify_gpu_setup

View File

@ -1,3 +1,11 @@
# Project N.O.M.A.D. management services Docker Compose configuration
#
# This compose file defines the admin server, database, and other supporting services required to run Project N.O.M.A.D.
# You can use this with `docker-compose up -d` to start all the necessary services with a single command after installation.
#
# Note: we recommend leaving all of the environment variables as-is except for any "replaceme" values,
# which must be updated for the admin server to start successfully. The default values are optimized for ease of installation and use,
# but you can customize them as needed (e.g. changing ports, database connection details, log level, etc.)
name: project-nomad
services:
admin:
@ -10,40 +18,44 @@ services:
ports:
- "8080:8080"
volumes:
- /opt/project-nomad/storage:/app/storage
- /tmp/nomad-disk-info.json:/app/storage/nomad-disk-info.json
- /opt/project-nomad/storage:/app/storage # If you change the default storage path (/opt/project-nomad/storage), make sure to update the disk-collector config as well!
- /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon
- ./entrypoint.sh:/usr/local/bin/entrypoint.sh
- ./wait-for-it.sh:/usr/local/bin/wait-for-it.sh
- nomad-update-shared:/app/update-shared # Shared volume for update communication
environment:
- NODE_ENV=production
# PORT is the port the admin server listens on *inside* the container and should not be changed. If you want to change which port the admin interface is accessible from on the host, you can change the port mapping in the "ports" section (e.g. "9090:8080" to access it on port 9090 from the host)
- PORT=8080
- LOG_LEVEL=debug
- LOG_LEVEL=info
# APP_KEY needs to be at least 16 chars or will fail validation and container won't start!
- APP_KEY=replaceme
# # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking
- HOST=0.0.0.0
# URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080)
- URL=replaceme
- DB_HOST=mysql
# If you change the MySQL port, make sure to update this accordingly
- DB_PORT=3306
- DB_DATABASE=nomad
- DB_USER=nomad_user
# Needs to match the MYSQL_PASSWORD in the mysql service!
- DB_PASSWORD=replaceme
- DB_NAME=nomad
- DB_SSL=false
- REDIS_HOST=redis
# If you change the Redis port, make sure to update this accordingly
- REDIS_PORT=6379
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
entrypoint: ["/usr/local/bin/entrypoint.sh"]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/api/health"]
interval: 30s
timeout: 10s
retries: 3
dozzle:
# Dozzle is an optional container that allows for easily viewing container logs. We recommend including it unless you have a specific reason not to. Note that if you don't install it, the "Service Logs & Metrics" link in Settings that launches Dozzle will not work.
image: amir20/dozzle:v10.0
container_name: nomad_dozzle
restart: unless-stopped
@ -52,21 +64,20 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Allows Dozzle to read logs from the Host's Docker daemon
environment:
- DOZZLE_ENABLE_ACTIONS=true # Enables the action buttons (restart, stop, etc.)
- DOZZLE_ENABLE_SHELL=true # Enables web-based shell access
- DOZZLE_ENABLE_ACTIONS=false # Disabled — unauthenticated container stop/restart on LAN
- DOZZLE_ENABLE_SHELL=false # Disabled — shell access + Docker socket = privilege escalation
mysql:
image: mysql:8.0
container_name: nomad_mysql
restart: unless-stopped
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=replaceme
- MYSQL_DATABASE=nomad
- MYSQL_USER=nomad_user
# Needs to match DB_PASSWORD in the admin service!
- MYSQL_PASSWORD=replaceme
volumes:
- /opt/project-nomad/mysql:/var/lib/mysql
- /opt/project-nomad/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated.
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 30s
@ -76,25 +87,33 @@ services:
image: redis:7-alpine
container_name: nomad_redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- /opt/project-nomad/redis:/data
- /opt/project-nomad/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated.
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
updater:
build:
context: ./sidecar-updater
dockerfile: Dockerfile
# Updater is a lightweight sidecar container that allows the admin container to be updated from within it's own UI
image: ghcr.io/crosstalk-solutions/project-nomad-sidecar-updater:latest
pull_policy: always
container_name: nomad_updater
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Allows communication with the Host's Docker daemon
- /opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml
- /opt/project-nomad:/opt/project-nomad # Writable access required so the updater can set the correct image tag in compose.yml. This needs to be the same location that the compose file is located at on the host for the updater to work correctly
- nomad-update-shared:/shared # Shared volume for communication with admin container
disk-collector:
# Disk Collector is a lightweight privileged container that collects disk usage information from the host system and shares it with the admin container so it can be displayed in the UI.
# It requires read-only access to the host filesystem and is designed to be as secure and limited in scope as possible while still providing the necessary functionality.
image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest
pull_policy: always
container_name: nomad_disk_collector
restart: unless-stopped
volumes:
- /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible
- /opt/project-nomad/storage:/storage
volumes:
nomad-update-shared:

View File

@ -0,0 +1,40 @@
# Project N.O.M.A.D. — About the Disk Collector Migration Script
This script migrates your Project N.O.M.A.D. installation from the old host-based disk info collector to the new disk-collector sidecar. It modifies `/opt/project-nomad/compose.yml` to add the new service and remove the old bind mount, then restarts the full compose stack to apply the changes.
### Why the Migration?
The new disk-collector sidecar provides a more robust and scalable way to collect disk information from the host. It removes the original bind mount to `/tmp/nomad-disk-info.json`, which was fragile and prone to issues on host reboots.
The original host-based collector relied on a process running on the host that wrote disk info to a file, which was then read by the admin container via a bind mount. This approach had several drawbacks:
- The host process could fail or be killed, leading to stale or missing disk info.
- The bind mount to `/tmp/nomad-disk-info.json` was cleared on host reboots, causing Docker to create a directory at the mount point instead of a file.
- Necessitated a tighter coupling to the host, which would make more flexible future deployment options tougher to achieve.
The migration script automates the necessary changes to your compose configuration and ensures a smooth transition to the new architecture.
### Why does Nomad need the nomad-disk-info.json file?
Nomad uses the disk info stored and updated in `nomad-disk-info.json` to allow users to view disk usage and availability within the Nomad "Command Center". While not critical to the core functionality of Nomad, it provides a more pleasant experience for users with limited storage space and/or who aren't familiar with command-line tools and Linux management.
### Why a separate container?
The disk-collector runs in a separate container to isolate its functionality from the main admin container. This separation provides several benefits:
- **Stability**: If the disk-collector encounters an issue or crashes, it won't affect the main admin container and vice versa.
- **Security**: The main admin container already has significant host access via the Docker socket, storage directory, and host.docker.internal. Additionally, Nomad may add more features in the future that support multi-user environments and/or more network exposure, so isolating the disk-collector reduces the exposure of the host filesystem (even if read-only) to just the one container, which has a very limited scope of functionality and access.
- **Modularity**: Because having the host disk info is not a critical component of Nomad's core functionality, isolating it in a sidecar allows users who don't need/want the disk info features to simply not run that container, without impacting the main admin container or other services. It also allows for more flexible future development of the disk-collector without needing to modify the main admin container.
### What if I don't want to run the migration script?
No worries - you can replicate the changes manually by editing your `/opt/project-nomad/compose.yml` to add the new disk-collector service and remove the old bind mount from the admin service, then restarting your compose stack. The migration script just automates these steps and ensures they're done correctly, but the underlying changes are straightforward if you prefer to do it yourself. Just be sure to back up your `compose.yml` before making any changes.
Here's the disk-collector service configuration to add to your `compose.yml`:
```yml
disk-collector:
image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest
pull_policy: always
container_name: nomad_disk_collector
restart: unless-stopped
volumes:
- /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible
- /opt/project-nomad/storage:/storage
```
and remove the `- /tmp/nomad-disk-info.json:/app/storage/nomad-disk-info.json` bind mount from the admin service volumes.

250
install/migrate-disk-collector.sh Executable file
View File

@ -0,0 +1,250 @@
#!/bin/bash
# Project N.O.M.A.D. — Disk Collector Migration Script
#
# Script | Project N.O.M.A.D. Disk Collector Migration Script
# Version | 1.0.0
# Author | Crosstalk Solutions, LLC
# Website | https://crosstalksolutions.com
#
# PURPOSE:
# One-time migration from the host-based disk info collector to the
# disk-collector Docker sidecar. The old approach used a nohup background
# process that wrote to /tmp/nomad-disk-info.json, which was bind-mounted
# into the admin container. This broke on host reboots because /tmp is
# cleared and Docker would create a directory at the mount point instead of a file.
#
# The new approach uses a disk-collector sidecar container that reads host
# disk info via the /:/host:ro,rslave bind-mount pattern (same pattern as Prometheus
# node-exporter, and no SYS_ADMIN or privileged capabilities required) and writes directly to
# /opt/project-nomad/storage/nomad-disk-info.json, which the admin container
# already reads via its existing storage bind-mount. Thus, no admin image update
# or new volume mounts required.
###############################################################################
# Color Codes
###############################################################################
RESET='\033[0m'
YELLOW='\033[1;33m'
RED='\033[1;31m'
GREEN='\033[1;32m'
WHITE_R='\033[39m'
###############################################################################
# Constants
###############################################################################
NOMAD_DIR="/opt/project-nomad"
COMPOSE_FILE="${NOMAD_DIR}/compose.yml"
COMPOSE_PROJECT_NAME="project-nomad"
###############################################################################
# Pre-flight Checks
###############################################################################
check_is_bash() {
if [[ -z "$BASH_VERSION" ]]; then
echo -e "${RED}#${RESET} This script must be run with bash."
echo -e "${RED}#${RESET} Example: bash $(basename "$0")"
exit 1
fi
echo -e "${GREEN}#${RESET} Running in bash.\n"
}
check_has_sudo() {
if sudo -n true 2>/dev/null; then
echo -e "${GREEN}#${RESET} Sudo permissions confirmed.\n"
else
echo -e "${RED}#${RESET} This script requires sudo permissions."
echo -e "${RED}#${RESET} Example: sudo bash $(basename "$0")"
exit 1
fi
}
check_confirmation() {
echo -e "${YELLOW}#${RESET} This script migrates your Project N.O.M.A.D. installation from the"
echo -e "${YELLOW}#${RESET} host-based disk info collector to the new disk-collector sidecar."
echo -e "${YELLOW}#${RESET} It will modify compose.yml and restart the full compose stack"
echo -e "${YELLOW}#${RESET} to drop the old /tmp bind mount and start the disk-collector sidecar."
echo -e "${YELLOW}#${RESET} Please ensure you have a backup of your data before proceeding.\n"
echo -e "${RED}#${RESET} STOP: If you have customized your compose.yml or Nomad's storage setup (not common), please make these changes manually instead of using this script!\n"
read -rp "Do you want to continue? (y/N) " response
if [[ ! "$response" =~ ^[Yy]$ ]]; then
echo -e "${RED}#${RESET} Aborting. No changes have been made."
exit 0
fi
echo -e "${GREEN}#${RESET} Confirmation received. Proceeding with migration...\n"
}
check_docker_running() {
if ! command -v docker &>/dev/null; then
echo -e "${RED}#${RESET} Docker is not installed. Cannot proceed."
exit 1
fi
if ! systemctl is-active --quiet docker; then
echo -e "${RED}#${RESET} Docker is not running. Please start Docker and try again."
exit 1
fi
echo -e "${GREEN}#${RESET} Docker is running.\n"
}
check_compose_file() {
if [[ ! -f "$COMPOSE_FILE" ]]; then
echo -e "${RED}#${RESET} compose.yml not found at ${COMPOSE_FILE}."
echo -e "${RED}#${RESET} Project N.O.M.A.D. does not appear to be installed or compose.yml is missing."
exit 1
fi
echo -e "${GREEN}#${RESET} Found compose.yml at ${COMPOSE_FILE}.\n"
}
# Step 1: Stop old host process
stop_old_host_process() {
local pid_file="${NOMAD_DIR}/nomad-collect-disk-info.pid"
if [[ -f "$pid_file" ]]; then
echo -e "${YELLOW}#${RESET} Stopping old collect-disk-info background process..."
local pid
pid=$(cat "$pid_file")
if kill "$pid" 2>/dev/null; then
echo -e "${GREEN}#${RESET} Process ${pid} stopped.\n"
else
echo -e "${YELLOW}#${RESET} Process ${pid} was not running (already stopped).\n"
fi
rm -f "$pid_file"
else
echo -e "${GREEN}#${RESET} No old collect-disk-info PID file found — nothing to stop.\n"
fi
}
# Step 2: Backup compose.yml
backup_compose_file() {
local backup="${COMPOSE_FILE}.bak.$(date +%Y%m%d%H%M%S)"
echo -e "${YELLOW}#${RESET} Backing up compose.yml to ${backup}..."
if cp "$COMPOSE_FILE" "$backup"; then
echo -e "${GREEN}#${RESET} Backup created at ${backup}.\n"
else
echo -e "${RED}#${RESET} Failed to create backup. Aborting."
exit 1
fi
}
# Step 3: Remove old bind-mount from admin volumes
remove_old_bind_mount() {
if ! grep -q 'nomad-disk-info\.json' "$COMPOSE_FILE"; then
echo -e "${GREEN}#${RESET} Old /tmp/nomad-disk-info.json bind-mount not found — already removed.\n"
return 0
fi
echo -e "${YELLOW}#${RESET} Removing old /tmp/nomad-disk-info.json bind-mount from admin volumes..."
sed -i '/\/tmp\/nomad-disk-info\.json:\/app\/storage\/nomad-disk-info\.json/d' "$COMPOSE_FILE"
if grep -q 'nomad-disk-info\.json' "$COMPOSE_FILE"; then
echo -e "${RED}#${RESET} Failed to remove old bind-mount from compose.yml. Please remove it manually:"
echo -e "${WHITE_R} - /tmp/nomad-disk-info.json:/app/storage/nomad-disk-info.json${RESET}"
exit 1
fi
echo -e "${GREEN}#${RESET} Old bind-mount removed.\n"
}
# Step 4: Add disk-collector service block
add_disk_collector_service() {
if grep -q 'disk-collector:' "$COMPOSE_FILE"; then
echo -e "${GREEN}#${RESET} disk-collector service already present in compose.yml — skipping.\n"
return 0
fi
echo -e "${YELLOW}#${RESET} Adding disk-collector service to compose.yml..."
# Insert the disk-collector service block before the top-level `volumes:` key
awk '/^volumes:/{
print " disk-collector:"
print " image: ghcr.io/crosstalk-solutions/project-nomad-disk-collector:latest"
print " pull_policy: always"
print " container_name: nomad_disk_collector"
print " restart: unless-stopped"
print " volumes:"
print " - /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible"
print " - /opt/project-nomad/storage:/storage # Shared storage dir — disk info written here is read by the admin container"
print ""
}
{print}' "$COMPOSE_FILE" > "${COMPOSE_FILE}.tmp" && mv "${COMPOSE_FILE}.tmp" "$COMPOSE_FILE"
if ! grep -q 'disk-collector:' "$COMPOSE_FILE"; then
echo -e "${RED}#${RESET} Failed to add disk-collector service. Please add it manually before the top-level volumes: key."
exit 1
fi
echo -e "${GREEN}#${RESET} disk-collector service added.\n"
}
# Step 5 — Pull new image and restart the full stack
# This will re-create the admin container and drop the old /tmp bind, and
# also starts the new disk-collector sidecar we just added to compose.yml
restart_stack() {
echo -e "${YELLOW}#${RESET} Pulling latest images (including disk-collector)..."
if ! docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" pull; then
echo -e "${RED}#${RESET} Failed to pull images. Check your network connection."
exit 1
fi
echo -e "${GREEN}#${RESET} Images pulled.\n"
echo -e "${YELLOW}#${RESET} Restarting stack..."
if ! docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" up -d; then
echo -e "${RED}#${RESET} Failed to bring the stack up."
exit 1
fi
echo -e "${GREEN}#${RESET} Stack restarted.\n"
}
# Step 6: Verify
verify_disk_collector_running() {
sleep 3
if docker ps --filter "name=^nomad_disk_collector$" --filter "status=running" --format '{{.Names}}' | grep -qx "nomad_disk_collector"; then
echo -e "${GREEN}#${RESET} disk-collector container is running.\n"
else
echo -e "${RED}#${RESET} disk-collector container does not appear to be running."
echo -e "${RED}#${RESET} Check its logs with: docker logs nomad_disk_collector"
exit 1
fi
}
# Main
echo -e "${GREEN}#########################################################################${RESET}"
echo -e "${GREEN}#${RESET} Project N.O.M.A.D. — Disk Collector Migration Script ${GREEN}#${RESET}"
echo -e "${GREEN}#########################################################################${RESET}\n"
check_is_bash
check_has_sudo
check_confirmation
check_docker_running
check_compose_file
echo -e "${YELLOW}#${RESET} Step 1: Stopping old host process...\n"
stop_old_host_process
echo -e "${YELLOW}#${RESET} Step 2: Backing up compose.yml...\n"
backup_compose_file
echo -e "${YELLOW}#${RESET} Step 3: Removing old bind-mount...\n"
remove_old_bind_mount
echo -e "${YELLOW}#${RESET} Step 4: Adding disk-collector service...\n"
add_disk_collector_service
echo -e "${YELLOW}#${RESET} Step 5: Pulling images and restarting stack...\n"
restart_stack
echo -e "${YELLOW}#${RESET} Step 6: Verifying disk-collector is running...\n"
verify_disk_collector_running
echo -e "${GREEN}#########################################################################${RESET}"
echo -e "${GREEN}#${RESET} Migration completed successfully!"
echo -e "${GREEN}#${RESET}"
echo -e "${GREEN}#${RESET} The disk-collector sidecar is now running and will update disk info"
echo -e "${GREEN}#${RESET} every 2 minutes. The /api/system/info endpoint will return disk data"
echo -e "${GREEN}#${RESET} after the first collector write (~5 seconds after startup)."
echo -e "${GREEN}#${RESET}"
echo -e "${GREEN}#########################################################################${RESET}\n"

Some files were not shown because too many files have changed in this diff Show More