mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-07-03 03:38:40 +00:00
feat: configurable internet test url override in new Advanced Settings page
This commit is contained in:
parent
02c9f72bf0
commit
4a795df793
10 changed files with 210 additions and 27 deletions
|
|
@ -108,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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import {
|
||||
IconAdjustments,
|
||||
IconArrowBigUpLines,
|
||||
IconBox,
|
||||
IconChartBar,
|
||||
|
|
@ -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 },
|
||||
]
|
||||
|
|
|
|||
130
admin/inertia/pages/settings/advanced.tsx
Normal file
130
admin/inertia/pages/settings/advanced.tsx
Normal file
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -363,6 +363,16 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
{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 */}
|
||||
|
|
@ -383,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>
|
||||
|
|
@ -442,8 +451,8 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
|||
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'
|
||||
? '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}
|
||||
|
|
@ -899,8 +908,8 @@ 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'
|
||||
? '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
|
||||
|
|
@ -1065,15 +1074,15 @@ function AppCard({
|
|||
{
|
||||
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>
|
||||
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 ? (
|
||||
|
|
@ -1147,8 +1156,8 @@ function DropdownItem({
|
|||
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'
|
||||
? 'text-desert-red hover:bg-desert-red/10'
|
||||
: 'text-text-primary hover:bg-surface-secondary'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue