]> git.proxmox.com Git - proxmox-backup.git/blob - www/tape/ChangerStatus.js
ui: tape/ChangerStatus: hide selector for single drives in barcode-label
[proxmox-backup.git] / www / tape / ChangerStatus.js
1 Ext.define('pbs-slot-model', {
2 extend: 'Ext.data.Model',
3 fields: ['entry-id', 'label-text', 'is-labeled', ' model', 'name', 'vendor', 'serial', 'state', 'status', 'pool',
4 {
5 name: 'is-blocked',
6 calculate: function(data) {
7 return data.state !== undefined;
8 },
9 },
10 ],
11 idProperty: 'entry-id',
12 });
13
14 Ext.define('PBS.TapeManagement.FreeSlotSelector', {
15 extend: 'Proxmox.form.ComboGrid',
16 alias: 'widget.pbsFreeSlotSelector',
17
18 valueField: 'id',
19 displayField: 'id',
20
21 listConfig: {
22 columns: [
23 {
24 dataIndex: 'id',
25 text: gettext('ID'),
26 flex: 1,
27 },
28 {
29 dataIndex: 'type',
30 text: gettext('Type'),
31 flex: 1,
32 },
33 ],
34 },
35 });
36
37 Ext.define('PBS.TapeManagement.ChangerStatus', {
38 extend: 'Ext.panel.Panel',
39 alias: 'widget.pbsChangerStatus',
40
41 tools: [PBS.Utils.get_help_tool("tape_backup")],
42
43 controller: {
44 xclass: 'Ext.app.ViewController',
45
46 importTape: function(v, rI, cI, button, el, record) {
47 let me = this;
48 let view = me.getView();
49 let from = record.data['entry-id'];
50 let changer = encodeURIComponent(view.changer);
51 Ext.create('Proxmox.window.Edit', {
52 title: gettext('Import'),
53 isCreate: true,
54 submitText: gettext('OK'),
55 method: 'POST',
56 url: `/api2/extjs/tape/changer/${changer}/transfer`,
57 items: [
58 {
59 xtype: 'displayfield',
60 name: 'from',
61 value: from,
62 submitValue: true,
63 fieldLabel: gettext('From Slot'),
64 },
65 {
66 xtype: 'pbsFreeSlotSelector',
67 name: 'to',
68 fieldLabel: gettext('To Slot'),
69 store: {
70 data: me.free_slots,
71 },
72 },
73 ],
74 listeners: {
75 destroy: function() {
76 me.reload();
77 },
78 },
79 }).show();
80 },
81
82 slotTransfer: function(v, rI, cI, button, el, record) {
83 let me = this;
84 let view = me.getView();
85 let from = record.data['entry-id'];
86 let changer = encodeURIComponent(view.changer);
87 Ext.create('Proxmox.window.Edit', {
88 title: gettext('Transfer'),
89 isCreate: true,
90 submitText: gettext('OK'),
91 method: 'POST',
92 url: `/api2/extjs/tape/changer/${changer}/transfer`,
93 items: [
94 {
95 xtype: 'displayfield',
96 name: 'from',
97 value: from,
98 submitValue: true,
99 fieldLabel: gettext('From Slot'),
100 },
101 {
102 xtype: 'pbsFreeSlotSelector',
103 name: 'to',
104 fieldLabel: gettext('To Slot'),
105 store: {
106 data: me.free_slots.concat(me.free_ie_slots),
107 },
108 },
109 ],
110 listeners: {
111 destroy: function() {
112 me.reload();
113 },
114 },
115 }).show();
116 },
117
118 labelMedia: function(button, event, record) {
119 let me = this;
120 Ext.create('PBS.TapeManagement.LabelMediaWindow', {
121 driveid: record.data.name,
122 label: record.data["label-text"],
123 }).show();
124 },
125
126 catalog: function(button, event, record) {
127 let me = this;
128
129 let view = me.getView();
130 PBS.Utils.driveCommand(record.data.name, 'catalog', {
131 waitMsgTarget: view,
132 method: 'POST',
133 success: function(response) {
134 Ext.create('Proxmox.window.TaskViewer', {
135 upid: response.result.data,
136 }).show();
137 },
138 });
139 },
140
141 erase: function(v, rI, cI, button, el, record) {
142 let me = this;
143 let view = me.getView();
144 let label = record.data['label-text'];
145
146 let changer = encodeURIComponent(view.changer);
147 let singleDrive = me.drives.length === 1 ? me.drives[0] : undefined;
148 Ext.create('PBS.TapeManagement.EraseWindow', {
149 label,
150 changer,
151 singleDrive,
152 listeners: {
153 destroy: function() {
154 me.reload();
155 },
156 },
157 }).show();
158 },
159
160 load: function(v, rI, cI, button, el, record) {
161 let me = this;
162 let view = me.getView();
163 let label = record.data['label-text'];
164
165 let changer = encodeURIComponent(view.changer);
166 let singleDrive = me.drives.length === 1 ? me.drives[0] : undefined;
167
168 if (singleDrive !== undefined) {
169 Proxmox.Utils.API2Request({
170 method: 'POST',
171 params: {
172 'label-text': label,
173 },
174 url: `/api2/extjs/tape/drive/${singleDrive}/load-media`,
175 success: function(response, opt) {
176 Ext.create('Proxmox.window.TaskProgress', {
177 upid: response.result.data,
178 taskDone: function(success) {
179 me.reload();
180 },
181 }).show();
182 },
183 failure: function(response, opt) {
184 Ext.Msg.alert(gettext('Error'), response.htmlStatus);
185 },
186 });
187 } else {
188 Ext.create('Proxmox.window.Edit', {
189 isCreate: true,
190 autoShow: true,
191 submitText: gettext('OK'),
192 title: gettext('Load Media into Drive'),
193 url: `/api2/extjs/tape/drive`,
194 method: 'POST',
195 submitUrl: function(url, values) {
196 let drive = values.drive;
197 delete values.drive;
198 return `${url}/${encodeURIComponent(drive)}/load-media`;
199 },
200 items: [
201 {
202 xtype: 'displayfield',
203 name: 'label-text',
204 value: label,
205 submitValue: true,
206 fieldLabel: gettext('Media'),
207 },
208 {
209 xtype: 'pbsDriveSelector',
210 fieldLabel: gettext('Drive'),
211 changer: changer,
212 name: 'drive',
213 },
214 ],
215 listeners: {
216 destroy: function() {
217 me.reload();
218 },
219 },
220 });
221 }
222 },
223
224 unload: async function(v, rI, cI, button, el, record) {
225 let me = this;
226 let drive = record.data.name;
227 try {
228 await PBS.Async.api2({
229 method: 'POST',
230 timeout: 5*60*1000,
231 url: `/api2/extjs/tape/drive/${encodeURIComponent(drive)}/unload`,
232 });
233 } catch (error) {
234 Ext.Msg.alert(gettext('Error'), error);
235 }
236 me.reload();
237 },
238
239 cartridgeMemory: function(view, rI, cI, button, el, record) {
240 let me = this;
241 let drive = record.data.name;
242 PBS.Utils.driveCommand(drive, 'cartridge-memory', {
243 waitMsgTarget: me.getView(),
244 success: PBS.Utils.showCartridgeMemoryWindow,
245 });
246 },
247
248 cleanDrive: function(button, event, record) {
249 let me = this;
250 PBS.Utils.driveCommand(record.data.name, 'clean', {
251 waitMsgTarget: me.getView(),
252 method: 'PUT',
253 success: function(response) {
254 Ext.create('Proxmox.window.TaskProgress', {
255 upid: response.result.data,
256 taskDone: function() {
257 me.reload();
258 },
259 }).show();
260 },
261 });
262 },
263
264 volumeStatistics: function(view, rI, cI, button, el, record) {
265 let me = this;
266 let drive = record.data.name;
267 PBS.Utils.driveCommand(drive, 'volume-statistics', {
268 waitMsgTarget: me.getView(),
269 success: PBS.Utils.showVolumeStatisticsWindow,
270 });
271 },
272
273 readLabel: function(view, rI, cI, button, el, record) {
274 let me = this;
275 let drive = record.data.name;
276
277 PBS.Utils.driveCommand(drive, 'read-label', {
278 waitMsgTarget: me.getView(),
279 success: PBS.Utils.showMediaLabelWindow,
280 });
281 },
282
283 status: function(view, rI, cI, button, el, record) {
284 let me = this;
285 let drive = record.data.name;
286 PBS.Utils.driveCommand(drive, 'status', {
287 waitMsgTarget: me.getView(),
288 success: PBS.Utils.showDriveStatusWindow,
289 });
290 },
291
292 reloadList: function() {
293 let me = this;
294 me.lookup('changerselector').getStore().load();
295 },
296
297 barcodeLabel: function() {
298 let me = this;
299 let view = me.getView();
300 let changer = view.changer;
301 if (changer === '') {
302 return;
303 }
304
305 let singleDrive = me.drives.length === 1 ? me.drives[0] : undefined;
306
307 Ext.create('Proxmox.window.Edit', {
308 title: gettext('Barcode Label'),
309 showTaskViewer: true,
310 method: 'POST',
311 url: '/api2/extjs/tape/drive',
312 submitUrl: function(url, values) {
313 let drive = values.drive;
314 delete values.drive;
315 return `${url}/${encodeURIComponent(drive)}/barcode-label-media`;
316 },
317
318 items: [
319 {
320 xtype: singleDrive === undefined ? 'pbsDriveSelector' : 'displayfield',
321 fieldLabel: gettext('Drive'),
322 submitValue: true,
323 name: 'drive',
324 value: singleDrive,
325 changer: changer,
326 },
327 {
328 xtype: 'pbsMediaPoolSelector',
329 fieldLabel: gettext('Pool'),
330 name: 'pool',
331 skipEmptyText: true,
332 allowBlank: true,
333 },
334 ],
335 }).show();
336 },
337
338 inventory: function() {
339 let me = this;
340 let view = me.getView();
341 let changer = view.changer;
342 if (changer === '') {
343 return;
344 }
345
346 let singleDrive = me.drives.length === 1 ? me.drives[0] : undefined;
347
348 if (singleDrive !== undefined) {
349 Proxmox.Utils.API2Request({
350 method: 'PUT',
351 url: `/api2/extjs/tape/drive/${singleDrive}/inventory`,
352 success: function(response, opt) {
353 Ext.create('Proxmox.window.TaskViewer', {
354 upid: response.result.data,
355 taskDone: function(success) {
356 me.reload();
357 },
358 }).show();
359 },
360 failure: function(response, opt) {
361 Ext.Msg.alert(gettext('Error'), response.htmlStatus);
362 },
363 });
364 } else {
365 Ext.create('Proxmox.window.Edit', {
366 title: gettext('Inventory'),
367 showTaskViewer: true,
368 method: 'PUT',
369 url: '/api2/extjs/tape/drive',
370 submitUrl: function(url, values) {
371 let drive = values.drive;
372 delete values.drive;
373 return `${url}/${encodeURIComponent(drive)}/inventory`;
374 },
375
376 items: [
377 {
378 xtype: 'pbsDriveSelector',
379 fieldLabel: gettext('Drive'),
380 name: 'drive',
381 changer: changer,
382 },
383 ],
384 }).show();
385 }
386 },
387
388 scheduleReload: function(time) {
389 let me = this;
390 if (me.reloadTimeout === undefined) {
391 me.reloadTimeout = setTimeout(function() {
392 me.reload();
393 }, time);
394 }
395 },
396
397 cancelReload: function() {
398 let me = this;
399 if (me.reloadTimeout !== undefined) {
400 clearTimeout(me.reloadTimeout);
401 me.reloadTimeout = undefined;
402 }
403 },
404
405 reload: function() {
406 let me = this;
407 me.cancelReload();
408 me.reload_full(true);
409 },
410
411 reload_no_cache: function() {
412 let me = this;
413 if (me.reloadTimeout !== undefined) {
414 clearTimeout(me.reloadTimeout);
415 me.reloadTimeout = undefined;
416 }
417 me.reload_full(false);
418 },
419
420 drives: [],
421
422 updateDrives: function(drives) {
423 let me = this;
424 me.drives = drives;
425 },
426
427 free_slots: [],
428 free_ie_slots: [],
429
430 updateFreeSlots: function(free_slots, free_ie_slots) {
431 let me = this;
432 me.free_slots = free_slots;
433 me.free_ie_slots = free_ie_slots;
434 },
435
436 reload_full: async function(use_cache) {
437 let me = this;
438 let view = me.getView();
439 let changer = view.changer;
440 if (changer === '') {
441 return;
442 }
443
444 try {
445 if (!use_cache) {
446 Proxmox.Utils.setErrorMask(view, true);
447 Proxmox.Utils.setErrorMask(me.lookup('content'));
448 }
449 let status_fut = PBS.Async.api2({
450 timeout: 5*60*1000,
451 method: 'GET',
452 url: `/api2/extjs/tape/changer/${encodeURIComponent(changer)}/status`,
453 params: {
454 cache: use_cache,
455 },
456 });
457 let drives_fut = PBS.Async.api2({
458 timeout: 5*60*1000,
459 url: `/api2/extjs/tape/drive?changer=${encodeURIComponent(changer)}`,
460 });
461
462 let tapes_fut = PBS.Async.api2({
463 timeout: 5*60*1000,
464 url: '/api2/extjs/tape/media/list',
465 method: 'GET',
466 params: {
467 "update-status": false,
468 },
469 });
470
471 let [status, drives, tapes_list] = await Promise.all([status_fut, drives_fut, tapes_fut]);
472
473 let data = {
474 slot: [],
475 'import-export': [],
476 drive: [],
477 };
478
479 let tapes = {};
480
481 for (const tape of tapes_list.result.data) {
482 tapes[tape['label-text']] = {
483 labeled: true,
484 pool: tape.pool,
485 status: tape.expired ? 'expired' : tape.status,
486 };
487 }
488
489 let drive_entries = {};
490
491 for (const entry of drives.result.data) {
492 drive_entries[entry['changer-drivenum'] || 0] = entry;
493 }
494
495 let free_slots = [];
496 let free_ie_slots = [];
497
498 let valid_drives = [];
499
500 for (let entry of status.result.data) {
501 let type = entry['entry-kind'];
502 let id = entry['entry-id'];
503
504 if (type === 'drive' && drive_entries[id] !== undefined) {
505 entry = Ext.applyIf(entry, drive_entries[id]);
506 valid_drives.push(drive_entries[id].name);
507 }
508
509 if (tapes[entry['label-text']] !== undefined) {
510 entry['is-labeled'] = true;
511 entry.pool = tapes[entry['label-text']].pool;
512 entry.status = tapes[entry['label-text']].status;
513 } else {
514 entry['is-labeled'] = false;
515 }
516
517 if (!entry['label-text'] && type !== 'drive') {
518 if (type === 'slot') {
519 free_slots.push({
520 id,
521 type,
522 });
523 } else {
524 free_ie_slots.push({
525 id,
526 type,
527 });
528 }
529 }
530 data[type].push(entry);
531 }
532
533 // the stores are diffstores and are only refreshed
534 // on a 'load' event, which does not trigger on 'setData'
535 // so we have to fire them ourselves
536
537 me.lookup('slots').getStore().rstore.setData(data.slot);
538 me.lookup('slots').getStore().rstore.fireEvent('load', me, [], true);
539
540 me.lookup('import_export').getStore().rstore.setData(data['import-export']);
541 me.lookup('import_export').getStore().rstore.fireEvent('load', me, [], true);
542
543 me.lookup('drives').getStore().rstore.setData(data.drive);
544 me.lookup('drives').getStore().rstore.fireEvent('load', me, [], true);
545
546 // manually fire selectionchange to update button status
547 me.lookup('drives').getSelectionModel().fireEvent('selectionchange', me);
548
549 me.updateFreeSlots(free_slots, free_ie_slots);
550 me.updateDrives(valid_drives);
551
552 if (!use_cache) {
553 Proxmox.Utils.setErrorMask(view);
554 }
555 Proxmox.Utils.setErrorMask(me.lookup('content'));
556 } catch (err) {
557 if (!view || view.isDestroyed) {
558 return;
559 }
560
561 if (!use_cache) {
562 Proxmox.Utils.setErrorMask(view);
563 }
564 Proxmox.Utils.setErrorMask(me.lookup('content'), err.toString());
565 }
566
567 me.scheduleReload(5000);
568 },
569
570 renderIsLabeled: function(value, mD, record) {
571 if (!record.data['label-text']) {
572 return "";
573 }
574
575 if (record.data['label-text'].startsWith("CLN")) {
576 return "";
577 }
578
579 if (!value) {
580 return gettext('Not Labeled');
581 }
582
583 let status = record.data.status;
584 if (record.data.pool) {
585 return `${status} (${record.data.pool})`;
586 }
587 return status;
588 },
589
590 control: {
591 'grid[reference=drives]': {
592 cellclick: function(table, td, ci, rec, tr, ri, e) {
593 if (e.position.column.dataIndex !== 'state') {
594 return;
595 }
596
597 let upid = rec.data.state;
598 if (!upid || !upid.startsWith("UPID")) {
599 return;
600 }
601
602 Ext.create('Proxmox.window.TaskViewer', {
603 autoShow: true,
604 upid,
605 });
606 },
607 },
608 },
609
610 init: function(view) {
611 let me = this;
612 if (!view.changer) {
613 throw "no changer given";
614 }
615
616 view.title = `${gettext("Changer")}: ${view.changer}`;
617 me.reload();
618 },
619 },
620
621 listeners: {
622 deactivate: 'cancelReload',
623 destroy: 'cancelReload',
624 },
625
626 tbar: [
627 {
628 text: gettext('Reload'),
629 xtype: 'proxmoxButton',
630 handler: 'reload_no_cache',
631 selModel: false,
632 },
633 '-',
634 {
635 text: gettext('Barcode Label'),
636 xtype: 'proxmoxButton',
637 handler: 'barcodeLabel',
638 iconCls: 'fa fa-barcode',
639 },
640 {
641 text: gettext('Inventory'),
642 xtype: 'proxmoxButton',
643 handler: 'inventory',
644 iconCls: 'fa fa-book',
645 },
646 ],
647
648 layout: 'auto',
649 bodyPadding: 5,
650 scrollable: true,
651
652 items: [
653 {
654 xtype: 'container',
655 reference: 'content',
656 layout: {
657 type: 'hbox',
658 aling: 'stretch',
659 },
660 items: [
661 {
662 xtype: 'grid',
663 reference: 'slots',
664 title: gettext('Slots'),
665 padding: 5,
666 flex: 1,
667 store: {
668 type: 'diff',
669 rstore: {
670 type: 'store',
671 model: 'pbs-slot-model',
672 },
673 data: [],
674 },
675 columns: [
676 {
677 text: gettext('ID'),
678 dataIndex: 'entry-id',
679 width: 50,
680 },
681 {
682 text: gettext("Content"),
683 dataIndex: 'label-text',
684 flex: 1,
685 renderer: (value) => value || '',
686 },
687 {
688 text: gettext('Inventory'),
689 dataIndex: 'is-labeled',
690 renderer: 'renderIsLabeled',
691 flex: 1,
692 },
693 {
694 text: gettext('Actions'),
695 xtype: 'actioncolumn',
696 width: 100,
697 items: [
698 {
699 iconCls: 'fa fa-rotate-90 fa-exchange',
700 handler: 'slotTransfer',
701 tooltip: gettext('Transfer'),
702 isDisabled: (v, r, c, i, rec) => !rec.data['label-text'],
703 },
704 {
705 iconCls: 'fa fa-trash-o',
706 handler: 'erase',
707 tooltip: gettext('Erase'),
708 isDisabled: (v, r, c, i, rec) => !rec.data['label-text'],
709 },
710 {
711 iconCls: 'fa fa-rotate-90 fa-upload',
712 handler: 'load',
713 tooltip: gettext('Load'),
714 isDisabled: (v, r, c, i, rec) => !rec.data['label-text'],
715 },
716 ],
717 },
718 ],
719 },
720 {
721 xtype: 'container',
722 flex: 2,
723 defaults: {
724 padding: 5,
725 },
726 items: [
727 {
728 xtype: 'grid',
729 reference: 'drives',
730 title: gettext('Drives'),
731 store: {
732 type: 'diff',
733 rstore: {
734 type: 'store',
735 model: 'pbs-slot-model',
736 },
737 data: [],
738 },
739 tbar: [
740 {
741 text: gettext('Label Media'),
742 xtype: 'proxmoxButton',
743 handler: 'labelMedia',
744 iconCls: 'fa fa-barcode',
745 disabled: true,
746 enableFn: (rec) => rec.data["label-text"] !== undefined,
747 },
748 {
749 text: gettext('Catalog'),
750 xtype: 'proxmoxButton',
751 handler: 'catalog',
752 iconCls: 'fa fa-book',
753 disabled: true,
754 enableFn: (rec) => rec.data["label-text"] !== undefined,
755 },
756 {
757 text: gettext('Clean Drive'),
758 xtype: 'proxmoxButton',
759 handler: 'cleanDrive',
760 iconCls: 'fa fa-shower',
761 disabled: true,
762 },
763 ],
764 columns: [
765 {
766 text: gettext('ID'),
767 dataIndex: 'entry-id',
768 hidden: true,
769 width: 50,
770 },
771 {
772 text: gettext("Content"),
773 dataIndex: 'label-text',
774 flex: 1,
775 renderer: (value) => value || '',
776 },
777 {
778 text: gettext('Inventory'),
779 dataIndex: 'is-labeled',
780 renderer: 'renderIsLabeled',
781 flex: 1.5,
782 },
783 {
784 text: gettext("Name"),
785 sortable: true,
786 dataIndex: 'name',
787 flex: 1,
788 renderer: Ext.htmlEncode,
789 },
790 {
791 text: gettext('State'),
792 dataIndex: 'state',
793 flex: 3,
794 renderer: PBS.Utils.renderDriveState,
795 },
796 {
797 text: gettext("Vendor"),
798 sortable: true,
799 dataIndex: 'vendor',
800 hidden: true,
801 flex: 1,
802 renderer: Ext.htmlEncode,
803 },
804 {
805 text: gettext("Model"),
806 sortable: true,
807 dataIndex: 'model',
808 hidden: true,
809 flex: 1,
810 renderer: Ext.htmlEncode,
811 },
812 {
813 text: gettext("Serial"),
814 sortable: true,
815 dataIndex: 'serial',
816 hidden: true,
817 flex: 1,
818 renderer: Ext.htmlEncode,
819 },
820 {
821 xtype: 'actioncolumn',
822 text: gettext('Actions'),
823 width: 140,
824 items: [
825 {
826 iconCls: 'fa fa-rotate-270 fa-upload',
827 handler: 'unload',
828 tooltip: gettext('Unload'),
829 isDisabled: (v, r, c, i, rec) => !rec.data['label-text'] || rec.data['is-blocked'],
830 },
831 {
832 iconCls: 'fa fa-hdd-o',
833 handler: 'cartridgeMemory',
834 tooltip: gettext('Cartridge Memory'),
835 isDisabled: (v, r, c, i, rec) => !rec.data['label-text'] || rec.data['is-blocked'],
836 },
837 {
838 iconCls: 'fa fa-line-chart',
839 handler: 'volumeStatistics',
840 tooltip: gettext('Volume Statistics'),
841 isDisabled: (v, r, c, i, rec) => !rec.data['label-text'] || rec.data['is-blocked'],
842 },
843 {
844 iconCls: 'fa fa-tag',
845 handler: 'readLabel',
846 tooltip: gettext('Read Label'),
847 isDisabled: (v, r, c, i, rec) => !rec.data['label-text'] || rec.data['is-blocked'],
848 },
849 {
850 iconCls: 'fa fa-info-circle',
851 tooltip: gettext('Status'),
852 handler: 'status',
853 isDisabled: (v, r, c, i, rec) => rec.data['is-blocked'],
854 },
855 ],
856 },
857 ],
858 },
859 {
860 xtype: 'grid',
861 reference: 'import_export',
862 store: {
863 type: 'diff',
864 rstore: {
865 type: 'store',
866 model: 'pbs-slot-model',
867 },
868 data: [],
869 },
870 title: gettext('Import-Export Slots'),
871 columns: [
872 {
873 text: gettext('ID'),
874 dataIndex: 'entry-id',
875 width: 50,
876 },
877 {
878 text: gettext("Content"),
879 dataIndex: 'label-text',
880 renderer: (value) => value || '',
881 flex: 1,
882 },
883 {
884 text: gettext('Inventory'),
885 dataIndex: 'is-labeled',
886 renderer: 'renderIsLabeled',
887 flex: 1,
888 },
889 {
890 text: gettext('Actions'),
891 xtype: 'actioncolumn',
892 items: [
893 {
894 iconCls: 'fa fa-rotate-270 fa-upload',
895 handler: 'importTape',
896 tooltip: gettext('Import'),
897 isDisabled: (v, r, c, i, rec) => !rec.data['label-text'],
898 },
899 ],
900 width: 80,
901 },
902 ],
903 },
904 ],
905 },
906 ],
907 },
908 ],
909 });