Files
dualfroz d0dbabcade fix #8098: ui: ceph: osd: sort by name naturally
Sorting the OSD tree by the name column compared the names as plain
strings, so 'osd.10' and 'osd.11' ended up before 'osd.8' and 'osd.9',
and the same happened for host names like 'pve10' and 'pve2'.

Add a sorter to the name column that compares numbers in the names by
their value, like it is already done for the storage content view.

Signed-off-by: Michal Fox <me@dualfroz.com>
2026-10-02 16:44:49 +02:00

1492 lines
53 KiB
JavaScript

Ext.define('PVE.CephCreateOsd', {
extend: 'Proxmox.window.Edit',
xtype: 'pveCephCreateOsd',
subject: 'Ceph OSD',
showProgress: true,
onlineHelp: 'pve_ceph_osds',
initComponent: function () {
let me = this;
if (!me.nodename) {
throw 'no node name specified';
}
me.isCreate = true;
Proxmox.Utils.API2Request({
url: `/nodes/${me.nodename}/ceph/crush`,
method: 'GET',
failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
success: function ({ result: { data } }) {
let classes = [
...new Set(
Array.from(
data.matchAll(/^device\s[0-9]*\sosd\.[0-9]*\sclass\s(.*)$/gim),
(m) => m[1],
).filter((v) => !['hdd', 'ssd', 'nvme'].includes(v)),
),
].map((v) => [v, v]);
if (classes.length) {
let kvField = me.down('field[name=crush-device-class]');
kvField.setComboItems([...kvField.comboItems, ...classes]);
}
},
});
Ext.applyIf(me, {
url: '/nodes/' + me.nodename + '/ceph/osd',
method: 'POST',
items: [
{
xtype: 'inputpanel',
onGetValues: function (values) {
Object.keys(values || {}).forEach(function (name) {
if (values[name] === '') {
delete values[name];
}
});
return values;
},
column1: [
{
xtype: 'pmxDiskSelector',
name: 'dev',
nodename: me.nodename,
diskType: 'unused',
includePartitions: true,
fieldLabel: gettext('Disk'),
allowBlank: false,
},
],
column2: [
{
xtype: 'pmxDiskSelector',
name: 'db_dev',
nodename: me.nodename,
diskType: 'journal_disks',
includePartitions: true,
fieldLabel: gettext('DB Disk'),
value: '',
autoSelect: false,
allowBlank: true,
emptyText: gettext('use OSD disk'),
listeners: {
change: function (field, val) {
me.down('field[name=db_dev_size]').setDisabled(!val);
},
},
},
{
xtype: 'numberfield',
name: 'db_dev_size',
fieldLabel: `${gettext('DB size')} (${gettext('GiB')})`,
minValue: 1,
maxValue: 128 * 1024,
decimalPrecision: 2,
allowBlank: true,
disabled: true,
emptyText: gettext('Automatic'),
},
],
advancedColumn1: [
{
xtype: 'proxmoxcheckbox',
name: 'encrypted',
fieldLabel: gettext('Encrypt OSD'),
},
{
xtype: 'proxmoxKVComboBox',
comboItems: [
['hdd', 'HDD'],
['ssd', 'SSD'],
['nvme', 'NVMe'],
],
name: 'crush-device-class',
nodename: me.nodename,
fieldLabel: gettext('Device Class'),
value: '',
autoSelect: false,
allowBlank: true,
editable: true,
emptyText: gettext('auto detect'),
deleteEmpty: !me.isCreate,
},
],
advancedColumn2: [
{
xtype: 'pmxDiskSelector',
name: 'wal_dev',
nodename: me.nodename,
diskType: 'journal_disks',
includePartitions: true,
fieldLabel: gettext('WAL Disk'),
value: '',
autoSelect: false,
allowBlank: true,
emptyText: gettext('use OSD/DB disk'),
listeners: {
change: function (field, val) {
me.down('field[name=wal_dev_size]').setDisabled(!val);
},
},
},
{
xtype: 'numberfield',
name: 'wal_dev_size',
fieldLabel: `${gettext('WAL size')} (${gettext('GiB')})`,
minValue: 0.5,
maxValue: 128 * 1024,
decimalPrecision: 2,
allowBlank: true,
disabled: true,
emptyText: gettext('Automatic'),
},
],
},
{
xtype: 'displayfield',
padding: '5 0 0 0',
userCls: 'pmx-hint',
value:
'Note: Ceph is not compatible with disks backed by a hardware ' +
'RAID controller. For details see ' +
'<a target="_blank" href="' +
Proxmox.Utils.get_help_link('chapter_pveceph') +
'">the reference documentation</a>.',
},
],
});
me.callParent();
},
});
Ext.define('PVE.CephRemoveOsd', {
extend: 'Proxmox.window.Edit',
alias: ['widget.pveCephRemoveOsd'],
isRemove: true,
showProgress: true,
method: 'DELETE',
items: [
{
xtype: 'proxmoxcheckbox',
name: 'cleanup',
checked: true,
labelWidth: 130,
fieldLabel: gettext('Cleanup Disks'),
},
{
xtype: 'displayfield',
name: 'osd-flag-hint',
userCls: 'pmx-hint',
value: gettext('Global flags limiting the self healing of Ceph are enabled.'),
hidden: true,
},
{
xtype: 'displayfield',
name: 'degraded-objects-hint',
userCls: 'pmx-hint',
value: gettext('Objects are degraded. Consider waiting until the cluster is healthy.'),
hidden: true,
},
],
initComponent: function () {
let me = this;
if (!me.nodename) {
throw 'no node name specified';
}
if (me.osdid === undefined || me.osdid < 0) {
throw 'no osdid specified';
}
me.isCreate = true;
me.title = gettext('Destroy') + ': Ceph OSD osd.' + me.osdid.toString();
Ext.applyIf(me, {
url: '/nodes/' + me.nodename + '/ceph/osd/' + me.osdid.toString(),
});
me.callParent();
if (me.warnings.flags) {
me.down('field[name=osd-flag-hint]').setHidden(false);
}
if (me.warnings.degraded) {
me.down('field[name=degraded-objects-hint]').setHidden(false);
}
},
});
Ext.define('PVE.CephSetFlags', {
extend: 'Proxmox.window.Edit',
xtype: 'pveCephSetFlags',
showProgress: true,
width: 720,
layout: 'fit',
onlineHelp: 'pve_ceph_osds',
isCreate: true,
title: gettext('Manage Global OSD Flags'),
submitText: gettext('Apply'),
items: [
{
xtype: 'inputpanel',
onGetValues: function (values) {
let me = this;
let val = {};
me.down('#flaggrid')
.getStore()
.each((rec) => {
val[rec.data.name] = rec.data.value ? 1 : 0;
});
return val;
},
items: [
{
xtype: 'grid',
itemId: 'flaggrid',
store: {
listeners: {
update: function () {
this.commitChanges();
},
},
},
columns: [
{
text: gettext('Enable'),
xtype: 'checkcolumn',
width: 75,
dataIndex: 'value',
},
{
text: 'Name',
dataIndex: 'name',
},
{
text: 'Description',
flex: 1,
dataIndex: 'description',
renderer: Ext.htmlEncode,
},
],
},
],
},
],
initComponent: function () {
let me = this;
if (!me.nodename) {
throw 'no node name specified';
}
Ext.applyIf(me, {
url: '/cluster/ceph/flags',
method: 'PUT',
});
me.callParent();
let grid = me.down('#flaggrid');
me.load({
success: function (response, options) {
let data = response.result.data;
grid.getStore().setData(data);
// re-align after store load, else the window is not centered
me.alignTo(Ext.getBody(), 'c-c');
},
});
},
});
Ext.define('PVE.window.CephBulkRestartOSDs', {
extend: 'Ext.window.Window',
width: 600,
title: gettext('Bulk Restart OSDs'),
modal: true,
resizable: false,
layout: 'fit',
// required: { host: count, ... } enumerated from the tree
osdsByHost: undefined,
// optional: the currently-navigated node, used to pre-select that node in the combo
nodename: undefined,
// required: function(upid) called after the API accepts the request
onTaskStarted: undefined,
initComponent: function () {
let me = this;
if (!me.osdsByHost || !me.onTaskStarted) {
throw 'osdsByHost and onTaskStarted are required';
}
let totalOsds = Object.values(me.osdsByHost).reduce((a, b) => a + b, 0);
let hosts = Object.keys(me.osdsByHost).sort();
// Combo store: only per-host entries. Cluster-wide scope is its own checkbox
// below that disables the combo when checked.
let hostStore = hosts.map((host) => {
let c = me.osdsByHost[host];
return {
value: host,
label: Ext.String.format(
ngettext("Node '{0}' ({1} OSD)", "Node '{0}' ({1} OSDs)", c),
host,
c,
),
};
});
// Default node: the currently-navigated node if it has OSDs, otherwise the
// first node with OSDs. The 'all nodes' checkbox starts off (per-node action
// is the common case and the dialog opened from a node-level toolbar).
let defaultNode = me.nodename && me.osdsByHost[me.nodename] ? me.nodename : hosts[0];
// Populated asynchronously from /cluster/ceph/metadata. Keyed by host:
// count of OSDs whose ceph_version_short differs from the host's installed
// ceph package version. Used when the 'only outdated' checkbox is on.
// Falls back to "up to N" wording while undefined or if the fetch failed.
let outdatedByHost; // { host: count } once loaded, undef while pending
let computeAffected = function (allNodes, node, onlyOutdated) {
let maxCount = allNodes ? totalOsds : me.osdsByHost[node] || 0;
let scope = allNodes
? gettext('cluster-wide')
: Ext.String.format(gettext("on node '{0}'"), node);
let count, affected;
if (onlyOutdated && outdatedByHost) {
count = allNodes
? Object.values(outdatedByHost).reduce((a, b) => a + b, 0)
: outdatedByHost[node] || 0;
affected = Ext.String.format(
ngettext(
'{0} outdated OSD {1} (of {2} total)',
'{0} outdated OSDs {1} (of {2} total)',
count,
),
count,
scope,
maxCount,
);
} else if (onlyOutdated) {
// metadata not yet loaded - acknowledge uncertainty
count = maxCount;
affected = Ext.String.format(
ngettext(
'up to {0} OSD {1} (outdated filter, exact count at task start)',
'up to {0} OSDs {1} (outdated filter, exact count at task start)',
maxCount,
),
maxCount,
scope,
);
} else {
count = maxCount;
affected = Ext.String.format(
ngettext('{0} OSD {1}', '{0} OSDs {1}', count),
count,
scope,
);
}
let mins = Math.max(2, count * 2);
return {
affected,
duration: Ext.String.format(
gettext(
'Approximately 2 minutes per OSD (total {0} minutes, depending on cluster recovery speed).',
),
mins,
),
};
};
let initial = computeAffected(false, defaultNode, false);
let refreshDisplay = function () {
let allNodes = me.down('#allNodesCheck').getValue();
let node = me.down('#nodeSelector').getValue();
let onlyOutdated = me.down('#onlyOutdatedCheck').getValue();
let info = computeAffected(allNodes, node, onlyOutdated);
me.down('#affectedField').setValue(info.affected);
me.down('#durationField').setValue(info.duration);
};
// Compute outdated counts from cluster metadata: per host, count OSDs whose
// running ceph_version_short does not match the host's installed package
// version. An approximation of the backend's exact check (which compares
// full "version (commit)" tuples); the task log shows authoritative numbers.
// Fetched lazily the first time the "only outdated" filter is enabled, so a
// plain per-node restart needs no cluster metadata round-trip (which also
// spares users holding only Sys.Modify a pointless 403 on every dialog open).
let outdatedRequested = false;
let loadOutdatedCounts = function () {
if (outdatedRequested) {
return;
}
outdatedRequested = true;
Proxmox.Utils.API2Request({
url: '/cluster/ceph/metadata',
method: 'GET',
params: { scope: 'all' },
success: function (response) {
if (me.destroyed) {
return;
}
let d = response.result.data || {};
let nodeVer = {};
for (const [n, info] of Object.entries(d.node || {})) {
nodeVer[n] = info?.version?.str;
}
let counts = {};
for (const osd of d.osd || []) {
let host = osd.hostname;
if (!host) {
continue;
}
if (!Object.hasOwn(nodeVer, host) && !Object.hasOwn(me.osdsByHost, host)) {
let short = host.split('.')[0];
if (
Object.hasOwn(nodeVer, short) ||
Object.hasOwn(me.osdsByHost, short)
) {
host = short;
}
}
counts[host] ||= 0;
let installed = nodeVer[host];
if (!installed || osd.ceph_version_short !== installed) {
counts[host]++;
}
}
outdatedByHost = counts;
refreshDisplay();
},
failure: () => {
/* leave outdatedByHost undef; UI shows "up to N" wording */
},
});
};
// Warn before submit rather than letting the task error explain it. Only the checks
// the backend calls blocking, so an upgraded cluster is not warned about a restart
// that then succeeds.
Proxmox.Utils.API2Request({
url: '/cluster/ceph/status',
method: 'GET',
success: function (response) {
if (me.destroyed) {
return;
}
let checks = response.result.data?.health?.checks || {};
let blocking = Object.keys(checks)
.sort()
.filter((k) => checks[k]['blocks-restart']?.osd);
if (!blocking.length) {
return;
}
let items = blocking
.map(
(k) =>
'<li>' +
Ext.String.htmlEncode(checks[k].summary?.message || k) +
'</li>',
)
.join('');
let forceable = blocking.every((k) => checks[k].severity !== 'HEALTH_ERR');
me.down('#forceCheck').setHidden(!forceable);
let hint = me.down('#healthHint');
hint.setValue(
'<i class="fa fa-exclamation-triangle warning"></i> ' +
(forceable
? gettext(
'These block a rolling restart. Enable Force to proceed' +
' anyway:',
)
: gettext(
'These block a rolling restart and Force cannot override' +
' them:',
)) +
`<ul>${items}</ul>`,
);
hint.setHidden(false);
},
failure: (response) => {
if (me.destroyed) {
return;
}
// A 403 only means this user may not read the health, the backend still can,
// so the restart runs as usual and force stays available. Anything else means
// the backend hits the same wall, and force cannot override that.
let hint = me.down('#healthHint');
if (response.status === 403) {
me.down('#forceCheck').setHidden(false);
return;
}
hint.setValue(
'<i class="fa fa-exclamation-triangle warning"></i> ' +
Ext.String.format(
gettext(
'The cluster health could not be read ({0}). A restart is likely' +
' to be refused for the same reason, so check the cluster' +
' status first.',
),
Ext.String.htmlEncode(response.htmlStatus || response.status),
),
);
hint.setHidden(false);
},
});
Ext.apply(me, {
items: [
{
xtype: 'form',
bodyPadding: 10,
border: false,
fieldDefaults: { labelWidth: 100, anchor: '100%' },
items: [
{
xtype: 'combobox',
itemId: 'nodeSelector',
fieldLabel: gettext('Node'),
editable: false,
queryMode: 'local',
valueField: 'value',
displayField: 'label',
store: {
fields: ['value', 'label'],
data: hostStore,
},
value: defaultNode,
listeners: { change: refreshDisplay },
},
{
xtype: 'fieldcontainer',
fieldLabel: gettext('Apply to'),
layout: 'hbox',
items: [
{
xtype: 'proxmoxcheckbox',
itemId: 'allNodesCheck',
boxLabel: gettext('all nodes (cluster-wide)'),
uncheckedValue: 0,
checked: false,
margin: '0 20 0 0',
listeners: {
change: function (cb, val) {
me.down('#nodeSelector').setDisabled(!!val);
refreshDisplay();
},
},
},
{
xtype: 'proxmoxcheckbox',
itemId: 'onlyOutdatedCheck',
boxLabel: gettext('only outdated OSD versions'),
boxLabelAttrTpl:
'data-qtip="' +
Ext.String.htmlEncode(
gettext(
'Restart only OSDs whose running version differs' +
' from the locally-installed ceph-osd' +
' binary on each node. Useful for' +
' post-upgrade rolling restarts.',
),
) +
'"',
uncheckedValue: 0,
checked: false,
listeners: {
change: function (cb, val) {
if (val) {
loadOutdatedCounts();
}
refreshDisplay();
},
},
},
],
},
{
xtype: 'displayfield',
itemId: 'affectedField',
fieldLabel: gettext('Affected'),
value: initial.affected,
},
{
xtype: 'displayfield',
itemId: 'durationField',
fieldLabel: gettext('Duration'),
value: initial.duration,
},
{
xtype: 'displayfield',
fieldLabel: gettext('Notes'),
value: gettext(
"OSDs are restarted serially with a per-step 'ok-to-stop' check." +
" 'noout' is applied per-OSD for the duration and unset on" +
' completion. A host crash or SIGKILL of the task can leave' +
" 'noout' set on the affected OSDs; see the Ceph chapter of" +
' the Proxmox VE documentation for cleanup steps.',
),
},
// last, so that a cluster with blocking checks grows the dialog
// downwards instead of pushing every field further down
{
xtype: 'displayfield',
itemId: 'healthHint',
fieldLabel: gettext('Health Issues'),
hidden: true,
},
{
xtype: 'proxmoxcheckbox',
itemId: 'forceCheck',
fieldLabel: gettext('Force'),
boxLabel: gettext('proceed past the blocking health checks'),
hidden: true,
uncheckedValue: 0,
checked: false,
},
],
},
],
buttons: [
{
xtype: 'proxmoxHelpButton',
onlineHelp: 'pve_ceph_osds',
hidden: false,
},
'->',
{ text: gettext('Cancel'), handler: () => me.close() },
{
text: gettext('Restart'),
iconCls: 'fa fa-refresh',
handler: function () {
let allNodes = me.down('#allNodesCheck').getValue();
let onlyOutdated = me.down('#onlyOutdatedCheck').getValue();
let url = allNodes
? '/cluster/ceph/restart-bulk'
: `/nodes/${me.down('#nodeSelector').getValue()}/ceph/restart-bulk`;
let params = { 'service-type': 'osd' };
if (onlyOutdated) {
params['only-outdated'] = 1;
}
if (me.down('#forceCheck').getValue()) {
params.force = 1;
}
Proxmox.Utils.API2Request({
url,
method: 'POST',
params,
waitMsgTarget: me,
success: function (response) {
me.close();
me.onTaskStarted(response.result.data);
},
failure: (response) =>
Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
},
},
],
});
me.callParent();
},
});
Ext.define('PVE.node.CephOsdTree', {
extend: 'Ext.tree.Panel',
alias: ['widget.pveNodeCephOsdTree'],
onlineHelp: 'chapter_pveceph',
viewModel: {
data: {
nodename: '',
flags: [],
maxversion: '0',
mixedversions: false,
versions: {},
isOsd: false,
downOsd: false,
upOsd: false,
inOsd: false,
outOsd: false,
osdid: '',
osdhost: '',
},
},
controller: {
xclass: 'Ext.app.ViewController',
reload: function () {
let me = this;
let view = me.getView();
let vm = me.getViewModel();
let nodename = vm.get('nodename');
let sm = view.getSelectionModel();
Proxmox.Utils.API2Request({
url: '/nodes/' + nodename + '/ceph/osd',
waitMsgTarget: view,
method: 'GET',
failure: function (response, opts) {
let msg = response.htmlStatus;
PVE.Utils.showCephInstallOrMask(view, msg, nodename, (win) =>
view.mon(win, 'cephInstallWindowClosed', () => {
me.reload();
}),
);
},
success: function (response, opts) {
let data = response.result.data;
let selected = view.getSelection();
let name;
if (selected.length) {
name = selected[0].data.name;
}
data.versions = data.versions || {};
vm.set('versions', data.versions);
// extract max version
let maxversion = '0';
let mixedversions = false;
let traverse;
traverse = function (node, fn) {
fn(node);
if (Array.isArray(node.children)) {
node.children.forEach((c) => {
traverse(c, fn);
});
}
};
traverse(data.root, (node) => {
// compatibility for old api call
if (node.type === 'host' && !node.version) {
node.version = data.versions[node.name];
}
if (node.version === undefined) {
return;
}
if (
PVE.Utils.compare_ceph_versions(node.version, maxversion) !== 0 &&
maxversion !== '0'
) {
mixedversions = true;
}
if (PVE.Utils.compare_ceph_versions(node.version, maxversion) > 0) {
maxversion = node.version;
}
});
vm.set('maxversion', maxversion);
vm.set('mixedversions', mixedversions);
sm.deselectAll();
view.setRootNode(data.root);
view.expandAll();
if (name) {
let node = view.getRootNode().findChild('name', name, true);
if (node) {
view.setSelection([node]);
}
}
let flags = data.flags.split(',');
vm.set('flags', flags);
},
});
},
osd_cmd: function (comp) {
let me = this;
let vm = this.getViewModel();
let cmd = comp.cmd;
let params = comp.params || {};
let osdid = vm.get('osdid');
let doRequest = function () {
let targetnode = vm.get('osdhost');
// cmds not node specific and need to work if the OSD node is down
if (['in', 'out'].includes(cmd)) {
targetnode = vm.get('nodename');
}
Proxmox.Utils.API2Request({
url: `/nodes/${targetnode}/ceph/osd/${osdid}/${cmd}`,
waitMsgTarget: me.getView(),
method: 'POST',
params: params,
success: () => {
me.reload();
},
failure: function (response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
};
if (cmd === 'scrub') {
Ext.MessageBox.defaultButton = params.deep === 1 ? 2 : 1;
Ext.Msg.show({
title: gettext('Confirm'),
icon: params.deep === 1 ? Ext.Msg.WARNING : Ext.Msg.QUESTION,
msg:
params.deep !== 1
? Ext.String.format(gettext('Scrub OSD.{0}'), osdid)
: Ext.String.format(gettext('Deep Scrub OSD.{0}'), osdid) +
'<br>Caution: This can reduce performance while it is running.',
buttons: Ext.Msg.YESNO,
callback: function (btn) {
if (btn !== 'yes') {
return;
}
doRequest();
},
});
} else {
doRequest();
}
},
create_osd: function () {
let me = this;
let vm = this.getViewModel();
Ext.create('PVE.CephCreateOsd', {
nodename: vm.get('nodename'),
taskDone: () => {
me.reload();
},
}).show();
},
destroy_osd: async function () {
let me = this;
let vm = this.getViewModel();
let warnings = {
flags: false,
degraded: false,
};
let flagsPromise = Proxmox.Async.api2({
url: `/cluster/ceph/flags`,
method: 'GET',
});
let statusPromise = Proxmox.Async.api2({
url: `/cluster/ceph/status`,
method: 'GET',
});
me.getView().mask(gettext('Loading...'));
try {
let result = await Promise.all([flagsPromise, statusPromise]);
let flagsData = result[0].result.data;
let statusData = result[1].result.data;
let flags = Array.from(
flagsData.filter((v) => v.value),
(v) => v.name,
).filter((v) => ['norebalance', 'norecover', 'noout'].includes(v));
if (flags.length) {
warnings.flags = true;
}
if (Object.keys(statusData.pgmap).includes('degraded_objects')) {
warnings.degraded = true;
}
} catch (error) {
Ext.Msg.alert(gettext('Error'), error.htmlStatus);
me.getView().unmask();
return;
}
me.getView().unmask();
Ext.create('PVE.CephRemoveOsd', {
nodename: vm.get('osdhost'),
osdid: vm.get('osdid'),
warnings: warnings,
taskDone: () => {
me.reload();
},
autoShow: true,
});
},
set_flags: function () {
let me = this;
let vm = this.getViewModel();
Ext.create('PVE.CephSetFlags', {
nodename: vm.get('nodename'),
taskDone: () => {
me.reload();
},
}).show();
},
bulk_restart_osds: function () {
let me = this;
let vm = this.getViewModel();
let nodename = vm.get('nodename');
// Walk the full node tree to count OSDs per host; cascadeBy descends collapsed
// nodes too, which a flat store iteration would skip. Each OSD record's tree path
// includes its parent host bucket (the CRUSH tree's typical layout), so we walk
// up parentNode until we find a host entry.
let osdsByHost = {};
me.getView()
.getRootNode()
.cascadeBy(function (rec) {
if (rec.data.type !== 'osd') {
return;
}
let p = rec.parentNode;
while (p && p.data.type !== 'host') {
p = p.parentNode;
}
if (p) {
let h = p.data.name;
osdsByHost[h] = (osdsByHost[h] || 0) + 1;
}
});
if (!Object.keys(osdsByHost).length) {
Ext.Msg.alert(
gettext('No OSDs'),
gettext('No OSDs are configured in the cluster. Nothing to restart.'),
);
return;
}
Ext.create('PVE.window.CephBulkRestartOSDs', {
autoShow: true,
osdsByHost,
nodename,
onTaskStarted: (upid) => {
Ext.create('Proxmox.window.TaskProgress', {
autoShow: true,
upid,
taskDone: () => me.reload(),
});
},
});
},
service_cmd: function (comp) {
let me = this;
let vm = this.getViewModel();
let cmd = comp.cmd || comp;
let doRequest = function () {
Proxmox.Utils.API2Request({
url: `/nodes/${vm.get('osdhost')}/ceph/${cmd}`,
params: { service: 'osd.' + vm.get('osdid') },
waitMsgTarget: me.getView(),
method: 'POST',
success: function (response, options) {
let upid = response.result.data;
let win = Ext.create('Proxmox.window.TaskProgress', {
upid: upid,
taskDone: () => {
me.reload();
},
});
win.show();
},
failure: function (response, opts) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
});
};
if (cmd === 'stop') {
Proxmox.Utils.API2Request({
url: `/nodes/${vm.get('osdhost')}/ceph/cmd-safety`,
params: {
service: 'osd',
id: vm.get('osdid'),
action: 'stop',
},
waitMsgTarget: me.getView(),
method: 'GET',
success: function ({ result: { data } }) {
if (!data.safe) {
Ext.Msg.show({
title: ngettext('Warning', 'Warnings', 1),
message: data.status,
icon: Ext.Msg.WARNING,
buttons: Ext.Msg.OKCANCEL,
buttonText: { ok: gettext('Stop OSD') },
fn: function (selection) {
if (selection === 'ok') {
doRequest();
}
},
});
} else {
doRequest();
}
},
failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus),
});
} else {
doRequest();
}
},
run_details: function (view, rec) {
if (rec.data.host && rec.data.type === 'osd' && rec.data.id >= 0) {
this.details();
}
},
details: function () {
let vm = this.getViewModel();
Ext.create('PVE.CephOsdDetails', {
nodename: vm.get('osdhost'),
osdid: vm.get('osdid'),
}).show();
},
set_selection_status: function (tp, selection) {
if (selection.length < 1) {
return;
}
let rec = selection[0];
let vm = this.getViewModel();
let isOsd = rec.data.host && rec.data.type === 'osd' && rec.data.id >= 0;
vm.set('isOsd', isOsd);
vm.set('downOsd', isOsd && rec.data.status === 'down');
vm.set('upOsd', isOsd && rec.data.status !== 'down');
vm.set('inOsd', isOsd && rec.data.in);
vm.set('outOsd', isOsd && !rec.data.in);
vm.set('osdid', isOsd ? rec.data.id : undefined);
vm.set('osdhost', isOsd ? rec.data.host : undefined);
},
render_status: function (value, metaData, rec) {
if (!value) {
return value;
}
let inout = rec.data.in ? 'in' : 'out';
let updownicon =
value === 'up' ? 'good fa-arrow-circle-up' : 'critical fa-arrow-circle-down';
let inouticon = rec.data.in ? 'good fa-circle' : 'warning fa-circle-o';
let text =
value +
' <i class="fa ' +
updownicon +
'"></i> / ' +
inout +
' <i class="fa ' +
inouticon +
'"></i>';
return text;
},
render_wal: function (value, metaData, rec) {
if (!value && rec.data.osdtype === 'bluestore' && rec.data.type === 'osd') {
return 'N/A';
}
return value;
},
render_version: function (value, metadata, rec) {
let vm = this.getViewModel();
let versions = vm.get('versions');
let icon = '';
let version = value || '';
let maxversion = vm.get('maxversion');
if (value && PVE.Utils.compare_ceph_versions(value, maxversion) !== 0) {
let host_version = rec.parentNode?.data?.version || versions[rec.data.host] || '';
if (
rec.data.type === 'host' ||
PVE.Utils.compare_ceph_versions(host_version, maxversion) !== 0
) {
icon = PVE.Utils.get_ceph_icon_html('HEALTH_UPGRADE');
} else {
icon = PVE.Utils.get_ceph_icon_html('HEALTH_OLD');
}
} else if (value && vm.get('mixedversions')) {
icon = PVE.Utils.get_ceph_icon_html('HEALTH_OK');
}
return icon + version;
},
render_osd_val: function (value, metaData, rec) {
return rec.data.type === 'osd' ? value : '';
},
render_osd_weight: function (value, metaData, rec) {
if (rec.data.type !== 'osd') {
return '';
}
return Ext.util.Format.number(value, '0.00###');
},
render_osd_latency: function (value, metaData, rec) {
if (rec.data.type !== 'osd') {
return '';
}
let commit_ms = rec.data.commit_latency_ms,
apply_ms = rec.data.apply_latency_ms;
return apply_ms + ' / ' + commit_ms;
},
render_osd_size: function (value, metaData, rec) {
return this.render_osd_val(Proxmox.Utils.render_size(value), metaData, rec);
},
control: {
'#': {
selectionchange: 'set_selection_status',
},
},
init: function (view) {
let me = this;
let vm = this.getViewModel();
if (!view.pveSelNode.data.node) {
throw 'no node name specified';
}
vm.set('nodename', view.pveSelNode.data.node);
me.callParent();
me.reload();
},
},
stateful: true,
stateId: 'grid-ceph-osd',
rootVisible: false,
useArrows: true,
listeners: {
itemdblclick: 'run_details',
},
columns: [
{
xtype: 'treecolumn',
text: 'Name',
dataIndex: 'name',
width: 150,
sorter: (a, b) => a.data.name.localeCompare(b.data.name, undefined, { numeric: true }),
},
{
text: 'Type',
dataIndex: 'type',
hidden: true,
align: 'right',
width: 75,
},
{
text: gettext('Class'),
dataIndex: 'device_class',
align: 'right',
width: 75,
renderer: Ext.htmlEncode,
},
{
text: 'OSD Type',
dataIndex: 'osdtype',
align: 'right',
width: 100,
},
{
text: 'Bluestore Device',
dataIndex: 'blfsdev',
align: 'right',
width: 75,
hidden: true,
renderer: Ext.htmlEncode,
},
{
text: 'DB Device',
dataIndex: 'dbdev',
align: 'right',
width: 75,
hidden: true,
renderer: Ext.htmlEncode,
},
{
text: 'WAL Device',
dataIndex: 'waldev',
align: 'right',
renderer: 'render_wal',
width: 75,
hidden: true,
},
{
text: 'Status',
dataIndex: 'status',
align: 'right',
renderer: 'render_status',
width: 120,
},
{
text: gettext('Version'),
dataIndex: 'version',
align: 'right',
renderer: 'render_version',
},
{
text: 'weight',
dataIndex: 'crush_weight',
align: 'right',
renderer: 'render_osd_weight',
width: 90,
},
{
text: 'reweight',
dataIndex: 'reweight',
align: 'right',
renderer: 'render_osd_weight',
width: 90,
},
{
text: gettext('Used') + ' (%)',
dataIndex: 'percent_used',
align: 'right',
renderer: function (value, metaData, rec) {
if (rec.data.type !== 'osd') {
return '';
}
return Ext.util.Format.number(value, '0.00');
},
width: 100,
},
{
text: gettext('Total'),
dataIndex: 'total_space',
align: 'right',
renderer: 'render_osd_size',
width: 100,
},
{
text: 'Apply/Commit<br>Latency (ms)',
dataIndex: 'apply_latency_ms',
align: 'right',
renderer: 'render_osd_latency',
width: 120,
},
{
text: 'PGs',
dataIndex: 'pgs',
align: 'right',
renderer: 'render_osd_val',
width: 90,
},
],
tbar: {
overflowHandler: 'scroller',
items: [
{
text: gettext('Reload'),
iconCls: 'fa fa-refresh',
handler: 'reload',
},
'-',
{
text: gettext('Create: OSD'),
handler: 'create_osd',
},
{
text: gettext('Manage Global Flags'),
handler: 'set_flags',
},
{
text: gettext('Bulk Restart OSDs'),
iconCls: 'fa fa-refresh',
handler: 'bulk_restart_osds',
},
'->',
{
xtype: 'tbtext',
data: {
osd: undefined,
},
bind: {
data: {
osd: '{osdid}',
},
},
tpl: [
'<tpl if="osd">',
'osd.{osd}:',
'<tpl else>',
gettext('No OSD selected'),
'</tpl>',
],
},
{
text: gettext('Details'),
iconCls: 'fa fa-info-circle',
disabled: true,
bind: {
disabled: '{!isOsd}',
},
handler: 'details',
},
{
text: gettext('Start'),
iconCls: 'fa fa-play',
disabled: true,
bind: {
disabled: '{!downOsd}',
},
cmd: 'start',
handler: 'service_cmd',
},
{
text: gettext('Stop'),
iconCls: 'fa fa-stop',
disabled: true,
bind: {
disabled: '{!upOsd}',
},
cmd: 'stop',
handler: 'service_cmd',
},
{
text: gettext('Restart'),
iconCls: 'fa fa-refresh',
disabled: true,
bind: {
disabled: '{!upOsd}',
},
cmd: 'restart',
handler: 'service_cmd',
},
'-',
{
text: 'Out',
iconCls: 'fa fa-circle-o',
disabled: true,
bind: {
disabled: '{!inOsd}',
},
cmd: 'out',
handler: 'osd_cmd',
},
{
text: 'In',
iconCls: 'fa fa-circle',
disabled: true,
bind: {
disabled: '{!outOsd}',
},
cmd: 'in',
handler: 'osd_cmd',
},
'-',
{
text: gettext('More'),
iconCls: 'fa fa-bars',
disabled: true,
bind: {
disabled: '{!isOsd}',
},
menu: [
{
text: gettext('Scrub'),
iconCls: 'fa fa-shower',
cmd: 'scrub',
handler: 'osd_cmd',
},
{
text: gettext('Deep Scrub'),
iconCls: 'fa fa-bath',
cmd: 'scrub',
params: {
deep: 1,
},
handler: 'osd_cmd',
},
{
text: gettext('Destroy'),
itemId: 'remove',
iconCls: 'fa fa-fw fa-trash-o',
bind: {
disabled: '{!downOsd}',
},
handler: 'destroy_osd',
},
],
},
],
},
fields: [
'name',
'type',
'status',
'host',
'in',
'id',
{ type: 'number', name: 'reweight' },
{ type: 'number', name: 'percent_used' },
{ type: 'integer', name: 'bytes_used' },
{ type: 'integer', name: 'total_space' },
{ type: 'integer', name: 'apply_latency_ms' },
{ type: 'integer', name: 'commit_latency_ms' },
{ type: 'string', name: 'device_class' },
{ type: 'string', name: 'osdtype' },
{ type: 'string', name: 'blfsdev' },
{ type: 'string', name: 'dbdev' },
{ type: 'string', name: 'waldev' },
{
type: 'string',
name: 'version',
calculate: function (data) {
return PVE.Utils.parse_ceph_version(data);
},
},
{
type: 'string',
name: 'iconCls',
calculate: function (data) {
let iconMap = {
host: 'fa-building',
osd: 'fa-hdd-o',
root: 'fa-server',
};
return `fa x-fa-tree ${iconMap[data.type] ?? 'fa-folder-o'}`;
},
},
{ type: 'number', name: 'crush_weight' },
],
});