mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-16 21:00:03 +02:00
Merge 9aad8ff3c9 into 54ee30a449
This commit is contained in:
commit
f2d24e2756
|
|
@ -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 ?? '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const SETTINGS_KEYS: KVStoreKey[] = [
|
|||
'chat.lastModel',
|
||||
'ui.hasVisitedEasySetup',
|
||||
'ui.theme',
|
||||
'ui.serviceLogsUrl',
|
||||
'system.earlyAccess',
|
||||
'system.internetStatusTestUrl',
|
||||
'ai.assistantCustomName',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
“Service Logs & Metrics” 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 —
|
||||
otherwise the link always resolves to <code>http://<current-host>: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>
|
||||
|
|
|
|||
83
admin/tests/unit/service_logs_url.spec.ts
Normal file
83
admin/tests/unit/service_logs_url.spec.ts
Normal 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/)
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user