Compare commits
83 Commits
v1.33.0-rc
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
6a4f02dd46 | |
|
|
cf294bf908 | |
|
|
c794d5e4b0 | |
|
|
587600ba78 | |
|
|
e82a2934c7 | |
|
|
0ee5dda534 | |
|
|
f4af81ef02 | |
|
|
c9a86790de | |
|
|
d8cca183f9 | |
|
|
76ce725340 | |
|
|
9609edc281 | |
|
|
0143afe7cc | |
|
|
5af27e9904 | |
|
|
88ac4d5ec4 | |
|
|
f0142b67f8 | |
|
|
4a795df793 | |
|
|
02c9f72bf0 | |
|
|
5181637926 | |
|
|
fad9e30ddc | |
|
|
a7d05859d4 | |
|
|
fe6735fdcb | |
|
|
b507b8bd4e | |
|
|
8982d93a31 | |
|
|
1b040c8b9a | |
|
|
475781ffa2 | |
|
|
bd65c885be | |
|
|
83576ec33d | |
|
|
e2beb7ebeb | |
|
|
536ad49277 | |
|
|
61ef8f3697 | |
|
|
5eb208fb50 | |
|
|
37ad684fbd | |
|
|
d4972445d9 | |
|
|
b6a08058e8 | |
|
|
6a2d4c2bf6 | |
|
|
9de6473f6a | |
|
|
bbd62d8ed1 | |
|
|
2ae30a4abb | |
|
|
e5565d1e95 | |
|
|
4ece29b6d4 | |
|
|
5de58da4b7 | |
|
|
da60c6ce9c | |
|
|
9b84d3aa54 | |
|
|
a315ce0f54 | |
|
|
df47139846 | |
|
|
663c1593df | |
|
|
1b9f4f30f2 | |
|
|
026ab6df8f | |
|
|
ca5ec1767f | |
|
|
98d235679e | |
|
|
36068c645e | |
|
|
3977c723c2 | |
|
|
25000b9869 | |
|
|
dfc284c34d | |
|
|
3f574e4003 | |
|
|
cf8db6218d | |
|
|
02d985db5e | |
|
|
d175259219 | |
|
|
f553a0d57d | |
|
|
e1c7435fc4 | |
|
|
ccc221f88d | |
|
|
057fb693f6 | |
|
|
fab15c68a9 | |
|
|
b2bea191a8 | |
|
|
555ffa8790 | |
|
|
f488f08c96 | |
|
|
b8674193da | |
|
|
3501144caa | |
|
|
7b1b480c5e | |
|
|
02c33b277e | |
|
|
be434d755a | |
|
|
e38dbf8a65 | |
|
|
7943ae5d1f | |
|
|
820c5c415e | |
|
|
b8961d27dd | |
|
|
17630c048f | |
|
|
d5f7c3f615 | |
|
|
fa144c4777 | |
|
|
e26ce4f1f6 | |
|
|
8d2bf785e1 | |
|
|
97c65cca67 | |
|
|
5bf5bc33b7 | |
|
|
737e6784f0 |
|
|
@ -4,6 +4,7 @@ FROM node:22-slim AS base
|
|||
RUN apt-get update && apt-get install -y \
|
||||
bash \
|
||||
curl \
|
||||
openssl \
|
||||
graphicsmagick \
|
||||
libvips-dev \
|
||||
build-essential \
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ N.O.M.A.D. is a management UI ("Command Center") and API that orchestrates a col
|
|||
- **Data Tools** — encryption, encoding, and analysis via [CyberChef](https://gchq.github.io/CyberChef/)
|
||||
- **Notes** — local note-taking via [FlatNotes](https://github.com/dullage/flatnotes)
|
||||
- **System Benchmark** — hardware scoring with a [community leaderboard](https://benchmark.projectnomad.us)
|
||||
- **Supply Depot** — a one-click app catalog (PDF tools, file browser, e-book library, password manager, and more) plus the ability to run your own custom Docker containers
|
||||
- **Automatic Updates** — opt-in, hands-off updates for the core software, installed apps, and offline content, on a schedule you control
|
||||
- **Easy Setup Wizard** — guided first-time configuration with curated content collections
|
||||
|
||||
N.O.M.A.D. also includes built-in tools like a Wikipedia content selector, ZIM library manager, and content explorer.
|
||||
|
|
@ -59,10 +61,11 @@ N.O.M.A.D. also includes built-in tools like a Wikipedia content selector, ZIM l
|
|||
| Information Library | Kiwix | Offline Wikipedia, medical references, survival guides, ebooks |
|
||||
| AI Assistant | Ollama + Qdrant | Built-in chat with document upload and semantic search |
|
||||
| Education Platform | Kolibri | Khan Academy courses, progress tracking, multi-user support |
|
||||
| Offline Maps | ProtoMaps | Downloadable regional maps with search and navigation |
|
||||
| Offline Maps | ProtoMaps | Downloadable regional maps for offline viewing and search |
|
||||
| Data Tools | CyberChef | Encryption, encoding, hashing, and data analysis |
|
||||
| Notes | FlatNotes | Local note-taking with markdown support |
|
||||
| System Benchmark | Built-in | Hardware scoring, Builder Tags, and community leaderboard |
|
||||
| Supply Depot | Built-in | One-click app catalog + bring-your-own custom Docker containers |
|
||||
|
||||
## Device Requirements
|
||||
While many similar offline survival computers are designed to be run on bare-minimum, lightweight hardware, Project N.O.M.A.D. is quite the opposite. To install and run the
|
||||
|
|
@ -105,7 +108,9 @@ For answers to common questions about Project N.O.M.A.D., please see our [FAQ](F
|
|||
## About Internet Usage & Privacy
|
||||
Project N.O.M.A.D. is designed for offline usage. An internet connection is only required during the initial installation (to download dependencies) and if you (the user) decide to download additional tools and resources at a later time. Otherwise, N.O.M.A.D. does not require an internet connection and has ZERO built-in telemetry.
|
||||
|
||||
To test internet connectivity, N.O.M.A.D. first attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace`. If that endpoint is unreachable (for example, because your network blocks `1.1.1.1`), it falls back to other endpoints the application already contacts (the GitHub API and the Project N.O.M.A.D. API) and considers the connection online if any of them respond. You can override the endpoint used for this check with the `INTERNET_STATUS_TEST_URL` environment variable.
|
||||
To test internet connectivity, N.O.M.A.D. first attempts to make a request to Cloudflare's utility endpoint, `https://1.1.1.1/cdn-cgi/trace`. If that endpoint is unreachable (for example, because your network blocks `1.1.1.1`), it falls back to other endpoints the application already contacts (the GitHub API and the Project N.O.M.A.D. API) and considers the connection online if any of them respond.
|
||||
|
||||
You can override the endpoint used for this check in two ways. The connectivity test URL can be configured from the UI under **Settings → Advanced** (stored locally on your instance), or you can set the `INTERNET_STATUS_TEST_URL` environment variable. When set, the environment variable always takes precedence over the UI-configured value. If neither is set, the built-in defaults above are used.
|
||||
|
||||
## About Security
|
||||
By design, Project N.O.M.A.D. is intended to be open and available without hurdles — it includes no authentication. If you decide to connect your device to a local network after install (e.g. for allowing other devices to access its resources), you can block/open ports to control which services are exposed.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import app from '@adonisjs/core/services/app'
|
|||
import { randomBytes } from 'node:crypto'
|
||||
import { sanitizeFilename } from '../utils/fs.js'
|
||||
import { basename } from 'node:path'
|
||||
import { deleteFileSchema, embedFileSchema, estimateBatchSchema, getJobStatusSchema } from '#validators/rag'
|
||||
import { deleteFileSchema, embedFileSchema, estimateBatchSchema, fileSourceSchema, getJobStatusSchema } from '#validators/rag'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
|
||||
@inject()
|
||||
|
|
@ -110,6 +110,14 @@ export default class RagController {
|
|||
})
|
||||
}
|
||||
|
||||
public async cancelAllJobs({ response }: HttpContext) {
|
||||
const result = await EmbedFileJob.cancelAllJobs()
|
||||
return response.status(200).json({
|
||||
message: `Cancelled ${result.cancelled} job${result.cancelled !== 1 ? 's' : ''}${result.filesDeleted > 0 ? `, deleted ${result.filesDeleted} file${result.filesDeleted !== 1 ? 's' : ''}` : ''}.`,
|
||||
...result,
|
||||
})
|
||||
}
|
||||
|
||||
public async policyPromptState({ response }: HttpContext) {
|
||||
const result = await this.ragService.getPolicyPromptState()
|
||||
return response.status(200).json(result)
|
||||
|
|
@ -162,4 +170,23 @@ export default class RagController {
|
|||
const result = await KbRatioRegistry.estimateBatch(normalized)
|
||||
return response.status(200).json(result)
|
||||
}
|
||||
|
||||
public async getFileContent({ request, response }: HttpContext) {
|
||||
const { source } = await request.validateUsing(fileSourceSchema)
|
||||
const result = await this.ragService.readFileContent(source)
|
||||
if (!result) {
|
||||
return response.status(404).json({ error: 'File not found or not viewable' })
|
||||
}
|
||||
return response.status(200).json(result)
|
||||
}
|
||||
|
||||
public async downloadFile({ request, response }: HttpContext) {
|
||||
const { source } = await request.validateUsing(fileSourceSchema)
|
||||
const filePath = await this.ragService.resolveDownloadPath(source)
|
||||
if (!filePath) {
|
||||
return response.status(404).json({ error: 'File not found' })
|
||||
}
|
||||
const fileName = filePath.split(/[/\\]/).at(-1) ?? 'download'
|
||||
return response.attachment(filePath, fileName)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SystemService } from '#services/system_service'
|
|||
import { getSettingSchema, updateSettingSchema, validateSettingValue } from '#validators/settings'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import env from '#start/env'
|
||||
|
||||
@inject()
|
||||
export default class SettingsController {
|
||||
|
|
@ -110,6 +111,19 @@ export default class SettingsController {
|
|||
})
|
||||
}
|
||||
|
||||
async advanced({ inertia }: HttpContext) {
|
||||
// When the env var is set it always takes precedence over the stored value,
|
||||
// so surface that to the UI to disable the field and explain the override.
|
||||
const envOverride = Boolean(env.get('INTERNET_STATUS_TEST_URL')?.trim())
|
||||
const internetStatusTestUrl = await KVStore.getValue('system.internetStatusTestUrl')
|
||||
return inertia.render('settings/advanced', {
|
||||
advanced: {
|
||||
internetStatusTestUrl: internetStatusTestUrl ?? '',
|
||||
internetStatusTestUrlEnvOverride: envOverride,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async getSetting({ request, response }: HttpContext) {
|
||||
const { key } = await getSettingSchema.validate({ key: request.qs().key });
|
||||
const value = await KVStore.getValue(key);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
preflightValidator,
|
||||
serviceLogsValidator,
|
||||
subscribeToReleaseNotesValidator,
|
||||
uninstallServiceValidator,
|
||||
updateCustomAppValidator,
|
||||
updateServiceValidator,
|
||||
setServiceAutoUpdateValidator,
|
||||
|
|
@ -467,6 +468,37 @@ export default class SystemController {
|
|||
return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` })
|
||||
}
|
||||
|
||||
/** Uninstall a curated catalog app: stop + remove its container (optionally its image) and
|
||||
* return the card to the available catalog. App data under the storage path stays on disk,
|
||||
* so a later reinstall picks it back up. Custom apps are removed via deleteCustomApp instead,
|
||||
* which also drops their DB record. */
|
||||
async uninstallService({ request, response }: HttpContext) {
|
||||
const payload = await request.validateUsing(uninstallServiceValidator)
|
||||
|
||||
const service = await Service.query().where('service_name', payload.service_name).first()
|
||||
if (!service) {
|
||||
return response.status(404).send({ error: `Service ${payload.service_name} not found` })
|
||||
}
|
||||
if (service.is_custom) {
|
||||
return response.status(403).send({ error: 'Custom apps are removed via delete.' })
|
||||
}
|
||||
if (service.is_dependency_service) {
|
||||
return response.status(403).send({ error: 'Dependency services cannot be uninstalled directly.' })
|
||||
}
|
||||
if (!service.installed) {
|
||||
return response.status(409).send({ error: `Service ${payload.service_name} is not installed` })
|
||||
}
|
||||
|
||||
const result = await this.dockerService.uninstallService(
|
||||
payload.service_name,
|
||||
payload.remove_image ?? false
|
||||
)
|
||||
if (!result.success) {
|
||||
return response.status(500).send({ success: false, message: result.message })
|
||||
}
|
||||
return response.send({ success: true, message: result.message })
|
||||
}
|
||||
|
||||
/** Set or clear an app's custom launch URL (works for curated and custom apps). Purely a
|
||||
* metadata change — no container is touched. An empty/invalid value clears the override, after
|
||||
* which the default host + port link is used again. */
|
||||
|
|
|
|||
|
|
@ -8,7 +8,12 @@ import {
|
|||
} from '#validators/common'
|
||||
import { addCustomLibraryValidator, browseLibraryValidator, idParamValidator, listRemoteZimValidator } from '#validators/zim'
|
||||
import { inject } from '@adonisjs/core'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import { createWriteStream } from 'fs'
|
||||
import { rename } from 'fs/promises'
|
||||
import { join, resolve, sep } from 'path'
|
||||
import { ZIM_STORAGE_PATH, ensureDirectoryExists, sanitizeFilename } from '../utils/fs.js'
|
||||
|
||||
@inject()
|
||||
export default class ZimController {
|
||||
|
|
@ -83,6 +88,87 @@ export default class ZimController {
|
|||
}
|
||||
}
|
||||
|
||||
async upload({ request, response }: HttpContext) {
|
||||
let filename: string | null = null
|
||||
let tmpPath: string | null = null
|
||||
let uploadError: string | null = null
|
||||
|
||||
try {
|
||||
const basePath = resolve(join(process.cwd(), ZIM_STORAGE_PATH))
|
||||
await ensureDirectoryExists(basePath)
|
||||
|
||||
request.multipart.onFile('*', {}, async (part) => {
|
||||
const clientName = part.filename || ''
|
||||
if (!clientName.toLowerCase().endsWith('.zim')) {
|
||||
part.resume()
|
||||
uploadError = 'INVALID_TYPE'
|
||||
return
|
||||
}
|
||||
|
||||
const sanitized = sanitizeFilename(clientName)
|
||||
const finalPath = resolve(join(basePath, sanitized))
|
||||
|
||||
if (!finalPath.startsWith(basePath + sep)) {
|
||||
part.resume()
|
||||
uploadError = 'INVALID_FILENAME'
|
||||
return
|
||||
}
|
||||
|
||||
const { access } = await import('fs/promises')
|
||||
const exists = await access(finalPath).then(() => true).catch(() => false)
|
||||
if (exists) {
|
||||
part.resume()
|
||||
uploadError = 'DUPLICATE_FILENAME'
|
||||
return
|
||||
}
|
||||
|
||||
filename = sanitized
|
||||
tmpPath = finalPath + '.tmp'
|
||||
const ws = createWriteStream(tmpPath)
|
||||
|
||||
await new Promise<void>((res, rej) => {
|
||||
ws.on('error', rej)
|
||||
ws.on('finish', res)
|
||||
part.on('error', rej)
|
||||
part.pipe(ws)
|
||||
})
|
||||
|
||||
await rename(tmpPath, finalPath)
|
||||
tmpPath = null
|
||||
})
|
||||
|
||||
await request.multipart.process()
|
||||
|
||||
if (uploadError === 'INVALID_TYPE') {
|
||||
return response.status(422).send({ message: 'Only .zim files are accepted' })
|
||||
}
|
||||
if (uploadError === 'INVALID_FILENAME') {
|
||||
return response.status(422).send({ message: 'Invalid filename' })
|
||||
}
|
||||
if (uploadError === 'DUPLICATE_FILENAME') {
|
||||
return response.status(409).send({ message: 'A ZIM file with that name already exists' })
|
||||
}
|
||||
if (!filename) {
|
||||
return response.status(400).send({ message: 'No file received' })
|
||||
}
|
||||
|
||||
const { added } = await this.zimService.registerLocalUpload(filename)
|
||||
|
||||
return response.status(201).send({
|
||||
message: 'ZIM file uploaded and registered successfully',
|
||||
filename,
|
||||
added,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('[ZimController] Upload failed:', error)
|
||||
if (tmpPath) {
|
||||
const { unlink } = await import('fs/promises')
|
||||
await unlink(tmpPath).catch(() => {})
|
||||
}
|
||||
return response.status(500).send({ message: 'Upload failed' })
|
||||
}
|
||||
}
|
||||
|
||||
// Wikipedia selector endpoints
|
||||
|
||||
async getWikipediaState({}: HttpContext) {
|
||||
|
|
|
|||
|
|
@ -164,6 +164,22 @@ export class EmbedFileJob {
|
|||
await new Promise((resolve) => setTimeout(resolve, EmbedFileJob.CPU_BATCH_DELAY_MS))
|
||||
}
|
||||
|
||||
// Bail before re-populating the queue if this job was cancelled mid-batch.
|
||||
// cancelAllJobs() obliterates the queue (including this active job), but a
|
||||
// worker already inside handle() would otherwise dispatch its continuation
|
||||
// afterwards and silently revive a cancelled ZIM ingestion. If our own job
|
||||
// key is gone, the cancel happened — skip the dispatch. Mirrors the
|
||||
// "tolerate external removal" handling in safeUpdateProgress above.
|
||||
const stillQueued = await QueueService.getInstance()
|
||||
.getQueue(EmbedFileJob.queue)
|
||||
.getJob(job.id!)
|
||||
if (!stillQueued) {
|
||||
logger.info(
|
||||
`[EmbedFileJob] Job ${fileName} was cancelled; skipping continuation dispatch`
|
||||
)
|
||||
return { success: false, cancelled: true, fileName, filePath }
|
||||
}
|
||||
|
||||
// Dispatch next batch (not final yet). Carry forward the running
|
||||
// chunk count so the final batch can persist an accurate total (#933).
|
||||
const chunksSoFarNext = (job.data.chunksSoFar || 0) + (result.chunks || 0)
|
||||
|
|
@ -443,6 +459,46 @@ export class EmbedFileJob {
|
|||
return { cleaned, filesDeleted }
|
||||
}
|
||||
|
||||
/** Unconditionally clear every embedding job regardless of state.
|
||||
*
|
||||
* cleanupFailedJobs only removes jobs explicitly tagged status === 'failed',
|
||||
* which leaves stuck jobs (waiting / active / delayed / paused that never
|
||||
* reached 'failed') unreachable from the UI — the operator's only recourse was
|
||||
* flushing Redis by hand. This wipes the whole queue, including a locked active
|
||||
* job, via obliterate({ force: true }) (plain obliterate/job.remove throw on a
|
||||
* locked job). It touches only Redis, so it is safe while Qdrant/Ollama are
|
||||
* offline — which is exactly when jobs pile up and wedge. */
|
||||
static async cancelAllJobs(): Promise<{ cancelled: number; filesDeleted: number }> {
|
||||
const queueService = QueueService.getInstance()
|
||||
const queue = queueService.getQueue(this.queue)
|
||||
const jobs = await queue.getJobs(['waiting', 'active', 'delayed', 'paused', 'failed'])
|
||||
|
||||
let filesDeleted = 0
|
||||
for (const job of jobs) {
|
||||
const filePath = (job.data as EmbedFileJobParams).filePath
|
||||
// Same guard as cleanupFailedJobs: only delete user uploads, never ZIM
|
||||
// library files or Nomad docs that live outside the uploads path.
|
||||
if (filePath && filePath.includes(RagService.UPLOADS_STORAGE_PATH)) {
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
filesDeleted++
|
||||
} catch {
|
||||
// File may already be deleted — that's fine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cancelled = jobs.length
|
||||
|
||||
// force: true removes the locked/active job too. An in-flight worker may keep
|
||||
// running its current batch in memory; the self-exists guard in handle()
|
||||
// prevents it from dispatching a continuation back into the cleared queue.
|
||||
await queue.obliterate({ force: true })
|
||||
|
||||
logger.info(`[EmbedFileJob] Cancelled ${cancelled} jobs, deleted ${filesDeleted} files`)
|
||||
return { cancelled, filesDeleted }
|
||||
}
|
||||
|
||||
static async getStatus(filePath: string): Promise<{
|
||||
exists: boolean
|
||||
status?: string
|
||||
|
|
|
|||
|
|
@ -85,6 +85,16 @@ export default class Service extends BaseModel {
|
|||
@column()
|
||||
declare category: string | null
|
||||
|
||||
// When true the service is sunset: hidden from the install catalog unless it is already
|
||||
// installed (see SystemService.getServices). Lets a deprecated app stay manageable for users who
|
||||
// still run it while keeping new users from installing it.
|
||||
@column({
|
||||
serialize(value) {
|
||||
return Boolean(value)
|
||||
},
|
||||
})
|
||||
declare is_deprecated: boolean
|
||||
|
||||
@column()
|
||||
declare source_repo: string | null
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
BOOKS_STORAGE_PATH,
|
||||
CALIBRE_EMPTY_LIBRARY_ASSET_PATH,
|
||||
VAULTWARDEN_STORAGE_PATH,
|
||||
MESHCORE_WEB_STORAGE_PATH,
|
||||
MEDIA_STORAGE_PATH,
|
||||
JELLYFIN_MEDIA_SUBFOLDERS,
|
||||
} from '../utils/fs.js'
|
||||
|
|
@ -19,7 +20,7 @@ import { KiwixLibraryService } from './kiwix_library_service.js'
|
|||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { readFile, mkdir, copyFile, chown, chmod, access } from 'node:fs/promises'
|
||||
import { readFile, mkdir, copyFile, chown, chmod, access, writeFile } from 'node:fs/promises'
|
||||
import KVStore from '#models/kv_store'
|
||||
import { BROADCAST_CHANNELS } from '../../constants/broadcast.js'
|
||||
import { KIWIX_LIBRARY_CMD } from '../../constants/kiwix.js'
|
||||
|
|
@ -697,6 +698,15 @@ export class DockerService {
|
|||
)
|
||||
}
|
||||
|
||||
if (service.service_name === SERVICE_NAMES.MESHCORE_WEB) {
|
||||
await this._runPreinstallActions__MeshCoreWeb()
|
||||
this._broadcast(
|
||||
service.service_name,
|
||||
'preinstall-complete',
|
||||
`Pre-install actions for MeshCore Web completed successfully.`
|
||||
)
|
||||
}
|
||||
|
||||
// GPU-aware configuration for Ollama
|
||||
let finalImage = service.container_image
|
||||
let gpuHostConfig = containerConfig?.HostConfig || {}
|
||||
|
|
@ -1030,18 +1040,58 @@ export class DockerService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a self-signed TLS cert (cert.pem + key.pem) exists in `certDir`, generating one if not.
|
||||
* Used by apps that need a secure (HTTPS) context but run on a LAN appliance with no public DNS to
|
||||
* get a trusted cert for. Idempotent: an existing pair is left untouched, so the cert is stable
|
||||
* across reinstalls (no fresh browser warning each time) and a cert an admin swapped in by hand is
|
||||
* never clobbered. The private key is locked to 0600; the cert stays world-readable.
|
||||
*/
|
||||
private async _ensureSelfSignedCert(
|
||||
certDir: string,
|
||||
commonName: string
|
||||
): Promise<{ certPath: string; keyPath: string }> {
|
||||
const certPath = join(certDir, 'cert.pem')
|
||||
const keyPath = join(certDir, 'key.pem')
|
||||
|
||||
await mkdir(certDir, { recursive: true })
|
||||
|
||||
const alreadyHasCert = await Promise.all([
|
||||
access(certPath)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
access(keyPath)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]).then(([c, k]) => c && k)
|
||||
|
||||
if (alreadyHasCert) return { certPath, keyPath }
|
||||
|
||||
// 10-year self-signed cert. CN/SAN are cosmetic for a self-signed cert (the browser warns
|
||||
// regardless), but a SAN keeps it structurally valid for clients that require one.
|
||||
const execAsync = promisify(exec)
|
||||
await execAsync(
|
||||
`openssl req -x509 -newkey rsa:2048 -nodes ` +
|
||||
`-keyout "${keyPath}" -out "${certPath}" -days 3650 ` +
|
||||
`-subj "/CN=${commonName}" ` +
|
||||
`-addext "subjectAltName=DNS:nomad,DNS:localhost"`
|
||||
)
|
||||
|
||||
await chmod(keyPath, 0o600)
|
||||
await chmod(certPath, 0o644)
|
||||
|
||||
return { certPath, keyPath }
|
||||
}
|
||||
|
||||
/**
|
||||
* Vaultwarden's web vault refuses to run without a secure context — over plain HTTP it shows
|
||||
* "You need to enable HTTPS!" and you can't register or unlock. We give it a self-signed TLS
|
||||
* cert in its /data volume so it serves HTTPS out of the box (paired with the ROCKET_TLS env and
|
||||
* the `https:8480` ui_location set in the seeder). Users click through a one-time browser warning;
|
||||
* after that the vault is fully functional offline. Self-signed is the only option on a LAN
|
||||
* appliance with no public DNS to get a trusted cert for.
|
||||
* after that the vault is fully functional offline.
|
||||
*/
|
||||
private async _runPreinstallActions__Vaultwarden(): Promise<void> {
|
||||
const dataDir = join(process.cwd(), VAULTWARDEN_STORAGE_PATH)
|
||||
const certPath = join(dataDir, 'cert.pem')
|
||||
const keyPath = join(dataDir, 'key.pem')
|
||||
|
||||
this._broadcast(
|
||||
SERVICE_NAMES.VAULTWARDEN,
|
||||
|
|
@ -1050,48 +1100,11 @@ export class DockerService {
|
|||
)
|
||||
|
||||
try {
|
||||
await mkdir(dataDir, { recursive: true })
|
||||
|
||||
// Don't regenerate if a cert is already present — keeps the same cert across reinstalls so
|
||||
// users don't get a fresh browser warning every time, and never clobbers a cert an admin
|
||||
// may have swapped in themselves.
|
||||
const alreadyHasCert = await Promise.all([
|
||||
access(certPath)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
access(keyPath)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]).then(([c, k]) => c && k)
|
||||
|
||||
if (alreadyHasCert) {
|
||||
this._broadcast(
|
||||
SERVICE_NAMES.VAULTWARDEN,
|
||||
'preinstall',
|
||||
`Existing Vaultwarden TLS certificate found — leaving it as-is.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// 10-year self-signed cert. CN/SAN are cosmetic for a self-signed cert (the browser warns
|
||||
// regardless), but a SAN keeps it structurally valid for clients that require one.
|
||||
const execAsync = promisify(exec)
|
||||
await execAsync(
|
||||
`openssl req -x509 -newkey rsa:2048 -nodes ` +
|
||||
`-keyout "${keyPath}" -out "${certPath}" -days 3650 ` +
|
||||
`-subj "/CN=Project NOMAD Vaultwarden" ` +
|
||||
`-addext "subjectAltName=DNS:nomad,DNS:localhost"`
|
||||
)
|
||||
|
||||
// Lock the private key down; the cert can stay world-readable. Vaultwarden runs as root and
|
||||
// reads both from /data, so no chown is needed (admin writes them as root too).
|
||||
await chmod(keyPath, 0o600)
|
||||
await chmod(certPath, 0o644)
|
||||
|
||||
await this._ensureSelfSignedCert(dataDir, 'Project NOMAD Vaultwarden')
|
||||
this._broadcast(
|
||||
SERVICE_NAMES.VAULTWARDEN,
|
||||
'preinstall',
|
||||
`Generated a self-signed HTTPS certificate for Vaultwarden.`
|
||||
`Vaultwarden HTTPS certificate is ready.`
|
||||
)
|
||||
} catch (error: any) {
|
||||
this._broadcast(
|
||||
|
|
@ -1103,6 +1116,63 @@ export class DockerService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The MeshCore web client (aXistem's prebuilt image) is stock nginx serving a static Flutter build
|
||||
* over plain HTTP. The client reaches a radio over Web Bluetooth / Web Serial, which browsers only
|
||||
* permit from a secure (HTTPS) context — so over plain HTTP the app loads but can't connect to a
|
||||
* thing. We generate a self-signed cert and a small SSL nginx config here; the seeder bind-mounts
|
||||
* both into the container (the config over the image's default.conf) so it serves the same static
|
||||
* files over HTTPS instead. Same one-time-browser-warning approach as Vaultwarden.
|
||||
*/
|
||||
private async _runPreinstallActions__MeshCoreWeb(): Promise<void> {
|
||||
const appDir = join(process.cwd(), MESHCORE_WEB_STORAGE_PATH)
|
||||
const certDir = join(appDir, 'certs')
|
||||
const nginxConfPath = join(appDir, 'nginx-ssl.conf')
|
||||
|
||||
this._broadcast(
|
||||
SERVICE_NAMES.MESHCORE_WEB,
|
||||
'preinstall',
|
||||
`Running pre-install actions for MeshCore Web...`
|
||||
)
|
||||
|
||||
try {
|
||||
await this._ensureSelfSignedCert(certDir, 'Project NOMAD MeshCore Web')
|
||||
|
||||
// SSL server block bind-mounted over the image's default.conf. Serves the Flutter build that
|
||||
// already lives at /usr/share/nginx/html in the image, over HTTPS only, with the SPA fallback
|
||||
// that single-page apps need. Cert paths match the /certs bind mount set in the seeder.
|
||||
const nginxConf =
|
||||
[
|
||||
'server {',
|
||||
' listen 443 ssl;',
|
||||
' server_name _;',
|
||||
' ssl_certificate /certs/cert.pem;',
|
||||
' ssl_certificate_key /certs/key.pem;',
|
||||
' root /usr/share/nginx/html;',
|
||||
' index index.html;',
|
||||
' location / {',
|
||||
' try_files $uri $uri/ /index.html;',
|
||||
' }',
|
||||
'}',
|
||||
].join('\n') + '\n'
|
||||
await writeFile(nginxConfPath, nginxConf)
|
||||
await chmod(nginxConfPath, 0o644)
|
||||
|
||||
this._broadcast(
|
||||
SERVICE_NAMES.MESHCORE_WEB,
|
||||
'preinstall',
|
||||
`MeshCore Web HTTPS certificate and config are ready.`
|
||||
)
|
||||
} catch (error: any) {
|
||||
this._broadcast(
|
||||
SERVICE_NAMES.MESHCORE_WEB,
|
||||
'preinstall-error',
|
||||
`Failed to prepare MeshCore Web: ${error.message}`
|
||||
)
|
||||
throw new Error(`Pre-install action failed: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jellyfin works best when each library points at its own subfolder. Pointing one library at the
|
||||
* whole media root and another at a subfolder inside it makes Jellyfin report a "duplicate path"
|
||||
|
|
@ -1867,6 +1937,34 @@ export class DockerService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall a curated catalog app: stop and remove its container (and, optionally, its image),
|
||||
* then mark the record not-installed so the card returns to the available catalog. Host
|
||||
* bind-mount data is deliberately left on disk — a later reinstall picks it back up. Contrast
|
||||
* with forceReinstall, which clears volumes and immediately recreates the container.
|
||||
*/
|
||||
async uninstallService(
|
||||
serviceName: string,
|
||||
removeImage = false
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const service = await Service.query().where('service_name', serviceName).first()
|
||||
if (!service || !service.installed) {
|
||||
return { success: false, message: `Service ${serviceName} not found or not installed` }
|
||||
}
|
||||
|
||||
// Container/image removal is shared with custom-app deletion — the lookup is by container
|
||||
// name, which is the service_name for curated and custom apps alike.
|
||||
const removal = await this.removeCustomAppContainer(serviceName, removeImage)
|
||||
if (!removal.success) return removal
|
||||
|
||||
service.installed = false
|
||||
service.installation_status = 'idle'
|
||||
await service.save()
|
||||
this.invalidateServicesStatusCache()
|
||||
|
||||
return { success: true, message: `Service ${serviceName} uninstalled` }
|
||||
}
|
||||
|
||||
/** Find a container by its managed service name (`/serviceName`), or null. */
|
||||
private async _findContainerByName(serviceName: string) {
|
||||
const containers = await this.docker.listContainers({ all: true })
|
||||
|
|
|
|||
|
|
@ -14,9 +14,10 @@ export class DocsService {
|
|||
'use-cases': 3,
|
||||
'supply-depot-apps': 4,
|
||||
'community-add-ons': 5,
|
||||
'faq': 6,
|
||||
'about': 7,
|
||||
'release-notes': 8,
|
||||
'updates': 6,
|
||||
'faq': 7,
|
||||
'about': 8,
|
||||
'release-notes': 9,
|
||||
}
|
||||
|
||||
async getDocs() {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Queue } from 'bullmq'
|
||||
import queueConfig from '#config/queue'
|
||||
|
||||
// Process-wide singleton. Each `Queue` opens two ioredis connections (one for
|
||||
// commands, one blocking). Instantiating a fresh QueueService per dispatch /
|
||||
// status lookup leaks both, and under sustained job churn (e.g. multi-batch ZIM
|
||||
// ingestion enqueueing a continuation every few seconds) it saturates Redis's
|
||||
// maxclients within hours.
|
||||
// Process-wide singleton. Instantiating a fresh QueueService per dispatch /
|
||||
// status lookup leaks connections, and under sustained job churn (e.g.
|
||||
// multi-batch ZIM ingestion enqueueing a continuation every few seconds) it
|
||||
// saturates Redis's maxclients within hours. All queues additionally reuse the
|
||||
// single shared ioredis instance exported from #config/queue (#885).
|
||||
export class QueueService {
|
||||
private queues: Map<string, Queue> = new Map()
|
||||
|
||||
|
|
|
|||
|
|
@ -1140,20 +1140,90 @@ export class RagService {
|
|||
)
|
||||
}
|
||||
|
||||
return Array.from(sources).map((source) => {
|
||||
const row = stateByPath.get(source)
|
||||
return {
|
||||
source,
|
||||
state: row?.state ?? null,
|
||||
chunksEmbedded: row?.chunks_embedded ?? 0,
|
||||
}
|
||||
})
|
||||
const uploadsAbsPath = resolve(join(process.cwd(), RagService.UPLOADS_STORAGE_PATH))
|
||||
return await Promise.all(
|
||||
Array.from(sources).map(async (source) => {
|
||||
const row = stateByPath.get(source)
|
||||
const fileName = source.split(/[/\\]/).at(-1) ?? source
|
||||
const isUserUpload = resolve(source).startsWith(uploadsAbsPath + sep)
|
||||
const stats = await getFileStatsIfExists(source)
|
||||
return {
|
||||
source,
|
||||
state: row?.state ?? null,
|
||||
chunksEmbedded: row?.chunks_embedded ?? 0,
|
||||
fileName,
|
||||
size: stats?.size ?? null,
|
||||
uploadedAt: stats?.modifiedTime.toISOString() ?? null,
|
||||
isUserUpload,
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error('Error retrieving stored files:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a stored-file `source` to an absolute disk path, but only if the
|
||||
* path lives under the uploads directory. Mirrors the docs_service traversal
|
||||
* guard: resolve, then require the resolved path to be strictly inside the
|
||||
* base + path separator (so siblings of `kb_uploads` can't slip through).
|
||||
* Returns null for anything else — ZIMs, admin docs, README, or paths
|
||||
* outside the app entirely. The viewer/download endpoints lean on this so
|
||||
* they don't need to repeat the check.
|
||||
*/
|
||||
private resolveUploadPath(source: string): string | null {
|
||||
const uploadsAbsPath = resolve(join(process.cwd(), RagService.UPLOADS_STORAGE_PATH))
|
||||
const resolved = resolve(source)
|
||||
if (!resolved.startsWith(uploadsAbsPath + sep)) return null
|
||||
return resolved
|
||||
}
|
||||
|
||||
private static readonly VIEWABLE_TEXT_EXTENSIONS: ReadonlySet<string> = new Set([
|
||||
'md', 'txt', 'csv', 'json', 'yaml', 'yml', 'toml', 'xml', 'html',
|
||||
])
|
||||
|
||||
/**
|
||||
* Read the text content of a user-uploaded file for in-browser viewing.
|
||||
* Returns null when the source is outside uploads, missing, or not a
|
||||
* recognized text extension. The extension allowlist is intentionally narrow
|
||||
* — PDFs/EPUBs/ZIMs round-trip through Download, not the viewer.
|
||||
*/
|
||||
public async readFileContent(
|
||||
source: string
|
||||
): Promise<{ content: string; extension: string; fileName: string } | null> {
|
||||
const resolved = this.resolveUploadPath(source)
|
||||
if (!resolved) return null
|
||||
|
||||
const extension = resolved.split('.').at(-1)?.toLowerCase() ?? ''
|
||||
if (!RagService.VIEWABLE_TEXT_EXTENSIONS.has(extension)) return null
|
||||
|
||||
const stats = await getFileStatsIfExists(resolved)
|
||||
if (!stats) return null
|
||||
|
||||
try {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const content = await readFile(resolved, 'utf-8')
|
||||
const fileName = resolved.split(/[/\\]/).at(-1) ?? resolved
|
||||
return { content, extension, fileName }
|
||||
} catch (error) {
|
||||
logger.warn({ err: error, source }, '[RagService.readFileContent] read failed')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a download-target path for a stored upload. Returns null if the
|
||||
* source isn't an upload or the file is missing on disk.
|
||||
*/
|
||||
public async resolveDownloadPath(source: string): Promise<string | null> {
|
||||
const resolved = this.resolveUploadPath(source)
|
||||
if (!resolved) return null
|
||||
const stats = await getFileStatsIfExists(resolved)
|
||||
return stats ? resolved : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute whether the first-chat JIT prompt should fire and surface the file
|
||||
* count the banner uses in its copy ("Index your N existing files?"). The
|
||||
|
|
|
|||
|
|
@ -49,31 +49,37 @@ export class SystemService {
|
|||
const MAX_ATTEMPTS = 3
|
||||
|
||||
let testUrls = DEFAULT_TEST_URLS
|
||||
const customTestUrl = env.get('INTERNET_STATUS_TEST_URL')?.trim()
|
||||
|
||||
// If a custom test URL is provided and valid, use it exclusively. This
|
||||
// preserves the existing override behavior for operators who intentionally
|
||||
// point connectivity checks at a specific endpoint.
|
||||
// Resolve the test endpoint in priority order: the INTERNET_STATUS_TEST_URL
|
||||
// env var always wins (legacy override for operators who intentionally point
|
||||
// connectivity checks at a specific endpoint), then the UI-configurable value
|
||||
// stored in KVStore, and finally the built-in defaults.
|
||||
const envTestUrl = env.get('INTERNET_STATUS_TEST_URL')?.trim()
|
||||
const kvTestUrl = (await KVStore.getValue('system.internetStatusTestUrl'))?.trim()
|
||||
const customTestUrl = envTestUrl || kvTestUrl
|
||||
|
||||
// If a custom test URL is provided and valid, use it exclusively.
|
||||
if (customTestUrl && customTestUrl !== '') {
|
||||
try {
|
||||
new URL(customTestUrl)
|
||||
testUrls = [customTestUrl]
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Invalid INTERNET_STATUS_TEST_URL: ${customTestUrl}. Falling back to default URLs.`
|
||||
`Invalid internet status test URL: ${customTestUrl}. Falling back to default URLs.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
// Probe all endpoints in parallel and resolve as soon as the first one
|
||||
// Probe all test endpoints in parallel and resolve as soon as the first one
|
||||
// responds. Any HTTP response (including non-2xx) means we reached the
|
||||
// internet, so accept all status codes rather than requiring a strict 200.
|
||||
await Promise.any(
|
||||
testUrls.map((testUrl) =>
|
||||
axios.get(testUrl, { timeout: 5000, validateStatus: () => true })
|
||||
)
|
||||
testUrls.map((testUrl) => {
|
||||
logger.debug(`[SystemService] Checking internet connectivity via: ${testUrl}`)
|
||||
return axios.get(testUrl, { timeout: 5000, validateStatus: () => true })
|
||||
})
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
|
|
@ -333,9 +339,15 @@ export class SystemService {
|
|||
'auto_update_enabled',
|
||||
'is_custom',
|
||||
'is_user_modified',
|
||||
'is_deprecated',
|
||||
'category'
|
||||
)
|
||||
.where('is_dependency_service', false)
|
||||
// Deprecated/sunset apps stay visible only while still installed, so the user can manage and
|
||||
// uninstall them — they never reappear in the install catalog once removed.
|
||||
.where((q) => {
|
||||
q.where('is_deprecated', false).orWhere('installed', true)
|
||||
})
|
||||
if (installedOnly) {
|
||||
query.where('installed', true)
|
||||
}
|
||||
|
|
@ -367,6 +379,7 @@ export class SystemService {
|
|||
auto_update_enabled: service.auto_update_enabled,
|
||||
is_custom: service.is_custom,
|
||||
is_user_modified: service.is_user_modified,
|
||||
is_deprecated: service.is_deprecated,
|
||||
category: service.category,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import vine from '@vinejs/vine'
|
|||
import { wikipediaOptionsFileSchema } from '#validators/curated_collections'
|
||||
import WikipediaSelection from '#models/wikipedia_selection'
|
||||
import InstalledResource from '#models/installed_resource'
|
||||
import CollectionManifest from '#models/collection_manifest'
|
||||
import { RunDownloadJob } from '#jobs/run_download_job'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
import { CollectionManifestService } from './collection_manifest_service.js'
|
||||
|
|
@ -469,6 +470,97 @@ export class ZimService {
|
|||
return { before, after, added: Math.max(0, after - before) }
|
||||
}
|
||||
|
||||
async registerLocalUpload(filename: string): Promise<{ added: number }> {
|
||||
let added = 0
|
||||
try {
|
||||
const result = await this.rescanLibrary()
|
||||
added = result.added
|
||||
} catch (err) {
|
||||
logger.error('[ZimService] Failed to rebuild kiwix library after local upload:', err)
|
||||
}
|
||||
|
||||
const parsed = CollectionManifestService.parseZimFilename(filename)
|
||||
if (parsed) {
|
||||
const filepath = join(process.cwd(), ZIM_STORAGE_PATH, filename)
|
||||
const stats = await getFileStatsIfExists(filepath)
|
||||
try {
|
||||
const { DateTime } = await import('luxon')
|
||||
await InstalledResource.updateOrCreate(
|
||||
{ resource_id: parsed.resource_id, resource_type: 'zim' },
|
||||
{
|
||||
version: parsed.version,
|
||||
url: `local-upload://${filename}`,
|
||||
file_path: filepath,
|
||||
file_size_bytes: stats ? Number(stats.size) : null,
|
||||
installed_at: DateTime.now(),
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`[ZimService] Failed to create InstalledResource for ${filename}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// If the uploaded file matches a known Wikipedia option, mark it as installed
|
||||
try {
|
||||
const manifest = await CollectionManifest.find('wikipedia')
|
||||
if (manifest) {
|
||||
const spec = manifest.spec_data as { options: Array<{ id: string; url: string | null }> }
|
||||
const matchedOption = spec.options.find(
|
||||
(opt) => opt.url && opt.url.split('/').pop() === filename
|
||||
)
|
||||
if (matchedOption && matchedOption.url) {
|
||||
const existing = await WikipediaSelection.query().first()
|
||||
if (existing) {
|
||||
existing.option_id = matchedOption.id
|
||||
existing.url = matchedOption.url
|
||||
existing.filename = filename
|
||||
existing.status = 'installed'
|
||||
await existing.save()
|
||||
} else {
|
||||
await WikipediaSelection.create({
|
||||
option_id: matchedOption.id,
|
||||
url: matchedOption.url,
|
||||
filename,
|
||||
status: 'installed',
|
||||
})
|
||||
}
|
||||
logger.info(`[ZimService] Marked Wikipedia option '${matchedOption.id}' as installed from local upload`)
|
||||
|
||||
// Remove any other wikipedia_en_*.zim files, same as the download flow
|
||||
const allFiles = await this.list()
|
||||
const staleWikipediaFiles = allFiles.files.filter(
|
||||
(f) => f.name.startsWith('wikipedia_en_') && f.name !== filename
|
||||
)
|
||||
for (const stale of staleWikipediaFiles) {
|
||||
try {
|
||||
await this.delete(stale.name)
|
||||
logger.info(`[ZimService] Deleted stale Wikipedia file after upload: ${stale.name}`)
|
||||
} catch (err) {
|
||||
logger.warn(`[ZimService] Could not delete stale Wikipedia file: ${stale.name}`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[ZimService] Failed to update WikipediaSelection for ${filename}:`, error)
|
||||
}
|
||||
|
||||
const ollamaUrl = await this.dockerService.getServiceURL('nomad_ollama')
|
||||
if (ollamaUrl) {
|
||||
try {
|
||||
const { EmbedFileJob } = await import('#jobs/embed_file_job')
|
||||
await EmbedFileJob.dispatch({
|
||||
fileName: filename,
|
||||
filePath: join(process.cwd(), ZIM_STORAGE_PATH, filename),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`[ZimService] EmbedFileJob dispatch failed after local upload:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return { added }
|
||||
}
|
||||
|
||||
async delete(file: string): Promise<void> {
|
||||
let fileName = file
|
||||
if (!fileName.endsWith('.zim')) {
|
||||
|
|
@ -505,6 +597,21 @@ export class ZimService {
|
|||
.delete()
|
||||
logger.info(`[ZimService] Deleted InstalledResource entry for: ${parsed.resource_id}`)
|
||||
}
|
||||
|
||||
// If this file was the active Wikipedia selection, clear the selection
|
||||
try {
|
||||
const selection = await WikipediaSelection.query().first()
|
||||
if (selection && selection.filename === fileName) {
|
||||
selection.option_id = 'none'
|
||||
selection.status = 'none'
|
||||
selection.filename = null
|
||||
selection.url = null
|
||||
await selection.save()
|
||||
logger.info(`[ZimService] Cleared WikipediaSelection after deleting ${fileName}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[ZimService] Failed to clear WikipediaSelection after deleting ${fileName}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Wikipedia selector methods
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ export const CALIBRE_EMPTY_LIBRARY_ASSET_PATH = 'assets/calibre/metadata.db'
|
|||
// Vaultwarden's /data volume. A self-signed TLS cert is generated here on install so the
|
||||
// web vault has the secure context (HTTPS) it requires — see _runPreinstallActions__Vaultwarden.
|
||||
export const VAULTWARDEN_STORAGE_PATH = '/storage/vaultwarden'
|
||||
// MeshCore Web's working dir. On install a self-signed cert (certs/) and an SSL nginx config
|
||||
// (nginx-ssl.conf) are generated here, then bind-mounted into the container so the static client is
|
||||
// served over HTTPS — required for its Web Bluetooth/Serial connections. See
|
||||
// _runPreinstallActions__MeshCoreWeb.
|
||||
export const MESHCORE_WEB_STORAGE_PATH = '/storage/meshcore-web'
|
||||
|
||||
export async function listDirectoryContents(path: string): Promise<FileEntry[]> {
|
||||
const entries = await readdir(path, { withFileTypes: true })
|
||||
|
|
|
|||
|
|
@ -19,6 +19,12 @@ export const embedFileSchema = vine.compile(
|
|||
})
|
||||
)
|
||||
|
||||
export const fileSourceSchema = vine.compile(
|
||||
vine.object({
|
||||
source: vine.string().minLength(1),
|
||||
})
|
||||
)
|
||||
|
||||
export const estimateBatchSchema = vine.compile(
|
||||
vine.object({
|
||||
files: vine
|
||||
|
|
|
|||
|
|
@ -36,6 +36,24 @@ export function validateSettingValue(key: KVStoreKey, value: unknown): string |
|
|||
}
|
||||
return null
|
||||
}
|
||||
case 'system.internetStatusTestUrl': {
|
||||
// Empty clears the setting (reverts to env var / built-in defaults).
|
||||
if (value === '' || value === undefined || value === null) {
|
||||
return null
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return 'Test URL must be a string.'
|
||||
}
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return 'Test URL must use http or https.'
|
||||
}
|
||||
} catch {
|
||||
return 'Test URL must be a valid URL (e.g. "https://example.com").'
|
||||
}
|
||||
return null
|
||||
}
|
||||
case 'contentAutoUpdate.maxBytesPerWindow': {
|
||||
// Per-window download budget in bytes. 0 = unlimited.
|
||||
const num = Number(value)
|
||||
|
|
|
|||
|
|
@ -131,6 +131,14 @@ export const deleteCustomAppValidator = vine.compile(
|
|||
})
|
||||
)
|
||||
|
||||
export const uninstallServiceValidator = vine.compile(
|
||||
vine.object({
|
||||
service_name: vine.string().trim(),
|
||||
// When true, also remove the backing Docker image (best-effort).
|
||||
remove_image: vine.boolean().optional(),
|
||||
})
|
||||
)
|
||||
|
||||
export const serviceLogsValidator = vine.compile(
|
||||
vine.object({
|
||||
tail: vine.number().min(1).max(2000).optional(),
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ const bodyParserConfig = defineConfig({
|
|||
*/
|
||||
autoProcess: true,
|
||||
convertEmptyStringsToNull: true,
|
||||
processManually: [],
|
||||
processManually: ['/api/zim/upload'],
|
||||
|
||||
/**
|
||||
* Maximum limit of data to parse including all files
|
||||
|
|
|
|||
|
|
@ -1,11 +1,42 @@
|
|||
import env from '#start/env'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { Redis } from 'ioredis'
|
||||
|
||||
// BullMQ treats a plain `{host, port}` connection object as a recipe: every
|
||||
// Queue / Worker instantiates its own ioredis client from it, and script
|
||||
// commands executed against those clients can spawn further short-lived
|
||||
// connections. Under sustained ZIM ingestion this leaked ~1 client/sec until
|
||||
// Redis maxclients was exhausted (#885). Passing a single shared ioredis
|
||||
// instance instead gives BullMQ a pool to reuse — Workers still duplicate it
|
||||
// once for their blocking client, which is expected and bounded.
|
||||
// `maxRetriesPerRequest: null` is mandatory for connections shared with BullMQ.
|
||||
const sharedConnection = new Redis({
|
||||
host: env.get('REDIS_HOST'),
|
||||
port: env.get('REDIS_PORT') ?? 6379,
|
||||
db: env.get('REDIS_DB') ?? 0,
|
||||
maxRetriesPerRequest: null,
|
||||
// Don't open the socket at module import time. Importing this file (during
|
||||
// `node ace migration:run`, `db:seed`, `queue:work`, or HTTP boot) otherwise
|
||||
// races Docker's network/DNS lifecycle: on a fresh `up` the `redis` name is
|
||||
// not yet resolvable (EAI_AGAIN), and on a recreate the embedded DNS briefly
|
||||
// serves the previous container's IP (ECONNREFUSED to the stale address).
|
||||
// Lazy-connecting defers the first dial until BullMQ actually needs Redis —
|
||||
// after the entrypoint has confirmed it is reachable — so each (re)connect
|
||||
// re-resolves the current IP instead of hammering a stale one.
|
||||
lazyConnect: true,
|
||||
// Bounded, backing-off retry so a transient outage doesn't busy-loop.
|
||||
retryStrategy: (times) => Math.min(times * 200, 2000),
|
||||
})
|
||||
|
||||
// Without an `error` listener ioredis logs the raw "[ioredis] Unhandled error
|
||||
// event" lines and, on some Node versions, an EventEmitter `error` with no
|
||||
// listener can crash the process. Route them through the app logger instead.
|
||||
sharedConnection.on('error', (err) => {
|
||||
logger.error({ err }, 'Shared Redis connection error')
|
||||
})
|
||||
|
||||
const queueConfig = {
|
||||
connection: {
|
||||
host: env.get('REDIS_HOST'),
|
||||
port: env.get('REDIS_PORT') ?? 6379,
|
||||
db: env.get('REDIS_DB') ?? 0,
|
||||
},
|
||||
connection: sharedConnection,
|
||||
}
|
||||
|
||||
export default queueConfig
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
import { KVStoreKey } from "../types/kv_store.js";
|
||||
|
||||
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled', 'contentAutoUpdate.enabled', 'contentAutoUpdate.windowStart', 'contentAutoUpdate.windowEnd', 'contentAutoUpdate.cooloffHours', 'contentAutoUpdate.maxBytesPerWindow'];
|
||||
export const SETTINGS_KEYS: KVStoreKey[] = ['chat.suggestionsEnabled', 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', 'system.earlyAccess', 'system.internetStatusTestUrl', 'ai.assistantCustomName', 'ai.remoteOllamaUrl', 'ai.ollamaFlashAttention', 'rag.defaultIngestPolicy', 'autoUpdate.enabled', 'autoUpdate.windowStart', 'autoUpdate.windowEnd', 'autoUpdate.cooloffHours', 'appAutoUpdate.enabled', 'contentAutoUpdate.enabled', 'contentAutoUpdate.windowStart', 'contentAutoUpdate.windowEnd', 'contentAutoUpdate.cooloffHours', 'contentAutoUpdate.maxBytesPerWindow'];
|
||||
|
|
@ -5,6 +5,7 @@ export const SERVICE_NAMES = {
|
|||
CYBERCHEF: 'nomad_cyberchef',
|
||||
FLATNOTES: 'nomad_flatnotes',
|
||||
KOLIBRI: 'nomad_kolibri',
|
||||
KOLIBRI_GEN2: 'nomad_kolibri_2',
|
||||
// Supply Depot — curated catalog (ports 8400–8499)
|
||||
STIRLING_PDF: 'nomad_stirling_pdf',
|
||||
FILEBROWSER: 'nomad_filebrowser',
|
||||
|
|
@ -13,6 +14,7 @@ export const SERVICE_NAMES = {
|
|||
EXCALIDRAW: 'nomad_excalidraw',
|
||||
MESHTASTIC_WEB: 'nomad_meshtastic_web',
|
||||
MESHTASTICD: 'nomad_meshtasticd',
|
||||
MESHCORE_WEB: 'nomad_meshcore_web',
|
||||
HOMEBOX: 'nomad_homebox',
|
||||
VAULTWARDEN: 'nomad_vaultwarden',
|
||||
JELLYFIN: 'nomad_jellyfin',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ export const SUPPLY_DEPOT_DOC_ANCHORS: Record<string, string> = {
|
|||
[SERVICE_NAMES.VAULTWARDEN]: 'vaultwarden',
|
||||
[SERVICE_NAMES.JELLYFIN]: 'jellyfin',
|
||||
[SERVICE_NAMES.MESHTASTIC_WEB]: 'meshtastic-web',
|
||||
[SERVICE_NAMES.KOLIBRI]: 'kolibri',
|
||||
[SERVICE_NAMES.KOLIBRI_GEN2]: 'kolibri',
|
||||
[SERVICE_NAMES.MESHCORE_WEB]: 'meshcore-web',
|
||||
}
|
||||
|
||||
// Returns the in-app docs link for a service, or null if it has no documentation section.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names.js'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'services'
|
||||
|
||||
async up() {
|
||||
// Generic deprecation flag (reusable for future sunsets): a deprecated service is hidden from
|
||||
// the install catalog unless it is already installed — see SystemService.getServices().
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.boolean('is_deprecated').notNullable().defaultTo(false)
|
||||
})
|
||||
|
||||
// Sunset the legacy treehouses/kolibri:0.12.8 entry, replaced by the learningequality Gen 2
|
||||
// entry seeded as `nomad_kolibri_2`. The seeder is additive + sync-existing and never deletes,
|
||||
// so without this step every existing deployment keeps an orphaned `nomad_kolibri` row and can
|
||||
// still install the dead 6-year-old image. Conditional handling keeps it data-safe:
|
||||
this.defer(async (db) => {
|
||||
// Never installed → just an orphaned catalog row; drop it outright.
|
||||
await db
|
||||
.from(this.tableName)
|
||||
.where('service_name', SERVICE_NAMES.KOLIBRI)
|
||||
.where('installed', false)
|
||||
.delete()
|
||||
|
||||
// Currently installed → a running 0.12.8 container holds port 8300 + a bind mount. Keep the
|
||||
// row (it's Nomad's only handle to open/stop/uninstall that container) but flag it deprecated
|
||||
// so it shows a "Legacy" badge and drops out of the catalog once the user uninstalls it.
|
||||
await db
|
||||
.from(this.tableName)
|
||||
.where('service_name', SERVICE_NAMES.KOLIBRI)
|
||||
.where('installed', true)
|
||||
.update({ is_deprecated: true })
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
// Note: the legacy-row deletion in up() is a one-way data change and is not restored here.
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('is_deprecated')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ type ServiceSeedRecord = Omit<
|
|||
| 'update_checked_at'
|
||||
| 'metadata'
|
||||
| 'is_user_modified'
|
||||
| 'is_deprecated'
|
||||
| 'custom_url'
|
||||
| 'auto_update_enabled'
|
||||
| 'available_update_first_seen_at'
|
||||
|
|
@ -91,7 +92,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 3,
|
||||
description: 'Local AI chat that runs entirely on your hardware - no internet required',
|
||||
icon: 'IconWand',
|
||||
container_image: 'ollama/ollama:0.18.1',
|
||||
container_image: 'ollama/ollama:0.24.0',
|
||||
source_repo: 'https://github.com/ollama/ollama',
|
||||
container_command: 'serve',
|
||||
container_config: JSON.stringify({
|
||||
|
|
@ -117,7 +118,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 11,
|
||||
description: 'Swiss Army knife for data encoding, encryption, and analysis',
|
||||
icon: 'IconChefHat',
|
||||
container_image: 'ghcr.io/gchq/cyberchef:10.22.1',
|
||||
container_image: 'ghcr.io/gchq/cyberchef:10.24.0',
|
||||
source_repo: 'https://github.com/gchq/CyberChef',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
|
|
@ -163,24 +164,38 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
depends_on: null,
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.KOLIBRI,
|
||||
friendly_name: 'Education Platform',
|
||||
// "Kolibri Gen 2" — the upstream-official learningequality image replacing the ~6-year-old
|
||||
// community treehouses/kolibri:0.12.8. This is a distinct catalog entry (own service_name,
|
||||
// volume, and ports), not an in-place upgrade: the new image uses a different repo, mounts at
|
||||
// /kolibri instead of /root/.kolibri, and crosses 7 minor versions of Kolibri's own data
|
||||
// schema. Existing 0.12.8 installs are sunset via the deprecate-legacy-kolibri migration and
|
||||
// keep running on 8300 until uninstalled; content is re-imported into the fresh Gen 2 install.
|
||||
service_name: SERVICE_NAMES.KOLIBRI_GEN2,
|
||||
friendly_name: 'Education Platform (Gen 2)',
|
||||
powered_by: 'Kolibri',
|
||||
display_order: 2,
|
||||
description: 'Interactive learning platform with video courses and exercises',
|
||||
icon: 'IconSchool',
|
||||
container_image: 'treehouses/kolibri:0.12.8',
|
||||
container_image: 'learningequality/kolibri:0.19.4',
|
||||
source_repo: 'https://github.com/learningequality/kolibri',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '8080/tcp': [{ HostPort: '8300' }] },
|
||||
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri:/root/.kolibri`],
|
||||
// 8080 = web UI. 8311 = zip-content server (interactive exercises / HTML5 apps), served
|
||||
// from a separate "alternate origin" the browser connects to DIRECTLY. KOLIBRI_ZIP_CONTENT_PORT
|
||||
// sets the port Kolibri both LISTENS on inside the container AND advertises in content URLs,
|
||||
// so the internal port, the published host port, and that env value must all be identical
|
||||
// (8311) — otherwise content URLs point at a host port that doesn't route to the listener
|
||||
// and every content page fails with ERR_CONNECTION_REFUSED. The image's default 8081 is
|
||||
// unused here. The image refuses to start without /kolibri mounted (KOLIBRI_HOME = /kolibri).
|
||||
PortBindings: { '8080/tcp': [{ HostPort: '8310' }], '8311/tcp': [{ HostPort: '8311' }] },
|
||||
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/kolibri-gen2:/kolibri`],
|
||||
},
|
||||
ExposedPorts: { '8080/tcp': {} },
|
||||
ExposedPorts: { '8080/tcp': {}, '8311/tcp': {} },
|
||||
Env: ['KOLIBRI_ZIP_CONTENT_PORT=8311'],
|
||||
}),
|
||||
ui_location: '8300',
|
||||
ui_location: '8310',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
|
|
@ -198,7 +213,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 20,
|
||||
description: 'Locally-hosted PDF manipulation tool — merge, split, compress, convert, and more',
|
||||
icon: 'IconFileDescription',
|
||||
container_image: 'ghcr.io/stirling-tools/s-pdf:latest',
|
||||
container_image: 'ghcr.io/stirling-tools/s-pdf:2.13.1',
|
||||
source_repo: 'https://github.com/Stirling-Tools/Stirling-PDF',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
|
|
@ -289,7 +304,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 22,
|
||||
description: 'Web-based e-book reader and library manager for your Calibre collection',
|
||||
icon: 'IconBook',
|
||||
container_image: 'lscr.io/linuxserver/calibre-web:latest',
|
||||
container_image: 'linuxserver/calibre-web:0.6.26-ls386',
|
||||
source_repo: 'https://github.com/janeczku/calibre-web',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
|
|
@ -320,7 +335,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 23,
|
||||
description: 'Collection of handy utilities for developers — UUID, hash, encoding, formatters, and more',
|
||||
icon: 'IconTool',
|
||||
container_image: 'ghcr.io/corentinth/it-tools:latest',
|
||||
container_image: 'ghcr.io/corentinth/it-tools:2024.10.22-7ca5933',
|
||||
source_repo: 'https://github.com/CorentinTh/it-tools',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
|
|
@ -345,7 +360,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 24,
|
||||
description: 'Virtual whiteboard for sketching hand-drawn-style diagrams — works fully offline',
|
||||
icon: 'IconPencil',
|
||||
container_image: 'excalidraw/excalidraw:latest',
|
||||
container_image: 'excalidraw/excalidraw:sha-4bfc5bb',
|
||||
source_repo: 'https://github.com/excalidraw/excalidraw',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
|
|
@ -370,7 +385,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 30,
|
||||
description: 'Browser-based client for managing Meshtastic mesh radio devices',
|
||||
icon: 'IconWifi',
|
||||
container_image: 'ghcr.io/meshtastic/web:latest',
|
||||
container_image: 'ghcr.io/meshtastic/web:2.7.1',
|
||||
source_repo: 'https://github.com/meshtastic/web',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
|
|
@ -390,24 +405,35 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
depends_on: null,
|
||||
},
|
||||
{
|
||||
service_name: SERVICE_NAMES.MESHTASTICD,
|
||||
friendly_name: 'Meshtastic Daemon',
|
||||
powered_by: 'Meshtastic',
|
||||
display_order: 31,
|
||||
description: 'Software-defined Meshtastic node with REST API — connect devices and build mesh networks',
|
||||
icon: 'IconBroadcast',
|
||||
container_image: 'meshtastic/meshtasticd:latest',
|
||||
source_repo: 'https://github.com/meshtastic/meshtasticd',
|
||||
service_name: SERVICE_NAMES.MESHCORE_WEB,
|
||||
friendly_name: 'MeshCore Web',
|
||||
powered_by: 'MeshCore',
|
||||
display_order: 32,
|
||||
description: 'Browser-based client for MeshCore mesh radio devices',
|
||||
icon: 'IconAntenna',
|
||||
// aXistem's prebuilt image of Liam Cottle's MeshCore web client (MeshCore is a sibling LoRa
|
||||
// mesh project to Meshtastic).
|
||||
container_image: 'ghcr.io/axistem-dev/meshcore-web:v1.45.0',
|
||||
source_repo: 'https://github.com/aXistem-dev/meshcore-web',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
HostConfig: {
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
PortBindings: { '4403/tcp': [{ HostPort: '8460' }] },
|
||||
Binds: [`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshtasticd:/root/.portduino`],
|
||||
// The image is stock nginx:alpine serving the Flutter build over HTTP on 80. MeshCore's
|
||||
// client reaches a radio via Web Bluetooth / Web Serial, which browsers only permit from a
|
||||
// secure (HTTPS) context — so we serve it over HTTPS. _runPreinstallActions__MeshCoreWeb
|
||||
// writes a self-signed cert + an SSL server config into storage/meshcore-web; we bind both
|
||||
// in (the config over the image's default.conf) and publish 443. The https: prefix on
|
||||
// ui_location builds an https:// Open link (one-time cert warning, same as Vaultwarden).
|
||||
PortBindings: { '443/tcp': [{ HostPort: '8500' }] },
|
||||
Binds: [
|
||||
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshcore-web/nginx-ssl.conf:/etc/nginx/conf.d/default.conf:ro`,
|
||||
`${ServiceSeeder.NOMAD_STORAGE_ABS_PATH}/meshcore-web/certs:/certs:ro`,
|
||||
],
|
||||
},
|
||||
ExposedPorts: { '4403/tcp': {} },
|
||||
ExposedPorts: { '443/tcp': {} },
|
||||
}),
|
||||
ui_location: '8460',
|
||||
ui_location: 'https:8500',
|
||||
installed: false,
|
||||
installation_status: 'idle',
|
||||
is_dependency_service: false,
|
||||
|
|
@ -425,7 +451,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
// Maintained fork. The original hay-kot/homebox was archived June 2024;
|
||||
// sysadminsmedia is the official continuation (drop-in: same 7745 port + /data volume,
|
||||
// migrates an existing DB forward, telemetry off by default).
|
||||
container_image: 'ghcr.io/sysadminsmedia/homebox:latest',
|
||||
container_image: 'ghcr.io/sysadminsmedia/homebox:0.26.2',
|
||||
source_repo: 'https://github.com/sysadminsmedia/homebox',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
|
|
@ -451,7 +477,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 26,
|
||||
description: 'Lightweight Bitwarden-compatible password manager server — secure your credentials offline',
|
||||
icon: 'IconShieldLock',
|
||||
container_image: 'vaultwarden/server:latest',
|
||||
container_image: 'vaultwarden/server:1.36.0',
|
||||
source_repo: 'https://github.com/dani-garcia/vaultwarden',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
|
|
@ -486,7 +512,7 @@ export default class ServiceSeeder extends BaseSeeder {
|
|||
display_order: 27,
|
||||
description: 'Open-source media server — stream your video, music, and photo libraries',
|
||||
icon: 'IconMovie',
|
||||
container_image: 'jellyfin/jellyfin:latest',
|
||||
container_image: 'jellyfin/jellyfin:10.11.11',
|
||||
source_repo: 'https://github.com/jellyfin/jellyfin',
|
||||
container_command: null,
|
||||
container_config: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ This helps you balance content coverage against storage usage.
|
|||
2. Type your question or request
|
||||
3. The AI responds in conversational style
|
||||
|
||||
The AI must be installed first — enable it during Easy Setup or install it from the [Apps](/settings/apps) page.
|
||||
The AI must be installed first — enable it during Easy Setup or install it from the [Supply Depot](/supply-depot) page.
|
||||
|
||||
### How do I upload documents to the Knowledge Base?
|
||||
1. Go to **[Knowledge Base →](/knowledge-base)**
|
||||
|
|
@ -104,7 +104,7 @@ The Early Access Channel lets you opt in to receive release candidate builds wit
|
|||
2. Refresh the page (Ctrl+R or Cmd+R)
|
||||
3. Go back to the Command Center and try again
|
||||
4. Check Settings → System to see if the service is running
|
||||
5. Try restarting the service (Stop, then Start in Apps manager)
|
||||
5. Try restarting the service (Stop, then Start in the Supply Depot)
|
||||
|
||||
### Maps show a gray/blank area
|
||||
|
||||
|
|
@ -119,7 +119,7 @@ The Maps feature requires downloaded map data. If you see a blank area:
|
|||
This usually means the Information Library service started before its Kiwix library index was fully initialized.
|
||||
|
||||
Try this recovery flow:
|
||||
1. Go to **[Apps](/settings/apps)**
|
||||
1. Go to **[Supply Depot](/supply-depot)**
|
||||
2. Stop **Information Library (Kiwix)**
|
||||
3. Wait 10-15 seconds, then start it again
|
||||
4. If the error persists, run **Force Reinstall** for Information Library from the same page
|
||||
|
|
@ -140,7 +140,7 @@ N.O.M.A.D. automatically detects NVIDIA GPUs when the NVIDIA Container Toolkit i
|
|||
|
||||
1. **Install an NVIDIA GPU** in your server (if not already present)
|
||||
2. **Install the NVIDIA Container Toolkit** on the host — follow the [official installation guide](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)
|
||||
3. **Reinstall the AI Assistant** — Go to [Apps](/settings/apps), find AI Assistant, and click **Force Reinstall**
|
||||
3. **Reinstall the AI Assistant** — Go to [Supply Depot](/supply-depot), find AI Assistant, and click **Force Reinstall**
|
||||
|
||||
N.O.M.A.D. will detect the GPU during installation and configure the AI to use it automatically. You'll see "NVIDIA container runtime detected" in the installation progress.
|
||||
|
||||
|
|
@ -151,7 +151,7 @@ N.O.M.A.D. will detect the GPU during installation and configure the AI to use i
|
|||
When you add or swap a GPU, N.O.M.A.D. needs to reconfigure the AI container to use it:
|
||||
|
||||
1. Make sure the **NVIDIA Container Toolkit** is installed on the host
|
||||
2. Go to **[Apps](/settings/apps)**
|
||||
2. Go to **[Supply Depot](/supply-depot)**
|
||||
3. Find the **AI Assistant** and click **Force Reinstall**
|
||||
|
||||
Force Reinstall recreates the AI container with GPU support enabled. Without this step, the AI continues to run on CPU only.
|
||||
|
|
@ -163,7 +163,7 @@ N.O.M.A.D. checks whether your GPU is actually accessible inside the AI containe
|
|||
### AI Chat not available
|
||||
|
||||
The AI Chat page requires the AI Assistant to be installed first:
|
||||
1. Go to **[Apps](/settings/apps)**
|
||||
1. Go to **[Supply Depot](/supply-depot)**
|
||||
2. Install the **AI Assistant**
|
||||
3. Wait for the installation to complete
|
||||
4. The AI Chat will then be accessible from the home screen or [Chat](/chat)
|
||||
|
|
@ -171,7 +171,7 @@ The AI Chat page requires the AI Assistant to be installed first:
|
|||
### Knowledge Base upload stuck
|
||||
|
||||
If a document upload appears stuck in the Knowledge Base:
|
||||
1. Check that the AI Assistant is running in **Settings → Apps**
|
||||
1. Check that the AI Assistant is running in **Settings → Supply Depot**
|
||||
2. Large documents take time to process — wait a few minutes
|
||||
3. Try uploading a smaller document to verify the system is working
|
||||
4. Check **Settings → System** for any error messages
|
||||
|
|
@ -190,7 +190,7 @@ If submission fails, check the error message for details.
|
|||
The service might still be starting up. Wait 1-2 minutes and try again.
|
||||
|
||||
If the problem persists:
|
||||
1. Go to **Settings → Apps**
|
||||
1. Go to **Settings → Supply Depot**
|
||||
2. Find the problematic service
|
||||
3. Click **Restart**
|
||||
4. Wait 30 seconds, then try again
|
||||
|
|
@ -233,12 +233,17 @@ Yes, while you have internet access. Updates include:
|
|||
- Security improvements
|
||||
- Performance enhancements
|
||||
|
||||
### Can N.O.M.A.D. update itself automatically?
|
||||
Yes. N.O.M.A.D. can keep its software, its installed apps, and its content current on its own. Automatic updates are **opt-in and off by default** — you turn on what you want from **Settings → Updates** (and, for apps, a per-app toggle in the Supply Depot). They only run inside a time window you choose, after safety checks, and never apply major version jumps automatically. See the **[Updates guide](/docs/updates)** for a full walkthrough.
|
||||
|
||||
### How do I update content (Wikipedia, etc.)?
|
||||
Content updates are separate from software updates:
|
||||
1. Go to **Settings → Content Manager** or **Content Explorer**
|
||||
2. Check for newer versions of your installed content
|
||||
3. Download updated versions as needed
|
||||
|
||||
You can also turn on **automatic content updates** so installed Wikipedia/ZIM libraries and map regions refresh on their own overnight — see the [Updates guide](/docs/updates).
|
||||
|
||||
Tip: New Wikipedia snapshots are released approximately monthly.
|
||||
|
||||
### What happens if an update fails?
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ The Information Library stores compressed versions of websites and references th
|
|||
- Classic books from Project Gutenberg
|
||||
|
||||
**How to use it:**
|
||||
1. Click **Information Library** from the Command Center home screen or [Apps](/settings/apps) page
|
||||
1. Click **Information Library** from the Command Center home screen or the [Supply Depot](/supply-depot)
|
||||
2. Choose a collection (like Wikipedia)
|
||||
3. Search or browse just like the regular website
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ The Education Platform provides complete educational courses that work offline.
|
|||
- Works for all ages
|
||||
|
||||
**How to use it:**
|
||||
1. Click **Education Platform** from the Command Center home screen or [Apps](/settings/apps) page
|
||||
1. Click **Education Platform** from the Command Center home screen or the [Supply Depot](/supply-depot)
|
||||
2. Sign in or create a learner account
|
||||
3. Browse courses and start learning
|
||||
|
||||
|
|
@ -82,9 +82,9 @@ N.O.M.A.D. includes a built-in AI chat interface powered by Ollama. It runs enti
|
|||
|
||||
**Tip:** Be specific in your questions. Instead of "tell me about plants," try "what vegetables grow well in shade?"
|
||||
|
||||
**Note:** The AI Assistant must be installed first. Enable it during Easy Setup or install it from the [Apps](/settings/apps) page.
|
||||
**Note:** The AI Assistant must be installed first. Enable it during Easy Setup or install it from the [Supply Depot](/supply-depot).
|
||||
|
||||
**GPU Acceleration:** If your server has an NVIDIA GPU with the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) installed, N.O.M.A.D. will automatically use it for AI — dramatically faster responses (10-20x improvement). If you add a GPU later, go to [Apps](/settings/apps) and **Force Reinstall** the AI Assistant to enable it.
|
||||
**GPU Acceleration:** If your server has an NVIDIA GPU with the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) installed, N.O.M.A.D. will automatically use it for AI — dramatically faster responses (10-20x improvement). If you add a GPU later, go to the [Supply Depot](/supply-depot) and **Force Reinstall** the AI Assistant to enable it.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ View maps without internet. Download the regions you need before going offline.
|
|||
|
||||
As your needs change, you can add more content anytime:
|
||||
|
||||
- **More apps:** Settings → Apps
|
||||
- **More apps:** Settings → Supply Depot
|
||||
- **More references:** Settings → Content Explorer or Content Manager
|
||||
- **More map regions:** Settings → Maps Manager
|
||||
- **More educational content:** Through Kolibri's built-in content browser
|
||||
|
|
@ -184,6 +184,8 @@ While you have internet, periodically check for updates:
|
|||
|
||||
Content updates (Wikipedia, maps, etc.) can be managed separately from software updates.
|
||||
|
||||
**Automatic updates:** N.O.M.A.D. can also keep itself current without you having to check. Software, installed apps, and content can each be set to update automatically on an opt-in basis, with safety checks and a time window you control. See the **[Updates guide](/docs/updates)** for the full picture.
|
||||
|
||||
**Early Access Channel:** Want the latest features before they hit stable? Enable the Early Access Channel from the Check for Updates page to receive release candidate builds. You can switch back to stable anytime.
|
||||
|
||||
### Monitoring System Health
|
||||
|
|
|
|||
|
|
@ -15,12 +15,12 @@ Think of it as having Wikipedia, Khan Academy, an AI assistant, and offline maps
|
|||
### Browse Offline Knowledge
|
||||
Access millions of Wikipedia articles, medical references, how-to guides, and ebooks — all stored locally on your server. No internet required.
|
||||
|
||||
*Launch the Information Library from the home screen or the [Apps](/settings/apps) page.*
|
||||
*Launch the Information Library from the home screen or the [Supply Depot](/supply-depot).*
|
||||
|
||||
### Learn Something New
|
||||
Khan Academy courses covering math, science, economics, and more. Complete with videos and exercises, all available offline.
|
||||
|
||||
*Launch the Education Platform from the home screen or the [Apps](/settings/apps) page.*
|
||||
*Launch the Education Platform from the home screen or the [Supply Depot](/supply-depot).*
|
||||
|
||||
### Chat with AI
|
||||
Ask questions, get explanations, brainstorm ideas, or get help with writing. Your local AI assistant works completely offline — and you can upload documents to the Knowledge Base for document-aware responses.
|
||||
|
|
@ -60,7 +60,7 @@ Or explore the **[Getting Started Guide](/docs/getting-started)** for a walkthro
|
|||
|--------------|---------|
|
||||
| Chat with the AI | [AI Chat →](/chat) |
|
||||
| Upload documents for AI | [Knowledge Base →](/knowledge-base) |
|
||||
| Download more content | [Install Apps →](/settings/apps) |
|
||||
| Install more apps | [Supply Depot →](/supply-depot) |
|
||||
| Add Wikipedia/reference content | [Content Explorer →](/settings/zim/remote-explorer) |
|
||||
| Manage installed content | [Content Manager →](/settings/zim) |
|
||||
| Download map regions | [Maps Manager →](/settings/maps) |
|
||||
|
|
@ -80,4 +80,6 @@ N.O.M.A.D. works best when kept up to date while you have internet access. This
|
|||
|
||||
When you go offline, you'll have everything you need — the last synced versions of all your content.
|
||||
|
||||
You can update on demand, or turn on **automatic updates** so N.O.M.A.D. keeps its software, apps, and content current on its own while you have internet. See the **[Updates guide](/docs/updates)** for how it works.
|
||||
|
||||
**[Check for Updates →](/settings/update)**
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Release Notes
|
||||
|
||||
## Unreleased
|
||||
## Version 1.33.0 - June 23, 2026
|
||||
|
||||
### Features
|
||||
- **Supply Depot — Custom Apps**: The Supply Depot is NOMAD's new home for installable apps, and it now lets you run your *own* custom Docker containers — not just the curated catalog. Specify an image, port mappings, volume binds, environment variables, and memory/CPU limits, and NOMAD spins it up as a managed sibling container. A live, debounced pre-flight check warns about port conflicts and resource limits as you type and hard-blocks unsafe configurations, with an "install anyway" override for warning-only cases (e.g. an untrusted registry or a `:latest` tag). Installed custom apps can be edited, updated (re-pull latest + recreate with a safe rollback if the new container fails), and removed (optionally deleting the image), and every installed app — curated or custom — gets per-container **Logs** and **Stats** modals. Host-path binds are hardened against escapes and logs/stats are scoped to NOMAD-managed containers only. Thanks @jakeaturner for the contribution!
|
||||
|
|
@ -13,6 +13,8 @@
|
|||
- **Maps — Persistent View**: The Maps page now remembers your position and zoom across refreshes instead of resetting to the default US-wide view. The saved view is bounds-checked, so a corrupt value safely falls back to the default. Thanks @chriscrosstalk for the contribution!
|
||||
- **Content Manager — Rescan Library**: A new "Rescan Library" button rebuilds the Kiwix index from the ZIM files currently on disk, so files **sideloaded** outside NOMAD's download flow (USB stick, SSH, network share) can be served without dropping to a terminal. It reports how many new books were found and, in library mode, hot-reloads without a container restart. Thanks @chriscrosstalk for the contribution!
|
||||
- **Configuration — Redis database selection**: Added a `REDIS_DB` environment variable so operators can pick a Redis logical database (0–15) for the job queue and live-update transport. This prevents key collisions when a single Redis instance is shared across multiple stacks (common in homelabs). Defaults to db 0, preserving existing behavior. Thanks @johno10661 for the contribution!
|
||||
- **Advanced Settings — Internet Test URL**: Added a new Advanced Settings page with an option to override the default internet test "beacon" URL (more advanced settings to come). Previously, overriding this URL required an ENV variable change and container restart. The legacy ENV variable is still respected if you've set it. Thanks @jakeaturner for the contribution!
|
||||
- **RAG**: Embedding jobs can now be cancelled, allowing users to clear stuck jobs that haven't explicitly failed. Thanks @jakeaturner for the contribution!
|
||||
|
||||
### Bug Fixes
|
||||
- **Storage**: When the admin storage volume is relocated to another disk, child apps (Kiwix, Ollama, Qdrant, Flatnotes, Kolibri) now automatically follow to the new location instead of mounting the old, empty path. The host storage root is now derived from the admin's actual mount, with an explicit `NOMAD_STORAGE_PATH` override and clearer compose comments. Thanks @chriscrosstalk for the fix!
|
||||
|
|
@ -41,6 +43,8 @@
|
|||
- **Dependencies**: Bumped React and React DOM. Thanks @jakeaturner for the contribution!
|
||||
- **Dependencies**: Bumped autoprefixer. Thanks @jakeaturner for the contribution!
|
||||
- **Dependencies**: Bumped BullMQ to 5.77.6 and updated affected job calls to the new arguments shape. Thanks @jakeaturner for the contribution!
|
||||
- **Supply Depot**: Pinned all curated image versions to ensure consistent baseline deployments. Thanks @jakeaturner for the contribution!
|
||||
- **Supply Depot**: Bumped the default versions of CyberChef to 10.24.0 and Ollama to 0.24.0. Thanks @jakeaturner for the contribution!
|
||||
|
||||
## Version 1.32.1 - May 27, 2026
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,38 @@ A quick note on logins: some of these apps have their own accounts, separate fro
|
|||
|
||||
---
|
||||
|
||||
## Managing your apps
|
||||
|
||||
Every app you install gets a **Manage** menu on its card. From there you can:
|
||||
|
||||
- **Docs** — jump straight to the NOMAD getting-started notes for that app (the same per-app sections you'll find below).
|
||||
- **Edit** — change an app's settings: port mappings, volume binds, environment variables, and memory/CPU limits. This works for curated apps too, not just custom ones. Your edits are merged into the app's existing setup, so advanced settings (like GPU access on the AI Assistant) are preserved, and an edited app stops getting overwritten by catalog updates.
|
||||
- **Logs** and **Stats** — open a live view of an app's log output or its current memory and CPU use, handy when something isn't behaving.
|
||||
- **Update** and **Remove** — pull the latest version of an app, or remove it (optionally deleting its image too). If an update's new container fails to start, NOMAD automatically rolls back to the version that was working.
|
||||
|
||||
**Seeing what version you're running:** Each app card shows the installed version right next to the app name (for example, `Kiwix · 3.7.0`). When a newer version is available, an orange **Update available** pill appears on the card so it's easy to spot at a glance.
|
||||
|
||||
**Custom "Open" links:** By default the **Open** button points at the app on your NOMAD's own address. If you run a reverse proxy or local DNS and would rather open an app at a friendlier address (for example `https://jellyfin.myhomelab.net`), use **Manage › Edit** to set a custom launch URL. NOMAD keeps your original link safely on file, so you can always switch back, and the override sticks across upgrades.
|
||||
|
||||
**Keeping apps updated automatically:** Installed apps can update themselves hands-off. This is opt-in at two levels — a master switch in **Settings → Updates** and a per-app toggle in the Supply Depot — and only minor and patch updates are ever applied automatically (major versions always stay manual). See the [Updates guide](/docs/updates) for the full story.
|
||||
|
||||
---
|
||||
|
||||
## Bringing your own app
|
||||
|
||||
Beyond the curated catalog, the Supply Depot can run **your own Docker container** as a managed app alongside everything else. Click **Add a custom app** and tell NOMAD:
|
||||
|
||||
- the **image** to pull (for example `ghcr.io/owner/app:1.2.3`),
|
||||
- any **port mappings**, **volume binds**, **environment variables**, and **memory/CPU limits** it needs.
|
||||
|
||||
As you fill it in, NOMAD runs a live pre-flight check and warns you about things like port conflicts or risky settings. Some warnings (an untrusted registry, or a `:latest` tag that can't be version-tracked) are advisory and you can choose **Install anyway**; genuinely unsafe configurations are blocked outright.
|
||||
|
||||
Once installed, a custom app behaves like any other: it gets the same **Manage** menu (Edit, Logs, Stats, Update, Remove), shows its version on the card, and can opt in to automatic updates. NOMAD hardens host-path binds and scopes logs and stats to its own managed containers, so a custom app can't reach outside what you give it.
|
||||
|
||||
> A custom app is exactly that — yours. NOMAD runs it and gets out of the way; it doesn't provide setup docs or support for software outside the curated catalog. Check the project's own documentation for how to use it.
|
||||
|
||||
---
|
||||
|
||||
## Stirling PDF {% #stirling-pdf %}
|
||||
|
||||
A full toolbox for working with PDFs, all on your own hardware. Merge and split files, convert to and from PDF, compress, rotate, add or remove passwords, OCR scanned documents so they're searchable, sign, stamp, and redact. There are over 50 tools in here, and because it runs locally, none of your documents ever leave your NOMAD.
|
||||
|
|
@ -210,3 +242,43 @@ A browser-based control panel for [Meshtastic](https://meshtastic.org) devices.
|
|||
**Your data:** There's nothing to set up or store on your NOMAD for this app. Your radio's settings live on the radio itself, and this app's preferences live in your browser. There's no NOMAD folder to manage.
|
||||
|
||||
**Works offline:** Fully offline, which is the entire point of Meshtastic. The app is served from your NOMAD, and talking to your radios happens over your local network or radio, never the internet. The only online bits are the links in the footer (Vercel, legal), which don't matter for using your mesh.
|
||||
|
||||
## Education Platform (Kolibri) {% #kolibri %}
|
||||
|
||||
A complete offline learning platform from Learning Equality. Kolibri pulls together video lessons, exercises, and readings into structured channels, organizes them into classes and lessons, tracks learner progress, and works entirely on your NOMAD with no internet. It's built for schools and learners in places with little or no connectivity.
|
||||
|
||||
**Official site:** [learningequality.org/kolibri](https://learningequality.org/kolibri) · **Source:** [github.com/learningequality/kolibri](https://github.com/learningequality/kolibri)
|
||||
|
||||
**First time you open it, you'll go through a quick setup wizard.** Pick your facility type and create the **admin account** (this is the super-user that manages the whole device, so give it a real password and keep track of it). Once you're in, you import learning content as **channels**.
|
||||
|
||||
**Importing content:** Kolibri's content is delivered as channels you import. Open **Device → Channels → Import**, and either pull channels from Kolibri Studio (online) or import from a local drive or another Kolibri device if you already have the content files. There's a lot available, so import just the channels you need; they can be large.
|
||||
|
||||
**Migrating content from Education Platform (Gen 1):** Earlier NOMAD releases shipped a much older Kolibri (the `treehouses/kolibri:0.12.8` image). The Education Platform "Gen 2" is a newer, upstream-official Kolibri and installs **fresh** — your old channels and learner data are **not** carried over automatically, because the two versions store data too differently to migrate safely. If you were running the old one and want to import your existing channels into the new one, here's the process:
|
||||
|
||||
1. Install "Education Platform (Gen 2)" from the catalog (it runs alongside the old one on a different port, so nothing is disrupted while you set it up).
|
||||
2. Launch the new one, walk through the setup wizard, then from the sidebar menu, navigate to **Device > Channels > Import**. Choose the "Local network or internet" option, and then "Add new device". In the dialog that appears, enter the IP address of your NOMAD with the old Education Platform port (8300 by default, so for example `http://192.168.1.36:8300`), give it a name (anything you'd like), and click "Add", and then "Continue".
|
||||
3. You can now select individual channels from the old Education Platform, or choose "Select entire channels instead" to import everything at once. Click "Import" when ready, and the transfer will start.
|
||||
3. Once you're happy with the new install and have any content copied over, uninstall the old Education Platform from its card (it carries a **legacy** badge). It's also recommended to choose to remove the old image and data volume when uninstalling to avoid confusion and free up space, but if you want to keep it around for a while just in case, that's totally fine too.
|
||||
|
||||
**Your data:** Your imported channels, classes, and learner progress live in the `storage/kolibri-gen2` folder on your NOMAD. Backing up that folder backs up your whole Kolibri.
|
||||
|
||||
**Works offline:** Fully offline once content is imported, that's what Kolibri is for. The only step that uses the internet is importing channels from Kolibri Studio; everything after that, browsing lessons, doing exercises, tracking progress, runs entirely on your NOMAD.
|
||||
## MeshCore Web {% #meshcore-web %}
|
||||
|
||||
A browser-based client for [MeshCore](https://meshcore.co.uk) radios. MeshCore is another take on off-grid, long-range LoRa mesh messaging, a sibling to Meshtastic: small radios that form their own network and pass text and location for miles with no cell service, no internet, and no fees. This app is how you configure a MeshCore radio and read and send messages from a full-size screen. If you're not already running MeshCore gear, the Meshtastic client above is the more common starting point. This one is here for people who use MeshCore.
|
||||
|
||||
**Official site:** [meshcore.co.uk](https://meshcore.co.uk) · **Source:** [github.com/aXistem-dev/meshcore-web](https://github.com/aXistem-dev/meshcore-web) (a packaged build of Liam Cottle's MeshCore client)
|
||||
|
||||
**You need a MeshCore radio to use this.** Like the Meshtastic client, this is just the control panel. With no radio connected, there's nothing for it to talk to.
|
||||
|
||||
**First time you open it, you'll see a security warning. That's expected, here's why:** MeshCore connects to your radio over USB or Bluetooth, and browsers only let a web page use USB or Bluetooth when the page is loaded over a secure (HTTPS) connection. So NOMAD serves this app over HTTPS, and because your NOMAD is a private device with no public web address, it uses a self-signed certificate that browsers warn about the first time they see it. To get past it once:
|
||||
|
||||
1. Click **Open** on the MeshCore Web card. Your browser shows something like *"Your connection is not private"* or *"Not secure."*
|
||||
2. Click **Advanced**, then **Proceed to (your NOMAD's address)**. (On some browsers the button says "Continue" or "Accept the Risk.")
|
||||
3. You'll land in MeshCore Web. Your browser remembers your choice, so you won't see the warning again on that device.
|
||||
|
||||
**Connecting your radio:** Use **Chrome or Edge**, which have the best support for browser USB and Bluetooth. Plug the radio into the computer you're browsing from (USB), or have it nearby (Bluetooth), then connect to it from inside the app. The radio connects to **the computer you're using**, not to the NOMAD itself, so connect from a device that has the radio plugged in or in Bluetooth range. Some phones are stricter about self-signed certificates and may refuse to connect; a desktop Chrome or Edge is the most reliable.
|
||||
|
||||
**Your data:** There's nothing to set up or store on your NOMAD for this app. Your radio's settings live on the radio itself, and the app's preferences live in your browser. There's no NOMAD folder to manage.
|
||||
|
||||
**Works offline:** Fully offline, which is the whole point of MeshCore. The app is served from your NOMAD and talks to your radio directly over USB or Bluetooth, never the internet.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
# Keeping N.O.M.A.D. Updated
|
||||
|
||||
N.O.M.A.D. works best when it's kept current while you have internet, so it's ready with the latest software and content the next time you go offline. This page explains what can be updated, how to do it on demand, and how to let N.O.M.A.D. handle it for you automatically.
|
||||
|
||||
---
|
||||
|
||||
## The three kinds of updates
|
||||
|
||||
There are three separate things that can be updated, and you control each one independently:
|
||||
|
||||
1. **Software (the core)** — N.O.M.A.D. itself: the Command Center, new features, bug fixes, and security improvements.
|
||||
2. **Apps** — the installable apps from the [Supply Depot](/supply-depot) (Kiwix, the AI Assistant, and any others you've added).
|
||||
3. **Content** — your offline material: Wikipedia and other Kiwix libraries, and downloaded map regions.
|
||||
|
||||
You can update any of these on demand, or set any of them to update automatically.
|
||||
|
||||
---
|
||||
|
||||
## Updating on demand
|
||||
|
||||
To check for and install updates yourself:
|
||||
|
||||
1. Go to **[Settings → Check for Updates](/settings/update)**.
|
||||
2. If a software update is available, click to install it. N.O.M.A.D. downloads the update and restarts (usually 2–5 minutes).
|
||||
3. Apps can be updated from their card in the [Supply Depot](/supply-depot) using **Manage › Update**.
|
||||
4. Content is managed from **Settings → Content Manager** and **Content Explorer**, where you can download newer versions of installed libraries and maps.
|
||||
|
||||
If a software or app update ever fails, N.O.M.A.D. is designed to recover gracefully — the previous working version keeps running, so your server stays up.
|
||||
|
||||
---
|
||||
|
||||
## Automatic updates
|
||||
|
||||
N.O.M.A.D. can keep itself current without you having to remember to check. **Automatic updates are opt-in and off by default** — nothing updates on its own until you turn it on. You manage all of it from **Settings → Updates**.
|
||||
|
||||
A few things are true across all three:
|
||||
|
||||
- **You choose a time window.** Automatic updates only run during the hours you set, so they never interrupt you mid-use.
|
||||
- **Major versions are never automatic.** Only minor and patch updates apply on their own; a big version jump always waits for you to do it manually, on purpose.
|
||||
- **Safety checks come first.** Before applying anything, N.O.M.A.D. confirms there's enough disk space and that no other update, download, or install is already in progress.
|
||||
- **Being offline is harmless.** If N.O.M.A.D. can't reach the internet to check, it simply skips that round and tries again later.
|
||||
|
||||
### Automatic software (core) updates
|
||||
|
||||
Turn this on from **Settings → Updates**. When enabled, N.O.M.A.D. updates its own core to newer releases within the same major version, during your chosen window, after a configurable **cool-off** period (so a brand-new release has time to prove itself before your server takes it). The same page shows the toggle, the window, the cool-off setting, and live status. If updates fail repeatedly for a real reason, N.O.M.A.D. turns the feature back off and lets you know rather than retrying forever.
|
||||
|
||||
### Automatic app updates
|
||||
|
||||
App auto-updates are opt-in at **two levels**: a master switch in **Settings → Updates**, *and* a per-app toggle on each app's card in the [Supply Depot](/supply-depot). Both have to be on for an app to update itself. App updates share the same update window and cool-off as the core, apply only minor and patch versions, and back off automatically for any individual app that keeps failing.
|
||||
|
||||
### Automatic content updates
|
||||
|
||||
Installed Wikipedia/ZIM libraries and map regions can refresh themselves too. Because content downloads are large (often many gigabytes), content updates run on their **own dedicated overnight window** with a **bandwidth cap**, separate from the software and app schedule. N.O.M.A.D. checks the upstream Kiwix and map catalogs directly, and when a Wikipedia library is replaced with a newer version, it keeps the AI Knowledge Base in sync automatically.
|
||||
|
||||
---
|
||||
|
||||
## Early Access Channel
|
||||
|
||||
Want new features before they reach the stable release? Enable the **Early Access Channel** from the [Check for Updates](/settings/update) page to receive release-candidate builds. Early-access builds may contain rough edges — you can switch back to stable at any time.
|
||||
|
||||
---
|
||||
|
||||
## Before you go offline
|
||||
|
||||
Whatever you choose, the habit that matters most is simple: **update while you still have internet.** Whether you do it by hand or let automatic updates handle it, make sure your software and content are current before you head somewhere without a connection. When you're offline, you'll have the last synced versions of everything ready to go.
|
||||
|
||||
**[Check for Updates →](/settings/update)** · **[See what's new in each version →](/docs/release-notes)**
|
||||
|
|
@ -8,7 +8,7 @@ interface LoadingSpinnerProps {
|
|||
|
||||
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
text,
|
||||
fullscreen = true,
|
||||
fullscreen = false,
|
||||
iconOnly = false,
|
||||
light = false,
|
||||
className,
|
||||
|
|
@ -29,9 +29,12 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="ui active inverted dimmer">
|
||||
<div className="ui text loader">{!iconOnly && <span>{text || 'Loading'}</span>}</div>
|
||||
<div
|
||||
className={`fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm ${className || ''}`}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<div className="w-10 h-10 border-[3px] border-white border-t-transparent rounded-full animate-spin" />
|
||||
{!iconOnly && <div className="text-white mt-3 font-medium">{text || 'Loading'}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import Uppy from '@uppy/core'
|
||||
import Dashboard from '@uppy/react/dashboard'
|
||||
import XHRUpload from '@uppy/xhr-upload'
|
||||
import '@uppy/core/css/style.min.css'
|
||||
import '@uppy/dashboard/css/style.min.css'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface ZimUploaderProps {
|
||||
onUploadComplete: (added: number) => void
|
||||
existingFilenames: string[]
|
||||
}
|
||||
|
||||
export default function ZimUploader({ onUploadComplete, existingFilenames }: ZimUploaderProps) {
|
||||
const existingFilenamesRef = useRef(existingFilenames)
|
||||
useEffect(() => {
|
||||
existingFilenamesRef.current = existingFilenames
|
||||
}, [existingFilenames])
|
||||
|
||||
// Keep the latest callback in a ref so the Uppy lifecycle effect below does
|
||||
// not depend on it. The parent passes a new inline function on every render,
|
||||
// and if that identity were in the effect deps, a re-render mid-upload (e.g.
|
||||
// a window-focus refetch) would tear down and `uppy.destroy()` the instance,
|
||||
// aborting the in-flight upload.
|
||||
const onUploadCompleteRef = useRef(onUploadComplete)
|
||||
useEffect(() => {
|
||||
onUploadCompleteRef.current = onUploadComplete
|
||||
}, [onUploadComplete])
|
||||
|
||||
const [uppy] = useState(() =>
|
||||
new Uppy({
|
||||
restrictions: {
|
||||
maxNumberOfFiles: 5,
|
||||
allowedFileTypes: ['.zim'],
|
||||
},
|
||||
autoProceed: false,
|
||||
}).use(XHRUpload, {
|
||||
endpoint: '/api/zim/upload',
|
||||
fieldName: 'file',
|
||||
getResponseError: (responseText) => {
|
||||
try {
|
||||
const body = JSON.parse(responseText)
|
||||
if (body?.message) return new Error(body.message)
|
||||
} catch {}
|
||||
return new Error('Upload failed')
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleFileAdded = (file: { id: string; name: string }) => {
|
||||
if (existingFilenamesRef.current.includes(file.name)) {
|
||||
uppy.removeFile(file.id)
|
||||
uppy.info('A ZIM file with that name already exists', 'error', 6000)
|
||||
return
|
||||
}
|
||||
|
||||
const isWikipedia = (name: string) => name.startsWith('wikipedia_en_')
|
||||
if (isWikipedia(file.name)) {
|
||||
const alreadyQueued = uppy.getFiles().some((f) => f.id !== file.id && isWikipedia(f.name))
|
||||
if (alreadyQueued) {
|
||||
uppy.removeFile(file.id)
|
||||
uppy.info('Only one Wikipedia file can be uploaded at a time', 'error', 6000)
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleComplete = (result: {
|
||||
successful: Array<{ response?: { body?: { added?: number } } }>
|
||||
failed: Array<unknown>
|
||||
}) => {
|
||||
// Uppy emits `complete` even when uploads fail or are aborted. Only treat
|
||||
// the run as a success when nothing failed — otherwise leave the uploader
|
||||
// open so the Dashboard can surface the error instead of closing it with a
|
||||
// false "Upload complete" toast.
|
||||
if (result.failed.length > 0) return
|
||||
const added = result.successful.reduce((sum, f) => sum + (f.response?.body?.added ?? 0), 0)
|
||||
onUploadCompleteRef.current(added)
|
||||
}
|
||||
uppy.on('file-added', handleFileAdded)
|
||||
uppy.on('complete', handleComplete)
|
||||
return () => {
|
||||
uppy.off('file-added', handleFileAdded)
|
||||
uppy.off('complete', handleComplete)
|
||||
uppy.destroy()
|
||||
}
|
||||
}, [uppy])
|
||||
|
||||
return (
|
||||
<Dashboard
|
||||
uppy={uppy}
|
||||
width="100%"
|
||||
height={300}
|
||||
note="ZIM files only. Large files (up to 20 GB) are supported. For best results, upload from the same machine or over a stable LAN connection. Larger files should be copied directly to the storage volume"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,9 +10,19 @@ import api from '~/lib/api'
|
|||
import {
|
||||
groupAndSortKbFiles,
|
||||
type KbFileGroup,
|
||||
type KbFileSort,
|
||||
type KbFileSortKey,
|
||||
} from '~/lib/kb_file_grouping'
|
||||
import type { KbIngestStateValue } from '../../../types/kb_ingest_state'
|
||||
import { IconX } from '@tabler/icons-react'
|
||||
import { formatBytes } from '~/lib/util'
|
||||
import {
|
||||
IconArrowsSort,
|
||||
IconDownload,
|
||||
IconEye,
|
||||
IconSortAscending,
|
||||
IconSortDescending,
|
||||
IconX,
|
||||
} from '@tabler/icons-react'
|
||||
import { useModals } from '~/context/ModalContext'
|
||||
import StyledModal from '../StyledModal'
|
||||
import ActiveEmbedJobs from '~/components/ActiveEmbedJobs'
|
||||
|
|
@ -23,6 +33,42 @@ interface KnowledgeBaseModalProps {
|
|||
onClose: () => void
|
||||
}
|
||||
|
||||
// File extensions the in-browser viewer can render. Must stay in sync with
|
||||
// `RagService.VIEWABLE_TEXT_EXTENSIONS` — anything outside this set falls back
|
||||
// to Download.
|
||||
const VIEWABLE_EXTENSIONS = new Set(['md', 'txt', 'csv', 'json', 'yaml', 'yml', 'toml', 'xml', 'html'])
|
||||
|
||||
function isViewableExtension(filename: string): boolean {
|
||||
const ext = filename.split('.').at(-1)?.toLowerCase() ?? ''
|
||||
return VIEWABLE_EXTENSIONS.has(ext)
|
||||
}
|
||||
|
||||
function renderSortHeader(
|
||||
label: string,
|
||||
key: KbFileSortKey,
|
||||
sort: KbFileSort,
|
||||
setSort: (s: KbFileSort) => void
|
||||
): React.ReactNode {
|
||||
const active = sort.key === key
|
||||
const Icon = !active ? IconArrowsSort : sort.direction === 'asc' ? IconSortAscending : IconSortDescending
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-left hover:text-text-primary transition-colors"
|
||||
onClick={() => {
|
||||
if (!active) {
|
||||
setSort({ key, direction: 'asc' })
|
||||
} else {
|
||||
setSort({ key, direction: sort.direction === 'asc' ? 'desc' : 'asc' })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<Icon size={14} className={active ? 'text-text-primary' : 'text-text-muted'} aria-hidden="true" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact label for the per-row ingestion state. Files that exist in Qdrant
|
||||
* with no `kb_ingest_state` row (`state === null`) are legacy/pre-RFC-883
|
||||
|
|
@ -101,6 +147,8 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
const [confirmReembed, setConfirmReembed] = useState<{ source: string; displayName: string } | null>(null)
|
||||
const [bulkMode, setBulkMode] = useState<null | 'reembed' | 'reset'>(null)
|
||||
const [resetTyped, setResetTyped] = useState('')
|
||||
const [sort, setSort] = useState<KbFileSort>({ key: 'name', direction: 'asc' })
|
||||
const [viewerSource, setViewerSource] = useState<string | null>(null)
|
||||
const fileUploaderRef = useRef<React.ComponentRef<typeof FileUploader>>(null)
|
||||
const { openModal, closeModal } = useModals()
|
||||
const queryClient = useQueryClient()
|
||||
|
|
@ -214,6 +262,20 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
},
|
||||
})
|
||||
|
||||
const cancelAllMutation = useMutation({
|
||||
mutationFn: () => api.cancelAllEmbedJobs(),
|
||||
onSuccess: (data) => {
|
||||
addNotification({ type: 'success', message: data?.message || 'All embedding jobs cancelled.' })
|
||||
queryClient.invalidateQueries({ queryKey: ['embed-jobs'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['failedEmbedJobs'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['storedFiles'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['kbFileWarnings'] })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
addNotification({ type: 'error', message: error?.message || 'Failed to cancel jobs.' })
|
||||
},
|
||||
})
|
||||
|
||||
const startQdrantMutation = useMutation({
|
||||
mutationFn: () => api.affectService(SERVICE_NAMES.QDRANT, 'start'),
|
||||
onSuccess: () => {
|
||||
|
|
@ -310,6 +372,31 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
}
|
||||
}
|
||||
|
||||
const handleConfirmCancelAll = () => {
|
||||
openModal(
|
||||
<StyledModal
|
||||
title='Cancel All Embedding Jobs?'
|
||||
onConfirm={() => {
|
||||
cancelAllMutation.mutate()
|
||||
closeModal('confirm-cancel-all-modal')
|
||||
}}
|
||||
onCancel={() => closeModal('confirm-cancel-all-modal')}
|
||||
open={true}
|
||||
confirmText='Cancel All Jobs'
|
||||
cancelText='Keep Jobs'
|
||||
confirmVariant='danger'
|
||||
>
|
||||
<p className='text-text-primary'>
|
||||
This stops <strong>every</strong> embedding job — including ones still in progress or
|
||||
stuck — and clears the processing queue. The uploaded source files for those jobs are
|
||||
deleted, so you'll need to re-upload anything you still want indexed. Stored files that
|
||||
already finished embedding are not affected. Are you sure you want to proceed?
|
||||
</p>
|
||||
</StyledModal>,
|
||||
'confirm-cancel-all-modal'
|
||||
)
|
||||
}
|
||||
|
||||
const handleConfirmSync = () => {
|
||||
openModal(
|
||||
<StyledModal
|
||||
|
|
@ -485,18 +572,33 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
</div>
|
||||
|
||||
<div className="my-8">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between mb-4 gap-2 flex-wrap">
|
||||
<StyledSectionHeader title="Processing Queue" className="!mb-0" />
|
||||
<StyledButton
|
||||
variant="danger"
|
||||
size="md"
|
||||
icon="IconTrash"
|
||||
onClick={() => cleanupFailedMutation.mutate()}
|
||||
loading={cleanupFailedMutation.isPending}
|
||||
disabled={cleanupFailedMutation.isPending || qdrantOffline}
|
||||
>
|
||||
Clean Up Failed
|
||||
</StyledButton>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<StyledButton
|
||||
variant="danger"
|
||||
size="md"
|
||||
icon="IconTrash"
|
||||
onClick={() => cleanupFailedMutation.mutate()}
|
||||
loading={cleanupFailedMutation.isPending}
|
||||
disabled={cleanupFailedMutation.isPending || qdrantOffline}
|
||||
>
|
||||
Clean Up Failed
|
||||
</StyledButton>
|
||||
{/* Not gated on qdrantOffline: clearing stuck jobs must work during
|
||||
a Qdrant/Ollama outage, which is exactly when they wedge. */}
|
||||
<StyledButton
|
||||
variant="danger"
|
||||
size="md"
|
||||
icon="IconPlayerStop"
|
||||
onClick={handleConfirmCancelAll}
|
||||
loading={cancelAllMutation.isPending}
|
||||
disabled={cancelAllMutation.isPending}
|
||||
title="Stop and clear every embedding job regardless of state, including stuck or in-progress ones. Deletes the uploaded source files for those jobs."
|
||||
>
|
||||
Cancel All Jobs
|
||||
</StyledButton>
|
||||
</div>
|
||||
</div>
|
||||
<ActiveEmbedJobs withHeader={false} />
|
||||
</div>
|
||||
|
|
@ -555,7 +657,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
columns={[
|
||||
{
|
||||
accessor: 'source',
|
||||
title: 'File Name',
|
||||
title: renderSortHeader('File Name', 'name', sort, setSort),
|
||||
render(record) {
|
||||
const warnings = fileWarnings[record.source] ?? []
|
||||
const pill = renderStatePill(record)
|
||||
|
|
@ -594,6 +696,35 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: 'size',
|
||||
title: renderSortHeader('Size', 'size', sort, setSort),
|
||||
className: 'whitespace-nowrap',
|
||||
render(record) {
|
||||
// The collapsed admin_docs group has no single size — leave blank
|
||||
// rather than misleadingly summing across N files.
|
||||
if (record.bucket === 'admin_docs' || record.size === null) {
|
||||
return <span className="text-text-muted">—</span>
|
||||
}
|
||||
return <span className="text-text-secondary">{formatBytes(record.size)}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: 'uploadedAt',
|
||||
title: renderSortHeader('Uploaded', 'uploadedAt', sort, setSort),
|
||||
className: 'whitespace-nowrap',
|
||||
render(record) {
|
||||
if (record.bucket === 'admin_docs' || !record.uploadedAt) {
|
||||
return <span className="text-text-muted">—</span>
|
||||
}
|
||||
const d = new Date(record.uploadedAt)
|
||||
return (
|
||||
<span className="text-text-secondary" title={d.toISOString()}>
|
||||
{d.toLocaleDateString()}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: 'source',
|
||||
title: '',
|
||||
|
|
@ -642,6 +773,9 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
const actionPendingForThisRow =
|
||||
embedMutation.isPending && embedMutation.variables?.source === record.source
|
||||
|
||||
const canView = record.isUserUpload && isViewableExtension(record.displayName) && record.size !== null
|
||||
const canDownload = record.isUserUpload && record.size !== null
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center gap-2">
|
||||
{action && (
|
||||
|
|
@ -662,6 +796,24 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
{action.label}
|
||||
</StyledButton>
|
||||
)}
|
||||
{canView && (
|
||||
<StyledButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="IconEye"
|
||||
onClick={() => setViewerSource(record.source)}
|
||||
>View</StyledButton>
|
||||
)}
|
||||
{canDownload && (
|
||||
<StyledButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon="IconDownload"
|
||||
onClick={() => {
|
||||
window.location.href = `/api/rag/files/download?source=${encodeURIComponent(record.source)}`
|
||||
}}
|
||||
>Download</StyledButton>
|
||||
)}
|
||||
<StyledButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
|
|
@ -675,7 +827,7 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
},
|
||||
},
|
||||
]}
|
||||
data={groupAndSortKbFiles(storedFiles)}
|
||||
data={groupAndSortKbFiles(storedFiles, sort)}
|
||||
loading={isLoadingFiles}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -807,6 +959,57 @@ export default function KnowledgeBaseModal({ aiAssistantName = "AI Assistant", o
|
|||
</div>
|
||||
</StyledModal>
|
||||
)}
|
||||
|
||||
{viewerSource && (
|
||||
<FileViewerModal
|
||||
source={viewerSource}
|
||||
onClose={() => setViewerSource(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FileViewerModal({ source, onClose }: { source: string; onClose: () => void }) {
|
||||
const { data, isLoading, isFetched } = useQuery({
|
||||
queryKey: ['rag', 'file-content', source],
|
||||
queryFn: () => api.getFileContent(source),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
// Title falls back to the trailing path segment so the modal still has a
|
||||
// useful header while the fetch is in-flight or if it failed.
|
||||
const fallbackName = source.split(/[/\\]/).at(-1) ?? source
|
||||
const title = data?.fileName ?? fallbackName
|
||||
// `catchInternal` swallows errors and resolves to undefined, surfacing a
|
||||
// toast — so the "couldn't load" branch is gated on a finished-but-empty
|
||||
// fetch rather than on react-query's `isError`.
|
||||
const showError = isFetched && !data
|
||||
|
||||
return (
|
||||
<StyledModal
|
||||
title={title}
|
||||
open={true}
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
cancelText="Close"
|
||||
large
|
||||
>
|
||||
<div className="text-left text-sm">
|
||||
{isLoading && (
|
||||
<div className="text-text-secondary">Loading…</div>
|
||||
)}
|
||||
{showError && (
|
||||
<div className="text-amber-700 dark:text-amber-300">
|
||||
Couldn't load file. It may have been moved or its type isn't viewable.
|
||||
</div>
|
||||
)}
|
||||
{data && (
|
||||
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded border border-border-subtle bg-surface-secondary p-3 font-mono text-xs text-text-primary">
|
||||
{data.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</StyledModal>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import {
|
||||
IconAdjustments,
|
||||
IconArrowBigUpLines,
|
||||
IconBox,
|
||||
IconChartBar,
|
||||
IconDashboard,
|
||||
IconFolder,
|
||||
|
|
@ -7,7 +9,6 @@ import {
|
|||
IconHeart,
|
||||
IconMapRoute,
|
||||
IconSettings,
|
||||
IconTerminal2,
|
||||
IconWand,
|
||||
IconZoom
|
||||
} from '@tabler/icons-react'
|
||||
|
|
@ -23,7 +24,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
|||
|
||||
const navigation = [
|
||||
...(aiAssistantInstallStatus.isInstalled ? [{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false }] : []),
|
||||
{ name: 'Supply Depot', href: '/supply-depot', icon: IconTerminal2, current: false },
|
||||
{ name: 'Supply Depot', href: '/supply-depot', icon: IconBox, current: false },
|
||||
{ name: 'Benchmark', href: '/settings/benchmark', icon: IconChartBar, current: false },
|
||||
{ name: 'Content Explorer', href: '/settings/zim/remote-explorer', icon: IconZoom, current: false },
|
||||
{ name: 'Content Manager', href: '/settings/zim', icon: IconFolder, current: false },
|
||||
|
|
@ -42,6 +43,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
|||
current: false,
|
||||
},
|
||||
{ name: 'System', href: '/settings/system', icon: IconSettings, current: false },
|
||||
{ name: 'Advanced', href: '/settings/advanced', icon: IconAdjustments, current: false },
|
||||
{ name: 'Support the Project', href: '/settings/support', icon: IconHeart, current: false },
|
||||
{ name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },
|
||||
]
|
||||
|
|
|
|||
|
|
@ -479,6 +479,13 @@ class API {
|
|||
})()
|
||||
}
|
||||
|
||||
async cancelAllEmbedJobs(): Promise<{ message: string; cancelled: number; filesDeleted: number } | undefined> {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.delete<{ message: string; cancelled: number; filesDeleted: number }>('/rag/jobs')
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async checkRAGHealth() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{ online: boolean; message?: string }>('/rag/health')
|
||||
|
|
@ -514,6 +521,17 @@ class API {
|
|||
})()
|
||||
}
|
||||
|
||||
async getFileContent(source: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<{
|
||||
content: string
|
||||
extension: string
|
||||
fileName: string
|
||||
}>('/rag/files/content', { params: { source } })
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async getSystemInfo() {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.get<SystemInformationResponse>('/system/info')
|
||||
|
|
@ -1078,6 +1096,16 @@ class API {
|
|||
})()
|
||||
}
|
||||
|
||||
async uninstallService(service_name: string, remove_image = false) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{ success: boolean; message: string }>(
|
||||
'/system/services/uninstall',
|
||||
{ service_name, remove_image }
|
||||
)
|
||||
return response.data
|
||||
})()
|
||||
}
|
||||
|
||||
async updateCustomAppImage(service_name: string) {
|
||||
return catchInternal(async () => {
|
||||
const response = await this.client.post<{ success: boolean; message: string }>(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
IconAntenna,
|
||||
IconArrowUp,
|
||||
IconBook,
|
||||
IconBooks,
|
||||
|
|
@ -72,6 +73,7 @@ import {
|
|||
* very limited subset of the full Tabler Icons library.
|
||||
*/
|
||||
export const icons = {
|
||||
IconAntenna,
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
|
|
|
|||
|
|
@ -52,19 +52,62 @@ export interface KbFileGroup {
|
|||
/** Chunks currently embedded for this source; 0 for state-row-less or
|
||||
* zero-chunk files. Always 0 for the collapsed admin_docs group. */
|
||||
chunksEmbedded: number
|
||||
/** File size in bytes from disk. Null for the collapsed admin_docs group,
|
||||
* and for any file the scanner couldn't stat. */
|
||||
size: number | null
|
||||
/** Last-modified timestamp (ISO 8601). Null for collapsed groups and for
|
||||
* files the scanner couldn't stat. */
|
||||
uploadedAt: string | null
|
||||
/** True when the row corresponds to a user upload — drives whether the
|
||||
* view/download buttons render. False for the collapsed admin_docs group. */
|
||||
isUserUpload: boolean
|
||||
}
|
||||
|
||||
const BUCKET_SORT_ORDER: KbFileBucket[] = ['zim', 'upload', 'admin_docs', 'other']
|
||||
|
||||
export type KbFileSortKey = 'name' | 'size' | 'uploadedAt'
|
||||
export type KbFileSortDirection = 'asc' | 'desc'
|
||||
export interface KbFileSort {
|
||||
key: KbFileSortKey
|
||||
direction: KbFileSortDirection
|
||||
}
|
||||
|
||||
const DEFAULT_SORT: KbFileSort = { key: 'name', direction: 'asc' }
|
||||
|
||||
function compareForSort(a: StoredFileInfo, b: StoredFileInfo, sort: KbFileSort): number {
|
||||
// Files the scanner couldn't stat sort to the end regardless of direction so
|
||||
// they don't pollute the top of size/uploaded-at views.
|
||||
const aMissing = sort.key !== 'name' && (sort.key === 'size' ? a.size === null : a.uploadedAt === null)
|
||||
const bMissing = sort.key !== 'name' && (sort.key === 'size' ? b.size === null : b.uploadedAt === null)
|
||||
if (aMissing && !bMissing) return 1
|
||||
if (!aMissing && bMissing) return -1
|
||||
|
||||
let cmp = 0
|
||||
if (sort.key === 'size') {
|
||||
cmp = (a.size ?? 0) - (b.size ?? 0)
|
||||
} else if (sort.key === 'uploadedAt') {
|
||||
cmp = (a.uploadedAt ?? '').localeCompare(b.uploadedAt ?? '')
|
||||
}
|
||||
if (cmp === 0) {
|
||||
// Tiebreak (and primary key for 'name') is filename — keeps stable order.
|
||||
cmp = sourceToDisplayName(a.source).localeCompare(sourceToDisplayName(b.source))
|
||||
}
|
||||
return sort.direction === 'desc' ? -cmp : cmp
|
||||
}
|
||||
|
||||
/**
|
||||
* Group stored-file rows into table rows for the Stored Files panel.
|
||||
*
|
||||
* - Admin docs (`/app/docs/*`, README) collapse into a single
|
||||
* "Project NOMAD documentation · N files" row.
|
||||
* - ZIMs, uploads, and others stay as individual rows, sorted by bucket then
|
||||
* alphabetically by filename so related items cluster naturally.
|
||||
* - ZIMs, uploads, and others stay as individual rows, sorted within their
|
||||
* bucket by the active sort key. Bucket order itself is fixed — sorting
|
||||
* never flattens or reorders the groups themselves.
|
||||
*/
|
||||
export function groupAndSortKbFiles(files: StoredFileInfo[]): KbFileGroup[] {
|
||||
export function groupAndSortKbFiles(
|
||||
files: StoredFileInfo[],
|
||||
sort: KbFileSort = DEFAULT_SORT
|
||||
): KbFileGroup[] {
|
||||
const buckets: Record<KbFileBucket, StoredFileInfo[]> = {
|
||||
zim: [],
|
||||
upload: [],
|
||||
|
|
@ -90,13 +133,14 @@ export function groupAndSortKbFiles(files: StoredFileInfo[]): KbFileGroup[] {
|
|||
members: members.map((m) => m.source),
|
||||
state: null,
|
||||
chunksEmbedded: 0,
|
||||
size: null,
|
||||
uploadedAt: null,
|
||||
isUserUpload: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
for (const file of members.sort((a, b) =>
|
||||
sourceToDisplayName(a.source).localeCompare(sourceToDisplayName(b.source))
|
||||
)) {
|
||||
for (const file of members.sort((a, b) => compareForSort(a, b, sort))) {
|
||||
groups.push({
|
||||
bucket,
|
||||
source: file.source,
|
||||
|
|
@ -105,6 +149,9 @@ export function groupAndSortKbFiles(files: StoredFileInfo[]): KbFileGroup[] {
|
|||
members: [],
|
||||
state: file.state,
|
||||
chunksEmbedded: file.chunksEmbedded,
|
||||
size: file.size,
|
||||
uploadedAt: file.uploadedAt,
|
||||
isUserUpload: file.isUserUpload,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ function buildCoreCapabilities(aiAssistantName: string): Capability[] {
|
|||
'Interactive exercises and quizzes',
|
||||
'Progress tracking for learners',
|
||||
],
|
||||
services: [SERVICE_NAMES.KOLIBRI],
|
||||
services: [SERVICE_NAMES.KOLIBRI_GEN2],
|
||||
icon: 'IconSchool',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {
|
||||
IconBolt,
|
||||
IconBox,
|
||||
IconHelp,
|
||||
IconMapRoute,
|
||||
IconPlus,
|
||||
IconSettings,
|
||||
IconWifiOff,
|
||||
} from '@tabler/icons-react'
|
||||
|
|
@ -46,7 +46,7 @@ const SYSTEM_ITEMS = [
|
|||
to: '/supply-depot',
|
||||
target: '',
|
||||
description: 'Browse and install curated apps, or add your own Docker container',
|
||||
icon: <IconPlus size={48} />,
|
||||
icon: <IconBox size={48} />,
|
||||
installed: true,
|
||||
displayOrder: 51,
|
||||
poweredBy: null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
import { Head } from '@inertiajs/react'
|
||||
import { useState } from 'react'
|
||||
import SettingsLayout from '~/layouts/SettingsLayout'
|
||||
import StyledButton from '~/components/StyledButton'
|
||||
import StyledSectionHeader from '~/components/StyledSectionHeader'
|
||||
import Alert from '~/components/Alert'
|
||||
import Input from '~/components/inputs/Input'
|
||||
import { useNotifications } from '~/context/NotificationContext'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import api from '~/lib/api'
|
||||
|
||||
export default function AdvancedPage(props: {
|
||||
advanced: {
|
||||
internetStatusTestUrl: string
|
||||
internetStatusTestUrlEnvOverride: boolean
|
||||
}
|
||||
}) {
|
||||
const { addNotification } = useNotifications()
|
||||
const { internetStatusTestUrlEnvOverride } = props.advanced
|
||||
|
||||
const [internetStatusTestUrl, setInternetStatusTestUrl] = useState(
|
||||
props.advanced.internetStatusTestUrl ?? ''
|
||||
)
|
||||
const [testUrlError, setTestUrlError] = useState<string | null>(null)
|
||||
|
||||
// Mirror the backend validation (admin/app/validators/settings.ts) for instant
|
||||
// feedback. The backend remains the source of truth and returns 422 on failure.
|
||||
function validateTestUrl(value: string): string | null {
|
||||
if (value.trim() === '') return null // empty clears the setting
|
||||
try {
|
||||
const url = new URL(value)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return 'Test URL must use http or https.'
|
||||
}
|
||||
} catch {
|
||||
return 'Test URL must be a valid URL (e.g. "https://example.com").'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const updateTestUrlMutation = useMutation({
|
||||
mutationFn: async (value: string) => {
|
||||
return await api.updateSetting('system.internetStatusTestUrl', value)
|
||||
},
|
||||
onSuccess: () => {
|
||||
addNotification({ message: 'Setting updated successfully.', type: 'success' })
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const msg =
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
'There was an error updating the setting. Please try again.'
|
||||
setTestUrlError(msg)
|
||||
addNotification({ message: msg, type: 'error' })
|
||||
},
|
||||
})
|
||||
|
||||
function handleSaveTestUrl() {
|
||||
const trimmed = internetStatusTestUrl.trim()
|
||||
const validationError = validateTestUrl(trimmed)
|
||||
if (validationError) {
|
||||
setTestUrlError(validationError)
|
||||
return
|
||||
}
|
||||
setTestUrlError(null)
|
||||
updateTestUrlMutation.mutate(trimmed)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Head title="Advanced Settings | Project N.O.M.A.D." />
|
||||
<div className="xl:pl-72 w-full">
|
||||
<main className="px-12 py-6">
|
||||
<h1 className="text-4xl font-semibold mb-4">Advanced</h1>
|
||||
<p className="text-text-muted mb-4">
|
||||
Advanced configuration for operators. These settings are optional — the defaults work
|
||||
for most deployments.
|
||||
</p>
|
||||
|
||||
<StyledSectionHeader title="Connectivity" className="mt-8 mb-4" />
|
||||
<div className="bg-surface-primary rounded-lg border-2 border-border-subtle p-6">
|
||||
<p className="text-sm text-text-secondary mb-4">
|
||||
N.O.M.A.D. periodically checks whether it can reach the internet. By default it probes
|
||||
Cloudflare's utility endpoint with a few fallbacks. Set a custom endpoint below if your
|
||||
network blocks the defaults. Leave blank to use the built-in defaults.
|
||||
</p>
|
||||
|
||||
{internetStatusTestUrlEnvOverride && (
|
||||
<Alert
|
||||
type="info"
|
||||
variant="bordered"
|
||||
title="Managed by environment variable"
|
||||
message="The INTERNET_STATUS_TEST_URL environment variable is set and takes precedence over this setting. Remove it to manage the test URL here."
|
||||
className="!mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
name="internetStatusTestUrl"
|
||||
label="Internet Status Test URL"
|
||||
helpText="A single http(s) URL used to check connectivity. Any HTTP response counts as online."
|
||||
placeholder="https://1.1.1.1/cdn-cgi/trace"
|
||||
value={internetStatusTestUrl}
|
||||
disabled={internetStatusTestUrlEnvOverride}
|
||||
error={Boolean(testUrlError)}
|
||||
onChange={(e) => {
|
||||
setInternetStatusTestUrl(e.target.value)
|
||||
setTestUrlError(null)
|
||||
}}
|
||||
/>
|
||||
{testUrlError && <p className="text-sm text-red-600 mt-1">{testUrlError}</p>}
|
||||
</div>
|
||||
<StyledButton
|
||||
variant="primary"
|
||||
onClick={handleSaveTestUrl}
|
||||
loading={updateTestUrlMutation.isPending}
|
||||
disabled={updateTestUrlMutation.isPending || internetStatusTestUrlEnvOverride}
|
||||
className="mb-0.5"
|
||||
>
|
||||
Save
|
||||
</StyledButton>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import StyledModal from '~/components/StyledModal'
|
|||
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
|
||||
import Alert from '~/components/Alert'
|
||||
import { useNotifications } from '~/context/NotificationContext'
|
||||
import ZimUploader from '~/components/ZimUploader'
|
||||
import { ZimFileWithMetadata } from '../../../../types/zim'
|
||||
import { SERVICE_NAMES } from '../../../../constants/service_names'
|
||||
import { formatBytes } from '~/lib/util'
|
||||
|
|
@ -25,9 +26,11 @@ export default function ZimPage() {
|
|||
const { isInstalled } = useServiceInstalledStatus(SERVICE_NAMES.KIWIX)
|
||||
const [sortKey, setSortKey] = useState<SortKey>('size')
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
|
||||
const [showUploader, setShowUploader] = useState(false)
|
||||
const { data, isLoading } = useQuery<ZimFileWithMetadata[]>({
|
||||
queryKey: ['zim-files'],
|
||||
queryFn: getFiles,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
async function getFiles() {
|
||||
|
|
@ -135,18 +138,50 @@ export default function ZimPage() {
|
|||
Manage your stored content files.
|
||||
</p>
|
||||
</div>
|
||||
{isInstalled && (
|
||||
<div className="flex items-center gap-2">
|
||||
<StyledButton
|
||||
variant="secondary"
|
||||
icon={'IconRefresh'}
|
||||
loading={rescanMutation.isPending}
|
||||
title="Rebuild the Kiwix library index from the files on disk. Use this after manually adding ZIM files outside of NOMAD."
|
||||
onClick={() => rescanMutation.mutate()}
|
||||
icon={showUploader ? 'IconX' : 'IconUpload'}
|
||||
onClick={() => setShowUploader((v) => !v)}
|
||||
>
|
||||
Rescan Library
|
||||
{showUploader ? 'Hide Uploader' : 'Upload ZIM File'}
|
||||
</StyledButton>
|
||||
)}
|
||||
{isInstalled && (
|
||||
<StyledButton
|
||||
variant="secondary"
|
||||
icon={'IconRefresh'}
|
||||
loading={rescanMutation.isPending}
|
||||
title="Rebuild the Kiwix library index from the files on disk. Use this after manually adding ZIM files outside of NOMAD."
|
||||
onClick={() => rescanMutation.mutate()}
|
||||
>
|
||||
Rescan Library
|
||||
</StyledButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showUploader && (
|
||||
<div className="mt-6">
|
||||
<p className="text-text-muted text-sm mb-3">
|
||||
Upload a ZIM file from your browser. Files up to 20 GB are supported. For best results upload from the same machine or over a stable LAN connection. Larger files should be copied directly to the storage volume.
|
||||
</p>
|
||||
<ZimUploader
|
||||
existingFilenames={data?.map((f) => f.name) ?? []}
|
||||
onUploadComplete={(added) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['zim-files'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['wikipedia-state'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['curated-categories'] })
|
||||
setShowUploader(false)
|
||||
addNotification({
|
||||
type: 'success',
|
||||
message:
|
||||
added > 0
|
||||
? `Upload complete. ${added} new ${added === 1 ? 'book' : 'books'} added to the library.`
|
||||
: 'Upload complete. Library is up to date.',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isInstalled && (
|
||||
<Alert
|
||||
title="The Kiwix application is not installed. Please install it to view downloaded ZIM files"
|
||||
|
|
|
|||
|
|
@ -40,10 +40,12 @@ import type { CategoryWithStatus, SpecTier } from '../../../../types/collections
|
|||
import useDownloads from '~/hooks/useDownloads'
|
||||
import ActiveDownloads from '~/components/ActiveDownloads'
|
||||
import { SERVICE_NAMES } from '../../../../constants/service_names'
|
||||
import { ZimFileWithMetadata } from '../../../../types/zim'
|
||||
|
||||
const CURATED_CATEGORIES_KEY = 'curated-categories'
|
||||
const WIKIPEDIA_STATE_KEY = 'wikipedia-state'
|
||||
const CUSTOM_LIBRARIES_KEY = 'custom-libraries'
|
||||
const ZIM_FILES_KEY = 'zim-files'
|
||||
|
||||
type CustomLibrary = { id: number; name: string; base_url: string; is_default: boolean }
|
||||
type BrowseResult = {
|
||||
|
|
@ -104,6 +106,15 @@ export default function ZimRemoteExplorer() {
|
|||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { data: localFiles } = useQuery<ZimFileWithMetadata[]>({
|
||||
queryKey: [ZIM_FILES_KEY],
|
||||
queryFn: async () => {
|
||||
const res = await api.listZimFiles()
|
||||
return res.data.files
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
const { data: downloads, invalidate: invalidateDownloads } = useDownloads({
|
||||
filetype: 'zim',
|
||||
enabled: true,
|
||||
|
|
@ -152,15 +163,16 @@ export default function ZimRemoteExplorer() {
|
|||
|
||||
const flatData = useMemo(() => {
|
||||
const mapped = data?.pages.flatMap((page) => page.items) || []
|
||||
// remove items that are currently downloading
|
||||
const localNames = new Set(localFiles?.map((f) => f.name) ?? [])
|
||||
return mapped.filter((item) => {
|
||||
const isDownloading = downloads?.some((download) => {
|
||||
const filename = item.download_url.split('/').pop()
|
||||
return filename && download.filepath.endsWith(filename)
|
||||
})
|
||||
return !isDownloading
|
||||
const isPresent = localNames.has(item.file_name)
|
||||
return !isDownloading && !isPresent
|
||||
})
|
||||
}, [data, downloads])
|
||||
}, [data, downloads, localFiles])
|
||||
const hasMore = useMemo(() => data?.pages[data.pages.length - 1]?.has_more || false, [data])
|
||||
|
||||
const fetchOnBottomReached = useCallback(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Head, router } from '@inertiajs/react'
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowRight,
|
||||
IconArrowUp,
|
||||
IconBook,
|
||||
IconBox,
|
||||
|
|
@ -44,6 +45,7 @@ import { getServiceLink } from '~/lib/navigation'
|
|||
import { getSupplyDepotDocLink } from '../../constants/supply_depot_docs'
|
||||
import api from '~/lib/api'
|
||||
import { toTitleCase } from '../../app/utils/misc'
|
||||
import { SERVICE_NAMES } from '../../constants/service_names'
|
||||
|
||||
function extractTag(containerImage: string): string {
|
||||
if (!containerImage) return ''
|
||||
|
|
@ -82,6 +84,7 @@ type Modal =
|
|||
| { type: 'restart'; service: ServiceSlim }
|
||||
| { type: 'reinstall'; service: ServiceSlim }
|
||||
| { type: 'delete'; service: ServiceSlim }
|
||||
| { type: 'uninstall'; service: ServiceSlim }
|
||||
| { type: 'logs'; service: ServiceSlim }
|
||||
| { type: 'stats'; service: ServiceSlim }
|
||||
| { type: 'update'; service: ServiceSlim }
|
||||
|
|
@ -167,7 +170,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
.then((res) => {
|
||||
if (res) setPreflight(res)
|
||||
})
|
||||
.catch(() => {}) // non-fatal; proceed without warnings
|
||||
.catch(() => { }) // non-fatal; proceed without warnings
|
||||
.finally(() => setPreflightLoading(false))
|
||||
}, [modal])
|
||||
|
||||
|
|
@ -192,6 +195,12 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
const installedServices = filteredServices.filter((s) => s.installed)
|
||||
const availableServices = filteredServices.filter((s) => !s.installed)
|
||||
|
||||
// Whether the new Kolibri (Gen 2) install exists — gates the "Migrate content to Gen 2" action on
|
||||
// the legacy Kolibri card. Computed from the full (unfiltered) list so a search filter can't hide it.
|
||||
const educationGen2Installed = props.system.services.some(
|
||||
(s) => s.service_name === SERVICE_NAMES.KOLIBRI_GEN2 && s.installed
|
||||
)
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────────
|
||||
async function handleInstall(service: ServiceSlim) {
|
||||
const hasWarnings =
|
||||
|
|
@ -199,38 +208,61 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
|
||||
if (hasWarnings && !forceInstall) return
|
||||
|
||||
// Keep the modal open with a spinning confirm button while the request is in
|
||||
// flight; close it once the install job is dispatched. Progress then streams
|
||||
// via the InstallActivityFeed broadcast, so we drop the loading flag here.
|
||||
setLoading(true)
|
||||
setModal(null)
|
||||
const result = await api.installService(service.service_name)
|
||||
setModal(null)
|
||||
setLoading(false)
|
||||
if (!result?.success) showError(result?.message || 'Failed to start installation.')
|
||||
}
|
||||
|
||||
async function handleAffect(service: ServiceSlim, action: 'start' | 'stop' | 'restart') {
|
||||
setModal(null)
|
||||
setLoading(true)
|
||||
const result = await api.affectService(service.service_name, action)
|
||||
setLoading(false)
|
||||
if (!result?.success) showError(result?.message || `Failed to ${action} service.`)
|
||||
else setTimeout(() => window.location.reload(), 1500)
|
||||
setModal(null)
|
||||
if (!result?.success) {
|
||||
setLoading(false)
|
||||
showError(result?.message || `Failed to ${action} service.`)
|
||||
} else {
|
||||
// Keep loading=true so the overlay covers the page until it reloads.
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleForceReinstall(service: ServiceSlim) {
|
||||
setModal(null)
|
||||
setLoading(true)
|
||||
const result = await api.forceReinstallService(service.service_name)
|
||||
setModal(null)
|
||||
setLoading(false)
|
||||
if (!result?.success) showError(result?.message || 'Failed to start reinstall.')
|
||||
}
|
||||
|
||||
async function handleDelete(service: ServiceSlim) {
|
||||
setModal(null)
|
||||
setLoading(true)
|
||||
const result = await api.deleteCustomApp(service.service_name, removeImage)
|
||||
setRemoveImage(false)
|
||||
setLoading(false)
|
||||
if (!result?.success) showError(result?.message || 'Failed to delete app.')
|
||||
else setTimeout(() => window.location.reload(), 1000)
|
||||
setModal(null)
|
||||
if (!result?.success) {
|
||||
setLoading(false)
|
||||
showError(result?.message || 'Failed to delete app.')
|
||||
} else {
|
||||
setTimeout(() => window.location.reload(), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUninstall(service: ServiceSlim) {
|
||||
setLoading(true)
|
||||
const result = await api.uninstallService(service.service_name, removeImage)
|
||||
setRemoveImage(false)
|
||||
setModal(null)
|
||||
if (!result?.success) {
|
||||
setLoading(false)
|
||||
showError(result?.message || 'Failed to uninstall app.')
|
||||
} else {
|
||||
setTimeout(() => window.location.reload(), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdate(service: ServiceSlim) {
|
||||
|
|
@ -328,9 +360,19 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
<AppLayout>
|
||||
<Head title="Supply Depot" />
|
||||
|
||||
{loading && <LoadingSpinner fullscreen text="Working..." />}
|
||||
{loading && !modal && <LoadingSpinner fullscreen text="Working..." />}
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{!isOnline && (
|
||||
<Alert
|
||||
title="No internet connection. You may not be able to download files."
|
||||
message=""
|
||||
type="warning"
|
||||
variant="solid"
|
||||
className="!mb-4"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Hero / controls panel ─────────────────────────────────────────── */}
|
||||
<div className="rounded-lg overflow-hidden bg-desert-white border border-desert-stone-light shadow-sm mb-8">
|
||||
{/* Green header band */}
|
||||
|
|
@ -351,7 +393,6 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
<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" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-3">
|
||||
<IconBox className="text-white opacity-90 flex-shrink-0" size={28} />
|
||||
<div>
|
||||
|
|
@ -409,11 +450,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors cursor-pointer border ${
|
||||
activeCategory === cat.id
|
||||
? 'bg-desert-green text-white border-desert-green'
|
||||
: 'bg-surface-secondary text-text-muted border-desert-stone-lighter hover:text-text-primary hover:border-desert-stone-light'
|
||||
}`}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors cursor-pointer border ${activeCategory === cat.id
|
||||
? 'bg-desert-green text-white border-desert-green'
|
||||
: 'bg-surface-secondary text-text-muted border-desert-stone-lighter hover:text-text-primary hover:border-desert-stone-light'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
|
|
@ -450,6 +490,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
onRestart={() => setModal({ type: 'restart', service })}
|
||||
onReinstall={() => setModal({ type: 'reinstall', service })}
|
||||
onDelete={() => setModal({ type: 'delete', service })}
|
||||
onUninstall={() => setModal({ type: 'uninstall', service })}
|
||||
onLogs={() => setModal({ type: 'logs', service })}
|
||||
onStats={() => setModal({ type: 'stats', service })}
|
||||
onEdit={() => handleEdit(service)}
|
||||
|
|
@ -461,6 +502,8 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
}
|
||||
autoUpdateMasterEnabled={appAutoUpdateMasterEnabled}
|
||||
onToggleAutoUpdate={(enabled) => handleToggleAutoUpdate(service, enabled)}
|
||||
migrationInstructionsHref={(service.service_name.startsWith(SERVICE_NAMES.KOLIBRI) && educationGen2Installed) ? getSupplyDepotDocLink(SERVICE_NAMES.KOLIBRI) || undefined : undefined}
|
||||
migrationInstructionsText={(service.service_name === SERVICE_NAMES.KOLIBRI) ? 'How to migrate content to Gen 2' : "How to migrate content from Gen 1"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -484,6 +527,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
onRestart={() => setModal({ type: 'restart', service })}
|
||||
onReinstall={() => setModal({ type: 'reinstall', service })}
|
||||
onDelete={() => setModal({ type: 'delete', service })}
|
||||
onUninstall={() => setModal({ type: 'uninstall', service })}
|
||||
onLogs={() => setModal({ type: 'logs', service })}
|
||||
onStats={() => setModal({ type: 'stats', service })}
|
||||
onEdit={() => handleEdit(service)}
|
||||
|
|
@ -506,7 +550,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
<StyledModal
|
||||
title={`Install ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => setModal(null)}
|
||||
onCancel={() => {
|
||||
if (loading) return
|
||||
setModal(null)
|
||||
}}
|
||||
onConfirm={() => handleInstall(modal.service)}
|
||||
confirmText="Install"
|
||||
confirmIcon="IconDownload"
|
||||
|
|
@ -564,7 +611,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
<StyledModal
|
||||
title={`Start ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => setModal(null)}
|
||||
onCancel={() => {
|
||||
if (loading) return
|
||||
setModal(null)
|
||||
}}
|
||||
onConfirm={() => handleAffect(modal.service, 'start')}
|
||||
confirmText="Start"
|
||||
confirmIcon="IconPlayerPlay"
|
||||
|
|
@ -580,7 +630,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
<StyledModal
|
||||
title={`Stop ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => setModal(null)}
|
||||
onCancel={() => {
|
||||
if (loading) return
|
||||
setModal(null)
|
||||
}}
|
||||
onConfirm={() => handleAffect(modal.service, 'stop')}
|
||||
confirmText="Stop"
|
||||
confirmIcon="IconPlayerStop"
|
||||
|
|
@ -596,7 +649,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
<StyledModal
|
||||
title={`Restart ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => setModal(null)}
|
||||
onCancel={() => {
|
||||
if (loading) return
|
||||
setModal(null)
|
||||
}}
|
||||
onConfirm={() => handleAffect(modal.service, 'restart')}
|
||||
confirmText="Restart"
|
||||
confirmIcon="IconRefresh"
|
||||
|
|
@ -612,7 +668,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
<StyledModal
|
||||
title={`Force Reinstall ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => setModal(null)}
|
||||
onCancel={() => {
|
||||
if (loading) return
|
||||
setModal(null)
|
||||
}}
|
||||
onConfirm={() => handleForceReinstall(modal.service)}
|
||||
confirmText="Wipe & Reinstall"
|
||||
confirmIcon="IconRefresh"
|
||||
|
|
@ -633,6 +692,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
title={`Delete ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => {
|
||||
if (loading) return
|
||||
setRemoveImage(false)
|
||||
setModal(null)
|
||||
}}
|
||||
|
|
@ -659,6 +719,39 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
</StyledModal>
|
||||
)}
|
||||
|
||||
{/* Uninstall curated app modal */}
|
||||
{modal?.type === 'uninstall' && (
|
||||
<StyledModal
|
||||
title={`Uninstall ${modal.service.friendly_name ?? modal.service.service_name}`}
|
||||
open
|
||||
onCancel={() => {
|
||||
if (loading) return
|
||||
setRemoveImage(false)
|
||||
setModal(null)
|
||||
}}
|
||||
onConfirm={() => handleUninstall(modal.service)}
|
||||
confirmText="Uninstall"
|
||||
confirmIcon="IconTrash"
|
||||
confirmVariant="danger"
|
||||
confirmLoading={loading}
|
||||
icon={<IconAlertTriangle className="text-desert-red" size={40} />}
|
||||
>
|
||||
<div className="space-y-3 text-sm text-text-muted">
|
||||
<p className="font-semibold text-desert-red">This will remove the app from this device.</p>
|
||||
<p>The container will be stopped and removed, and the app returns to the catalog below. App data under the storage folder stays on disk, so reinstalling brings it back as it was.</p>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeImage}
|
||||
onChange={(e) => setRemoveImage(e.target.checked)}
|
||||
className="accent-desert-red h-4 w-4 rounded"
|
||||
/>
|
||||
<span className="text-text-muted text-xs">Also remove the Docker image to reclaim disk space</span>
|
||||
</label>
|
||||
</div>
|
||||
</StyledModal>
|
||||
)}
|
||||
|
||||
{/* Logs modal */}
|
||||
{modal?.type === 'logs' && (
|
||||
<ServiceLogsModal
|
||||
|
|
@ -685,7 +778,10 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
record={modal.service}
|
||||
currentTag={extractTag(modal.service.container_image)}
|
||||
latestVersion={modal.service.available_update_version!}
|
||||
onCancel={() => setModal(null)}
|
||||
onCancel={() => {
|
||||
if (loading) return
|
||||
setModal(null)
|
||||
}}
|
||||
onUpdate={(targetVersion) => {
|
||||
const service = modal.service
|
||||
setModal(null)
|
||||
|
|
@ -738,6 +834,7 @@ interface AppCardProps {
|
|||
onRestart: () => void
|
||||
onReinstall: () => void
|
||||
onDelete: () => void
|
||||
onUninstall: () => void
|
||||
onLogs: () => void
|
||||
onStats: () => void
|
||||
onEdit: () => void
|
||||
|
|
@ -749,6 +846,8 @@ interface AppCardProps {
|
|||
// Global master switch (Settings → Updates). When off, per-app toggles are inert.
|
||||
autoUpdateMasterEnabled?: boolean
|
||||
onToggleAutoUpdate?: (enabled: boolean) => void
|
||||
migrationInstructionsHref?: string
|
||||
migrationInstructionsText?: string
|
||||
}
|
||||
|
||||
function AppCard({
|
||||
|
|
@ -762,6 +861,7 @@ function AppCard({
|
|||
onRestart,
|
||||
onReinstall,
|
||||
onDelete,
|
||||
onUninstall,
|
||||
onLogs,
|
||||
onStats,
|
||||
onEdit,
|
||||
|
|
@ -771,6 +871,8 @@ function AppCard({
|
|||
autoUpdateEnabled,
|
||||
autoUpdateMasterEnabled,
|
||||
onToggleAutoUpdate,
|
||||
migrationInstructionsHref,
|
||||
migrationInstructionsText,
|
||||
}: AppCardProps) {
|
||||
const isRunning = service.status === 'running'
|
||||
const isStopped = service.installed && !isRunning
|
||||
|
|
@ -805,11 +907,10 @@ function AppCard({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`relative flex flex-col rounded-xl border p-4 bg-surface-primary shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${
|
||||
service.installed
|
||||
? 'border-desert-stone-light'
|
||||
: 'border-desert-stone-lighter hover:border-desert-stone-light'
|
||||
}`}
|
||||
className={`relative flex flex-col rounded-xl border p-4 bg-surface-primary shadow-sm transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${service.installed
|
||||
? 'border-desert-stone-light'
|
||||
: 'border-desert-stone-lighter hover:border-desert-stone-light'
|
||||
}`}
|
||||
>
|
||||
{/* Installed accent spine (rounded to follow the card corners — the card no longer clips
|
||||
overflow so the Manage dropdown can open above the card without being cut off) */}
|
||||
|
|
@ -883,6 +984,14 @@ function AppCard({
|
|||
modified
|
||||
</span>
|
||||
) : null}
|
||||
{service.is_deprecated ? (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded-full font-medium bg-desert-orange-lighter text-desert-orange-dark border border-desert-orange-light"
|
||||
title="This is a legacy version that's no longer maintained. Install the current Education Platform from the catalog, then uninstall this one."
|
||||
>
|
||||
legacy
|
||||
</span>
|
||||
) : null}
|
||||
{uiPort && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-secondary text-text-muted font-mono">
|
||||
{uiIsHttps ? '🔒 ' : ''}:{uiPort}
|
||||
|
|
@ -920,7 +1029,7 @@ function AppCard({
|
|||
{/* Open button — shown when the app has a default location or a user-set custom URL */}
|
||||
{(service.ui_location || service.custom_url) && (
|
||||
<a
|
||||
href={getServiceLink(service.ui_location, service.custom_url)}
|
||||
href={getServiceLink(service.ui_location || "", service.custom_url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1"
|
||||
|
|
@ -962,6 +1071,20 @@ function AppCard({
|
|||
<DropdownItem icon={<IconChartBar className="h-4 w-4" />} label="Stats" onClick={onStats} />
|
||||
<DropdownItem icon={<IconPencil className="h-4 w-4" />} label="Edit" onClick={onEdit} />
|
||||
<DropdownItem icon={<IconWorld className="h-4 w-4" />} label="Set custom URL" onClick={onSetUrl} />
|
||||
{
|
||||
migrationInstructionsHref ? (
|
||||
<a
|
||||
href={migrationInstructionsHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer text-text-primary hover:bg-surface-secondary"
|
||||
>
|
||||
<IconBook className="h-4 w-4" />
|
||||
{migrationInstructionsText || 'Migration instructions'}
|
||||
</a>
|
||||
) : (null)
|
||||
}
|
||||
{!service.is_custom && onToggleAutoUpdate ? (
|
||||
autoUpdateMasterEnabled ? (
|
||||
<DropdownItem
|
||||
|
|
@ -994,7 +1117,9 @@ function AppCard({
|
|||
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
|
||||
{service.is_custom ? (
|
||||
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Delete" onClick={onDelete} danger />
|
||||
): null}
|
||||
) : (
|
||||
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Uninstall" onClick={onUninstall} danger />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1030,11 +1155,10 @@ function DropdownItem({
|
|||
e.stopPropagation()
|
||||
onClick()
|
||||
}}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer ${
|
||||
danger
|
||||
? 'text-desert-red hover:bg-desert-red/10'
|
||||
: 'text-text-primary hover:bg-surface-secondary'
|
||||
}`}
|
||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer ${danger
|
||||
? 'text-desert-red hover:bg-desert-red/10'
|
||||
: 'text-text-primary hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"@uppy/core": "5.2.0",
|
||||
"@uppy/dashboard": "5.1.0",
|
||||
"@uppy/react": "5.1.1",
|
||||
"@uppy/xhr-upload": "5.2.0",
|
||||
"@vinejs/vine": "3.0.1",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"autoprefixer": "10.5.0",
|
||||
|
|
@ -47,11 +48,12 @@
|
|||
"edge.js": "6.4.0",
|
||||
"fast-xml-parser": "5.7.0",
|
||||
"fuse.js": "7.1.0",
|
||||
"ioredis": "5.10.1",
|
||||
"ipaddr.js": "2.4.0",
|
||||
"jszip": "3.10.1",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "4.7.1",
|
||||
"mysql2": "3.16.2",
|
||||
"mysql2": "3.22.5",
|
||||
"ollama": "0.6.3",
|
||||
"openai": "6.38.0",
|
||||
"pdf-parse": "2.4.5",
|
||||
|
|
@ -5683,6 +5685,12 @@
|
|||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/retry": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz",
|
||||
"integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
|
|
@ -6029,6 +6037,20 @@
|
|||
"integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@uppy/companion-client": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-5.1.1.tgz",
|
||||
"integrity": "sha512-DzrOWTbIZHvtgAFXBMYHk2wD27NjpBSVhY2tEiEIUhPd2CxbFRZjHM/N3HOt3VwZEAP471QWFLlJRWPcIY3A2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uppy/utils": "^7.1.1",
|
||||
"namespace-emitter": "^2.0.1",
|
||||
"p-retry": "^6.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@uppy/core": "^5.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/components": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/components/-/components-1.2.0.tgz",
|
||||
|
|
@ -6174,6 +6196,19 @@
|
|||
"preact": "^10.26.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@uppy/xhr-upload": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-5.2.0.tgz",
|
||||
"integrity": "sha512-3LV/X5Of6BINnKplP+CwUJ0a4/7cRFfzxwGyXnW+uCrNQHoo09dttcz3begWHejGvzenQHuUnMO3Fxyc71Pryg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@uppy/companion-client": "^5.1.1",
|
||||
"@uppy/utils": "^7.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@uppy/core": "^5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vavite/multibuild": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vavite/multibuild/-/multibuild-5.1.0.tgz",
|
||||
|
|
@ -10513,6 +10548,18 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-network-error": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz",
|
||||
"integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
|
|
@ -12809,9 +12856,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.16.2",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.2.tgz",
|
||||
"integrity": "sha512-JsqBpYNy7pH20lGfPuSyRSIcCxSeAIwxWADpV64nP9KeyN3ZKpHZgjKXuBKsh7dH6FbOvf1bOgoVKjSUPXRMTw==",
|
||||
"version": "3.22.5",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.5.tgz",
|
||||
"integrity": "sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"aws-ssl-profiles": "^1.1.2",
|
||||
|
|
@ -12819,13 +12866,15 @@
|
|||
"generate-function": "^2.3.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"long": "^5.3.2",
|
||||
"lru.min": "^1.1.3",
|
||||
"lru.min": "^1.1.4",
|
||||
"named-placeholders": "^1.1.6",
|
||||
"seq-queue": "^0.0.5",
|
||||
"sqlstring": "^2.3.3"
|
||||
"sql-escaper": "^1.3.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2/node_modules/iconv-lite": {
|
||||
|
|
@ -13343,6 +13392,23 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-retry": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz",
|
||||
"integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/retry": "0.12.2",
|
||||
"is-network-error": "^1.0.0",
|
||||
"retry": "^0.13.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-timeout": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-6.1.4.tgz",
|
||||
|
|
@ -14826,7 +14892,6 @@
|
|||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
|
|
@ -15031,11 +15096,6 @@
|
|||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/seq-queue": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-12.0.0.tgz",
|
||||
|
|
@ -15581,13 +15641,19 @@
|
|||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sqlstring": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||
"integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==",
|
||||
"node_modules/sql-escaper": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"bun": ">=1.0.0",
|
||||
"deno": ">=2.0.0",
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@
|
|||
"@uppy/core": "5.2.0",
|
||||
"@uppy/dashboard": "5.1.0",
|
||||
"@uppy/react": "5.1.1",
|
||||
"@uppy/xhr-upload": "5.2.0",
|
||||
"@vinejs/vine": "3.0.1",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"autoprefixer": "10.5.0",
|
||||
|
|
@ -100,11 +101,12 @@
|
|||
"edge.js": "6.4.0",
|
||||
"fast-xml-parser": "5.7.0",
|
||||
"fuse.js": "7.1.0",
|
||||
"ioredis": "5.10.1",
|
||||
"ipaddr.js": "2.4.0",
|
||||
"jszip": "3.10.1",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "4.7.1",
|
||||
"mysql2": "3.16.2",
|
||||
"mysql2": "3.22.5",
|
||||
"ollama": "0.6.3",
|
||||
"openai": "6.38.0",
|
||||
"pdf-parse": "2.4.5",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ MEMORY_CAP = "2g" # generous cap; some apps (Stirling) OOM under 1g and fal
|
|||
KNOWN_NEEDS_SETUP = {
|
||||
"nomad_kiwix_server": "needs a ZIM library (managed separately by NOMAD)",
|
||||
"nomad_meshtasticd": "needs a config.yaml with a MAC address",
|
||||
"nomad_meshcore_web": "serves HTTPS on 443 only with the bind-mounted SSL config (absent in a bare probe)",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ router
|
|||
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
|
||||
router.get('/benchmark', [SettingsController, 'benchmark'])
|
||||
router.get('/support', [SettingsController, 'support'])
|
||||
router.get('/advanced', [SettingsController, 'advanced'])
|
||||
})
|
||||
.prefix('/settings')
|
||||
|
||||
|
|
@ -147,9 +148,12 @@ router
|
|||
router.get('/file-warnings', [RagController, 'getFileWarnings'])
|
||||
router.delete('/files', [RagController, 'deleteFile'])
|
||||
router.post('/files/embed', [RagController, 'embedFile'])
|
||||
router.get('/files/content', [RagController, 'getFileContent'])
|
||||
router.get('/files/download', [RagController, 'downloadFile'])
|
||||
router.get('/active-jobs', [RagController, 'getActiveJobs'])
|
||||
router.get('/failed-jobs', [RagController, 'getFailedJobs'])
|
||||
router.delete('/failed-jobs', [RagController, 'cleanupFailedJobs'])
|
||||
router.delete('/jobs', [RagController, 'cancelAllJobs'])
|
||||
router.get('/job-status', [RagController, 'getJobStatus'])
|
||||
router.post('/sync', [RagController, 'scanAndSync'])
|
||||
router.post('/re-embed-all', [RagController, 'reembedAll'])
|
||||
|
|
@ -169,6 +173,7 @@ router
|
|||
router.post('/services/affect', [SystemController, 'affectService'])
|
||||
router.post('/services/install', [SystemController, 'installService'])
|
||||
router.post('/services/force-reinstall', [SystemController, 'forceReinstallService'])
|
||||
router.post('/services/uninstall', [SystemController, 'uninstallService'])
|
||||
router.post('/services/check-updates', [SystemController, 'checkServiceUpdates'])
|
||||
router.get('/services/preflight', [SystemController, 'preflightCheck'])
|
||||
router.get('/services/suggest-port', [SystemController, 'suggestCustomPort'])
|
||||
|
|
@ -205,6 +210,7 @@ router
|
|||
router.post('/download-remote', [ZimController, 'downloadRemote'])
|
||||
router.post('/download-category-tier', [ZimController, 'downloadCategoryTier'])
|
||||
|
||||
router.post('/upload', [ZimController, 'upload'])
|
||||
router.get('/wikipedia', [ZimController, 'getWikipediaState'])
|
||||
router.post('/wikipedia/select', [ZimController, 'selectWikipedia'])
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,19 @@ import type { StoredFileInfo } from '../../types/rag.js'
|
|||
/** Wrap source paths into the minimal StoredFileInfo shape that
|
||||
* `groupAndSortKbFiles` now expects. State + chunk count are irrelevant to
|
||||
* grouping/sorting behavior; the per-file state-pill rendering is exercised
|
||||
* separately in the modal's component tests (added in the follow-up PR). */
|
||||
* separately in the modal's component tests (added in the follow-up PR).
|
||||
* Metadata fields default to nullish/false — individual tests that exercise
|
||||
* sort-by-size or sort-by-uploadedAt override as needed. */
|
||||
const asInfos = (sources: string[]): StoredFileInfo[] =>
|
||||
sources.map((source) => ({ source, state: null, chunksEmbedded: 0 }))
|
||||
sources.map((source) => ({
|
||||
source,
|
||||
state: null,
|
||||
chunksEmbedded: 0,
|
||||
fileName: sourceToDisplayName(source),
|
||||
size: null,
|
||||
uploadedAt: null,
|
||||
isUserUpload: classifyKbFile(source) === 'upload',
|
||||
}))
|
||||
|
||||
test('classifyKbFile distinguishes ZIM, upload, admin_docs, and other', () => {
|
||||
assert.equal(
|
||||
|
|
@ -106,3 +116,78 @@ test('groupAndSortKbFiles preserves a stable synthetic key for the admin docs gr
|
|||
// can be used as a React key without colliding with any real file row.
|
||||
assert.equal(groups[0].source, '__admin_docs_group__')
|
||||
})
|
||||
|
||||
/** Sized fixtures for the sort tests. `size` and `uploadedAt` are set so the
|
||||
* three sort keys (name, size, uploadedAt) produce visibly different orders —
|
||||
* if a test passed for name it had better fail for size. */
|
||||
const sized: StoredFileInfo[] = [
|
||||
{ source: '/app/storage/kb_uploads/charlie.txt', state: null, chunksEmbedded: 0, fileName: 'charlie.txt', size: 100, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: true },
|
||||
{ source: '/app/storage/kb_uploads/alpha.txt', state: null, chunksEmbedded: 0, fileName: 'alpha.txt', size: 300, uploadedAt: '2026-03-01T00:00:00Z', isUserUpload: true },
|
||||
{ source: '/app/storage/kb_uploads/bravo.txt', state: null, chunksEmbedded: 0, fileName: 'bravo.txt', size: 200, uploadedAt: '2026-02-01T00:00:00Z', isUserUpload: true },
|
||||
]
|
||||
|
||||
test('groupAndSortKbFiles sorts by size ascending', () => {
|
||||
const groups = groupAndSortKbFiles(sized, { key: 'size', direction: 'asc' })
|
||||
assert.deepEqual(groups.map((g) => g.displayName), ['charlie.txt', 'bravo.txt', 'alpha.txt'])
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles sorts by size descending', () => {
|
||||
const groups = groupAndSortKbFiles(sized, { key: 'size', direction: 'desc' })
|
||||
assert.deepEqual(groups.map((g) => g.displayName), ['alpha.txt', 'bravo.txt', 'charlie.txt'])
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles sorts by uploadedAt ascending', () => {
|
||||
const groups = groupAndSortKbFiles(sized, { key: 'uploadedAt', direction: 'asc' })
|
||||
assert.deepEqual(groups.map((g) => g.displayName), ['charlie.txt', 'bravo.txt', 'alpha.txt'])
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles sorts by uploadedAt descending', () => {
|
||||
const groups = groupAndSortKbFiles(sized, { key: 'uploadedAt', direction: 'desc' })
|
||||
assert.deepEqual(groups.map((g) => g.displayName), ['alpha.txt', 'bravo.txt', 'charlie.txt'])
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles parks files with null size at the end of size sort', () => {
|
||||
const withMissing: StoredFileInfo[] = [
|
||||
...sized,
|
||||
{ source: '/app/storage/kb_uploads/zzz_missing.txt', state: null, chunksEmbedded: 0, fileName: 'zzz_missing.txt', size: null, uploadedAt: null, isUserUpload: true },
|
||||
]
|
||||
// Missing-size files sort last regardless of direction so the "real" data
|
||||
// owns the top of the view either way.
|
||||
const asc = groupAndSortKbFiles(withMissing, { key: 'size', direction: 'asc' })
|
||||
assert.equal(asc.at(-1)?.displayName, 'zzz_missing.txt')
|
||||
const desc = groupAndSortKbFiles(withMissing, { key: 'size', direction: 'desc' })
|
||||
assert.equal(desc.at(-1)?.displayName, 'zzz_missing.txt')
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles preserves bucket order across all sort modes', () => {
|
||||
const mixed: StoredFileInfo[] = [
|
||||
{ source: '/app/storage/zim/big.zim', state: null, chunksEmbedded: 0, fileName: 'big.zim', size: 999, uploadedAt: '2026-01-01T00:00:00Z', isUserUpload: false },
|
||||
{ source: '/app/storage/kb_uploads/small.txt', state: null, chunksEmbedded: 0, fileName: 'small.txt', size: 1, uploadedAt: '2026-09-01T00:00:00Z', isUserUpload: true },
|
||||
{ source: '/app/docs/release-notes.md', state: null, chunksEmbedded: 0, fileName: 'release-notes.md', size: 50, uploadedAt: '2026-05-01T00:00:00Z', isUserUpload: false },
|
||||
]
|
||||
// Even if size-desc would put zim first naturally, sort runs *within* a
|
||||
// bucket — buckets themselves stay in the canonical zim → upload → admin_docs
|
||||
// order. This is the invariant that lets the per-bucket grouping stay
|
||||
// legible while still giving the user a sortable view.
|
||||
for (const direction of ['asc', 'desc'] as const) {
|
||||
for (const key of ['name', 'size', 'uploadedAt'] as const) {
|
||||
const groups = groupAndSortKbFiles(mixed, { key, direction })
|
||||
assert.deepEqual(
|
||||
groups.map((g) => g.bucket),
|
||||
['zim', 'upload', 'admin_docs'],
|
||||
`bucket order changed for ${key}/${direction}`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('groupAndSortKbFiles emits null metadata + isUserUpload=false for the admin_docs group', () => {
|
||||
const groups = groupAndSortKbFiles(asInfos([
|
||||
'/app/docs/release-notes.md',
|
||||
'/app/README.md',
|
||||
]))
|
||||
assert.equal(groups[0].bucket, 'admin_docs')
|
||||
assert.equal(groups[0].size, null)
|
||||
assert.equal(groups[0].uploadedAt, null)
|
||||
assert.equal(groups[0].isUserUpload, false)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export const KV_STORE_SCHEMA = {
|
|||
'system.updateAvailable': 'boolean',
|
||||
'system.latestVersion': 'string',
|
||||
'system.earlyAccess': 'boolean',
|
||||
'system.internetStatusTestUrl': 'string',
|
||||
'autoUpdate.enabled': 'boolean',
|
||||
'autoUpdate.windowStart': 'string',
|
||||
'autoUpdate.windowEnd': 'string',
|
||||
|
|
|
|||
|
|
@ -56,6 +56,15 @@ export type StoredFileInfo = {
|
|||
source: string
|
||||
state: import('./kb_ingest_state.js').KbIngestStateValue | null
|
||||
chunksEmbedded: number
|
||||
/** Filename portion of `source` (last path segment). */
|
||||
fileName: string
|
||||
/** File size in bytes from disk; null if the file is missing or unreadable. */
|
||||
size: number | null
|
||||
/** Last-modified timestamp from disk (ISO 8601); null if unavailable. */
|
||||
uploadedAt: string | null
|
||||
/** True when `source` lives under the user-uploads directory. Drives which
|
||||
* rows offer view/download in the UI. */
|
||||
isUserUpload: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,5 +18,6 @@ export type ServiceSlim = Pick<
|
|||
| 'auto_update_enabled'
|
||||
| 'is_custom'
|
||||
| 'is_user_modified'
|
||||
| 'is_deprecated'
|
||||
| 'category'
|
||||
> & { status?: string }
|
||||
|
|
|
|||
|
|
@ -7,6 +7,29 @@ echo "Starting entrypoint script..."
|
|||
# Ensure required storage directories exist (volume may be freshly mounted)
|
||||
mkdir -p /app/storage/logs /app/storage/kb_uploads
|
||||
|
||||
# Wait for Redis to be reachable before booting anything that opens a BullMQ
|
||||
# connection. `depends_on: condition: service_healthy` only gates a clean
|
||||
# `up --recreate`; it is NOT re-checked on `docker compose restart` or a
|
||||
# `restart: unless-stopped` bounce, so without this the app can race Docker's
|
||||
# DNS (EAI_AGAIN) or dial a restarted Redis container's stale IP (ECONNREFUSED).
|
||||
# This isn't load-bearing since legacy installs used a mounted entrypoint script
|
||||
# that may override this script, but it's a cost-nothing check for newer installs.
|
||||
# The real check is done by the application itself, but this provides a safety net.
|
||||
REDIS_HOST="${REDIS_HOST:-redis}"
|
||||
REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
echo "Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..."
|
||||
for i in $(seq 1 60); do
|
||||
if node -e "const net=require('net');const s=net.connect(Number(process.env.REDIS_PORT||6379),process.env.REDIS_HOST||'redis');s.on('connect',()=>{s.end();process.exit(0)});s.on('error',()=>process.exit(1));" 2>/dev/null; then
|
||||
echo "Redis is up and running!"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq 60 ]; then
|
||||
echo "Timed out waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Run AdonisJS migrations
|
||||
echo "Running AdonisJS migrations..."
|
||||
node ace migration:run --force
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "project-nomad",
|
||||
"version": "1.32.0-rc.6",
|
||||
"version": "1.33.0-rc.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "project-nomad",
|
||||
"version": "1.32.0-rc.6",
|
||||
"version": "1.33.0-rc.1",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "project-nomad",
|
||||
"version": "1.39.0",
|
||||
"version": "1.33.0",
|
||||
"description": "Project N.O.M.A.D. (Node for Offline Media, Archives, and Data) - an offline-first knowledge and education server.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue