diff --git a/www/manager6/ceph/OSD.js b/www/manager6/ceph/OSD.js
new file mode 100644
index 0000000..e7e91eb
--- /dev/null
+++ b/www/manager6/ceph/OSD.js
@@ -0,0 +1,1490 @@
+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 ' +
+ 'the reference documentation.',
+ },
+ ],
+ });
+
+ 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) =>
+ '
' +
+ Ext.String.htmlEncode(checks[k].summary?.message || k) +
+ '',
+ )
+ .join('');
+ let forceable = blocking.every((k) => checks[k].severity !== 'HEALTH_ERR');
+ me.down('#forceCheck').setHidden(!forceable);
+ let hint = me.down('#healthHint');
+ hint.setValue(
+ ' ' +
+ (forceable
+ ? gettext(
+ 'These block a rolling restart. Enable Force to proceed' +
+ ' anyway:',
+ )
+ : gettext(
+ 'These block a rolling restart and Force cannot override' +
+ ' them:',
+ )) +
+ ``,
+ );
+ 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(
+ ' ' +
+ 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) +
+ '
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 +
+ ' / ' +
+ inout +
+ ' ';
+
+ 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,
+ },
+ {
+ 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
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: [
+ '',
+ 'osd.{osd}:',
+ '',
+ gettext('No OSD selected'),
+ '',
+ ],
+ },
+ {
+ 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' },
+ ],
+});