From 9aad8ff3c9ac7b4f576a9f6e76794bb105f4e295 Mon Sep 17 00:00:00 2001 From: Yi-111-a Date: Sat, 26 Sep 2026 00:44:23 +0800 Subject: [PATCH] fix(ui): let the Service Logs sidebar link honor a custom URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #991 added custom_url overrides for app launch links, but the Settings sidebar's "Service Logs & Metrics" entry still built its href from just a port: getServiceLink('9999') in SettingsLayout.tsx. Dozzle is a management-compose container (nomad_dozzle), not a `services` row, so that override has nothing to attach to and the link always resolved to http://:9999. For anyone reaching NOMAD through a reverse proxy or a local DNS name — the exact audience #991 served — the logs link is broken with no workaround in the UI. Add a KV-backed ui.serviceLogsUrl override, editable under Advanced > Reverse Proxy, and route the sidebar entry through getServiceLink's existing customUrl argument. With nothing set the link is unchanged. Validation accepts a full http(s) URL or a bare host (normalizeCustomUrl prepends http://), treats empty as "clear", and refuses a declared non-http(s) scheme so an input like file:///etc/passwd is rejected at the boundary instead of being silently reinterpreted as a hostname. --- admin/app/controllers/settings_controller.ts | 2 + admin/app/validators/settings.ts | 33 +++++++ admin/constants/kv_store.ts | 1 + admin/inertia/layouts/SettingsLayout.tsx | 7 +- admin/inertia/pages/settings/advanced.tsx | 94 ++++++++++++++++++++ admin/tests/unit/service_logs_url.spec.ts | 83 +++++++++++++++++ admin/types/kv_store.ts | 6 ++ 7 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 admin/tests/unit/service_logs_url.spec.ts diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index bab7c5e4..343e8937 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -126,10 +126,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 ?? '', }, }) } diff --git a/admin/app/validators/settings.ts b/admin/app/validators/settings.ts index 089e9221..55b5f284 100644 --- a/admin/app/validators/settings.ts +++ b/admin/app/validators/settings.ts @@ -54,6 +54,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) diff --git a/admin/constants/kv_store.ts b/admin/constants/kv_store.ts index 80e0a543..b14dc695 100644 --- a/admin/constants/kv_store.ts +++ b/admin/constants/kv_store.ts @@ -5,6 +5,7 @@ export const SETTINGS_KEYS: KVStoreKey[] = [ 'chat.lastModel', 'ui.hasVisitedEasySetup', 'ui.theme', + 'ui.serviceLogsUrl', 'system.earlyAccess', 'system.internetStatusTestUrl', 'ai.assistantCustomName', diff --git a/admin/inertia/layouts/SettingsLayout.tsx b/admin/inertia/layouts/SettingsLayout.tsx index a9a5c2ac..eb9de348 100644 --- a/admin/inertia/layouts/SettingsLayout.tsx +++ b/admin/inertia/layouts/SettingsLayout.tsx @@ -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', diff --git a/admin/inertia/pages/settings/advanced.tsx b/admin/inertia/pages/settings/advanced.tsx index 397aa8a4..ab729d35 100644 --- a/admin/inertia/pages/settings/advanced.tsx +++ b/admin/inertia/pages/settings/advanced.tsx @@ -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(null) + const [serviceLogsUrl, setServiceLogsUrl] = useState(props.advanced.serviceLogsUrl ?? '') + const [serviceLogsUrlError, setServiceLogsUrlError] = useState(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 ( @@ -123,6 +176,47 @@ export default function AdvancedPage(props: { + + +
+

+ 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 http://<current-host>:9999. Leave + blank to use that port default. +

+ +
+
+ { + setServiceLogsUrl(e.target.value) + setServiceLogsUrlError(null) + }} + /> + {serviceLogsUrlError && ( +

{serviceLogsUrlError}

+ )} +
+ + Save + +
+
diff --git a/admin/tests/unit/service_logs_url.spec.ts b/admin/tests/unit/service_logs_url.spec.ts new file mode 100644 index 00000000..b1bf8d74 --- /dev/null +++ b/admin/tests/unit/service_logs_url.spec.ts @@ -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://: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/) +}) diff --git a/admin/types/kv_store.ts b/admin/types/kv_store.ts index f2a5fbb5..4def7215 100644 --- a/admin/types/kv_store.ts +++ b/admin/types/kv_store.ts @@ -34,6 +34,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://: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',