Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion desktop/src/electron/config/ui-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import path from 'path'
import { getPaths } from '@/electron/globals'
import {
MODULAR_DEFAULT_LOG_LEVEL,
MODULAR_DEFAULT_PROXY_RESPONSE_TIMEOUT_MINUTES,
isModularLogLevel,
type ModularLogLevel
} from '@/shared/constants/modular-runtime'
import { isValidProxyResponseTimeoutMinutes } from '@/shared/utils/proxy-response-timeout'

interface UiConfig {
/** When true, first-run onboarding has not been completed or explicitly dismissed. */
Expand All @@ -17,12 +19,15 @@ interface UiConfig {
modularLogLevel: ModularLogLevel
/** macOS only: the one-time privileged-helper setup (register the SMAppService daemon + configure the Application Firewall) has completed. Gates the first-run admin prompt; left false until the daemon is enabled and firewall configuration succeeds, so an approval-pending launch retries next time. */
macHelperSetupComplete: boolean
/** Minutes passed to the broker as `--proxy-response-timeout` at spawn. 0 means wait indefinitely (no automatic failover). Takes effect on the next service restart. */
proxyResponseTimeoutMinutes: number
}

const DEFAULTS: UiConfig = {
firstRun: true,
modularLogLevel: MODULAR_DEFAULT_LOG_LEVEL,
macHelperSetupComplete: false
macHelperSetupComplete: false,
proxyResponseTimeoutMinutes: MODULAR_DEFAULT_PROXY_RESPONSE_TIMEOUT_MINUTES
}

let config: UiConfig = { ...DEFAULTS }
Expand Down Expand Up @@ -121,3 +126,15 @@ export function setMacHelperSetupComplete(value: boolean): void {
config.macHelperSetupComplete = value
save()
}

export function getProxyResponseTimeoutMinutes(): number {
// Guard against a hand-edited / legacy config value that isn't valid.
return isValidProxyResponseTimeoutMinutes(config.proxyResponseTimeoutMinutes)
? config.proxyResponseTimeoutMinutes
: MODULAR_DEFAULT_PROXY_RESPONSE_TIMEOUT_MINUTES
}

export function setProxyResponseTimeoutMinutes(value: number): void {
config.proxyResponseTimeoutMinutes = value
save()
}
5 changes: 4 additions & 1 deletion desktop/src/electron/connector/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { BrowserWindow } from 'electron'
import { createStructuredLogger } from '@/shared/utils/log'
import { getModularLogLevel } from '@/electron/config/ui-config'
import { getModularLogLevel, getProxyResponseTimeoutMinutes } from '@/electron/config/ui-config'
import getErrorString from '@/shared/utils/get-error-string'
import {
getModularSupervisor,
Expand Down Expand Up @@ -141,6 +141,9 @@ export const initializeConnector = async (): Promise<void> => {
// Seed the persisted log level so spawn passes the right `--log-level`.
const supervisor = getModularSupervisor()
supervisor.setLogLevel(getModularLogLevel())
// Same for the broker's --proxy-response-timeout (spawn-arg only, no
// live JSON-RPC equivalent — see setProxyResponseTimeout's doc comment).
supervisor.setProxyResponseTimeout(getProxyResponseTimeoutMinutes())
ensureBrokerCrashHandler()
supervisor.start()
weSpawned = true
Expand Down
24 changes: 23 additions & 1 deletion desktop/src/electron/ipc/service.ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,18 @@ import {
destroyConnector,
restartConnector
} from '@/electron/connector'
import { getModularLogLevel, setModularLogLevel } from '@/electron/config/ui-config'
import {
getModularLogLevel,
setModularLogLevel,
getProxyResponseTimeoutMinutes,
setProxyResponseTimeoutMinutes
} from '@/electron/config/ui-config'
import {
getModularSupervisor,
readCliBinManifest
} from '@/electron/service-bridge/modular-supervisor'
import { modularShippedBinaryBaseNames } from '@/shared/constants/modular-binaries'
import { isValidProxyResponseTimeoutMinutes } from '@/shared/utils/proxy-response-timeout'

const LICENSE_FILE = 'LICENSE'
const THIRD_PARTY_LICENSE_FILE = 'THIRD_PARTY_NOTICES.md'
Expand Down Expand Up @@ -98,6 +104,22 @@ export function registerServiceIpc(): void {
getModularSupervisor().setLogLevel(payload.level)
})

safeHandle('service:get-proxy-response-timeout', async () => {
return getProxyResponseTimeoutMinutes()
})

safeHandle('service:set-proxy-response-timeout', async (_event, payload) => {
if (!isValidProxyResponseTimeoutMinutes(payload.minutes)) {
throw new Error('Proxy response timeout must be a non-negative number of minutes')
}
// Spawn-arg only (no JSON-RPC equivalent to fan out live like log level) —
// persist for the next broker restart. Reflected in the supervisor's
// in-memory value too, so a restart triggered right after saving already
// picks up the new value even before the renderer refetches it.
setProxyResponseTimeoutMinutes(payload.minutes)
getModularSupervisor().setProxyResponseTimeout(payload.minutes)
})

safeHandle('service:open-log-file', async () => {
const logPath = getStructuredLogFilePath()
if (!logPath || !fs.existsSync(logPath)) return
Expand Down
29 changes: 29 additions & 0 deletions desktop/src/electron/service-bridge/modular-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,16 @@ import { parseClusterNodes, parseInvite, parseNodeIdentity } from './cluster-jso
import { startNodeInfoPoller, stopNodeInfoPoller } from './node-info-poller'
import {
MODULAR_DEFAULT_LOG_LEVEL,
MODULAR_DEFAULT_PROXY_RESPONSE_TIMEOUT_MINUTES,
MODULAR_INVITE_STATUS_POLL_INTERVAL_MS,
MODULAR_MODEL_ACTION_TIMEOUT_MS,
isModularLogLevel,
type ModularLogLevel
} from '@/shared/constants/modular-runtime'
import {
isValidProxyResponseTimeoutMinutes,
proxyResponseTimeoutArg
} from '@/shared/utils/proxy-response-timeout'
import { listManualNodeEntries } from './manual-nodes-store'
import {
MODULAR_RUNTIME_BINARIES,
Expand Down Expand Up @@ -350,6 +355,11 @@ class ModularSupervisor {
// connector seeds it via setLogLevel() before start() so spawn args use it.
// This default only applies if start() runs before the connector seeds.
private logLevel: ModularLogLevel = MODULAR_DEFAULT_LOG_LEVEL
// Same pattern as logLevel above, for the broker's `--proxy-response-timeout`
// (see ui-config's `proxyResponseTimeoutMinutes`). Unlike log level this has
// no live JSON-RPC fan-out — the broker only reads it at spawn — so a change
// takes effect on the next service restart, not immediately.
private proxyResponseTimeoutMinutes: number = MODULAR_DEFAULT_PROXY_RESPONSE_TIMEOUT_MINUTES
private brokerReady = false
private isReady = false
// True only while stop() is intentionally tearing down the subprocess tree.
Expand Down Expand Up @@ -739,6 +749,21 @@ class ModularSupervisor {
return this.logLevel
}

/**
* Update the in-memory value used for the broker's next
* `--proxy-response-timeout` spawn arg. No live effect on a running broker
* (it is a spawn-time flag, not a JSON-RPC-settable one) — the caller is
* expected to restart the service for this to take effect.
*/
setProxyResponseTimeout(minutes: number): void {
if (!isValidProxyResponseTimeoutMinutes(minutes)) return
this.proxyResponseTimeoutMinutes = minutes
}

getProxyResponseTimeout(): number {
return this.proxyResponseTimeoutMinutes
}

private validateRequiredBinaries(): void {
for (const definition of MODULAR_RUNTIME_BINARIES) {
if (definition.optional) continue
Expand Down Expand Up @@ -838,6 +863,10 @@ class ModularSupervisor {
// streams, and fans its schedule:priority out to the proxies via
// node/set-priority (all broker-internal).
passPath('--scheduler-path', 'job-scheduler')
args.push(
'--proxy-response-timeout',
proxyResponseTimeoutArg(this.proxyResponseTimeoutMinutes)
)
return [...args, ...this.logLevelArgs()]
}

Expand Down
9 changes: 9 additions & 0 deletions desktop/src/preload/api/service.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export interface IServiceApi {
restart(): Promise<void>
getLogLevel(): Promise<ModularLogLevel>
setLogLevel(level: ModularLogLevel): Promise<void>
/** Minutes; applies on the next service restart. */
getProxyResponseTimeout(): Promise<number>
setProxyResponseTimeout(minutes: number): Promise<void>
openLogFile(): Promise<void>
openLogDir(): Promise<void>
openLicense(): Promise<void>
Expand All @@ -31,6 +34,12 @@ export const serviceApi: IServiceApi = {
invokeAndUnwrap<ModularLogLevel>(ipcRenderer.invoke('service:get-log-level')),
setLogLevel: level =>
invokeAndUnwrap<void>(ipcRenderer.invoke('service:set-log-level', { level })),
getProxyResponseTimeout: () =>
invokeAndUnwrap<number>(ipcRenderer.invoke('service:get-proxy-response-timeout')),
setProxyResponseTimeout: minutes =>
invokeAndUnwrap<void>(
ipcRenderer.invoke('service:set-proxy-response-timeout', { minutes })
),
openLogFile: () => invokeAndUnwrap<void>(ipcRenderer.invoke('service:open-log-file')),
openLogDir: () => invokeAndUnwrap<void>(ipcRenderer.invoke('service:open-log-dir')),
openLicense: () => invokeAndUnwrap<void>(ipcRenderer.invoke('service:open-license')),
Expand Down
13 changes: 13 additions & 0 deletions desktop/src/shared/constants/modular-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,19 @@ export function isModularLogLevel(value: string): value is ModularLogLevel {

export const MODULAR_DEFAULT_LOG_LEVEL: ModularLogLevel = 'warn'

// Default for the broker's `--proxy-response-timeout`, in minutes. Mirrors the
// broker's own hardcoded default (5*time.Minute in
// services/nvpair-ui-broker/main.go) so a user who never touches this setting
// gets byte-for-byte the same spawn args as before this setting existed.
//
// This is how long the broker tells ollama-proxy to wait for a forwarded
// request's response headers before giving up and failing over to another
// node — see --response-timeout in services/ollama-proxy/main.go. 0 disables
// the timeout entirely (wait indefinitely): only safe against a backend
// trusted to eventually respond or fail on its own, since a genuinely wedged
// backend then hangs the request forever with no automatic failover.
export const MODULAR_DEFAULT_PROXY_RESPONSE_TIMEOUT_MINUTES = 5

// Local `/v1/node-info` HTTP poll. This is the single sanctioned HTTP exception
// for the modular bridge: the broker does not yet expose rich per-node
// telemetry, so Electron polls each discovered node's `/v1/node-info` endpoint
Expand Down
3 changes: 3 additions & 0 deletions desktop/src/shared/types/ipc-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ export interface IpcChannelMap {
'service:restart': { request: void; response: void }
'service:get-log-level': { request: void; response: ModularLogLevel }
'service:set-log-level': { request: { level: ModularLogLevel }; response: void }
/** Minutes; applies on the next service restart (spawn-arg only, no live fan-out). */
'service:get-proxy-response-timeout': { request: void; response: number }
'service:set-proxy-response-timeout': { request: { minutes: number }; response: void }
'service:open-log-file': { request: void; response: void }
'service:open-log-dir': { request: void; response: void }
'service:get-versions': { request: void; response: ServiceVersions }
Expand Down
28 changes: 28 additions & 0 deletions desktop/src/shared/utils/proxy-response-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

/**
* Validation and CLI-arg formatting for the "Proxy response timeout" setting
* (persisted as minutes, passed to the broker as `--proxy-response-timeout`).
*
* The value is entered and stored as minutes rather than a raw Go duration
* string (e.g. "5m30s") so the UI never has to parse or round-trip Go's
* duration grammar — a plain non-negative number is unambiguous, and 0 maps
* directly onto the broker/proxy's own "0 disables the timeout" convention
* (see `services/nvpair-ui-broker/main.go`, `services/ollama-proxy/main.go`).
*/

/** Non-negative, finite minutes — `0` means "wait indefinitely". */
export function isValidProxyResponseTimeoutMinutes(value: number): boolean {
return Number.isFinite(value) && value >= 0
}

/**
* Render minutes as the Go duration string the broker's `--proxy-response-
* timeout` flag (a `time.Duration`) expects, e.g. `5` -> `"5m"`, `0` -> `"0m"`.
* Go's `time.ParseDuration` accepts fractional values with a unit (`"2.5m"`),
* so this is safe for non-integer minutes too.
*/
export function proxyResponseTimeoutArg(minutes: number): string {
return `${minutes}m`
}
104 changes: 104 additions & 0 deletions desktop/src/ui/components/ServiceSettings/ServiceSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,19 @@ import {
Flex,
Stack,
Text,
TextInput,
type DropdownEntry
} from '@nvidia/foundations-react-core'
import { useConnectionStore } from '@/ui/stores/connection.store'
import { Download, OpenInNew } from '@/ui/components/icons'
import type { ServiceStatus } from '@/shared/types/ipc-channels'
import {
MODULAR_DEFAULT_LOG_LEVEL,
MODULAR_DEFAULT_PROXY_RESPONSE_TIMEOUT_MINUTES,
MODULAR_LOG_LEVELS,
type ModularLogLevel
} from '@/shared/constants/modular-runtime'
import { isValidProxyResponseTimeoutMinutes } from '@/shared/utils/proxy-response-timeout'
import getErrorString from '@/shared/utils/get-error-string'
import { DismissibleTooltip } from '@/ui/components/DismissibleTooltip/DismissibleTooltip'
import { isElectron } from '@/ui/api/bootstrap'
Expand Down Expand Up @@ -101,6 +104,16 @@ export default function ServiceSettings() {
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState<string | null>(null)
const [logLevel, setLogLevel] = useState<ModularLogLevel>(MODULAR_DEFAULT_LOG_LEVEL)
// Persisted value (minutes), and the input's own draft text — kept apart so
// typing an in-progress/invalid value never clobbers the saved one, and so
// "changed" can be judged against what is actually stored.
const [proxyTimeoutMinutes, setProxyTimeoutMinutes] = useState(
MODULAR_DEFAULT_PROXY_RESPONSE_TIMEOUT_MINUTES
)
const [proxyTimeoutInput, setProxyTimeoutInput] = useState(
String(MODULAR_DEFAULT_PROXY_RESPONSE_TIMEOUT_MINUTES)
)
const [savingProxyTimeout, setSavingProxyTimeout] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const [startingDemo, setStartingDemo] = useState(false)
const setActiveTab = useOverviewUiStore(state => state.setActiveTab)
Expand Down Expand Up @@ -140,6 +153,17 @@ export default function ServiceSettings() {
.catch(() => {})
}, [])

useEffect(() => {
if (!isElectron) return
window.windowApi.service
.getProxyResponseTimeout()
.then(minutes => {
setProxyTimeoutMinutes(minutes)
setProxyTimeoutInput(String(minutes))
})
.catch(() => {})
}, [])

const handleLogLevelChange = useCallback(
async (level: ModularLogLevel) => {
const prev = logLevel
Expand All @@ -154,6 +178,28 @@ export default function ServiceSettings() {
[logLevel]
)

const parsedProxyTimeout = Number(proxyTimeoutInput.trim())
const proxyTimeoutValid = isValidProxyResponseTimeoutMinutes(parsedProxyTimeout)
const proxyTimeoutChanged = proxyTimeoutValid && parsedProxyTimeout !== proxyTimeoutMinutes

const handleSaveProxyTimeout = useCallback(async () => {
if (!proxyTimeoutValid) {
setError('Enter a non-negative number of minutes (0 waits indefinitely).')
return
}
setSavingProxyTimeout(true)
setError(null)
try {
await window.windowApi.service.setProxyResponseTimeout(parsedProxyTimeout)
setProxyTimeoutMinutes(parsedProxyTimeout)
setProxyTimeoutInput(String(parsedProxyTimeout))
} catch (err) {
setError(getErrorString(err))
} finally {
setSavingProxyTimeout(false)
}
}, [parsedProxyTimeout, proxyTimeoutValid])

const logLevelItems: DropdownEntry[] = useMemo(
() =>
MODULAR_LOG_LEVELS.map(level => ({
Expand Down Expand Up @@ -374,6 +420,64 @@ export default function ServiceSettings() {
)}
</Stack>

<Stack gap="1">
<Flex align="center" justify="between" gap="4">
<Text kind="body/semibold/md">Proxy response timeout</Text>
{isElectron ? (
<Flex align="center" gap="2">
<TextInput
value={proxyTimeoutInput}
onValueChange={setProxyTimeoutInput}
disabled={savingProxyTimeout}
size="small"
className="w-18 min-w-18"
aria-label="Proxy response timeout, in minutes"
/>
<Text kind="body/regular/sm" className="text-subtle-color">
min
</Text>
<Button
kind="secondary"
size="small"
onClick={() => void handleSaveProxyTimeout()}
disabled={
savingProxyTimeout ||
!proxyTimeoutChanged ||
proxyTimeoutInput.trim() === ''
}
>
{savingProxyTimeout ? (
<span
className="spinner-element"
role="status"
aria-label=""
/>
) : (
'Save'
)}
</Button>
</Flex>
) : (
<DismissibleTooltip slotContent={BROWSER_TOOLTIP}>
<span className="inline-flex">
<Button kind="secondary" size="small" disabled>
{proxyTimeoutMinutes} min
</Button>
</span>
</DismissibleTooltip>
)}
</Flex>
<Text kind="body/regular/sm" className="text-subtle-color">
How long the proxy waits for a node&apos;s response before failing
over to another node. Default is{' '}
{MODULAR_DEFAULT_PROXY_RESPONSE_TIMEOUT_MINUTES} minutes, matching
the broker&apos;s own default — leaving this unset changes nothing.
Set to 0 to wait indefinitely: useful for a very slow model, but it
means no automatic failover if a node hangs. Takes effect the next
time the service restarts.
</Text>
</Stack>

<WipeAppDataCard />
</Stack>
</div>
Expand Down
Loading