This commit is contained in:
yi111 2026-10-06 15:39:14 -07:00 committed by GitHub
commit f2d24e2756
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 225 additions and 1 deletions

View File

@ -162,10 +162,12 @@ export default class SettingsController {
// 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')
const serviceLogsUrl = await KVStore.getValue('ui.serviceLogsUrl')
return inertia.render('settings/advanced', {
advanced: {
internetStatusTestUrl: internetStatusTestUrl ?? '',
internetStatusTestUrlEnvOverride: envOverride,
serviceLogsUrl: serviceLogsUrl ?? '',
},
})
}

View File

@ -56,6 +56,39 @@ export function validateSettingValue(key: KVStoreKey, value: unknown): string |
}
return null
}
case 'ui.serviceLogsUrl': {
if (value === undefined || value === null) {
return null
}
if (typeof value !== 'string') {
return 'Service logs URL must be a string.'
}
// Empty clears the setting (reverts to the port-derived default). The Advanced
// page trims before submitting, so whitespace-only has to mean "clear" too.
const trimmed = value.trim()
if (trimmed === '') {
return null
}
// A bare host ("logs.lan", "10.0.0.5:9999") is accepted and normalized to
// http:// by normalizeCustomUrl before it becomes an href, so validate the
// same way rather than demanding a scheme. An explicitly declared
// non-http(s) scheme ("file://", "ftp://") is refused rather than silently
// reinterpreted as a hostname by that http:// prefix.
const isHttpUrl = /^https?:\/\//i.test(trimmed)
if (!isHttpUrl && /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
return 'Service logs URL must use http or https.'
}
const withScheme = isHttpUrl ? trimmed : `http://${trimmed}`
try {
const url = new URL(withScheme)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'Service logs URL must use http or https.'
}
} catch {
return 'Service logs URL must be a valid URL (e.g. "https://logs.example.com").'
}
return null
}
case 'contentAutoUpdate.maxBytesPerWindow': {
// Per-window download budget in bytes. 0 = unlimited.
const num = Number(value)

View File

@ -5,6 +5,7 @@ export const SETTINGS_KEYS: KVStoreKey[] = [
'chat.lastModel',
'ui.hasVisitedEasySetup',
'ui.theme',
'ui.serviceLogsUrl',
'system.earlyAccess',
'system.internetStatusTestUrl',
'ai.assistantCustomName',

View File

@ -19,6 +19,7 @@ import StyledSidebar from '~/components/StyledSidebar'
import { getServiceLink } from '~/lib/navigation'
import useServiceInstalledStatus from '~/hooks/useServiceInstalledStatus'
import useCreatorPacks from '~/hooks/useCreatorPacks'
import { useSystemSetting } from '~/hooks/useSystemSetting'
import { SERVICE_NAMES } from '../../constants/service_names'
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
@ -27,6 +28,10 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
// Only show the Creator Packs entry on builds that can actually install packs
// (release-injected key present) — a fork built from source has no key.
const { configured: creatorPacksConfigured } = useCreatorPacks()
// Dozzle is a management-compose container rather than a `services` row, so the
// per-app custom_url override has nothing to attach to. Fall back to the override
// set in Advanced settings so the link works behind a reverse proxy / local DNS.
const { data: serviceLogsUrlSetting } = useSystemSetting({ key: 'ui.serviceLogsUrl' })
const navigation = [
...(aiAssistantInstallStatus.isInstalled ? [{ name: aiAssistantName, href: '/settings/models', icon: IconWand, current: false }] : []),
@ -38,7 +43,7 @@ export default function SettingsLayout({ children }: { children: React.ReactNode
{ name: 'Maps Manager', href: '/settings/maps', icon: IconMapRoute, current: false },
{
name: 'Service Logs & Metrics',
href: getServiceLink('9999'),
href: getServiceLink('9999', serviceLogsUrlSetting?.value),
icon: IconDashboard,
current: false,
target: '_blank',

View File

@ -13,6 +13,7 @@ export default function AdvancedPage(props: {
advanced: {
internetStatusTestUrl: string
internetStatusTestUrlEnvOverride: boolean
serviceLogsUrl: string
}
}) {
const { addNotification } = useNotifications()
@ -23,6 +24,9 @@ export default function AdvancedPage(props: {
)
const [testUrlError, setTestUrlError] = useState<string | null>(null)
const [serviceLogsUrl, setServiceLogsUrl] = useState(props.advanced.serviceLogsUrl ?? '')
const [serviceLogsUrlError, setServiceLogsUrlError] = 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 {
@ -66,6 +70,55 @@ export default function AdvancedPage(props: {
updateTestUrlMutation.mutate(trimmed)
}
// Mirrors the backend's `ui.serviceLogsUrl` validation
// (admin/app/validators/settings.ts). A bare host is accepted — normalizeCustomUrl
// prepends http:// before it reaches an href.
function validateServiceLogsUrl(value: string): string | null {
if (value.trim() === '') return null // empty clears the override
const trimmed = value.trim()
const isHttpUrl = /^https?:\/\//i.test(trimmed)
if (!isHttpUrl && /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
return 'Service logs URL must use http or https.'
}
try {
const url = new URL(isHttpUrl ? trimmed : `http://${trimmed}`)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return 'Service logs URL must use http or https.'
}
} catch {
return 'Service logs URL must be a valid URL (e.g. "https://logs.example.com").'
}
return null
}
const updateServiceLogsUrlMutation = useMutation({
mutationFn: async (value: string) => {
return await api.updateSetting('ui.serviceLogsUrl', 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.'
setServiceLogsUrlError(msg)
addNotification({ message: msg, type: 'error' })
},
})
function handleSaveServiceLogsUrl() {
const trimmed = serviceLogsUrl.trim()
const validationError = validateServiceLogsUrl(trimmed)
if (validationError) {
setServiceLogsUrlError(validationError)
return
}
setServiceLogsUrlError(null)
updateServiceLogsUrlMutation.mutate(trimmed)
}
return (
<SettingsLayout>
<Head title="Advanced Settings | Project NOMAD" />
@ -123,6 +176,47 @@ export default function AdvancedPage(props: {
</StyledButton>
</div>
</div>
<StyledSectionHeader title="Reverse Proxy" 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">
NOMAD's per-app URL overrides cover everything in the Services list, but the
&ldquo;Service Logs &amp; Metrics&rdquo; entry in the Settings sidebar points at Dozzle,
which runs as a management-compose container rather than an installed app. Set its
address here if you serve NOMAD behind a reverse proxy or a local DNS name &mdash;
otherwise the link always resolves to <code>http://&lt;current-host&gt;:9999</code>. Leave
blank to use that port default.
</p>
<div className="flex items-end gap-3">
<div className="flex-1">
<Input
name="serviceLogsUrl"
label="Service Logs & Metrics URL"
helpText="Where the Dozzle container is reachable, e.g. https://logs.example.com or logs.lan"
placeholder="https://logs.example.com"
value={serviceLogsUrl}
error={Boolean(serviceLogsUrlError)}
onChange={(e) => {
setServiceLogsUrl(e.target.value)
setServiceLogsUrlError(null)
}}
/>
{serviceLogsUrlError && (
<p className="text-sm text-red-600 mt-1">{serviceLogsUrlError}</p>
)}
</div>
<StyledButton
variant="primary"
onClick={handleSaveServiceLogsUrl}
loading={updateServiceLogsUrlMutation.isPending}
disabled={updateServiceLogsUrlMutation.isPending}
className="mb-0.5"
>
Save
</StyledButton>
</div>
</div>
</main>
</div>
</SettingsLayout>

View File

@ -0,0 +1,83 @@
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import { validateSettingValue } from '../../app/validators/settings.js'
// Validation for the `ui.serviceLogsUrl` override that backs the Settings sidebar's
// "Service Logs & Metrics" entry (see #1324). Dozzle is a management-compose
// container rather than a `services` row, so the per-app custom_url column has
// nothing to attach to and the link used to always resolve to
// http://<current-host>:9999 — unreachable behind a reverse proxy or local DNS.
//
// These cover the server side only. The href itself is built by getServiceLink /
// normalizeCustomUrl in inertia/lib/navigation.ts, which the Advanced page's client
// mirror below duplicates; that module reads window and is therefore not importable
// from a Node unit test (the app's tsconfig excludes inertia/ from the Node program).
const valid = (value: string) => assert.equal(validateSettingValue('ui.serviceLogsUrl', value), null)
// ── Accepted values ──────────────────────────────────────────────────────────
test('accepts http(s) URLs and bare hosts', () => {
for (const value of [
'https://logs.example.com',
'http://logs.example.com',
'https://example.com/logs',
'https://example.com:8443/logs',
'logs.lan',
'logs.lan:9999',
'10.0.0.5:9999',
]) {
valid(value)
}
})
test('ignores surrounding whitespace', () => {
valid(' https://logs.example.com ')
valid(' logs.lan ')
})
// ── Empty clears the override ─────────────────────────────────────────────────
// The Advanced page trims before submitting, so whitespace-only has to mean "clear"
// too — otherwise a field the user blanked by deleting the text would 422.
test('treats an empty or whitespace-only value as clearing the override', () => {
for (const value of ['', ' ', '\t\n', undefined, null]) {
assert.equal(validateSettingValue('ui.serviceLogsUrl', value), null)
}
})
// ── Refused values ───────────────────────────────────────────────────────────
test('rejects a declared non-http(s) scheme', () => {
// Refused at the input rather than silently reinterpreted as a hostname by the
// http:// prefix normalizeCustomUrl applies to a scheme-less value.
for (const value of [
'file:///etc/passwd',
'ftp://logs.example.com',
'javascript://alert(1)',
'gopher://logs.example.com',
]) {
assert.match(
String(validateSettingValue('ui.serviceLogsUrl', value)),
/http or https/,
`${value} should be refused`
)
}
})
test('rejects malformed URLs', () => {
for (const value of ['not a url', 'http://', '://logs.example.com']) {
assert.match(
String(validateSettingValue('ui.serviceLogsUrl', value)),
/valid URL/,
`${value} should be refused`
)
}
})
test('rejects non-string values', () => {
assert.match(String(validateSettingValue('ui.serviceLogsUrl', 42)), /must be a string/)
assert.match(String(validateSettingValue('ui.serviceLogsUrl', true)), /must be a string/)
assert.match(String(validateSettingValue('ui.serviceLogsUrl', ['https://a.example'])), /must be a string/)
})

View File

@ -48,6 +48,12 @@ export const KV_STORE_SCHEMA = {
'contentAutoUpdate.windowResetAt': 'string',
'ui.hasVisitedEasySetup': 'boolean',
'ui.theme': 'string',
// Reverse-proxy / local-DNS override for the Settings sidebar's "Service Logs &
// Metrics" entry. Dozzle is a management-compose container, not a `services` row,
// so the per-app `custom_url` column (#991) has nothing to attach to and the link
// would otherwise always resolve to http://<current-host>:9999 — unreachable for
// anyone serving NOMAD behind a proxy or a DNS name. Null keeps the port default.
'ui.serviceLogsUrl': 'string',
'ai.assistantCustomName': 'string',
'gpu.type': 'string',
'ai.remoteOllamaUrl': 'string',