feat: configurable internet test url override in new Advanced Settings page
This commit is contained in:
parent
02c9f72bf0
commit
4a795df793
|
|
@ -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
|
## 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.
|
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
|
## 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.
|
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 { getSettingSchema, updateSettingSchema, validateSettingValue } from '#validators/settings'
|
||||||
import { inject } from '@adonisjs/core'
|
import { inject } from '@adonisjs/core'
|
||||||
import type { HttpContext } from '@adonisjs/core/http'
|
import type { HttpContext } from '@adonisjs/core/http'
|
||||||
|
import env from '#start/env'
|
||||||
|
|
||||||
@inject()
|
@inject()
|
||||||
export default class SettingsController {
|
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) {
|
async getSetting({ request, response }: HttpContext) {
|
||||||
const { key } = await getSettingSchema.validate({ key: request.qs().key });
|
const { key } = await getSettingSchema.validate({ key: request.qs().key });
|
||||||
const value = await KVStore.getValue(key);
|
const value = await KVStore.getValue(key);
|
||||||
|
|
|
||||||
|
|
@ -49,31 +49,37 @@ export class SystemService {
|
||||||
const MAX_ATTEMPTS = 3
|
const MAX_ATTEMPTS = 3
|
||||||
|
|
||||||
let testUrls = DEFAULT_TEST_URLS
|
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
|
// Resolve the test endpoint in priority order: the INTERNET_STATUS_TEST_URL
|
||||||
// preserves the existing override behavior for operators who intentionally
|
// env var always wins (legacy override for operators who intentionally point
|
||||||
// point connectivity checks at a specific endpoint.
|
// 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 !== '') {
|
if (customTestUrl && customTestUrl !== '') {
|
||||||
try {
|
try {
|
||||||
new URL(customTestUrl)
|
new URL(customTestUrl)
|
||||||
testUrls = [customTestUrl]
|
testUrls = [customTestUrl]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(
|
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++) {
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||||
try {
|
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
|
// responds. Any HTTP response (including non-2xx) means we reached the
|
||||||
// internet, so accept all status codes rather than requiring a strict 200.
|
// internet, so accept all status codes rather than requiring a strict 200.
|
||||||
await Promise.any(
|
await Promise.any(
|
||||||
testUrls.map((testUrl) =>
|
testUrls.map((testUrl) => {
|
||||||
axios.get(testUrl, { timeout: 5000, validateStatus: () => true })
|
logger.debug(`[SystemService] Checking internet connectivity via: ${testUrl}`)
|
||||||
)
|
return axios.get(testUrl, { timeout: 5000, validateStatus: () => true })
|
||||||
|
})
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,24 @@ export function validateSettingValue(key: KVStoreKey, value: unknown): string |
|
||||||
}
|
}
|
||||||
return null
|
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': {
|
case 'contentAutoUpdate.maxBytesPerWindow': {
|
||||||
// Per-window download budget in bytes. 0 = unlimited.
|
// Per-window download budget in bytes. 0 = unlimited.
|
||||||
const num = Number(value)
|
const num = Number(value)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
import { KVStoreKey } from "../types/kv_store.js";
|
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 {
|
import {
|
||||||
|
IconAdjustments,
|
||||||
IconArrowBigUpLines,
|
IconArrowBigUpLines,
|
||||||
IconBox,
|
IconBox,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
|
|
@ -42,6 +43,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
|
||||||
current: false,
|
current: false,
|
||||||
},
|
},
|
||||||
{ name: 'System', href: '/settings/system', icon: IconSettings, 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: 'Support the Project', href: '/settings/support', icon: IconHeart, current: false },
|
||||||
{ name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },
|
{ name: 'Legal Notices', href: '/settings/legal', icon: IconGavel, current: false },
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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..." />}
|
{loading && !modal && <LoadingSpinner fullscreen text="Working..." />}
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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 ─────────────────────────────────────────── */}
|
{/* ── Hero / controls panel ─────────────────────────────────────────── */}
|
||||||
<div className="rounded-lg overflow-hidden bg-desert-white border border-desert-stone-light shadow-sm mb-8">
|
<div className="rounded-lg overflow-hidden bg-desert-white border border-desert-stone-light shadow-sm mb-8">
|
||||||
{/* Green header band */}
|
{/* 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="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 className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center gap-3">
|
<div className="relative flex items-center gap-3">
|
||||||
<IconBox className="text-white opacity-90 flex-shrink-0" size={28} />
|
<IconBox className="text-white opacity-90 flex-shrink-0" size={28} />
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -442,8 +451,8 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
onClick={() => setActiveCategory(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
|
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-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-surface-secondary text-text-muted border-desert-stone-lighter hover:text-text-primary hover:border-desert-stone-light'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{cat.label}
|
{cat.label}
|
||||||
|
|
@ -899,8 +908,8 @@ function AppCard({
|
||||||
return (
|
return (
|
||||||
<div
|
<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
|
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-light'
|
||||||
: 'border-desert-stone-lighter hover: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
|
{/* Installed accent spine (rounded to follow the card corners — the card no longer clips
|
||||||
|
|
@ -1065,15 +1074,15 @@ function AppCard({
|
||||||
{
|
{
|
||||||
migrationInstructionsHref ? (
|
migrationInstructionsHref ? (
|
||||||
<a
|
<a
|
||||||
href={migrationInstructionsHref}
|
href={migrationInstructionsHref}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
onClick={(e) => e.stopPropagation()}
|
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"
|
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" />
|
<IconBook className="h-4 w-4" />
|
||||||
{migrationInstructionsText || 'Migration instructions'}
|
{migrationInstructionsText || 'Migration instructions'}
|
||||||
</a>
|
</a>
|
||||||
) : (null)
|
) : (null)
|
||||||
}
|
}
|
||||||
{!service.is_custom && onToggleAutoUpdate ? (
|
{!service.is_custom && onToggleAutoUpdate ? (
|
||||||
|
|
@ -1147,8 +1156,8 @@ function DropdownItem({
|
||||||
onClick()
|
onClick()
|
||||||
}}
|
}}
|
||||||
className={`flex items-center gap-2 w-full px-3 py-2 text-xs transition-colors text-left cursor-pointer ${danger
|
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-desert-red hover:bg-desert-red/10'
|
||||||
: 'text-text-primary hover:bg-surface-secondary'
|
: 'text-text-primary hover:bg-surface-secondary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ router
|
||||||
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
|
router.get('/zim/remote-explorer', [SettingsController, 'zimRemote'])
|
||||||
router.get('/benchmark', [SettingsController, 'benchmark'])
|
router.get('/benchmark', [SettingsController, 'benchmark'])
|
||||||
router.get('/support', [SettingsController, 'support'])
|
router.get('/support', [SettingsController, 'support'])
|
||||||
|
router.get('/advanced', [SettingsController, 'advanced'])
|
||||||
})
|
})
|
||||||
.prefix('/settings')
|
.prefix('/settings')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export const KV_STORE_SCHEMA = {
|
||||||
'system.updateAvailable': 'boolean',
|
'system.updateAvailable': 'boolean',
|
||||||
'system.latestVersion': 'string',
|
'system.latestVersion': 'string',
|
||||||
'system.earlyAccess': 'boolean',
|
'system.earlyAccess': 'boolean',
|
||||||
|
'system.internetStatusTestUrl': 'string',
|
||||||
'autoUpdate.enabled': 'boolean',
|
'autoUpdate.enabled': 'boolean',
|
||||||
'autoUpdate.windowStart': 'string',
|
'autoUpdate.windowStart': 'string',
|
||||||
'autoUpdate.windowEnd': 'string',
|
'autoUpdate.windowEnd': 'string',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue