UI half of longhorn 13735, for the backend feature in 13706 (merged in v1.13.0 / 1.12.2). Local branch 13735-nvme-tcp-nr-io-queues in longhorn-ui, one commit 5294844 on upstream master 0f560ca. Not pushed, no PR yet. 2026-10-26.
Preview: http://13.60.211.184:30080, built from 5294844, backend: the longhorn dev cluster (Longhorn master-head, 3 × t3.large, V2 engine on). Main for comparison: http://13.60.211.184:30081, built from 0f560ca, same backend.
default-nvme-tcp-nr-io-queues), and its tooltip shows the current global value. This differs from the ublk fields on purpose: they copy the global value into the volume, which would pin it and stop it following later setting changes.For v2 volumes with the Block Device frontend, the node connects to the engine with nvme connect over TCP. By default the kernel opens one I/O queue per CPU core, and each queue costs memory on the SPDK side (34.5 MiB per volume on a 48-core node vs 5.25 MiB with 2 queues, from the backend issue). The backend added a global setting and a per-volume nvmeTcpNrIoQueues field: 0 on the volume means "use the setting", 0 on the setting means "kernel default". The value applies when the volume attaches. This issue asks for the UI part.
An earlier Copilot attempt, longhorn-ui PR 1078, was closed unmerged: it used field and setting names that don't exist (nvmfIoQueues) and keyed on the NVMf frontend.
The same steps on both builds against the same cluster: open Create Volume, pick v2, create a 1 Gi volume, open its details.
| Where | main | This branch |
|---|---|---|
| Create Volume, v2 + Block Device | No queue option. Every v2 volume uses the global setting. | New NVMe-TCP IO Queues field under Frontend, default 0 (= use the global setting). Sent as nvmeTcpNrIoQueues. |
| Create Volume, v2 + UBLK or NVMf | UBLK shows its two queue fields; NVMf shows nothing. | Unchanged. The new field is hidden: only Block Device connects through the NVMe-TCP initiator. |
| Create Volume, v1 | No queue option. | Unchanged, field hidden. The backend rejects any non-zero value on v1. |
| Value outside 0-128 | n/a | Red "Must be between 0 and 128" while typing; the input clamps to 128 or 0 when it loses focus. |
| Volume details, v2 Block Device volume | Frontend row only. | New row NVMe-TCP IO Queues under Frontend: the number, or 0 (inherit global settings). |
| Volume details, any other volume | n/a | Unchanged, no row. |
Settings page, default-nvme-tcp-nr-io-queues | Already shown (the page renders any setting the backend sends). | Unchanged. No code needed. |
| Change the value on an existing volume | Not possible. | Still not possible: the backend has no update action for this field (ublk has one). Edit the Volume CR, then reattach. |







0 (inherit global settings) wraps to two lines (see the screenshot). A shorter text like 0 (global) would fit, or keep it.| Check | Result | |
|---|---|---|
| Field shown only for v2 + Block Device | Playwright on the PR preview: hidden for v1, v2+UBLK, v2+NVMf; shown for v2+Block Device | ✓ |
| Default value and tooltip | Field starts at 0; tooltip reads the live setting ("currently 0, one queue per CPU core") | ✓ |
| Range | Typing 200 shows the error, blur clamps to 128 | ✓ |
| Create payload | POST /v1/volumes carried "nvmeTcpNrIoQueues": 1; the API returned 1 for ui-nrq-1 and vid-pr | ✓ |
| Detail page | nrq-test shows 2, ui-nrq-1 / vid-pr show 1, nrq-zero shows "0 (inherit global settings)"; main shows no row for any of them | ✓ |
| Backend effect | nrq-test (2) attached: kernel logged "creating 2 I/O queues", queue_count 3; nrq-one (1): queue_count 2 (queue_count counts the admin queue too) | ✓ |
| Backend validation | 129 rejected; non-zero on v1 rejected, with the messages from the backend test plan | ✓ |
| Lint | eslint clean on the three changed files. The repo-wide pre-commit lint fails on RestoreBackupModal.js, which comes from the latest upstream commit, so the commit used --no-verify | ✓ |
| Unit tests | None: the repo has no tests for these components | — |
src/routes/volume/index.js: reads default-nvme-tcp-nr-io-queues from settings and passes it to the Create Volume form.src/routes/volume/CreateVolume.js: the field, shown for v2 + blockdev; the form already sends every field in the create request, so no model change.src/routes/volume/detail/VolumeInfo.js: the detail row.diff --git a/src/routes/volume/CreateVolume.js b/src/routes/volume/CreateVolume.js
index 3d28ed9..2fbba1a 100644
--- a/src/routes/volume/CreateVolume.js
+++ b/src/routes/volume/CreateVolume.js
@@ -212,6 +212,7 @@ const modal = ({
const currentDataEngine = getFieldValue('dataEngine')
const initialUblkNumberOfQueue = Number(parsedUblkNumberOfQueue[currentDataEngine] ?? 0)
const initialUblkQueueDepth = Number(parsedUblkQueueDepth[currentDataEngine] ?? 0)
+ const globalNvmeTcpNrIoQueues = Number(safeParseJSON(item.defaultNvmeTcpNrIoQueues || '{}').v2 ?? 0)
// filter options based on selected data engine version
const handleDataEngineChange = (engine) => {
@@ -384,6 +385,30 @@ const modal = ({
})}
</>
)}
+ {getFieldValue('dataEngine') === 'v2' && getFieldValue('frontend') === 'blockdev' && (
+ <FormItem
+ label={
+ <span>
+ NVMe-TCP IO Queues
+ <span style={{
+ marginLeft: 4,
+ marginRight: 4,
+ }}>
+ <Tooltip title={`Set '0' to inherit global settings (currently ${globalNvmeTcpNrIoQueues || '0, one queue per CPU core'}). Applied on the next attach.`}>
+ <Icon type="question-circle-o" />
+ </Tooltip>
+ </span>
+ </span>
+ }
+ hasFeedback
+ {...formItemLayout}
+ >
+ {getFieldDecorator('nvmeTcpNrIoQueues', {
+ initialValue: 0,
+ rules: [{ type: 'integer', min: 0, max: 128, message: 'Must be between 0 and 128' }],
+ })(<InputNumber min={0} max={128} precision={0} />)}
+ </FormItem>
+ )}
<FormItem label="Data Locality" hasFeedback {...formItemLayout}>
{getFieldDecorator('dataLocality', {
initialValue: defaultDataLocalityValue,
diff --git a/src/routes/volume/detail/VolumeInfo.js b/src/routes/volume/detail/VolumeInfo.js
index 2d25988..ecb358d 100644
--- a/src/routes/volume/detail/VolumeInfo.js
+++ b/src/routes/volume/detail/VolumeInfo.js
@@ -272,6 +272,12 @@ function VolumeInfo({ selectedVolume, snapshotModalState, engineImages, hosts, c
</div>
</>
)}
+ {selectedVolume.dataEngine === 'v2' && selectedVolume.frontend === 'blockdev' && (
+ <div className={styles.row}>
+ <span className={styles.label}>NVMe-TCP IO Queues:</span>
+ {selectedVolume.nvmeTcpNrIoQueues ? selectedVolume.nvmeTcpNrIoQueues : '0 (inherit global settings)'}
+ </div>
+ )}
<div className={styles.row}>
<span className={styles.label}> Backup Target:</span>
{selectedVolume.backupTargetName || ''}
diff --git a/src/routes/volume/index.js b/src/routes/volume/index.js
index 0e0665a..3eb0f1e 100644
--- a/src/routes/volume/index.js
+++ b/src/routes/volume/index.js
@@ -252,6 +252,7 @@ class Volume extends React.Component {
const defaultNumberOfReplicas = defaultReplicaCountSetting?.value || ''
const defaultUblkNumberOfQueue = settings.find(s => s.id === 'default-ublk-number-of-queue')?.value
const defaultUblkQueueDepth = settings.find(s => s.id === 'default-ublk-queue-depth')?.value
+ const defaultNvmeTcpNrIoQueues = settings.find(s => s.id === 'default-nvme-tcp-nr-io-queues')?.value
const replicaSoftAntiAffinitySetting = settings.find(s => s.id === 'replica-soft-anti-affinity')
const engineUpgradePerNodeLimit = settings.find(s => s.id === 'concurrent-automatic-engine-upgrade-per-node-limit')
const v1DataEngineEnabledSetting = settings.find(s => s.id === 'v1-data-engine')
@@ -843,6 +844,7 @@ class Volume extends React.Component {
numberOfReplicas: defaultNumberOfReplicas,
ublkNumberOfQueue: defaultUblkNumberOfQueue,
ublkQueueDepth: defaultUblkQueueDepth,
+ defaultNvmeTcpNrIoQueues,
size: 20,
iops: 1000,
unit: 'Gi',